diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..28f54bb --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,10 @@ +{ + "name": "Python 3.12 Development Container For DEEP", + "image": "python:3.12", + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance" + ], + "postCreateCommand": "pip install -e . && deepfacility ux", + "forwardPorts": [8000] +} diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml new file mode 100644 index 0000000..6cbb500 --- /dev/null +++ b/.github/workflows/unittests.yml @@ -0,0 +1,29 @@ +name: tests + +on: + pull_request: + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + run-tests: + runs-on: ubuntu-latest + strategy: + max-parallel: 1 + matrix: + python-version: [ '3.12' ] + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install packages + run: | + pip install --upgrade pip + pip install --upgrade build + pip install -e .[test] + pip install -e .[i18n] + pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 + - name: Run unit-tests + run: pytest -v -m "not network_dependent" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b80c62b --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + + +# Sphinx documentation +docs/_build/ + +# pyenv +.python-version + +# Environments +.env/ +.venv/ + +*.egg-info + +app-data/ +.idea/ +*venv*/ +build/ +from-research +config.toml +*.zip diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c05c55e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Bill & Melinda Gates Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0dd7e27 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# Digitally Enabled Equitably Placed (DEEP) Facility +Tools for optimizing placement of health workers and services based on village locations. + +## Prerequisites +- [Python](https://www.python.org/downloads) 3.12 or higher. +- [Python virtual environment](https://realpython.com/python-virtual-environments-a-primer/) + +## Setup +This section describes how to set up the tool and the demo web app. + +1. Activate your Python virtual environment and upgrade pip and build tools. +```bash +# Update pip and build package. +pip install --upgrade pip +pip install --upgrade build +pip install --upgrade setuptools +``` + +2. [Clone this repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) or [download](https://github.com/InstituteforDiseaseModeling/deepfacility/archive/refs/heads/main.zip) and extract the source code. Then open a terminal and navigate to the root directory of the source code. +```bash +# Navigate to the root directory. +cd deepfacility +``` + +3. Install the tool: +```bash +# Install the tool and required packages. +pip install -e . +``` + +## Getting Started + +Start the demo web app: +```bash +# Start the demo web app using `ux` command. +deepfacility ux +```` + +After you see the message `Serving on ...` the **demo** web app is running. + +Follow these steps to experience the workflow end-to-end: +- Open your web browser and navigate to [http://localhost:8000](http://localhost:8000) to access the **demo** web app. +- Follow instructions to "upload" and configure village locations .csv file. + - _Note that this app is running on your local machine and all files are stored locally._ +- Click `Prepare Data` to download and prepare input data: households and commune shapes. +- Select locations (communes) and click `Run Clustering` to start the processing. +- Visualize and explore village shapes and health facilities recommendations on a map. +- Obtain the results file. + +_Notes_: +- _This web app is only for **demo** purposes and is **not** intended for production use._ +- _To start a fresh copy of the app, without cached data, run `deepfacility reset` command before starting the app._ + +# Terminology +In this repository, we use the following terms and abbreviations: + +- `Cluster` and `Village` refer to the same entity with small differences in context: + - Cluster: a group of households. + - Village: spatial interpretation of a cluster. +- `Location`: an administrative area where the clustering is performed. + - In this tool a location value is a colon-separated list of names of administrative levels, per [configuration](docs/design.md#locations). + - For example, in Burkina Faso: + - `Tapoa:Diapaga` represents the `Diapaga` commune from the `Tapoa` province. + - `Tapoa:Diapaga:Mangou` represents a village from the `Diapaga` commune. + +- Abbreviations: + - Health Facilities (HF) + - Empirical Cumulative Distribution Function (eCDF) + +# Multi-Language Support +By default, the demo web app supports French and English languages. To add a support for additional languages see [Add New Language](docs/design.md#adding-new-languages) section in the design document. + +## Documentation +- [Components](docs/components.md) +- [Design](docs/design.md) +- [Scientific Workflow](docs/workflow.md) +- [CLI commands](docs/commands.md) + +## Data Sources +The system is using the following external data sources: +- Open Buildings (Google) + W. Sirko, S. Kashubin, M. Ritter, A. Annkah, Y.S.E. Bouchareb, Y. Dauphin, D. Keysers, M. Neumann, M. Cisse, J.A. Quinn. Continental-scale building detection from high resolution satellite imagery. arXiv:2107.12283, 2021. + +- [GADM shapes](https://gadm.org/data.html) for countries administrative areas + +## Package Dependencies +Packages listed in +[pyproject.toml](pyproject.toml) + +## Disclaimer +The code in this repository was developed by IDM to support our research into healthcare system capacity. We’ve made it publicly available under the MIT License to provide others with a better understanding of our research and an opportunity to build upon it for their own work. We make no representations that the code works as intended or that we will provide support, address issues that are found, or accept pull requests. You are welcome to create your own fork and modify the code to suit your own modeling needs as contemplated under the MIT License. + diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..417450b --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,79 @@ +# Command Line Tool Commands + +## Setup +Follow the [setup instruction](../README.md#setup) from the README. + +## Configuration +Confirm the tool has been installed and observe the help content: +```bash +# Check the tool has been installed and see the usage help. +deepfacility + +> usage: deepfacility run [-h] [-l LOCATION_FILTER [LOCATION_FILTER ...]] [-c CONFIG_FILE] [-n RUN_NAME] [-r RESULT_DIR] [--sid SESSION_ID] + {countries,config,prep,locations,run,viewmap,ux,reset} +``` + +See the list of supported countries: +```bash +deepfacility countries + +> INFO: Supported countries (you can set in config): +> ...list of countries... +``` + +Create a config file: +```bash +# Generate a config file at the default path: app-data/config.toml +deepfacility config +``` + +Update the config file to set paths and column names for your files: +- `[args.village_centers]` section for the village centers file. +- `[args.baseline_facilities]` section for the baseline facilities file (this is optional). + +## Data Preparation + +Prepare scientific workflow input files: +```bash +# Prepare input files +deepfacility prep +``` +The above command will: +- download and preprocess Google buildings and GADM shapes +- standardize your village centers and baseline facilities files + +## Scientific Workflow + +See the list of all available locations execute the `locations` command: +```bash +# Read all locations available in the input data. +deepfacility locations +``` +_Note: The filter can be a specific location or a location [regex](https://docs.python.org/3/howto/regex.html#regex-howto) pattern._ + +Process specified subset of locations. +```bash +# Run the processing for a subset of locations. +deepfacility run -l "Noumbiel:.*" # All locations in the Noumbiel province +``` + +To process all locations execute the `run` command without a location filter (this may take 1-2h). + +## Visualizing Results +Generate the interactive visualization map by specifying the result directory: +```bash +# Create viz map for results generated with -l `Noumbiel:.*` +deepfacility viewmap -r Noumbiel-_1_5a819a9 +```` +_Note: Look at the `app-data/data/BFA/results` directory for the list of available results directories._ + +## Testing +Install the test dependencies: +```bash +# Install test dependencies. +pip install -e .[test] +``` +Run available tests: +```bash +pytest -v +``` diff --git a/docs/components.md b/docs/components.md new file mode 100644 index 0000000..8c414ef --- /dev/null +++ b/docs/components.md @@ -0,0 +1,235 @@ +# Solution Components +This document provides a high-level overview of solution components. + +## Table of Contents +* [Code Structure](#code-structure) +* [Components Diagram](#components-diagram) +* [Workflows](#workflows) +* [User Experience (UX)](#user-interface-ux) +* [Command Line Tool (CLI)](#command-line-tool) + + +## Code Structure + +The code structure is organized into modules and packages. The main modules are: +- `deepfacility.flows`: Workflow classes which methods drive data preparation and scientific workflows. +- `deepfacility.data`: data preparation workflow steps. +- `deepfacility.tasks`: scientific workflow functionality. +- `deepfacility.viz`: visualization and interactive map features. +- `deepfacility.config`: configuration classes. +- `deepfacility.utils`: utility functions. +- `deepfacility.lang`: language translation features. +- `deepfacility.ux`: demo web UI app. + +## Components Diagram + +```mermaid +%%{ + init: { + 'theme': 'base', + 'themeVariables': { + 'primaryColor': '#eeffcfff', + 'primaryTextColor': 'black', + 'primaryBorderColor': 'black', + 'lineColor': '#789abc', + 'secondaryColor': '#006100', + 'tertiaryColor': '#ffffff', + 'tertiaryBorderColor': 'lightgray' + } + } +}%% +graph TD; + subgraph Workflows + external_data((External Data + Google Open buildings + GADM country admin shapes)) --> + data_prep(Data Preparation + Download external data + Unify user data per config + Match user data to admin names + Add metadata to support viz); + user_data((User Data + Village centers + Baseline health facilities)) --> + data_prep --> + ready_inputs((Inputs + Admin shapes and locations + Building coordinates + Village center coordinates + Baseline facilities coordinates)) --> + scientific_workflow(Scientific Workflow + Cluster households into villages + Create village shapes + Calculate facility/household distances + Recommend health facility coordinates) --> + result_files((Results + Locations + Clustered households + Village shapes + Placement recommendations + Population coverage plots + Logs)); + data_prep <--> + data_cache((Data Cache + Function calls caching + using joblib.Memory objects + and a local cache directory.)); + scientific_workflow <--> data_cache; + config((Configuration + User provided files + Workflow params and file paths)) --> data_prep; + config --> scientific_workflow; + end + subgraph "App(UX)" + html_templates(HTML Templates + UI declared using HTMX) --> + fast_api(FastAPI Backend + Handles web requests to render HTML templates into web UI. + Receives user inputs and files to configure and run workflows.); + css(CSS + HTML style definitions.) --> fast_api; + + fast_api --> + background_tasks(Background Tasks + Run workflows in background. + Progress is reported in UI via logs.); + background_tasks --> data_prep; + background_tasks --> scientific_workflow; + + fast_api <--> + rendered_ui(Web UI + Users interact with backend to configure + and run workflows and visualize results.); + + fast_api <--> session(Session + Functionality to configure and run workflows. + Preserving user session state between requests, + like configuration and language selection.); + session <--> config; + + fast_api <--> multi_lang_support(Multi-Language Support + Functionality for translating UI and backend messages. + Either a text dictionary or a ML language model.); + + rendered_ui --> viz_map(Visualization Map + Leaflet UI overlaying workflow results on an interactive map.); + result_files --> fast_api; + end +``` +Notes: +- The diagram uses terms and abbreviations describes in the [Terminology](../README.md#terminology) section in the main README. +- For simplicity, the CLI part of the components is not included in the diagram. + +--- +## Workflows +The core library is responsible for implementing the data and scientific workflows and provide supporting functionality like configuration, multi-language support, data caching. + +The processing workflows are driven from the [flows.py](../src/deepfacility/flows.py) module which is a great starting point for following the code flow: +- `prepare_inputs` - this function drives the data preparation workflow +- `process_locations` - this function drives the scientific workflow + +These two functions are driving two main workflows: +- [Data preparation](#data-preparation-workflow) workflow's code is in the [data](../src/deepfacility/data) directory: + - [download.py](../src/deepfacility/data/downloads.py) - functions for downloading external data + - [inputs.py](../src/deepfacility/data/inputs.py) - functions for preparing input files + +- [Scientific workflow](#scientific-workflow) workflow's code is in the [tasks](../src/deepfacility/tasks) directory: + - [clustering.py](../src/deepfacility/tasks/clustering.py) - functions for clustering households + - [distance.py](../src/deepfacility/tasks/distance.py) - functions for calculating distances + - [outlines.py](../src/deepfacility/tasks/outlines.py) - functions for creating village shapes + - [placement.py](../src/deepfacility/tasks/placement.py) - functions for generating recommendations for health facilities locations + +### Data Preparation Workflow +The data preparation workflow is responsible for preparing input data for the scientific workflow, which includes the following steps: +- Download Google Open Buildings for the specified country using [config/countries_s2_tokens.json](../src/deepfacility/config/countries_s2_tokens.json) file to determine the list of s2 geometry cells to download. +- Download [GADM](https://gadm.org/download_country.html) shapes for the specified country. +- Clip Google Open Buildings to GADM country shapes and produce a households file containing admin names (from GADM shapes) and building coordinates. +- Standardize user provided village centers file by associating admin names from GADM shapes through a spatial join between center coordinates and admin shapes. +- Optionally, standardize user provided baseline health facilities file (in the same way as village centers) add enrich it with metadata to support visualizations. + +In summary, the data preparation workflow ensures the scientific workflow receives standardized input files, +consistent with the system config and GADM admin names. + +### Scientific Workflow +The processing workflow receives the input files produced by the data preparation workflow and executes +the scientific workflow to produce the recommended health facility coordinates and population coverage metrics. + +Here is a high-level overview of the scientific workflow code structure: +- [flow.py](../src/deepfacility/flows.py) - contains top level workflow functions. + - [tasks/clustering.py](../src/deepfacility/tasks/clustering.py) - contains functions for clustering households. + - [tasks/outlines.py](../src/deepfacility/tasks/outlines.py) - contains functions for creating village shapes and merging results. + - [tasks/placement.py](../src/deepfacility/tasks/placement.py) - contains functions for recommending health facility placements. + +--- + +## User Interface (UX) + +The UX implemented in the `deepfacility` package was designed for demo purposes. +It provides a web-based interface for users to configure the tool, run the data preparation and scientific workflows and +generate and explore visualizations. + +### UX Tech Stack + +The technology stack used in this UX solution for the main web app scenarios includes [HTMX](https://htmx.org/) and [FastAPI](https://fastapi.tiangolo.com/) Python packages. + +The [HTMX](https://htmx.org/) package is used to provide a seamless user experience by updating parts of the page without a full page reload. +This solution also removes JavaScript from the front-end HTML templates which simplifies the solution development. +Still, under-the-hood when [HTMX templates](../src/deepfacility/ux/templates) are rendered they still contain JavaScript. + +On the backend side, [FastAPI](https://fastapi.tiangolo.com/) is used to provide a REST API for the web app in the [ux/main](../src/deepfacility/ux/main.py) module. + +The [session](../src/deepfacility/ux/session.py) module implements the session object which is: +- Instantiated when the first request is processed. +- Stored in the app state and is available to all requests. +- Has a reference to a config which is used throughout the app for constructing file paths. + +### Configuration UI +The app configuration allows users to upload the village centers and baseline health facilities (optional) files and +select which columns contain coordinates and which represent village names. + +The backend then uses geopandas and pycountry packages to determine the country (and country code) based on the coordinates of the village centers. + +After this, a config file is created in the session dir and its reference is stored in the session object. + +Finally, the user is shown configuration parameters and a preview of the content of uploaded files. +If all looks good, the user can proceed and kick off the data preparation workflow. + +In case the app is stopped and the session object needs to be recreated, the app will look in the session directory for +the latest saved config file and use it to recreate the session object. + +### Data Preparation UI +After the `Prepare Input Files` button is clicked, the data preparation workflow is started as a background process. +UI uses the approach described in the [Monitoring Progress UI](design.md#monitoring-progress-ui) section to shows the progress in the form of +stage completion and background task logs. + +The artifacts of the data preparation workflow are stored in the `inputs` dir and they include: +- locations csv file, containing a list of available admin locations +- admin shapes geojson file, containing specified admin level shapes +- households csv file, containing country building coordinates and admin names +- village centers csv file, containing standardized village centers data +- baseline facilities csv file, containing standardized baseline facilities data + +### Scientific Workflow UI +After input files are ready a user can use the scientific workflow UI to select locations and run the scientific workflow. +The scientific workflow is started as a background process and the progress is reported in the UI. +This scientific workflow UI and backend work in the similar way as the data preparation UI and backend. + +### Results Visualization UI +After the scientific workflow is completed, the visualization map is loaded automatically. +The web visualization component is providing an interactive map of health facilities and villages, +is implemented in html/javascript solution using the [Leaflet](https://leafletjs.com/) javascript library. +All the result files used for visualization are also available for download. +The UI also offers ability to select and see visualizations of previous workflow runs. + +--- +## Command Line Tool +The command line interface (CLI) supports a set of commands which allow users to: +- see available countries and locations +- configure the tool +- run the data preparation workflow +- run the scientific workflow +- generate web visualization map +- run the UX web app + +In other words, CLI support all the functionality offers via UX. + diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..8dd09fd --- /dev/null +++ b/docs/design.md @@ -0,0 +1,351 @@ +# Design Concepts +This document describes main design concepts and implementation details of the `deepfacility` tool. + +## Table of Contents +* [Environment Variables](#environment-variables) +* [Configuration](#configuration) +* [Location Format](#location-format) +* [UX Sessions](#ux-sessions) +* [UX Background Tasks](#ux-background-tasks) +* [Multi-Language Support](#multi-language-support) +* [Logging](#logging) +* [Parallelization](#parallelization) +* [Data Caching](#data-caching) +* [Other Considerations and Limitations](#other-considerations-and-limitations) + +## Environment Variables +The following environment variables are used in the tool: + +| Environment Variable | Default | Description | +| ---|-------------|---| +| DEEPFACILITY_ROOT_DIR | `app-data` | The root directory of the app. | +| DEEPFACILITY_LANG_MODEL | `NLP` | Set the translation ML model to use (default isHelsinki-NLP/opus-mt-en-fr). | +| DEEPFACILITY_HOST | `localhost` | Demo web app host name. | +| DEEPFACILITY_PORT | `8000` | Demo web app port. | +| DEEPFACILITY_SID | `None` | Set the session id for the CLI scenario. | + + +## Configuration +The configuration consists of user and system configuration TOML files. + +### Configuration Templates +The user configuration is focused on files a user needs to provide and describe required column names. +The system configuration describe workflow input and results files. + +### Configuration Template Variables +During the initiation, the variables are replaced with values collected from users or determined based on user inputs: +- `{app_dir}`: App root directory containing the cache, data, and downloads directories and the config file. +The default is `app-data`, positioned relative to the working directory, for example, the repo dir. +It can be customized using the `DEEPFACILITY_ROOT_DIR` environment variable. +- `{data_dir}`: Data directory containing country directories with input and results files. +- `{country_code}`: Country code determined based on the village centers coordinates. +- `{level}`: Admin level determined based on the village centers coordinates. + +The following variables are replaced with values determined at runtime: +- `{run_name}`: A unique run name based on the selected locations. +- `{location}`: The location iterator value. + +### Configuration Initiation +The configuration initiation is done in the `deepfacility.config` module. The `Config` class is used to load, populate and merge the user and system configuration files. +In the UX scenario, users are not directly exposed to configuration files. Instead, the session object, from the `deepfacility.ux` module, generates the configuration based of user inputs and uploaded files. +In the CLI scenario, the user can create a configuration file using the `config` command. Then the user can populate the configuration file to match files and columns in the data. + +### Accessing Configuration +Methods of WorkflowEntity subclasses have access to the config through the `cfg` field. Other function receive the configuration object as an argument. + +### Configuration Usage +The configuration contains most of the information needed for the tool to run. +This means that most functions could receive the configuration object as the only argument. +However, this approach would make the code less modular and harder to test. + +Therefore, the general principle is for functions to have input values as explicit arguments and use parameters and +file paths from the configuration object for generate outputs. This allows the code to be tested independently of the configuration object. + +### Configuration File Structure +The configuration file is a TOML file which contains sections for user and system configurations. +The configuration file contains the following sections: +- `args`: basic parameters like country, data directory, thresholds and file path and column names for user-provided files. +- `downloads`: contains URLs for downloading external data, directories for storing downloaded files and coordinates column names. +- `inputs`: contains file paths and column names for input files generated by the data preparation workflow. +- `results`: contains file paths and column names for output files generated by the scientific workflow. + +For more detail see configuration templates which contain detailed descriptions of all configuration parameters: +- [user config template](../src/deepfacility/config/template_user.toml) +- [system config template](../src/deepfacility/config/template_sys.toml). + +### Directory Structure +The configuration file facilitates the directory structure of the app. + +Typical structure of the root `app-data` dir used by the web app looks like this: +```bash +app-data +│ +├──── downloads # all downloads for all countries +│ ├── GADM_shapes # all GADM shapes for all countries +│ │ └── gadm41_BFA_shp.zip +│ └── google_buildings # all Google Open Buildings for all countries +│ ├── 0e3_buildings.csv.gz +│ ├── 0e5_buildings.csv.gz +│ ├── 0fb_buildings.csv.gz +│ ├── 0fd_buildings.csv.gz +│ └── 11d_buildings.csv.gz +│ +├── 9593161cb53f # session id +│ ├── config.village_centers.toml # generated user and system configuration file +│ └── data # all data for all countries│ +│ └── BFA # all data for a specific country +│ ├── args # args: user provided data +│ │ ├── health_facilities.csv +│ │ └── locality_villages.csv +│ │ +│ ├── inputs # inputs: data generated by the data preparation workflow +│ │ ├── all_locations.csv # all available admin locations +│ │ ├── baseline_facilities.csv # baseline health facilities with matched admin names +│ │ ├── baseline_facilities.geojson # baseline health facilities points for visualization +│ │ ├── buildings_BFA.feather # Google Open Buildings clipped for BFA +│ │ ├── households.csv # households coordinates with matched admin names +│ │ ├── households.stats.csv # households stats +│ │ ├── prep.log # data preparation workflow log +│ │ ├── shapes # GADM shapes +│ │ ├── village_centers.csv # village centers with matched admin names +│ │ └── village_centers.geojson # village centers points for visualization +│ │ +│ └── results # results: data generated by the scientific workflow +│ └── Bale-Boromo_3_41353ad # results for 3 communes (Bale-Boromo and two other) +│ ├── cluster_centers.csv # cluster centers +│ ├── cluster_counts.csv # cluster counts +│ ├── cluster_stats.csv # cluster stats +│ ├── clustered_households.csv # clustered households +│ ├── locations.csv # locations +│ ├── optimal_facilities.csv # optimal health facilities +│ ├── population_coverage_optimal.png # optimal population coverage plot +│ ├── population_coverage_baseline.png # existing population coverage plot +│ ├── run.log # scientific workflow log +│ ├── village_shapes.geojson # village shapes for visualization +│ └── www # interactive visualization map +│ +└── cache # cache directory managed by the system (joblib.Memory python package) + └── deepfacility # cache directory for the deepfacility package + ├── data # data preparation functions cache + ├── tasks # scientific workflow functions cache + └── utils # utility functions cache +``` + +## Location Format +As described in the main readme a location is an administrative area where the clustering is performed. +Location values are constructed from colon-separated names of administrative levels. + +The default configuration for Burkina Faso specifies the following location formats: + +- Communes: `{province}:{commune}` (e.g., `Tapoa:Diapaga`) + - Data from GADM shapes (columns NAME2, NAME3) +- Villages: `{province}:{commune}:{village}` (e.g., `Tapoa:Diapaga:Mangou`) + - Data from user-provided village centers file (custom column) + +Location columns are specified in the configuration: + - Commune column: [system configuration](../src/deepfacility/config/template_sys.toml) > `[inputs.shapes].adm_cols` + - Village column: [user configuration](../src/deepfacility/config/template_user.toml) > `[args.village_centers].adm_cols` + +## UX Sessions +The main purpose of the session object is to preserve references to a config and translator objects between requests +and to facilitate the execution of the background tasks. + +UX session support is implemented using the `deepfacility.ux.session` module, with `deepfacility.ux.session.init` +being the function which handles most of the session management: creation and retrieval. + +The session object is created when the user starts the web app. At that time a session id is generated and used to store +the reference to a session object in the session dictionary. +The session dictionary is created in the FastAPI state object and preserved until the app is stopped. + +The session id is also stored as long-living cookie in the user's browser. The session id is used to retrieve the session object from the session dictionary when the user makes a new request. + +In case a session id cookie is lost a user would have to start a new session. This would also mean loosing all the previously generated files. +To mitigate that, a user has few options: +- Set the session id in the URL: `http:://localhost:8000?sid=1234` +- Set the session id in the environment variable: `export DEEPFACILITY_SID=1234` before running the `deepfacility ux` command. +```bash +# Set the session id with the environment variable +export DEEPFACILITY_SID=1234 +deepfacility ux +``` + +- Use the CLI scenario where the session id is set as a command line argument. +```bash +# Hardcode the session id for all session +deepfacility ux --sid 1234 +``` + + +## UX Background Tasks +The background tasks are run using FastAPI `BackgroundTasks` object to asynchronously run the data preparation and scientific workflows commands in the background. + +### Execution Control +The background task execution is abstracted using `config.Operation` and `ux.Session` classes. + +The `Operation` class fields are used to control workflow functions execution: +- `conrol_file` - a file used to signal the workflow function which is running in the background tasks to stop. +- `log_file` - point to a log file where workflow function logs are stored (and from where the logs are streamed to UI). +- `logger` - a logger object used to log messages to the log file. + +The `Operation` is an abstract class inherited by `Inputs` and `Results` config classes which are used by data preparation and scientific workflows functions. + +The `ux.Session` class has a private member `_operation` which points to a workflow operation object when a user triggers execution. +It also has `start_task` and `stop_task` methods meant to facilitate the execution of the background tasks. + +### Monitoring Progress UI +This section describes how workflow execution progress is monitored in the UI. + +The steps below are for the data preparation workflow. The scientific workflow monitoring uses the same approach. + +1. User initiates data preparation: + - User clicks the "Prepare Input Data" button on the data prep page [30-prep.html](../src/deepfacility/ux/templates/30-prep.html). + - This triggers the `ux.main.prep` function which: + - Starts data preparation background tasks. + - Renders the status monitoring page [30-prep-status-container.html](../src/deepfacility/ux/templates/30-prep-status-container.html). + +2. Status monitoring: + - The status monitoring page automatically sends `/prep/status` request every 5 seconds. + - This is specified with htmx `hx-trigger` attribute as shown in the example below. + - The `ux.main.prep_status` function on the server: + - Checks the status of input files and backend tasks. + - Renders a status update response based on the progress. + - The response is displayed in the `status` div in the page [30-prep-status.html](../src/deepfacility/ux/templates/30-prep-status.html). + ```html +
+
+
+

Waiting for status...

+
+ ``` +3. Success: + - If all files are ready, the server prompts the browser to refresh + - Page refresh triggers top level htmx div element which send `/info` and `/driver` requests. + - The `/driver` request handler renders [40-run.html](../src/deepfacility/ux/templates/40-run.html), allowing execution of the scientific workflow. + +4. Failure: + - If files are not ready, the `/driver` request handler: + - Deletes the config file. + - Returns a response to clear the `download` div. + - The `/info` request handler renders the upload page again [10-upload.html](../src/deepfacility/ux/templates/10-upload.html) + - This prompts the user to reconfigure input files and do the data prep again. + +## Multi-Language Support +This section describes: +- existing multi-language support design and implementation and how to extend it +- how to enable automated translation to support additional languages or UI text updates. + +### Existing Multi-Language Support + +#### Overview Of Multi-Language Support +The default multi-language support is implemented using a simple [dictionary](../src/deepfacility/lang/messages/fr.json) containing English-French translations for all the text currently used in the tool. +This approach has the advantage of being simple and fast. The main disadvantage is that it requires manual translation +of all the text used in the tool and maintaining translations as the text is updated. + +To address the limitations of the default multi-language support, the tool also supports multi-language support using pre-trained language models. +This approach is more flexible and can be used to translate arbitrary text. In other words, it can be used to support additional languages or to support new text in the tool. + +Both multi-language support approaches are implemented in the `deepfacility.lang` module by inheriting the `BaseTranslator` abstract class defines the translation interface. + +#### Translator Interface +It defines the translation interface which consists of two functions: +- `set_language` - sets the language to be used for translation and do all necessary initializations. +- `translate` - traslates the input string to the current language. + +The `BaseTranslator` abstract class also implements the factory class method `create` which instantiates a translator and set the language. +It takes language and request as argument and if language is not explicitly set it extracts it from the request headers. Then it calls the `set_language` and returns the translator. + +#### DefaultTranslator + +The DefaultTranslator implements the translation interface using a simple dictionary for English-French translations for all the text currently used in the tool. +For the DefaultTranslator, `set_language` method loads the [translation dictionary](../src/deepfacility/lang/messages/fr.json) and sets the language string. +The translation is simple English message lookup using the `messages` dictionary. +The DefaultTranslator is simple and fast and it fully supports the current text used in the tool. + +#### ML Translators +The i18n translators are an experimental feature. +These translators are based on [ML language models](#ml-language-models), and they are capable of successfully translating +arbitrary text, so they can be used in specific situations when tools text is modified or a new language is added. +The downside is that using them requires installing additional dependencies. +When the app starts they need to download the model (NLP is 250MB, NLLB is 2GB). +They inherit the default translator, so they still use the dictionary lookup if a match can be found. +If not they will fall back to the pre-trained language model. +This makes page transitions noticeably longer (for new text not covered by the default message dictionary). + +#### ML Language Models +ML translators are based on pre-trained machine translation models. Some of the models considered for the tool are: +- [Hugging Face](https://huggingface.co/models) provides a range of pre-trained machine translation models accessible via the transformers library. +- [Helsinki-NLP/opus-mt-en-fr (NLP)](https://huggingface.co/Helsinki-NLP/opus-mt-en-fr) can be used for English to French translation. +- [Facebook's NLLB model](https://huggingface.co/facebook/nllb-200-3.3B) supports more than two hundred languages. + +### Adding New Languages +To add a new language to the tool you first need to list that language and local in the [language dictionary](../src/deepfacility/lang/languages.json). + +Then you need to do one of the following: +- Add a message translation file for the new language in the [src/deepfacility/lang/messages](../src/deepfacility/lang/messages) dir, or +- Enable the automated translation, as described in the next section. + +To generate the message translation file you can +- Copy the existing [fr.json](../src/deepfacility/lang/messages/fr.json) file and name it to match the local string listed in the [language dictionary](../src/deepfacility/lang/languages.json). +- Translate messages to the new language manually or using one of the AI assistants. + +### Enable Automated Translation +You can enable automated translation by installing the tool with the `i18n` extra dependencies: +```bash +# Install the tool with gettext multi-language support. +pip install -e .[i18n] +``` +Once `i18n` extra dependencies the tool will use the ML translators by default. + +Note that the `i18n` extra dependencies include the `torch` packages which is required for the ML translators. +For more information about using PyTorch see the [documentation](https://pytorch.org/get-started/locally/). + +## Logging +The logging is implemented using the Python `logging` module. The default level is set to `INFO`. + +Because UX scenario could run multiple user session simultaneously, the code is not using a single global logger. +Instead, separate loggers are instantiated for each workflow execution. This is facilitated by the `Operation` class in +the [deepfacility.config](../src/deepfacility/config/config.py) module. + +All logs are displayed to the console. Workflow logs are also stored in log files, which path matches the workflow, per configuration. +In the UX scenario, background tasks, running workflows are streaming logs from those log files into the UI. + +## Parallelization +The parallelization in this tool is implemented using the `concurrent.futures` module which provides a high-level interface +for asynchronous execution using either ThreadPoolExecutor (on Windows and Mac) or ProcessPoolExecutor (on Linux). + +The scientific workflow functions which implement asynchronous execution are +(both in the [flows.py](../src/deepfacility/flows.py) module): +- cluster_households: submits `cluster_houses_by_villages_centers` function for each location +- outline_and_place: submits `outline_clusters_and_place_facilities` function for each location + +In both cases, `future` is set to have a callback enclosure `process_future` which: +- receives the `future` object after completion. +- retrieves the resulting ClusteredHouseholds object from the future. +- stores it in the result dictionary and +- reports the progress using `util.report_progress` function. + +## Data Caching +Data caching is set up using `joblib.Memory` object which is used to cache the results of the data preparation and scientific workflows functions. +The `joblib.Memory` object is configured to store cached data in a `cache` directory, +in the app root: `app-data/cache` so that the cache can be shared between sessions. + +The criteria for selecting functions for caching is that they are: +- time-consuming. +- called multiple times with same arguments. +- arguments are hashable in a way that uniquely identifies the result. +- return values are pickleable objects which contains data, like a dataframe. + +To locate cached functions in the code search for the `@memory.cache` decorator. + +To invalidate function's cache (e.g., due to a code change), delete the entire cache directory, or a subdir corresponding to that function. +You can also use the `deepfacility reset` command which will remove the cache directory. + + +## Other Considerations and Limitations +The UX provided in the `deepfacility` package was designed for demo purposes and **should ONLY be used on a local machine**. +This demo web application is not ready to be used as a hosted web application and is missing important security and reliability features a hosted web application must have. +That said, the demo web app UI can be a good starting point for developing a fully fledged web application. diff --git a/docs/images/Boudry.png b/docs/images/Boudry.png new file mode 100644 index 0000000..24bdd68 Binary files /dev/null and b/docs/images/Boudry.png differ diff --git a/docs/images/Mogtedo-Boudry.png b/docs/images/Mogtedo-Boudry.png new file mode 100644 index 0000000..64acb69 Binary files /dev/null and b/docs/images/Mogtedo-Boudry.png differ diff --git a/docs/images/Mogtedo.png b/docs/images/Mogtedo.png new file mode 100644 index 0000000..bbfc9ab Binary files /dev/null and b/docs/images/Mogtedo.png differ diff --git a/docs/images/optimal_facilities.png b/docs/images/optimal_facilities.png new file mode 100644 index 0000000..8f4d120 Binary files /dev/null and b/docs/images/optimal_facilities.png differ diff --git a/docs/workflow.md b/docs/workflow.md new file mode 100644 index 0000000..d25c910 --- /dev/null +++ b/docs/workflow.md @@ -0,0 +1,79 @@ +# Scientific Workflow Diagram +The below diagram illustrates the scientific workflow this tool performs. + +_Note: The diagram uses terms and abbreviations describes in the [Terminology](../README.md#terminology) section in the main README._ + +```mermaid +%%{ + init: { + 'theme': 'base', + 'themeVariables': { + 'primaryColor': '#eeffcfff', + 'primaryTextColor': 'black', + 'primaryBorderColor': 'black', + 'lineColor': '#789abc', + 'secondaryColor': '#006100', + 'tertiaryColor': '#ffffff', + 'tertiaryBorderColor': 'lightgray' + } + } +}%% +graph TD + ready_inputs((Inputs + Admin shapes and locations + Building coordinates + Village center coordinates + Baseline facilities coordinates)) --> + + cluster_houses["Cluster Houses + Group households into clusters using KMeans algorithm, + initializing cluster centers with input villages centers. + The result are clusters (villages), each containing + a subset of input households."] --> + + village_shapes["Create Village Shapes + Create village shapes by calculating + convex hull around household points + for each village cluster."] --> + + result_files(("Result Files + Locations file + Clustered Households file + Village Shapes GeoJson file + HF Optimal Placements file + HF Population Coverage Plots + Logs")); + + cluster_houses --> + recommend_locations["Recommend Facility Placement + Performs KMeans clustering of village households + to find a specified number of points (e.g., 3) that are + optimally distant from all village households."] --> + + calculate_distances["Calculate Minkowski Distances + Calculate Minkowski distances between + households and nearest HF for existing (baseline) + and optimal HF placements."] --> + + plot_location_distances["Plot Commune HF Population Coverage + Plot optimal and baseline commune population coverage + using eCDF based on calculated Minkowski distances."] --> + + calculate_distances --> + merge_results["Merge Results + Merge commune result files. + See 'Result Files' for details."] --> result_files; + + merge_results --> plot_merged_results["Plot Overall HF Population Coverage + Support HF placement decision making by + plotting optimal and baseline population coverage using eCDF + based on Minkowski distances calculated on merged data."] --> result_files; +; +``` +`Clustered Households` file contains: + - cluster centers + - cluster-household mapping + - cluster-household counts statistics + - Descriptive statistics of cluster-household counts + - % of small villages (with less than ) + - Total number of villages diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..befacfe --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,69 @@ +[project] +name = "deepfacility" +version = "0.1.0" +description = "Tools for optimizing placement of health facilities based on village locations." +readme = "README.md" +requires-python = ">=3.10" +license = {file = "LICENSE"} +authors = [ + {name = "IDM", email = "idm@gatesfoundation.org" } +] + +dependencies = [ + "geopandas~=0.14.3", + "matplotlib~=3.8.3", + "openlocationcode~=1.0.1", + "pandas~=2.2.1", + "pyarrow~=15.0.1", + "scikit-learn~=1.4.1", + "scipy~=1.12.0", + "tomli~=2.0.1", + "tomli_w~=1.0.0", + "unidecode~=1.3.8", + "fastapi~=0.108.0", + "htmx~=0.0.0", + "requests~=2.32", + "pycountry~=23.12.11", + "uvicorn~=0.22.0", + "joblib~=1.3.2" +] + +[project.optional-dependencies] # extras +test = ["pytest~=8.1.1", "pytest_mock~=3.14.0"] +i18n = ["polib~=1.2.0", "transformers~=4.40.1", "sentencepiece", "torch", "torchvision", "torchaudio"] + +# see readme for more details about installing PyTorch + +[project.urls] +"Homepage" = "https://https://github.com/InstituteforDiseaseModeling/deepfacility" + +[project.scripts] +deepfacility = "deepfacility:main" + +[build-system] +# These are the assumed default build requirements from pip: +# https://pip.pypa.io/en/stable/reference/pip/#pep-517-and-518-support +requires = ["setuptools>=43.0.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] # data files to be installed +package-data = {"deepfacility" = [ + "config/*.toml", + "config/*.json", + "translator/messages/*.json", + "viz/leaflet_template/.html", + "viz/leaflet_template/css/*.css", + "viz/leaflet_template/css/images/*.png", + "viz/leaflet_template/js/*.js", + "viz/leaflet_template/js/*.map", + "viz/leaflet_template/markers/*.png", + "viz/leaflet_template/webfonts/*.*", + "viz/leaflet_template/*.html", + "ux/css/*.css", + "ux/templates/*.html"]} + +# See template: +# https://github.com/pypa/sampleproject/blob/main/pyproject.toml + +# See spec: +# https://peps.python.org/pep-0518/ diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..253d786 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +markers = + unit: Unit tests. \ No newline at end of file diff --git a/scripts/google-buildings/README.md b/scripts/google-buildings/README.md new file mode 100644 index 0000000..33afa66 --- /dev/null +++ b/scripts/google-buildings/README.md @@ -0,0 +1,6 @@ +# Generate Country S2 Tokens +This script is used to generate countries_s2_tokens.json, used for downloading Google Buildings csv files. + +## Install +To ruin this script you need to also install s2geometry +[https://s2geometry.io/about/platforms](https://s2geometry.io/about/platforms) \ No newline at end of file diff --git a/scripts/google-buildings/google_buildings.py b/scripts/google-buildings/google_buildings.py new file mode 100644 index 0000000..91b5ab9 --- /dev/null +++ b/scripts/google-buildings/google_buildings.py @@ -0,0 +1,87 @@ +import json +import pandas as pd +import geopandas as gpd +import pycountry +import s2geometry as s2 +from shapely.geometry.polygon import Polygon + +from pathlib import Path + +from deepfacility.utils import spatial + + +updates = { + 'W. Sahara': 'Western Sahara', + 'Dem. Rep. Congo': 'Congo, The Democratic Republic of the', + 'Central African Rep.': 'Central African Republic', + 'Eq. Guinea': 'Equatorial Guinea', + 'Somaliland': 'Somalia', + 'S. Sudan': 'South Sudan', + 'eSwatini': 'Eswatini' +} + + +def s2token_to_polygon(s2_token: str) -> Polygon: + s2_cell = s2.S2Cell(s2.S2CellId_FromToken(s2_token, len(s2_token))) + s2s = [s2.S2LatLng(s2_cell.GetVertex(i)) for i in range(4)] + xy = [(s.lng().degrees(), s.lat().degrees()) for s in s2s] + return Polygon(xy) + + +def bounds_to_s2token(bounds: dict[str, float], level: int): + s2_lat_lng_rect = s2.S2LatLngRect_FromPointPair( + s2.S2LatLng_FromDegrees(bounds["miny"], bounds["minx"]), + s2.S2LatLng_FromDegrees(bounds["maxy"], bounds["maxx"])) + coverer = s2.S2RegionCoverer() + coverer.set_fixed_level(level) + cells = [cell.ToToken() for cell in coverer.GetCovering(s2_lat_lng_rect)] + return cells + + +def gdf_to_s2tokens(gdf: gpd.GeoDataFrame, level: int): + df = pd.concat([gdf.name, gdf.bounds], axis=1) + country_bounds = df.set_index("name").to_dict(orient="index") + s2tokens = {n: bounds_to_s2token(bs, level) for n, bs in country_bounds.items()} + return s2tokens + + +def get_countries_gdf(continent: str = None, names: list = None): + gdf = gpd.read_file(gpd.datasets.get_path("naturalearth_lowres")) + + if continent: + gdf = gdf[gdf.continent == continent] # filter by continent + + if names and len(names) > 0: + gdf = gdf[gdf.name.isin(names)] # filter by country + + return gdf + + +def get_country_s2_tokens(continent: str = "Africa", names: list = None, level: int = 4): + gdf = get_countries_gdf(continent, names) + s2tokens = gdf_to_s2tokens(gdf, level) + + # Filter out those not in the country + s2tokens_final = {} + for name, s2ids in s2tokens.items(): + geom: Polygon = gdf[gdf["name"] == name][spatial.geom_col].iloc[0] + assert geom, f"Country {name} shape is missing." + name2 = updates[name] if name in updates else name + s2tokens_final[name2] = { + "code": pycountry.countries.lookup(name2).alpha_3, + "s2": [t for t in s2ids if geom.intersects(s2token_to_polygon(t))] + } + + return s2tokens_final + + +def main(): + country_s2_tokens = get_country_s2_tokens() + filename = "../../src/deepfacility/config/countries_s2_tokens.json" + Path(filename).parent.mkdir(exist_ok=True) + with open(filename, 'w', encoding="utf-8") as fp: + json.dump(country_s2_tokens, fp, indent=4) + + +if __name__ == "__main__": + main() diff --git a/scripts/google-buildings/requirements.txt b/scripts/google-buildings/requirements.txt new file mode 100644 index 0000000..65741ea --- /dev/null +++ b/scripts/google-buildings/requirements.txt @@ -0,0 +1,4 @@ +geopandas +pandas +pycountry +s2geometry \ No newline at end of file diff --git a/src/deepfacility/__init__.py b/src/deepfacility/__init__.py new file mode 100644 index 0000000..c6313ee --- /dev/null +++ b/src/deepfacility/__init__.py @@ -0,0 +1,9 @@ +from deepfacility.main import main + +# module level doc-string +__doc__ = """ +**deepfacility** package provides features for extracting data from raster files using shapes/geometries as selectors. +""" + +# Use __all__ to let type checkers know what is part of the public API. +_all_ = [] diff --git a/src/deepfacility/config/__init__.py b/src/deepfacility/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/deepfacility/config/config.py b/src/deepfacility/config/config.py new file mode 100644 index 0000000..33527ba --- /dev/null +++ b/src/deepfacility/config/config.py @@ -0,0 +1,677 @@ +from __future__ import annotations + +import geopandas as gpd +import json +import logging +import pandas as pd +import re +import tomli +import shutil + +from dataclasses import dataclass, asdict, fields +from pathlib import Path + +from deepfacility.utils import util, spatial + + +# Config base data classes +@dataclass +class BaseData: + """Base abstract config data class.""" + @classmethod + def from_instance(cls, instance: object): + return cls(**asdict(instance)) + + +# Base data classes +@dataclass +class AdmFile(BaseData): + """Admin file data class.""" + file: Path # admin file path + adm_cols: list[str] # admin column names + + +@dataclass +class PointsFile(BaseData): + """Points file data class.""" + file: Path # file path + xy_cols: list[str] # coordinates column names + + +@dataclass +class DownloadFile(BaseData): + """Download file data class.""" + file: Path # file path + url: str # download url + dir: Path # download directory + + def __post_init__(self): + self.file = self.dir.joinpath(Path(self.url).name) + + +@dataclass +class Section(BaseData): + """Config section data abstract class.""" + + def ready(self): + """Check if all required files are ready.""" + raise NotImplementedError("ready method must be implemented.") + + def remove_files(self): + """Remove section files.""" + raise NotImplementedError("remove_files method must be implemented.") + + +@dataclass +class Operation: + """Config operation data abstract class.""" + control_file: Path # control file path + log_file: Path # log file path + _logger: logging.Logger + + def __post_init__(self): + """Set up logging and the 'stop' file.""" + # Remove log and control files if they exist + if self.control_file: + self.control_file.unlink(missing_ok=True) + + @property + def logger(self): + """Get the logger.""" + if not self._logger: + self.init_logger() + + return self._logger + + @logger.setter + def logger(self, value): + """Set the logger.""" + self._logger = value + + def init_logger(self): + """Initialize the file logger.""" + self._logger = util.init_logger(file=self.log_file) + + @property + def dir(self): + """Run results directory.""" + return self.log_file.parent + + @property + def root_dir(self): + """Root 'results' directory for the selected country.""" + return self.log_file.parent.parent + + def stop(self): + """Update the 'stop' file.""" + self.control_file.parent.mkdir(parents=True, exist_ok=True) + self.control_file.touch() + + def clear(self): + """Update the 'stop' file.""" + self.control_file.unlink(missing_ok=True) + + def is_stopped(self): + """Check if the operation is stopped.""" + if not self.control_file.is_file(): + return False + + ts0 = self.log_file.stat().st_atime if self.log_file.is_file() else 0 + ts1 = self.control_file.stat().st_mtime if self.control_file.is_file() else 0 + return ts1 >= ts0 + + def raise_if_stopped(self): + """Raise an exception if the operation is stopped.""" + if self.is_stopped(): + self.logger.warning("Stopping...") + raise InterruptedError("Stopped by the user.") + + def cleanup(self): + """Clean up logging and the 'stop' file.""" + self.logger.handlers.clear() + + +class WorkflowEntity: + cfg: Config + logger: logging.Logger + + def __init__(self, cfg: Config, logger: logging.Logger = None): + self.cfg = cfg + self.logger = logger + + +class Workflow(WorkflowEntity): + pass + + +@dataclass +class AdmPointsFile(PointsFile, AdmFile): + """Admin points file data class.""" + pass + + +@dataclass +class BaselineFile(PointsFile): + """Baseline file data class.""" + info_cols: list[str] # Info column names + +# Config section data classes + + +@dataclass +class Args(Section): + """Config args section data class.""" + country: str + data_dir: Path # root data for all config paths + country_code: str = "" + # Village centers args subsection + village_centers: AdmPointsFile = None + # Baseline args subsection + baseline_facilities: BaselineFile = None + # The number of households below which a village is considered small. + threshold_households: int = -1 + # Max allowed percent of small villages + threshold_village_perc: int = -1 + + def __post_init__(self): + """Init country code and root data directory.""" + assert len(self.country.strip()) > 0, "Country not specified." + if len(self.country_code.strip()) == 0: + self.country_code = get_country_code(self.country) + self.data_dir = Path(str(self.data_dir).format(country=self.country, country_code=self.country_code)) + + def has_baseline(self): + """Check if the baseline file is set.""" + return util.file_ready(self.baseline_facilities.file) + + +@dataclass +class BuildingsDownload(DownloadFile, PointsFile): + """Buildings download file data class.""" + pass + + +@dataclass +class ShapesDownload(DownloadFile): # AdmFile + """Shapes download file data class.""" + pass + + +@dataclass +class Downloads(Section): + """Config downloads section data class.""" + buildings: BuildingsDownload + shapes: ShapesDownload + + +@dataclass +class Inputs(Section, Operation): + """Config inputs section data class.""" + all_locations_file: Path # all locations file path + buildings: PointsFile # buildings file + shapes: AdmFile # shapes file + village_centers: AdmPointsFile # village centers file + baseline_facilities: AdmPointsFile # baseline facilities file + households: AdmPointsFile # households file + + @property + def shape_files(self): + """Shape files list. Populate shape file pattern with levels 0 and 3.""" + return [Path(str(self.shapes.file).format(level=i)) for i in [0, 3]] + + def ready(self): + """Check if all required files are ready.""" + required_files = [ + self.all_locations_file, + self.buildings.file, + self.village_centers.file, + self.households.file] + # Add admin shape files to the list + required_files += self.shape_files # merge two lists + return all([util.file_ready(f) for f in required_files]) + + def remove_files(self): + """Remove all input files.""" + # self.shapes.file.unlink(), + # self.buildings.file.unlink(), + self.households.file.unlink(missing_ok=True) + self.households.file.with_suffix('.stats.csv').unlink(missing_ok=True) + self.village_centers.file.unlink(missing_ok=True) + self.village_centers.file.with_suffix('.geojson').unlink(missing_ok=True) + self.baseline_facilities.file.unlink(missing_ok=True) + self.baseline_facilities.file.with_suffix('.geojson').unlink(missing_ok=True) + + def has_baseline(self): + """Check if the baseline file is set.""" + return util.file_ready(self.baseline_facilities.file) + + +@dataclass +class ResultsAdmFile(AdmFile): + """Results admin file data class.""" + data_cols: list[str] + + +@dataclass +class ResultsAdmPointsFile(PointsFile, ResultsAdmFile): + """Results admin points file data class.""" + pass + + +@dataclass +class Facilities(ResultsAdmPointsFile): + """Facilities data class.""" + n_facilities: int + + +@dataclass +class ResultsClusteredHouseholds(ResultsAdmPointsFile): + """Results clustered households data class.""" + centers_file: Path + counts_file: Path + + +@dataclass +class Results(Section, Operation): + """Config results section data class.""" + locations_file: Path # locations file path + # max locations for displaying visualization + viz_max_locations: int # to avoid unresponsive UI + clusters: ResultsClusteredHouseholds # clustered households file + shapes: ResultsAdmFile # village shapes files + facilities: Facilities # recommended facilities file + + def ready(self): + """Check if all required files are ready.""" + required_files = [self.clusters.file, self.shapes.file, self.facilities.file] + required_files = [spatial.location_path(f, "") for f in required_files] + required_files += [self.locations_file] + return all([util.file_ready(f) for f in required_files]) + + def remove_files(self): + """Remove run result directory.""" + if self.locations_file.parent.is_dir(): + shutil.rmtree(self.locations_file.parent, ignore_errors=True) + + +def prim_items(cfg): + """Get non-dict items from a dict.""" + return {k: v for k, v in cfg.items() if not isinstance(v, dict)} + + +@dataclass +class RuntimeArgs(BaseData): + """Runtime args, not specified in the config.""" + config_file: Path = None + command: str = "" + locations: list[str] = None + location_filter: list[str] = "" + run_name: str = "" + result_dir: str = "" + + def __post_init__(self): + """Init config file path.""" + if self.config_file and isinstance(self.config_file, str): + self.config_file = Path(self.config_file) + + def init_run_name(self): + """Init run name.""" + if not self.run_name: + if self.location_filter: + self.run_name = util.format_run_name(self.location_filter) + else: + self.run_name = "all" + + @classmethod + def from_dict(cls, args_dict: dict) -> RuntimeArgs: + a = {k: v for k, v in args_dict.items() if k in RuntimeArgs().__dict__} + return cls(**a) + + +class DataClassFactory: + """Data class factory responsible for constructing data classes from a config.""" + def __init__(self, cfg: dict): + self.cfg = cfg + self.unused = [] + self.missing = [] + + def make(self, dc: dataclass, key_path: list[str], ignore: list[str] = None) -> dataclass: + """ + Constructs a data class using a specified config sub-dict and reports validation. + For example, to construct the input buildings data class the "inputs.buildings" sub-dict + is located in the self.cfg and used to initialize the `dc` data class. + :param dc: data class to construct + :param key_path: list of a nested dict keys + :param ignore: list is keys to ignore + :return: data class object + """ + # Locate the config sub-dict per the key path + visited = [] # visited is the visited key list + current = self.cfg # current config dict level + for k in key_path: # for all keys in the path + visited.append(k) # add the key to the visited list + if k in current: # if path key is found + current = current[k] # go to the next level + else: # if not found, record as missing + self.missing.append(self.key_str(visited)) + return None # and bail out + + # Determine required, missing and unused key paths + ignore = ignore or [] + expected_fields = [f.name for f in fields(dc) if f.name not in ignore] + missing = [k for k in expected_fields if k not in current] + unused = [k for k in current if k not in expected_fields] + + if unused: # Report unused key paths + self.unused.extend([self.key_str(key_path + [k]) for k in unused]) + + if missing: # Report missing key paths + self.missing.extend([self.key_str(key_path + [k]) for k in missing]) + + # Construct dict to init the `dc` data class (from required fields) + ok = {k: v for k, v in current.items() if k in expected_fields} + if ignore: # skip ignored fields + for f in ignore: + ok[f] = "" + + try: + res = dc(**ok) # instantiate the data class + except Exception as ex: + # Get the config console logger + logger = util.init_logger(name="config") + for m in ex.args: # Report instantiation errors + logger.error(f"Unable to instantiate: {dc}\n{m}") + logger.error(f"Please update the config file.") + return None + + return res + + @staticmethod + def key_str(key_path: list[str]): + return '/'.join(key_path) + + +@dataclass +class Config(RuntimeArgs): + """Config data singleton class. Encapsulates all config data.""" + args: Args = None # Args section (user files and parameters) + downloads: Downloads = None # Downloads section + inputs: Inputs = None # Inputs section + results: Results = None # Results section + _load: bool = True # Load the config file flag + _instance = None # Singleton instance + default_file: str = util.app_dir() / "config.toml" # Default config file name + # Default config template files + default_template_sys_file: Path = Path(__file__).parent / "template_sys.toml" + default_template_user_file: Path = Path(__file__).parent / "template_user.toml" + + def __post_init__(self): + """Init config file path and load the config.""" + self.config_file = Path(self.config_file or Config.default_file) + self.init_run_name() + if self._load: + self._load_config_file() + + def _load_config_file(self, config_file: Path = None, run_args: RuntimeArgs = None) -> None: + """Load the config file.""" + if config_file: # update config file + self.config_file = config_file + + if run_args: # update runtime args + assert isinstance(run_args, RuntimeArgs), "run_args must be a RuntimeArgs data class" + self.__dict__.update(asdict(run_args)) + + assert self.config_file is not None, "Config config_file must be set at this point." + # Read system and user configs and merge them + try: + cfg = read_toml_file(Config.default_template_sys_file) + cfg_user = read_toml_file(self.config_file) + except (FileNotFoundError, tomli.TOMLDecodeError) as ex: + # Get the config console logger + logger = util.init_logger(name="config") + logger.error(f"Unable to reading the config file: {str(self.config_file)}") + for m in ex.args[1:]: + logger.error(f"{str(config_file)}: {m}") + exit(1) + + cfg.update(cfg_user) + + # Create the data class factory, in charge of producing config sections + dc = DataClassFactory(cfg=cfg) # by populating template variables + + # Ignore attributes not in the config + args_ignore = [v for v in ["country_code"] if v not in cfg["args"]] + + # Create Args, Downloads, Inputs and Results config sections using DataClassFactory and the merged config. + + # Create Args config section + self.args = dc.make(Args, ["args"], ignore=args_ignore) + dc.cfg = path_to_obj(populate(data=dc.cfg, + args={ + 'app_dir': util.app_dir(), + **self.__dict__, + **prim_items(self.args.__dict__) + })) + + self.args.village_centers = dc.make(AdmPointsFile, ["args", "village_centers"]) + self.args.baseline_facilities = dc.make(BaselineFile, ["args", "baseline_facilities"]) + + # Create Downloads config section + self.downloads = Downloads(buildings=dc.make(BuildingsDownload, ["downloads", "buildings"], ignore=["file"]), + shapes=dc.make(ShapesDownload, ["downloads", "shapes"], ignore=["file"])) + + # Create Inputs config section + dc.cfg["inputs"]["_logger"] = None + self.inputs = Inputs(**prim_items(dc.cfg["inputs"]), + buildings=dc.make(PointsFile, ["inputs", "buildings"]), + shapes=dc.make(AdmFile, ["inputs", "shapes"]), + village_centers=dc.make(AdmPointsFile, ["inputs", "village_centers"]), + baseline_facilities=dc.make(AdmPointsFile, ["inputs", "baseline_facilities"]), + households=dc.make(AdmPointsFile, ["inputs", "households"])) + + # Create Results config section + dc.cfg["results"]["_logger"] = None + self.results = Results(**prim_items(dc.cfg["results"]), + clusters=dc.make(ResultsClusteredHouseholds, ["results", "clusters"]), + shapes=dc.make(ResultsAdmFile, ["results", "shapes"]), + facilities=dc.make(Facilities, ["results", "facilities"])) + + if dc.unused: # Report unused keys + self.results.logger.warning(f"Unused config keys (can be removed) in file: {self.config_file}") + self.results.logger.info('\n'.join(dc.unused)) + + if dc.missing: # Report missing keys + self.results.logger.error(f"Missing required fields (must be set) in file: {self.config_file}") + self.results.logger.info('\n'.join(dc.missing)) + exit(1) + + # Load locations, filtered or all + self._parse_location_filter() + + # Locations properties and methods + + def _parse_location_filter(self): + """Parse the location filter and apply it to the `inputs.all_locations_file` + to construct the list of target locations to be processed.""" + # Read all locations from the input `all_locations_file` + if self.location_filter: # if location filter is specified, parse it and apply it + locations = [util.strip_accents(p) for p in self.location_filter] + self.locations = [loc for loc in get_all_locations(self) if any([re.match(p, loc) for p in locations])] + else: # if location filter is not specified return all locations + self.locations = get_all_locations(self) + + @property + def has_locations(self): + return isinstance(self.locations, list) and len(self.locations) > 0 + + def update_locations(self, location_filter: list[str], run_name: str = ""): + """Update the locations and run name.""" + self.location_filter = location_filter # update location filter + self.run_name = run_name # update run name + self.init_run_name() # init run name is not set + self._load_config_file() # reload the config to reflect changes + + # Config singleton methods + + @classmethod + def create_instance(cls, config_file: Path = None, run_args: RuntimeArgs = None) -> Config: + """Create an instance of the Config class.""" + if run_args: + # use runtime args if provided + run_args.config_file = config_file or run_args.config_file + run_args.init_run_name() + cfg = cls(**asdict(run_args)) + else: + # use config file if provided, else the default is used + cfg = cls(config_file=config_file) + + return cfg + + +# Config dict helper functions + +def read_toml_file(config_file: Path) -> dict: + """Read a TOML config file and return a dict.""" + assert isinstance(config_file, Path), f"read_toml_file fn. expects Path (got {type(config_file)} instead)." + if config_file.is_file(): + # Load the config file + cfg_dict = tomli.loads(config_file.read_text()) + else: + # Report and exit if the config file is not found + raise FileNotFoundError(f"File not found: {str(config_file)}.") + + # Figure out the run name if not set in the config + return cfg_dict + + +def populate(data: dict, args: dict) -> dict: + """Populate input dict with values from the args dict.""" + d = str(data) + for k, v in args.items(): + if is_str_item(k, v): + if isinstance(v, Path): + v = str(v) + d = d.replace("{" + k + "}", v) + + return eval(d.replace("\\", "/")) + + +def is_str_item(k: str, v: Path) -> bool: + """Check if the key is of string type and value of string or path types.""" + k_type_ok = isinstance(k, str) and not k.startswith("_") + v_type_ok = isinstance(v, Path) or isinstance(v, str) + return k_type_ok and v_type_ok + + +def is_path_key(k: str) -> bool: + """Check if the key represents a file or dir.""" + return k in ["file", "dir"] or re.match(".*(_file|_dir)$", str(k)) + + +def path_to_obj(data: dict): + """Convert all file and dir items to Path objects, recursively.""" + for k, v in data.items(): + if is_str_item(k, v) and is_path_key(k): + data[k] = Path(v) + elif isinstance(v, dict): + data[k] = path_to_obj(data[k]) + return data + + +def path_to_str(data: dict) -> dict: + """Convert all Path objects to string values, recursively.""" + for k, v in data.items(): + if isinstance(v, Path): + data[k] = str(v) + elif isinstance(v, dict): + data[k] = path_to_str(data[k]) + + return data + + +@dataclass +class ResultFiles: + """Encapsulate result files.""" + shape_file: Path + clusters_file: Path + centers_file: Path + counts_file: Path + facilities_file: Path + + +@dataclass +class ResultData: + """Encapsulate result data.""" + gdf_shapes: gpd.GeoDataFrame + df_clusters: pd.DataFrame + df_centers: pd.DataFrame + df_counts: pd.DataFrame + df_facilities: pd.DataFrame + + def save(self, rf: ResultFiles): + """Save result data to files.""" + self.gdf_shapes.to_file(rf.shape_file.with_suffix('.geojson'), driver='GeoJSON') + self.df_clusters.to_csv(rf.clusters_file, index=False, encoding='utf-8') + self.df_centers.to_csv(rf.centers_file, index=False, encoding='utf-8') + self.df_counts.to_csv(rf.counts_file, index=False, encoding='utf-8') + self.df_facilities.to_csv(rf.facilities_file, index=False, encoding='utf-8') + + +def read_s2_dict() -> dict[str, dict[str, object]]: + """Read the `country to S2 geometry` lookup dict, used to download Google Open Buildings files.""" + with open(Path(__file__).parent.joinpath("countries_s2_tokens.json"), encoding="utf-8") as fp: + s2s: dict = json.load(fp) + return s2s + + +def get_country_code(country: str) -> str: + """Get country code from the country name.""" + return str(read_s2_dict()[country]["code"]) + + +def get_supported_countries() -> list[str]: + """Get the list of supported countries.""" + return sorted(read_s2_dict()) + + +def create_config_file(config_file, force: bool = False): + """Create a new config file from the default template.""" + cfg_file = Path(config_file) + # Get the config console logger + logger = util.init_logger(name="config") + if force or not cfg_file.is_file(): + util.make_dir(cfg_file.parent) + shutil.copy2(Config.default_template_user_file, cfg_file) + logger.info(f"Created the config file: {str(cfg_file)}") + else: + logger.warning(f"Skipping. Config {str(cfg_file)} already exists.") + + +def get_adm_columns(ins: Inputs, df: pd.DataFrame): + """Get the admin columns from a dataframe.""" + if util.has_cols(df, ins.households.adm_cols): + cols = ins.households.adm_cols + elif util.has_cols(df, ins.shapes.adm_cols): + cols = ins.shapes.adm_cols + elif util.has_cols(df, ins.village_centers.adm_cols): + cols = ins.households.adm_cols + else: + raise ValueError("Dataframe admin columns can't be detected.") + + return cols + + +def get_all_locations(cfg: Config) -> list[str]: + """Get all locations from the `all_locations_file`.""" + if cfg.inputs.all_locations_file.is_file(): + return cfg.inputs.all_locations_file.read_text().splitlines() + else: + return [] + + +def filter_by_locations(ins: Inputs, df: pd.DataFrame, locations: list[str], columns: list[str] = None): + """Filter a dataframe by locations.""" + if not locations: + return df + + return spatial.filter_locations(df=df, locations=locations, columns=columns or get_adm_columns(ins=ins, df=df)) diff --git a/src/deepfacility/config/countries_s2_tokens.json b/src/deepfacility/config/countries_s2_tokens.json new file mode 100644 index 0000000..9724e50 --- /dev/null +++ b/src/deepfacility/config/countries_s2_tokens.json @@ -0,0 +1,554 @@ +{ + "Tanzania": { + "code": "TZA", + "s2": [ + "183", + "185", + "189", + "18f", + "191", + "19b", + "19d" + ] + }, + "Western Sahara": { + "code": "ESH", + "s2": [ + "0c3", + "0dd", + "0e9", + "0eb" + ] + }, + "Congo, The Democratic Republic of the": { + "code": "COD", + "s2": [ + "10b", + "10d", + "171", + "173", + "175", + "177", + "191", + "197", + "199", + "19b", + "19d", + "19f", + "1a1", + "1a3", + "1a5", + "1a7", + "1bd" + ] + }, + "Somalia": { + "code": "SOM", + "s2": [ + "163", + "3d9" + ] + }, + "Kenya": { + "code": "KEN", + "s2": [ + "171", + "177", + "179", + "17b", + "17d", + "17f", + "181", + "183", + "185", + "19d" + ] + }, + "Sudan": { + "code": "SDN", + "s2": [ + "113", + "141", + "143", + "15d", + "165", + "167", + "169", + "16b", + "16d", + "16f", + "171", + "173" + ] + }, + "Chad": { + "code": "TCD", + "s2": [ + "10d", + "10f", + "111", + "113", + "115", + "117", + "13d", + "13f", + "16b", + "16d" + ] + }, + "South Africa": { + "code": "ZAF", + "s2": [ + "1c1", + "1c3", + "1c5", + "1dd", + "1e5", + "1e7", + "1e9", + "1eb", + "1ed", + "1ef" + ] + }, + "Lesotho": { + "code": "LSO", + "s2": [ + "1e9", + "1ef" + ] + }, + "Zimbabwe": { + "code": "ZWE", + "s2": [ + "193", + "195", + "1eb", + "1ed" + ] + }, + "Botswana": { + "code": "BWA", + "s2": [ + "195", + "1bf", + "1c1", + "1eb", + "1ed" + ] + }, + "Namibia": { + "code": "NAM", + "s2": [ + "195", + "1b9", + "1bf", + "1c1", + "1c3", + "1c5", + "1c7" + ] + }, + "Senegal": { + "code": "SEN", + "s2": [ + "0e9", + "0eb", + "0ed", + "0ef" + ] + }, + "Mali": { + "code": "MLI", + "s2": [ + "0dd", + "0df", + "0e1", + "0e3", + "0e5", + "0e7", + "0ef", + "0fb", + "11d", + "11f" + ] + }, + "Mauritania": { + "code": "MRT", + "s2": [ + "0c3", + "0dd", + "0df", + "0e5", + "0e7", + "0e9", + "0eb", + "0ef" + ] + }, + "Benin": { + "code": "BEN", + "s2": [ + "103", + "11d" + ] + }, + "Niger": { + "code": "NER", + "s2": [ + "111", + "117", + "119", + "11b", + "11d", + "11f", + "123", + "13d" + ] + }, + "Nigeria": { + "code": "NGA", + "s2": [ + "103", + "105", + "107", + "10f", + "111", + "11b", + "11d" + ] + }, + "Cameroon": { + "code": "CMR", + "s2": [ + "105", + "107", + "109", + "10f", + "111" + ] + }, + "Togo": { + "code": "TGO", + "s2": [ + "0e3", + "103", + "11d" + ] + }, + "Ghana": { + "code": "GHA", + "s2": [ + "0e3", + "0fd", + "0ff", + "103", + "11d" + ] + }, + "C\u00f4te d'Ivoire": { + "code": "CIV", + "s2": [ + "0f9", + "0fb", + "0fd", + "0ff" + ] + }, + "Guinea": { + "code": "GIN", + "s2": [ + "0e5", + "0ef", + "0f1", + "0fb" + ] + }, + "Guinea-Bissau": { + "code": "GNB", + "s2": [ + "0ed", + "0ef" + ] + }, + "Liberia": { + "code": "LBR", + "s2": [ + "0f1", + "0f9", + "0fb" + ] + }, + "Sierra Leone": { + "code": "SLE", + "s2": [ + "0f1", + "0fb" + ] + }, + "Burkina Faso": { + "code": "BFA", + "s2": [ + "0e3", + "0e5", + "0fb", + "0fd", + "11d" + ] + }, + "Central African Republic": { + "code": "CAF", + "s2": [ + "109", + "10b", + "10d", + "10f", + "113", + "16d", + "173", + "175" + ] + }, + "Congo": { + "code": "COG", + "s2": [ + "109", + "10b", + "1a1", + "1a5", + "1a7" + ] + }, + "Gabon": { + "code": "GAB", + "s2": [ + "107", + "109", + "1a7", + "1a9" + ] + }, + "Equatorial Guinea": { + "code": "GNQ", + "s2": [ + "107", + "109" + ] + }, + "Zambia": { + "code": "ZMB", + "s2": [ + "191", + "193", + "195", + "197", + "199", + "19b", + "1bd", + "1bf" + ] + }, + "Malawi": { + "code": "MWI", + "s2": [ + "18d", + "18f", + "191", + "193" + ] + }, + "Mozambique": { + "code": "MOZ", + "s2": [ + "189", + "18b", + "18d", + "18f", + "191", + "193", + "1ed", + "1ef", + "1f1", + "1f3" + ] + }, + "Eswatini": { + "code": "SWZ", + "s2": [ + "1ef" + ] + }, + "Angola": { + "code": "AGO", + "s2": [ + "195", + "197", + "1a3", + "1a5", + "1a7", + "1b9", + "1bb", + "1bd", + "1bf" + ] + }, + "Burundi": { + "code": "BDI", + "s2": [ + "19b", + "19d" + ] + }, + "Madagascar": { + "code": "MDG", + "s2": [ + "18b", + "1f5", + "1f7", + "21d", + "21f", + "221", + "223", + "227" + ] + }, + "Gambia": { + "code": "GMB", + "s2": [ + "0ed", + "0ef" + ] + }, + "Tunisia": { + "code": "TUN", + "s2": [ + "125", + "12f", + "131", + "13b" + ] + }, + "Algeria": { + "code": "DZA", + "s2": [ + "0d7", + "0d9", + "0db", + "0dd", + "0df", + "0e1", + "119", + "11f", + "121", + "123", + "125", + "127", + "129", + "12f", + "13d" + ] + }, + "Eritrea": { + "code": "ERI", + "s2": [ + "161", + "167" + ] + }, + "Morocco": { + "code": "MAR", + "s2": [ + "0c1", + "0c3", + "0c5", + "0d1", + "0d7", + "0d9", + "0db", + "0dd", + "0e9", + "0eb" + ] + }, + "Egypt": { + "code": "EGY", + "s2": [ + "141", + "143", + "145", + "147", + "149", + "14f", + "151", + "15b", + "15d" + ] + }, + "Libya": { + "code": "LBY", + "s2": [ + "115", + "123", + "125", + "137", + "139", + "13b", + "13d", + "13f", + "141", + "147", + "149", + "16b" + ] + }, + "Ethiopia": { + "code": "ETH", + "s2": [ + "161", + "163", + "165", + "167", + "16f", + "171", + "179", + "17b", + "17d", + "3d7", + "3d9" + ] + }, + "Djibouti": { + "code": "DJI", + "s2": [ + "161", + "163" + ] + }, + "Uganda": { + "code": "UGA", + "s2": [ + "171", + "177", + "179", + "19d" + ] + }, + "Rwanda": { + "code": "RWA", + "s2": [ + "19d" + ] + }, + "South Sudan": { + "code": "SSD", + "s2": [ + "16d", + "16f", + "171", + "173", + "175", + "177", + "17b" + ] + } +} \ No newline at end of file diff --git a/src/deepfacility/config/template_sys.toml b/src/deepfacility/config/template_sys.toml new file mode 100644 index 0000000..4159eb8 --- /dev/null +++ b/src/deepfacility/config/template_sys.toml @@ -0,0 +1,63 @@ +[downloads] # External data downloads + [downloads.buildings] # download, prep + url = "https://storage.googleapis.com/open-buildings-data/v2/points_s2_level_4_gzip/{s2token}_buildings.csv.gz" # Google buildings URL + dir = "{app_dir}/downloads/google_buildings" # Google buildings download directory + xy_cols = ["longitude", "latitude"] # longitude and latitude column names + + [downloads.shapes] # download + url = "https://geodata.ucdavis.edu/gadm/gadm4.1/shp/gadm41_{country_code}_shp.zip" # GADM shapes URL + dir = "{app_dir}/downloads/GADM_shapes" # GADM shapes download directory + +[inputs] # Generated by the data preparation workflow from downloads and user provided files +all_locations_file = "{data_dir}/{country_code}/inputs/all_locations.csv" # All locations in the country +control_file = "{data_dir}/{country_code}/inputs/.stop" # Stop file for the data preparation workflow +log_file = "{data_dir}/{country_code}/inputs/prep.log" # Log file for the data preparation workflow + + [inputs.buildings] # Buildings data clipped using country GADM shapes + file = "{data_dir}/{country_code}/inputs/buildings_{country_code}.feather" # file path + xy_cols = ["lon", "lat"] # longitude and latitude column names + + [inputs.shapes] # country GADM shapes, for the specified admin level, extracted from the downlaoded GADM file + file = "{data_dir}/{country_code}/inputs/shapes/gadm41_{country_code}_{level}.geojson" # file path + adm_cols = ["NAME_2", "NAME_3"] # administrative levels column names + + [inputs.village_centers] # Village center locations, prepared from user provided village center file + file = "{data_dir}/{country_code}/inputs/village_centers.csv" # file path + xy_cols = ["lon", "lat"] # longitude and latitude column names + adm_cols = ["adm2", "adm3", "adm4"] # administrative levels column names + + [inputs.baseline_facilities] # Optional, baseline health facility locations, prepared from user provided baseline facility file + file = "{data_dir}/{country_code}/inputs/baseline_facilities.csv" # file path + xy_cols = ["lon", "lat"] # longitude and latitude column names + adm_cols = ["adm2", "adm3"] # administrative levels column names + + [inputs.households] # Household locations, prepared by spatially joining buildings and GADM shapes + file = "{data_dir}/{country_code}/inputs/households.csv" # file path + xy_cols = ["lon", "lat"] # longitude and latitude column names + adm_cols = ["adm2", "adm3"] # administrative levels column names + +[results] # Results generated by the scientific workflow +control_file = "{data_dir}/{country_code}/results/{run_name}/.stop" # Stop file for the given run +log_file = "{data_dir}/{country_code}/results/{run_name}/run.log" # Log file for the scientific workflow +locations_file = "{data_dir}/{country_code}/results/{run_name}/locations.csv" # Locations processed by the given run +viz_max_locations = 50 # Max number of locations to visualize on a map to mitigate performance degradation. + + [results.clusters] # Clustered households, cluster centers, and cluster counts + file = "{data_dir}/{country_code}/results/{run_name}/{location}/clustered_households.csv" # file path + centers_file = "{data_dir}/{country_code}/results/{run_name}/{location}/cluster_centers.csv" # file path + counts_file = "{data_dir}/{country_code}/results/{run_name}/{location}/cluster_counts.csv" # file path + xy_cols = ["lon", "lat"] # longitude and latitude column names + adm_cols = ["adm2", "adm3", "adm4"] # administrative levels column names + data_cols = ["cluster"] # cluster column name + + [results.shapes] # Village shapes, generated as a cunvas hull of the clustered households + file = "{data_dir}/{country_code}/results/{run_name}/{location}/village_shapes.geojson" # file path + adm_cols = ["adm2", "adm3", "adm4"] # administrative levels column names + data_cols = ["cluster"] # cluster column name + + [results.facilities] # Recommedned optimal facility locations + file = "{data_dir}/{country_code}/results/{run_name}/{location}/optimal_facilities.csv" # file path + xy_cols = ["lon", "lat"] # longitude and latitude column names + adm_cols = ["adm2", "adm3", "adm4"] # administrative levels column names + data_cols = ["cluster", "village"] # cluster and village column names + n_facilities = 3 # Number of recommended facility locations per village diff --git a/src/deepfacility/config/template_user.toml b/src/deepfacility/config/template_user.toml new file mode 100644 index 0000000..39faaea --- /dev/null +++ b/src/deepfacility/config/template_user.toml @@ -0,0 +1,15 @@ +[args] # User provided files and parameters. +country = "Burkina Faso" # run `deepfacility countries` to see supported countries. +data_dir = "app-data/data" # root data dir. +threshold_households = 30 # Min number of households per village. +threshold_village_perc = 10 # Max percent of invalid villages. + + [args.village_centers] # Optional if GADM shapes contain admin 4 + file = "{data_dir}/{country_code}/args/village_centers.csv" # file containing village centers + adm_cols = ["adm4"] # village column name + xy_cols = ["lon", "lat"] # longitude and latitude column names + + [args.baseline_facilities] # Optional for baseline analysis + file = "{data_dir}/{country_code}/args/baseline_facilities.csv" # file containing existing baseline facilities + xy_cols = ["lon", "lat"] # longitude and latitude column names + info_cols = [] # columns to include in the output \ No newline at end of file diff --git a/src/deepfacility/data/__init__.py b/src/deepfacility/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/deepfacility/data/downloads.py b/src/deepfacility/data/downloads.py new file mode 100644 index 0000000..90b21db --- /dev/null +++ b/src/deepfacility/data/downloads.py @@ -0,0 +1,86 @@ +import pandas as pd +import tempfile + +from pathlib import Path + +from deepfacility.config.config import WorkflowEntity, read_s2_dict, get_country_code +from deepfacility.utils import util + + +class Downloads(WorkflowEntity): + """External data downloads.""" + def download_country_shapes(self, country: str) -> Path: + """ + Download country shapes from the GADM database. + :param country: Country name. + :return: Path to the downloaded zip file. + """ + country_code = get_country_code(country) + url = self.cfg.downloads.shapes.url.format(country_code=country_code) + zip_file = util.download_url(url, self.cfg.downloads.shapes.dir) + assert zip_file.is_file(), "Download zip not found." + return zip_file + + def download_buildings(self, country: str) -> Path: + """ + Download, merge and save Google building files. + :param country: Country name. + :return: Path to the downloaded file. + """ + buildings_file = self.cfg.inputs.buildings.file + if buildings_file.is_file(): + self.logger.info("Skipping buildings download, file already exists.") + return buildings_file + else: + buildings_file.write_text("") # Create an empty file to indicate shapes are ready. + + files = self.download_google_buildings(country, dir_name=self.cfg.downloads.buildings.dir) + xy_cols = self.cfg.downloads.buildings.xy_cols + df_list = [pd.read_csv(f, compression="gzip", usecols=xy_cols, dtype=float, index_col=False, encoding='utf-8') for f in files] + df_all = pd.concat(df_list).reset_index(drop=True) + + xy_cols2 = self.cfg.inputs.buildings.xy_cols + if xy_cols != xy_cols2: + df_all = util.rename_df_cols(df_all, xy_cols, xy_cols2) + + util.make_dir(buildings_file) + + df_all.to_feather(buildings_file) + return buildings_file + + def download_google_buildings(self, country: str, dir_name: Path = None, s2_dict: dict[str, list] = None) -> list[Path]: + """ + Download Google Open Building data. + :param country: Country name. + :param dir_name: Directory to download the files to. + :param s2_dict: S2 tokens dictionary. + :return: List of downloaded files. + """ + s2_dict = s2_dict or read_s2_dict() + assert country in s2_dict, f"Country {country} not supported or misspelled." + s2tokens = s2_dict[country]["s2"] + assert s2tokens and len(s2tokens) > 0, "Country s2 token list can't be empty." + + dir_name = Path(dir_name or Path(tempfile.mkdtemp(suffix="GB"))) + util.make_dir(dir_name) + file_list = [self.download_s2_token(t, dir_name) for t in s2tokens] + return file_list + + def download_s2_token(self, s2token: str, dir_name: Path): + """ + Download Google Open Building data for a given S2 token. + :param s2token: S2 token. + :param dir_name: Directory to download the files to. + :return: Path to the downloaded file. + """ + url = self.cfg.downloads.buildings.url.format(s2token=s2token) + filename = util.download_url(url, dir_name) + return filename + + +# country shapes +# https://biogeo.ucdavis.edu/data/diva/adm/BFA_adm.zip +# https://geodata.ucdavis.edu/gadm/gadm4.1/json/gadm41_BFA_3.json.zip +# https://geodata.ucdavis.edu/gadm/gadm4.1/shp/gadm41_BFA_shp.zip + + diff --git a/src/deepfacility/data/inputs.py b/src/deepfacility/data/inputs.py new file mode 100755 index 0000000..968942f --- /dev/null +++ b/src/deepfacility/data/inputs.py @@ -0,0 +1,344 @@ +import geopandas as gpd +import pandas as pd +import time + +from pathlib import Path + +from deepfacility.config.config import Config, WorkflowEntity, AdmPointsFile +from deepfacility.utils import spatial +from deepfacility.utils import util + + +# Initialize data cache +memory = util.memory_cache() + + +class DataInputs(WorkflowEntity): + """Data inputs preparation.""" + def __init__(self, cfg: Config): + super().__init__(cfg) + self.logger = self.cfg.inputs.logger + + def prepare_country_shapes(self, zip_file: Path) -> list[Path]: + """ + Prepare country shapes from the GADM database. + :param zip_file: Path to the downloaded zip file. + :return: List of paths to the shape files. + """ + cfg: Config = self.cfg + assert zip_file.is_file(), "Raw shape file not found." + shape_files: list[Path] = cfg.inputs.shape_files + # Check if all shape files exist + if all([f.is_file() for f in shape_files]): + return shape_files + + # Load shapes + gdf_list: list[gpd.GeoDataFrame] = [ + gpd.read_file(format_zip_path(zip_file, f)) for f in shape_files] + + # Clean gdf objects + adm_cols = cfg.inputs.shapes.adm_cols + gdf_list: list[gpd.GeoDataFrame] = [util.clean_dataframe(gdf, adm_cols, keep=True) for gdf in gdf_list] + + # Save shape files to inputs dir + for gdf, f in zip(gdf_list, shape_files): + util.make_dir(f) + gdf.to_file(f) + + return shape_files # [adm0_file, adm3_file] + + def prepare_households(self, + buildings_file: Path, + buildings_xy_cols: list[str], + shapes_file: Path, + shapes_adm_cols: list[str]) -> Path: + """ + Prepare google households data and the country shapefile. + :param buildings_file: Path to the Google buildings file. + :param buildings_xy_cols: Google buildings longitude and latitude columns names. + :param shapes_file: Path to the country shapefile. + :param shapes_adm_cols: Shapefile admin column names. + :return: Path to the prepared households file, as configured in the input section. + """ + # Get the households file path from the config. + hh_file: Path = self.cfg.inputs.households.file + + # Check if the households file already exists. + if hh_file.is_file(): + self.logger.info("Skipping households prep, file already exists.") + return hh_file + + # Create an empty file to indicate household preparation has started. + hh_file.write_text("") # This is used to track the workflow progress. + + # Load the shapefile + gdf = gpd.read_file(shapes_file) + + # Load the buildings data + df_xy = pd.read_feather(buildings_file, columns=buildings_xy_cols) + + # Process the buildings data + df = self.process_buildings(gdf_shapes=gdf, adm_cols=shapes_adm_cols, df_xy=df_xy, xy_cols=buildings_xy_cols) + + # Save the prepared households data + df.to_csv(hh_file, index=False, encoding='utf-8') + + return hh_file + + def process_buildings(self, + gdf_shapes: gpd.GeoDataFrame, + adm_cols: list[str], + df_xy: pd.DataFrame, + xy_cols: list[str]) -> pd.DataFrame: + """Wrapper for the process_google_buildings function.""" + st = time.time() # Capture start time + + df_hh = process_google_buildings( + gdf_shapes=gdf_shapes, + adm_cols=adm_cols, + df_xy=df_xy, + xy_cols=xy_cols, + hh_adm_cols=self.cfg.inputs.households.adm_cols, + hh_xy_cols=self.cfg.inputs.households.xy_cols, + stop_fn=self.cfg.inputs.raise_if_stopped) + + self.logger.info(f"Completed processing buildings in: {util.elapsed_time_str(st)}.") + + return df_hh + + def prepare_village_locality(self, village_locality: AdmPointsFile, shape_files: list[Path]) -> Path: + """ + Prepare user provided village centers file. + :param village_locality: User provided village centers file. + :param shape_files: Shape files. + :return: Path to the prepared village centers file. + """ + # Set village centers config section alias + vc: AdmPointsFile = self.cfg.inputs.village_centers + + # # Check if the village centers file already exists + # if vc.file.is_file(): + # self.logger.info("Skipping village centers prep, file already exists.") + # return vc.file + + self.logger.info(f"Preparing village centers from: {vc.file.name}") + # Create an empty file to indicate village centers preparation has started. + vc.file.write_text("") # This is used to track the workflow progress. + + # Prepare village centers + df = self.prepare_village_centers(village_locality_file=village_locality.file, + xy_cols=village_locality.xy_cols, + village_col=village_locality.adm_cols[-1], + shape_file=shape_files[-1], + adm_cols=self.cfg.inputs.shapes.adm_cols) + + # Validate columns + cols = vc.adm_cols + vc.xy_cols + assert util.has_cols(df, cols), f"Prepared village centers are missing columns: {str(cols)}" + + # Save the prepared village centers + df.to_csv(vc.file, index=False, encoding='utf-8') + spatial.create_geojson(vc.file, Path(vc.file).stem, Path(vc.file.parent), vc.xy_cols[0], vc.xy_cols[1]) + + return vc.file + + def prepare_village_centers(self, + village_locality_file: Path, + xy_cols: list[str], + village_col: str, + shape_file: Path, + adm_cols) -> pd.DataFrame: + """ + Prepare village centers. + :param village_locality_file: Path to the village centers file. + :param xy_cols: Village centers longitude and latitude columns names. + :param village_col: Village column name. + :param shape_file: Path to the shape file. + :param adm_cols: Shapefile admin column names. + :return: DataFrame with prepared village centers. + """ + # Set a village center section alias + vc: AdmPointsFile = self.cfg.inputs.village_centers + + # Load village centers + df = pd.read_csv(village_locality_file, encoding='utf-8') + df = util.clean_dataframe(df, [village_col]) + + # Validate columns + old_cols = [village_col] + xy_cols + assert util.has_cols(df, old_cols), f"Village centers are missing columns: {str(old_cols)}" + df = df[old_cols].copy() + + # Clean village names + df[village_col] = df[village_col].apply(util.text_to_id) + + # Rename baseline columns if needed + if xy_cols != vc.xy_cols: + df = util.rename_df_cols(df, xy_cols, vc.xy_cols) + + # Join villages to shapes + + new_cols = vc.adm_cols + vc.xy_cols + + # Rename village column if needed + if village_col != vc.adm_cols[-1]: + df = util.rename_df_cols(df, village_col, vc.adm_cols[-1]) + + # Spatial join of village centers and shapes + gdf_shp = gpd.read_file(shape_file) + if adm_cols != vc.adm_cols[:-1]: + gdf_shp = util.rename_df_cols(gdf_shp, adm_cols, vc.adm_cols[:-1]) + + gdf = spatial.join_xy_shapes(df, vc.xy_cols, gdf_shp) + df = pd.DataFrame(gdf[new_cols]) + + # Clean strings + df = df[new_cols].sort_values(vc.adm_cols) + + return df + + def prepare_baseline_facilities(self, + baseline_file: Path, + baseline_xy_cols: list[str], + shape_file: Path, + shape_adm_cols: list[str], + info_cols: list[str], + id_col: str = 'facility_id', + baseline_info_col = 'info_col') -> Path: + """ + Prepare baseline facilities. + :param baseline_file: Path to the baseline facilities file. + :param baseline_xy_cols: Baseline facilities longitude and latitude columns names. + :param shape_file: Path to the shape file. + :param shape_adm_cols: Shapefile admin column names. + :param info_cols: Info columns used for results visualization. + :param id_col: Facility ID column name. + :param baseline_info_col: Info column name for baseline facilities. + :return: Path to the prepared baseline facilities file. + """ + # Set baseline facilities config section alias + bs: AdmPointsFile = self.cfg.inputs.baseline_facilities + + self.logger.info(f"Preparing baseline facilities from: {baseline_file.name}") + + # Load user provided baseline facilities + df = pd.read_csv(baseline_file, encoding='utf-8') + + # If the ID column is missing, create it + if id_col not in df.columns: + # create facility_id and set as index with incremental IDs + df[id_col] = range(1, len(df) + 1) + + # Rename baseline columns if needed + if baseline_xy_cols != bs.xy_cols: + df = util.rename_df_cols(df, baseline_xy_cols, bs.xy_cols) + + # Create info_col column + if baseline_info_col: + def add_info_cols(r): + r = "".join([f"{c.lower()}{r[c]}" for c in info_cols]) + return f"{r}" + + df[baseline_info_col] = df.apply(lambda r: add_info_cols(r), axis=1) + else: + df[baseline_info_col] = '' + + # Join villages to shapes + + # Spatial join of village centers and shapes + gdf_shp = gpd.read_file(shape_file) + gdf = spatial.join_xy_shapes(df, bs.xy_cols, gdf_shp) + + # Rename shape columns if needed + if shape_adm_cols != bs.adm_cols: + gdf = util.rename_df_cols(gdf, shape_adm_cols, bs.adm_cols) + + # Prepare the output DataFrame + new_cols = bs.adm_cols + bs.xy_cols + [id_col, baseline_info_col] + df = pd.DataFrame(gdf[new_cols]) + + # Generate Google Plus codes based on baseline coordinates + df["plus"] = df.apply(lambda r: spatial.get_plus_code(r.lon, r.lat), axis=1) + + # Save the prepared baseline facilities + Path.mkdir(bs.file.parent, exist_ok=True) + df.to_csv(bs.file, index=False, encoding='utf-8') + + # Create baseline facilities GeoJSON file for results visualizations + spatial.create_geojson(bs.file, bs.file.stem, bs.file.parent, *bs.xy_cols) + + return bs.file + + +def format_zip_path(zf: Path, af: Path): + """ + Format the path to a shape file within a zip archive. + :param zf: Path to the zip file. + :param af: Path to the target shape file (when extracted). + :return: Path to the shape file within the zip archive. + """ + return Path(f"{str(zf)}!{af.stem}.shp") + + +@memory.cache +def process_google_buildings(gdf_shapes: gpd.GeoDataFrame, + adm_cols: list[str], + df_xy: pd.DataFrame, + xy_cols: list[str], + hh_adm_cols: list[str], + hh_xy_cols: list[str], + stop_fn) -> pd.DataFrame: + """ + Process the Google buildings data. + :param gdf_shapes: GeoDataFrame with shapes. + :param adm_cols: Shapes admin column names. + :param df_xy: DataFrame with building coordinates. + :param xy_cols: Building longitude and latitude columns names. + :param hh_adm_cols: Households file admin columns. + :param hh_xy_cols: Households file coordinates columns. + :return: DataFrame with processed Google buildings data. + """ + # Get configured column names + hh_cols = hh_adm_cols + hh_xy_cols + + # Rename columns if needed + if xy_cols != hh_xy_cols: + df_xy = util.rename_df_cols(df_xy, xy_cols, hh_xy_cols) + + # Prepare the shapefile + gdf_shp = gdf_shapes[adm_cols + [spatial.geom_col]].copy() + if adm_cols != hh_adm_cols: + gdf_shp = util.rename_df_cols(gdf_shp, adm_cols, hh_adm_cols) + + # Process the buildings data in chunks due to memory constraints + point_count = len(df_xy) + chunk_size = 1000000 # TODO: cacl based on available RAM + chunk_count = point_count // chunk_size + + df_hh: pd.DataFrame = None + for p in range(chunk_count + 1): + # Check if the process should stop + stop_fn() + + # Calculate the start and end indices for the current chunk + start = p * chunk_size + end = min((p + 1) * chunk_size, point_count) + df = df_xy.iloc[start:end] + + # Clip buildings chunk to shapes + gdf = spatial.join_xy_shapes(df, hh_xy_cols, gdf_shp) + + # Concatenate the processed chunk results to the final DataFrame + df = pd.DataFrame(gdf[hh_cols]) + df_hh = df if df_hh is None else pd.concat([df_hh, df]) + + # Validate the number of households + assert len(df_hh) <= point_count, "The number of households is too large." + + # Finalize the output DataFrame + df_hh = df_hh.dropna() + df_hh = df_hh.sort_values(hh_cols) + + return df_hh + + diff --git a/src/deepfacility/flows.py b/src/deepfacility/flows.py new file mode 100644 index 0000000..814ca42 --- /dev/null +++ b/src/deepfacility/flows.py @@ -0,0 +1,525 @@ +import time + +import geopandas as gpd +import pandas as pd +import logging + +from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, Future +from pathlib import Path +from typing import Optional + +from deepfacility.data import inputs, downloads +from deepfacility.tasks import outlines, placement, clustering, distance +from deepfacility.utils import util, spatial + +from deepfacility.config.config import (Config, Args, Inputs, Workflow, + ResultsClusteredHouseholds, ResultFiles, filter_by_locations) +from deepfacility.tasks.clustering import ClusteredHouseholds +from deepfacility.tasks.distance import plot_ecdf_distance + + +# set pool executor variable to point to ThreadPoolExecutor if on Linux or ThreadPoolExecutor if on Windows or Mac +PoolExecutor = ProcessPoolExecutor if util.is_linux() else ThreadPoolExecutor + + +class DataPrepWorkflow(Workflow): + """Data preparation workflow.""" + downloader: downloads.Downloads + inputs: inputs.DataInputs + + def __init__(self, cfg: Config): + super().__init__(cfg) + self.logger = self.cfg.inputs.logger + self.downloader = downloads.Downloads(cfg=self.cfg, logger=self.logger) + self.data_inputs = inputs.DataInputs(cfg=self.cfg) + + def prepare_inputs(self, country: str) -> tuple[list[Path], Path, Path, Path, bool]: + """ + Prepare input files for the clustering and placement tasks. + :param country: str: Country name + :return: Shape files, households file, village centers file, baseline file, success flag + """ + cfg: Config = self.cfg + + try: + # Prepare shapes and households files + shp_files: list[Path] = self.prepare_shape_files(country=country) + hh_file: Path = self.prepare_households_file(country=country, shape_files=shp_files) + + # Validate columns + self.check_input_households(shapes_file=shp_files[-1], + households_file=hh_file, + stats_file=hh_file.with_suffix('.stats.csv')) + + # Prepare village centers + cfg.inputs.raise_if_stopped() + vc_file: Path = self.data_inputs.prepare_village_locality(village_locality=cfg.args.village_centers, shape_files=shp_files) + + # Prepare baseline facilities + if cfg.args.has_baseline(): + cfg.inputs.raise_if_stopped() + bl_file: Path = self.data_inputs.prepare_baseline_facilities( + baseline_file=cfg.args.baseline_facilities.file, + baseline_xy_cols=cfg.args.baseline_facilities.xy_cols, + shape_file=shp_files[-1], + shape_adm_cols=cfg.inputs.shapes.adm_cols, + info_cols=cfg.args.baseline_facilities.info_cols) + else: + bl_file = '' + self.logger.info("Skipping baseline facilities, no file provided.") + + except InterruptedError as e: + cfg.inputs.cleanup() + cfg.inputs.remove_files() + return [], Path(), Path(), Path(), False + + # Store the list of all locations in the input dir + df = pd.read_csv(vc_file, encoding='utf-8') + df = df[cfg.inputs.village_centers.adm_cols[:-1]].drop_duplicates() + locations = [":".join(r) for r in df.to_numpy()] + cfg.inputs.all_locations_file.write_text('\n'.join(locations)) + + return shp_files, hh_file, vc_file, bl_file, True + + def check_input_households(self, shapes_file: Path, households_file: Path, stats_file: Path) -> bool: + """ + Check the number of households per shape meets the configured threshold. + :param shapes_file: Shapes file. + :param households_file: Households file. + :param stats_file: Path to save the stats. + :return: True if the number of shapes is sufficient. + """ + shapes: gpd.GeoDataFrame = gpd.read_file(shapes_file) + df_households: pd.DataFrame = pd.read_csv(households_file) + + # Calculate household counts per shape stats + df_adm = df_households.groupby(self.cfg.inputs.households.adm_cols).size().to_frame(name='counts') + df_stats = df_adm["counts"].describe().apply(round) + + # Calculate the percentage of shapes with households + actual, expected = df_stats['count'], len(shapes) + perc: int = 100 * actual // expected + + # Create stats DataFrame + df_stats = df_stats.reset_index().rename(columns={'index': 'metric', 'counts': 'households'}) + df_stats = df_stats[df_stats['metric'] != 'count'] + + # Log stats + self.logger.info("Shape/Household Stats:") + self.logger.info(df_stats.to_string(index=False)) + self.logger.info(f"Shapes with households: {perc}% ({actual}/{expected})") + + # Check if the number of shapes is sufficient + if perc < 100 - int(self.cfg.args.threshold_village_perc): + self.logger.warning("The number of shapes is too low.") + + # Save stats + df_stats.to_csv(stats_file, index=False, encoding='utf-8') + + return True + + def prepare_shape_files(self, country: str) -> list[Path]: + """ + Download, extract and clean country shape files. + :param country: str: Country name + :return: List of admin shape files + """ + # country adm3 shapes + zip_file = self.downloader.download_country_shapes(country=country) + shape_files: list[Path] = self.data_inputs.prepare_country_shapes(zip_file=zip_file) + assert [f.is_file() for f in shape_files], "Shape files not ready." + return shape_files + + def prepare_households_file(self, country: str, shape_files: list[Path]) -> Path: + """ + Download, merge and transform buildings data into households. + :param country: str: Country name + :param shape_files: list[Path]: List of admin shape files + :return: Households file + """ + buildings_file = self.downloader.download_buildings(country=country) + households_file = self.data_inputs.prepare_households(buildings_file=buildings_file, + buildings_xy_cols=self.cfg.inputs.buildings.xy_cols, + shapes_file=shape_files[-1], + shapes_adm_cols=self.cfg.inputs.shapes.adm_cols) + assert households_file.is_file(), "Households file is not ready." + return households_file + + +# Processing flows +class ScientificWorkflow(Workflow): + """Scientific workflow for clustering and optimal facility""" + def __init__(self, cfg: Config): + super().__init__(cfg) + self.logger = self.cfg.results.logger + + def process_locations(self) -> Optional[tuple[ResultFiles, list]]: + """ + Run the scientific workflow for specified locations. + :return: ResultFiles object, list of failed locations + """ + ts = time.time() + try: + self.logger.info('Starting household clustering...') + chs: dict[str, ClusteredHouseholds] = self.cluster_households(locations=self.cfg.locations) + self.logger.info(f"Completed household clustering in: {util.elapsed_time_str(ts)}.") + + valid, failed = self.validate_clusters(chs) # check required files exist + + # Outline and Place + ts = time.time() + self.logger.info('Starting optimal placement...') + results: dict[str, ResultFiles] = self.outline_and_place(clustered_households=valid) + self.logger.info(f"Completed optimal placement in: {util.elapsed_time_str(ts)}.") + for loc, res in results.items(): + if res: # record successful results + results[loc] = res + else: # remove failed locations + del results[loc] + failed.append(loc) + + ts = time.time() + # Check if the processing was stopped + self.cfg.results.raise_if_stopped() + # Merge and plot final results + final_results: ResultFiles = self.process_results(results) + self.logger.info(f"Completed merging and plotting in: {util.elapsed_time_str(ts)}.") + + except InterruptedError as e: + self.cfg.results.cleanup() + return None, False + + return final_results, failed + + def cluster_households(self, locations: list[str]) -> dict[str, ClusteredHouseholds]: + """ + Cluster households for specified locations. + :param locations: list[str]: List of locations + :return: dict[str, ClusteredHouseholds]: Clustered households for each location + """ + ins: Inputs = self.cfg.inputs + self.logger.info(f"Clustering households for locations: {len(locations)}") + + # read all households and village centers + df_hh_all = pd.read_csv(ins.households.file, index_col=None, encoding='utf-8') + df_vc_all = pd.read_csv(ins.village_centers.file, encoding='utf-8') + + with PoolExecutor() as executor: # init parallel processing + # Inti counts for tracking progress + total_count, done_count, done_perc = len(self.cfg.locations), 0, 0 + fts, hh_cc = {}, {} # futures and clustered households dicts + + def process_future(ft: Future): + """Callback closure to handle the future result.""" + nonlocal hh_cc, total_count, done_count, done_perc + if self.cfg.results.is_stopped(): + return + + # Get and save the result + ch: ClusteredHouseholds = ft.result() + ch.save() + + # capture the result + hh_cc[ch.location] = ch + + # Track and report progress + done_perc = util.report_progress(logger=self.logger, + name="Clustering", + items=hh_cc, + done_perc=done_perc, + total_count=total_count) + + return + + # For each location, submit the task to the pool + for location in self.cfg.locations: + self.cfg.results.raise_if_stopped() + # Filter households and village centers by location + df_hh = filter_by_locations(ins=self.cfg.inputs, df=df_hh_all, locations=[location]) + df_vc = filter_by_locations(ins=self.cfg.inputs, df=df_vc_all, locations=[location]) + + # Submit the clustering tasks to the pool + fts[location] = executor.submit( + clustering.cluster_houses_by_villages_centers, + cfg=self.cfg, + df_households=df_hh, + df_villages_centers=df_vc, + location=location) + + self.cfg.results.raise_if_stopped() + fts[location].add_done_callback(process_future) + + return hh_cc + + def outline_and_place(self, clustered_households: dict[str, ClusteredHouseholds]) -> dict[str, ResultFiles]: + """ + Outline and optimally place facilities for clustered households. + :param clustered_households: dict[str, ClusteredHouseholds]: Clustered households for each location + :return: dict[str, ResultFiles]: Result files for each location + """ + res = self.cfg.results + # Init results dict and progress tracking + results_dict: dict[str, Optional[ResultFiles]] = {} + total_count, done_count, done_perc = len(clustered_households), 0, 0 + + def process_future(ft: Future, location: str): + """Callback closure to handle the future result.""" + nonlocal results_dict, total_count, done_count, done_perc + if res.is_stopped(): + return + + # capture the result + results_dict[location] = ft.result() + + # Track and report progress + done_perc = util.report_progress(logger= self.logger, + name="Placement", + items=results_dict, + done_perc=done_perc, + total_count=total_count) + return + + fts: dict[str, Future] = {} + with PoolExecutor() as executor: # init parallel processing + # For each location, submit the task to the pool + for loc, ch in clustered_households.items(): + res.raise_if_stopped() + self.logger.debug(f"Outlining and placing for: {loc}...") + fts[loc] = executor.submit( + self.outline_and_place_clustered_households, + ch=ch, + location=loc, + adm_files=self.cfg.inputs.shape_files, + has_baseline=self.cfg.inputs.has_baseline()) + + from functools import partial + process_future2 = partial(process_future, location=loc) + fts[loc].add_done_callback(process_future2) + + return results_dict + + def outline_and_place_clustered_households(self, + ch: ClusteredHouseholds, + location: str, + adm_files: list[Path], + has_baseline: bool) -> Optional[ResultFiles]: + """ + Create village shapes and recommend health facility placement. + :param ch: ClusteredHouseholds: Clustered households + :param location: str: Location name + :param adm_files: list[Path]: List of admin shape files + :param has_baseline: bool: Flag to indicate if baseline facilities are available + :return: ResultFiles: Result files + """ + # Prep shape GeoDataFrames + gdf_adm3_all = gpd.read_file(adm_files[-1]) + gdf_adm3 = filter_by_locations(ins=self.cfg.inputs, df=gdf_adm3_all, locations=[location]) + + # Check if the clustered households file exists and is not empty + if not (ch.valid and ch.clusters_file.is_file()): + return None + + df_cs = pd.read_csv(ch.clusters_file, encoding='utf-8') + if len(df_cs) == 0: + return None + + # Create cluster(village) shapes + gdf_shp = outlines.create_clusters_shapes(cfg=self.cfg, + gdf_adm_shape=gdf_adm3, + df_clusters=df_cs, + location=location) + + self.logger.debug(f"Completed creating cluster shapes for: {location}.") + + # Save cluster shapes + shape_file = spatial.location_path(pattern=self.cfg.results.shapes.file, location=location) + outlines.export_cluster_shapes(cluster_shapes=gdf_shp, shape_file=shape_file) + + self.logger.debug(f"Completed exporting cluster shapes for: {location}.") + + # Place facilities + df_facilities = placement.place_facilities(cfg=self.cfg, df_clusters=df_cs, location=location) + self.logger.debug(f"Completed facility placement for: {location}.") + + # Calculate distances from household to nearest facility and save it + ch.clusters_df, ch.centers_df = distance.calculate_distance(self.cfg, + df_clusters=ch.clusters_df, + df_centers=ch.centers_df, + center_xy_cols=ch.center_xy_cols, + df_facilities=df_facilities, + gdf_shp=gdf_shp) + + # Save the calculated distances and cluster centers + ch.clusters_df.to_csv(ch.clusters_file, index=False, encoding='utf-8') + ch.centers_df.to_csv(ch.centers_file, index=False, encoding='utf-8') + self.logger.debug(f"Completed distance calculations for: {location}.") + + self.cfg.results.raise_if_stopped() + # Plot commune population coverage for recommended health facilities + distance.plot_ecdf_distance(cfg=self.cfg, + df=ch.clusters_df, + filename=ch.clusters_file.with_suffix('.png'), + location=location, + distance_col='hh_minkowski') + + self.logger.debug(f"Completed plotting distance for: {location}.") + + if has_baseline: + self.cfg.results.raise_if_stopped() + + # Plot commune population coverage for baseline health facilities + distance.plot_ecdf_distance( + cfg=self.cfg, + df=ch.clusters_df, + filename=ch.clusters_file.parent / "population_coverage_baseline.png", + location=location + " (baseline)", + distance_col='baseline_hh_minkowski', + plot_properties={'color': 'darkgoldenrod'}) + + self.logger.debug(f"Completed plotting baseline distance for: {location}.") + + # Save recommended health facility placements to file + facilities_file = spatial.location_path(pattern=self.cfg.results.facilities.file, location=location) + df_facilities.to_csv(facilities_file, index=False, encoding='utf-8') + + self.logger.debug(f"Completed exporting facilities for: {location}.") + + # Store result files into a ResultFiles object + results = ResultFiles(shape_file=shape_file, + clusters_file=ch.clusters_file, + centers_file=ch.centers_file, + counts_file=ch.counts_file, + facilities_file=facilities_file) + + self.logger.debug(f"Completed outlining and placing for: {location}.") + + if not facilities_file: + self.logger.info(f"Skipping outlining, clustered households file not found or is empty: {location}") + + return results + + def validate_clusters(self, clustered_households: dict[str, ClusteredHouseholds]) -> tuple[dict[str, ClusteredHouseholds], list]: + """ + Validate clustered households and check required files exist. + :param clustered_households: dict[str, ClusteredHouseholds]: Clustered households for each location + :return: dict[str, ClusteredHouseholds], list: Valid clustered households, failed locations + """ + # Validate clustered households + valid = {} + failed = [] + for loc, ch in clustered_households.items(): + if ch.valid: + assert ch.clusters_file.is_file(), f"The {loc} clusters file {str(ch.clusters_file)} not found." + assert ch.centers_file.is_file(), f"The {loc} centers file {str(ch.centers_file)} not found." + assert ch.counts_file.is_file(), f"The {loc} counts file {str(ch.counts_file)} not found." + valid[loc] = ch + else: + failed.append(loc) + + if len(failed) > 0: + content = '\n'.join(failed) + ff = self.cfg.results.locations_file.with_suffix(".failed.csv") + ff.write_text(content) + + return valid, failed + + def process_results(self, results: dict[str, ResultFiles]) -> Optional[ResultFiles]: + """ + Merge and plot final results. + :param results: dict[str, ResultFiles]: Result files for each location + :return: ResultFiles: Merged result files + """ + if len(results) > 0: + # Merge commune results, each result file 'type' into a single file + rf: Optional[ResultFiles] = outlines.merge_results(cfg=self.cfg, results=results) + self.logger.info(f"Completed merging results.") + else: + rf = None + + if rf: + # Plot overall population coverage for recommended health facilities + self.plot_distances(rf) + self.logger.info(f"Completed distance calculations.") + return rf + + def plot_distances(self, result_files: ResultFiles): + """ + Plot cumulative health facility population coverage by distances. + :param result_files: ResultFiles: Result files + """ + hh_cluster = pd.read_csv(result_files.clusters_file, encoding='utf-8') + optimal_png = result_files.clusters_file.parent / "population_coverage_optimal.png" + plot_ecdf_distance(cfg=self.cfg, + df=hh_cluster, + filename=optimal_png, + location=self.cfg.run_name, + distance_col='hh_minkowski') + + if self.cfg.inputs.has_baseline(): + baseline_png = result_files.clusters_file.parent / "population_coverage_baseline.png" + plot_ecdf_distance(cfg=self.cfg, + df=hh_cluster, + filename=baseline_png, + location=self.cfg.run_name + " (baseline)", + distance_col='baseline_hh_minkowski', + plot_properties={'color': 'darkgoldenrod'}) + + # Check clusters-households counts against thresholds + cls: ResultsClusteredHouseholds = self.cfg.results.clusters + self.check_thresholds(result_files.clusters_file, columns=cls.adm_cols + cls.data_cols) + + def check_thresholds(self, clusters_file: Path, columns: list[str]) -> bool: + """ + Check the number of households per cluster meets the configured threshold. + :param clusters_file: Path to the clusters file. + :param columns: Columns to group by. + :return: True if the number of households is sufficient. + """ + # Get thresholds from configuration + a: Args = self.cfg.args + threshold_households = a.threshold_households + threshold_village_perc = a.threshold_village_perc + + # Calculate household counts per cluster + df: pd.DataFrame = pd.read_csv(clusters_file, encoding='utf-8') + df_cnt = df.groupby(by=columns).size().to_frame(name='counts') + + # Set small village flag, save to CSV + df_cnt['small'] = df_cnt.counts < threshold_households + df_cnt.reset_index().to_csv(clusters_file.parent.joinpath("cluster_counts.csv"), index=False, encoding='utf-8') + + # Calculate invalid cluster counts and percentage + df_inv = df_cnt[df_cnt.counts < threshold_households] + invalid_cnt = len(df_inv) + df_stats = df_cnt["counts"].describe().apply(round) + invalid_perc = round(100.0 * invalid_cnt / df_stats['count'], 2) + + # Log stats + self.logger.info(f"Village/Households Stats:") + df_stats: pd.DataFrame = df_stats.reset_index() + df_stats['index'] = df_stats.apply(lambda r: f"village households {str(r['index']).ljust(4)}", axis=1) + df_stats.loc[len(df_stats)] = [f"small villages (<{threshold_households} hh)", f"{invalid_perc}%"] + df_stats.loc[len(df_stats)] = [f"total number of villages", df_stats.iloc[0, 1]] + df_stats = df_stats.iloc[1:, :].copy() + + # Prepare stats DataFrame + df_stats = util.rename_df_cols(df_stats, 'index', 'metric') + + # Log stats and save to CSV + self.logger.info(df_stats.to_string(index=False)) + df_stats.to_csv(clusters_file.parent.joinpath("cluster_stats.csv"), index=False, encoding='utf-8') + + # Log invalid clusters and their counts + self.logger.info("Number households per location:") + for t in df_inv.index: + self.logger.info(f" {':'.join([str(v) for v in t])} : {df_inv.loc[t][0]}") + + self.logger.info("") + + # Check if the number of invalid clusters is below the threshold + ok = invalid_perc < threshold_village_perc + if not ok: + self.logger.warning( + f"The percent of villages with low number of households: {invalid_perc} % (less than {threshold_households}).") + + return ok \ No newline at end of file diff --git a/src/deepfacility/lang/__init__.py b/src/deepfacility/lang/__init__.py new file mode 100644 index 0000000..e2587cf --- /dev/null +++ b/src/deepfacility/lang/__init__.py @@ -0,0 +1,18 @@ +import importlib +import os + +from deepfacility.lang.helpers import locale_language, request_language + +# Load the translation model +translation_model = os.environ.get('DEEPFACILITY_LANG_MODEL', "NLP") +if importlib.util.find_spec('torchvision'): + # If PyTorch is installed use the ML model + if translation_model == "NLLB": + from deepfacility.lang.translator_i18n import TranslatorI18N as Translator + elif translation_model == "NLP": + from deepfacility.lang.translator_i18n_nlp import TranslatorI18N_NLP as Translator +else: + # Otherwise use the default model + from deepfacility.lang.translator_default import DefaultTranslator as Translator + + diff --git a/src/deepfacility/lang/helpers.py b/src/deepfacility/lang/helpers.py new file mode 100644 index 0000000..bb922de --- /dev/null +++ b/src/deepfacility/lang/helpers.py @@ -0,0 +1,50 @@ +import json +import locale + +from fastapi import Request +from pathlib import Path + + +def code_from_locale(locale_str: str): + """Get the language code from the locale string.""" + return locale_str[:2].lower() + + +def locale_language(): + """Get the language code from the locale.""" + return code_from_locale(locale.getlocale()[0]) + + +def request_language(request: Request): + """Get the language code from the http request.""" + try: + if request: + locale_str = request.headers.get("accept-language", "en").split(",")[0] + else: + locale_str = "" + except Exception as e: + locale_str = "" + + return code_from_locale(locale_str) + + +def get_language(request: Request = None): + """Get the language code.""" + return request_language(request) or locale_language() + + +def get_supported_languages() -> list[tuple]: + """Get the list of supported languages.""" + + # Path to your JSON file + filename = Path(__file__).parent / "languages.json" + + # Open the file in read mode + with open(filename, "r", encoding="utf-8") as f: + # Load the JSON data + data = json.load(f) + + langs = [tuple(t) for t in data['langs']] + + return langs + diff --git a/src/deepfacility/lang/languages.json b/src/deepfacility/lang/languages.json new file mode 100644 index 0000000..57cf63e --- /dev/null +++ b/src/deepfacility/lang/languages.json @@ -0,0 +1,6 @@ +{ + "langs": [ + ["en", "English"], + ["fr", "Français"] + ] +} diff --git a/src/deepfacility/lang/messages/fr.json b/src/deepfacility/lang/messages/fr.json new file mode 100644 index 0000000..41f4da6 --- /dev/null +++ b/src/deepfacility/lang/messages/fr.json @@ -0,0 +1,195 @@ +{ + "### --- UX --- ###" : "", + "Digitally Enabled Equitably Placed (DEEP) Facility": "Formation Sanitaire à Emplacement Digitale et Equitable (DEEP Facility)", + "Stop": "Arrêter", + "Language": "Langue", + "Version": "Version", + + "### UPLOAD ###": {}, + "Upload village centers file (.csv).": "Télécharger le fichier des centres de village (.csv).", + "Upload (optional) facilities baseline file (.csv).": "Télécharger (facultatif) le fichier de base des installations (.csv).", + "Upload": "Télécharger", + + "### CONFIGURE NEW ###": {}, + "Yes": "Oui", + "No": "Non", + "Are you sure?": "Êtes-vous sûr?", + + "### CONFIGURE ###": {}, + "Configure": "Configurer", + "Choose column names and parameters.": "Choisir les noms de colonnes et les paramètres.", + "Village Centers File": "Fichier des Centres de Village", + "Village Name Column": "Nom du Village", + "Village Longitude Column": "Longitude du Village", + "Village Latitude Column": "Latitude du Village", + "Facilities Baseline File": "Fichier de Base des Installations", + "Baseline Longitude Column": "Longitude de Base", + "Baseline Latitude Column": "Latitude de Base", + "Baseline Info Columns": "Colonnes d'Information de Base", + + "### INFO ###": {}, + "Country": "Pays", + "Code": "Code", + "Baseline": "Baseline", + "Results": "Résultats", + "New Config": "Nouvelle Configuration", + "Session": "Session", + + "### PREP ###": {}, + "Prepare Input Data": "Préparer les Entrées", + "Cancel": "Annuler", + "Review below information, then click": "Vérifiez les informations ci-dessous, puis cliquez", + "takes a few minutes to complete": "prend quelques minutes pour terminer", + "Preview Village Centers (first 5 rows)": "Aperçu des Centres de Village (5 premières lignes)", + "Preview Baseline Facilities (first 5 rows)": "Aperçu des Installations de Base (5 premières lignes)", + "Goes back to the upload page.": "Retourne à la page de téléchargement.", + "Prepares input data like households. WARNING! This action will remove all previous results.": "Prépare les données d'entrée comme les ménages. AVERTISSEMENT: Cette action supprimera tous les résultats précédents.", + "Hover over buttons for more information.": "Survolez les boutons pour plus d'informations.", + "viz may take a few minutes to load": "la visualisation peut prendre quelques minutes pour charger", + "Click to visualize and get files.": "Cliquez pour visualiser et obtenir les fichiers.", + "Show large viz": "Afficher la grande visualisation", + + "### PREP STATUS ###": {}, + "Shapes": "Formes", + "Buildings": "Bâtiments", + "Households": "Ménages", + "Villages": "Villages", + "Time": "Temps", + + "### RUNS ###": {}, + "Locations": "Emplacements", + "Run Clustering": "Exécuter le Regroupement", + "Remove": "Supprimer", + "Downloads": "Téléchargements", + "Download": "Télécharger", + "All locations may take up to ~1h.": "Tous les emplacements peuvent prendre jusqu'à ~1h.", + "No results yet.": "Pas de résultats pour l'instant.", + "Waiting for status...": "En attente de statut...", + + "### RUN STATUS ###": {}, + "Done": "Terminé", + "In Progress": "En Cours", + "Not Started": "Pas Commencé", + "Clustering": "Regroupement", + + "### MAP ###": "Carte", + "Result Files": "Fichiers de Résultats", + "Browser may ask for confirmation.": "Le navigateur peut demander une confirmation.", + + "### --- LOGGER --- ###" : "", + + "### SESSION ###": {}, + "Removing invalid session file": "Suppression du fichier de session invalide", + "No task to stop": "Aucune tâche à arrêter", + + "### CONFIG ###": {}, + "Stopping...": "Arrêt...", + "Unable to instantiate": "Impossible d'instancier", + "Please update the config file.": "Veuillez mettre à jour le fichier de configuration.", + "Unused config keys (can be removed) in file": "Clés de configuration inutilisées (peuvent être supprimées) dans le fichier", + "Missing required fields (must be set) in file": "Champs requis manquants (doivent être définis) dans le fichier", + "File not found": "Fichier non trouvé", + + "### DOWNLOADS ###": {}, + "Skipping buildings download, file already exists.": "Sauter le téléchargement des bâtiments, le fichier existe déjà.", + + "### INPUTS ###": {}, + "Skipping households prep, file already exists.": "Sauter la préparation des ménages, le fichier existe déjà.", + "Completed processing buildings in": "Traitement des bâtiments terminé en", + "Skipping village centers prep, file already exists.": "Sauter la préparation des centres de village, le fichier existe déjà.", + "Preparing village centers from": "Préparation des centres de village à partir de", + "Preparing baseline facilities from": "Préparation des installations de base à partir de", + + "### FLOWS ###": {}, + "Skipping baseline facilities, no file provided.": "Sauter les installations de base, aucun fichier fourni.", + "Starting household clustering...": "Démarrage du regroupement des ménages...", + "Completed household clustering in": "Regroupement des ménages terminé en", + "Clustering households for": "Regroupement des ménages pour", + "locations": "emplacements", + "Starting optimal placement...": "Démarrage du placement optimal...", + "Completed optimal placement in": "Placement optimal terminé en", + "Completed merging and plotting in": "Fusion et traçage terminés en", + "Clustering households for locations": "Regroupement des ménages pour les emplacements", + "Outlining and placing for": "Délimitation et placement pour", + "Completed creating cluster shapes for": "Création de formes de cluster terminée pour", + "Completed exporting cluster shapes for": "Exportation de formes de cluster terminée pour", + "Completed facility placement for": "Placement d'installations terminé pour", + "Completed distance calculations for": "Calculs de distance terminés pour", + "Completed plotting distance for": "Tracé de distance terminé pour", + "Completed plotting baseline distance for": "Tracé de distance de base terminé pour", + "Completed exporting facilities for": "Exportation d'installations terminée pour", + "Completed outlining and placing for": "Délimitation et placement terminés pour", + "Skipping outlining, clustered households file not found or is empty": "Sauter le contour, le fichier de ménages regroupés n'a pas été trouvé ou est vide", + "Completed merging results.": "Fusion des résultats terminée.", + "Completed distance calculations.": "Calculs de distance terminés.", + + "### MAIN ###" : "", + "Supported countries (you can set in config):": "Pays pris en charge (vous pouvez définir dans la configuration)", + "Creating leaflet map:": "Création de la carte leaflet", + "Finished processing in:": "Traitement terminé en:", + + + "### CLUSTERING ###" : "", + "No household data for": "Pas de données de ménage pour", + "No villages centers data for": "Pas de données de centres de village pour", + "Skipping clustering for": "Sauter, regroupement pour", + "Failed to cluster households:": "Échec du regroupement des ménages:", + "Clustering has not converged for": "Le regroupement n'a pas convergé pour", + "Time:": "Temps:", + + "### DISTANCE ###" : "", + "No data for distance computation, skipped!": "Pas de données pour le calcul de distance, sauté!", + "Unable to find any baseline locations within adm2 boundary!" : "Impossible de trouver des emplacements de base dans la limite de l'adm2!", + "Column does not exist in dataframe, unable to plot ECDF, skipped": "La colonne n'existe pas dans le dataframe, impossible de tracer ECDF, sauté", + + "### PLACEMENT ###" : "", + "Clustering facilities didn't converge for": "Le regroupement des installations n'a pas convergé pour", + + "### COMMANDS ###" : "", + "Prepared input files:": "Fichiers d'entrée préparés", + "Merged files:": "Fichiers fusionnés", + "village shapes": "formes de village", + "cluster households": "ménages de regroupement", + "facility placement": "emplacements d'installations", + "No results found.": "Aucun résultat trouvé.", + "Failed locations:": "Emplacements échoués:", + "No locations found": "Aucun emplacement trouvé", + "Command": "Commande", + "Options:": "Options", + "run": "exécuter", + "config": "configurer", + "results": "résultats", + + "### UTIL ###" : "", + "Download failed": "Téléchargement échoué", + "Skipping download, file already exists": "Sauter le téléchargement, le fichier existe déjà", + "Progress": "Progression", + + "### VALIDATE ###" : "", + "Village/Households Stats:": "Statistiques des Villages/Ménages", + "Number households per of location:": "Nombre de ménages par emplacement", + "The percent of villages with low number of households": "Le pourcentage de villages avec un faible nombre de ménages", + "Shape/Household Stats:": "Statistiques de Forme/Ménage", + "metric households": "ménages métriques", + "Shapes with households": "Formes avec ménages", + "The number of shapes is too low.": "Le nombre de formes est trop faible.", + + "### MAIN UX ###" : "", + "Completed data preparation!": "Préparation des données terminée!", + "Cancelled data preparation.": "Préparation des données annulée.", + "The configuration was not successful (no locations file). Redirecting to the upload page." : "La configuration n'a pas réussi (pas de fichier d'emplacements). Redirection vers la page de téléchargement.", + "Directory doesn't appear to be a valid 'run' directory": "Le répertoire ne semble pas être un répertoire 'run' valide", + + "### VISUALIZE ###" : "", + "JavaScript file created successfully": "Fichier JavaScript créé avec succès", + "infile deleted": "fichier d'entrée supprimé", + "Not an absolute path": "Pas un chemin absolu", + "Use absolute path": "Utiliser un chemin absolu", + "Creating facilities GeoJson layer for": "Création de la couche GeoJson des installations pour", + "Map folder already exists.": "Le dossier de la carte existe déjà.", + "Copying viz files": "Copie des fichiers de visualisation", + "Skip copying viz files": "sauter la copie des fichiers de visualisation", + "Matching visualization files have been copied to the images directory.": "Les fichiers de visualisation correspondants ont été copiés dans le répertoire des images.", + "show household population": "afficher la population des ménages", + "Village Household": "Ménage de Village" +} \ No newline at end of file diff --git a/src/deepfacility/lang/translator.py b/src/deepfacility/lang/translator.py new file mode 100644 index 0000000..b3a66aa --- /dev/null +++ b/src/deepfacility/lang/translator.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import logging + +from requests import Request + +from deepfacility.lang import helpers + + +class BaseTranslator: + """Base class for a translator.""" + _language: str = helpers.locale_language() + _all_languages: list[tuple] = None + + @property + def language(self) -> str: + """Get the current language.""" + return self._language + + @classmethod + def create(cls, language: str = None, request: Request = None): + """Create a translator instance.""" + language = language or helpers.get_language(request) + translator = cls() + translator.set_language(language) + return translator + + @property + def supported_languages(self) -> list[tuple]: + """Get the list of supported languages.""" + if self._all_languages is None: + self._all_languages = helpers.get_supported_languages() + + return self._all_languages + + ###################################################### + # API to be implemented by a subclass + + def set_language(self, language: str) -> BaseTranslator: + """Initialize the translator and related objects.""" + raise NotImplementedError() + + def translate(self, msg: str) -> None: + """Translate a message to the current language.""" + raise NotImplementedError() + + # + ###################################################### + \ No newline at end of file diff --git a/src/deepfacility/lang/translator_default.py b/src/deepfacility/lang/translator_default.py new file mode 100644 index 0000000..d13ebd1 --- /dev/null +++ b/src/deepfacility/lang/translator_default.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import json + +from pathlib import Path + +from deepfacility.lang.translator import BaseTranslator + + +class DefaultTranslator(BaseTranslator): + """Default translator implementation.""" + + _messages: dict = None + + def _load_messages(self): + """Load messages from the language file.""" + self._messages = self._messages or {} + # TODO: Make messages dir configurable to be any directory + msg_file = Path(__file__).parent / "messages" / f"{self.language}.json" + if msg_file.is_file(): + with open(msg_file, 'r', encoding='utf-8') as fp: + msg_dict = json.load(fp) + + self._messages[self.language] = msg_dict + else: + self._messages = {} + + # API + + def set_language(self, language: str): + """Set the current language.""" + assert language is not None, "Language must be set." + self._language = language + self._load_messages() + + def translate(self, msg): + """Translate a message to the current language.""" + try: + # If language supported and message is present set default translation + if self.language in self._messages: + if msg in self._messages[self.language]: + res = self._messages[self.language][msg] + else: + res = msg # if no translation keep the original text + + # consider status format [message][sep][status] and translate the text before the separator + for sep in [": ", " ("]: + if sep in msg: + key, data = msg.split(sep, maxsplit=1) + if key in self._messages[self.language]: + res = f"{self._messages[self.language][key]}{sep}{data}" # put it back together + + return res + + else: + return msg + + except KeyError: + return msg diff --git a/src/deepfacility/lang/translator_i18n.py b/src/deepfacility/lang/translator_i18n.py new file mode 100644 index 0000000..a7e7e53 --- /dev/null +++ b/src/deepfacility/lang/translator_i18n.py @@ -0,0 +1,40 @@ +from deepfacility.lang.translator_default import DefaultTranslator +from transformers import AutoTokenizer, AutoModelForSeq2SeqLM, pipeline + + +class TranslatorI18N(DefaultTranslator): + """i18n NLLB translator implementation.""" + tokenizer = None + model = None + model_name = None + + _supported_lang = { + "en": "eng_Latn", + "fr": "fra_Latn" + } + + def set_language(self, language: str): + """Set the current language.""" + super().set_language(language=language) + self.model_name = f"facebook/nllb-200-distilled-600M" + self.model = AutoModelForSeq2SeqLM.from_pretrained(self.model_name) + self.tokenizer = AutoTokenizer.from_pretrained(self.model_name) + + def translate(self, msg: str): + """Translate a message to the current language.""" + if self.language not in self._supported_lang.keys(): + return msg + + default_translated_msg = super().translate(msg) + if msg == default_translated_msg and self.language != "en": + translator = pipeline('translation', + model=self.model, + tokenizer=self.tokenizer, + src_lang=self._supported_lang["en"], + tgt_lang=self._supported_lang[self.language], + max_length=400) + output = translator(msg) + translated_msg = output[0]['translation_text'] + return translated_msg + else: + return default_translated_msg diff --git a/src/deepfacility/lang/translator_i18n_nlp.py b/src/deepfacility/lang/translator_i18n_nlp.py new file mode 100644 index 0000000..a48c697 --- /dev/null +++ b/src/deepfacility/lang/translator_i18n_nlp.py @@ -0,0 +1,35 @@ +from deepfacility.lang.translator_default import DefaultTranslator +from transformers import AutoTokenizer, AutoModelForSeq2SeqLM, pipeline + + +class TranslatorI18N_NLP(DefaultTranslator): + """i18n NLP translator implementation.""" + tokenizer = None + model = None + model_name = None + + _supported_lang = { + "fr": "fra_Latn" + } + + def set_language(self, language: str): + """Set the current language.""" + super().set_language(language=language) + self.model_name = f"Helsinki-NLP/opus-mt-en-fr" + self.model = AutoModelForSeq2SeqLM.from_pretrained(self.model_name) + self.tokenizer = AutoTokenizer.from_pretrained(self.model_name) + + def translate(self, msg: str): + """Translate a message to the current language.""" + if self.language not in self._supported_lang.keys(): + return msg + + default_translated_msg = super().translate(msg) + if msg == default_translated_msg and self.language != "en": + batch = self.tokenizer([msg], return_tensors="pt") + + generated_ids = self.model.generate(**batch) + translated_msg = self.tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0] + return translated_msg + else: + return default_translated_msg \ No newline at end of file diff --git a/src/deepfacility/main.py b/src/deepfacility/main.py new file mode 100644 index 0000000..6f21f79 --- /dev/null +++ b/src/deepfacility/main.py @@ -0,0 +1,106 @@ +import argparse +import os + +import time +import sys +import shutil + +from pathlib import Path + +from deepfacility.utils import commands, util +from deepfacility.viz import visualize + +from deepfacility.config.config import RuntimeArgs, Config, get_supported_countries, create_config_file + + +def parse_args() -> argparse.Namespace: + example = "deepfacility run" + parser = argparse.ArgumentParser(example) + # Commands + available_commands = ['countries', 'config', 'prep', 'locations', 'run', 'viewmap', 'ux', 'reset'] + parser.add_argument('command', choices=available_commands, help="Command to execute.") + # Optional arguments + parser.add_argument('-l', '--locations', dest='location_filter', nargs='+', default=[], + help="Location string or file path. If not provided, all locations are used.") + parser.add_argument('-c', '--config', dest='config_file', default=Config.default_file, + help="Config file path.") + parser.add_argument('-n', '--name', dest='run_name', default='', + help="Run name, used as a output dir suffix.") + parser.add_argument('-r', '--resultdir', dest='result_dir', default='', required='viewmap' in sys.argv, + help="Target dir where the results map files are stored.") + parser.add_argument('--sid', dest='session_id', default='', + help="Web app session ID to be used in a single-user scenario.") + args = parser.parse_args() + + return args + + +def main(): + ts0 = time.time() + args_raw: argparse.Namespace = parse_args() + runtime_args = {k: v for k, v in args_raw.__dict__.items() if k in RuntimeArgs().__dict__} + args: RuntimeArgs = RuntimeArgs(**runtime_args) + if args.command == "reset": + # Remove memory cache dir + shutil.rmtree(util.memory_cache_dir(), ignore_errors=True) + exit(0) + + elif args.command == "ux": + # Start demo web app + os.environ['DEEPFACILITY_ROOT_DIR'] = str(util.app_dir()) + if args_raw.session_id: + os.environ['DEEPFACILITY_SID'] = args_raw.session_id + os.chdir(Path(__file__).parent.joinpath('ux')) + import deepfacility.ux.main as ux_main + ux_main.main() + exit(0) + + elif args.command == "config": + # Create a config file from the user template file + create_config_file(args.config_file) + exit(0) + + # Create a config instance based on input arguments + cfg: Config = Config.create_instance(run_args=args) + + match args.command: + case "prep": + # Run the data preparation command. + commands.cmd_prep(cfg) + case "countries": + # List supported countries, which can be set in the config file. + cfg.inputs.logger.info("Supported countries (you can set in config):") + cfg.inputs.logger.info('\n'.join(get_supported_countries())) + + case "viewmap": + # Create an interactive visualization map from the results. + cfg.results.logger.info("Creating leaflet map:") + visualize.Visualizer(cfg=cfg).create_leaflet_map(result_dir=Path(args.result_dir)) + + case "locations": + # List locations, which can be used in the scientific workflow. + locations_str = commands.get_locations_str(cfg) + if locations_str: + commands.log_command_args(cfg, command='locations', locations_txt=locations_str, show_locations=True) + else: + cfg.results.logger.warning("Make sure to prepare input files using the 'prep' command.") + exit(0) + + case "run": + if not cfg.has_locations: + cfg.results.logger.warning("No locations found.") + exit(0) + + # Run the scientific workflow. + done = commands.cmd_run(cfg) + if not done: + exit(0) + case _: + raise ValueError("Unsupported command.") + + cfg.results.logger.info(f"Finished processing in: {util.elapsed_time_str(ts0)}") + exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/deepfacility/tasks/__init__.py b/src/deepfacility/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/deepfacility/tasks/clustering.py b/src/deepfacility/tasks/clustering.py new file mode 100644 index 0000000..c88ccc6 --- /dev/null +++ b/src/deepfacility/tasks/clustering.py @@ -0,0 +1,239 @@ +import pandas as pd +import warnings + +warnings.simplefilter(action='ignore', category=FutureWarning) + +from deepfacility.config.config import Config, AdmPointsFile, ResultsClusteredHouseholds +from deepfacility.utils import util, spatial + + +# Initialize data cache +memory = util.memory_cache() + + +class ClusteredHouseholds: + """ + Facilitates household clustering for a location. + Encapsulate clustered households and village centers data. + """ + cfg: Config + location: str + df_households: pd.DataFrame + df_village_centers: pd.DataFrame + + def __init__(self, cfg: Config, location: str, df_households: pd.DataFrame, df_village_centers: pd.DataFrame): + self.cfg: Config = cfg # configuration instance + self.location: str = location # location name + + self._valid: bool = True # data is valid + self._converged: bool = False # clustering has converged + self._df_counts: pd.DataFrame = None # cluster counts statistics dataframe + self._df_clusters: pd.DataFrame = df_households.copy() # clustered households dataframe + self._df_centers: pd.DataFrame = df_village_centers.copy() # village centers dataframe + + # aliases for households and village centers config section + hh = self.cfg.inputs.households + vc = self.cfg.inputs.village_centers + + # rename village centers xy columns to match households + if vc.xy_cols != hh.xy_cols: + self._df_centers = util.rename_df_cols(self._df_centers, [vc.xy_cols, 'index'], hh.xy_cols) + + # add cluster column to centers if not present + if self.cluster_col not in list(self._df_centers.columns): + self._df_centers[self.cluster_col] = pd.Series(self._df_centers.index) + + @property + def center_xy_cols(self): + """Return the cluster center xy columns.""" + return [f"{self.cluster_col}_lon", f"{self.cluster_col}_lat"] + + @property + def cluster_col(self): + """Return the cluster column name.""" + return self.cfg.results.clusters.data_cols[0] + + @property + def clusters_file(self): + """Return the clusters file path.""" + file_pattern = self.cfg.results.clusters.file + return spatial.location_path(file_pattern, self.location) + + @property + def centers_file(self): + """Return the cluster centers file path.""" + file_pattern = self.cfg.results.clusters.centers_file + return spatial.location_path(file_pattern, self.location) + + @property + def counts_file(self): + """Return the cluster counts file path.""" + file_pattern = self.cfg.results.clusters.counts_file + return spatial.location_path(file_pattern, self.location) + + @property + def converged(self) -> bool: + """Return True if clustering has converged.""" + return self._converged + + @property + def valid(self) -> bool: + """Return True if data is valid.""" + ok = self._converged is not None and self._valid + ok &= self._df_clusters is not None and len(self._df_clusters) > 0 + ok &= self._df_centers is not None and len(self._df_centers) > 0 + return ok + + @valid.setter + def valid(self, value): + """Set the data validity flag.""" + self._valid = value + + @property + def centers_df(self) -> pd.DataFrame: + """Return the village centers dataframe.""" + return self._df_centers + + @centers_df.setter + def centers_df(self, value): + """Set the village centers dataframe.""" + self._df_centers = value + + @property + def clusters_df(self): + """Return the clustered households dataframe.""" + return self._df_clusters + + @clusters_df.setter + def clusters_df(self, value): + """Set the clustered households dataframe.""" + self._df_clusters = value + + def _prep_centers(self) -> None: + """Prepare the village centers dataframe.""" + vc: AdmPointsFile = self.cfg.inputs.village_centers + cc_cols = [self.cluster_col] + vc.adm_cols + vc.xy_cols + self.center_xy_cols + self._df_centers = self._df_centers[cc_cols] + + def _prep_clusters(self): + """Prepare the clustered households dataframe.""" + vc: AdmPointsFile = self.cfg.inputs.village_centers + cs: ResultsClusteredHouseholds = self.cfg.results.clusters + + name_col = vc.adm_cols[-1] + if self._converged: + # join by cluster to match the village name + df_cc = self._df_centers[[self.cluster_col, name_col]] + self._df_clusters = self._df_clusters.merge(df_cc, on=self.cluster_col) + else: + self._df_clusters[name_col] = self._df_clusters[self.cluster_col] + + # finalize clusters dataframe + sel_cols = cs.adm_cols + [self.cluster_col] + cs.xy_cols + sort_cols = [self.cluster_col] + cs.adm_cols + cs.xy_cols + self._df_clusters = self._df_clusters[sel_cols].sort_values(sort_cols) + + def _calc_counts(self): + """Calculate cluster counts and small clusters.""" + cs: ResultsClusteredHouseholds = self.cfg.results.clusters + cols = cs.adm_cols + cs.data_cols + # Count households in each cluster + self._df_counts = self._df_clusters.groupby(by=cols).agg(counts=(cs.xy_cols[0], 'count')) + # Set small clusters flag + self._df_counts['small'] = self._df_counts.counts < self.cfg.args.threshold_households + + def finalize(self, converged: bool): + """Finalize the clustering process. Prepare the dataframes and calculate cluster counts.""" + self._converged = converged + self._prep_centers() + self._prep_clusters() + self._calc_counts() + return True + + def save(self) -> object: + """Save the clustered households and village centers data to files.""" + if self.valid: + self._df_clusters.to_csv(self.clusters_file, index=False, encoding='utf-8') + self._df_centers.to_csv(self.centers_file, index=False, encoding='utf-8') + self._df_counts.reset_index().to_csv(self.counts_file, index=False, encoding='utf-8') + else: + raise ValueError(f"Invalid data for '{self.location}'") + return self + + +def cluster_houses_by_villages_centers(cfg: Config, + df_households: pd.DataFrame, + df_villages_centers: pd.DataFrame, + location: str) -> ClusteredHouseholds: + """Script to cluster households initialized at given village centers via K-means""" + ok = True + if df_households is None or len(df_households) == 0: + cfg.results.logger.warning(f"No household data for: {location}") + ok = False + + if df_villages_centers is None or len(df_villages_centers) == 0: + cfg.results.logger.warning(f"No villages centers data for: {location}") + ok = False + + hh: AdmPointsFile = cfg.inputs.households + ch = ClusteredHouseholds(cfg=cfg, + location=location, + df_households=df_households, + df_village_centers=df_villages_centers) + + if not ok: + cfg.results.logger.info(f"Skipping clustering for: {location}.") + return ch + + try: + # Cluster households by village centers in parallel + converged = cluster_points(df_points=ch.clusters_df, + df_centers=ch.centers_df, + xy_cols=hh.xy_cols, + cluster_col=ch.cluster_col, + center_xy_cols=ch.center_xy_cols) + except ValueError as ex: + cfg.results.logger.error(f"Failed to cluster households:") + for m in ex.args: + cfg.results.logger.error(f"{location}: {m}") + + ch.valid = False + return ch + + # Finalize dataframes + ch.finalize(converged) + + if not converged: + cfg.results.logger.warning(f"Clustering has not converged for: '{location}'") + + return ch + + +def cluster_points(df_points: pd.DataFrame, + df_centers: pd.DataFrame, + xy_cols: list[str], + cluster_col: str = None, + center_xy_cols: list[str] = None) -> bool: + """Cluster points by centers using K-means algorithm.""" + # Determine cluster column and center xy columns + cluster_col = cluster_col or 'cluster' + center_xy_cols = center_xy_cols or [f"{cluster_col}_lon", f"{cluster_col}_lat"] + + # Prepare data for KMeans clustering + points = df_points[xy_cols].to_numpy() + centers = df_centers[xy_cols].to_numpy() + n_clusters = len(centers) + + # Perform KMeans clustering + kmeans_model = spatial.kmeans_fit(X=points, n_clusters=n_clusters, init=centers, n_init=1) + + # Capture cluster assignments and cluster centers + df_points[cluster_col] = kmeans_model.labels_ + df_centers[center_xy_cols[0]], df_centers[center_xy_cols[1]] = kmeans_model.cluster_centers_[:, 0], kmeans_model.cluster_centers_[:, 1] + + # Check if clustering has converged + converged = kmeans_model.n_iter_ < kmeans_model.max_iter + return converged + + + diff --git a/src/deepfacility/tasks/distance.py b/src/deepfacility/tasks/distance.py new file mode 100644 index 0000000..8170b60 --- /dev/null +++ b/src/deepfacility/tasks/distance.py @@ -0,0 +1,303 @@ +import geopandas as gpd +import pandas as pd +import matplotlib +matplotlib.use('agg') +import matplotlib.pyplot as plt +import numpy as np + +from scipy.spatial import distance + +from pathlib import Path + +from deepfacility.config.config import Config +from deepfacility.utils import spatial, util + +if util.is_linux(): + lock = None +else: + import threading + lock = threading.Lock() + + +# Initialize data cache +memory = util.memory_cache() + + +def convert_to_cartesian(lon, lat, elevation=0): + """ + Convert longitude, latitude, and elevation to Cartesian coordinates. + :param lon: Longitude in degrees (float). + :param lat: Latitude in degrees (float). + :param elevation: Elevation in meters (float, optional). Default is 0. + :return: tuple: Cartesian coordinates (x, y, z). + """ + # Radius of the Earth in meters + earth_radius = 6378137.0 # unit: meter + + # Convert latitude and longitude from degrees to radians + lat = np.radians(lat) # divide by 180 and multiply by pi + lon = np.radians(lon) + + # Convert using law of cosines + R = (earth_radius + elevation) + + # Calculate Cartesian coordinates + x = R * np.cos(lat) * np.cos(lon) + y = R * np.cos(lat) * np.sin(lon) + z = R * np.sin(lat) + + return x, y, z + + +def find_nearest_facility(location_xy: np.ndarray, facility_xy: np.ndarray): + """ + Find the nearest facility for each location. + :param location_xy: numpy.ndarray. Array of location coordinates. + :param facility_xy: numpy.ndarray. Array of facility coordinates. + :returns: tuple. A tuple containing two lists: + - List of indices of the nearest facility for each location. + - List of shortest distances from each location to its nearest facility. + """ + # Calculate pairwise Euclidean distances using cdist + distances = distance.cdist(location_xy, facility_xy, metric='euclidean') + + # Find the nearest facility for each household + nearest_facility_indices = distances.argmin(axis=1) + + return nearest_facility_indices, distances.min(axis=1) + + +def calculate_minkowski_from_cartesian(df_locations: pd.DataFrame, + df_facilities: pd.DataFrame, + left_on: str, + right_on: str, + p: float = 1.54, + distance_col: str = 'minkowski'): + """ + Calculate the Minkowski distance between location and facility coordinates. Both data must have x, y, z columns. + :param df_locations: pd.DataFrame. DataFrame containing location coordinates. + :param df_facilities: pd.DataFrame. DataFrame containing facility coordinates. + :param left_on: str: Column name in df_locations to join on. + :param right_on: str: Column name in df_facility to join on. + :param p: float, optional: Minkowski distance parameter. Default is 1.54. + :param distance_col: str: The name of the column where the Minkowski distance will be stored. Default is 'minkowski'. + :returns: pd.DataFrame: DataFrame with Minkowski distances. + """ + suffixes = ('_loc', '_facility') + df_merged = pd.merge(df_locations, df_facilities, left_on=left_on, right_on=right_on, suffixes=suffixes) + # Calculate the Minkowski distance using x, y, z coordinates + df_merged[distance_col] = df_merged.apply( + lambda row: distance.minkowski( + [row['x_loc'], row['y_loc'], row['z_loc']], + [row['x_facility'], row['y_facility'], row['z_facility']], + p=p), axis=1) + + df_merged.columns = [col.rstrip('_loc') if col.endswith('_loc') else col for col in df_merged.columns] + cols_to_keep = list(df_locations.columns) + [distance_col] + merged_df = df_merged[cols_to_keep] + return merged_df + + +def calculate_distance_df(df: pd.DataFrame, + xy_cols: list[str], + df2: pd.DataFrame, + xy_cols2: list[str], + col_prefix: str, + id_col: str = 'facility_id') -> pd.DataFrame: + """ + Calculate the distance between each point in two dataframes. + :param df: pd.DataFrame: The DataFrame containing the points. + :param xy_cols: list[str]: The column names in `df` representing the lon and lat coordinates. + :param df2: pd.DataFrame: The DataFrame containing the facility locations. + :param xy_cols2: list[str]: The column names in `df2` representing the lon and lat coordinates. + :param col_prefix: str: The prefix for the distance column names in the output DataFrame, which will be + {prefix}_minkowski and {prefix}_euclidean. + :param id_col: Facility ID column name. + :returns: pd.DataFrame: The input DataFrame with additional columns for the assigned facility and the distances. + """ + # Calculate the cartesian coordinates for the points and the facilities + if len(df) == 0 or len(df2) == 0: + return df + + xy = convert_to_cartesian(df[xy_cols[0]].values, df[xy_cols[1]].values) + df['x'] = xy[0] + df['y'] = xy[1] + df['z'] = xy[2] + # df[['x', 'y', 'z']] = df.apply( + # lambda row: pd.Series(convert_to_cartesian(row[df_xy[0]], row[df_xy[1]])), axis=1) + xy2 = convert_to_cartesian(df2[xy_cols2[0]].values, df2[xy_cols2[1]].values) + df2['x'] = xy2[0] + df2['y'] = xy2[1] + df2['z'] = xy2[2] + + # get Series values as array for pair-wise distance calculation + xy_ser = df[['x', 'y', 'z']].values + xy_ser2 = df2[['x', 'y', 'z']].values + + nearest_indices, distances = find_nearest_facility(xy_ser, xy_ser2) + + # assign facilities id to household for easier calculation + nearest_ids = df2[id_col][nearest_indices] + assigned_id_col = f'{col_prefix}_assigned_id' + df[assigned_id_col] = nearest_ids.values + df[f'{col_prefix}_euclidean'] = distances + + df = calculate_minkowski_from_cartesian(df_locations=df, + df_facilities=df2, + left_on=assigned_id_col, + right_on=id_col, + p=1.54, + distance_col=f'{col_prefix}_minkowski') + + df = df.copy().drop(['x', 'y', 'z'], axis=1) + + return df + + +@memory.cache +def calculate_distance(cfg: Config, + df_clusters: pd.DataFrame, + df_centers: pd.DataFrame, + center_xy_cols: list[str], + df_facilities: pd.DataFrame, + gdf_shp: gpd.GeoDataFrame = None) -> tuple[pd.DataFrame, pd.DataFrame]: + """ + Calculate the distance between the facility and households/centroids + :param cfg: Config. The configuration object. + :param df_clusters: pd.DataFrame. The DataFrame containing the clustered households. + :param df_centers: pd.DataFrame. The DataFrame containing the cluster centroids. + :param center_xy_cols: list[str]. The column names in `df_centers` representing the lon and lat coordinates. + :param df_facilities: pd.DataFrame. The DataFrame containing the facility locations. + :param gdf_shp: gpd.GeoDataFrame, optional. The GeoDataFrame containing the shapefile data. Default is None. + :returns: tuple[pd.DataFrame, pd.DataFrame]. The DataFrames containing the distances between the df_facilities + and the households/centroids. + """ + res = cfg.results + + df_clusters_in = df_clusters.reset_index(drop=True) + df_centers_in = df_centers.reset_index(drop=True) + + if len(df_clusters_in) > 0 and len(df_facilities) > 0: + # calculate distance between households and df_facilities + df_clusters = calculate_distance_df(df=df_clusters_in, + xy_cols=res.clusters.xy_cols, + df2=df_facilities, + xy_cols2=res.facilities.xy_cols, + col_prefix='hh') + + # calculate distance between cluster centroids and df_facilities + df_centers = calculate_distance_df(df=df_centers_in, + xy_cols=center_xy_cols, + df2=df_facilities, + xy_cols2=res.facilities.xy_cols, + col_prefix='village') + else: + df_clusters = df_clusters_in + df_centers = df_centers_in + cfg.results.logger.warning(f"No data for distance computation, skipped for optimal dataframes!") + + # calculate baseline distances if needed (baseline is provided) + if gdf_shp is not None and cfg.inputs.has_baseline(): + # Load baseline facilities, covert to GeoDataFrame and join with shapefile + df_pts = pd.read_csv(cfg.inputs.baseline_facilities.file, encoding='utf-8') + gdf_loc = spatial.join_xy_shapes(df_pts, cfg.inputs.baseline_facilities.xy_cols, gdf_shp) + + if len(df_clusters) > 0 and len(gdf_loc) > 0: + # calculate distances + gdf_loc.drop(columns=['geometry'], inplace=True) + gdf_loc.reset_index(drop=True, inplace=True) + + # calculate distance between households and baseline df_facilities + df_clusters = calculate_distance_df(df=df_clusters, + xy_cols=res.clusters.xy_cols, + df2=pd.DataFrame(gdf_loc), + xy_cols2=cfg.inputs.baseline_facilities.xy_cols, + col_prefix='baseline_hh') + + # calculate distance between cluster centroids and baseline df_facilities + df_centers = calculate_distance_df(df=df_centers, + xy_cols=center_xy_cols, + df2=pd.DataFrame(gdf_loc), + xy_cols2=cfg.inputs.baseline_facilities.xy_cols, + col_prefix='baseline_village') + else: + cfg.results.logger.warning(f"Unable to find any baseline locations within adm2 boundary!: " + f"{df_centers.head(1)[cfg.results.clusters.adm_cols].values}") + + return df_clusters, df_centers + + +def plot_ecdf_distance(cfg: Config, + df: pd.DataFrame, + distance_col: str = 'minkowski', + scale: float = 1e-3, + location: str = "", + filename: Path = None, + plot_properties=None) -> bool | None: + """ + Plots the empirical cumulative distribution function (ECDF) of the given distance data. + :param cfg: Config: The configuration object. + :param df: pd.DataFrame: The input dataframe containing the distance columns. + :param distance_col: str, optional. The name of the column in df that contains the distance values. Default is 'minkowski'. + :param scale: float, optional: The scaling factor to apply to the distance values. Default is 1e-3. assuming unit is kilometer + :param location: str, optional: The location information to include in the plot title. Default is an empty string. + :param filename: Path, optional: The file path to save the plot as an image. If not provided, the plot will not be saved. + :param plot_properties: dict, optional: The dictionary of keyword arguments (kwargs) to be used in plot function. + :returns: None + """ + if distance_col not in df.columns: + cfg.results.logger.warning(f"Column does not exist in dataframe, unable to plot ECDF, skipped: {distance_col}") + return None + + minkowski_distance = (df[distance_col] * scale).sort_values() + default_properties = { + 'color': 'blue', # Default color + 'linestyle': 'solid', # Default line style + 'marker': '.' + } + + if plot_properties is not None: + final_properties = {**default_properties, **plot_properties} + else: + final_properties = default_properties + + # Calculate the ECDF values + ecdf = np.arange(1, len(minkowski_distance) + 1) / len(minkowski_distance) * 100.0 + + plot_args = (minkowski_distance, ecdf, location, filename, final_properties) + + if lock: + with lock: + res = plot_minkowski_distance(*plot_args) + else: + res = plot_minkowski_distance(*plot_args) + + return res + + +def plot_minkowski_distance(minkowski_distance: np.ndarray, + ecdf: np.ndarray, + location: str, + filename: Path = None, + final_properties=None) -> bool: + """ + Plot the Minkowski distance and ECDF. + :param minkowski_distance: np.ndarray. The Minkowski distance values. + :param ecdf: np.ndarray: The ECDF values. + :param location: str: The location information to include in the plot title. + :param filename: Path, optional. The file path to save the plot as an image. If not provided, the plot will not be saved. + :param final_properties: dict, optional. The dictionary of keyword arguments (kwargs) to be used in plot function. + :returns: bool: True if the plot is saved successfully, False otherwise. + """ + final_properties = final_properties or {} + # Plotting the ECDF + plt.plot(minkowski_distance, ecdf, **final_properties) + plt.xlabel('Minkowski Distance (KM)') + plt.ylabel('Cumulative distribution %') + plt.title(f'Cumulative Distribution of Minkowski Distance {location}') + plt.grid(True) + if filename is not None: + Path(filename).parent.mkdir(exist_ok=True, parents=True) + plt.savefig(filename) + plt.close() + return True diff --git a/src/deepfacility/tasks/outlines.py b/src/deepfacility/tasks/outlines.py new file mode 100644 index 0000000..96b2d8b --- /dev/null +++ b/src/deepfacility/tasks/outlines.py @@ -0,0 +1,117 @@ +import geopandas as gpd +import pandas as pd +import matplotlib +matplotlib.use('agg') +import warnings + +warnings.simplefilter(action='ignore', category=FutureWarning) + +from pathlib import Path +from shapely import Polygon + +from deepfacility.utils import util, spatial + +from deepfacility.utils.spatial import location_path, geom_col +from deepfacility.config.config import Config, ResultsClusteredHouseholds, Results, ResultFiles, ResultData + + +def create_clusters_shapes(cfg: Config, + gdf_adm_shape: gpd.GeoDataFrame, + df_clusters: pd.DataFrame, + location: str,) -> gpd.GeoDataFrame: + """ + Create cluster shapes from cluster centers. + :param cfg: configuration + :param gdf_adm_shape: admin shapefile + :param df_clusters: cluster centers + :param location: location name + :return: cluster shapes + """ + res: ResultsClusteredHouseholds = cfg.results.clusters + + cols = res.adm_cols + [res.data_cols[0]] # admin and cluster columns + gdf = spatial.xy_to_gdf(df_clusters, res.xy_cols) # convert to GeoDataFrame + gdf = gdf[cols + [geom_col]] # select columns + gdf = gdf.dissolve(by=cols).reset_index() # dissolve clusters to create shapes + + # create cluster shapes using convex hull + gdf[geom_col] = gdf.geometry.apply(spatial.point_to_polygon) + gdf[geom_col] = gdf.geometry.convex_hull + + # Ensure village shapes are only within admin boundaries. + gdf = gpd.clip(gdf, gdf_adm_shape) + + # join household counts + cluster_col = res.data_cols[0] + counts_col = 'counts' + counts_file = location_path(res.counts_file, location, mkdir=False) + df_cnt = pd.read_csv(counts_file, encoding='utf-8')[[cluster_col, counts_col]] + gdf = gdf.merge(df_cnt, on=cluster_col) + gdf = gdf[cols + [counts_col, geom_col]] + gdf = util.rename_df_cols(gdf, 'counts', 'households') + return gdf + + +def export_cluster_shapes(cluster_shapes: gpd.GeoDataFrame, shape_file: Path) -> gpd.GeoDataFrame: + """ + Export cluster shapes to shapefile. + :param cluster_shapes: cluster shapes + :param shape_file: output shape file + :return: shapefile path + """ + gdf = cluster_shapes[[isinstance(g, Polygon) for g in cluster_shapes.geometry]] + gdf.to_file(filename=shape_file, driver="ESRI Shapefile") + return gdf + + +def merge_results(cfg: Config, results: dict[str, ResultFiles]) -> ResultFiles: + """ + Merge results from multiple locations. + :param cfg: configuration + :param results: results from multiple locations + :return: merged results + """ + # merge results + res: Results = cfg.results + rd: ResultData = merge_result_data(results) + + # save final results + rf = ResultFiles( + location_path(pattern=res.shapes.file, location=""), + location_path(pattern=res.clusters.file, location=""), + location_path(pattern=res.clusters.centers_file, location=""), + location_path(pattern=res.clusters.counts_file, location=""), + location_path(pattern=res.facilities.file, location="")) + + rd.save(rf) + + return rf + + +def merge_result_data(results: dict[str, ResultFiles]) -> ResultData: + """ + Merge results data from multiple locations. + :param results: results from multiple locations + :return: merged results data + """ + # Concatenate dataframes + gdf_shapes: gpd.GeoDataFrame = gpd.GeoDataFrame(pd.concat([gpd.read_file(rf.shape_file) for rf in results.values()])) + df_clusters: pd.DataFrame = pd.concat([pd.read_csv(rf.clusters_file, encoding='utf-8') for rf in results.values()]) + df_centers: pd.DataFrame = pd.concat([pd.read_csv(rf.centers_file, encoding='utf-8') for rf in results.values()]) + df_counts: pd.DataFrame = pd.concat([pd.read_csv(rf.counts_file, encoding='utf-8') for rf in results.values()]) + df_facilities: pd.DataFrame = pd.concat([pd.read_csv(rf.facilities_file, encoding='utf-8') for rf in results.values()]) + + # Sort dataframes + gdf_shapes.sort_values(by=gdf_shapes.columns.to_list(), inplace=True) + df_clusters.sort_values(by=df_clusters.columns.to_list(), inplace=True) + df_centers.sort_values(by=df_centers.columns.to_list(), inplace=True) + df_counts.sort_values(by=df_counts.columns.to_list(), inplace=True) + df_facilities.sort_values(by=df_facilities.columns.to_list(), inplace=True) + + # Encapsulate results data + rd = ResultData(gdf_shapes=gdf_shapes, + df_clusters=df_clusters, + df_centers=df_centers, + df_counts=df_counts, + df_facilities=df_facilities) + return rd diff --git a/src/deepfacility/tasks/placement.py b/src/deepfacility/tasks/placement.py new file mode 100644 index 0000000..7727c31 --- /dev/null +++ b/src/deepfacility/tasks/placement.py @@ -0,0 +1,77 @@ +import geopandas as gpd +import numpy as np +import pandas as pd +import matplotlib + +matplotlib.use('agg') +import warnings +warnings.simplefilter(action='ignore', category=FutureWarning) + +from pathlib import Path + +from deepfacility.config.config import Config +from deepfacility.utils import spatial, util + + +# Initialize data cache +memory = util.memory_cache() + + +@memory.cache +def place_facilities(cfg: Config, df_clusters: pd.DataFrame, location: str) -> pd.DataFrame: + """ + Recommend health facility placements by placing a specified number of + points to be optimally distant from village households. + :param cfg: configuration object + :param df_clusters: cluster centers + :param location: location name + :return: facilities dataframe + """ + # Get column names + res = cfg.results + cluster_col = res.clusters.data_cols[0] + village_col = res.facilities.data_cols[0] + adm_cols = res.clusters.adm_cols + xy_cols = res.clusters.xy_cols + clusters_cols = adm_cols + [res.clusters.data_cols[0]] + + # Initialize village names from cluster ids + df_clusters[village_col] = df_clusters[cluster_col] + + # Group households by cluster (village) process each cluster + optimal_facilities = [] + for i, dat in df_clusters.groupby(clusters_cols): + # Prepare data for clustering + x = np.array(dat[xy_cols[0]]) + y = np.array(dat[xy_cols[1]]) + X = np.array(list(zip(x, y))).reshape(len(x), 2) + + # Cluster points if possible + if X.shape[0] >= 3: + kmeans_model = spatial.kmeans_fit(X, res.facilities.n_facilities) + centers = np.array(kmeans_model.cluster_centers_) + if kmeans_model.n_iter_ == kmeans_model.max_iter: + cfg.results.logger.warning(f"Clustering facilities didn't converge for: {location}") + else: + centers = X + + # Create a dataframe of optimal facilities + df = pd.DataFrame(centers, columns=res.clusters.xy_cols) + df[village_col] = i[-1] # use cluster label as a village name + + # Add admin names + adm_vals = list(i[:-1]) # admin values + for col, name in zip(adm_cols, adm_vals): + df[col] = name # fill in adm col values + + # Append to list + optimal_facilities.append(df) + + # Merge optimal placements all clusters, add Google plus codes and id column + df_of: pd.DataFrame = pd.concat(optimal_facilities)[adm_cols + [village_col] + xy_cols].copy() + df_of["plus"] = df_of.apply(lambda r: spatial.get_plus_code(r.lon, r.lat), axis=1) + unique_ids = [f"{location}_{i}" for i in range(len(df_of))] + df_of['facility_id'] = unique_ids + df_of.reset_index(drop=True, inplace=True) + return df_of + diff --git a/src/deepfacility/utils/__init__.py b/src/deepfacility/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/deepfacility/utils/commands.py b/src/deepfacility/utils/commands.py new file mode 100644 index 0000000..7097387 --- /dev/null +++ b/src/deepfacility/utils/commands.py @@ -0,0 +1,85 @@ +import shutil + +from deepfacility.config.config import Config +from deepfacility.flows import DataPrepWorkflow, ScientificWorkflow +from deepfacility.utils import util + + +def cmd_prep(cfg: Config) -> None: + adm_files, hh_file, vc_file, bl_file, ok = DataPrepWorkflow(cfg=cfg).prepare_inputs(cfg.args.country) + if ok: + cfg.inputs.logger.info("Prepared input files:") + cfg.inputs.logger.info("\n".join([str(f) for f in adm_files] + [str(hh_file), str(vc_file), str(bl_file)])) + + +def cmd_run(cfg: Config, cli: bool = True) -> bool: + """Run the scientific workflow.""" + # Cleanup previous run + shutil.rmtree(cfg.results.dir, ignore_errors=True) + + # Get the locations string + locations_txt = get_locations_str(cfg) + if not locations_txt: + return False + + if cli: + # Log the command arguments + log_command_args(cfg, "run", locations_txt) + + # Save the locations file + util.make_dir(cfg.results.locations_file) + cfg.results.locations_file.write_text(locations_txt) + + # Run the workflow + rs, failed = ScientificWorkflow(cfg=cfg).process_locations() + + # Log the results + if rs: + cfg.results.logger.info("Merged files:") + cfg.results.logger.info(f" village shapes: {rs.shape_file}") + cfg.results.logger.info(f" cluster households: {rs.clusters_file}") + cfg.results.logger.info(f" facility placement: {rs.facilities_file}") + else: + cfg.results.logger.info("No results found.") + + if failed: + cfg.results.logger.info() + cfg.results.logger.info("Failed locations:") + for ff in failed: + cfg.results.logger.info(f" {ff}") + + # logger.handlers.clear() + + return True + + +def get_locations_str(cfg: Config) -> str: + """Get locations string for logging.""" + # Prepare the string containing the list of locations + if cfg.has_locations: + locations_txt = "\n".join(cfg.locations) + else: + locations_txt = "" + loc = f" for filter(s) {','.join(cfg.locations)}" if cfg.locations else "" + cfg.inputs.logger.warning(f"No locations found: {loc}") + + return locations_txt + + +def log_command_args(cfg: Config, command: str, + locations_txt, + show_locations: bool = False) -> None: + """Log the command arguments.""" + logger = cfg.results.logger + # Log the command and its arguments + logger.info("---") + logger.info(f"Command: {command}") + logger.info("Options:") + logger.info(f" run: {cfg.run_name}") + logger.info(f" config: {cfg.config_file}") + logger.info(f" results: {str(cfg.results.locations_file.parent)}") + logger.info(f" locations: {' '.join(cfg.location_filter) or 'all'}") + if show_locations or cfg.location_filter: + logger.info(f"---\n{locations_txt}") + if locations_txt: + logger.info("---") diff --git a/src/deepfacility/utils/spatial.py b/src/deepfacility/utils/spatial.py new file mode 100644 index 0000000..8f634e0 --- /dev/null +++ b/src/deepfacility/utils/spatial.py @@ -0,0 +1,225 @@ +import geopandas as gpd +import pandas as pd +import pycountry + +from pathlib import Path +from pyproj import CRS +from shapely import Geometry, Polygon + +from deepfacility.utils import util +from typing import Any +from sklearn.cluster import KMeans + +# Frequently used spatial global variables +geom_col = 'geometry' +default_crs: CRS = CRS("EPSG:4326") +default_projected_crs: CRS = CRS("EPSG:3857") + + +# Initialize data cache +memory = util.memory_cache() + + +def location_parts(location: str): + """Split location string into parts.""" + return location.strip().split(":") + + +def locations_to_dataframe(locations: list[str], columns: list[str]): + """ + Convert locations to DataFrame. + :param locations: locations + :param columns: columns + :return: DataFrame + """ + return pd.DataFrame([location_parts(c) for c in locations], columns=columns) + + +def filter_locations(df: pd.DataFrame, locations: list[str], columns: list[str]): + """ + Filter DataFrame by locations. + :param df: DataFrame + :param locations: locations + :param columns: columns + :return: filtered DataFrame + """ + # Convert locations to DataFrame + df2: pd.DataFrame = locations_to_dataframe(locations, columns=columns) + df2 = util.clean_dataframe(df2) + df2 = df2.drop_duplicates() + assert util.has_cols(df=df, columns=columns) and util.has_cols(df=df2, columns=columns) + + # Filter by joining with locations dataframe + df_res = pd.merge(df, df2, on=columns) + return df_res + + +def location_path(pattern: Path, location: str, mkdir: bool = True) -> Path: + """ + Create file path with location. + :param pattern: file path pattern + :param location: location + :param mkdir: create directory if not exists + :return: file path + """ + # Cleanup and populate file pattern with location + pattern = str(pattern) + loc_path = location.replace(":", "/") + if '{location}' in pattern: + file = str(pattern).format(location=loc_path) + else: + file = str(pattern) + + # Convert to Path + file = Path(file) + if mkdir: + util.make_dir(file) + return file + + +def point_to_polygon(g: Geometry): + """Convert point geometry to a polygon geometry.""" + return Polygon(g.buffer(0.00001, cap_style=3)) if g.type == "Point" else g + + +def xy_to_gdf(df: pd.DataFrame, xy_cols) -> gpd.GeoDataFrame: + """ + Convert coordinates DataFrame into GeoDataFrame with points. + :param df: DataFrame + :param xy_cols: columns with x, y coordinates + :return: GeoDataFrame + """ + gdf = gpd.GeoDataFrame(data=df, + geometry=gpd.points_from_xy(df[xy_cols[0]], df[xy_cols[1]]), + crs=default_crs) + gdf = gdf.dropna() + return gdf + + +@memory.cache +def join_xy_shapes(df: pd.DataFrame, xy_cols: list[str], gdf: gpd.GeoDataFrame, predicate: str = "within") -> gpd.GeoDataFrame: + """ + Join DataFrame with xy columns to GeoDataFrame. Returns filtered points as GeoDataFrame. + :param df: DataFrame + :param xy_cols: xy columns + :param gdf: GeoDataFrame + :param predicate: Spatial join predicate + """ + gdf_pts = xy_to_gdf(df, xy_cols) + return gpd.sjoin(gdf_pts, gdf, predicate=predicate) + + +@memory.cache +def join_shapes_xy(gdf: gpd.GeoDataFrame, + df: pd.DataFrame, + xy_cols: list[str], + predicate: str = "contains") -> gpd.GeoDataFrame: + """ + Join GeoDataFrame with DataFrame with xy columns. Returns filtered shapes GeoDataFrame. + :param gdf: GeoDataFrame + :param df: DataFrame + :param xy_cols: xy columns + :param predicate: Spatial join predicate + :return: GeoDataFrame with shapes + """ + gdf_pts = xy_to_gdf(df, xy_cols) + return gpd.sjoin(gdf, gdf_pts, predicate=predicate) + + +def detect_country(df, xy_cols: list[str]): + """ + Detect country from DataFrame with xy columns. + :param df: DataFrame + :param xy_cols: xy columns + :return: country name, country code + """ + + if df.empty: + raise ValueError("DataFrame is empty") + + if xy_cols[0] not in df.columns or xy_cols[1] not in df.columns: + raise KeyError("Longitude and latitude columns are not found") + + # Read GeoPandas built-in country shapes + gdf_shp = gpd.read_file(gpd.datasets.get_path("naturalearth_lowres")) + gdf_shp = gdf_shp[['name', 'iso_a3', 'gdp_md_est', 'geometry']] + + # Determine the country by spatially joining country shapes with village + # centers and taking the country containing the most village centers. + gdf = join_shapes_xy(gdf_shp, df[xy_cols], xy_cols) + gdf = gdf.groupby(['name', 'iso_a3']).size().to_frame(name='count') + gdf = gdf.sort_values(by='count', ascending=False).head(n=1) + + name, code = gdf.iloc[0].name + # Get the standardized country name and ISO code + cnt = pycountry.countries.search_fuzzy(code)[0] + assert code == cnt.alpha_3, "ISO code is not valid" + + return cnt.name, code + + +def get_plus_code(longitude: float, latitude: float) -> str: + """ + Generates Google Plus Code + :param latitude: latitude + :param longitude: longitude + :return: plus code string + """ + from openlocationcode import openlocationcode as olc + code = olc.encode(latitude, longitude) + return code + + +def create_geojson(file: Path, + output_prefix: str, + working_dir: Path, + lon: str = None, + lat: str = None, + rename_geocol: bool = False) -> Path: + """ + Create GeoJSON file from CSV or SHP file. + :param file: input file + :param output_prefix: output file prefix + :param working_dir: working directory + :param lon: longitude column + :param lat: latitude column + :param rename_geocol: rename columns to "lon", "lat" + :return: GeoJSON file + """ + # Create output GeoJSON file path + geojson_filename = working_dir / (output_prefix + ".geojson") + + if file.suffix == '.csv': + # Read CSV file and convert to GeoDataFrame + assert lon is not None and lat is not None + df = pd.read_csv(file, encoding='utf-8') + gdf = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df[lon], df[lat]), crs="EPSG:4326") + elif file.suffix == '.shp': + # Read SHP file + gdf = gpd.read_file(file, crs="EPSG:4326") + else: + raise NotImplementedError(f'file extension not supported! {file.name}') + + # Rename to "lon", "lat" if rename_geocol set to True + if rename_geocol: + gdf = util.rename_df_cols(gdf, [lon, lat], ['lon', 'lat']) + + # Write to GeoJSON file + gdf.to_file(geojson_filename, driver='GeoJSON') + + return geojson_filename + + +@memory.cache +def kmeans_fit(X: Any, n_clusters: int, **kwargs) -> KMeans: + """ + Create and fit the KMeans model. + :param X: data + :param n_clusters: number of clusters + :param kwargs: additional arguments + :return: KMeans model + """ + kmeans_model: KMeans = KMeans(n_clusters=n_clusters, **kwargs) + kmeans_model.fit(X) + return kmeans_model + \ No newline at end of file diff --git a/src/deepfacility/utils/util.py b/src/deepfacility/utils/util.py new file mode 100644 index 0000000..b3e6610 --- /dev/null +++ b/src/deepfacility/utils/util.py @@ -0,0 +1,315 @@ +import hashlib +import joblib +import logging +import logging.handlers +import geopandas as gdp +import pandas as pd +import re +import requests +import shutil +import tempfile +import time +import unicodedata +import zipfile + +from pathlib import Path +from uuid import uuid4 + +from unidecode import unidecode + + +def hash_str(v: str, max_len: int = None): + """Calculate the MD5 hash of a string.""" + hsh = hashlib.md5(str(v).encode()).hexdigest() + if max_len: + hsh = hsh[:max_len] + + return hsh + + +# String helpers + +def strip_accents(text: str) -> str: + """Strip accents from input String.""" + text = text.strip() + text = unicodedata.normalize('NFD', text) + text = text.encode('ascii', 'ignore') + text = text.decode("utf-8") + text = text.replace("'", "") + return str(text) + + +def text_to_id(text: str) -> str: + """Convert input text to id.""" + text = str(text) + text = strip_accents(text) + text = re.sub('[ ]+', '_', text) + text = re.sub('[^0-9a-zA-Z_-]', '', text) + return text + + +def clean_series(series: pd.Series) -> pd.Series: + """Clean a Series.""" + # -> unidecode -> normalize -> encode -> decode -> replace + new_series = (series + .astype("str") # Ensure all entries are strings + .str.strip() # Strip leading and trailing spaces + .apply(unidecode) # Remove accents + .str.normalize('NFKD') # Normalize unicode + .str.encode('ascii', errors='ignore') # Encode to ascii + .str.decode('utf-8') # Decode to utf-8 + .str.replace("'", "")) # Remove apostrophes + # Detect if any entries are difference after cleaning + # TODO: remove below + not_na = ~pd.isna(series) + diff = series[not_na][series[not_na] != new_series[not_na]] + if len(diff) > 0: + print(f"Fixed {len(diff)} entries.") + + return new_series + + +def clean_dataframe(df: pd.DataFrame, columns: list[str] = None, keep: bool = False): + """Clean a DataFrame.""" + cols = df.columns.to_list() + if columns is None: + columns = cols.copy() + else: + columns = [c for c in columns if c in cols] + if len(columns) == 0: + return df + + for c in columns: + if keep: + cc = f"{c}_raw" + if c.isupper(): + cc = cc.upper() + + df[cc] = df[c] + cols.insert(cols.index(c), cc) + + df[c] = clean_series(df[c]) + + if keep: + df = df[cols] + + return df + + +def rename_df_cols(df: pd.DataFrame | gdp.GeoDataFrame, + from_cols: list[str] | str, + to_cols: list[str] | str = None) -> pd.DataFrame | gdp.GeoDataFrame: + """Rename columns in a DataFrame.""" + # string to list + from_cols = [from_cols] if isinstance(from_cols, str) else from_cols + to_cols = [to_cols] if isinstance(to_cols, str) else to_cols + + # validate: lists, from-columns all exist + assert isinstance(from_cols, list), "from_cols must be a list." + assert isinstance(to_cols, list), "to_cosl must be a list." + assert all([c in df.columns for c in from_cols]), f"Columns not found: {from_cols}" + + # Create dictionary of from-to columns which are not the same + cols = lists_to_dict(from_cols, to_cols) + cols = {k: v for k, v in cols.items() if k != v} + + # Drop 'to' columns already in the DataFrame to allow renaming + to_drop = [c for c in cols.values() if c in df.columns] + df.drop(columns=to_drop, inplace=True) + + # Rename columns + return df.rename(columns=cols) + + +# Download helpers + +def download_url(url: str, download_dir: Path) -> Path: + """Download a file from a URL.""" + assert len(url.strip()) > 0, "Download URL must be provided." + assert download_dir, "Download path not specified." + + # Determine the download file name and path + download_dir = Path(download_dir) + name = str(Path(url).name) + file = download_dir.joinpath(name) + + if not file.is_file(): + # Download the URL into the file if it doesn't already exist + print(f"Downloading {name} into {str(download_dir)}") + r = requests.get(url, allow_redirects=True) + if r.status_code == 200: + # If OK, write the content to the file + make_dir(file) + with open(file, "wb") as f: + f.write(r.content) + print(f"Download of {name} complete.") + else: + # If not OK, log the error + file = None + else: + print(f"Skipping download, file already exists: {name}") + + return file + + +# dict helpers + + +def lists_to_dict(list1: list, list2: list) -> dict: + """Create a dictionary with 1st list as keys and 2nd list as values.""" + return {k: v for k, v in zip(list1, list2)} + + +# DataFrame helpers + +def has_cols(df: pd.DataFrame, columns: list[str]): + """Check if all columns are in the DataFrame.""" + return all([c in df.columns.values for c in columns]) + + +# Path helpers + +def make_dir(f: Path): + """Create a directory if it does not exist""" + d = f.parent if f.suffix else f + d.mkdir(exist_ok=True, parents=True) + return d + + +def elapsed_time(start_time: float) -> dict[str, str]: + """Get the elapsed time as a dictionary.""" + ts = (time.time() - start_time) + h = ts // 3600 + m = ts % 3600 // 60 + s = ts % 3600 % 60 + rj = lambda t: str(int(t)).rjust(2, '0') + # def rj(t): + # return str(int(t)).rjust(2, '0') + + return {'h': rj(h), 'm': rj(m), 's': rj(s)} + + +def elapsed_time_str(start_time: float): + """Get the elapsed time as a formatted string.""" + dt = elapsed_time(start_time=start_time) + return "{h}h:{m}m:{s}s".format(**dt) + + +def copy_to_dir(f, d): + """Copy a file to a directory.""" + shutil.copy(f, d.joinpath(f.name)) + + +def letters(loc: str, z): + """Get the first z letters of a location""" + return ''.join([s[0:z] for s in loc.split(':')]) + + +def format_run_name(locations: list[str]) -> str: + """Format the run name from locations with 1st location as prefix.""" + assert locations, "Locations are not specified." + + # Location count and hash + n = len(locations) + hsh = f"_{hash_str(''.join(locations), max_len=7)}" + + # Get the run name from the first location and suffix + run_name = text_to_id(f"{locations[0].replace(':', '-')}_{n}{hsh}") + run_name = run_name.strip('-').strip() + + return run_name + + +def file_ready(f: Path) -> bool: + """Check if the file exists and is not empty.""" + return f.is_file() and f.stat().st_size > 0 + + +def create_zip(file_list: list[Path], zip_name: str): + """Create a zip archive from a list of files.""" + archive_path = Path(tempfile.mkdtemp()) / zip_name + with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zip_archive: + for file_path in file_list: + try: + zip_archive.write(str(file_path), arcname=file_path.name) + except FileNotFoundError: + print(f"Warning: File '{file_path}' not found. Skipping.") + + return archive_path + + +def report_progress(logger, name, items, done_perc, total_count) -> int: + """Report progress of a task.""" + # Calculate the percentage of done items + done_count = len(items) + new_done_perc = round(100 * done_count / total_count) + # Log the progress if the percentage has changed + if done_perc != new_done_perc: + done_perc = new_done_perc + logger.info(f"{name}: {done_count}/{total_count}: {done_perc}%") + + return done_perc + + +def app_dir() -> Path: + """Get the application directory.""" + import os + default_dir = str(Path.cwd() / "app-data") + return Path(os.getenv("DEEPFACILITY_ROOT_DIR", default=default_dir)) + + +def memory_cache_dir() -> Path: + """Get the memory cache directory.""" + return app_dir() / "cache" + + +def memory_cache() -> joblib.Memory: + """Get a memory cache used for caching data.""" + return joblib.Memory(memory_cache_dir(), verbose=0) + + +def is_linux() -> bool: + """Check if the current system is Linux""" + import platform + return platform.system() == "Linux" + + +def new_session_id(length: int = None) -> str: + """Generate a new session ID.""" + sid: str = str(uuid4()).replace("-", "") + n = length or len(sid) + return sid[:n] + + +def init_logger(name: str = None, file: Path = None): + """Initialize a logger.""" + if file: + # Set the name based on the file for uniqueness, if name is not provided + name = name or hash_str(text_to_id(str(file))) + # Create the parent directory if it doesn't exist + file.parent.mkdir(parents=True, exist_ok=True) + + # Configure file handler + file_handler = logging.handlers.WatchedFileHandler(str(file), mode="w") + file_handler.setFormatter(logging.Formatter("%(message)s")) + else: + # Set the name to a random ID for uniqueness, if name is not provided + name = name or new_session_id(8) + file_handler = None + + # Configure console handler + console_handler = logging.StreamHandler() + console_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + + # Create and configure the logger + logger = logging.getLogger(name) + if file_handler: + logger.addHandler(file_handler) + + # Add the console handler + logger.addHandler(console_handler) + + # Set the logging level + logger.setLevel(logging.INFO) + + return logger diff --git a/src/deepfacility/ux/__init__.py b/src/deepfacility/ux/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/deepfacility/ux/css/style.css b/src/deepfacility/ux/css/style.css new file mode 100644 index 0000000..3ab8358 --- /dev/null +++ b/src/deepfacility/ux/css/style.css @@ -0,0 +1,235 @@ +body { + margin: 5px; + display: grid; + grid-template-rows: auto 150%; + grid-template-columns: auto 1fr; +} + +header { + margin: 0; + grid-row: 1; + grid-column: span 2; + background-color: #f0f0f0; /* Adjust as needed */ + padding-top: 20px; + padding-left: 10px; + margin: 0; +} + +nav { + grid-row: 2; + grid-column: 1; + align-content: baseline; + background-color: #e0e0e0; /* Adjust as needed */ + padding-left: 10px; + /*float: left;*/ +} + +main { + grid-row: 2; + grid-column: 2; + margin-top: 10px; + margin-left: 10px; + width: 100%; + height: 100%; +} + +form { + margin-top: 5px; + +} + +h1 { + margin: 0; +} + +h2 { + color: gray; + margin: 0; + +} + +h3 { + margin-top: 1fr; + color: gray; + margin-bottom: 0; + +} + +hr { + height: 1px; + border: none; + background-color: #ccc; +} + +table { + border-color: #333333; + width: 98%; +} + +tr td { + width: auto; + white-space: nowrap; +} + +table.status { + text-align: left; + width: 300px; + table-layout: fixed; +} + + +table.status tr td { + border-bottom: 1px solid #ddd; + width: 150px; + overflow: hidden; + text-align: left; + white-space: nowrap; +} + +table.header { + width: 98%; +} + +table.header tr td { + width: 100%; + white-space: nowrap; +} + +table.dataframe { + border-collapse: collapse; + /*margin: 25px 0;*/ + font-size: 0.9em; + font-family: sans-serif; + min-width: 400px; + max-width: 800px; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.15); +} + +table.dataframe thead tr { + background-color: cadetblue; + color: white; + text-align: left; +} + +table.dataframe td { + padding: 12px 15px; +} + +iframe{ + width: 100%; + height: 100%; + border: none; +} + + +footer { + height: 0px; +} + +label { + color: gray; + margin-top: 5px; + margin-right: 5px; + margin-bottom: 5px; +} + +label.instruction { + display: table; + background-color: khaki; + color: midnightblue; +} + +label.field { + color: cadetblue; +} + +button { + /*background-color: white;*/ + /*color: black;*/ + /*border: 1px gray;*/ + height: 40px; + width: 150px; +} + +button:hover { + border: 2px solid black; + background-color: cadetblue; /* Green */ + box-shadow: 3px 2px 22px 1px rgba(0, 0, 0, 0.24); + /* Adding a small shadow to the button on press */ + color: white; +} + +/* style for a button click/press */ +button:active { + transform: scale(0.95); + box-shadow: 0px 0px 0px 0px rgba(0, 0, 0, 0.24); + /* Scaling button to 0.95 of its size on press */ +} + +select { + margin: 2px; + height: auto; + width: auto; +} + + +button:active.small { + margin: 2px; + height: auto; + width: auto; +} + +.map { + width: 100%; + height: 140%; +} + +#info { + height: auto; +} + +#footer { + position: fixed; + bottom: 0; + width: 100%; +} + +.tooltip { + position: relative; + display: inline-block; +} + +.tooltip .tooltiptext { + visibility: hidden; + width: 600px; + background-color: black; + color: #fff; + text-align: center; + border-radius: 6px; + padding: 5px; + position: absolute; + z-index: 1; + top: 100%; /* Position above the tooltip element */ + left: 0%; + margin-left: -500px; /* Center the tooltip */ + opacity: 0; + transition: opacity 0.3s; +} + +.tooltip .tooltiptext::after { + content: ''; + visibility: hidden; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: black transparent transparent transparent; +} + +.tooltip:hover .tooltiptext { + visibility: visible; + opacity: 1; +} + diff --git a/src/deepfacility/ux/main.py b/src/deepfacility/ux/main.py new file mode 100644 index 0000000..e81f0ff --- /dev/null +++ b/src/deepfacility/ux/main.py @@ -0,0 +1,706 @@ +import shutil +import time + +import fastapi +import warnings + +import importlib.metadata +import pandas as pd +import uvicorn + +from dataclasses import asdict +from fastapi import File, UploadFile, Request, Form, BackgroundTasks +from fastapi.responses import HTMLResponse, RedirectResponse, FileResponse +from fastapi.staticfiles import StaticFiles +from pathlib import Path +from tempfile import mkdtemp + +from deepfacility.flows import DataPrepWorkflow +from deepfacility.lang import Translator +from deepfacility.utils import commands, spatial, util +from deepfacility.viz import visualize + +from deepfacility.config.config import Config, AdmPointsFile, BaselineFile, get_all_locations +from deepfacility.ux.session import Session, ConfigForm, is_localhost, app, templates + +warnings.simplefilter(action='ignore', category=FutureWarning) + + +# app and templates are initiated in the session module +app.mount("/css", StaticFiles(directory=str(Path(__file__).parent / "css")), name="css") + + +@app.get("/", response_class=HTMLResponse) +def index(request: Request, language: str = Form(default="")): + """The main page of the application.""" + # Initialize session + s = Session.init(request, language=language) + + # Get supported languages for the language selector + all_languages = s.translator.supported_languages + context = {"request": request, + "version": importlib.metadata.version('deepfacility'), + "language": s.translator.language, + "session_id": s.session_id, + "all_languages": all_languages} + + # Render the index app page + response = templates.TemplateResponse("index.html", context) + # Set the session cookie to last 10 years + response.set_cookie("session_id", s.session_id, max_age=315360000, httponly=True) + return response + + +@app.post("/lang", response_class=HTMLResponse) +def select_language(request: Request, language: str = Form(...)): + """Set session language, then redirect to index.""" + Session.init(request, language=language) + # Redirect to the index page to refresh UI content + # HTTP_302_FOUND is necessary to allow the browser to redirect + return RedirectResponse('/', status_code=fastapi.status.HTTP_302_FOUND) + + +@app.get("/driver", response_class=HTMLResponse) +def driver(request: Request): + """The main driver page showing clustering UI when app is configured.""" + s = Session.init(request) + + # Check if app is configured and inputs are redy + if s.has_config_file() and s.cfg.inputs.ready(): + # Get the list of existing runs + root_dir = s.cfg.results.root_dir + result_dirs = list(root_dir.glob("*")) + result_names = [] + if result_dirs and len(result_dirs) > 0: + # Filter complete runs + result_dirs = sorted( + [(d.lstat().st_mtime, d.stem, d.joinpath("locations.csv").read_text()) + for d in result_dirs + if d.is_dir() + and d.joinpath("optimal_facilities.csv").is_file() + and d.joinpath("locations.csv").is_file() + ], reverse=True) + + # Put the latest on top and sort rest by name + if len(result_dirs) > 0: + result_dirs = [result_dirs[0]] + sorted(result_dirs[1:], key=lambda x: x[1]) + result_names = [(d, locs) for _, d, locs in result_dirs] + + context = { + "request": request, + "locations": get_all_locations(s.cfg), + "result_names": result_names, + "show_large": "false" + } + + response = templates.TemplateResponse("40-run.html", context) + else: + # app is not configured or inputs are not ready + response = '' + + return response + + +@app.get("/clear_view", response_class=HTMLResponse) +def clear_view(request: Request): + """Clear the map view element.""" + s = Session.init(request) + + if s.has_prep_task: + status_page = "30-prep-status-container.html" + elif s.has_run_task: + status_page = "40-run-status-container.html" + else: + status_page = None + + context = {"request": request} + if status_page: + # Navigate to the page which sends regular status requests and displays the status + response = templates.TemplateResponse(status_page, context) + else: + # Return an empty `view` div to clear the map view element. + response = HTMLResponse(content=get_empty_div("view"), status_code=200) + + return response + + +@app.get("/new", response_class=HTMLResponse) +def new_config(request: Request): + """Display Yes/No buttons for the New Config button.""" + # Initialize session to ensure language is set + Session.init(request) + # Pressing `Yes` button sends `oknew` request, otherwise `/info`. + context = {"request": request} + return templates.TemplateResponse("12-confirm-new.html", context) + + +@app.get("/oknew", response_class=HTMLResponse) +def ok_new_config(request: Request): + """Deletes the config, stops background tasks, redirects to `upload`.""" + s = Session.init(request) + s.stop_task() + if s.has_config_file(): + s.cfg.config_file.unlink(missing_ok=True) + + context = {"request": request} + response = templates.TemplateResponse("10-upload.html", context) + return response + + +@app.get("/info", response_class=HTMLResponse) +def info(request: Request): + """ + Display the config info at the top if configured. + Otherwise, navigate to the upload form to allow user to configure. + """ + s = Session.init(request) + + # Check if app is configured and inputs are ready + if s.has_config_file() and s.cfg.inputs.ready(): + # If ready show the config info at the top + # Prepare response dict containing key confing info + cf_dict = asdict(s.get_config_form()) + # Detect if app is running locally + if is_localhost(request): + # Add app dir path to the config info to display in UI + # to help users manually access the dir + cf_dict['local_path'] = str(s.cfg.results.root_dir) + else: + # If not running locally, don't show the app path + cf_dict['local_path'] = "" + + context = {"request": request, "session_id": s.session_id, **cf_dict} + response = templates.TemplateResponse("22-info.html", context) + elif s.has_config_file(): + df, df_b = get_preview_dfs(s.cfg) + + context = {"request": request, + "village_preview": df.head().to_html(index=False), + "baseline_preview": df_b.head().to_html(index=False) if df_b is not None else "", + **asdict(s.get_config_form()) + } + response = templates.TemplateResponse("30-prep.html", context) + + elif not s.has_config_file() : + # If not ready navigate to the upload page + context = {"request": request} + response = templates.TemplateResponse("10-upload.html", context) + else: + response = "" + + return response + + +@app.post("/upload", response_class=HTMLResponse) +def upload_csv(request: Request, + village_file: UploadFile = File(...), + baseline_file: UploadFile = File(default="")): + """Upload the village and baseline files and navigate to the configure page.""" + s = Session.init(request) + # Read the uploaded file with pandas + try: + # Init session village file + tmp_dir = Path(mkdtemp()) + s.village_file = tmp_dir / village_file.filename + + # Load file, get columns and save to session dir + df = pd.read_csv(village_file.file, encoding='utf-8') + village_cols = sorted(df.columns) + df.to_csv(s.village_file, index=False, encoding='utf-8') + + # Check if baseline file is uploaded + if baseline_file: + # Load the baseline file + s.baseline_file = tmp_dir / baseline_file.filename + df2 = pd.read_csv(baseline_file.file, encoding='utf-8') + + def clean(col): + chrs = [c if c.isalnum() else ' ' for c in col] + return ''.join(chrs) + + # Remove special characters from column names + clean_cols = [clean(col) for col in df2.columns] + + # Rename columns with clean names and save to the session dir + df3 = util.rename_df_cols(df2, list(df2.columns), clean_cols) + baseline_cols = sorted([c for c in clean_cols if not str(c).startswith("Unnamed:")]) + df3.to_csv(s.baseline_file, index=False, encoding='utf-8') + else: + # Leave baseline vars empty if file is not uploaded + s.baseline_file = "" + baseline_cols = [] + + except Exception as e: + return HTMLResponse(content=f"Error reading file: {e}", status_code=400) + + # Prepare dict to display village and baseline files in the config page + context = {"request": request, + "village_file": s.village_file.name, + "village_cols": village_cols, + "baseline_file": s.baseline_file.name if baseline_file else "", + "baseline_cols": baseline_cols} + + return templates.TemplateResponse("20-configure.html", context) + + +@app.post("/config", response_class=HTMLResponse) +def configure(request: Request, + village_name_col: str = Form(...), + village_lon_col: str = Form(...), + village_lat_col: str = Form(...), + baseline_lon_col: str = Form(default=""), + baseline_lat_col: str = Form(default=""), + baseline_info_cols: list[str] = Form(default="")): + """Configure the app with based on uploaded village and baseline files.""" + s = Session.init(request) + + # Read uploaded village file + df = pd.read_csv(s.village_file, encoding='utf-8') + df = df[[village_lon_col, village_lat_col]] + + # Detect the country from village centers + country, code = spatial.detect_country(df=df, xy_cols=[village_lon_col, village_lat_col]) + + # Prepare response dict containing key confing info to be displayed in the driver + cf = ConfigForm(country=country, + country_code=code, + village_file=str(s.village_file), + village_name_col=village_name_col, + village_lon_col=village_lon_col, + village_lat_col=village_lat_col, + baseline_file=str(s.baseline_file), + baseline_lon_col=baseline_lon_col, + baseline_lat_col=baseline_lat_col, + baseline_info_cols=baseline_info_cols) + + # create config + s.init_cfg(cf) + cf.village_file = Path(cf.village_file).name + cf.baseline_file = Path(cf.baseline_file).name + + df, df_b = get_preview_dfs(s.cfg) + + context = {"request": request, + "has_data": s.cfg.inputs.ready(), + "village_preview": df.head().to_html(index=False), + "baseline_preview": df_b.head().to_html(index=False) if df_b is not None else "", + **asdict(cf)} + + return templates.TemplateResponse("30-prep.html", context) + + +@app.get("/prep", response_class=HTMLResponse) +def prep(request: Request, bt: BackgroundTasks): + """Run data preparation workflow for the detected country.""" + s = Session.init(request) + + # Cleanup previous tasks and data + s.stop_task() # Stop any running background task + s.cfg.inputs.remove_files() # Remove input files + # Remove all country results + shutil.rmtree(s.cfg.results.root_dir, ignore_errors=True) + + # Start the data preparation task + s.start_time = time.time() # capture new start time + s.start_task(s.cfg.inputs, bt, prep_data, s.cfg, s.translator, s.clear_task) + + context = {"request": request} + + # Navigate to the page which sends regular status requests and displays the status. + response = templates.TemplateResponse("30-prep-status-container.html", context) + + return response + + +@app.get("/prep/status/container", response_class=HTMLResponse) +def prep_status_container(request: Request): + """Data preparation workflow status tracking page.""" + context = {"request": request} + # The template renders the page which periodically sends + # `/prep/status` requests and displays responses in the `status` div. + return templates.TemplateResponse("40-prep-status-container.html", context) + + +@app.get("/prep/status", response_class=HTMLResponse) +def prep_status(request: Request): + """Data preparation workflow status page.""" + s = Session.init(request) + + # Check if app is not configured or the data preparation task is stopped + if not s.cfg or s.cfg.inputs.is_stopped(): + # Redirect to driver which will clean up and redirect to upload. + time.sleep(5) + return RedirectResponse(url='/driver') + + # Alias for the Inputs configuration section + ins = s.cfg.inputs + + # Check if all shape files are present + shp_x, bld_x, hhs_x, vil_x = (all([f.is_file() for f in ins.shape_files]), + ins.buildings.file.is_file(), + ins.households.file.is_file(), + ins.village_centers.file.is_file()) + + shp = bld = hhs = vil = "Not Started" + + # Calculate the progress of each step + if shp_x: + # Calculate the size of the shape files + size = ins.shape_files[-1].lstat().st_size // 1000000 + # Set Done if the buildings file (next step) is present. + shp = "In Progress" if not bld_x else "Done" + # Construct the status string. + shp = f"{shp} ({size}MB)" + else: + shp = "In Progress (...)" + + if bld_x: + size = ins.buildings.file.lstat().st_size // 1000000 + bld = "In Progress" if not hhs_x else "Done" + bld = f"{bld} ({size}MB)" + else: + bld = "In Progress (...)" + + if hhs_x: + size = ins.households.file.lstat().st_size // 1000000 + hhs = "In Progress" if not vil_x else "Done" + hhs = f"{hhs} ({size}MB)" + else: + hhs = "In Progress (...)" + + if vil_x: + size = ins.village_centers.file.lstat().st_size // 1000 + vil2_x = ins.village_centers.file.with_suffix(".geojson").is_file() + vil = "In Progress" if not vil2_x else "Done" + vil = f"{vil} ({size}kB)" + vil_time = time.time() - ins.village_centers.file.lstat().st_mtime + else: + vil_time = 0 + + # Check if the data preparation task is complete or stopped + if s.cfg.inputs.ready() and (vil_time > 10 or s.cfg.inputs.is_stopped()): + s.clear_task() + return reload_page_response() + + # If still running get latest logs to display in the UI + prep_logs = get_logs(s.cfg.inputs.log_file) + + # Prepare status response + elapsed = get_elapsed_time(s.start_time) + + # Construct the status response + context = {"request": request, + "shp": shp, + "bld": bld, + "hhs": hhs, + "vil": vil, + "elapsed": elapsed, + "logs": prep_logs} + + return templates.TemplateResponse("30-prep-status.html", context) + + +@app.post("/run", response_class=HTMLResponse) +def run(request: Request, bt: BackgroundTasks, locs: list[str] = Form(...)): + """Run the scientific workflow via FastAPI background tasks.""" + s = Session.init(request) + s.start_time = time.time() + assert isinstance(locs, list), "The locations field must be a list." + + # Don't run if no locations are selected. + if not locs: + return "" + + if locs: + # Set selected locations in the config. + s.cfg.update_locations(locs) + else: + s.cfg.update_locations('') + + # Clear previous run and start the new background task + s.cfg.results.remove_files() + s.start_task(s.cfg.results, bt, run_locs, s.cfg, s.translator, s.clear_task) + + # Navigate to the page which sends regular status requests and displays the status. + context = {"request": request, "show_large": "false"} + response = templates.TemplateResponse("40-run-status-container.html", context) + return response + + +@app.get("/run/status/container", response_class=HTMLResponse) +def run_status_container(request: Request): + """Scientific workflow status tracking page.""" + # The template renders the page which periodically sends + # `/run/status` requests and displays responses in the `status` div. + context = {"request": request} + return templates.TemplateResponse("40-run-status-container.html", context) + + +@app.get("/run/status", response_class=HTMLResponse) +def run_status(request: Request): + """Scientific workflow status page.""" + s = Session.init(request) + if not s.cfg or not s.cfg.config_file or not s.cfg.config_file.is_file(): + return "Config not found." + + locs_file = s.cfg.results.locations_file + assert locs_file.parent != "all", f"Run name not set properly {locs_file}." + + # If the locations file is not present reset the config. + if not locs_file.is_file(): + s.cfg.results.logger.warning("The configuration was not successful (no locations file). Redirecting to the upload page.") + + # Refresh and let `info` and `driver` sections handle the rest + return reload_page_response() + + # If the run is stopped, reload the page to display the `run` page. + if s.cfg.results.is_stopped(): + s.clear_task() + return reload_page_response() + + # Check if each location has a facility file. + locations = locs_file.read_text().splitlines() + file_ptt = s.cfg.results.facilities.file + files = [spatial.location_path(file_ptt, loc) for loc in locations] + files_ok = [f.is_file() for f in files] + + if all(files_ok): + # It is done, reload page to refresh the results list. + s.clear_task() + time.sleep(min(len(files_ok), 10)) + return reload_page_response() + + # Not yet done, display status and logs. + # Construct status to show number of done locations and those still in progress. + done_count = sum(files_ok) + res = [(f"{done_count} locations", True)] if done_count > 0 else [] + res += [(f"{f.parent.parent.name}:{f.parent.name}", False) + for (f, ok) in zip(files, files_ok) if not ok] + + # If still running get latest logs to display in the UI + res_logs = get_logs(s.cfg.results.log_file) + + # Prepare status response + elapsed = get_elapsed_time(s.start_time) + + # Render the status response + context = {"request": request, "files": res, "elapsed": elapsed, "res_logs": res_logs} + return templates.TemplateResponse("40-run-status.html", context) + + +@app.get("/run/stop", response_class=HTMLResponse) +def stop_run(request: Request): + """Stop the background tasks.""" + s = Session.init(request) + s.stop_task() + # Return a message to display in the UI + return HTMLResponse(s.translator.translate("Stopping...")) + + +@app.post("/view", response_class=HTMLResponse) +def show_results(request: Request, + result_name: str = Form(...), + show_large: str = Form(...)): + """Display the map with the run results""" + s = Session.init(request) + + # For running tasks, display status + if s.has_task: + context = {"request": request} + response = templates.TemplateResponse("40-run-status-container.html", context) + return response + + # If no run name clear the `downloads` section + if result_name == "None" and show_large == "None": + return "
" + + run_dir = s.cfg.results.root_dir / result_name + dirs = [str(d) for d in list(run_dir.glob("*")) if d.is_dir() and d.name != 'www'] + for d in dirs: + shutil.rmtree(str(d)) + + result_files = [str(f.name) for f in get_result_files(run_dir)] + + # Check if the map can be displayed + if show_large == "false": + # Tool many location can cause the map to be non-responsive + # This can be overridden by setting the `show_large` to `true` + msg = check_max_locations(s.cfg, run_dir.joinpath("locations.csv")) + if msg: + return msg + + # Construct the map URL and dir + result_url = f"/viewmap/{result_name}" + result_dir = run_dir / 'www' + + n = 0 + # Wait for the map to be ready + while not result_dir.is_dir() and n < 5: + time.sleep(1) + n += 1 + + # If the map is ready, display it + if result_dir.is_dir(): + # Mount the map directory to the app routes + app.mount(result_url, StaticFiles(directory=result_dir), name="static") + context = {'request': request, + 'result_name': result_name, + 'result_url': f"{result_url}/index.html", + 'result_files': result_files} + return templates.TemplateResponse("50-map.html", context) + else: + return "Map is not ready.
" + + +@app.post("/remove", response_class=RedirectResponse) +def remove_results(request: Request, result_name: str = Form(...)): + """Remove the selected run results.""" + s = Session.init(request) + run_dir = s.cfg.results.root_dir / result_name + # failsafe check + if run_dir.is_dir() and run_dir.parent.samefile(s.cfg.results.root_dir): + shutil.rmtree(run_dir) + else: + s.cfg.results.logger.warning(f"Directory doesn't appear to be a valid 'run' directory: {run_dir}") + + # Redirect to the index page to refresh the results list + # HTTP_302_FOUND is necessary to allow the browser to redirect + return RedirectResponse('/', status_code=fastapi.status.HTTP_302_FOUND) + + +@app.post("/download", response_class=FileResponse) +def download_results(request: Request, result_name: str = Form(...)): + """Download the selected run results as a zip file.""" + s = Session.init(request) + + # Construct the zip file name and path + file_name = f"{s.cfg.args.country_code}-{result_name}.zip" + res_dir = s.cfg.results.root_dir / result_name + + # Get result files to be zipped + res_files = get_result_files(res_dir) + assert all([result_name in str(f) for f in res_files]), f"{result_name} not present in result file paths." + path = util.create_zip(res_files, zip_name=file_name) + + # Return the zip file as a response, will prompt the user to download + return FileResponse(path=path, filename=file_name, media_type='application/zip') + + +# Workflow helpers + +def get_preview_dfs(cfg: Config) -> tuple[pd.DataFrame, pd.DataFrame]: + # Read uploaded village file + vc: AdmPointsFile = cfg.args.village_centers + bs: BaselineFile = cfg.args.baseline_facilities + + df0 = pd.read_csv(vc.file, encoding='utf-8') + df = df0[[vc.adm_cols[-1], *vc.xy_cols]] + + if bs.file and bs.file.is_file(): + # Read uploaded baseline file, if available + df_b0 = pd.read_csv(bs.file, encoding='utf-8') + df_b0 = df_b0.reset_index() + df_b = df_b0[['index', *bs.xy_cols, *bs.info_cols]] + else: + df_b = None + + return df, df_b + + +# Background tasks functions + + +def prep_data(cfg: Config, translator: Translator, clean_fn): + """Background task function for preparing data for the selected country.""" + t = DataPrepWorkflow(cfg=cfg).prepare_inputs(cfg.args.country) + if t[-1]: + time.sleep(3) + cfg.inputs.logger.info("Completed data preparation!") + else: + cfg.inputs.logger.warning("Cancelled data preparation.") + + clean_fn() + + +def run_locs(cfg: Config, translator: Translator, clean_fn): + """Background task function for running the scientific workflow.""" + done = commands.cmd_run(cfg=cfg, cli=False) + if done and not cfg.results.is_stopped() and cfg.results.ready(): + # Create the interactive map for the run results. + visualize.Visualizer(cfg=cfg).create_leaflet_map( + result_dir=cfg.results.dir, + translator=translator) + clean_fn() + + +# Helper functions + + +def check_max_locations(cfg: Config, loc_file: Path): + """ + Prevent non--responsive map visualization if + the number of locations is too large. + """ + if loc_file.is_file(): + loc_count = len(list(loc_file.read_text().split('\n'))) + else: + loc_count = 0 + + max_count = cfg.results.viz_max_locations + if loc_count == 0: + msg = "No locations found." + elif loc_count > max_count: + msg = f"The number of locations is greater than {max_count}." + else: + msg = "" + + return msg + + +def get_logs(log_file): + """Get the last 30 lines of the log file.""" + if log_file.is_file(): + logs = log_file.read_text().splitlines()[-30:] + else: + logs = "" + + return logs + + +def get_elapsed_time(start_time): + """Get the elapsed time since the start of the task.""" + et = util.elapsed_time(start_time) + elapsed = f"{et['m']}m:{et['s']}s" + return elapsed + + +def get_result_files(res_dir: Path) -> list[Path]: + """Get the list of files in the result directory.""" + files = list(res_dir.glob("*.csv")) + files += list(res_dir.glob("*.geojson")) + files += list(res_dir.glob("*.png")) + files += list(res_dir.glob("*.log")) + + return files + + +def get_empty_div(id): + """Return an empty div element with the specified id.""" + return f"
" + + +def reload_page_response(seconds=7): + """Reload the page after a delay.""" + time.sleep(seconds) + return HTMLResponse("") + + +def main(): + import os + host = os.environ.get('DEEPFACILITY_HOST', "localhost") + port = int(os.environ.get('DEEPFACILITY_PORT', "8000")) + uvicorn.run(app, host=host, port=port) + + +if __name__ == "__main__": + main() diff --git a/src/deepfacility/ux/session.py b/src/deepfacility/ux/session.py new file mode 100644 index 0000000..04bd627 --- /dev/null +++ b/src/deepfacility/ux/session.py @@ -0,0 +1,252 @@ +import os +import shutil + +import time +import tomli +import tomli_w + +from pathlib import Path +from fastapi import Request, BackgroundTasks +from dataclasses import dataclass + +from deepfacility import lang +from deepfacility.config.config import Config, Inputs, Operation, Results, create_config_file, read_s2_dict +from deepfacility.utils import util + +from fastapi import FastAPI +from fastapi.templating import Jinja2Templates + +# initiate app here to use app.state to preserve session +app = FastAPI() + +# initiate templates here to set `_` translation function in the session init +templates = Jinja2Templates(directory=Path(__file__).parent / "templates") + + +@dataclass +class ConfigForm: + """Config file form data.""" + # Country + country: str + country_code: str + # Village centers + village_file: str + village_name_col: str + village_lon_col: str + village_lat_col: str + # Baseline facilities + baseline_file: str = "" + baseline_lon_col: str = "" + baseline_lat_col: str = "" + baseline_info_cols: list[str] = None + + +@dataclass +class Session: + """Session data.""" + session_id: str = None + session_dir: Path = None + village_file: Path = "" + baseline_file: Path = "" + cfg: Config = None + start_time: float = time.time() + translator: lang.Translator = None + _operation: Operation = None + + @property + def data_dir(self): + """Get the directory containing country files.""" + return self.session_dir / "data" + + @classmethod + def get_session_id(cls, request: Request) -> str: + """Get the session id from one of the possible sources""" + session_id = os.environ.get('DEEPFACILITY_SID', None) # from env variable + session_id = session_id or request.query_params.get("sid") # from query string + session_id = session_id or request.cookies.get("session_id") # from cookie + session_id = session_id or util.new_session_id(length=12) # generate a new one + return session_id + + + @classmethod + def get_session_dir(cls, session_id: str) -> Path: + """Get the session directory.""" + return util.app_dir() / session_id + + @classmethod + def init(cls, request: Request, language: str = ""): + """Initialize the session.""" + global app + assert isinstance(app, FastAPI), "FastAPI app is not initialized" + + has_s_dict = app and hasattr(app.state, 'session') and isinstance(app.state.session, dict) + if not has_s_dict: + app.state.session = {} + + # Get session id + session_id = cls.get_session_id(request) + + s: Session = app.state.session.get(session_id, None) + + if s: + s.session_id = session_id + + # For existing session + if s.translator is None: + # Create a new translator if not set + s.translator = lang.Translator.create(language, request) + elif language: + # Set the language if different + if s.translator.language != language: + s.translator.set_language(language) + else: + pass + else: + # For a new session + config_file = None + for file in Session.get_session_dir(session_id).glob("*.toml"): + if not config_file or file.stat().st_ctime > config_file.stat().st_ctime: + # Take the latest config toml file + config_file = file + + # Create a config instance (using the latest config file, if exists) + cfg = Config.create_instance(config_file=config_file) if config_file else None + + # Create a session and set the translator + s = Session(cfg=cfg, session_id=session_id, translator=lang.Translator.create(language, request)) + + # Set the session directory + s.session_dir = cls.get_session_dir(session_id) + + app.state.session[s.session_id] = s + + # Set the translator in the global Jinja2 environment + templates.env.globals['_'] = s.translator.translate + + return s + + def init_cfg(self, cf: ConfigForm): + """Initialize the config from the config form.""" + # Create the config file + stem: str = Path(cf.village_file).stem + config_file = self.session_dir / Path(Config.default_file).with_suffix(f".{stem}.toml").name + create_config_file(config_file, force=True) + + # Read default config from the file + content = config_file.read_text() + cfg_dict = tomli.loads(content) + + # Get the country code + country_code: str = str(read_s2_dict()[cf.country]["code"]) + + # Get the village file pattenr and populate it + ptt = cfg_dict['args']['village_centers']['file'] + cfg_data_dir = str(self.data_dir) + ptt = ptt.replace("{data_dir}", cfg_data_dir) + ptt = ptt.replace("{country_code}", country_code) + args_dir = Path(ptt).parent + + def to_args_dir(file: str) -> str: + """Move uploaded files from tempt to args dir""" + file2 = args_dir / Path(file).name + file2.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(file, file2) + return str(file2) + + # Move the village file + cf.village_file = to_args_dir(cf.village_file) + + # Update config with values from the form + cfg_dict['args']['data_dir'] = cfg_data_dir + cfg_dict['args']['country'] = cf.country + cfg_dict['args']['country_code'] = country_code + + cfg_dict['args']['village_centers']['file'] = cf.village_file + cfg_dict['args']['village_centers']['adm_cols'] = [cf.village_name_col] + cfg_dict['args']['village_centers']['xy_cols'] = [cf.village_lon_col, cf.village_lat_col] + + if Path(cf.baseline_file).is_file(): + cf.baseline_file = to_args_dir(cf.baseline_file) + cfg_dict['args']['baseline_facilities']['file'] = cf.baseline_file + cfg_dict['args']['baseline_facilities']['xy_cols'] = [cf.baseline_lon_col, cf.baseline_lat_col] + cfg_dict['args']['baseline_facilities']['info_cols'] = cf.baseline_info_cols + else: + cfg_dict['args']['baseline_facilities']['file'] = "" + + # Write the updated config to the file + content = tomli_w.dumps(cfg_dict) + config_file.write_text(content) + + # Create a new config instance + self.cfg: Config = Config.create_instance(config_file=config_file) + + def get_config_form(self): + """Get the config form for the current config file.""" + # Prepare response dict containing key confing info to be displayed in the driver + return ConfigForm(country=self.cfg.args.country, + country_code=self.cfg.args.country_code, + # village centers + village_file=self.cfg.args.village_centers.file.name, + village_name_col=self.cfg.args.village_centers.adm_cols[-1], + village_lon_col=self.cfg.args.village_centers.xy_cols[0], + village_lat_col=self.cfg.args.village_centers.xy_cols[1], + # baseline facilities + baseline_file=self.cfg.args.baseline_facilities.file.name, + baseline_lon_col=self.cfg.args.baseline_facilities.xy_cols[0], + baseline_lat_col=self.cfg.args.baseline_facilities.xy_cols[1], + baseline_info_cols=self.cfg.args.baseline_facilities.info_cols) + + @property + def has_task(self) -> bool: + """Check if there is a running task.""" + return self._operation is not None + + @property + def has_prep_task(self) -> bool: + """Check if there is a running data prep task.""" + return self._operation is not None and isinstance(self._operation, Inputs) + + @property + def has_run_task(self) -> bool: + """Check if there is a running scientific workflow task.""" + return self._operation is not None and isinstance(self._operation, Results) + + def start_task(self, op: Operation, background_tasks: BackgroundTasks, task_fn: callable, *args, **kwargs): + """ + Start a background task. + :param op: Operation instance + :param background_tasks: FastAPI background tasks + :param task_fn: Task function to run + """ + # Clear the previous task if it exists + if self._operation and self._operation.is_stopped(): + time.sleep(5) + self._operation.clear() + self._operation = None + + # Start the new task + background_tasks.add_task(task_fn, *args, **kwargs) + self._operation = op + + def clear_task(self): + """Clear the current task.""" + if self._operation: + self._operation = None + + def stop_task(self): + """Create the stop file to signal the background task to stop.""" + if self._operation: + self._operation.stop() + self.clear_task() + # else: + # self._operation.logger.warning("No task to stop") + + def has_config_file(self): + """Check if the session has a config file.""" + return self.cfg and self.cfg.config_file and self.cfg.config_file.is_file() + + +def is_localhost(request): + """Check if the request is from localhost.""" + hosts = ["localhost", "127.0.0.1", "0.0.0.0"] + return any([request.headers.get("host").startswith(v) for v in hosts]) diff --git a/src/deepfacility/ux/templates/10-upload.html b/src/deepfacility/ux/templates/10-upload.html new file mode 100644 index 0000000..83be0d9 --- /dev/null +++ b/src/deepfacility/ux/templates/10-upload.html @@ -0,0 +1,33 @@ +{#

Configuration

#} +
+
+ + + + + + + + + + + + +
  + + +   + +

  + + +
+ +
+
+
+
\ No newline at end of file diff --git a/src/deepfacility/ux/templates/12-confirm-new.html b/src/deepfacility/ux/templates/12-confirm-new.html new file mode 100644 index 0000000..8dec8b6 --- /dev/null +++ b/src/deepfacility/ux/templates/12-confirm-new.html @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/deepfacility/ux/templates/20-configure.html b/src/deepfacility/ux/templates/20-configure.html new file mode 100644 index 0000000..68392a1 --- /dev/null +++ b/src/deepfacility/ux/templates/20-configure.html @@ -0,0 +1,102 @@ +
+
+ + + + + + + + + + + + + + + + + +{% if baseline_cols %} + + + + + + + + + + + + + + + + + +{% endif %} + + + + +
+ +
+ + + +
+ + + +
+ + + +
+ +

+ + + +
+ + + +
+ + + +
  + +
+
diff --git a/src/deepfacility/ux/templates/22-info.html b/src/deepfacility/ux/templates/22-info.html new file mode 100644 index 0000000..abda6f7 --- /dev/null +++ b/src/deepfacility/ux/templates/22-info.html @@ -0,0 +1,34 @@ +
+ + + + + + {% if baseline_file %} + + + + {% endif %} + {% if local_path %} + + + + {% endif %} +
+ +
+ + +
+ +
+ + + + +
+ +
+
diff --git a/src/deepfacility/ux/templates/30-prep-status-container.html b/src/deepfacility/ux/templates/30-prep-status-container.html new file mode 100644 index 0000000..936f2a0 --- /dev/null +++ b/src/deepfacility/ux/templates/30-prep-status-container.html @@ -0,0 +1,9 @@ +
+
+
+

{{ _("Waiting for status...") }}

+
diff --git a/src/deepfacility/ux/templates/30-prep-status.html b/src/deepfacility/ux/templates/30-prep-status.html new file mode 100644 index 0000000..7adc32f --- /dev/null +++ b/src/deepfacility/ux/templates/30-prep-status.html @@ -0,0 +1,19 @@ + + + + + + +
+ + + + + + +
{{ _("Shapes") }}:[{{ _(shp) }}]
{{ _("Buildings") }}:[{{ _(bld) }}]
{{ _("Households") }}:[{{ _(hhs) }}]
{{ _("Villages") }}:[{{ _(vil) }}]
{{ _("Time:") }}[{{ elapsed }}]
+
+ {% for line in logs %} + {{ _(line) }}
+ {% endfor %} +
diff --git a/src/deepfacility/ux/templates/30-prep.html b/src/deepfacility/ux/templates/30-prep.html new file mode 100644 index 0000000..69e40c3 --- /dev/null +++ b/src/deepfacility/ux/templates/30-prep.html @@ -0,0 +1,42 @@ + + + + + + + + + + {% if baseline_preview %} + + + + {% endif %} +
+ +
+ + {% if baseline_file %} + + {% endif %} +
+
+ {{ village_preview|safe }} +
+
+ {{ baseline_preview|safe }} +
+ +
+ + + +
+ + diff --git a/src/deepfacility/ux/templates/40-run-status-container.html b/src/deepfacility/ux/templates/40-run-status-container.html new file mode 100644 index 0000000..3651aa4 --- /dev/null +++ b/src/deepfacility/ux/templates/40-run-status-container.html @@ -0,0 +1,10 @@ + +
+
+
+

{{ _("Waiting for status...") }}

+
diff --git a/src/deepfacility/ux/templates/40-run-status.html b/src/deepfacility/ux/templates/40-run-status.html new file mode 100644 index 0000000..5a45d07 --- /dev/null +++ b/src/deepfacility/ux/templates/40-run-status.html @@ -0,0 +1,21 @@ + + + + + + +
+ + {% for name, ok in files %} + + + + + {% endfor %} + +
{{ name }}[{{ _("Done") if ok else _("In Progress") }}]
{{ _("Time:") }}[{{ elapsed }}]
+
+ {% for line in res_logs %} + {{ _(line) }}
+ {% endfor %} +
diff --git a/src/deepfacility/ux/templates/40-run.html b/src/deepfacility/ux/templates/40-run.html new file mode 100644 index 0000000..3641a84 --- /dev/null +++ b/src/deepfacility/ux/templates/40-run.html @@ -0,0 +1,74 @@ +

{{ _("Locations") }}

+
+
+
+ +
+
+
+
+
+ + +
+

{{ _("Results") }}

+
+ + +
+
+ + {% if result_names %} + +
+ + + + {% else %} + + + + {% endif %} + +
+
+{% if result_names %} + +{% endif %} \ No newline at end of file diff --git a/src/deepfacility/ux/templates/42-locs-select.html b/src/deepfacility/ux/templates/42-locs-select.html new file mode 100644 index 0000000..4a5c372 --- /dev/null +++ b/src/deepfacility/ux/templates/42-locs-select.html @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/src/deepfacility/ux/templates/50-map.html b/src/deepfacility/ux/templates/50-map.html new file mode 100644 index 0000000..9dd7bcd --- /dev/null +++ b/src/deepfacility/ux/templates/50-map.html @@ -0,0 +1,20 @@ +
+
+
+ + +
+

{{ _("Downloads") }}

+
+
+ + {{ _("Result Files") }} ({{ result_name }}):
+
    + {% for f in result_files %} +
  • {{ f }}
  • + {% endfor %} +
+ +
+ +
\ No newline at end of file diff --git a/src/deepfacility/ux/templates/52-result-dirs.html b/src/deepfacility/ux/templates/52-result-dirs.html new file mode 100644 index 0000000..458c744 --- /dev/null +++ b/src/deepfacility/ux/templates/52-result-dirs.html @@ -0,0 +1,6 @@ +{{ _("Locations") }}:
+
diff --git a/src/deepfacility/ux/templates/index.html b/src/deepfacility/ux/templates/index.html new file mode 100644 index 0000000..9dd0acf --- /dev/null +++ b/src/deepfacility/ux/templates/index.html @@ -0,0 +1,67 @@ + + + + + + DEEP Facility + + + + + + +
+ + + + + +
+

{{ _("Digitally Enabled Equitably Placed (DEEP) Facility") }}

+
+
+ + {{ _("Warning: Changing the language will not update the map created previously.") }} +
+
+
+
+
+ +
+
+
+ + + diff --git a/src/deepfacility/viz/__init__.py b/src/deepfacility/viz/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/deepfacility/viz/leaflet_template/css/Control.Loading.css b/src/deepfacility/viz/leaflet_template/css/Control.Loading.css new file mode 100644 index 0000000..f4157bc --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/css/Control.Loading.css @@ -0,0 +1,26 @@ +.leaflet-control-loading:empty { + /* Spinner via ajaxload.info, base64-encoded */ + background-image: url(data:image/gif;base64,R0lGODlhEAAQAPQAAP///wAAAPDw8IqKiuDg4EZGRnp6egAAAFhYWCQkJKysrL6+vhQUFJycnAQEBDY2NmhoaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH+GkNyZWF0ZWQgd2l0aCBhamF4bG9hZC5pbmZvACH5BAAKAAAAIf8LTkVUU0NBUEUyLjADAQAAACwAAAAAEAAQAAAFdyAgAgIJIeWoAkRCCMdBkKtIHIngyMKsErPBYbADpkSCwhDmQCBethRB6Vj4kFCkQPG4IlWDgrNRIwnO4UKBXDufzQvDMaoSDBgFb886MiQadgNABAokfCwzBA8LCg0Egl8jAggGAA1kBIA1BAYzlyILczULC2UhACH5BAAKAAEALAAAAAAQABAAAAV2ICACAmlAZTmOREEIyUEQjLKKxPHADhEvqxlgcGgkGI1DYSVAIAWMx+lwSKkICJ0QsHi9RgKBwnVTiRQQgwF4I4UFDQQEwi6/3YSGWRRmjhEETAJfIgMFCnAKM0KDV4EEEAQLiF18TAYNXDaSe3x6mjidN1s3IQAh+QQACgACACwAAAAAEAAQAAAFeCAgAgLZDGU5jgRECEUiCI+yioSDwDJyLKsXoHFQxBSHAoAAFBhqtMJg8DgQBgfrEsJAEAg4YhZIEiwgKtHiMBgtpg3wbUZXGO7kOb1MUKRFMysCChAoggJCIg0GC2aNe4gqQldfL4l/Ag1AXySJgn5LcoE3QXI3IQAh+QQACgADACwAAAAAEAAQAAAFdiAgAgLZNGU5joQhCEjxIssqEo8bC9BRjy9Ag7GILQ4QEoE0gBAEBcOpcBA0DoxSK/e8LRIHn+i1cK0IyKdg0VAoljYIg+GgnRrwVS/8IAkICyosBIQpBAMoKy9dImxPhS+GKkFrkX+TigtLlIyKXUF+NjagNiEAIfkEAAoABAAsAAAAABAAEAAABWwgIAICaRhlOY4EIgjH8R7LKhKHGwsMvb4AAy3WODBIBBKCsYA9TjuhDNDKEVSERezQEL0WrhXucRUQGuik7bFlngzqVW9LMl9XWvLdjFaJtDFqZ1cEZUB0dUgvL3dgP4WJZn4jkomWNpSTIyEAIfkEAAoABQAsAAAAABAAEAAABX4gIAICuSxlOY6CIgiD8RrEKgqGOwxwUrMlAoSwIzAGpJpgoSDAGifDY5kopBYDlEpAQBwevxfBtRIUGi8xwWkDNBCIwmC9Vq0aiQQDQuK+VgQPDXV9hCJjBwcFYU5pLwwHXQcMKSmNLQcIAExlbH8JBwttaX0ABAcNbWVbKyEAIfkEAAoABgAsAAAAABAAEAAABXkgIAICSRBlOY7CIghN8zbEKsKoIjdFzZaEgUBHKChMJtRwcWpAWoWnifm6ESAMhO8lQK0EEAV3rFopIBCEcGwDKAqPh4HUrY4ICHH1dSoTFgcHUiZjBhAJB2AHDykpKAwHAwdzf19KkASIPl9cDgcnDkdtNwiMJCshACH5BAAKAAcALAAAAAAQABAAAAV3ICACAkkQZTmOAiosiyAoxCq+KPxCNVsSMRgBsiClWrLTSWFoIQZHl6pleBh6suxKMIhlvzbAwkBWfFWrBQTxNLq2RG2yhSUkDs2b63AYDAoJXAcFRwADeAkJDX0AQCsEfAQMDAIPBz0rCgcxky0JRWE1AmwpKyEAIfkEAAoACAAsAAAAABAAEAAABXkgIAICKZzkqJ4nQZxLqZKv4NqNLKK2/Q4Ek4lFXChsg5ypJjs1II3gEDUSRInEGYAw6B6zM4JhrDAtEosVkLUtHA7RHaHAGJQEjsODcEg0FBAFVgkQJQ1pAwcDDw8KcFtSInwJAowCCA6RIwqZAgkPNgVpWndjdyohACH5BAAKAAkALAAAAAAQABAAAAV5ICACAimc5KieLEuUKvm2xAKLqDCfC2GaO9eL0LABWTiBYmA06W6kHgvCqEJiAIJiu3gcvgUsscHUERm+kaCxyxa+zRPk0SgJEgfIvbAdIAQLCAYlCj4DBw0IBQsMCjIqBAcPAooCBg9pKgsJLwUFOhCZKyQDA3YqIQAh+QQACgAKACwAAAAAEAAQAAAFdSAgAgIpnOSonmxbqiThCrJKEHFbo8JxDDOZYFFb+A41E4H4OhkOipXwBElYITDAckFEOBgMQ3arkMkUBdxIUGZpEb7kaQBRlASPg0FQQHAbEEMGDSVEAA1QBhAED1E0NgwFAooCDWljaQIQCE5qMHcNhCkjIQAh+QQACgALACwAAAAAEAAQAAAFeSAgAgIpnOSoLgxxvqgKLEcCC65KEAByKK8cSpA4DAiHQ/DkKhGKh4ZCtCyZGo6F6iYYPAqFgYy02xkSaLEMV34tELyRYNEsCQyHlvWkGCzsPgMCEAY7Cg04Uk48LAsDhRA8MVQPEF0GAgqYYwSRlycNcWskCkApIyEAOwAAAAAAAAAAAA==); + background-repeat: no-repeat; +} + +.leaflet-control-loading, +.leaflet-control-zoom a.leaflet-control-loading, +.leaflet-control-zoomslider a.leaflet-control-loading, +.leaflet-control-layer-container { + display: none; +} + +.leaflet-control-loading.is-loading, +.leaflet-control-zoom a.leaflet-control-loading.is-loading, +.leaflet-control-zoomslider a.leaflet-control-loading.is-loading, +.leaflet-control-layer-container.is-loading { + display: block; +} + +/* Necessary for display consistency in Leaflet >= 0.6 */ +.leaflet-bar-part-bottom { + border-bottom: medium none; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; +} diff --git a/src/deepfacility/viz/leaflet_template/css/MarkerCluster.Default.css b/src/deepfacility/viz/leaflet_template/css/MarkerCluster.Default.css new file mode 100644 index 0000000..bbc8c9f --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/css/MarkerCluster.Default.css @@ -0,0 +1,60 @@ +.marker-cluster-small { + background-color: rgba(181, 226, 140, 0.6); + } +.marker-cluster-small div { + background-color: rgba(110, 204, 57, 0.6); + } + +.marker-cluster-medium { + background-color: rgba(241, 211, 87, 0.6); + } +.marker-cluster-medium div { + background-color: rgba(240, 194, 12, 0.6); + } + +.marker-cluster-large { + background-color: rgba(253, 156, 115, 0.6); + } +.marker-cluster-large div { + background-color: rgba(241, 128, 23, 0.6); + } + + /* IE 6-8 fallback colors */ +.leaflet-oldie .marker-cluster-small { + background-color: rgb(181, 226, 140); + } +.leaflet-oldie .marker-cluster-small div { + background-color: rgb(110, 204, 57); + } + +.leaflet-oldie .marker-cluster-medium { + background-color: rgb(241, 211, 87); + } +.leaflet-oldie .marker-cluster-medium div { + background-color: rgb(240, 194, 12); + } + +.leaflet-oldie .marker-cluster-large { + background-color: rgb(253, 156, 115); + } +.leaflet-oldie .marker-cluster-large div { + background-color: rgb(241, 128, 23); +} + +.marker-cluster { + background-clip: padding-box; + border-radius: 20px; + } +.marker-cluster div { + width: 30px; + height: 30px; + margin-left: 5px; + margin-top: 5px; + + text-align: center; + border-radius: 15px; + font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif; + } +.marker-cluster span { + line-height: 30px; + } \ No newline at end of file diff --git a/src/deepfacility/viz/leaflet_template/css/MarkerCluster.css b/src/deepfacility/viz/leaflet_template/css/MarkerCluster.css new file mode 100644 index 0000000..c60d71b --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/css/MarkerCluster.css @@ -0,0 +1,14 @@ +.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow { + -webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in; + -moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in; + -o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in; + transition: transform 0.3s ease-out, opacity 0.3s ease-in; +} + +.leaflet-cluster-spider-leg { + /* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */ + -webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in; + -moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in; + -o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in; + transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in; +} diff --git a/src/deepfacility/viz/leaflet_template/css/custom_theme.css b/src/deepfacility/viz/leaflet_template/css/custom_theme.css new file mode 100644 index 0000000..055a250 --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/css/custom_theme.css @@ -0,0 +1,88 @@ +/* Set the map container to fill the viewport */ +#map { + width: 100vw; + height: 100vh; /* 100% of viewport height */ +} + +/* Apply a global font and background to the body */ +html, body { + font-family: 'Arial', sans-serif; + background-color: #f4f7f6; + margin: 0; + padding: 0; + color: #333; +} + +/* Style for headers */ +h1, h2, h3, h4, h5, h6 { + color: #0a0a0a; +} + +/* Style for all buttons */ +button { + background-color: #007bff; /* Blue shade */ + color: white; + border: none; + padding: 10px 20px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 16px; + margin: 4px 2px; + transition-duration: 0.4s; + cursor: pointer; + border-radius: 5px; +} + +/* Change the color of buttons on hover */ +button:hover { + background-color: #0056b3; /* Darker blue shade */ + color: white; +} + +/* Style for form elements */ +input[type="text"], +input[type="file"], +select { + width: 100%; + padding: 12px 20px; + margin: 8px 0; + display: inline-block; + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; +} + +/* Style for the submit button, if needed */ +input[type="submit"] { + background-color: #007bff; + color: white; + padding: 14px 20px; + margin: 8px 0; + border: none; + border-radius: 4px; + cursor: pointer; +} + +input[type="submit"]:hover { + background-color: #0056b3; +} + +/* Style for navigation bar */ +.navbar { + overflow: hidden; + background-color: #333; +} + +.navbar a { + float: left; + display: block; + color: #f2f2f2; + text-align: center; + padding: 14px 16px; + text-decoration: none; +} + +/* Links inside the navbar */ +.navbar a:hover { + background-color: #ddd; diff --git a/src/deepfacility/viz/leaflet_template/css/fontawesome-all.min.css b/src/deepfacility/viz/leaflet_template/css/fontawesome-all.min.css new file mode 100644 index 0000000..8539c00 --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/css/fontawesome-all.min.css @@ -0,0 +1,5 @@ +/*! + * Font Awesome Free 5.0.13 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + */ +.fa,.fab,.fal,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;text-rendering:auto;line-height:1}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-.0667em}.fa-xs{font-size:.75em}.fa-sm{font-size:.875em}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:2.5em;padding-left:0}.fa-ul>li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:.08em solid #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fab.fa-pull-left,.fal.fa-pull-left,.far.fa-pull-left,.fas.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fab.fa-pull-right,.fal.fa-pull-right,.far.fa-pull-right,.fas.fa-pull-right{margin-left:.3em}.fa-spin{animation:a 2s infinite linear}.fa-pulse{animation:a 1s infinite steps(8)}@keyframes a{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";transform:scaleX(-1)}.fa-flip-vertical{transform:scaleY(-1)}.fa-flip-horizontal.fa-flip-vertical,.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"}.fa-flip-horizontal.fa-flip-vertical{transform:scale(-1)}:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270{-webkit-filter:none;filter:none}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-500px:before{content:"\f26e"}.fa-accessible-icon:before{content:"\f368"}.fa-accusoft:before{content:"\f369"}.fa-address-book:before{content:"\f2b9"}.fa-address-card:before{content:"\f2bb"}.fa-adjust:before{content:"\f042"}.fa-adn:before{content:"\f170"}.fa-adversal:before{content:"\f36a"}.fa-affiliatetheme:before{content:"\f36b"}.fa-algolia:before{content:"\f36c"}.fa-align-center:before{content:"\f037"}.fa-align-justify:before{content:"\f039"}.fa-align-left:before{content:"\f036"}.fa-align-right:before{content:"\f038"}.fa-allergies:before{content:"\f461"}.fa-amazon:before{content:"\f270"}.fa-amazon-pay:before{content:"\f42c"}.fa-ambulance:before{content:"\f0f9"}.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-amilia:before{content:"\f36d"}.fa-anchor:before{content:"\f13d"}.fa-android:before{content:"\f17b"}.fa-angellist:before{content:"\f209"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-down:before{content:"\f107"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angrycreative:before{content:"\f36e"}.fa-angular:before{content:"\f420"}.fa-app-store:before{content:"\f36f"}.fa-app-store-ios:before{content:"\f370"}.fa-apper:before{content:"\f371"}.fa-apple:before{content:"\f179"}.fa-apple-pay:before{content:"\f415"}.fa-archive:before{content:"\f187"}.fa-arrow-alt-circle-down:before{content:"\f358"}.fa-arrow-alt-circle-left:before{content:"\f359"}.fa-arrow-alt-circle-right:before{content:"\f35a"}.fa-arrow-alt-circle-up:before{content:"\f35b"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-down:before{content:"\f063"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrows-alt:before{content:"\f0b2"}.fa-arrows-alt-h:before{content:"\f337"}.fa-arrows-alt-v:before{content:"\f338"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asterisk:before{content:"\f069"}.fa-asymmetrik:before{content:"\f372"}.fa-at:before{content:"\f1fa"}.fa-audible:before{content:"\f373"}.fa-audio-description:before{content:"\f29e"}.fa-autoprefixer:before{content:"\f41c"}.fa-avianex:before{content:"\f374"}.fa-aviato:before{content:"\f421"}.fa-aws:before{content:"\f375"}.fa-backward:before{content:"\f04a"}.fa-balance-scale:before{content:"\f24e"}.fa-ban:before{content:"\f05e"}.fa-band-aid:before{content:"\f462"}.fa-bandcamp:before{content:"\f2d5"}.fa-barcode:before{content:"\f02a"}.fa-bars:before{content:"\f0c9"}.fa-baseball-ball:before{content:"\f433"}.fa-basketball-ball:before{content:"\f434"}.fa-bath:before{content:"\f2cd"}.fa-battery-empty:before{content:"\f244"}.fa-battery-full:before{content:"\f240"}.fa-battery-half:before{content:"\f242"}.fa-battery-quarter:before{content:"\f243"}.fa-battery-three-quarters:before{content:"\f241"}.fa-bed:before{content:"\f236"}.fa-beer:before{content:"\f0fc"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-bell:before{content:"\f0f3"}.fa-bell-slash:before{content:"\f1f6"}.fa-bicycle:before{content:"\f206"}.fa-bimobject:before{content:"\f378"}.fa-binoculars:before{content:"\f1e5"}.fa-birthday-cake:before{content:"\f1fd"}.fa-bitbucket:before{content:"\f171"}.fa-bitcoin:before{content:"\f379"}.fa-bity:before{content:"\f37a"}.fa-black-tie:before{content:"\f27e"}.fa-blackberry:before{content:"\f37b"}.fa-blender:before{content:"\f517"}.fa-blind:before{content:"\f29d"}.fa-blogger:before{content:"\f37c"}.fa-blogger-b:before{content:"\f37d"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-bold:before{content:"\f032"}.fa-bolt:before{content:"\f0e7"}.fa-bomb:before{content:"\f1e2"}.fa-book:before{content:"\f02d"}.fa-book-open:before{content:"\f518"}.fa-bookmark:before{content:"\f02e"}.fa-bowling-ball:before{content:"\f436"}.fa-box:before{content:"\f466"}.fa-box-open:before{content:"\f49e"}.fa-boxes:before{content:"\f468"}.fa-braille:before{content:"\f2a1"}.fa-briefcase:before{content:"\f0b1"}.fa-briefcase-medical:before{content:"\f469"}.fa-broadcast-tower:before{content:"\f519"}.fa-broom:before{content:"\f51a"}.fa-btc:before{content:"\f15a"}.fa-bug:before{content:"\f188"}.fa-building:before{content:"\f1ad"}.fa-bullhorn:before{content:"\f0a1"}.fa-bullseye:before{content:"\f140"}.fa-burn:before{content:"\f46a"}.fa-buromobelexperte:before{content:"\f37f"}.fa-bus:before{content:"\f207"}.fa-buysellads:before{content:"\f20d"}.fa-calculator:before{content:"\f1ec"}.fa-calendar:before{content:"\f133"}.fa-calendar-alt:before{content:"\f073"}.fa-calendar-check:before{content:"\f274"}.fa-calendar-minus:before{content:"\f272"}.fa-calendar-plus:before{content:"\f271"}.fa-calendar-times:before{content:"\f273"}.fa-camera:before{content:"\f030"}.fa-camera-retro:before{content:"\f083"}.fa-capsules:before{content:"\f46b"}.fa-car:before{content:"\f1b9"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-caret-square-down:before{content:"\f150"}.fa-caret-square-left:before{content:"\f191"}.fa-caret-square-right:before{content:"\f152"}.fa-caret-square-up:before{content:"\f151"}.fa-caret-up:before{content:"\f0d8"}.fa-cart-arrow-down:before{content:"\f218"}.fa-cart-plus:before{content:"\f217"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-apple-pay:before{content:"\f416"}.fa-cc-diners-club:before{content:"\f24c"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-cc-visa:before{content:"\f1f0"}.fa-centercode:before{content:"\f380"}.fa-certificate:before{content:"\f0a3"}.fa-chalkboard:before{content:"\f51b"}.fa-chalkboard-teacher:before{content:"\f51c"}.fa-chart-area:before{content:"\f1fe"}.fa-chart-bar:before{content:"\f080"}.fa-chart-line:before{content:"\f201"}.fa-chart-pie:before{content:"\f200"}.fa-check:before{content:"\f00c"}.fa-check-circle:before{content:"\f058"}.fa-check-square:before{content:"\f14a"}.fa-chess:before{content:"\f439"}.fa-chess-bishop:before{content:"\f43a"}.fa-chess-board:before{content:"\f43c"}.fa-chess-king:before{content:"\f43f"}.fa-chess-knight:before{content:"\f441"}.fa-chess-pawn:before{content:"\f443"}.fa-chess-queen:before{content:"\f445"}.fa-chess-rook:before{content:"\f447"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-down:before{content:"\f078"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-chevron-up:before{content:"\f077"}.fa-child:before{content:"\f1ae"}.fa-chrome:before{content:"\f268"}.fa-church:before{content:"\f51d"}.fa-circle:before{content:"\f111"}.fa-circle-notch:before{content:"\f1ce"}.fa-clipboard:before{content:"\f328"}.fa-clipboard-check:before{content:"\f46c"}.fa-clipboard-list:before{content:"\f46d"}.fa-clock:before{content:"\f017"}.fa-clone:before{content:"\f24d"}.fa-closed-captioning:before{content:"\f20a"}.fa-cloud:before{content:"\f0c2"}.fa-cloud-download-alt:before{content:"\f381"}.fa-cloud-upload-alt:before{content:"\f382"}.fa-cloudscale:before{content:"\f383"}.fa-cloudsmith:before{content:"\f384"}.fa-cloudversify:before{content:"\f385"}.fa-code:before{content:"\f121"}.fa-code-branch:before{content:"\f126"}.fa-codepen:before{content:"\f1cb"}.fa-codiepie:before{content:"\f284"}.fa-coffee:before{content:"\f0f4"}.fa-cog:before{content:"\f013"}.fa-cogs:before{content:"\f085"}.fa-coins:before{content:"\f51e"}.fa-columns:before{content:"\f0db"}.fa-comment:before{content:"\f075"}.fa-comment-alt:before{content:"\f27a"}.fa-comment-dots:before{content:"\f4ad"}.fa-comment-slash:before{content:"\f4b3"}.fa-comments:before{content:"\f086"}.fa-compact-disc:before{content:"\f51f"}.fa-compass:before{content:"\f14e"}.fa-compress:before{content:"\f066"}.fa-connectdevelop:before{content:"\f20e"}.fa-contao:before{content:"\f26d"}.fa-copy:before{content:"\f0c5"}.fa-copyright:before{content:"\f1f9"}.fa-couch:before{content:"\f4b8"}.fa-cpanel:before{content:"\f388"}.fa-creative-commons:before{content:"\f25e"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-credit-card:before{content:"\f09d"}.fa-crop:before{content:"\f125"}.fa-crosshairs:before{content:"\f05b"}.fa-crow:before{content:"\f520"}.fa-crown:before{content:"\f521"}.fa-css3:before{content:"\f13c"}.fa-css3-alt:before{content:"\f38b"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-cut:before{content:"\f0c4"}.fa-cuttlefish:before{content:"\f38c"}.fa-d-and-d:before{content:"\f38d"}.fa-dashcube:before{content:"\f210"}.fa-database:before{content:"\f1c0"}.fa-deaf:before{content:"\f2a4"}.fa-delicious:before{content:"\f1a5"}.fa-deploydog:before{content:"\f38e"}.fa-deskpro:before{content:"\f38f"}.fa-desktop:before{content:"\f108"}.fa-deviantart:before{content:"\f1bd"}.fa-diagnoses:before{content:"\f470"}.fa-dice:before{content:"\f522"}.fa-dice-five:before{content:"\f523"}.fa-dice-four:before{content:"\f524"}.fa-dice-one:before{content:"\f525"}.fa-dice-six:before{content:"\f526"}.fa-dice-three:before{content:"\f527"}.fa-dice-two:before{content:"\f528"}.fa-digg:before{content:"\f1a6"}.fa-digital-ocean:before{content:"\f391"}.fa-discord:before{content:"\f392"}.fa-discourse:before{content:"\f393"}.fa-divide:before{content:"\f529"}.fa-dna:before{content:"\f471"}.fa-dochub:before{content:"\f394"}.fa-docker:before{content:"\f395"}.fa-dollar-sign:before{content:"\f155"}.fa-dolly:before{content:"\f472"}.fa-dolly-flatbed:before{content:"\f474"}.fa-donate:before{content:"\f4b9"}.fa-door-closed:before{content:"\f52a"}.fa-door-open:before{content:"\f52b"}.fa-dot-circle:before{content:"\f192"}.fa-dove:before{content:"\f4ba"}.fa-download:before{content:"\f019"}.fa-draft2digital:before{content:"\f396"}.fa-dribbble:before{content:"\f17d"}.fa-dribbble-square:before{content:"\f397"}.fa-dropbox:before{content:"\f16b"}.fa-drupal:before{content:"\f1a9"}.fa-dumbbell:before{content:"\f44b"}.fa-dyalog:before{content:"\f399"}.fa-earlybirds:before{content:"\f39a"}.fa-ebay:before{content:"\f4f4"}.fa-edge:before{content:"\f282"}.fa-edit:before{content:"\f044"}.fa-eject:before{content:"\f052"}.fa-elementor:before{content:"\f430"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-ember:before{content:"\f423"}.fa-empire:before{content:"\f1d1"}.fa-envelope:before{content:"\f0e0"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-square:before{content:"\f199"}.fa-envira:before{content:"\f299"}.fa-equals:before{content:"\f52c"}.fa-eraser:before{content:"\f12d"}.fa-erlang:before{content:"\f39d"}.fa-ethereum:before{content:"\f42e"}.fa-etsy:before{content:"\f2d7"}.fa-euro-sign:before{content:"\f153"}.fa-exchange-alt:before{content:"\f362"}.fa-exclamation:before{content:"\f12a"}.fa-exclamation-circle:before{content:"\f06a"}.fa-exclamation-triangle:before{content:"\f071"}.fa-expand:before{content:"\f065"}.fa-expand-arrows-alt:before{content:"\f31e"}.fa-expeditedssl:before{content:"\f23e"}.fa-external-link-alt:before{content:"\f35d"}.fa-external-link-square-alt:before{content:"\f360"}.fa-eye:before{content:"\f06e"}.fa-eye-dropper:before{content:"\f1fb"}.fa-eye-slash:before{content:"\f070"}.fa-facebook:before{content:"\f09a"}.fa-facebook-f:before{content:"\f39e"}.fa-facebook-messenger:before{content:"\f39f"}.fa-facebook-square:before{content:"\f082"}.fa-fast-backward:before{content:"\f049"}.fa-fast-forward:before{content:"\f050"}.fa-fax:before{content:"\f1ac"}.fa-feather:before{content:"\f52d"}.fa-female:before{content:"\f182"}.fa-fighter-jet:before{content:"\f0fb"}.fa-file:before{content:"\f15b"}.fa-file-alt:before{content:"\f15c"}.fa-file-archive:before{content:"\f1c6"}.fa-file-audio:before{content:"\f1c7"}.fa-file-code:before{content:"\f1c9"}.fa-file-excel:before{content:"\f1c3"}.fa-file-image:before{content:"\f1c5"}.fa-file-medical:before{content:"\f477"}.fa-file-medical-alt:before{content:"\f478"}.fa-file-pdf:before{content:"\f1c1"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-file-video:before{content:"\f1c8"}.fa-file-word:before{content:"\f1c2"}.fa-film:before{content:"\f008"}.fa-filter:before{content:"\f0b0"}.fa-fire:before{content:"\f06d"}.fa-fire-extinguisher:before{content:"\f134"}.fa-firefox:before{content:"\f269"}.fa-first-aid:before{content:"\f479"}.fa-first-order:before{content:"\f2b0"}.fa-first-order-alt:before{content:"\f50a"}.fa-firstdraft:before{content:"\f3a1"}.fa-flag:before{content:"\f024"}.fa-flag-checkered:before{content:"\f11e"}.fa-flask:before{content:"\f0c3"}.fa-flickr:before{content:"\f16e"}.fa-flipboard:before{content:"\f44d"}.fa-fly:before{content:"\f417"}.fa-folder:before{content:"\f07b"}.fa-folder-open:before{content:"\f07c"}.fa-font:before{content:"\f031"}.fa-font-awesome:before{content:"\f2b4"}.fa-font-awesome-alt:before{content:"\f35c"}.fa-font-awesome-flag:before{content:"\f425"}.fa-font-awesome-logo-full:before{content:"\f4e6"}.fa-fonticons:before{content:"\f280"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-football-ball:before{content:"\f44e"}.fa-fort-awesome:before{content:"\f286"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-forumbee:before{content:"\f211"}.fa-forward:before{content:"\f04e"}.fa-foursquare:before{content:"\f180"}.fa-free-code-camp:before{content:"\f2c5"}.fa-freebsd:before{content:"\f3a4"}.fa-frog:before{content:"\f52e"}.fa-frown:before{content:"\f119"}.fa-fulcrum:before{content:"\f50b"}.fa-futbol:before{content:"\f1e3"}.fa-galactic-republic:before{content:"\f50c"}.fa-galactic-senate:before{content:"\f50d"}.fa-gamepad:before{content:"\f11b"}.fa-gas-pump:before{content:"\f52f"}.fa-gavel:before{content:"\f0e3"}.fa-gem:before{content:"\f3a5"}.fa-genderless:before{content:"\f22d"}.fa-get-pocket:before{content:"\f265"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-gift:before{content:"\f06b"}.fa-git:before{content:"\f1d3"}.fa-git-square:before{content:"\f1d2"}.fa-github:before{content:"\f09b"}.fa-github-alt:before{content:"\f113"}.fa-github-square:before{content:"\f092"}.fa-gitkraken:before{content:"\f3a6"}.fa-gitlab:before{content:"\f296"}.fa-gitter:before{content:"\f426"}.fa-glass-martini:before{content:"\f000"}.fa-glasses:before{content:"\f530"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-globe:before{content:"\f0ac"}.fa-gofore:before{content:"\f3a7"}.fa-golf-ball:before{content:"\f450"}.fa-goodreads:before{content:"\f3a8"}.fa-goodreads-g:before{content:"\f3a9"}.fa-google:before{content:"\f1a0"}.fa-google-drive:before{content:"\f3aa"}.fa-google-play:before{content:"\f3ab"}.fa-google-plus:before{content:"\f2b3"}.fa-google-plus-g:before{content:"\f0d5"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-wallet:before{content:"\f1ee"}.fa-graduation-cap:before{content:"\f19d"}.fa-gratipay:before{content:"\f184"}.fa-grav:before{content:"\f2d6"}.fa-greater-than:before{content:"\f531"}.fa-greater-than-equal:before{content:"\f532"}.fa-gripfire:before{content:"\f3ac"}.fa-grunt:before{content:"\f3ad"}.fa-gulp:before{content:"\f3ae"}.fa-h-square:before{content:"\f0fd"}.fa-hacker-news:before{content:"\f1d4"}.fa-hacker-news-square:before{content:"\f3af"}.fa-hand-holding:before{content:"\f4bd"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-hand-holding-usd:before{content:"\f4c0"}.fa-hand-lizard:before{content:"\f258"}.fa-hand-paper:before{content:"\f256"}.fa-hand-peace:before{content:"\f25b"}.fa-hand-point-down:before{content:"\f0a7"}.fa-hand-point-left:before{content:"\f0a5"}.fa-hand-point-right:before{content:"\f0a4"}.fa-hand-point-up:before{content:"\f0a6"}.fa-hand-pointer:before{content:"\f25a"}.fa-hand-rock:before{content:"\f255"}.fa-hand-scissors:before{content:"\f257"}.fa-hand-spock:before{content:"\f259"}.fa-hands:before{content:"\f4c2"}.fa-hands-helping:before{content:"\f4c4"}.fa-handshake:before{content:"\f2b5"}.fa-hashtag:before{content:"\f292"}.fa-hdd:before{content:"\f0a0"}.fa-heading:before{content:"\f1dc"}.fa-headphones:before{content:"\f025"}.fa-heart:before{content:"\f004"}.fa-heartbeat:before{content:"\f21e"}.fa-helicopter:before{content:"\f533"}.fa-hips:before{content:"\f452"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-history:before{content:"\f1da"}.fa-hockey-puck:before{content:"\f453"}.fa-home:before{content:"\f015"}.fa-hooli:before{content:"\f427"}.fa-hospital:before{content:"\f0f8"}.fa-hospital-alt:before{content:"\f47d"}.fa-hospital-symbol:before{content:"\f47e"}.fa-hotjar:before{content:"\f3b1"}.fa-hourglass:before{content:"\f254"}.fa-hourglass-end:before{content:"\f253"}.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-start:before{content:"\f251"}.fa-houzz:before{content:"\f27c"}.fa-html5:before{content:"\f13b"}.fa-hubspot:before{content:"\f3b2"}.fa-i-cursor:before{content:"\f246"}.fa-id-badge:before{content:"\f2c1"}.fa-id-card:before{content:"\f2c2"}.fa-id-card-alt:before{content:"\f47f"}.fa-image:before{content:"\f03e"}.fa-images:before{content:"\f302"}.fa-imdb:before{content:"\f2d8"}.fa-inbox:before{content:"\f01c"}.fa-indent:before{content:"\f03c"}.fa-industry:before{content:"\f275"}.fa-infinity:before{content:"\f534"}.fa-info:before{content:"\f129"}.fa-info-circle:before{content:"\f05a"}.fa-instagram:before{content:"\f16d"}.fa-internet-explorer:before{content:"\f26b"}.fa-ioxhost:before{content:"\f208"}.fa-italic:before{content:"\f033"}.fa-itunes:before{content:"\f3b4"}.fa-itunes-note:before{content:"\f3b5"}.fa-java:before{content:"\f4e4"}.fa-jedi-order:before{content:"\f50e"}.fa-jenkins:before{content:"\f3b6"}.fa-joget:before{content:"\f3b7"}.fa-joomla:before{content:"\f1aa"}.fa-js:before{content:"\f3b8"}.fa-js-square:before{content:"\f3b9"}.fa-jsfiddle:before{content:"\f1cc"}.fa-key:before{content:"\f084"}.fa-keybase:before{content:"\f4f5"}.fa-keyboard:before{content:"\f11c"}.fa-keycdn:before{content:"\f3ba"}.fa-kickstarter:before{content:"\f3bb"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-kiwi-bird:before{content:"\f535"}.fa-korvue:before{content:"\f42f"}.fa-language:before{content:"\f1ab"}.fa-laptop:before{content:"\f109"}.fa-laravel:before{content:"\f3bd"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-leaf:before{content:"\f06c"}.fa-leanpub:before{content:"\f212"}.fa-lemon:before{content:"\f094"}.fa-less:before{content:"\f41d"}.fa-less-than:before{content:"\f536"}.fa-less-than-equal:before{content:"\f537"}.fa-level-down-alt:before{content:"\f3be"}.fa-level-up-alt:before{content:"\f3bf"}.fa-life-ring:before{content:"\f1cd"}.fa-lightbulb:before{content:"\f0eb"}.fa-line:before{content:"\f3c0"}.fa-link:before{content:"\f0c1"}.fa-linkedin:before{content:"\f08c"}.fa-linkedin-in:before{content:"\f0e1"}.fa-linode:before{content:"\f2b8"}.fa-linux:before{content:"\f17c"}.fa-lira-sign:before{content:"\f195"}.fa-list:before{content:"\f03a"}.fa-list-alt:before{content:"\f022"}.fa-list-ol:before{content:"\f0cb"}.fa-list-ul:before{content:"\f0ca"}.fa-location-arrow:before{content:"\f124"}.fa-lock:before{content:"\f023"}.fa-lock-open:before{content:"\f3c1"}.fa-long-arrow-alt-down:before{content:"\f309"}.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-long-arrow-alt-right:before{content:"\f30b"}.fa-long-arrow-alt-up:before{content:"\f30c"}.fa-low-vision:before{content:"\f2a8"}.fa-lyft:before{content:"\f3c3"}.fa-magento:before{content:"\f3c4"}.fa-magic:before{content:"\f0d0"}.fa-magnet:before{content:"\f076"}.fa-male:before{content:"\f183"}.fa-mandalorian:before{content:"\f50f"}.fa-map:before{content:"\f279"}.fa-map-marker:before{content:"\f041"}.fa-map-marker-alt:before{content:"\f3c5"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-mars:before{content:"\f222"}.fa-mars-double:before{content:"\f227"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mastodon:before{content:"\f4f6"}.fa-maxcdn:before{content:"\f136"}.fa-medapps:before{content:"\f3c6"}.fa-medium:before{content:"\f23a"}.fa-medium-m:before{content:"\f3c7"}.fa-medkit:before{content:"\f0fa"}.fa-medrt:before{content:"\f3c8"}.fa-meetup:before{content:"\f2e0"}.fa-meh:before{content:"\f11a"}.fa-memory:before{content:"\f538"}.fa-mercury:before{content:"\f223"}.fa-microchip:before{content:"\f2db"}.fa-microphone:before{content:"\f130"}.fa-microphone-alt:before{content:"\f3c9"}.fa-microphone-alt-slash:before{content:"\f539"}.fa-microphone-slash:before{content:"\f131"}.fa-microsoft:before{content:"\f3ca"}.fa-minus:before{content:"\f068"}.fa-minus-circle:before{content:"\f056"}.fa-minus-square:before{content:"\f146"}.fa-mix:before{content:"\f3cb"}.fa-mixcloud:before{content:"\f289"}.fa-mizuni:before{content:"\f3cc"}.fa-mobile:before{content:"\f10b"}.fa-mobile-alt:before{content:"\f3cd"}.fa-modx:before{content:"\f285"}.fa-monero:before{content:"\f3d0"}.fa-money-bill:before{content:"\f0d6"}.fa-money-bill-alt:before{content:"\f3d1"}.fa-money-bill-wave:before{content:"\f53a"}.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-money-check:before{content:"\f53c"}.fa-money-check-alt:before{content:"\f53d"}.fa-moon:before{content:"\f186"}.fa-motorcycle:before{content:"\f21c"}.fa-mouse-pointer:before{content:"\f245"}.fa-music:before{content:"\f001"}.fa-napster:before{content:"\f3d2"}.fa-neuter:before{content:"\f22c"}.fa-newspaper:before{content:"\f1ea"}.fa-nintendo-switch:before{content:"\f418"}.fa-node:before{content:"\f419"}.fa-node-js:before{content:"\f3d3"}.fa-not-equal:before{content:"\f53e"}.fa-notes-medical:before{content:"\f481"}.fa-npm:before{content:"\f3d4"}.fa-ns8:before{content:"\f3d5"}.fa-nutritionix:before{content:"\f3d6"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-old-republic:before{content:"\f510"}.fa-opencart:before{content:"\f23d"}.fa-openid:before{content:"\f19b"}.fa-opera:before{content:"\f26a"}.fa-optin-monster:before{content:"\f23c"}.fa-osi:before{content:"\f41a"}.fa-outdent:before{content:"\f03b"}.fa-page4:before{content:"\f3d7"}.fa-pagelines:before{content:"\f18c"}.fa-paint-brush:before{content:"\f1fc"}.fa-palette:before{content:"\f53f"}.fa-palfed:before{content:"\f3d8"}.fa-pallet:before{content:"\f482"}.fa-paper-plane:before{content:"\f1d8"}.fa-paperclip:before{content:"\f0c6"}.fa-parachute-box:before{content:"\f4cd"}.fa-paragraph:before{content:"\f1dd"}.fa-parking:before{content:"\f540"}.fa-paste:before{content:"\f0ea"}.fa-patreon:before{content:"\f3d9"}.fa-pause:before{content:"\f04c"}.fa-pause-circle:before{content:"\f28b"}.fa-paw:before{content:"\f1b0"}.fa-paypal:before{content:"\f1ed"}.fa-pen-square:before{content:"\f14b"}.fa-pencil-alt:before{content:"\f303"}.fa-people-carry:before{content:"\f4ce"}.fa-percent:before{content:"\f295"}.fa-percentage:before{content:"\f541"}.fa-periscope:before{content:"\f3da"}.fa-phabricator:before{content:"\f3db"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-phoenix-squadron:before{content:"\f511"}.fa-phone:before{content:"\f095"}.fa-phone-slash:before{content:"\f3dd"}.fa-phone-square:before{content:"\f098"}.fa-phone-volume:before{content:"\f2a0"}.fa-php:before{content:"\f457"}.fa-pied-piper:before{content:"\f2ae"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-piggy-bank:before{content:"\f4d3"}.fa-pills:before{content:"\f484"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-p:before{content:"\f231"}.fa-pinterest-square:before{content:"\f0d3"}.fa-plane:before{content:"\f072"}.fa-play:before{content:"\f04b"}.fa-play-circle:before{content:"\f144"}.fa-playstation:before{content:"\f3df"}.fa-plug:before{content:"\f1e6"}.fa-plus:before{content:"\f067"}.fa-plus-circle:before{content:"\f055"}.fa-plus-square:before{content:"\f0fe"}.fa-podcast:before{content:"\f2ce"}.fa-poo:before{content:"\f2fe"}.fa-portrait:before{content:"\f3e0"}.fa-pound-sign:before{content:"\f154"}.fa-power-off:before{content:"\f011"}.fa-prescription-bottle:before{content:"\f485"}.fa-prescription-bottle-alt:before{content:"\f486"}.fa-print:before{content:"\f02f"}.fa-procedures:before{content:"\f487"}.fa-product-hunt:before{content:"\f288"}.fa-project-diagram:before{content:"\f542"}.fa-pushed:before{content:"\f3e1"}.fa-puzzle-piece:before{content:"\f12e"}.fa-python:before{content:"\f3e2"}.fa-qq:before{content:"\f1d6"}.fa-qrcode:before{content:"\f029"}.fa-question:before{content:"\f128"}.fa-question-circle:before{content:"\f059"}.fa-quidditch:before{content:"\f458"}.fa-quinscape:before{content:"\f459"}.fa-quora:before{content:"\f2c4"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-r-project:before{content:"\f4f7"}.fa-random:before{content:"\f074"}.fa-ravelry:before{content:"\f2d9"}.fa-react:before{content:"\f41b"}.fa-readme:before{content:"\f4d5"}.fa-rebel:before{content:"\f1d0"}.fa-receipt:before{content:"\f543"}.fa-recycle:before{content:"\f1b8"}.fa-red-river:before{content:"\f3e3"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-alien:before{content:"\f281"}.fa-reddit-square:before{content:"\f1a2"}.fa-redo:before{content:"\f01e"}.fa-redo-alt:before{content:"\f2f9"}.fa-registered:before{content:"\f25d"}.fa-rendact:before{content:"\f3e4"}.fa-renren:before{content:"\f18b"}.fa-reply:before{content:"\f3e5"}.fa-reply-all:before{content:"\f122"}.fa-replyd:before{content:"\f3e6"}.fa-researchgate:before{content:"\f4f8"}.fa-resolving:before{content:"\f3e7"}.fa-retweet:before{content:"\f079"}.fa-ribbon:before{content:"\f4d6"}.fa-road:before{content:"\f018"}.fa-robot:before{content:"\f544"}.fa-rocket:before{content:"\f135"}.fa-rocketchat:before{content:"\f3e8"}.fa-rockrms:before{content:"\f3e9"}.fa-rss:before{content:"\f09e"}.fa-rss-square:before{content:"\f143"}.fa-ruble-sign:before{content:"\f158"}.fa-ruler:before{content:"\f545"}.fa-ruler-combined:before{content:"\f546"}.fa-ruler-horizontal:before{content:"\f547"}.fa-ruler-vertical:before{content:"\f548"}.fa-rupee-sign:before{content:"\f156"}.fa-safari:before{content:"\f267"}.fa-sass:before{content:"\f41e"}.fa-save:before{content:"\f0c7"}.fa-schlix:before{content:"\f3ea"}.fa-school:before{content:"\f549"}.fa-screwdriver:before{content:"\f54a"}.fa-scribd:before{content:"\f28a"}.fa-search:before{content:"\f002"}.fa-search-minus:before{content:"\f010"}.fa-search-plus:before{content:"\f00e"}.fa-searchengin:before{content:"\f3eb"}.fa-seedling:before{content:"\f4d8"}.fa-sellcast:before{content:"\f2da"}.fa-sellsy:before{content:"\f213"}.fa-server:before{content:"\f233"}.fa-servicestack:before{content:"\f3ec"}.fa-share:before{content:"\f064"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-share-square:before{content:"\f14d"}.fa-shekel-sign:before{content:"\f20b"}.fa-shield-alt:before{content:"\f3ed"}.fa-ship:before{content:"\f21a"}.fa-shipping-fast:before{content:"\f48b"}.fa-shirtsinbulk:before{content:"\f214"}.fa-shoe-prints:before{content:"\f54b"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-shopping-cart:before{content:"\f07a"}.fa-shower:before{content:"\f2cc"}.fa-sign:before{content:"\f4d9"}.fa-sign-in-alt:before{content:"\f2f6"}.fa-sign-language:before{content:"\f2a7"}.fa-sign-out-alt:before{content:"\f2f5"}.fa-signal:before{content:"\f012"}.fa-simplybuilt:before{content:"\f215"}.fa-sistrix:before{content:"\f3ee"}.fa-sitemap:before{content:"\f0e8"}.fa-sith:before{content:"\f512"}.fa-skull:before{content:"\f54c"}.fa-skyatlas:before{content:"\f216"}.fa-skype:before{content:"\f17e"}.fa-slack:before{content:"\f198"}.fa-slack-hash:before{content:"\f3ef"}.fa-sliders-h:before{content:"\f1de"}.fa-slideshare:before{content:"\f1e7"}.fa-smile:before{content:"\f118"}.fa-smoking:before{content:"\f48d"}.fa-smoking-ban:before{content:"\f54d"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-snowflake:before{content:"\f2dc"}.fa-sort:before{content:"\f0dc"}.fa-sort-alpha-down:before{content:"\f15d"}.fa-sort-alpha-up:before{content:"\f15e"}.fa-sort-amount-down:before{content:"\f160"}.fa-sort-amount-up:before{content:"\f161"}.fa-sort-down:before{content:"\f0dd"}.fa-sort-numeric-down:before{content:"\f162"}.fa-sort-numeric-up:before{content:"\f163"}.fa-sort-up:before{content:"\f0de"}.fa-soundcloud:before{content:"\f1be"}.fa-space-shuttle:before{content:"\f197"}.fa-speakap:before{content:"\f3f3"}.fa-spinner:before{content:"\f110"}.fa-spotify:before{content:"\f1bc"}.fa-square:before{content:"\f0c8"}.fa-square-full:before{content:"\f45c"}.fa-stack-exchange:before{content:"\f18d"}.fa-stack-overflow:before{content:"\f16c"}.fa-star:before{content:"\f005"}.fa-star-half:before{content:"\f089"}.fa-staylinked:before{content:"\f3f5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-steam-symbol:before{content:"\f3f6"}.fa-step-backward:before{content:"\f048"}.fa-step-forward:before{content:"\f051"}.fa-stethoscope:before{content:"\f0f1"}.fa-sticker-mule:before{content:"\f3f7"}.fa-sticky-note:before{content:"\f249"}.fa-stop:before{content:"\f04d"}.fa-stop-circle:before{content:"\f28d"}.fa-stopwatch:before{content:"\f2f2"}.fa-store:before{content:"\f54e"}.fa-store-alt:before{content:"\f54f"}.fa-strava:before{content:"\f428"}.fa-stream:before{content:"\f550"}.fa-street-view:before{content:"\f21d"}.fa-strikethrough:before{content:"\f0cc"}.fa-stripe:before{content:"\f429"}.fa-stripe-s:before{content:"\f42a"}.fa-stroopwafel:before{content:"\f551"}.fa-studiovinari:before{content:"\f3f8"}.fa-stumbleupon:before{content:"\f1a4"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-subscript:before{content:"\f12c"}.fa-subway:before{content:"\f239"}.fa-suitcase:before{content:"\f0f2"}.fa-sun:before{content:"\f185"}.fa-superpowers:before{content:"\f2dd"}.fa-superscript:before{content:"\f12b"}.fa-supple:before{content:"\f3f9"}.fa-sync:before{content:"\f021"}.fa-sync-alt:before{content:"\f2f1"}.fa-syringe:before{content:"\f48e"}.fa-table:before{content:"\f0ce"}.fa-table-tennis:before{content:"\f45d"}.fa-tablet:before{content:"\f10a"}.fa-tablet-alt:before{content:"\f3fa"}.fa-tablets:before{content:"\f490"}.fa-tachometer-alt:before{content:"\f3fd"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-tape:before{content:"\f4db"}.fa-tasks:before{content:"\f0ae"}.fa-taxi:before{content:"\f1ba"}.fa-teamspeak:before{content:"\f4f9"}.fa-telegram:before{content:"\f2c6"}.fa-telegram-plane:before{content:"\f3fe"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-terminal:before{content:"\f120"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-th:before{content:"\f00a"}.fa-th-large:before{content:"\f009"}.fa-th-list:before{content:"\f00b"}.fa-themeisle:before{content:"\f2b2"}.fa-thermometer:before{content:"\f491"}.fa-thermometer-empty:before{content:"\f2cb"}.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-thumbs-down:before{content:"\f165"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbtack:before{content:"\f08d"}.fa-ticket-alt:before{content:"\f3ff"}.fa-times:before{content:"\f00d"}.fa-times-circle:before{content:"\f057"}.fa-tint:before{content:"\f043"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-toolbox:before{content:"\f552"}.fa-trade-federation:before{content:"\f513"}.fa-trademark:before{content:"\f25c"}.fa-train:before{content:"\f238"}.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-trash:before{content:"\f1f8"}.fa-trash-alt:before{content:"\f2ed"}.fa-tree:before{content:"\f1bb"}.fa-trello:before{content:"\f181"}.fa-tripadvisor:before{content:"\f262"}.fa-trophy:before{content:"\f091"}.fa-truck:before{content:"\f0d1"}.fa-truck-loading:before{content:"\f4de"}.fa-truck-moving:before{content:"\f4df"}.fa-tshirt:before{content:"\f553"}.fa-tty:before{content:"\f1e4"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-tv:before{content:"\f26c"}.fa-twitch:before{content:"\f1e8"}.fa-twitter:before{content:"\f099"}.fa-twitter-square:before{content:"\f081"}.fa-typo3:before{content:"\f42b"}.fa-uber:before{content:"\f402"}.fa-uikit:before{content:"\f403"}.fa-umbrella:before{content:"\f0e9"}.fa-underline:before{content:"\f0cd"}.fa-undo:before{content:"\f0e2"}.fa-undo-alt:before{content:"\f2ea"}.fa-uniregistry:before{content:"\f404"}.fa-universal-access:before{content:"\f29a"}.fa-university:before{content:"\f19c"}.fa-unlink:before{content:"\f127"}.fa-unlock:before{content:"\f09c"}.fa-unlock-alt:before{content:"\f13e"}.fa-untappd:before{content:"\f405"}.fa-upload:before{content:"\f093"}.fa-usb:before{content:"\f287"}.fa-user:before{content:"\f007"}.fa-user-alt:before{content:"\f406"}.fa-user-alt-slash:before{content:"\f4fa"}.fa-user-astronaut:before{content:"\f4fb"}.fa-user-check:before{content:"\f4fc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-clock:before{content:"\f4fd"}.fa-user-cog:before{content:"\f4fe"}.fa-user-edit:before{content:"\f4ff"}.fa-user-friends:before{content:"\f500"}.fa-user-graduate:before{content:"\f501"}.fa-user-lock:before{content:"\f502"}.fa-user-md:before{content:"\f0f0"}.fa-user-minus:before{content:"\f503"}.fa-user-ninja:before{content:"\f504"}.fa-user-plus:before{content:"\f234"}.fa-user-secret:before{content:"\f21b"}.fa-user-shield:before{content:"\f505"}.fa-user-slash:before{content:"\f506"}.fa-user-tag:before{content:"\f507"}.fa-user-tie:before{content:"\f508"}.fa-user-times:before{content:"\f235"}.fa-users:before{content:"\f0c0"}.fa-users-cog:before{content:"\f509"}.fa-ussunnah:before{content:"\f407"}.fa-utensil-spoon:before{content:"\f2e5"}.fa-utensils:before{content:"\f2e7"}.fa-vaadin:before{content:"\f408"}.fa-venus:before{content:"\f221"}.fa-venus-double:before{content:"\f226"}.fa-venus-mars:before{content:"\f228"}.fa-viacoin:before{content:"\f237"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-vial:before{content:"\f492"}.fa-vials:before{content:"\f493"}.fa-viber:before{content:"\f409"}.fa-video:before{content:"\f03d"}.fa-video-slash:before{content:"\f4e2"}.fa-vimeo:before{content:"\f40a"}.fa-vimeo-square:before{content:"\f194"}.fa-vimeo-v:before{content:"\f27d"}.fa-vine:before{content:"\f1ca"}.fa-vk:before{content:"\f189"}.fa-vnv:before{content:"\f40b"}.fa-volleyball-ball:before{content:"\f45f"}.fa-volume-down:before{content:"\f027"}.fa-volume-off:before{content:"\f026"}.fa-volume-up:before{content:"\f028"}.fa-vuejs:before{content:"\f41f"}.fa-walking:before{content:"\f554"}.fa-wallet:before{content:"\f555"}.fa-warehouse:before{content:"\f494"}.fa-weibo:before{content:"\f18a"}.fa-weight:before{content:"\f496"}.fa-weixin:before{content:"\f1d7"}.fa-whatsapp:before{content:"\f232"}.fa-whatsapp-square:before{content:"\f40c"}.fa-wheelchair:before{content:"\f193"}.fa-whmcs:before{content:"\f40d"}.fa-wifi:before{content:"\f1eb"}.fa-wikipedia-w:before{content:"\f266"}.fa-window-close:before{content:"\f410"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-windows:before{content:"\f17a"}.fa-wine-glass:before{content:"\f4e3"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-won-sign:before{content:"\f159"}.fa-wordpress:before{content:"\f19a"}.fa-wordpress-simple:before{content:"\f411"}.fa-wpbeginner:before{content:"\f297"}.fa-wpexplorer:before{content:"\f2de"}.fa-wpforms:before{content:"\f298"}.fa-wrench:before{content:"\f0ad"}.fa-x-ray:before{content:"\f497"}.fa-xbox:before{content:"\f412"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-y-combinator:before{content:"\f23b"}.fa-yahoo:before{content:"\f19e"}.fa-yandex:before{content:"\f413"}.fa-yandex-international:before{content:"\f414"}.fa-yelp:before{content:"\f1e9"}.fa-yen-sign:before{content:"\f157"}.fa-yoast:before{content:"\f2b1"}.fa-youtube:before{content:"\f167"}.fa-youtube-square:before{content:"\f431"}.sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}@font-face{font-family:Font Awesome\ 5 Brands;font-style:normal;font-weight:400;src:url(../webfonts/fa-brands-400.eot);src:url(../webfonts/fa-brands-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.woff) format("woff"),url(../webfonts/fa-brands-400.ttf) format("truetype"),url(../webfonts/fa-brands-400.svg#fontawesome) format("svg")}.fab{font-family:Font Awesome\ 5 Brands}@font-face{font-family:Font Awesome\ 5 Free;font-style:normal;font-weight:400;src:url(../webfonts/fa-regular-400.eot);src:url(../webfonts/fa-regular-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.woff) format("woff"),url(../webfonts/fa-regular-400.ttf) format("truetype"),url(../webfonts/fa-regular-400.svg#fontawesome) format("svg")}.far{font-weight:400}@font-face{font-family:Font Awesome\ 5 Free;font-style:normal;font-weight:900;src:url(../webfonts/fa-solid-900.eot);src:url(../webfonts/fa-solid-900.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.woff) format("woff"),url(../webfonts/fa-solid-900.ttf) format("truetype"),url(../webfonts/fa-solid-900.svg#fontawesome) format("svg")}.fa,.far,.fas{font-family:Font Awesome\ 5 Free}.fa,.fas{font-weight:900} \ No newline at end of file diff --git a/src/deepfacility/viz/leaflet_template/css/images/cancel.png b/src/deepfacility/viz/leaflet_template/css/images/cancel.png new file mode 100644 index 0000000..a4e7c49 Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/cancel.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/cancel_@2X.png b/src/deepfacility/viz/leaflet_template/css/images/cancel_@2X.png new file mode 100644 index 0000000..dcc72f0 Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/cancel_@2X.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/check.png b/src/deepfacility/viz/leaflet_template/css/images/check.png new file mode 100644 index 0000000..55f274b Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/check.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/check_@2X.png b/src/deepfacility/viz/leaflet_template/css/images/check_@2X.png new file mode 100644 index 0000000..df8032e Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/check_@2X.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/focus.png b/src/deepfacility/viz/leaflet_template/css/images/focus.png new file mode 100644 index 0000000..5a87d24 Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/focus.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/focus_@2X.png b/src/deepfacility/viz/leaflet_template/css/images/focus_@2X.png new file mode 100644 index 0000000..1eb7dd4 Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/focus_@2X.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/layers-2x.png b/src/deepfacility/viz/leaflet_template/css/images/layers-2x.png new file mode 100644 index 0000000..a2cf7f9 Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/layers-2x.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/layers.png b/src/deepfacility/viz/leaflet_template/css/images/layers.png new file mode 100644 index 0000000..bca0a0e Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/layers.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/marker-icon-2x.png b/src/deepfacility/viz/leaflet_template/css/images/marker-icon-2x.png new file mode 100644 index 0000000..0015b64 Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/marker-icon-2x.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/marker-icon.png b/src/deepfacility/viz/leaflet_template/css/images/marker-icon.png new file mode 100644 index 0000000..e2e9f75 Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/marker-icon.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/marker-shadow.png b/src/deepfacility/viz/leaflet_template/css/images/marker-shadow.png new file mode 100644 index 0000000..d1e773c Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/marker-shadow.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/measure-control.png b/src/deepfacility/viz/leaflet_template/css/images/measure-control.png new file mode 100644 index 0000000..b88e8e4 Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/measure-control.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/rulers.png b/src/deepfacility/viz/leaflet_template/css/images/rulers.png new file mode 100644 index 0000000..b88e8e4 Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/rulers.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/rulers_@2X.png b/src/deepfacility/viz/leaflet_template/css/images/rulers_@2X.png new file mode 100644 index 0000000..7247a0c Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/rulers_@2X.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/start.png b/src/deepfacility/viz/leaflet_template/css/images/start.png new file mode 100644 index 0000000..b8ca942 Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/start.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/start_@2X.png b/src/deepfacility/viz/leaflet_template/css/images/start_@2X.png new file mode 100644 index 0000000..01da494 Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/start_@2X.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/throbber.gif b/src/deepfacility/viz/leaflet_template/css/images/throbber.gif new file mode 100644 index 0000000..c0c52a0 Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/throbber.gif differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/trash.png b/src/deepfacility/viz/leaflet_template/css/images/trash.png new file mode 100644 index 0000000..7ff478a Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/trash.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/images/trash_@2X.png b/src/deepfacility/viz/leaflet_template/css/images/trash_@2X.png new file mode 100644 index 0000000..fea11a8 Binary files /dev/null and b/src/deepfacility/viz/leaflet_template/css/images/trash_@2X.png differ diff --git a/src/deepfacility/viz/leaflet_template/css/leaflet.css b/src/deepfacility/viz/leaflet_template/css/leaflet.css new file mode 100644 index 0000000..609a662 --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/css/leaflet.css @@ -0,0 +1,640 @@ +/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg, +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + } + +.leaflet-container.leaflet-touch-zoom { + -ms-touch-action: pan-x pan-y; + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + -ms-touch-action: pinch-zoom; + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + -ms-touch-action: none; + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-tile { + will-change: opacity; + } +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + -ms-transform-origin: 0 0; + transform-origin: 0 0; + } +.leaflet-zoom-anim .leaflet-zoom-animated { + will-change: transform; + } +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + -moz-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline: 0; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-container a.leaflet-active { + outline: 2px solid orange; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a, +.leaflet-bar a:hover { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(images/layers.png); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(images/layers-2x.png); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { + background-image: url(images/marker-icon.png); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.7); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover { + text-decoration: underline; + } +.leaflet-container .leaflet-control-attribution, +.leaflet-container .leaflet-control-scale { + font-size: 11px; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + font-size: 11px; + white-space: nowrap; + overflow: hidden; + -moz-box-sizing: border-box; + box-sizing: border-box; + + background: #fff; + background: rgba(255, 255, 255, 0.5); + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 19px; + line-height: 1.4; + } +.leaflet-popup-content p { + margin: 18px 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + padding: 4px 4px 0 0; + border: none; + text-align: center; + width: 18px; + height: 14px; + font: 16px/14px Tahoma, Verdana, sans-serif; + color: #c3c3c3; + text-decoration: none; + font-weight: bold; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover { + color: #999; + } +.leaflet-popup-scrolled { + overflow: auto; + border-bottom: 1px solid #ddd; + border-top: 1px solid #ddd; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } +.leaflet-oldie .leaflet-popup-tip-container { + margin-top: -1px; + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-clickable { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } diff --git a/src/deepfacility/viz/leaflet_template/css/qgis2web.css b/src/deepfacility/viz/leaflet_template/css/qgis2web.css new file mode 100644 index 0000000..9bb0a02 --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/css/qgis2web.css @@ -0,0 +1,129 @@ + + #map { + background-color: #ffffff + } + th { + text-align: left; + vertical-align: top; + } + .info { + padding: 6px 8px; + font: 14px/16px Arial, Helvetica, sans-serif; + background: white; + background: rgba(255,255,255,0.8); + box-shadow: 0 0 15px rgba(0,0,0,0.2); + border-radius: 5px; + } + .info h2 { + margin: 0 0 5px; + color: #777; + } + .leaflet-container { + background: #fff; + padding-right: 10px; + } + .leaflet-popup-content { + width:auto; + padding-right:10px; + } + .leaflet-tooltip { + background: none; + box-shadow: none; + border: none; + } + .leaflet-tooltip-left:before, .leaflet-tooltip-right:before { + border: 0px; + } + } + .fa, .leaflet-container, a { + color: #000000 !important; + } + .leaflet-control-zoom-in, .leaflet-control-zoom-out, + .leaflet-control-locate a, + .leaflet-touch .leaflet-control-geocoder-icon, + .leaflet-control-search .search-button, + .leaflet-control-measure { + background-color: #f8f8f8 !important; + border-radius: 0px !important; + color: #000000 !important; + } + .abstract { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + background-color: #f8f8f8 !important; + width: 30px !important; + color: #000000 !important; + height: 30px !important; + text-align: center !important; + line-height: 30px !important; + } + .abstractUncollapsed { + padding: 6px 8px; + font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif; + background-color:#f8f8f8 !important; + color: #000000 !important; + box-shadow: 0 0 15px rgba(0,0,0,0.2); + border-radius: 5px; + max-width: 40%; + } + .leaflet-touch .leaflet-control-layers, + .leaflet-touch .leaflet-bar, + .leaflet-control-search, + .leaflet-control-measure { + border: 3px solid rgba(255,255,255,.4) !important; + } + .leaflet-control-attribution a { + color: #0078A8 !important; + } + .leaflet-control-scale-line { + border: 2px solid #f8f8f8 !important; + border-top: none !important; + color: black !important; + } + .leaflet-control-search .search-button, + .leaflet-container .leaflet-control-search, + .leaflet-control-measure { + box-shadow: none !important; + } + .leaflet-control-search .search-button { + width: 30px !important; + height: 30px !important; + font-size: 13px !important; + text-align: center !important; + line-height: 30px !important; + } + .leaflet-control-measure .leaflet-control { + width: 30px !important; + height: 30px !important; + } + .leaflet-container .leaflet-control-search{ + background: none !important; + } + .leaflet-control-search .search-input { + margin: 0px 0px 0px 0px !important; + height: 30px !important; + } + .leaflet-control-measure { + background: none!important; + border-radius: 4px !important; + } + .leaflet-control-measure .leaflet-control-measure-interaction { + background-color: #f8f8f8 !important; + } + .leaflet-touch .leaflet-control-measure + .leaflet-control-measure-toggle, + .leaflet-touch .leaflet-control-measure + .leaflet-control-measure-toggle:hover { + width: 30px !important; + height: 30px !important; + border-radius: 0px !important; + background-color: #f8f8f8 !important; + color: #000000 !important; + font-size: 13px; + line-height: 30px; + text-align: center; + text-indent: 0%; + } + .leaflet-control-layers-toggle { + background-color: #f8f8f8 !important; + } \ No newline at end of file diff --git a/src/deepfacility/viz/leaflet_template/index.html b/src/deepfacility/viz/leaflet_template/index.html new file mode 100644 index 0000000..7ba5656 --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/index.html @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + + +
+ + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/deepfacility/viz/leaflet_template/js/Autolinker.min.js b/src/deepfacility/viz/leaflet_template/js/Autolinker.min.js new file mode 100644 index 0000000..23f20b2 --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/js/Autolinker.min.js @@ -0,0 +1,11 @@ +/*! + * Autolinker.js + * 3.11.1 + * + * Copyright(c) 2019 Gregory Jacobs + * MIT License + * + * https://github.com/gregjacobs/Autolinker.js + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).Autolinker=t()}(this,function(){"use strict";function s(e,t){if(Array.prototype.indexOf)return e.indexOf(t);for(var u=0,n=e.length;u",this.getInnerHtml(),""].join("")},e.prototype.buildAttrsStr=function(){if(!this.attrs)return"";var e=this.getAttrs(),t=[];for(var u in e)e.hasOwnProperty(u)&&t.push(u+'="'+e[u]+'"');return t.join(" ")},e}();var r=function(){function e(e){void 0===e&&(e={}),this.newWindow=!1,this.truncate={},this.className="",this.newWindow=e.newWindow||!1,this.truncate=e.truncate||{},this.className=e.className||""}return e.prototype.build=function(e){return new u({tagName:"a",attrs:this.createAttrs(e),innerHtml:this.processAnchorText(e.getAnchorText())})},e.prototype.createAttrs=function(e){var t={href:e.getAnchorHref()},u=this.createCssClass(e);return u&&(t.class=u),this.newWindow&&(t.target="_blank",t.rel="noopener noreferrer"),this.truncate&&this.truncate.length&&this.truncate.length=h)return l.host.length==t?(l.host.substr(0,t-n)+i).substr(0,h+u):a(g,h).substr(0,h+u);var f="";if(l.path&&(f+="/"+l.path),l.query&&(f+="?"+l.query),f){if(h<=(g+f).length)return(g+f).length==t?(g+f).substr(0,t):(g+a(f,h-g.length)).substr(0,h+u);g+=f}if(l.fragment){var d="#"+l.fragment;if(h<=(g+d).length)return(g+d).length==t?(g+d).substr(0,t):(g+a(d,h-g.length)).substr(0,h+u);g+=d}if(l.scheme&&l.host){var m=l.scheme+"://";if((g+m).lengthr&&(i=null==a?(a="…",3):a.length,n=n.substring(0,r-i)+a),n)},e}(),e=function(){function e(e){this.__jsduckDummyDocProp=null,this.matchedText="",this.offset=0,this.tagBuilder=e.tagBuilder,this.matchedText=e.matchedText,this.offset=e.offset}return e.prototype.getMatchedText=function(){return this.matchedText},e.prototype.setOffset=function(e){this.offset=e},e.prototype.getOffset=function(){return this.offset},e.prototype.getCssClassSuffixes=function(){return[this.getType()]},e.prototype.buildTag=function(){return this.tagBuilder.build(this)},e}(),n=function(e,t){return(n=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var u in t)t.hasOwnProperty(u)&&(e[u]=t[u])})(e,t)};function a(e,t){function u(){this.constructor=e}n(e,t),e.prototype=null===t?Object.create(t):(u.prototype=t.prototype,new u)}var _=function(){return(_=Object.assign||function(e){for(var t,u=1,n=arguments.length;u"===e?(g=new V(_({},g,{name:H()})),O()):W.test(e)||I.test(e)||":"===e||N()}function b(e){">"===e?N():W.test(e)?l=3:N()}function x(e){L.test(e)||("/"===e?l=12:">"===e?O():"<"===e?S():"="===e||$.test(e)||Z.test(e)?N():l=5)}function y(e){L.test(e)?l=6:"/"===e?l=12:"="===e?l=7:">"===e?O():"<"===e?S():$.test(e)&&N()}function F(e){L.test(e)||("/"===e?l=12:"="===e?l=7:">"===e?O():"<"===e?S():$.test(e)?N():l=5)}function v(e){L.test(e)||('"'===e?l=8:"'"===e?l=9:/[>=`]/.test(e)?N():"<"===e?S():l=10)}function w(e){'"'===e&&(l=11)}function B(e){"'"===e&&(l=11)}function C(e){L.test(e)?l=4:">"===e?O():"<"===e&&S()}function D(e){L.test(e)?l=4:"/"===e?l=12:">"===e?O():"<"===e?S():(l=4,c--)}function E(e){">"===e?(g=new V(_({},g,{isClosing:!0})),O()):l=4}function k(e){"--"===t.substr(c,2)?(c+=2,g=new V(_({},g,{type:"comment"})),l=14):"DOCTYPE"===t.substr(c,7).toUpperCase()?(c+=7,g=new V(_({},g,{type:"doctype"})),l=20):N()}function T(e){"-"===e?l=15:">"===e?N():l=16}function M(e){"-"===e?l=18:">"===e?N():l=16}function P(e){"-"===e&&(l=17)}function j(e){l="-"===e?18:16}function z(e){">"===e?O():"!"===e?l=19:"-"===e||(l=16)}function R(e){"-"===e?l=17:">"===e?O():l=16}function q(e){">"===e?O():"<"===e&&S()}function N(){l=0,g=o}function S(){l=1,g=new V({idx:c})}function O(){var e=t.slice(p,g.idx);e&&a(e,p),"comment"===g.type?i(g.idx):"doctype"===g.type?s(g.idx):(g.isOpening&&n(g.name,g.idx),g.isClosing&&r(g.name,g.idx)),N(),p=c+1}function H(){var e=g.idx+(g.isClosing?2:1);return t.slice(e,c).toLowerCase()}pr?t:t+1;e.splice(i,1);continue}e[t+1].getOffset() 0; + }, + + _countLoaders: function() { + var size = 0, key; + for (key in this._dataLoaders) { + if (this._dataLoaders.hasOwnProperty(key)) size++; + } + return size; + }, + + _showIndicator: function() { + // Show loading indicator + L.DomUtil.addClass(this._indicator, 'is-loading'); + L.DomUtil.addClass(this._indicatorContainer, 'is-loading'); + + // If zoomControl exists, make the zoom-out button not last + if (!this.options.separate) { + if (this.zoomControl instanceof L.Control.Zoom) { + L.DomUtil.removeClass(this._getLastControlButton(), 'leaflet-bar-part-bottom'); + } + else if (typeof L.Control.Zoomslider === 'function' && this.zoomControl instanceof L.Control.Zoomslider) { + L.DomUtil.removeClass(this.zoomControl._ui.zoomOut, 'leaflet-bar-part-bottom'); + } + } + }, + + _hideIndicator: function() { + // Hide loading indicator + L.DomUtil.removeClass(this._indicator, 'is-loading'); + L.DomUtil.removeClass(this._indicatorContainer, 'is-loading'); + + // If zoomControl exists, make the zoom-out button last + if (!this.options.separate) { + if (this.zoomControl instanceof L.Control.Zoom) { + L.DomUtil.addClass(this._getLastControlButton(), 'leaflet-bar-part-bottom'); + } + else if (typeof L.Control.Zoomslider === 'function' && this.zoomControl instanceof L.Control.Zoomslider) { + L.DomUtil.addClass(this.zoomControl._ui.zoomOut, 'leaflet-bar-part-bottom'); + } + } + }, + + _getLastControlButton: function() { + var container = this.zoomControl._container, + index = container.children.length - 1; + + // Find the last visible control button that is not our loading + // indicator + while (index > 0) { + var button = container.children[index]; + if (!(this._indicator === button || button.offsetWidth === 0 || button.offsetHeight === 0)) { + break; + } + index--; + } + + return container.children[index]; + }, + + _handleLoading: function(e) { + this.addLoader(this.getEventId(e)); + }, + + _handleBaseLayerChange: function (e) { + var that = this; + + // Check for a target 'layer' that contains multiple layers, such as + // L.LayerGroup. This will happen if you have an L.LayerGroup in an + // L.Control.Layers. + if (e.layer && e.layer.eachLayer && typeof e.layer.eachLayer === 'function') { + e.layer.eachLayer(function (layer) { + that._handleBaseLayerChange({ layer: layer }); + }); + } + else { + // If we're changing to a canvas layer, don't handle loading + // as canvas layers will not fire load events. + if (!(L.TileLayer.Canvas && e.layer instanceof L.TileLayer.Canvas)) { + that._handleLoading(e); + } + } + }, + + _handleLoad: function(e) { + this.removeLoader(this.getEventId(e)); + }, + + getEventId: function(e) { + if (e.id) { + return e.id; + } + else if (e.layer) { + return e.layer._leaflet_id; + } + return e.target._leaflet_id; + }, + + _layerAdd: function(e) { + if (!e.layer || !e.layer.on) return + try { + e.layer.on({ + loading: this._handleLoading, + load: this._handleLoad + }, this); + } + catch (exception) { + console.warn('L.Control.Loading: Tried and failed to add ' + + ' event handlers to layer', e.layer); + console.warn('L.Control.Loading: Full details', exception); + } + }, + + _layerRemove: function(e) { + if (!e.layer || !e.layer.off) return; + try { + e.layer.off({ + loading: this._handleLoading, + load: this._handleLoad + }, this); + } + catch (exception) { + console.warn('L.Control.Loading: Tried and failed to remove ' + + 'event handlers from layer', e.layer); + console.warn('L.Control.Loading: Full details', exception); + } + }, + + _addLayerListeners: function(map) { + // Add listeners for begin and end of load to any layers already + // on the map + map.eachLayer(function(layer) { + if (!layer.on) return; + layer.on({ + loading: this._handleLoading, + load: this._handleLoad + }, this); + }, this); + + // When a layer is added to the map, add listeners for begin and + // end of load + map.on('layeradd', this._layerAdd, this); + map.on('layerremove', this._layerRemove, this); + }, + + _removeLayerListeners: function(map) { + // Remove listeners for begin and end of load from all layers + map.eachLayer(function(layer) { + if (!layer.off) return; + layer.off({ + loading: this._handleLoading, + load: this._handleLoad + }, this); + }, this); + + // Remove layeradd/layerremove listener from map + map.off('layeradd', this._layerAdd, this); + map.off('layerremove', this._layerRemove, this); + }, + + _addMapListeners: function(map) { + // Add listeners to the map for (custom) dataloading and dataload + // events, eg, for AJAX calls that affect the map but will not be + // reflected in the above layer events. + map.on({ + baselayerchange: this._handleBaseLayerChange, + dataloading: this._handleLoading, + dataload: this._handleLoad, + layerremove: this._handleLoad + }, this); + }, + + _removeMapListeners: function(map) { + map.off({ + baselayerchange: this._handleBaseLayerChange, + dataloading: this._handleLoading, + dataload: this._handleLoad, + layerremove: this._handleLoad + }, this); + } + }); + + L.Map.addInitHook(function () { + if (this.options.loadingControl) { + this.loadingControl = new L.Control.Loading(); + this.addControl(this.loadingControl); + } + }); + + L.Control.loading = function(options) { + return new L.Control.Loading(options); + }; + } + + if (typeof define === 'function' && define.amd) { + // Try to add leaflet.loading to Leaflet using AMD + define(['leaflet'], function (L) { + defineLeafletLoading(L); + }); + } + else { + // Else use the global L + defineLeafletLoading(L); + } + +})(); diff --git a/src/deepfacility/viz/leaflet_template/js/Leaflet.VectorGrid.js b/src/deepfacility/viz/leaflet_template/js/Leaflet.VectorGrid.js new file mode 100644 index 0000000..d28110f --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/js/Leaflet.VectorGrid.js @@ -0,0 +1,2585 @@ +(function () { +'use strict'; + +function __$strToBlobUri(str, mime, isBinary) {try {return window.URL.createObjectURL(new Blob([Uint8Array.from(str.split('').map(function(c) {return c.charCodeAt(0)}))], {type: mime}));} catch (e) {return "data:" + mime + (isBinary ? ";base64," : ",") + str;}} +(function(self) { + 'use strict'; + + if (self.fetch) { + return + } + + var support = { + searchParams: 'URLSearchParams' in self, + iterable: 'Symbol' in self && 'iterator' in Symbol, + blob: 'FileReader' in self && 'Blob' in self && (function() { + try { + new Blob(); + return true + } catch(e) { + return false + } + })(), + formData: 'FormData' in self, + arrayBuffer: 'ArrayBuffer' in self + }; + + if (support.arrayBuffer) { + var viewClasses = [ + '[object Int8Array]', + '[object Uint8Array]', + '[object Uint8ClampedArray]', + '[object Int16Array]', + '[object Uint16Array]', + '[object Int32Array]', + '[object Uint32Array]', + '[object Float32Array]', + '[object Float64Array]' + ]; + + var isDataView = function(obj) { + return obj && DataView.prototype.isPrototypeOf(obj) + }; + + var isArrayBufferView = ArrayBuffer.isView || function(obj) { + return obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1 + }; + } + + function normalizeName(name) { + if (typeof name !== 'string') { + name = String(name); + } + if (/[^a-z0-9\-#$%&'*+.\^_`|~]/i.test(name)) { + throw new TypeError('Invalid character in header field name') + } + return name.toLowerCase() + } + + function normalizeValue(value) { + if (typeof value !== 'string') { + value = String(value); + } + return value + } + + // Build a destructive iterator for the value list + function iteratorFor(items) { + var iterator = { + next: function() { + var value = items.shift(); + return {done: value === undefined, value: value} + } + }; + + if (support.iterable) { + iterator[Symbol.iterator] = function() { + return iterator + }; + } + + return iterator + } + + function Headers(headers) { + this.map = {}; + + if (headers instanceof Headers) { + headers.forEach(function(value, name) { + this.append(name, value); + }, this); + } else if (Array.isArray(headers)) { + headers.forEach(function(header) { + this.append(header[0], header[1]); + }, this); + } else if (headers) { + Object.getOwnPropertyNames(headers).forEach(function(name) { + this.append(name, headers[name]); + }, this); + } + } + + Headers.prototype.append = function(name, value) { + name = normalizeName(name); + value = normalizeValue(value); + var oldValue = this.map[name]; + this.map[name] = oldValue ? oldValue+','+value : value; + }; + + Headers.prototype['delete'] = function(name) { + delete this.map[normalizeName(name)]; + }; + + Headers.prototype.get = function(name) { + name = normalizeName(name); + return this.has(name) ? this.map[name] : null + }; + + Headers.prototype.has = function(name) { + return this.map.hasOwnProperty(normalizeName(name)) + }; + + Headers.prototype.set = function(name, value) { + this.map[normalizeName(name)] = normalizeValue(value); + }; + + Headers.prototype.forEach = function(callback, thisArg) { + var this$1 = this; + + for (var name in this.map) { + if (this$1.map.hasOwnProperty(name)) { + callback.call(thisArg, this$1.map[name], name, this$1); + } + } + }; + + Headers.prototype.keys = function() { + var items = []; + this.forEach(function(value, name) { items.push(name); }); + return iteratorFor(items) + }; + + Headers.prototype.values = function() { + var items = []; + this.forEach(function(value) { items.push(value); }); + return iteratorFor(items) + }; + + Headers.prototype.entries = function() { + var items = []; + this.forEach(function(value, name) { items.push([name, value]); }); + return iteratorFor(items) + }; + + if (support.iterable) { + Headers.prototype[Symbol.iterator] = Headers.prototype.entries; + } + + function consumed(body) { + if (body.bodyUsed) { + return Promise.reject(new TypeError('Already read')) + } + body.bodyUsed = true; + } + + function fileReaderReady(reader) { + return new Promise(function(resolve, reject) { + reader.onload = function() { + resolve(reader.result); + }; + reader.onerror = function() { + reject(reader.error); + }; + }) + } + + function readBlobAsArrayBuffer(blob) { + var reader = new FileReader(); + var promise = fileReaderReady(reader); + reader.readAsArrayBuffer(blob); + return promise + } + + function readBlobAsText(blob) { + var reader = new FileReader(); + var promise = fileReaderReady(reader); + reader.readAsText(blob); + return promise + } + + function readArrayBufferAsText(buf) { + var view = new Uint8Array(buf); + var chars = new Array(view.length); + + for (var i = 0; i < view.length; i++) { + chars[i] = String.fromCharCode(view[i]); + } + return chars.join('') + } + + function bufferClone(buf) { + if (buf.slice) { + return buf.slice(0) + } else { + var view = new Uint8Array(buf.byteLength); + view.set(new Uint8Array(buf)); + return view.buffer + } + } + + function Body() { + this.bodyUsed = false; + + this._initBody = function(body) { + this._bodyInit = body; + if (!body) { + this._bodyText = ''; + } else if (typeof body === 'string') { + this._bodyText = body; + } else if (support.blob && Blob.prototype.isPrototypeOf(body)) { + this._bodyBlob = body; + } else if (support.formData && FormData.prototype.isPrototypeOf(body)) { + this._bodyFormData = body; + } else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) { + this._bodyText = body.toString(); + } else if (support.arrayBuffer && support.blob && isDataView(body)) { + this._bodyArrayBuffer = bufferClone(body.buffer); + // IE 10-11 can't handle a DataView body. + this._bodyInit = new Blob([this._bodyArrayBuffer]); + } else if (support.arrayBuffer && (ArrayBuffer.prototype.isPrototypeOf(body) || isArrayBufferView(body))) { + this._bodyArrayBuffer = bufferClone(body); + } else { + throw new Error('unsupported BodyInit type') + } + + if (!this.headers.get('content-type')) { + if (typeof body === 'string') { + this.headers.set('content-type', 'text/plain;charset=UTF-8'); + } else if (this._bodyBlob && this._bodyBlob.type) { + this.headers.set('content-type', this._bodyBlob.type); + } else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) { + this.headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8'); + } + } + }; + + if (support.blob) { + this.blob = function() { + var rejected = consumed(this); + if (rejected) { + return rejected + } + + if (this._bodyBlob) { + return Promise.resolve(this._bodyBlob) + } else if (this._bodyArrayBuffer) { + return Promise.resolve(new Blob([this._bodyArrayBuffer])) + } else if (this._bodyFormData) { + throw new Error('could not read FormData body as blob') + } else { + return Promise.resolve(new Blob([this._bodyText])) + } + }; + + this.arrayBuffer = function() { + if (this._bodyArrayBuffer) { + return consumed(this) || Promise.resolve(this._bodyArrayBuffer) + } else { + return this.blob().then(readBlobAsArrayBuffer) + } + }; + } + + this.text = function() { + var rejected = consumed(this); + if (rejected) { + return rejected + } + + if (this._bodyBlob) { + return readBlobAsText(this._bodyBlob) + } else if (this._bodyArrayBuffer) { + return Promise.resolve(readArrayBufferAsText(this._bodyArrayBuffer)) + } else if (this._bodyFormData) { + throw new Error('could not read FormData body as text') + } else { + return Promise.resolve(this._bodyText) + } + }; + + if (support.formData) { + this.formData = function() { + return this.text().then(decode) + }; + } + + this.json = function() { + return this.text().then(JSON.parse) + }; + + return this + } + + // HTTP methods whose capitalization should be normalized + var methods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT']; + + function normalizeMethod(method) { + var upcased = method.toUpperCase(); + return (methods.indexOf(upcased) > -1) ? upcased : method + } + + function Request(input, options) { + options = options || {}; + var body = options.body; + + if (input instanceof Request) { + if (input.bodyUsed) { + throw new TypeError('Already read') + } + this.url = input.url; + this.credentials = input.credentials; + if (!options.headers) { + this.headers = new Headers(input.headers); + } + this.method = input.method; + this.mode = input.mode; + if (!body && input._bodyInit != null) { + body = input._bodyInit; + input.bodyUsed = true; + } + } else { + this.url = String(input); + } + + this.credentials = options.credentials || this.credentials || 'omit'; + if (options.headers || !this.headers) { + this.headers = new Headers(options.headers); + } + this.method = normalizeMethod(options.method || this.method || 'GET'); + this.mode = options.mode || this.mode || null; + this.referrer = null; + + if ((this.method === 'GET' || this.method === 'HEAD') && body) { + throw new TypeError('Body not allowed for GET or HEAD requests') + } + this._initBody(body); + } + + Request.prototype.clone = function() { + return new Request(this, { body: this._bodyInit }) + }; + + function decode(body) { + var form = new FormData(); + body.trim().split('&').forEach(function(bytes) { + if (bytes) { + var split = bytes.split('='); + var name = split.shift().replace(/\+/g, ' '); + var value = split.join('=').replace(/\+/g, ' '); + form.append(decodeURIComponent(name), decodeURIComponent(value)); + } + }); + return form + } + + function parseHeaders(rawHeaders) { + var headers = new Headers(); + rawHeaders.split(/\r?\n/).forEach(function(line) { + var parts = line.split(':'); + var key = parts.shift().trim(); + if (key) { + var value = parts.join(':').trim(); + headers.append(key, value); + } + }); + return headers + } + + Body.call(Request.prototype); + + function Response(bodyInit, options) { + if (!options) { + options = {}; + } + + this.type = 'default'; + this.status = 'status' in options ? options.status : 200; + this.ok = this.status >= 200 && this.status < 300; + this.statusText = 'statusText' in options ? options.statusText : 'OK'; + this.headers = new Headers(options.headers); + this.url = options.url || ''; + this._initBody(bodyInit); + } + + Body.call(Response.prototype); + + Response.prototype.clone = function() { + return new Response(this._bodyInit, { + status: this.status, + statusText: this.statusText, + headers: new Headers(this.headers), + url: this.url + }) + }; + + Response.error = function() { + var response = new Response(null, {status: 0, statusText: ''}); + response.type = 'error'; + return response + }; + + var redirectStatuses = [301, 302, 303, 307, 308]; + + Response.redirect = function(url, status) { + if (redirectStatuses.indexOf(status) === -1) { + throw new RangeError('Invalid status code') + } + + return new Response(null, {status: status, headers: {location: url}}) + }; + + self.Headers = Headers; + self.Request = Request; + self.Response = Response; + + self.fetch = function(input, init) { + return new Promise(function(resolve, reject) { + var request = new Request(input, init); + var xhr = new XMLHttpRequest(); + + xhr.onload = function() { + var options = { + status: xhr.status, + statusText: xhr.statusText, + headers: parseHeaders(xhr.getAllResponseHeaders() || '') + }; + options.url = 'responseURL' in xhr ? xhr.responseURL : options.headers.get('X-Request-URL'); + var body = 'response' in xhr ? xhr.response : xhr.responseText; + resolve(new Response(body, options)); + }; + + xhr.onerror = function() { + reject(new TypeError('Network request failed')); + }; + + xhr.ontimeout = function() { + reject(new TypeError('Network request failed')); + }; + + xhr.open(request.method, request.url, true); + + if (request.credentials === 'include') { + xhr.withCredentials = true; + } + + if ('responseType' in xhr && support.blob) { + xhr.responseType = 'blob'; + } + + request.headers.forEach(function(value, name) { + xhr.setRequestHeader(name, value); + }); + + xhr.send(typeof request._bodyInit === 'undefined' ? null : request._bodyInit); + }) + }; + self.fetch.polyfill = true; +})(typeof self !== 'undefined' ? self : undefined); + +var read = function (buffer, offset, isLE, mLen, nBytes) { + var e, m; + var eLen = nBytes * 8 - mLen - 1; + var eMax = (1 << eLen) - 1; + var eBias = eMax >> 1; + var nBits = -7; + var i = isLE ? (nBytes - 1) : 0; + var d = isLE ? -1 : 1; + var s = buffer[offset + i]; + + i += d; + + e = s & ((1 << (-nBits)) - 1); + s >>= (-nBits); + nBits += eLen; + for (; nBits > 0; e = e * 256 + buffer[offset + i], i += d, nBits -= 8) {} + + m = e & ((1 << (-nBits)) - 1); + e >>= (-nBits); + nBits += mLen; + for (; nBits > 0; m = m * 256 + buffer[offset + i], i += d, nBits -= 8) {} + + if (e === 0) { + e = 1 - eBias; + } else if (e === eMax) { + return m ? NaN : ((s ? -1 : 1) * Infinity) + } else { + m = m + Math.pow(2, mLen); + e = e - eBias; + } + return (s ? -1 : 1) * m * Math.pow(2, e - mLen) +}; + +var write = function (buffer, value, offset, isLE, mLen, nBytes) { + var e, m, c; + var eLen = nBytes * 8 - mLen - 1; + var eMax = (1 << eLen) - 1; + var eBias = eMax >> 1; + var rt = (mLen === 23 ? Math.pow(2, -24) - Math.pow(2, -77) : 0); + var i = isLE ? 0 : (nBytes - 1); + var d = isLE ? 1 : -1; + var s = value < 0 || (value === 0 && 1 / value < 0) ? 1 : 0; + + value = Math.abs(value); + + if (isNaN(value) || value === Infinity) { + m = isNaN(value) ? 1 : 0; + e = eMax; + } else { + e = Math.floor(Math.log(value) / Math.LN2); + if (value * (c = Math.pow(2, -e)) < 1) { + e--; + c *= 2; + } + if (e + eBias >= 1) { + value += rt / c; + } else { + value += rt * Math.pow(2, 1 - eBias); + } + if (value * c >= 2) { + e++; + c /= 2; + } + + if (e + eBias >= eMax) { + m = 0; + e = eMax; + } else if (e + eBias >= 1) { + m = (value * c - 1) * Math.pow(2, mLen); + e = e + eBias; + } else { + m = value * Math.pow(2, eBias - 1) * Math.pow(2, mLen); + e = 0; + } + } + + for (; mLen >= 8; buffer[offset + i] = m & 0xff, i += d, m /= 256, mLen -= 8) {} + + e = (e << mLen) | m; + eLen += mLen; + for (; eLen > 0; buffer[offset + i] = e & 0xff, i += d, e /= 256, eLen -= 8) {} + + buffer[offset + i - d] |= s * 128; +}; + +var index$1 = { + read: read, + write: write +}; + +var index = Pbf; + +var ieee754 = index$1; + +function Pbf(buf) { + this.buf = ArrayBuffer.isView && ArrayBuffer.isView(buf) ? buf : new Uint8Array(buf || 0); + this.pos = 0; + this.type = 0; + this.length = this.buf.length; +} + +Pbf.Varint = 0; // varint: int32, int64, uint32, uint64, sint32, sint64, bool, enum +Pbf.Fixed64 = 1; // 64-bit: double, fixed64, sfixed64 +Pbf.Bytes = 2; // length-delimited: string, bytes, embedded messages, packed repeated fields +Pbf.Fixed32 = 5; // 32-bit: float, fixed32, sfixed32 + +var SHIFT_LEFT_32 = (1 << 16) * (1 << 16); +var SHIFT_RIGHT_32 = 1 / SHIFT_LEFT_32; + +Pbf.prototype = { + + destroy: function() { + this.buf = null; + }, + + // === READING ================================================================= + + readFields: function(readField, result, end) { + var this$1 = this; + + end = end || this.length; + + while (this.pos < end) { + var val = this$1.readVarint(), + tag = val >> 3, + startPos = this$1.pos; + + this$1.type = val & 0x7; + readField(tag, result, this$1); + + if (this$1.pos === startPos) { this$1.skip(val); } + } + return result; + }, + + readMessage: function(readField, result) { + return this.readFields(readField, result, this.readVarint() + this.pos); + }, + + readFixed32: function() { + var val = readUInt32(this.buf, this.pos); + this.pos += 4; + return val; + }, + + readSFixed32: function() { + var val = readInt32(this.buf, this.pos); + this.pos += 4; + return val; + }, + + // 64-bit int handling is based on github.com/dpw/node-buffer-more-ints (MIT-licensed) + + readFixed64: function() { + var val = readUInt32(this.buf, this.pos) + readUInt32(this.buf, this.pos + 4) * SHIFT_LEFT_32; + this.pos += 8; + return val; + }, + + readSFixed64: function() { + var val = readUInt32(this.buf, this.pos) + readInt32(this.buf, this.pos + 4) * SHIFT_LEFT_32; + this.pos += 8; + return val; + }, + + readFloat: function() { + var val = ieee754.read(this.buf, this.pos, true, 23, 4); + this.pos += 4; + return val; + }, + + readDouble: function() { + var val = ieee754.read(this.buf, this.pos, true, 52, 8); + this.pos += 8; + return val; + }, + + readVarint: function(isSigned) { + var buf = this.buf, + val, b; + + b = buf[this.pos++]; val = b & 0x7f; if (b < 0x80) { return val; } + b = buf[this.pos++]; val |= (b & 0x7f) << 7; if (b < 0x80) { return val; } + b = buf[this.pos++]; val |= (b & 0x7f) << 14; if (b < 0x80) { return val; } + b = buf[this.pos++]; val |= (b & 0x7f) << 21; if (b < 0x80) { return val; } + b = buf[this.pos]; val |= (b & 0x0f) << 28; + + return readVarintRemainder(val, isSigned, this); + }, + + readVarint64: function() { // for compatibility with v2.0.1 + return this.readVarint(true); + }, + + readSVarint: function() { + var num = this.readVarint(); + return num % 2 === 1 ? (num + 1) / -2 : num / 2; // zigzag encoding + }, + + readBoolean: function() { + return Boolean(this.readVarint()); + }, + + readString: function() { + var end = this.readVarint() + this.pos, + str = readUtf8(this.buf, this.pos, end); + this.pos = end; + return str; + }, + + readBytes: function() { + var end = this.readVarint() + this.pos, + buffer = this.buf.subarray(this.pos, end); + this.pos = end; + return buffer; + }, + + // verbose for performance reasons; doesn't affect gzipped size + + readPackedVarint: function(arr, isSigned) { + var this$1 = this; + + var end = readPackedEnd(this); + arr = arr || []; + while (this.pos < end) { arr.push(this$1.readVarint(isSigned)); } + return arr; + }, + readPackedSVarint: function(arr) { + var this$1 = this; + + var end = readPackedEnd(this); + arr = arr || []; + while (this.pos < end) { arr.push(this$1.readSVarint()); } + return arr; + }, + readPackedBoolean: function(arr) { + var this$1 = this; + + var end = readPackedEnd(this); + arr = arr || []; + while (this.pos < end) { arr.push(this$1.readBoolean()); } + return arr; + }, + readPackedFloat: function(arr) { + var this$1 = this; + + var end = readPackedEnd(this); + arr = arr || []; + while (this.pos < end) { arr.push(this$1.readFloat()); } + return arr; + }, + readPackedDouble: function(arr) { + var this$1 = this; + + var end = readPackedEnd(this); + arr = arr || []; + while (this.pos < end) { arr.push(this$1.readDouble()); } + return arr; + }, + readPackedFixed32: function(arr) { + var this$1 = this; + + var end = readPackedEnd(this); + arr = arr || []; + while (this.pos < end) { arr.push(this$1.readFixed32()); } + return arr; + }, + readPackedSFixed32: function(arr) { + var this$1 = this; + + var end = readPackedEnd(this); + arr = arr || []; + while (this.pos < end) { arr.push(this$1.readSFixed32()); } + return arr; + }, + readPackedFixed64: function(arr) { + var this$1 = this; + + var end = readPackedEnd(this); + arr = arr || []; + while (this.pos < end) { arr.push(this$1.readFixed64()); } + return arr; + }, + readPackedSFixed64: function(arr) { + var this$1 = this; + + var end = readPackedEnd(this); + arr = arr || []; + while (this.pos < end) { arr.push(this$1.readSFixed64()); } + return arr; + }, + + skip: function(val) { + var type = val & 0x7; + if (type === Pbf.Varint) { while (this.buf[this.pos++] > 0x7f) {} } + else if (type === Pbf.Bytes) { this.pos = this.readVarint() + this.pos; } + else if (type === Pbf.Fixed32) { this.pos += 4; } + else if (type === Pbf.Fixed64) { this.pos += 8; } + else { throw new Error('Unimplemented type: ' + type); } + }, + + // === WRITING ================================================================= + + writeTag: function(tag, type) { + this.writeVarint((tag << 3) | type); + }, + + realloc: function(min) { + var length = this.length || 16; + + while (length < this.pos + min) { length *= 2; } + + if (length !== this.length) { + var buf = new Uint8Array(length); + buf.set(this.buf); + this.buf = buf; + this.length = length; + } + }, + + finish: function() { + this.length = this.pos; + this.pos = 0; + return this.buf.subarray(0, this.length); + }, + + writeFixed32: function(val) { + this.realloc(4); + writeInt32(this.buf, val, this.pos); + this.pos += 4; + }, + + writeSFixed32: function(val) { + this.realloc(4); + writeInt32(this.buf, val, this.pos); + this.pos += 4; + }, + + writeFixed64: function(val) { + this.realloc(8); + writeInt32(this.buf, val & -1, this.pos); + writeInt32(this.buf, Math.floor(val * SHIFT_RIGHT_32), this.pos + 4); + this.pos += 8; + }, + + writeSFixed64: function(val) { + this.realloc(8); + writeInt32(this.buf, val & -1, this.pos); + writeInt32(this.buf, Math.floor(val * SHIFT_RIGHT_32), this.pos + 4); + this.pos += 8; + }, + + writeVarint: function(val) { + val = +val || 0; + + if (val > 0xfffffff || val < 0) { + writeBigVarint(val, this); + return; + } + + this.realloc(4); + + this.buf[this.pos++] = val & 0x7f | (val > 0x7f ? 0x80 : 0); if (val <= 0x7f) { return; } + this.buf[this.pos++] = ((val >>>= 7) & 0x7f) | (val > 0x7f ? 0x80 : 0); if (val <= 0x7f) { return; } + this.buf[this.pos++] = ((val >>>= 7) & 0x7f) | (val > 0x7f ? 0x80 : 0); if (val <= 0x7f) { return; } + this.buf[this.pos++] = (val >>> 7) & 0x7f; + }, + + writeSVarint: function(val) { + this.writeVarint(val < 0 ? -val * 2 - 1 : val * 2); + }, + + writeBoolean: function(val) { + this.writeVarint(Boolean(val)); + }, + + writeString: function(str) { + str = String(str); + this.realloc(str.length * 4); + + this.pos++; // reserve 1 byte for short string length + + var startPos = this.pos; + // write the string directly to the buffer and see how much was written + this.pos = writeUtf8(this.buf, str, this.pos); + var len = this.pos - startPos; + + if (len >= 0x80) { makeRoomForExtraLength(startPos, len, this); } + + // finally, write the message length in the reserved place and restore the position + this.pos = startPos - 1; + this.writeVarint(len); + this.pos += len; + }, + + writeFloat: function(val) { + this.realloc(4); + ieee754.write(this.buf, val, this.pos, true, 23, 4); + this.pos += 4; + }, + + writeDouble: function(val) { + this.realloc(8); + ieee754.write(this.buf, val, this.pos, true, 52, 8); + this.pos += 8; + }, + + writeBytes: function(buffer) { + var this$1 = this; + + var len = buffer.length; + this.writeVarint(len); + this.realloc(len); + for (var i = 0; i < len; i++) { this$1.buf[this$1.pos++] = buffer[i]; } + }, + + writeRawMessage: function(fn, obj) { + this.pos++; // reserve 1 byte for short message length + + // write the message directly to the buffer and see how much was written + var startPos = this.pos; + fn(obj, this); + var len = this.pos - startPos; + + if (len >= 0x80) { makeRoomForExtraLength(startPos, len, this); } + + // finally, write the message length in the reserved place and restore the position + this.pos = startPos - 1; + this.writeVarint(len); + this.pos += len; + }, + + writeMessage: function(tag, fn, obj) { + this.writeTag(tag, Pbf.Bytes); + this.writeRawMessage(fn, obj); + }, + + writePackedVarint: function(tag, arr) { this.writeMessage(tag, writePackedVarint, arr); }, + writePackedSVarint: function(tag, arr) { this.writeMessage(tag, writePackedSVarint, arr); }, + writePackedBoolean: function(tag, arr) { this.writeMessage(tag, writePackedBoolean, arr); }, + writePackedFloat: function(tag, arr) { this.writeMessage(tag, writePackedFloat, arr); }, + writePackedDouble: function(tag, arr) { this.writeMessage(tag, writePackedDouble, arr); }, + writePackedFixed32: function(tag, arr) { this.writeMessage(tag, writePackedFixed32, arr); }, + writePackedSFixed32: function(tag, arr) { this.writeMessage(tag, writePackedSFixed32, arr); }, + writePackedFixed64: function(tag, arr) { this.writeMessage(tag, writePackedFixed64, arr); }, + writePackedSFixed64: function(tag, arr) { this.writeMessage(tag, writePackedSFixed64, arr); }, + + writeBytesField: function(tag, buffer) { + this.writeTag(tag, Pbf.Bytes); + this.writeBytes(buffer); + }, + writeFixed32Field: function(tag, val) { + this.writeTag(tag, Pbf.Fixed32); + this.writeFixed32(val); + }, + writeSFixed32Field: function(tag, val) { + this.writeTag(tag, Pbf.Fixed32); + this.writeSFixed32(val); + }, + writeFixed64Field: function(tag, val) { + this.writeTag(tag, Pbf.Fixed64); + this.writeFixed64(val); + }, + writeSFixed64Field: function(tag, val) { + this.writeTag(tag, Pbf.Fixed64); + this.writeSFixed64(val); + }, + writeVarintField: function(tag, val) { + this.writeTag(tag, Pbf.Varint); + this.writeVarint(val); + }, + writeSVarintField: function(tag, val) { + this.writeTag(tag, Pbf.Varint); + this.writeSVarint(val); + }, + writeStringField: function(tag, str) { + this.writeTag(tag, Pbf.Bytes); + this.writeString(str); + }, + writeFloatField: function(tag, val) { + this.writeTag(tag, Pbf.Fixed32); + this.writeFloat(val); + }, + writeDoubleField: function(tag, val) { + this.writeTag(tag, Pbf.Fixed64); + this.writeDouble(val); + }, + writeBooleanField: function(tag, val) { + this.writeVarintField(tag, Boolean(val)); + } +}; + +function readVarintRemainder(l, s, p) { + var buf = p.buf, + h, b; + + b = buf[p.pos++]; h = (b & 0x70) >> 4; if (b < 0x80) { return toNum(l, h, s); } + b = buf[p.pos++]; h |= (b & 0x7f) << 3; if (b < 0x80) { return toNum(l, h, s); } + b = buf[p.pos++]; h |= (b & 0x7f) << 10; if (b < 0x80) { return toNum(l, h, s); } + b = buf[p.pos++]; h |= (b & 0x7f) << 17; if (b < 0x80) { return toNum(l, h, s); } + b = buf[p.pos++]; h |= (b & 0x7f) << 24; if (b < 0x80) { return toNum(l, h, s); } + b = buf[p.pos++]; h |= (b & 0x01) << 31; if (b < 0x80) { return toNum(l, h, s); } + + throw new Error('Expected varint not more than 10 bytes'); +} + +function readPackedEnd(pbf) { + return pbf.type === Pbf.Bytes ? + pbf.readVarint() + pbf.pos : pbf.pos + 1; +} + +function toNum(low, high, isSigned) { + if (isSigned) { + return high * 0x100000000 + (low >>> 0); + } + + return ((high >>> 0) * 0x100000000) + (low >>> 0); +} + +function writeBigVarint(val, pbf) { + var low, high; + + if (val >= 0) { + low = (val % 0x100000000) | 0; + high = (val / 0x100000000) | 0; + } else { + low = ~(-val % 0x100000000); + high = ~(-val / 0x100000000); + + if (low ^ 0xffffffff) { + low = (low + 1) | 0; + } else { + low = 0; + high = (high + 1) | 0; + } + } + + if (val >= 0x10000000000000000 || val < -0x10000000000000000) { + throw new Error('Given varint doesn\'t fit into 10 bytes'); + } + + pbf.realloc(10); + + writeBigVarintLow(low, high, pbf); + writeBigVarintHigh(high, pbf); +} + +function writeBigVarintLow(low, high, pbf) { + pbf.buf[pbf.pos++] = low & 0x7f | 0x80; low >>>= 7; + pbf.buf[pbf.pos++] = low & 0x7f | 0x80; low >>>= 7; + pbf.buf[pbf.pos++] = low & 0x7f | 0x80; low >>>= 7; + pbf.buf[pbf.pos++] = low & 0x7f | 0x80; low >>>= 7; + pbf.buf[pbf.pos] = low & 0x7f; +} + +function writeBigVarintHigh(high, pbf) { + var lsb = (high & 0x07) << 4; + + pbf.buf[pbf.pos++] |= lsb | ((high >>>= 3) ? 0x80 : 0); if (!high) { return; } + pbf.buf[pbf.pos++] = high & 0x7f | ((high >>>= 7) ? 0x80 : 0); if (!high) { return; } + pbf.buf[pbf.pos++] = high & 0x7f | ((high >>>= 7) ? 0x80 : 0); if (!high) { return; } + pbf.buf[pbf.pos++] = high & 0x7f | ((high >>>= 7) ? 0x80 : 0); if (!high) { return; } + pbf.buf[pbf.pos++] = high & 0x7f | ((high >>>= 7) ? 0x80 : 0); if (!high) { return; } + pbf.buf[pbf.pos++] = high & 0x7f; +} + +function makeRoomForExtraLength(startPos, len, pbf) { + var extraLen = + len <= 0x3fff ? 1 : + len <= 0x1fffff ? 2 : + len <= 0xfffffff ? 3 : Math.ceil(Math.log(len) / (Math.LN2 * 7)); + + // if 1 byte isn't enough for encoding message length, shift the data to the right + pbf.realloc(extraLen); + for (var i = pbf.pos - 1; i >= startPos; i--) { pbf.buf[i + extraLen] = pbf.buf[i]; } +} + +function writePackedVarint(arr, pbf) { for (var i = 0; i < arr.length; i++) { pbf.writeVarint(arr[i]); } } +function writePackedSVarint(arr, pbf) { for (var i = 0; i < arr.length; i++) { pbf.writeSVarint(arr[i]); } } +function writePackedFloat(arr, pbf) { for (var i = 0; i < arr.length; i++) { pbf.writeFloat(arr[i]); } } +function writePackedDouble(arr, pbf) { for (var i = 0; i < arr.length; i++) { pbf.writeDouble(arr[i]); } } +function writePackedBoolean(arr, pbf) { for (var i = 0; i < arr.length; i++) { pbf.writeBoolean(arr[i]); } } +function writePackedFixed32(arr, pbf) { for (var i = 0; i < arr.length; i++) { pbf.writeFixed32(arr[i]); } } +function writePackedSFixed32(arr, pbf) { for (var i = 0; i < arr.length; i++) { pbf.writeSFixed32(arr[i]); } } +function writePackedFixed64(arr, pbf) { for (var i = 0; i < arr.length; i++) { pbf.writeFixed64(arr[i]); } } +function writePackedSFixed64(arr, pbf) { for (var i = 0; i < arr.length; i++) { pbf.writeSFixed64(arr[i]); } } + +// Buffer code below from https://github.com/feross/buffer, MIT-licensed + +function readUInt32(buf, pos) { + return ((buf[pos]) | + (buf[pos + 1] << 8) | + (buf[pos + 2] << 16)) + + (buf[pos + 3] * 0x1000000); +} + +function writeInt32(buf, val, pos) { + buf[pos] = val; + buf[pos + 1] = (val >>> 8); + buf[pos + 2] = (val >>> 16); + buf[pos + 3] = (val >>> 24); +} + +function readInt32(buf, pos) { + return ((buf[pos]) | + (buf[pos + 1] << 8) | + (buf[pos + 2] << 16)) + + (buf[pos + 3] << 24); +} + +function readUtf8(buf, pos, end) { + var str = ''; + var i = pos; + + while (i < end) { + var b0 = buf[i]; + var c = null; // codepoint + var bytesPerSequence = + b0 > 0xEF ? 4 : + b0 > 0xDF ? 3 : + b0 > 0xBF ? 2 : 1; + + if (i + bytesPerSequence > end) { break; } + + var b1, b2, b3; + + if (bytesPerSequence === 1) { + if (b0 < 0x80) { + c = b0; + } + } else if (bytesPerSequence === 2) { + b1 = buf[i + 1]; + if ((b1 & 0xC0) === 0x80) { + c = (b0 & 0x1F) << 0x6 | (b1 & 0x3F); + if (c <= 0x7F) { + c = null; + } + } + } else if (bytesPerSequence === 3) { + b1 = buf[i + 1]; + b2 = buf[i + 2]; + if ((b1 & 0xC0) === 0x80 && (b2 & 0xC0) === 0x80) { + c = (b0 & 0xF) << 0xC | (b1 & 0x3F) << 0x6 | (b2 & 0x3F); + if (c <= 0x7FF || (c >= 0xD800 && c <= 0xDFFF)) { + c = null; + } + } + } else if (bytesPerSequence === 4) { + b1 = buf[i + 1]; + b2 = buf[i + 2]; + b3 = buf[i + 3]; + if ((b1 & 0xC0) === 0x80 && (b2 & 0xC0) === 0x80 && (b3 & 0xC0) === 0x80) { + c = (b0 & 0xF) << 0x12 | (b1 & 0x3F) << 0xC | (b2 & 0x3F) << 0x6 | (b3 & 0x3F); + if (c <= 0xFFFF || c >= 0x110000) { + c = null; + } + } + } + + if (c === null) { + c = 0xFFFD; + bytesPerSequence = 1; + + } else if (c > 0xFFFF) { + c -= 0x10000; + str += String.fromCharCode(c >>> 10 & 0x3FF | 0xD800); + c = 0xDC00 | c & 0x3FF; + } + + str += String.fromCharCode(c); + i += bytesPerSequence; + } + + return str; +} + +function writeUtf8(buf, str, pos) { + for (var i = 0, c, lead; i < str.length; i++) { + c = str.charCodeAt(i); // code point + + if (c > 0xD7FF && c < 0xE000) { + if (lead) { + if (c < 0xDC00) { + buf[pos++] = 0xEF; + buf[pos++] = 0xBF; + buf[pos++] = 0xBD; + lead = c; + continue; + } else { + c = lead - 0xD800 << 10 | c - 0xDC00 | 0x10000; + lead = null; + } + } else { + if (c > 0xDBFF || (i + 1 === str.length)) { + buf[pos++] = 0xEF; + buf[pos++] = 0xBF; + buf[pos++] = 0xBD; + } else { + lead = c; + } + continue; + } + } else if (lead) { + buf[pos++] = 0xEF; + buf[pos++] = 0xBF; + buf[pos++] = 0xBD; + lead = null; + } + + if (c < 0x80) { + buf[pos++] = c; + } else { + if (c < 0x800) { + buf[pos++] = c >> 0x6 | 0xC0; + } else { + if (c < 0x10000) { + buf[pos++] = c >> 0xC | 0xE0; + } else { + buf[pos++] = c >> 0x12 | 0xF0; + buf[pos++] = c >> 0xC & 0x3F | 0x80; + } + buf[pos++] = c >> 0x6 & 0x3F | 0x80; + } + buf[pos++] = c & 0x3F | 0x80; + } + } + return pos; +} + +var index$5 = Point$1; + +function Point$1(x, y) { + this.x = x; + this.y = y; +} + +Point$1.prototype = { + clone: function() { return new Point$1(this.x, this.y); }, + + add: function(p) { return this.clone()._add(p); }, + sub: function(p) { return this.clone()._sub(p); }, + mult: function(k) { return this.clone()._mult(k); }, + div: function(k) { return this.clone()._div(k); }, + rotate: function(a) { return this.clone()._rotate(a); }, + matMult: function(m) { return this.clone()._matMult(m); }, + unit: function() { return this.clone()._unit(); }, + perp: function() { return this.clone()._perp(); }, + round: function() { return this.clone()._round(); }, + + mag: function() { + return Math.sqrt(this.x * this.x + this.y * this.y); + }, + + equals: function(p) { + return this.x === p.x && + this.y === p.y; + }, + + dist: function(p) { + return Math.sqrt(this.distSqr(p)); + }, + + distSqr: function(p) { + var dx = p.x - this.x, + dy = p.y - this.y; + return dx * dx + dy * dy; + }, + + angle: function() { + return Math.atan2(this.y, this.x); + }, + + angleTo: function(b) { + return Math.atan2(this.y - b.y, this.x - b.x); + }, + + angleWith: function(b) { + return this.angleWithSep(b.x, b.y); + }, + + // Find the angle of the two vectors, solving the formula for the cross product a x b = |a||b|sin(?) for ?. + angleWithSep: function(x, y) { + return Math.atan2( + this.x * y - this.y * x, + this.x * x + this.y * y); + }, + + _matMult: function(m) { + var x = m[0] * this.x + m[1] * this.y, + y = m[2] * this.x + m[3] * this.y; + this.x = x; + this.y = y; + return this; + }, + + _add: function(p) { + this.x += p.x; + this.y += p.y; + return this; + }, + + _sub: function(p) { + this.x -= p.x; + this.y -= p.y; + return this; + }, + + _mult: function(k) { + this.x *= k; + this.y *= k; + return this; + }, + + _div: function(k) { + this.x /= k; + this.y /= k; + return this; + }, + + _unit: function() { + this._div(this.mag()); + return this; + }, + + _perp: function() { + var y = this.y; + this.y = this.x; + this.x = -y; + return this; + }, + + _rotate: function(angle) { + var cos = Math.cos(angle), + sin = Math.sin(angle), + x = cos * this.x - sin * this.y, + y = sin * this.x + cos * this.y; + this.x = x; + this.y = y; + return this; + }, + + _round: function() { + this.x = Math.round(this.x); + this.y = Math.round(this.y); + return this; + } +}; + +// constructs Point from an array if necessary +Point$1.convert = function (a) { + if (a instanceof Point$1) { + return a; + } + if (Array.isArray(a)) { + return new Point$1(a[0], a[1]); + } + return a; +}; + +var Point = index$5; + +var vectortilefeature = VectorTileFeature$2; + +function VectorTileFeature$2(pbf, end, extent, keys, values) { + // Public + this.properties = {}; + this.extent = extent; + this.type = 0; + + // Private + this._pbf = pbf; + this._geometry = -1; + this._keys = keys; + this._values = values; + + pbf.readFields(readFeature, this, end); +} + +function readFeature(tag, feature, pbf) { + if (tag == 1) { feature.id = pbf.readVarint(); } + else if (tag == 2) { readTag(pbf, feature); } + else if (tag == 3) { feature.type = pbf.readVarint(); } + else if (tag == 4) { feature._geometry = pbf.pos; } +} + +function readTag(pbf, feature) { + var end = pbf.readVarint() + pbf.pos; + + while (pbf.pos < end) { + var key = feature._keys[pbf.readVarint()], + value = feature._values[pbf.readVarint()]; + feature.properties[key] = value; + } +} + +VectorTileFeature$2.types = ['Unknown', 'Point', 'LineString', 'Polygon']; + +VectorTileFeature$2.prototype.loadGeometry = function() { + var pbf = this._pbf; + pbf.pos = this._geometry; + + var end = pbf.readVarint() + pbf.pos, + cmd = 1, + length = 0, + x = 0, + y = 0, + lines = [], + line; + + while (pbf.pos < end) { + if (!length) { + var cmdLen = pbf.readVarint(); + cmd = cmdLen & 0x7; + length = cmdLen >> 3; + } + + length--; + + if (cmd === 1 || cmd === 2) { + x += pbf.readSVarint(); + y += pbf.readSVarint(); + + if (cmd === 1) { // moveTo + if (line) { lines.push(line); } + line = []; + } + + line.push(new Point(x, y)); + + } else if (cmd === 7) { + + // Workaround for https://github.com/mapbox/mapnik-vector-tile/issues/90 + if (line) { + line.push(line[0].clone()); // closePolygon + } + + } else { + throw new Error('unknown command ' + cmd); + } + } + + if (line) { lines.push(line); } + + return lines; +}; + +VectorTileFeature$2.prototype.bbox = function() { + var pbf = this._pbf; + pbf.pos = this._geometry; + + var end = pbf.readVarint() + pbf.pos, + cmd = 1, + length = 0, + x = 0, + y = 0, + x1 = Infinity, + x2 = -Infinity, + y1 = Infinity, + y2 = -Infinity; + + while (pbf.pos < end) { + if (!length) { + var cmdLen = pbf.readVarint(); + cmd = cmdLen & 0x7; + length = cmdLen >> 3; + } + + length--; + + if (cmd === 1 || cmd === 2) { + x += pbf.readSVarint(); + y += pbf.readSVarint(); + if (x < x1) { x1 = x; } + if (x > x2) { x2 = x; } + if (y < y1) { y1 = y; } + if (y > y2) { y2 = y; } + + } else if (cmd !== 7) { + throw new Error('unknown command ' + cmd); + } + } + + return [x1, y1, x2, y2]; +}; + +VectorTileFeature$2.prototype.toGeoJSON = function(x, y, z) { + var size = this.extent * Math.pow(2, z), + x0 = this.extent * x, + y0 = this.extent * y, + coords = this.loadGeometry(), + type = VectorTileFeature$2.types[this.type], + i, j; + + function project(line) { + for (var j = 0; j < line.length; j++) { + var p = line[j], y2 = 180 - (p.y + y0) * 360 / size; + line[j] = [ + (p.x + x0) * 360 / size - 180, + 360 / Math.PI * Math.atan(Math.exp(y2 * Math.PI / 180)) - 90 + ]; + } + } + + switch (this.type) { + case 1: + var points = []; + for (i = 0; i < coords.length; i++) { + points[i] = coords[i][0]; + } + coords = points; + project(coords); + break; + + case 2: + for (i = 0; i < coords.length; i++) { + project(coords[i]); + } + break; + + case 3: + coords = classifyRings(coords); + for (i = 0; i < coords.length; i++) { + for (j = 0; j < coords[i].length; j++) { + project(coords[i][j]); + } + } + break; + } + + if (coords.length === 1) { + coords = coords[0]; + } else { + type = 'Multi' + type; + } + + var result = { + type: "Feature", + geometry: { + type: type, + coordinates: coords + }, + properties: this.properties + }; + + if ('id' in this) { + result.id = this.id; + } + + return result; +}; + +// classifies an array of rings into polygons with outer rings and holes + +function classifyRings(rings) { + var len = rings.length; + + if (len <= 1) { return [rings]; } + + var polygons = [], + polygon, + ccw; + + for (var i = 0; i < len; i++) { + var area = signedArea(rings[i]); + if (area === 0) { continue; } + + if (ccw === undefined) { ccw = area < 0; } + + if (ccw === area < 0) { + if (polygon) { polygons.push(polygon); } + polygon = [rings[i]]; + + } else { + polygon.push(rings[i]); + } + } + if (polygon) { polygons.push(polygon); } + + return polygons; +} + +function signedArea(ring) { + var sum = 0; + for (var i = 0, len = ring.length, j = len - 1, p1, p2; i < len; j = i++) { + p1 = ring[i]; + p2 = ring[j]; + sum += (p2.x - p1.x) * (p1.y + p2.y); + } + return sum; +} + +var VectorTileFeature$1 = vectortilefeature; + +var vectortilelayer = VectorTileLayer$2; + +function VectorTileLayer$2(pbf, end) { + // Public + this.version = 1; + this.name = null; + this.extent = 4096; + this.length = 0; + + // Private + this._pbf = pbf; + this._keys = []; + this._values = []; + this._features = []; + + pbf.readFields(readLayer, this, end); + + this.length = this._features.length; +} + +function readLayer(tag, layer, pbf) { + if (tag === 15) { layer.version = pbf.readVarint(); } + else if (tag === 1) { layer.name = pbf.readString(); } + else if (tag === 5) { layer.extent = pbf.readVarint(); } + else if (tag === 2) { layer._features.push(pbf.pos); } + else if (tag === 3) { layer._keys.push(pbf.readString()); } + else if (tag === 4) { layer._values.push(readValueMessage(pbf)); } +} + +function readValueMessage(pbf) { + var value = null, + end = pbf.readVarint() + pbf.pos; + + while (pbf.pos < end) { + var tag = pbf.readVarint() >> 3; + + value = tag === 1 ? pbf.readString() : + tag === 2 ? pbf.readFloat() : + tag === 3 ? pbf.readDouble() : + tag === 4 ? pbf.readVarint64() : + tag === 5 ? pbf.readVarint() : + tag === 6 ? pbf.readSVarint() : + tag === 7 ? pbf.readBoolean() : null; + } + + return value; +} + +// return feature `i` from this layer as a `VectorTileFeature` +VectorTileLayer$2.prototype.feature = function(i) { + if (i < 0 || i >= this._features.length) { throw new Error('feature index out of bounds'); } + + this._pbf.pos = this._features[i]; + + var end = this._pbf.readVarint() + this._pbf.pos; + return new VectorTileFeature$1(this._pbf, end, this.extent, this._keys, this._values); +}; + +var VectorTileLayer$1 = vectortilelayer; + +var vectortile = VectorTile$1; + +function VectorTile$1(pbf, end) { + this.layers = pbf.readFields(readTile, {}, end); +} + +function readTile(tag, layers, pbf) { + if (tag === 3) { + var layer = new VectorTileLayer$1(pbf, pbf.readVarint() + pbf.pos); + if (layer.length) { layers[layer.name] = layer; } + } +} + +var VectorTile = vectortile; + +L.SVG.Tile = L.SVG.extend({ + + initialize: function (tileCoord, tileSize, options) { + L.SVG.prototype.initialize.call(this, options); + this._tileCoord = tileCoord; + this._size = tileSize; + + this._initContainer(); + this._container.setAttribute('width', this._size.x); + this._container.setAttribute('height', this._size.y); + this._container.setAttribute('viewBox', [0, 0, this._size.x, this._size.y].join(' ')); + + this._layers = {}; + }, + + getCoord: function() { + return this._tileCoord; + }, + + getContainer: function() { + return this._container; + }, + + onAdd: L.Util.falseFn, + + addTo: function(map) { + this._map = map; + if (this.options.interactive) { + for (var i in this._layers) { + var layer = this._layers[i]; + // By default, Leaflet tiles do not have pointer events. + layer._path.style.pointerEvents = 'auto'; + this._map._targets[L.stamp(layer._path)] = layer; + } + } + }, + + removeFrom: function (map) { + if (this.options.interactive) { + for (var i in this._layers) { + var layer = this._layers[i]; + delete this._map._targets[L.stamp(layer._path)]; + } + } + delete this._map; + }, + + _initContainer: function() { + L.SVG.prototype._initContainer.call(this); + var rect = L.SVG.create('rect'); + }, + + /// TODO: Modify _initPath to include an extra parameter, a group name + /// to order symbolizers by z-index + + _addPath: function (layer) { + this._rootGroup.appendChild(layer._path); + this._layers[L.stamp(layer)] = layer; + }, + + _updateIcon: function (layer) { + var path = layer._path = L.SVG.create('image'), + icon = layer.options.icon, + options = icon.options, + size = L.point(options.iconSize), + anchor = options.iconAnchor || + size && size.divideBy(2, true), + p = layer._point.subtract(anchor); + path.setAttribute('x', p.x); + path.setAttribute('y', p.y); + path.setAttribute('width', size.x + 'px'); + path.setAttribute('height', size.y + 'px'); + path.setAttribute('href', options.iconUrl); + } +}); + + +L.svg.tile = function(tileCoord, tileSize, opts){ + return new L.SVG.Tile(tileCoord, tileSize, opts); +}; + +// ??class Symbolizer +// ??inherits Class +// The abstract Symbolizer class is mostly equivalent in concept to a `L.Path` - it's an interface for +// polylines, polygons and circles. But instead of representing leaflet Layers, +// it represents things that have to be drawn inside a vector tile. + +// A vector tile *data layer* might have zero, one, or more *symbolizer definitions* +// A vector tile *feature* might have zero, one, or more *symbolizers*. +// The actual symbolizers applied will depend on filters and the symbolizer functions. + +var Symbolizer = L.Class.extend({ + // ??method initialize(feature: GeoJSON, pxPerExtent: Number) + // Initializes a new Line Symbolizer given a GeoJSON feature and the + // pixel-to-coordinate-units ratio. Internal use only. + + // ??method render(renderer, style) + // Renders this symbolizer in the given tiled renderer, with the given + // `L.Path` options. Internal use only. + render: function(renderer, style) { + this._renderer = renderer; + this.options = style; + renderer._initPath(this); + renderer._updateStyle(this); + }, + + // ??method render(renderer, style) + // Updates the `L.Path` options used to style this symbolizer, and re-renders it. + // Internal use only. + updateStyle: function(renderer, style) { + this.options = style; + renderer._updateStyle(this); + }, + + _getPixelBounds: function() { + var parts = this._parts; + var bounds = L.bounds([]); + for (var i = 0; i < parts.length; i++) { + var part = parts[i]; + for (var j = 0; j < part.length; j++) { + bounds.extend(part[j]); + } + } + + var w = this._clickTolerance(), + p = new L.Point(w, w); + + bounds.min._subtract(p); + bounds.max._add(p); + + return bounds; + }, + _clickTolerance: L.Path.prototype._clickTolerance, +}); + +// Contains mixins which are common to the Line Symbolizer and the Fill Symbolizer. + +var PolyBase = { + _makeFeatureParts: function(feat, pxPerExtent) { + var rings = feat.geometry; + var coord; + + this._parts = []; + for (var i = 0; i < rings.length; i++) { + var ring = rings[i]; + var part = []; + for (var j = 0; j < ring.length; j++) { + coord = ring[j]; + // Protobuf vector tiles return {x: , y:} + // Geojson-vt returns [,] + part.push(L.point(coord).scaleBy(pxPerExtent)); + } + this._parts.push(part); + } + }, + + makeInteractive: function() { + this._pxBounds = this._getPixelBounds(); + } +}; + +// ??class PointSymbolizer +// ??inherits CircleMarker +// A symbolizer for points. + +var PointSymbolizer = L.CircleMarker.extend({ + includes: Symbolizer.prototype, + + statics: { + iconCache: {} + }, + + initialize: function(feature, pxPerExtent) { + this.properties = feature.properties; + this._makeFeatureParts(feature, pxPerExtent); + }, + + render: function(renderer, style) { + Symbolizer.prototype.render.call(this, renderer, style); + this._radius = style.radius || L.CircleMarker.prototype.options.radius; + this._updatePath(); + }, + + _makeFeatureParts: function(feat, pxPerExtent) { + var coord = feat.geometry[0]; + if (typeof coord[0] === 'object' && 'x' in coord[0]) { + // Protobuf vector tiles return [{x: , y:}] + this._point = L.point(coord[0]).scaleBy(pxPerExtent); + this._empty = L.Util.falseFn; + } else { + // Geojson-vt returns [,] + this._point = L.point(coord).scaleBy(pxPerExtent); + this._empty = L.Util.falseFn; + } + }, + + makeInteractive: function() { + this._updateBounds(); + }, + + updateStyle: function(renderer, style) { + this._radius = style.radius || this._radius; + this._updateBounds(); + return Symbolizer.prototype.updateStyle.call(this, renderer, style); + }, + + _updateBounds: function() { + var icon = this.options.icon; + if (icon) { + var size = L.point(icon.options.iconSize), + anchor = icon.options.iconAnchor || + size && size.divideBy(2, true), + p = this._point.subtract(anchor); + this._pxBounds = new L.Bounds(p, p.add(icon.options.iconSize)); + } else { + L.CircleMarker.prototype._updateBounds.call(this); + } + }, + + _updatePath: function() { + if (this.options.icon) { + this._renderer._updateIcon(this); + } else { + L.CircleMarker.prototype._updatePath.call(this); + } + }, + + _getImage: function () { + if (this.options.icon) { + var url = this.options.icon.options.iconUrl, + img = PointSymbolizer.iconCache[url]; + if (!img) { + var icon = this.options.icon; + img = PointSymbolizer.iconCache[url] = icon.createIcon(); + } + return img; + } else { + return null; + } + + }, + + _containsPoint: function(p) { + var icon = this.options.icon; + if (icon) { + return this._pxBounds.contains(p); + } else { + return L.CircleMarker.prototype._containsPoint.call(this, p); + } + } +}); + +// ??class LineSymbolizer +// ??inherits Polyline +// A symbolizer for lines. Can be applied to line and polygon features. + +var LineSymbolizer = L.Polyline.extend({ + includes: [Symbolizer.prototype, PolyBase], + + initialize: function(feature, pxPerExtent) { + this.properties = feature.properties; + this._makeFeatureParts(feature, pxPerExtent); + }, + + render: function(renderer, style) { + style.fill = false; + Symbolizer.prototype.render.call(this, renderer, style); + this._updatePath(); + }, + + updateStyle: function(renderer, style) { + style.fill = false; + Symbolizer.prototype.updateStyle.call(this, renderer, style); + }, +}); + +// ??class FillSymbolizer +// ??inherits Polyline +// A symbolizer for filled areas. Applies only to polygon features. + +var FillSymbolizer = L.Polygon.extend({ + includes: [Symbolizer.prototype, PolyBase], + + initialize: function(feature, pxPerExtent) { + this.properties = feature.properties; + this._makeFeatureParts(feature, pxPerExtent); + }, + + render: function(renderer, style) { + Symbolizer.prototype.render.call(this, renderer, style); + this._updatePath(); + } +}); + +/* ??class VectorGrid + * ??inherits GridLayer + * + * A `VectorGrid` is a generic, abstract class for displaying tiled vector data. + * it provides facilities for symbolizing and rendering the data in the vector + * tiles, but lacks the functionality to fetch the vector tiles from wherever + * they are. + * + * Extends Leaflet's `L.GridLayer`. + */ + +L.VectorGrid = L.GridLayer.extend({ + + options: { + // ??option rendererFactory = L.svg.tile + // A factory method which will be used to instantiate the per-tile renderers. + rendererFactory: L.svg.tile, + + // ??option vectorTileLayerStyles: Object = {} + // A data structure holding initial symbolizer definitions for the vector features. + vectorTileLayerStyles: {}, + + onEachFeature: null, + + // ??option interactive: Boolean = false + // Whether this `VectorGrid` fires `Interactive Layer` events. + interactive: false, + + // ??option getFeatureId: Function = undefined + // A function that, given a vector feature, returns an unique identifier for it, e.g. + // `function(feat) { return feat.properties.uniqueIdField; }`. + // Must be defined for `setFeatureStyle` to work. + }, + + initialize: function(options) { + L.setOptions(this, options); + L.GridLayer.prototype.initialize.apply(this, arguments); + if (this.options.getFeatureId) { + this._vectorTiles = {}; + this._overriddenStyles = {}; + } + this._userLayers = {}; + this.on('tileunload', function(e) { + this._tileUnload(e); + }, this); + this._dataLayerNames = {}; + }, + + createTile: function(coords, done) { + var storeFeatures = this.options.getFeatureId; + var onEachFeature = this.options.onEachFeature; + + var tileSize = this.getTileSize(); + var renderer = this.options.rendererFactory(coords, tileSize, this.options); + + var vectorTilePromise = this._getVectorTilePromise(coords); + + if (storeFeatures) { + this._vectorTiles[this._tileCoordsToKey(coords)] = renderer; + renderer._features = {}; + } + + vectorTilePromise.then( function renderTile(vectorTile) { + for (var layerName in vectorTile.layers) { + this._dataLayerNames[layerName] = true; + var layer = vectorTile.layers[layerName]; + + var pxPerExtent = this.getTileSize().divideBy(layer.extent); + + var layerStyle = this.options.vectorTileLayerStyles[ layerName ] || + L.Path.prototype.options; + + for (var i = 0; i < layer.features.length; i++) { + var feat = layer.features[i]; + var id; + + var styleOptions = layerStyle; + if (storeFeatures) { + id = this.options.getFeatureId(feat); + var styleOverride = this._overriddenStyles[id]; + if (styleOverride) { + if (styleOverride[layerName]) { + styleOptions = styleOverride[layerName]; + } else { + styleOptions = styleOverride; + } + } + } + + if (styleOptions instanceof Function) { + styleOptions = styleOptions(feat.properties, coords.z, feat.type); + } + + if (!(styleOptions instanceof Array)) { + styleOptions = [styleOptions]; + } + + if (!styleOptions.length) { + if (onEachFeature) { + onEachFeature.call(this, feat, null, layer, coords); + } + continue; + } + + var featureLayer = this._createLayer(feat, pxPerExtent); + + if (onEachFeature) { + onEachFeature.call(this, feat, null, layer, coords); + } + + for (var j = 0; j < styleOptions.length; j++) { + if (styleOptions[j] instanceof Function) { + var styleOption = styleOptions[j](feat.properties, coords.z, feat.type); + } + var style = L.extend({}, L.Path.prototype.options, styleOption); + featureLayer.render(renderer, style); + renderer._addPath(featureLayer); + } + + if (this.options.interactive) { + featureLayer.makeInteractive(); + } + + if (storeFeatures) { + renderer._features[id] = { + layerName: layerName, + feature: featureLayer + }; + } + } + + } + if (this._map != null) { + renderer.addTo(this._map); + } + L.Util.requestAnimFrame(done.bind(coords, null, null)); + }.bind(this)); + + return renderer.getContainer(); + }, + + // ??method setFeatureStyle(id: Number, layerStyle: L.Path Options): this + // Given the unique ID for a vector features (as per the `getFeatureId` option), + // re-symbolizes that feature across all tiles it appears in. + setFeatureStyle: function(id, layerStyle) { + this._overriddenStyles[id] = layerStyle; + + for (var tileKey in this._vectorTiles) { + var tile = this._vectorTiles[tileKey]; + var features = tile._features; + var data = features[id]; + if (data) { + var feat = data.feature; + + var styleOptions = layerStyle; + if (layerStyle[data.layerName]) { + styleOptions = layerStyle[data.layerName]; + } + + this._updateStyles(feat, tile, styleOptions); + } + } + return this; + }, + + // ??method setFeatureStyle(id: Number): this + // Reverts the effects of a previous `setFeatureStyle` call. + resetFeatureStyle: function(id) { + delete this._overriddenStyles[id]; + + for (var tileKey in this._vectorTiles) { + var tile = this._vectorTiles[tileKey]; + var features = tile._features; + var data = features[id]; + if (data) { + var feat = data.feature; + var styleOptions = this.options.vectorTileLayerStyles[ data.layerName ] || + L.Path.prototype.options; + this._updateStyles(feat, tile, styleOptions); + } + } + return this; + }, + + // ??method getDataLayerNames(): Array + // Returns an array of strings, with all the known names of data layers in + // the vector tiles displayed. Useful for introspection. + getDataLayerNames: function() { + return Object.keys(this._dataLayerNames); + }, + + vtGeometryToPoint: function(geometry, vtLayer, tileCoords) { + var pxPerExtent = this.getTileSize().x / vtLayer.extent; + var tileSize = this.getTileSize(); + var offset = tileCoords.scaleBy(tileSize); + var point; + if (typeof geometry[0] === 'object' && 'x' in geometry[0]) { + // Protobuf vector tiles return [{x: , y:}] + point = L.point(offset.x + (geometry[0].x * pxPerExtent), offset.y + (geometry[0].y * pxPerExtent)); + } else { + // Geojson-vt returns [,] + point = L.point(offset.x + (geometry[0] * pxPerExtent), offset.y + (geometry[1] * pxPerExtent)); + } + return point; + }, + + vtGeometryToLatLng: function(geometry, vtLayer, tileCoords) { + return this._map.unproject(this.vtGeometryToPoint(geometry, vtLayer, tileCoords)); + }, + + addUserLayer: function(userLayer, tileCoords) { + var tileKey = this._tileCoordsToKey(tileCoords); + this._userLayers[tileKey] = this._userLayers[tileKey] || []; + this._userLayers[tileKey].push(userLayer); + this._map.addLayer(userLayer); + }, + + _tileUnload: function(e) { + var tileKey = this._tileCoordsToKey(e.coords); + if (this._vectorTiles) { + delete this._vectorTiles[tileKey]; + } + var userLayers = this._userLayers[tileKey]; + if (!userLayers) { + return; + } + for(var i = 0; i < userLayers.length; i++) { +// console.log('remove layer'); + this._map.removeLayer(userLayers[i]); + } + delete this._userLayers[tileKey]; + }, + + _updateStyles: function(feat, renderer, styleOptions) { + styleOptions = (styleOptions instanceof Function) ? + styleOptions(feat.properties, renderer.getCoord().z, feat.type) : + styleOptions; + + if (!(styleOptions instanceof Array)) { + styleOptions = [styleOptions]; + } + + for (var j = 0; j < styleOptions.length; j++) { + + var styleOption = (styleOptions[j] instanceof Function) ? + styleOptions[j](feat.properties, renderer.getCoord().z, feat.type) : + styleOptions[j]; + + var style = L.extend({}, L.Path.prototype.options, styleOption); + feat.updateStyle(renderer, style); + } + }, + + _createLayer: function(feat, pxPerExtent, layerStyle) { + var layer; + switch (feat.type) { + case 1: + layer = new PointSymbolizer(feat, pxPerExtent); + break; + case 2: + layer = new LineSymbolizer(feat, pxPerExtent); + break; + case 3: + layer = new FillSymbolizer(feat, pxPerExtent); + break; + } + + if (this.options.interactive) { + layer.addEventParent(this); + } + + return layer; + }, +}); + +/* + * ??section Extension methods + * + * Classes inheriting from `VectorGrid` **must** define the `_getVectorTilePromise` private method. + * + * ??method getVectorTilePromise(coords: Object): Promise + * Given a `coords` object in the form of `{x: Number, y: Number, z: Number}`, + * this function must return a `Promise` for a vector tile. + * + */ +L.vectorGrid = function (options) { + return new L.VectorGrid(options); +}; + +/* + * ??class VectorGrid.Protobuf + * ??extends VectorGrid + * + * A `VectorGrid` for vector tiles fetched from the internet. + * Tiles are supposed to be protobufs (AKA "protobuffer" or "Protocol Buffers"), + * containing data which complies with the + * [MapBox Vector Tile Specification](https://github.com/mapbox/vector-tile-spec/tree/master/2.1). + * + * This is the format used by: + * - Mapbox Vector Tiles + * - Mapzen Vector Tiles + * - ESRI Vector Tiles + * - [OpenMapTiles hosted Vector Tiles](https://openmaptiles.com/hosting/) + * + * ??example + * + * You must initialize a `VectorGrid.Protobuf` with a URL template, just like in + * `L.TileLayer`s. The difference is that the template must point to vector tiles + * (usually `.pbf` or `.mvt`) instead of raster (`.png` or `.jpg`) tiles, and that + * you should define the styling for all the features. + * + *

+ * + * For OpenMapTiles, with a key from [https://openmaptiles.org/docs/host/use-cdn/](https://openmaptiles.org/docs/host/use-cdn/), + * initialization looks like this: + * + * ``` + * L.vectorGrid.protobuf("https://free-{s}.tilehosting.com/data/v3/{z}/{x}/{y}.pbf.pict?key={key}", { + * vectorTileLayerStyles: { ... }, + * subdomains: "0123", + * key: 'abcdefghi01234567890', + * maxNativeZoom: 14 + * }).addTo(map); + * ``` + * + * And for Mapbox vector tiles, it looks like this: + * + * ``` + * L.vectorGrid.protobuf("https://{s}.tiles.mapbox.com/v4/mapbox.mapbox-streets-v6/{z}/{x}/{y}.vector.pbf?access_token={token}", { + * vectorTileLayerStyles: { ... }, + * subdomains: "abcd", + * token: "pk.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRTS.TUVWXTZ0123456789abcde" + * }).addTo(map); + * ``` + */ +L.VectorGrid.Protobuf = L.VectorGrid.extend({ + + options: { + // ??section + // As with `L.TileLayer`, the URL template might contain a reference to + // any option (see the example above and note the `{key}` or `token` in the URL + // template, and the corresponding option). + // + // ??option subdomains: String = 'abc' + // Akin to the `subdomains` option for `L.TileLayer`. + subdomains: 'abc', // Like L.TileLayer + // + // ??option fetchOptions: Object = {} + // options passed to `fetch`, e.g. {credentials: 'same-origin'} to send cookie for the current domain + fetchOptions: {} + }, + + initialize: function(url, options) { + // Inherits options from geojson-vt! +// this._slicer = geojsonvt(geojson, options); + this._url = url; + L.VectorGrid.prototype.initialize.call(this, options); + }, + + // ??method setUrl(url: String, noRedraw?: Boolean): this + // Updates the layer's URL template and redraws it (unless `noRedraw` is set to `true`). + setUrl: function(url, noRedraw) { + this._url = url; + + if (!noRedraw) { + this.redraw(); + } + + return this; + }, + + _getSubdomain: L.TileLayer.prototype._getSubdomain, + + _getVectorTilePromise: function(coords) { + var data = { + s: this._getSubdomain(coords), + x: coords.x, + y: coords.y, + z: coords.z +// z: this._getZoomForUrl() /// TODO: Maybe replicate TileLayer's maxNativeZoom + }; + if (this._map && !this._map.options.crs.infinite) { + var invertedY = this._globalTileRange.max.y - coords.y; + if (this.options.tms) { // Should this option be available in Leaflet.VectorGrid? + data['y'] = invertedY; + } + data['-y'] = invertedY; + } + + var tileUrl = L.Util.template(this._url, L.extend(data, this.options)); + + return fetch(tileUrl, this.options.fetchOptions).then(function(response){ + + if (!response.ok) { + return {layers:[]}; + } + + return response.blob().then( function (blob) { +// console.log(blob); + + var reader = new FileReader(); + return new Promise(function(resolve){ + reader.addEventListener("loadend", function() { + // reader.result contains the contents of blob as a typed array + + // blob.type === 'application/x-protobuf' + var pbf = new index( reader.result ); +// console.log(pbf); + return resolve(new VectorTile( pbf )); + + }); + reader.readAsArrayBuffer(blob); + }); + }); + }).then(function(json){ + +// console.log('Vector tile:', json.layers); +// console.log('Vector tile water:', json.layers.water); // Instance of VectorTileLayer + + // Normalize feature getters into actual instanced features + for (var layerName in json.layers) { + var feats = []; + + for (var i=0; i maxSqDist) {\n index = i;\n maxSqDist = sqDist;\n }\n }\n\n if (maxSqDist > sqTolerance) {\n points[index][2] = maxSqDist; // save the point importance in squared pixels as a z coordinate\n stack.push(first);\n stack.push(index);\n first = index;\n\n } else {\n last = stack.pop();\n first = stack.pop();\n }\n }\n}\n\n// square distance from a point to a segment\nfunction getSqSegDist(p, a, b) {\n\n var x = a[0], y = a[1],\n bx = b[0], by = b[1],\n px = p[0], py = p[1],\n dx = bx - x,\n dy = by - y;\n\n if (dx !== 0 || dy !== 0) {\n\n var t = ((px - x) * dx + (py - y) * dy) / (dx * dx + dy * dy);\n\n if (t > 1) {\n x = bx;\n y = by;\n\n } else if (t > 0) {\n x += dx * t;\n y += dy * t;\n }\n }\n\n dx = px - x;\n dy = py - y;\n\n return dx * dx + dy * dy;\n}\n\nvar convert_1 = convert$1;\n\nvar simplify = simplify_1;\n\n// converts GeoJSON feature into an intermediate projected JSON vector format with simplification data\n\nfunction convert$1(data, tolerance) {\n var features = [];\n\n if (data.type === 'FeatureCollection') {\n for (var i = 0; i < data.features.length; i++) {\n convertFeature(features, data.features[i], tolerance);\n }\n } else if (data.type === 'Feature') {\n convertFeature(features, data, tolerance);\n\n } else {\n // single geometry or a geometry collection\n convertFeature(features, {geometry: data}, tolerance);\n }\n return features;\n}\n\nfunction convertFeature(features, feature, tolerance) {\n if (feature.geometry === null) {\n // ignore features with null geometry\n return;\n }\n\n var geom = feature.geometry,\n type = geom.type,\n coords = geom.coordinates,\n tags = feature.properties,\n i, j, rings, projectedRing;\n\n if (type === 'Point') {\n features.push(create(tags, 1, [projectPoint(coords)]));\n\n } else if (type === 'MultiPoint') {\n features.push(create(tags, 1, project(coords)));\n\n } else if (type === 'LineString') {\n features.push(create(tags, 2, [project(coords, tolerance)]));\n\n } else if (type === 'MultiLineString' || type === 'Polygon') {\n rings = [];\n for (i = 0; i < coords.length; i++) {\n projectedRing = project(coords[i], tolerance);\n if (type === 'Polygon') { projectedRing.outer = (i === 0); }\n rings.push(projectedRing);\n }\n features.push(create(tags, type === 'Polygon' ? 3 : 2, rings));\n\n } else if (type === 'MultiPolygon') {\n rings = [];\n for (i = 0; i < coords.length; i++) {\n for (j = 0; j < coords[i].length; j++) {\n projectedRing = project(coords[i][j], tolerance);\n projectedRing.outer = (j === 0);\n rings.push(projectedRing);\n }\n }\n features.push(create(tags, 3, rings));\n\n } else if (type === 'GeometryCollection') {\n for (i = 0; i < geom.geometries.length; i++) {\n convertFeature(features, {\n geometry: geom.geometries[i],\n properties: tags\n }, tolerance);\n }\n\n } else {\n throw new Error('Input data is not a valid GeoJSON object.');\n }\n}\n\nfunction create(tags, type, geometry) {\n var feature = {\n geometry: geometry,\n type: type,\n tags: tags || null,\n min: [2, 1], // initial bbox values;\n max: [-1, 0] // note that coords are usually in [0..1] range\n };\n calcBBox(feature);\n return feature;\n}\n\nfunction project(lonlats, tolerance) {\n var projected = [];\n for (var i = 0; i < lonlats.length; i++) {\n projected.push(projectPoint(lonlats[i]));\n }\n if (tolerance) {\n simplify(projected, tolerance);\n calcSize(projected);\n }\n return projected;\n}\n\nfunction projectPoint(p) {\n var sin = Math.sin(p[1] * Math.PI / 180),\n x = (p[0] / 360 + 0.5),\n y = (0.5 - 0.25 * Math.log((1 + sin) / (1 - sin)) / Math.PI);\n\n y = y < 0 ? 0 :\n y > 1 ? 1 : y;\n\n return [x, y, 0];\n}\n\n// calculate area and length of the poly\nfunction calcSize(points) {\n var area = 0,\n dist = 0;\n\n for (var i = 0, a, b; i < points.length - 1; i++) {\n a = b || points[i];\n b = points[i + 1];\n\n area += a[0] * b[1] - b[0] * a[1];\n\n // use Manhattan distance instead of Euclidian one to avoid expensive square root computation\n dist += Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]);\n }\n points.area = Math.abs(area / 2);\n points.dist = dist;\n}\n\n// calculate the feature bounding box for faster clipping later\nfunction calcBBox(feature) {\n var geometry = feature.geometry,\n min = feature.min,\n max = feature.max;\n\n if (feature.type === 1) { calcRingBBox(min, max, geometry); }\n else { for (var i = 0; i < geometry.length; i++) { calcRingBBox(min, max, geometry[i]); } }\n\n return feature;\n}\n\nfunction calcRingBBox(min, max, points) {\n for (var i = 0, p; i < points.length; i++) {\n p = points[i];\n min[0] = Math.min(p[0], min[0]);\n max[0] = Math.max(p[0], max[0]);\n min[1] = Math.min(p[1], min[1]);\n max[1] = Math.max(p[1], max[1]);\n }\n}\n\nvar tile = transformTile;\nvar point = transformPoint;\n\n// Transforms the coordinates of each feature in the given tile from\n// mercator-projected space into (extent x extent) tile space.\nfunction transformTile(tile, extent) {\n if (tile.transformed) { return tile; }\n\n var z2 = tile.z2,\n tx = tile.x,\n ty = tile.y,\n i, j, k;\n\n for (i = 0; i < tile.features.length; i++) {\n var feature = tile.features[i],\n geom = feature.geometry,\n type = feature.type;\n\n if (type === 1) {\n for (j = 0; j < geom.length; j++) { geom[j] = transformPoint(geom[j], extent, z2, tx, ty); }\n\n } else {\n for (j = 0; j < geom.length; j++) {\n var ring = geom[j];\n for (k = 0; k < ring.length; k++) { ring[k] = transformPoint(ring[k], extent, z2, tx, ty); }\n }\n }\n }\n\n tile.transformed = true;\n\n return tile;\n}\n\nfunction transformPoint(p, extent, z2, tx, ty) {\n var x = Math.round(extent * (p[0] * z2 - tx)),\n y = Math.round(extent * (p[1] * z2 - ty));\n return [x, y];\n}\n\nvar transform$1 = {\n tile: tile,\n point: point\n};\n\nvar clip_1 = clip$1;\n\n/* clip features between two axis-parallel lines:\n * | |\n * ___|___ | /\n * / | \____|____/\n * | |\n */\n\nfunction clip$1(features, scale, k1, k2, axis, intersect, minAll, maxAll) {\n\n k1 /= scale;\n k2 /= scale;\n\n if (minAll >= k1 && maxAll <= k2) { return features; } // trivial accept\n else if (minAll > k2 || maxAll < k1) { return null; } // trivial reject\n\n var clipped = [];\n\n for (var i = 0; i < features.length; i++) {\n\n var feature = features[i],\n geometry = feature.geometry,\n type = feature.type,\n min, max;\n\n min = feature.min[axis];\n max = feature.max[axis];\n\n if (min >= k1 && max <= k2) { // trivial accept\n clipped.push(feature);\n continue;\n } else if (min > k2 || max < k1) { continue; } // trivial reject\n\n var slices = type === 1 ?\n clipPoints(geometry, k1, k2, axis) :\n clipGeometry(geometry, k1, k2, axis, intersect, type === 3);\n\n if (slices.length) {\n // if a feature got clipped, it will likely get clipped on the next zoom level as well,\n // so there's no need to recalculate bboxes\n clipped.push({\n geometry: slices,\n type: type,\n tags: features[i].tags || null,\n min: feature.min,\n max: feature.max\n });\n }\n }\n\n return clipped.length ? clipped : null;\n}\n\nfunction clipPoints(geometry, k1, k2, axis) {\n var slice = [];\n\n for (var i = 0; i < geometry.length; i++) {\n var a = geometry[i],\n ak = a[axis];\n\n if (ak >= k1 && ak <= k2) { slice.push(a); }\n }\n return slice;\n}\n\nfunction clipGeometry(geometry, k1, k2, axis, intersect, closed) {\n\n var slices = [];\n\n for (var i = 0; i < geometry.length; i++) {\n\n var ak = 0,\n bk = 0,\n b = null,\n points = geometry[i],\n area = points.area,\n dist = points.dist,\n outer = points.outer,\n len = points.length,\n a, j, last;\n\n var slice = [];\n\n for (j = 0; j < len - 1; j++) {\n a = b || points[j];\n b = points[j + 1];\n ak = bk || a[axis];\n bk = b[axis];\n\n if (ak < k1) {\n\n if ((bk > k2)) { // ---|-----|-->\n slice.push(intersect(a, b, k1), intersect(a, b, k2));\n if (!closed) { slice = newSlice(slices, slice, area, dist, outer); }\n\n } else if (bk >= k1) { slice.push(intersect(a, b, k1)); } // ---|--> |\n\n } else if (ak > k2) {\n\n if ((bk < k1)) { // <--|-----|---\n slice.push(intersect(a, b, k2), intersect(a, b, k1));\n if (!closed) { slice = newSlice(slices, slice, area, dist, outer); }\n\n } else if (bk <= k2) { slice.push(intersect(a, b, k2)); } // | <--|---\n\n } else {\n\n slice.push(a);\n\n if (bk < k1) { // <--|--- |\n slice.push(intersect(a, b, k1));\n if (!closed) { slice = newSlice(slices, slice, area, dist, outer); }\n\n } else if (bk > k2) { // | ---|-->\n slice.push(intersect(a, b, k2));\n if (!closed) { slice = newSlice(slices, slice, area, dist, outer); }\n }\n // | --> |\n }\n }\n\n // add the last point\n a = points[len - 1];\n ak = a[axis];\n if (ak >= k1 && ak <= k2) { slice.push(a); }\n\n // close the polygon if its endpoints are not the same after clipping\n\n last = slice[slice.length - 1];\n if (closed && last && (slice[0][0] !== last[0] || slice[0][1] !== last[1])) { slice.push(slice[0]); }\n\n // add the final slice\n newSlice(slices, slice, area, dist, outer);\n }\n\n return slices;\n}\n\nfunction newSlice(slices, slice, area, dist, outer) {\n if (slice.length) {\n // we don't recalculate the area/length of the unclipped geometry because the case where it goes\n // below the visibility threshold as a result of clipping is rare, so we avoid doing unnecessary work\n slice.area = area;\n slice.dist = dist;\n if (outer !== undefined) { slice.outer = outer; }\n\n slices.push(slice);\n }\n return [];\n}\n\nvar clip$2 = clip_1;\n\nvar wrap_1 = wrap$1;\n\nfunction wrap$1(features, buffer, intersectX) {\n var merged = features,\n left = clip$2(features, 1, -1 - buffer, buffer, 0, intersectX, -1, 2), // left world copy\n right = clip$2(features, 1, 1 - buffer, 2 + buffer, 0, intersectX, -1, 2); // right world copy\n\n if (left || right) {\n merged = clip$2(features, 1, -buffer, 1 + buffer, 0, intersectX, -1, 2); // center world copy\n\n if (left) { merged = shiftFeatureCoords(left, 1).concat(merged); } // merge left into center\n if (right) { merged = merged.concat(shiftFeatureCoords(right, -1)); } // merge right into center\n }\n\n return merged;\n}\n\nfunction shiftFeatureCoords(features, offset) {\n var newFeatures = [];\n\n for (var i = 0; i < features.length; i++) {\n var feature = features[i],\n type = feature.type;\n\n var newGeometry;\n\n if (type === 1) {\n newGeometry = shiftCoords(feature.geometry, offset);\n } else {\n newGeometry = [];\n for (var j = 0; j < feature.geometry.length; j++) {\n newGeometry.push(shiftCoords(feature.geometry[j], offset));\n }\n }\n\n newFeatures.push({\n geometry: newGeometry,\n type: type,\n tags: feature.tags,\n min: [feature.min[0] + offset, feature.min[1]],\n max: [feature.max[0] + offset, feature.max[1]]\n });\n }\n\n return newFeatures;\n}\n\nfunction shiftCoords(points, offset) {\n var newPoints = [];\n newPoints.area = points.area;\n newPoints.dist = points.dist;\n\n for (var i = 0; i < points.length; i++) {\n newPoints.push([points[i][0] + offset, points[i][1], points[i][2]]);\n }\n return newPoints;\n}\n\nvar tile$1 = createTile$1;\n\nfunction createTile$1(features, z2, tx, ty, tolerance, noSimplify) {\n var tile = {\n features: [],\n numPoints: 0,\n numSimplified: 0,\n numFeatures: 0,\n source: null,\n x: tx,\n y: ty,\n z2: z2,\n transformed: false,\n min: [2, 1],\n max: [-1, 0]\n };\n for (var i = 0; i < features.length; i++) {\n tile.numFeatures++;\n addFeature(tile, features[i], tolerance, noSimplify);\n\n var min = features[i].min,\n max = features[i].max;\n\n if (min[0] < tile.min[0]) { tile.min[0] = min[0]; }\n if (min[1] < tile.min[1]) { tile.min[1] = min[1]; }\n if (max[0] > tile.max[0]) { tile.max[0] = max[0]; }\n if (max[1] > tile.max[1]) { tile.max[1] = max[1]; }\n }\n return tile;\n}\n\nfunction addFeature(tile, feature, tolerance, noSimplify) {\n\n var geom = feature.geometry,\n type = feature.type,\n simplified = [],\n sqTolerance = tolerance * tolerance,\n i, j, ring, p;\n\n if (type === 1) {\n for (i = 0; i < geom.length; i++) {\n simplified.push(geom[i]);\n tile.numPoints++;\n tile.numSimplified++;\n }\n\n } else {\n\n // simplify and transform projected coordinates for tile geometry\n for (i = 0; i < geom.length; i++) {\n ring = geom[i];\n\n // filter out tiny polylines & polygons\n if (!noSimplify && ((type === 2 && ring.dist < tolerance) ||\n (type === 3 && ring.area < sqTolerance))) {\n tile.numPoints += ring.length;\n continue;\n }\n\n var simplifiedRing = [];\n\n for (j = 0; j < ring.length; j++) {\n p = ring[j];\n // keep points with importance > tolerance\n if (noSimplify || p[2] > sqTolerance) {\n simplifiedRing.push(p);\n tile.numSimplified++;\n }\n tile.numPoints++;\n }\n\n if (type === 3) { rewind(simplifiedRing, ring.outer); }\n\n simplified.push(simplifiedRing);\n }\n }\n\n if (simplified.length) {\n tile.features.push({\n geometry: simplified,\n type: type,\n tags: feature.tags || null\n });\n }\n}\n\nfunction rewind(ring, clockwise) {\n var area = signedArea(ring);\n if (area < 0 === clockwise) { ring.reverse(); }\n}\n\nfunction signedArea(ring) {\n var sum = 0;\n for (var i = 0, len = ring.length, j = len - 1, p1, p2; i < len; j = i++) {\n p1 = ring[i];\n p2 = ring[j];\n sum += (p2[0] - p1[0]) * (p1[1] + p2[1]);\n }\n return sum;\n}\n\nvar index = geojsonvt;\n\nvar convert = convert_1;\nvar transform = transform$1;\nvar clip = clip_1;\nvar wrap = wrap_1;\nvar createTile = tile$1; // final simplified tile generation\n\n\nfunction geojsonvt(data, options) {\n return new GeoJSONVT(data, options);\n}\n\nfunction GeoJSONVT(data, options) {\n options = this.options = extend(Object.create(this.options), options);\n\n var debug = options.debug;\n\n if (debug) { console.time('preprocess data'); }\n\n var z2 = 1 << options.maxZoom, // 2^z\n features = convert(data, options.tolerance / (z2 * options.extent));\n\n this.tiles = {};\n this.tileCoords = [];\n\n if (debug) {\n console.timeEnd('preprocess data');\n console.log('index: maxZoom: %d, maxPoints: %d', options.indexMaxZoom, options.indexMaxPoints);\n console.time('generate tiles');\n this.stats = {};\n this.total = 0;\n }\n\n features = wrap(features, options.buffer / options.extent, intersectX);\n\n // start slicing from the top tile down\n if (features.length) { this.splitTile(features, 0, 0, 0); }\n\n if (debug) {\n if (features.length) { console.log('features: %d, points: %d', this.tiles[0].numFeatures, this.tiles[0].numPoints); }\n console.timeEnd('generate tiles');\n console.log('tiles generated:', this.total, JSON.stringify(this.stats));\n }\n}\n\nGeoJSONVT.prototype.options = {\n maxZoom: 14, // max zoom to preserve detail on\n indexMaxZoom: 5, // max zoom in the tile index\n indexMaxPoints: 100000, // max number of points per tile in the tile index\n solidChildren: false, // whether to tile solid square tiles further\n tolerance: 3, // simplification tolerance (higher means simpler)\n extent: 4096, // tile extent\n buffer: 64, // tile buffer on each side\n debug: 0 // logging level (0, 1 or 2)\n};\n\nGeoJSONVT.prototype.splitTile = function (features, z, x, y, cz, cx, cy) {\n var this$1 = this;\n\n\n var stack = [features, z, x, y],\n options = this.options,\n debug = options.debug,\n solid = null;\n\n // avoid recursion by using a processing queue\n while (stack.length) {\n y = stack.pop();\n x = stack.pop();\n z = stack.pop();\n features = stack.pop();\n\n var z2 = 1 << z,\n id = toID(z, x, y),\n tile = this$1.tiles[id],\n tileTolerance = z === options.maxZoom ? 0 : options.tolerance / (z2 * options.extent);\n\n if (!tile) {\n if (debug > 1) { console.time('creation'); }\n\n tile = this$1.tiles[id] = createTile(features, z2, x, y, tileTolerance, z === options.maxZoom);\n this$1.tileCoords.push({z: z, x: x, y: y});\n\n if (debug) {\n if (debug > 1) {\n console.log('tile z%d-%d-%d (features: %d, points: %d, simplified: %d)',\n z, x, y, tile.numFeatures, tile.numPoints, tile.numSimplified);\n console.timeEnd('creation');\n }\n var key = 'z' + z;\n this$1.stats[key] = (this$1.stats[key] || 0) + 1;\n this$1.total++;\n }\n }\n\n // save reference to original geometry in tile so that we can drill down later if we stop now\n tile.source = features;\n\n // if it's the first-pass tiling\n if (!cz) {\n // stop tiling if we reached max zoom, or if the tile is too simple\n if (z === options.indexMaxZoom || tile.numPoints <= options.indexMaxPoints) { continue; }\n\n // if a drilldown to a specific tile\n } else {\n // stop tiling if we reached base zoom or our target tile zoom\n if (z === options.maxZoom || z === cz) { continue; }\n\n // stop tiling if it's not an ancestor of the target tile\n var m = 1 << (cz - z);\n if (x !== Math.floor(cx / m) || y !== Math.floor(cy / m)) { continue; }\n }\n\n // stop tiling if the tile is solid clipped square\n if (!options.solidChildren && isClippedSquare(tile, options.extent, options.buffer)) {\n if (cz) { solid = z; } // and remember the zoom if we're drilling down\n continue;\n }\n\n // if we slice further down, no need to keep source geometry\n tile.source = null;\n\n if (debug > 1) { console.time('clipping'); }\n\n // values we'll use for clipping\n var k1 = 0.5 * options.buffer / options.extent,\n k2 = 0.5 - k1,\n k3 = 0.5 + k1,\n k4 = 1 + k1,\n tl, bl, tr, br, left, right;\n\n tl = bl = tr = br = null;\n\n left = clip(features, z2, x - k1, x + k3, 0, intersectX, tile.min[0], tile.max[0]);\n right = clip(features, z2, x + k2, x + k4, 0, intersectX, tile.min[0], tile.max[0]);\n\n if (left) {\n tl = clip(left, z2, y - k1, y + k3, 1, intersectY, tile.min[1], tile.max[1]);\n bl = clip(left, z2, y + k2, y + k4, 1, intersectY, tile.min[1], tile.max[1]);\n }\n\n if (right) {\n tr = clip(right, z2, y - k1, y + k3, 1, intersectY, tile.min[1], tile.max[1]);\n br = clip(right, z2, y + k2, y + k4, 1, intersectY, tile.min[1], tile.max[1]);\n }\n\n if (debug > 1) { console.timeEnd('clipping'); }\n\n if (tl) { stack.push(tl, z + 1, x * 2, y * 2); }\n if (bl) { stack.push(bl, z + 1, x * 2, y * 2 + 1); }\n if (tr) { stack.push(tr, z + 1, x * 2 + 1, y * 2); }\n if (br) { stack.push(br, z + 1, x * 2 + 1, y * 2 + 1); }\n }\n\n return solid;\n};\n\nGeoJSONVT.prototype.getTile = function (z, x, y) {\n var this$1 = this;\n\n var options = this.options,\n extent = options.extent,\n debug = options.debug;\n\n var z2 = 1 << z;\n x = ((x % z2) + z2) % z2; // wrap tile x coordinate\n\n var id = toID(z, x, y);\n if (this.tiles[id]) { return transform.tile(this.tiles[id], extent); }\n\n if (debug > 1) { console.log('drilling down to z%d-%d-%d', z, x, y); }\n\n var z0 = z,\n x0 = x,\n y0 = y,\n parent;\n\n while (!parent && z0 > 0) {\n z0--;\n x0 = Math.floor(x0 / 2);\n y0 = Math.floor(y0 / 2);\n parent = this$1.tiles[toID(z0, x0, y0)];\n }\n\n if (!parent || !parent.source) { return null; }\n\n // if we found a parent tile containing the original geometry, we can drill down from it\n if (debug > 1) { console.log('found parent tile z%d-%d-%d', z0, x0, y0); }\n\n // it parent tile is a solid clipped square, return it instead since it's identical\n if (isClippedSquare(parent, extent, options.buffer)) { return transform.tile(parent, extent); }\n\n if (debug > 1) { console.time('drilling down'); }\n var solid = this.splitTile(parent.source, z0, x0, y0, z, x, y);\n if (debug > 1) { console.timeEnd('drilling down'); }\n\n // one of the parent tiles was a solid clipped square\n if (solid !== null) {\n var m = 1 << (z - solid);\n id = toID(solid, Math.floor(x / m), Math.floor(y / m));\n }\n\n return this.tiles[id] ? transform.tile(this.tiles[id], extent) : null;\n};\n\nfunction toID(z, x, y) {\n return (((1 << z) * y + x) * 32) + z;\n}\n\nfunction intersectX(a, b, x) {\n return [x, (x - a[0]) * (b[1] - a[1]) / (b[0] - a[0]) + a[1], 1];\n}\nfunction intersectY(a, b, y) {\n return [(y - a[1]) * (b[0] - a[0]) / (b[1] - a[1]) + a[0], y, 1];\n}\n\nfunction extend(dest, src) {\n for (var i in src) { dest[i] = src[i]; }\n return dest;\n}\n\n// checks whether a tile is a whole-area fill after clipping; if it is, there's no sense slicing it further\nfunction isClippedSquare(tile, extent, buffer) {\n\n var features = tile.source;\n if (features.length !== 1) { return false; }\n\n var feature = features[0];\n if (feature.type !== 3 || feature.geometry.length > 1) { return false; }\n\n var len = feature.geometry[0].length;\n if (len !== 5) { return false; }\n\n for (var i = 0; i < len; i++) {\n var p = transform.point(feature.geometry[0][i], extent, tile.z2, tile.x, tile.y);\n if ((p[0] !== -buffer && p[0] !== extent + buffer) ||\n (p[1] !== -buffer && p[1] !== extent + buffer)) { return false; }\n }\n\n return true;\n}\n\nvar identity = function(x) {\n return x;\n};\n\nvar transform$3 = function(topology) {\n if ((transform = topology.transform) == null) { return identity; }\n var transform,\n x0,\n y0,\n kx = transform.scale[0],\n ky = transform.scale[1],\n dx = transform.translate[0],\n dy = transform.translate[1];\n return function(point, i) {\n if (!i) { x0 = y0 = 0; }\n point[0] = (x0 += point[0]) * kx + dx;\n point[1] = (y0 += point[1]) * ky + dy;\n return point;\n };\n};\n\nvar bbox = function(topology) {\n var bbox = topology.bbox;\n\n function bboxPoint(p0) {\n p1[0] = p0[0], p1[1] = p0[1], t(p1);\n if (p1[0] < x0) { x0 = p1[0]; }\n if (p1[0] > x1) { x1 = p1[0]; }\n if (p1[1] < y0) { y0 = p1[1]; }\n if (p1[1] > y1) { y1 = p1[1]; }\n }\n\n function bboxGeometry(o) {\n switch (o.type) {\n case \"GeometryCollection\": o.geometries.forEach(bboxGeometry); break;\n case \"Point\": bboxPoint(o.coordinates); break;\n case \"MultiPoint\": o.coordinates.forEach(bboxPoint); break;\n }\n }\n\n if (!bbox) {\n var t = transform$3(topology), p0, p1 = new Array(2), name,\n x0 = Infinity, y0 = x0, x1 = -x0, y1 = -x0;\n\n topology.arcs.forEach(function(arc) {\n var i = -1, n = arc.length;\n while (++i < n) {\n p0 = arc[i], p1[0] = p0[0], p1[1] = p0[1], t(p1, i);\n if (p1[0] < x0) { x0 = p1[0]; }\n if (p1[0] > x1) { x1 = p1[0]; }\n if (p1[1] < y0) { y0 = p1[1]; }\n if (p1[1] > y1) { y1 = p1[1]; }\n }\n });\n\n for (name in topology.objects) {\n bboxGeometry(topology.objects[name]);\n }\n\n bbox = topology.bbox = [x0, y0, x1, y1];\n }\n\n return bbox;\n};\n\nvar reverse = function(array, n) {\n var t, j = array.length, i = j - n;\n while (i < --j) { t = array[i], array[i++] = array[j], array[j] = t; }\n};\n\nvar feature = function(topology, o) {\n return o.type === \"GeometryCollection\"\n ? {type: \"FeatureCollection\", features: o.geometries.map(function(o) { return feature$1(topology, o); })}\n : feature$1(topology, o);\n};\n\nfunction feature$1(topology, o) {\n var id = o.id,\n bbox = o.bbox,\n properties = o.properties == null ? {} : o.properties,\n geometry = object(topology, o);\n return id == null && bbox == null ? {type: \"Feature\", properties: properties, geometry: geometry}\n : bbox == null ? {type: \"Feature\", id: id, properties: properties, geometry: geometry}\n : {type: \"Feature\", id: id, bbox: bbox, properties: properties, geometry: geometry};\n}\n\nfunction object(topology, o) {\n var transformPoint = transform$3(topology),\n arcs = topology.arcs;\n\n function arc(i, points) {\n if (points.length) { points.pop(); }\n for (var a = arcs[i < 0 ? ~i : i], k = 0, n = a.length; k < n; ++k) {\n points.push(transformPoint(a[k].slice(), k));\n }\n if (i < 0) { reverse(points, n); }\n }\n\n function point(p) {\n return transformPoint(p.slice());\n }\n\n function line(arcs) {\n var points = [];\n for (var i = 0, n = arcs.length; i < n; ++i) { arc(arcs[i], points); }\n if (points.length < 2) { points.push(points[0].slice()); }\n return points;\n }\n\n function ring(arcs) {\n var points = line(arcs);\n while (points.length < 4) { points.push(points[0].slice()); }\n return points;\n }\n\n function polygon(arcs) {\n return arcs.map(ring);\n }\n\n function geometry(o) {\n var type = o.type, coordinates;\n switch (type) {\n case \"GeometryCollection\": return {type: type, geometries: o.geometries.map(geometry)};\n case \"Point\": coordinates = point(o.coordinates); break;\n case \"MultiPoint\": coordinates = o.coordinates.map(point); break;\n case \"LineString\": coordinates = line(o.arcs); break;\n case \"MultiLineString\": coordinates = o.arcs.map(line); break;\n case \"Polygon\": coordinates = polygon(o.arcs); break;\n case \"MultiPolygon\": coordinates = o.arcs.map(polygon); break;\n default: return null;\n }\n return {type: type, coordinates: coordinates};\n }\n\n return geometry(o);\n}\n\nvar stitch = function(topology, arcs) {\n var stitchedArcs = {},\n fragmentByStart = {},\n fragmentByEnd = {},\n fragments = [],\n emptyIndex = -1;\n\n // Stitch empty arcs first, since they may be subsumed by other arcs.\n arcs.forEach(function(i, j) {\n var arc = topology.arcs[i < 0 ? ~i : i], t;\n if (arc.length < 3 && !arc[1][0] && !arc[1][1]) {\n t = arcs[++emptyIndex], arcs[emptyIndex] = i, arcs[j] = t;\n }\n });\n\n arcs.forEach(function(i) {\n var e = ends(i),\n start = e[0],\n end = e[1],\n f, g;\n\n if (f = fragmentByEnd[start]) {\n delete fragmentByEnd[f.end];\n f.push(i);\n f.end = end;\n if (g = fragmentByStart[end]) {\n delete fragmentByStart[g.start];\n var fg = g === f ? f : f.concat(g);\n fragmentByStart[fg.start = f.start] = fragmentByEnd[fg.end = g.end] = fg;\n } else {\n fragmentByStart[f.start] = fragmentByEnd[f.end] = f;\n }\n } else if (f = fragmentByStart[end]) {\n delete fragmentByStart[f.start];\n f.unshift(i);\n f.start = start;\n if (g = fragmentByEnd[start]) {\n delete fragmentByEnd[g.end];\n var gf = g === f ? f : g.concat(f);\n fragmentByStart[gf.start = g.start] = fragmentByEnd[gf.end = f.end] = gf;\n } else {\n fragmentByStart[f.start] = fragmentByEnd[f.end] = f;\n }\n } else {\n f = [i];\n fragmentByStart[f.start = start] = fragmentByEnd[f.end = end] = f;\n }\n });\n\n function ends(i) {\n var arc = topology.arcs[i < 0 ? ~i : i], p0 = arc[0], p1;\n if (topology.transform) { p1 = [0, 0], arc.forEach(function(dp) { p1[0] += dp[0], p1[1] += dp[1]; }); }\n else { p1 = arc[arc.length - 1]; }\n return i < 0 ? [p1, p0] : [p0, p1];\n }\n\n function flush(fragmentByEnd, fragmentByStart) {\n for (var k in fragmentByEnd) {\n var f = fragmentByEnd[k];\n delete fragmentByStart[f.start];\n delete f.start;\n delete f.end;\n f.forEach(function(i) { stitchedArcs[i < 0 ? ~i : i] = 1; });\n fragments.push(f);\n }\n }\n\n flush(fragmentByEnd, fragmentByStart);\n flush(fragmentByStart, fragmentByEnd);\n arcs.forEach(function(i) { if (!stitchedArcs[i < 0 ? ~i : i]) { fragments.push([i]); } });\n\n return fragments;\n};\n\nfunction extractArcs(topology, object$$1, filter) {\n var arcs = [],\n geomsByArc = [],\n geom;\n\n function extract0(i) {\n var j = i < 0 ? ~i : i;\n (geomsByArc[j] || (geomsByArc[j] = [])).push({i: i, g: geom});\n }\n\n function extract1(arcs) {\n arcs.forEach(extract0);\n }\n\n function extract2(arcs) {\n arcs.forEach(extract1);\n }\n\n function extract3(arcs) {\n arcs.forEach(extract2);\n }\n\n function geometry(o) {\n switch (geom = o, o.type) {\n case \"GeometryCollection\": o.geometries.forEach(geometry); break;\n case \"LineString\": extract1(o.arcs); break;\n case \"MultiLineString\": case \"Polygon\": extract2(o.arcs); break;\n case \"MultiPolygon\": extract3(o.arcs); break;\n }\n }\n\n geometry(object$$1);\n\n geomsByArc.forEach(filter == null\n ? function(geoms) { arcs.push(geoms[0].i); }\n : function(geoms) { if (filter(geoms[0].g, geoms[geoms.length - 1].g)) { arcs.push(geoms[0].i); } });\n\n return arcs;\n}\n\nfunction planarRingArea(ring) {\n var i = -1, n = ring.length, a, b = ring[n - 1], area = 0;\n while (++i < n) { a = b, b = ring[i], area += a[0] * b[1] - a[1] * b[0]; }\n return Math.abs(area); // Note: doubled area!\n}\n\nvar bisect = function(a, x) {\n var lo = 0, hi = a.length;\n while (lo < hi) {\n var mid = lo + hi >>> 1;\n if (a[mid] < x) { lo = mid + 1; }\n else { hi = mid; }\n }\n return lo;\n};\n\nvar slicers = {};\nvar options;\n\nonmessage = function (e) {\n if (e.data[0] === 'slice') {\n // Given a blob of GeoJSON and some topojson/geojson-vt options, do the slicing.\n var geojson = e.data[1];\n options = e.data[2];\n\n if (geojson.type && geojson.type === 'Topology') {\n for (var layerName in geojson.objects) {\n slicers[layerName] = index(\n feature(geojson, geojson.objects[layerName])\n , options);\n }\n } else {\n slicers[options.vectorTileLayerName] = index(geojson, options);\n }\n\n } else if (e.data[0] === 'get') {\n // Gets the vector tile for the given coordinates, sends it back as a message\n var coords = e.data[1];\n\n var tileLayers = {};\n for (var layerName in slicers) {\n var slicedTileLayer = slicers[layerName].getTile(coords.z, coords.x, coords.y);\n\n if (slicedTileLayer) {\n var vectorTileLayer = {\n features: [],\n extent: options.extent,\n name: options.vectorTileLayerName,\n length: slicedTileLayer.features.length\n };\n\n for (var i in slicedTileLayer.features) {\n var feat = {\n geometry: slicedTileLayer.features[i].geometry,\n properties: slicedTileLayer.features[i].tags,\n type: slicedTileLayer.features[i].type // 1 = point, 2 = line, 3 = polygon\n };\n vectorTileLayer.features.push(feat);\n }\n tileLayers[layerName] = vectorTileLayer;\n }\n }\n postMessage({ layers: tileLayers, coords: coords });\n }\n};\n//# sourceMap" + "pingURL=slicerWebWorker.js.worker.map\n", "text/plain; charset=us-ascii", false); + +// The geojson/topojson is sliced into tiles via a web worker. +// This import statement depends on rollup-file-as-blob, so that the +// variable 'workerCode' is a blob URL. + +/* + * ??class VectorGrid.Slicer + * ??extends VectorGrid + * + * A `VectorGrid` for slicing up big GeoJSON or TopoJSON documents in vector + * tiles, leveraging [`geojson-vt`](https://github.com/mapbox/geojson-vt). + * + * ??example + * + * ``` + * var geoJsonDocument = { + * type: 'FeatureCollection', + * features: [ ... ] + * }; + * + * L.vectorGrid.slicer(geoJsonDocument, { + * vectorTileLayerStyles: { + * sliced: { ... } + * } + * }).addTo(map); + * + * ``` + * + * `VectorGrid.Slicer` can also handle [TopoJSON](https://github.com/mbostock/topojson) transparently: + * ```js + * var layer = L.vectorGrid.slicer(topojson, options); + * ``` + * + * The TopoJSON format [implicitly groups features into "objects"](https://github.com/mbostock/topojson-specification/blob/master/README.md#215-objects). + * These will be transformed into vector tile layer names when styling (the + * `vectorTileLayerName` option is ignored when using TopoJSON). + * + */ + +L.VectorGrid.Slicer = L.VectorGrid.extend({ + + options: { + // ??section + // Additionally to these options, `VectorGrid.Slicer` can take in any + // of the [`geojson-vt` options](https://github.com/mapbox/geojson-vt#options). + + // ??option vectorTileLayerName: String = 'sliced' + // Vector tiles contain a set of *data layers*, and those data layers + // contain features. Thus, the slicer creates one data layer, with + // the name given in this option. This is important for symbolizing the data. + vectorTileLayerName: 'sliced', + + extent: 4096, // Default for geojson-vt + maxZoom: 14 // Default for geojson-vt + }, + + initialize: function(geojson, options) { + L.VectorGrid.prototype.initialize.call(this, options); + + // Create a shallow copy of this.options, excluding things that might + // be functions - we only care about topojson/geojsonvt options + var options = {}; + for (var i in this.options) { + if (i !== 'rendererFactory' && + i !== 'vectorTileLayerStyles' && + typeof (this.options[i]) !== 'function' + ) { + options[i] = this.options[i]; + } + } + +// this._worker = new Worker(window.URL.createObjectURL(new Blob([workerCode]))); + this._worker = new Worker(workerCode); + + // Send initial data to worker. + this._worker.postMessage(['slice', geojson, options]); + + }, + + + _getVectorTilePromise: function(coords) { + + var _this = this; + + var p = new Promise( function waitForWorker(res) { + _this._worker.addEventListener('message', function recv(m) { + if (m.data.coords && + m.data.coords.x === coords.x && + m.data.coords.y === coords.y && + m.data.coords.z === coords.z ) { + + res(m.data); + _this._worker.removeEventListener('message', recv); + } + }); + }); + + this._worker.postMessage(['get', coords]); + + return p; + }, + +}); + + +L.vectorGrid.slicer = function (geojson, options) { + return new L.VectorGrid.Slicer(geojson, options); +}; + +L.Canvas.Tile = L.Canvas.extend({ + + initialize: function (tileCoord, tileSize, options) { + L.Canvas.prototype.initialize.call(this, options); + this._tileCoord = tileCoord; + this._size = tileSize; + + this._initContainer(); + this._container.setAttribute('width', this._size.x); + this._container.setAttribute('height', this._size.y); + this._layers = {}; + this._drawnLayers = {}; + this._drawing = true; + + if (options.interactive) { + // By default, Leaflet tiles do not have pointer events + this._container.style.pointerEvents = 'auto'; + } + }, + + getCoord: function() { + return this._tileCoord; + }, + + getContainer: function() { + return this._container; + }, + + getOffset: function() { + return this._tileCoord.scaleBy(this._size).subtract(this._map.getPixelOrigin()); + }, + + onAdd: L.Util.falseFn, + + addTo: function(map) { + this._map = map; + }, + + removeFrom: function (map) { + delete this._map; + }, + + _onClick: function (e) { + var point = this._map.mouseEventToLayerPoint(e).subtract(this.getOffset()), layer, clickedLayer; + + for (var id in this._layers) { + layer = this._layers[id]; + if (layer.options.interactive && layer._containsPoint(point) && !this._map._draggableMoved(layer)) { + clickedLayer = layer; + } + } + if (clickedLayer) { + L.DomEvent.fakeStop(e); + this._fireEvent([clickedLayer], e); + } + }, + + _onMouseMove: function (e) { + if (!this._map || this._map.dragging.moving() || this._map._animatingZoom) { return; } + + var point = this._map.mouseEventToLayerPoint(e).subtract(this.getOffset()); + this._handleMouseHover(e, point); + }, + + /// TODO: Modify _initPath to include an extra parameter, a group name + /// to order symbolizers by z-index + + _updateIcon: function (layer) { + if (!this._drawing) { return; } + + var icon = layer.options.icon, + options = icon.options, + size = L.point(options.iconSize), + anchor = options.iconAnchor || + size && size.divideBy(2, true), + p = layer._point.subtract(anchor), + ctx = this._ctx, + img = layer._getImage(); + + if (img.complete) { + ctx.drawImage(img, p.x, p.y, size.x, size.y); + } else { + L.DomEvent.on(img, 'load', function() { + ctx.drawImage(img, p.x, p.y, size.x, size.y); + }); + } + + this._drawnLayers[layer._leaflet_id] = layer; + } +}); + + +L.canvas.tile = function(tileCoord, tileSize, opts){ + return new L.Canvas.Tile(tileCoord, tileSize, opts); +}; + +// Aux file to bundle everything together, including the optional dependencies +// for protobuf tiles + +}()); +//# sourceMappingURL=Leaflet.VectorGrid.bundled.js.map diff --git a/src/deepfacility/viz/leaflet_template/js/OSMBuildings-Leaflet.js b/src/deepfacility/viz/leaflet_template/js/OSMBuildings-Leaflet.js new file mode 100644 index 0000000..904e0a5 --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/js/OSMBuildings-Leaflet.js @@ -0,0 +1,48 @@ +(function(ca){function S(b,a){var c=b.x-a.x,d=b.y-a.y;return c*c+d*d}function va(b){var a=b.length;if(16>a)return!1;var c,d=Infinity,f=-Infinity,e=Infinity,g=-Infinity;for(c=0;cf||1.15g/e||1.2=a?90:1<=a?-90:(2*xa(ya(E* +(1-2*a)))-J)/E*180;c[d]=f;c[za]=360*(1===b?1:(b%1+1)%1)-180;return c}function da(b,a){var c=U(1,K(0,0.5-Aa(ka(Ba+J*b/180))/E/2));return{x:(a/360+0.5)*T<<0,y:c*T<<0}}function V(b){for(var a=B+p,c=v+n,d=0,f=b.length-3;dp&&b[d]n&&b[d+1]b[c].scale&&(b[c].scale+=0.1,1c&&(c+=1);1c?b:c<2/3?a+(b-a)*(2/3-c)*6:a}var c={aqua:"#00ffff",black:"#000000",blue:"#0000ff",fuchsia:"#ff00ff",gray:"#808080",grey:"#808080",green:"#008000",lime:"#00ff00",maroon:"#800000",navy:"#000080",olive:"#808000",orange:"#ffa500",purple:"#800080",red:"#ff0000",silver:"#c0c0c0",teal:"#008080",white:"#ffffff",yellow:"#ffff00"},d=function(a,b, +c,d){this.H=a;this.S=b;this.L=c;this.A=d};d.parse=function(a){var b=0,d=0,h=0,k=1,m;a=(""+a).toLowerCase();a=c[a]||a;if(m=a.match(/^#(\w{2})(\w{2})(\w{2})$/))b=parseInt(m[1],16),d=parseInt(m[2],16),h=parseInt(m[3],16);else if(m=a.match(/rgba?\((\d+)\D+(\d+)\D+(\d+)(\D+([\d.]+))?\)/))b=parseInt(m[1],10),d=parseInt(m[2],10),h=parseInt(m[3],10),k=m[4]?parseFloat(m[5]):1;else return;return this.fromRGBA(b,d,h,k)};d.fromRGBA=function(a,b,c,h){"object"===typeof a?(b=a.g/255,c=a.b/255,h=a.a,a=a.r/255):(a/= +255,b/=255,c/=255);var k=Math.max(a,b,c),m=Math.min(a,b,c),l,y=(k+m)/2,s=k-m;if(s){m=0.5d?d*(1+c):d+c-d*c,d=2*d-m,b=b/360,c=a(d,m,b+1/3);k=a(d,m,b);b= +a(d,m,b-1/3)}return{r:Math.round(255*c),g:Math.round(255*k),b:Math.round(255*b),a:h}},toString:function(){var a=this.toRGBA();return 1===a.a?"#"+(16777216+(a.r<<16)+(a.g<<8)+a.b).toString(16).slice(1,7):"rgba("+[a.r,a.g,a.b,a.a.toFixed(2)].join()+")"},hue:function(a){return new d(this.H*a,this.S,this.L,this.A)},saturation:function(a){return new d(this.H,this.S*a,this.L,this.A)},lightness:function(a){return new d(this.H,this.S,this.L*a,this.A)},alpha:function(a){return new d(this.H,this.S,this.L,this.A* +a)}};return d}(this),Ha=function(){var b=Math,a=b.PI,c=b.sin,d=b.cos,f=b.tan,e=b.asin,g=b.atan2,h=a/180,k=23.4397*h;return function(b,l,y){y=h*-y;l*=h;b=b.valueOf()/864E5-0.5+2440588-2451545;var s=h*(357.5291+0.98560028*b),D;D=h*(1.9148*c(s)+0.02*c(2*s)+3E-4*c(3*s));D=s+D+102.9372*h+a;s=e(c(0)*d(k)+d(0)*c(k)*c(D));D=g(c(D)*d(k)-f(0)*c(k),d(D));b=h*(280.16+360.9856235*b)-y-D;y=e(c(l)*c(s)+d(l)*d(s)*d(b));l=g(c(b),d(b)*c(l)-f(s)*d(l));return{altitude:y,azimuth:l-a/2}}}(),Ja=function(){function b(a){a= +a.toLowerCase();return"#"===a[0]?a:d[f[a]||a]||null}function a(a,b){var c,d,f,s,D=0,p,n;p=0;for(n=a.length-3;ph.status||299f;)k=c.shift(),d-=k.size,delete a[k.url]}};h.open("GET",b);h.send(null);return h}}var a={},c=[],d=0,f=5242880;return{loadJSON:function(a,c){return b(a,function(a){var b;try{b= +JSON.parse(a)}catch(d){}c(b)})}}}(),F={loadedItems:{},items:[],getPixelFootprint:function(b){for(var a=new ra(b.length),c,d=0,f=b.length-1;dg&&(k=e,g=h)}2a.length))return a},resetItems:function(){this.items=[];this.loadedItems={};Y.reset()},addRenderItems:function(b,a){for(var c,d,f,e=Ja.read(b),g=0,h=e.length;gga)&&(a.footprint=this.getPixelFootprint(b.footprint),a.footprint)){for(var d=a.footprint,f=Infinity,e=-Infinity,g=Infinity,h=-Infinity,k=0,m=d.length-3;k>16-x,c=p/a<<0, +d=n/a<<0,f=qa((p+B)/a),a=qa((n+v)/a),e,g=this;for(e=d;e<=a;e++)for(d=c;d<=f;d++)this.loadTile(d,e,16,b)}},loadTile:function(b,a,c,d){b=this.src.replace("{s}","abcd"[(b+a)%4]).replace("{x}",b).replace("{y}",a).replace("{z}",c);return Ka.loadJSON(b,d)}},Z={draw:function(b,a,c,d,f,e,g,h){var k,m=this._extrude(b,a,d,f,e,g),l=[];if(c)for(a=0,k=c.length;a(m.x-h.x)*(k.y-h.y)&&(b.fillStyle=h.xk.x&&h.y>k.y?e:f,b.beginPath(),this._ring(b,[k.x,k.y,h.x,h.y,m.x,m.y,l.x,l.y]),b.closePath(),b.fill()),y[s]=m.x,y[s+1]=m.y;return y},_ring:function(b,a){b.moveTo(a[0], +a[1]);for(var c=2,d=a.length-1;c(k.x-g.x)*(h.y-g.y)?(1===e&&b.lineTo(g.x,g.y),e=0,l||b.moveTo(g.x,g.y),b.lineTo(h.x,h.y)):(0===e&&b.lineTo(k.x,k.y),e=1,l||b.moveTo(k.x,k.y),b.lineTo(m.x,m.y));if(c)for(l=0,q=c.length;l(e.x-g.x)*(h.y-g.y)?(1===c&&b.lineTo(g.x,g.y),c=0,l||b.moveTo(g.x,g.y),b.lineTo(h.x,h.y)):(0===c&&b.lineTo(e.x,e.y),c=1,l||b.moveTo(e.x,e.y),b.lineTo(m.x,m.y));b.closePath();b.fill()}},w={draw:function(b,a,c,d,f,e,g,h,k){a={x:a.x-p,y:a.y-n};var m=q/(q-f),l=q/(q-e);f=r.project(a,m);d*=m;e&&(a=r.project(a,l),c*=l);(m=this._tangents(a, +c,f,d))?(e=P(m[0].y1-a.y,m[0].x1-a.x),m=P(m[1].y1-a.y,m[1].x1-a.x)):(e=1.5*E,m=1.5*E);b.fillStyle=g;b.beginPath();b.arc(f.x,f.y,d,J,e,!0);b.arc(a.x,a.y,c,e,J);b.closePath();b.fill();b.fillStyle=h;b.beginPath();b.arc(f.x,f.y,d,m,J,!0);b.arc(a.x,a.y,c,J,m);b.closePath();b.fill();b.fillStyle=k;this._circle(b,f,d)},simplified:function(b,a,c){this._circle(b,{x:a.x-p,y:a.y-n},c)},shadow:function(b,a,c,d,f,e){a={x:a.x-p,y:a.y-n};f=z.project(a,f);var g;e&&(a=z.project(a,e));var h=this._tangents(a,c,f,d); +h?(e=P(h[0].y1-a.y,h[0].x1-a.x),g=P(h[1].y1-a.y,h[1].x1-a.x),b.moveTo(h[1].x2,h[1].y2),b.arc(f.x,f.y,d,g,e),b.arc(a.x,a.y,c,e,g)):(b.moveTo(a.x+c,a.y),b.arc(a.x,a.y,c,0,2*E))},shadowMask:function(b,a,c){var d=a.x-p;a=a.y-n;b.moveTo(d+c,a);b.arc(d,a,c,0,2*E)},hitArea:function(b,a,c,d,f,e,g){a={x:a.x-p,y:a.y-n};var h=q/(q-f),k=q/(q-e);f=r.project(a,h);d*=h;e&&(a=r.project(a,k),c*=k);e=this._tangents(a,c,f,d);b.fillStyle=g;b.beginPath();e?(g=P(e[0].y1-a.y,e[0].x1-a.x),h=P(e[1].y1-a.y,e[1].x1-a.x),b.moveTo(e[1].x2, +e[1].y2),b.arc(f.x,f.y,d,h,g),b.arc(a.x,a.y,c,g,h)):(b.moveTo(a.x+c,a.y),b.arc(a.x,a.y,c,0,2*E));b.closePath();b.fill()},_circle:function(b,a,c){b.beginPath();b.arc(a.x,a.y,c,0,2*E);b.stroke();b.fill()},_tangents:function(b,a,c,d){var f=b.x-c.x,e=b.y-c.y,g=a-d,h=f*f+e*e;if(!(h<=g*g)){var h=pa(h),f=-f/h,e=-e/h,g=g/h,h=[],k,m,l;k=pa(K(0,1-g*g));for(var n=1;-1<=n;n-=2)m=f*g-n*k*e,l=e*g+n*k*f,h.push({x1:b.x+a*m<<0,y1:b.y+a*l<<0,x2:c.x+d*m<<0,y2:c.y+d*l<<0});return h}}},R={draw:function(b,a,c,d,f,e,g){var h= +q/(q-f);c=r.project({x:c.x-p,y:c.y-n},q/(q-d));d={x:0,y:0};for(var k={x:0,y:0},m=0,l=a.length-3;m(c.x-d.x)*(k.y-d.y)&&(b.fillStyle=d.xk.x&&d.y>k.y?g:e,b.beginPath(),this._triangle(b,d,k,c),b.closePath(),b.fill())},_triangle:function(b,a,c,d){b.moveTo(a.x,a.y);b.lineTo(c.x,c.y);b.lineTo(d.x,d.y)},_ring:function(b,a){b.moveTo(a[0]-p,a[1]-n);for(var c=2,d=a.length-1;c< +d;c+=2)b.lineTo(a[c]-p,a[c+1]-n)},shadow:function(b,a,c,d,f){var e={x:0,y:0},g={x:0,y:0};c=z.project({x:c.x-p,y:c.y-n},d);d=0;for(var h=a.length-3;d(c.x-e.x)*(g.y-e.y)&&this._triangle(b,e,g,c)},shadowMask:function(b,a){this._ring(b,a)},hitArea:function(b,a,c,d,f,e){var g=q/(q-f);c=r.project({x:c.x-p,y:c.y-n},q/(q-d));d={x:0,y:0};var h={x:0,y:0};b.fillStyle=e;b.beginPath();e=0;for(var k= +a.length-3;e(c.x-d.x)*(h.y-d.y)&&this._triangle(b,d,h,c);b.closePath();b.fill()}},r={project:function(b,a){return{x:(b.x-M)*a+M<<0,y:(b.y-N)*a+N<<0}},render:function(){var b=this.context;b.clearRect(0,0,B,v);if(!(xa.scale?a.height*a.scale:a.height;d=0;a.minHeight&&(d=1>a.scale?a.minHeight*a.scale:a.minHeight);g=a.wallColor||ha;h=a.altColor||aa;k=a.roofColor||X;b.strokeStyle=h;switch(a.shape){case "cylinder":w.draw(b,a.center,a.radius,a.radius,c,d,g,h,k);break;case "cone":w.draw(b,a.center,a.radius,0,c,d,g,h);break;case "dome":w.draw(b,a.center,a.radius,a.radius/2,c,d,g,h);break;case "sphere":w.draw(b,a.center,a.radius,a.radius,c,d,g,h,k);break;case "pyramid":R.draw(b, +e,a.center,c,d,g,h);break;default:Z.draw(b,e,a.holes,c,d,g,h,k)}switch(a.roofShape){case "cone":w.draw(b,a.center,a.radius,0,c+a.roofHeight,c,k,""+I.parse(k).lightness(0.9));break;case "dome":w.draw(b,a.center,a.radius,a.radius/2,c+a.roofHeight,c,k,""+I.parse(k).lightness(0.9));break;case "pyramid":R.draw(b,e,a.center,c+a.roofHeight,c,k,I.parse(k).lightness(0.9))}}}}},ia={maxZoom:G+2,maxHeight:5,isSimple:function(b){return x<=this.maxZoom&&b.height+b.roofHeightthis.maxZoom))for(var a,c,d=F.items,f=0,e=d.length;f=this.maxHeight)&&(c=a.footprint,V(c)))switch(b.strokeStyle=a.altColor||aa,b.fillStyle=a.roofColor||X,a.shape){case "cylinder":case "cone":case "dome":case "sphere":w.simplified(b,a.center,a.radius);break;default:Z.simplified(b,c,a.holes)}}},z={enabled:!0,color:"#666666",blurColor:"#000000",blurSize:15,date:new Date,direction:{x:0,y:0},project:function(b,a){return{x:b.x+ +this.direction.x*a,y:b.y+this.direction.y*a}},render:function(){var b=this.context,a,c,d;b.clearRect(0,0,B,v);if(!(!this.enabled||x=a.altitude))){c=1/ka(a.altitude);d=5>c?0.75:1/c*5;this.direction.x=Fa(a.azimuth)*c;this.direction.y=Ea(a.azimuth)*c;var f,e,g,h;a=F.items;b.canvas.style.opacity=d/(2*C);b.shadowColor=this.blurColor;b.shadowBlur=C/2*this.blurSize;b.fillStyle=this.color;b.beginPath();d=0;for(c=a.length;df.scale?f.height*f.scale:f.height;g=0;f.minHeight&&(g=1>f.scale?f.minHeight*f.scale:f.minHeight);switch(f.shape){case "cylinder":w.shadow(b,f.center,f.radius,f.radius,e,g);break;case "cone":w.shadow(b,f.center,f.radius,0,e,g);break;case "dome":w.shadow(b,f.center,f.radius,f.radius/2,e,g);break;case "sphere":w.shadow(b,f.center,f.radius,f.radius,e,g);break;case "pyramid":R.shadow(b,h,f.center,e,g);break;default:Z.shadow(b,h,f.holes,e,g)}switch(f.roofShape){case "cone":w.shadow(b, +f.center,f.radius,0,e+f.roofHeight,e);break;case "dome":w.shadow(b,f.center,f.radius,f.radius/2,e+f.roofHeight,e);break;case "pyramid":R.shadow(b,h,f.center,e+f.roofHeight,e)}}b.closePath();b.fill();b.shadowBlur=null;b.globalCompositeOperation="destination-out";b.beginPath();d=0;for(c=a.length;d>8&255,a>>16&255].join()+")"}}, +$,A={container:document.createElement("DIV"),items:[],init:function(){this.container.style.pointerEvents="none";this.container.style.position="absolute";this.container.style.left=0;this.container.style.top=0;z.context=this.createContext(this.container);ia.context=this.createContext(this.container);r.context=this.createContext(this.container);Y.context=this.createContext()},render:function(b){Ga(function(){b||(z.render(),ia.render(),Y.render());r.render()})},createContext:function(b){var a=document.createElement("CANVAS"); +a.style.transform="translate3d(0, 0, 0)";a.style.imageRendering="optimizeSpeed";a.style.position="absolute";a.style.left=0;a.style.top=0;var c=a.getContext("2d");c.lineCap="round";c.lineJoin="round";c.lineWidth=1;c.imageSmoothingEnabled=!1;this.items.push(a);b&&b.appendChild(a);return c},appendTo:function(b){b.appendChild(this.container)},remove:function(){this.container.parentNode.removeChild(this.container)},setSize:function(b,a){for(var c=0,d=this.items.length;cOSM Buildings');F.update()};t.onRemove=function(){var b=this.map;b.attributionControl&&b.attributionControl.removeAttribution('© OSM Buildings');b.off({move:this.onMove, +moveend:this.onMoveEnd,zoomstart:this.onZoomStart,zoomend:this.onZoomEnd,resize:this.onResize,viewreset:this.onViewReset,click:this.onClick},this);b.options.zoomAnimation&&b.off("zoomanim",this.onZoom,this);A.remove()};t.onMove=function(b){b=this.getOffset();ea({x:this.offset.x-b.x,y:this.offset.y-b.y})};t.onMoveEnd=function(b){if(this.noMoveEnd)this.noMoveEnd=!1;else{var a=this.map;b=this.getOffset();var c=a.getPixelOrigin();this.offset=b;A.setPosition(-b.x,-b.y);ea({x:0,y:0});la({width:a._size.x, +height:a._size.y});a=c.y-b.y;p=c.x-b.x;n=a;A.render();F.update()}};t.onZoomStart=function(b){Q=!0;A.render()};t.onZoom=function(b){};t.onZoomEnd=function(b){b=this.map;var a=this.getOffset(),c=b.getPixelOrigin(),d=c.y-a.y;p=c.x-a.x;n=d;b=b._zoom;Q=!1;ma(b);F.update();A.render();this.noMoveEnd=!0};t.onResize=function(){};t.onViewReset=function(){var b=this.getOffset();this.offset=b;A.setPosition(-b.x,-b.y);ea({x:0,y:0})};t.onClick=function(b){var a=Y.getIdFromXY(b.containerPoint.x,b.containerPoint.y); +a&&ua({feature:a,lat:b.latlng.lat,lon:b.latlng.lng})};t.getOffset=function(){return L.DomUtil.getPosition(this.map._mapPane)};t.style=function(b){b=b||{};var a;if(a=b.color||b.wallColor)H=I.parse(a),ha=""+H.alpha(C),ba=H.lightness(0.8),aa=""+ba.alpha(C),O=H.lightness(1.2),X=""+O.alpha(C);b.roofColor&&(O=I.parse(b.roofColor),X=""+O.alpha(C));void 0!==b.shadows&&(z.enabled=!!b.shadows);A.render();return this};t.date=function(b){z.date=b;z.render();return this};t.load=function(b){F.load(b);return this}; +t.set=function(b){F.set(b);return this};var ta=function(){};t.each=function(b){ta=function(a){return b(a)};return this};var ua=function(){};t.click=function(b){ua=function(a){return b(a)};return this};u.VERSION="0.2.2b";u.ATTRIBUTION='© OSM Buildings';ca.OSMBuildings=u})(this); diff --git a/src/deepfacility/viz/leaflet_template/js/jquery-3.7.1.min.js b/src/deepfacility/viz/leaflet_template/js/jquery-3.7.1.min.js new file mode 100644 index 0000000..7f37b5d --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/js/jquery-3.7.1.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.7.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(ie,e){"use strict";var oe=[],r=Object.getPrototypeOf,ae=oe.slice,g=oe.flat?function(e){return oe.flat.call(e)}:function(e){return oe.concat.apply([],e)},s=oe.push,se=oe.indexOf,n={},i=n.toString,ue=n.hasOwnProperty,o=ue.toString,a=o.call(Object),le={},v=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},y=function(e){return null!=e&&e===e.window},C=ie.document,u={type:!0,src:!0,nonce:!0,noModule:!0};function m(e,t,n){var r,i,o=(n=n||C).createElement("script");if(o.text=e,t)for(r in u)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[i.call(e)]||"object":typeof e}var t="3.7.1",l=/HTML$/i,ce=function(e,t){return new ce.fn.init(e,t)};function c(e){var t=!!e&&"length"in e&&e.length,n=x(e);return!v(e)&&!y(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+ge+")"+ge+"*"),x=new RegExp(ge+"|>"),j=new RegExp(g),A=new RegExp("^"+t+"$"),D={ID:new RegExp("^#("+t+")"),CLASS:new RegExp("^\\.("+t+")"),TAG:new RegExp("^("+t+"|[*])"),ATTR:new RegExp("^"+p),PSEUDO:new RegExp("^"+g),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+ge+"*(even|odd|(([+-]|)(\\d*)n|)"+ge+"*(?:([+-]|)"+ge+"*(\\d+)|))"+ge+"*\\)|)","i"),bool:new RegExp("^(?:"+f+")$","i"),needsContext:new RegExp("^"+ge+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+ge+"*((?:-\\d)?\\d*)"+ge+"*\\)|)(?=[^-]|$)","i")},N=/^(?:input|select|textarea|button)$/i,q=/^h\d$/i,L=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,H=/[+~]/,O=new RegExp("\\\\[\\da-fA-F]{1,6}"+ge+"?|\\\\([^\\r\\n\\f])","g"),P=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},M=function(){V()},R=J(function(e){return!0===e.disabled&&fe(e,"fieldset")},{dir:"parentNode",next:"legend"});try{k.apply(oe=ae.call(ye.childNodes),ye.childNodes),oe[ye.childNodes.length].nodeType}catch(e){k={apply:function(e,t){me.apply(e,ae.call(t))},call:function(e){me.apply(e,ae.call(arguments,1))}}}function I(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(V(e),e=e||T,C)){if(11!==p&&(u=L.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return k.call(n,a),n}else if(f&&(a=f.getElementById(i))&&I.contains(e,a)&&a.id===i)return k.call(n,a),n}else{if(u[2])return k.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&e.getElementsByClassName)return k.apply(n,e.getElementsByClassName(i)),n}if(!(h[t+" "]||d&&d.test(t))){if(c=t,f=e,1===p&&(x.test(t)||m.test(t))){(f=H.test(t)&&U(e.parentNode)||e)==e&&le.scope||((s=e.getAttribute("id"))?s=ce.escapeSelector(s):e.setAttribute("id",s=S)),o=(l=Y(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+Q(l[o]);c=l.join(",")}try{return k.apply(n,f.querySelectorAll(c)),n}catch(e){h(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return re(t.replace(ve,"$1"),e,n,r)}function W(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function F(e){return e[S]=!0,e}function $(e){var t=T.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function B(t){return function(e){return fe(e,"input")&&e.type===t}}function _(t){return function(e){return(fe(e,"input")||fe(e,"button"))&&e.type===t}}function z(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&R(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function X(a){return F(function(o){return o=+o,F(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function U(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}function V(e){var t,n=e?e.ownerDocument||e:ye;return n!=T&&9===n.nodeType&&n.documentElement&&(r=(T=n).documentElement,C=!ce.isXMLDoc(T),i=r.matches||r.webkitMatchesSelector||r.msMatchesSelector,r.msMatchesSelector&&ye!=T&&(t=T.defaultView)&&t.top!==t&&t.addEventListener("unload",M),le.getById=$(function(e){return r.appendChild(e).id=ce.expando,!T.getElementsByName||!T.getElementsByName(ce.expando).length}),le.disconnectedMatch=$(function(e){return i.call(e,"*")}),le.scope=$(function(){return T.querySelectorAll(":scope")}),le.cssHas=$(function(){try{return T.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),le.getById?(b.filter.ID=function(e){var t=e.replace(O,P);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(O,P);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):t.querySelectorAll(e)},b.find.CLASS=function(e,t){if("undefined"!=typeof t.getElementsByClassName&&C)return t.getElementsByClassName(e)},d=[],$(function(e){var t;r.appendChild(e).innerHTML="",e.querySelectorAll("[selected]").length||d.push("\\["+ge+"*(?:value|"+f+")"),e.querySelectorAll("[id~="+S+"-]").length||d.push("~="),e.querySelectorAll("a#"+S+"+*").length||d.push(".#.+[+~]"),e.querySelectorAll(":checked").length||d.push(":checked"),(t=T.createElement("input")).setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),r.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&d.push(":enabled",":disabled"),(t=T.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||d.push("\\["+ge+"*name"+ge+"*="+ge+"*(?:''|\"\")")}),le.cssHas||d.push(":has"),d=d.length&&new RegExp(d.join("|")),l=function(e,t){if(e===t)return a=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!le.sortDetached&&t.compareDocumentPosition(e)===n?e===T||e.ownerDocument==ye&&I.contains(ye,e)?-1:t===T||t.ownerDocument==ye&&I.contains(ye,t)?1:o?se.call(o,e)-se.call(o,t):0:4&n?-1:1)}),T}for(e in I.matches=function(e,t){return I(e,null,null,t)},I.matchesSelector=function(e,t){if(V(e),C&&!h[t+" "]&&(!d||!d.test(t)))try{var n=i.call(e,t);if(n||le.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){h(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(O,P),e[3]=(e[3]||e[4]||e[5]||"").replace(O,P),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||I.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&I.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return D.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&j.test(n)&&(t=Y(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(O,P).toLowerCase();return"*"===e?function(){return!0}:function(e){return fe(e,t)}},CLASS:function(e){var t=s[e+" "];return t||(t=new RegExp("(^|"+ge+")"+e+"("+ge+"|$)"))&&s(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=I.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function T(e,n,r){return v(n)?ce.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?ce.grep(e,function(e){return e===n!==r}):"string"!=typeof n?ce.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(ce.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||k,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:S.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof ce?t[0]:t,ce.merge(this,ce.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:C,!0)),w.test(r[1])&&ce.isPlainObject(t))for(r in t)v(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=C.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):v(e)?void 0!==n.ready?n.ready(e):e(ce):ce.makeArray(e,this)}).prototype=ce.fn,k=ce(C);var E=/^(?:parents|prev(?:Until|All))/,j={children:!0,contents:!0,next:!0,prev:!0};function A(e,t){while((e=e[t])&&1!==e.nodeType);return e}ce.fn.extend({has:function(e){var t=ce(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,Ce=/^$|^module$|\/(?:java|ecma)script/i;xe=C.createDocumentFragment().appendChild(C.createElement("div")),(be=C.createElement("input")).setAttribute("type","radio"),be.setAttribute("checked","checked"),be.setAttribute("name","t"),xe.appendChild(be),le.checkClone=xe.cloneNode(!0).cloneNode(!0).lastChild.checked,xe.innerHTML="",le.noCloneChecked=!!xe.cloneNode(!0).lastChild.defaultValue,xe.innerHTML="",le.option=!!xe.lastChild;var ke={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function Se(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&fe(e,t)?ce.merge([e],n):n}function Ee(e,t){for(var n=0,r=e.length;n",""]);var je=/<|&#?\w+;/;function Ae(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function Re(e,t){return fe(e,"table")&&fe(11!==t.nodeType?t:t.firstChild,"tr")&&ce(e).children("tbody")[0]||e}function Ie(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function We(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Fe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(_.hasData(e)&&(s=_.get(e).events))for(i in _.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),C.head.appendChild(r[0])},abort:function(){i&&i()}}});var Jt,Kt=[],Zt=/(=)\?(?=&|$)|\?\?/;ce.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Kt.pop()||ce.expando+"_"+jt.guid++;return this[e]=!0,e}}),ce.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Zt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Zt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=v(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Zt,"$1"+r):!1!==e.jsonp&&(e.url+=(At.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||ce.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=ie[r],ie[r]=function(){o=arguments},n.always(function(){void 0===i?ce(ie).removeProp(r):ie[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Kt.push(r)),o&&v(i)&&i(o[0]),o=i=void 0}),"script"}),le.createHTMLDocument=((Jt=C.implementation.createHTMLDocument("").body).innerHTML="
",2===Jt.childNodes.length),ce.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(le.createHTMLDocument?((r=(t=C.implementation.createHTMLDocument("")).createElement("base")).href=C.location.href,t.head.appendChild(r)):t=C),o=!n&&[],(i=w.exec(e))?[t.createElement(i[1])]:(i=Ae([e],t,o),o&&o.length&&ce(o).remove(),ce.merge([],i.childNodes)));var r,i,o},ce.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(ce.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},ce.expr.pseudos.animated=function(t){return ce.grep(ce.timers,function(e){return t===e.elem}).length},ce.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=ce.css(e,"position"),c=ce(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=ce.css(e,"top"),u=ce.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),v(t)&&(t=t.call(e,n,ce.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},ce.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){ce.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===ce.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===ce.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=ce(e).offset()).top+=ce.css(e,"borderTopWidth",!0),i.left+=ce.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-ce.css(r,"marginTop",!0),left:t.left-i.left-ce.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===ce.css(e,"position"))e=e.offsetParent;return e||J})}}),ce.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;ce.fn[t]=function(e){return M(this,function(e,t,n){var r;if(y(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),ce.each(["top","left"],function(e,n){ce.cssHooks[n]=Ye(le.pixelPosition,function(e,t){if(t)return t=Ge(e,n),_e.test(t)?ce(e).position()[n]+"px":t})}),ce.each({Height:"height",Width:"width"},function(a,s){ce.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){ce.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return M(this,function(e,t,n){var r;return y(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?ce.css(e,t,i):ce.style(e,t,n,i)},s,n?e:void 0,n)}})}),ce.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){ce.fn[t]=function(e){return this.on(t,e)}}),ce.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.on("mouseenter",e).on("mouseleave",t||e)}}),ce.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){ce.fn[n]=function(e,t){return 0\x20\t\r\n\f]*)[^>]*)\/>/gi;s.UNSAFE_restoreLegacyHtmlPrefilter=function(){s.migrateEnablePatches("self-closed-tags")},i(s,"htmlPrefilter",function(e){var t,r;return(r=(t=e).replace(F,"<$1>"))!==t&&T(t)!==T(r)&&u("self-closed-tags","HTML tags must be properly nested and closed: "+t),e.replace(F,"<$1>")},"self-closed-tags"),s.migrateDisablePatches("self-closed-tags");var D,W,_,I=s.fn.offset;return i(s.fn,"offset",function(){var e=this[0];return!e||e.nodeType&&e.getBoundingClientRect?I.apply(this,arguments):(u("offset-valid-elem","jQuery.fn.offset() requires a valid DOM element"),arguments.length?this:void 0)},"offset-valid-elem"),s.ajax&&(D=s.param,i(s,"param",function(e,t){var r=s.ajaxSettings&&s.ajaxSettings.traditional;return void 0===t&&r&&(u("param-ajax-traditional","jQuery.param() no longer uses jQuery.ajaxSettings.traditional"),t=r),D.call(this,e,t)},"param-ajax-traditional")),c(s.fn,"andSelf",s.fn.addBack,"andSelf","jQuery.fn.andSelf() is deprecated and removed, use jQuery.fn.addBack()"),s.Deferred&&(W=s.Deferred,_=[["resolve","done",s.Callbacks("once memory"),s.Callbacks("once memory"),"resolved"],["reject","fail",s.Callbacks("once memory"),s.Callbacks("once memory"),"rejected"],["notify","progress",s.Callbacks("memory"),s.Callbacks("memory")]],i(s,"Deferred",function(e){var a=W(),i=a.promise();function t(){var o=arguments;return s.Deferred(function(n){s.each(_,function(e,t){var r="function"==typeof o[e]&&o[e];a[t[1]](function(){var e=r&&r.apply(this,arguments);e&&"function"==typeof e.promise?e.promise().done(n.resolve).fail(n.reject).progress(n.notify):n[t[0]+"With"](this===i?n.promise():this,r?[e]:arguments)})}),o=null}).promise()}return c(a,"pipe",t,"deferred-pipe","deferred.pipe() is deprecated"),c(i,"pipe",t,"deferred-pipe","deferred.pipe() is deprecated"),e&&e.call(a,a),a},"deferred-pipe"),s.Deferred.exceptionHook=W.exceptionHook),s}); diff --git a/src/deepfacility/viz/leaflet_template/js/labelgun.min.js b/src/deepfacility/viz/leaflet_template/js/labelgun.min.js new file mode 100644 index 0000000..41eb5d4 --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/js/labelgun.min.js @@ -0,0 +1 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t(require("rbush")):"function"==typeof define&&define.amd?define(["rbush"],t):"object"==typeof exports?exports.labelgun=t(require("rbush")):e.labelgun=t(e.rbush)}(this,function(e){return function(e){function t(l){if(a[l])return a[l].exports;var n=a[l]={i:l,l:!1,exports:{}};return e[l].call(n.exports,n,n.exports,t),n.l=!0,n.exports}var a={};return t.m=e,t.c=a,t.d=function(e,a,l){t.o(e,a)||Object.defineProperty(e,a,{configurable:!1,enumerable:!0,get:l})},t.n=function(e){var a=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(a,"a",a),a},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="",t(t.s=0)}([function(e,t,a){"use strict";function l(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(t,"__esModule",{value:!0});var n=function(){function e(e,t){for(var a=0;at.weight||a.isDragged)return!1;return!0}},{key:"_compare",value:function(e,t){return e.weight>t.weight?-1:e.weight0&&(this.hasChanged.forEach(function(t){e._handleLabelIngestion(t)}),this.hasChanged=[])}},{key:"_handleLabelIngestion",value:function(e){var t=this.allLabels[e];this.ingestLabel({bottomLeft:[t.minX,t.minY],topRight:[t.maxX,t.maxY]},t.id,t.weight,t.labelObject,t.name,t.isDragged)}},{key:"update",value:function(e){this.allChanged=!e,this._setupLabels(),this._compareLabels(),this._callLabelCallbacks()}},{key:"removeLabel",value:function(e){var t=this.allLabels[e];this.tree.remove(t),delete this.allLabels[e]}},{key:"_addToTree",value:function(e){this.allLabels[e.id]=e,this.tree.insert(e)}},{key:"ingestLabel",value:function(e,t,a,l,n,i){if(void 0!==a&&null!==a||(a=0),!e||!e.bottomLeft||!e.topRight)throw Error("Bounding box must be defined with bottomLeft and topRight properties");if("string"!=typeof t&&"number"!=typeof t)throw Error("Label IDs must be a string or a number");var s=this.allLabels[t];s&&this.removeLabel(s.id);var r={minX:e.bottomLeft[0],minY:e.bottomLeft[1],maxX:e.topRight[0],maxY:e.topRight[1],state:"hide",id:t,weight:a,labelObject:l,name:n,isDragged:i};this._addToTree(r)}},{key:"labelHasChanged",value:function(e){-1===this.hasChanged.indexOf(e)&&this.hasChanged.push(e)}}]),e}();t.default=r},function(t,a){t.exports=e}])}); \ No newline at end of file diff --git a/src/deepfacility/viz/leaflet_template/js/labels.js b/src/deepfacility/viz/leaflet_template/js/labels.js new file mode 100644 index 0000000..a9348f1 --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/js/labels.js @@ -0,0 +1,62 @@ +var hideLabel = function(label) { + label.labelObject.style.opacity = 0; + label.labelObject.style.transition = 'opacity 0s'; +}; +var showLabel = function(label) { + label.labelObject.style.opacity = 1; + label.labelObject.style.transition = 'opacity 1s'; +}; +labelEngine = new labelgun.default(hideLabel, showLabel); + +var id = 0; +var labels = []; +var totalMarkers = 0; + +function resetLabels(markers) { + labelEngine.reset(); + var i = 0; + for (var j = 0; j < markers.length; j++) { + markers[j].eachLayer(function(label){ + addLabel(label, ++i); + }); + } + labelEngine.update(); +} + +function addLabel(layer, id) { + + // This is ugly but there is no getContainer method on the tooltip :( + if (layer.getTooltip()) { + var label = layer.getTooltip()._source._tooltip._container; + if (label) { + + // We need the bounding rectangle of the label itself + var rect = label.getBoundingClientRect(); + + // We convert the container coordinates (screen space) to Lat/lng + var bottomLeft = map.containerPointToLatLng([rect.left, rect.bottom]); + var topRight = map.containerPointToLatLng([rect.right, rect.top]); + var boundingBox = { + bottomLeft : [bottomLeft.lng, bottomLeft.lat], + topRight : [topRight.lng, topRight.lat] + }; + + // Ingest the label into labelgun itself + labelEngine.ingestLabel( + boundingBox, + id, + parseInt(Math.random() * (5 - 1) + 1), // Weight + label, + "Test " + id, + false + ); + + // If the label hasn't been added to the map already + // add it and set the added flag to true + if (!layer.added) { + layer.addTo(map); + layer.added = true; + } + } + } +} \ No newline at end of file diff --git a/src/deepfacility/viz/leaflet_template/js/leaflet-hash.js b/src/deepfacility/viz/leaflet_template/js/leaflet-hash.js new file mode 100644 index 0000000..70a1007 --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/js/leaflet-hash.js @@ -0,0 +1,162 @@ +(function(window) { + var HAS_HASHCHANGE = (function() { + var doc_mode = window.documentMode; + return ('onhashchange' in window) && + (doc_mode === undefined || doc_mode > 7); + })(); + + L.Hash = function(map) { + this.onHashChange = L.Util.bind(this.onHashChange, this); + + if (map) { + this.init(map); + } + }; + + L.Hash.parseHash = function(hash) { + if(hash.indexOf('#') === 0) { + hash = hash.substr(1); + } + var args = hash.split("/"); + if (args.length == 3) { + var zoom = parseInt(args[0], 10), + lat = parseFloat(args[1]), + lon = parseFloat(args[2]); + if (isNaN(zoom) || isNaN(lat) || isNaN(lon)) { + return false; + } else { + return { + center: new L.LatLng(lat, lon), + zoom: zoom + }; + } + } else { + return false; + } + }; + + L.Hash.formatHash = function(map) { + var center = map.getCenter(), + zoom = map.getZoom(), + precision = Math.max(0, Math.ceil(Math.log(zoom) / Math.LN2)); + + return "#" + [zoom, + center.lat.toFixed(precision), + center.lng.toFixed(precision) + ].join("/"); + }, + + L.Hash.prototype = { + map: null, + lastHash: null, + + parseHash: L.Hash.parseHash, + formatHash: L.Hash.formatHash, + + init: function(map) { + this.map = map; + + // reset the hash + this.lastHash = null; + this.onHashChange(); + + if (!this.isListening) { + this.startListening(); + } + }, + + removeFrom: function(map) { + if (this.changeTimeout) { + clearTimeout(this.changeTimeout); + } + + if (this.isListening) { + this.stopListening(); + } + + this.map = null; + }, + + onMapMove: function() { + // bail if we're moving the map (updating from a hash), + // or if the map is not yet loaded + + if (this.movingMap || !this.map._loaded) { + return false; + } + + var hash = this.formatHash(this.map); + if (this.lastHash != hash) { + location.replace(hash); + this.lastHash = hash; + } + }, + + movingMap: false, + update: function() { + var hash = location.hash; + if (hash === this.lastHash) { + return; + } + var parsed = this.parseHash(hash); + if (parsed) { + this.movingMap = true; + + this.map.setView(parsed.center, parsed.zoom); + + this.movingMap = false; + } else { + this.onMapMove(this.map); + } + }, + + // defer hash change updates every 100ms + changeDefer: 100, + changeTimeout: null, + onHashChange: function() { + // throttle calls to update() so that they only happen every + // `changeDefer` ms + if (!this.changeTimeout) { + var that = this; + this.changeTimeout = setTimeout(function() { + that.update(); + that.changeTimeout = null; + }, this.changeDefer); + } + }, + + isListening: false, + hashChangeInterval: null, + startListening: function() { + this.map.on("moveend", this.onMapMove, this); + + if (HAS_HASHCHANGE) { + L.DomEvent.addListener(window, "hashchange", this.onHashChange); + } else { + clearInterval(this.hashChangeInterval); + this.hashChangeInterval = setInterval(this.onHashChange, 50); + } + this.isListening = true; + }, + + stopListening: function() { + this.map.off("moveend", this.onMapMove, this); + + if (HAS_HASHCHANGE) { + L.DomEvent.removeListener(window, "hashchange", this.onHashChange); + } else { + clearInterval(this.hashChangeInterval); + } + this.isListening = false; + } + }; + L.hash = function(map) { + return new L.Hash(map); + }; + L.Map.prototype.addHash = function() { + this._hash = L.hash(this); + }; + L.Map.prototype.removeHash = function() { + this._hash.removeFrom(); + }; +})(window); diff --git a/src/deepfacility/viz/leaflet_template/js/leaflet-heat.js b/src/deepfacility/viz/leaflet_template/js/leaflet-heat.js new file mode 100644 index 0000000..aa8031a --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/js/leaflet-heat.js @@ -0,0 +1,11 @@ +/* + (c) 2014, Vladimir Agafonkin + simpleheat, a tiny JavaScript library for drawing heatmaps with Canvas + https://github.com/mourner/simpleheat +*/ +!function(){"use strict";function t(i){return this instanceof t?(this._canvas=i="string"==typeof i?document.getElementById(i):i,this._ctx=i.getContext("2d"),this._width=i.width,this._height=i.height,this._max=1,void this.clear()):new t(i)}t.prototype={defaultRadius:25,defaultGradient:{.4:"blue",.6:"cyan",.7:"lime",.8:"yellow",1:"red"},data:function(t,i){return this._data=t,this},max:function(t){return this._max=t,this},add:function(t){return this._data.push(t),this},clear:function(){return this._data=[],this},radius:function(t,i){i=i||15;var a=this._circle=document.createElement("canvas"),s=a.getContext("2d"),e=this._r=t+i;return a.width=a.height=2*e,s.shadowOffsetX=s.shadowOffsetY=200,s.shadowBlur=i,s.shadowColor="black",s.beginPath(),s.arc(e-200,e-200,t,0,2*Math.PI,!0),s.closePath(),s.fill(),this},gradient:function(t){var i=document.createElement("canvas"),a=i.getContext("2d"),s=a.createLinearGradient(0,0,0,256);i.width=1,i.height=256;for(var e in t)s.addColorStop(e,t[e]);return a.fillStyle=s,a.fillRect(0,0,1,256),this._grad=a.getImageData(0,0,1,256).data,this},draw:function(t){this._circle||this.radius(this.defaultRadius),this._grad||this.gradient(this.defaultGradient);var i=this._ctx;i.clearRect(0,0,this._width,this._height);for(var a,s=0,e=this._data.length;e>s;s++)a=this._data[s],i.globalAlpha=Math.max(a[2]/this._max,t||.05),i.drawImage(this._circle,a[0]-this._r,a[1]-this._r);var n=i.getImageData(0,0,this._width,this._height);return this._colorize(n.data,this._grad),i.putImageData(n,0,0),this},_colorize:function(t,i){for(var a,s=3,e=t.length;e>s;s+=4)a=4*t[s],a&&(t[s-3]=i[a],t[s-2]=i[a+1],t[s-1]=i[a+2])}},window.simpleheat=t}(),/* + (c) 2014, Vladimir Agafonkin + Leaflet.heat, a tiny and fast heatmap plugin for Leaflet. + https://github.com/Leaflet/Leaflet.heat +*/ +L.HeatLayer=(L.Layer?L.Layer:L.Class).extend({initialize:function(t,i){this._latlngs=t,L.setOptions(this,i)},setLatLngs:function(t){return this._latlngs=t,this.redraw()},addLatLng:function(t){return this._latlngs.push(t),this.redraw()},setOptions:function(t){return L.setOptions(this,t),this._heat&&this._updateOptions(),this.redraw()},redraw:function(){return!this._heat||this._frame||this._map._animating||(this._frame=L.Util.requestAnimFrame(this._redraw,this)),this},onAdd:function(t){this._map=t,this._canvas||this._initCanvas(),t._panes.overlayPane.appendChild(this._canvas),t.on("moveend",this._reset,this),t.options.zoomAnimation&&L.Browser.any3d&&t.on("zoomanim",this._animateZoom,this),this._reset()},onRemove:function(t){t.getPanes().overlayPane.removeChild(this._canvas),t.off("moveend",this._reset,this),t.options.zoomAnimation&&t.off("zoomanim",this._animateZoom,this)},addTo:function(t){return t.addLayer(this),this},_initCanvas:function(){var t=this._canvas=L.DomUtil.create("canvas","leaflet-heatmap-layer leaflet-layer"),i=L.DomUtil.testProp(["transformOrigin","WebkitTransformOrigin","msTransformOrigin"]);t.style[i]="50% 50%";var a=this._map.getSize();t.width=a.x,t.height=a.y;var s=this._map.options.zoomAnimation&&L.Browser.any3d;L.DomUtil.addClass(t,"leaflet-zoom-"+(s?"animated":"hide")),this._heat=simpleheat(t),this._updateOptions()},_updateOptions:function(){this._heat.radius(this.options.radius||this._heat.defaultRadius,this.options.blur),this.options.gradient&&this._heat.gradient(this.options.gradient),this.options.max&&this._heat.max(this.options.max)},_reset:function(){var t=this._map.containerPointToLayerPoint([0,0]);L.DomUtil.setPosition(this._canvas,t);var i=this._map.getSize();this._heat._width!==i.x&&(this._canvas.width=this._heat._width=i.x),this._heat._height!==i.y&&(this._canvas.height=this._heat._height=i.y),this._redraw()},_redraw:function(){var t,i,a,s,e,n,h,o,r,d=[],_=this._heat._r,l=this._map.getSize(),m=new L.Bounds(L.point([-_,-_]),l.add([_,_])),c=void 0===this.options.max?1:this.options.max,u=void 0===this.options.maxZoom?this._map.getMaxZoom():this.options.maxZoom,f=1/Math.pow(2,Math.max(0,Math.min(u-this._map.getZoom(),12))),g=_/2,p=[],v=this._map._getMapPanePos(),w=v.x%g,y=v.y%g;for(t=0,i=this._latlngs.length;i>t;t++)if(a=this._map.latLngToContainerPoint(this._latlngs[t]),m.contains(a)){e=Math.floor((a.x-w)/g)+2,n=Math.floor((a.y-y)/g)+2;var x=void 0!==this._latlngs[t].alt?this._latlngs[t].alt:void 0!==this._latlngs[t][2]?+this._latlngs[t][2]:1;r=x*f,p[n]=p[n]||[],s=p[n][e],s?(s[0]=(s[0]*s[2]+a.x*r)/(s[2]+r),s[1]=(s[1]*s[2]+a.y*r)/(s[2]+r),s[2]+=r):p[n][e]=[a.x,a.y,r]}for(t=0,i=p.length;i>t;t++)if(p[t])for(h=0,o=p[t].length;o>h;h++)s=p[t][h],s&&d.push([Math.round(s[0]),Math.round(s[1]),Math.min(s[2],c)]);this._heat.data(d).draw(this.options.minOpacity),this._frame=null},_animateZoom:function(t){var i=this._map.getZoomScale(t.zoom),a=this._map._getCenterOffset(t.center)._multiplyBy(-i).subtract(this._map._getMapPanePos());L.DomUtil.setTransform?L.DomUtil.setTransform(this._canvas,a,i):this._canvas.style[L.DomUtil.TRANSFORM]=L.DomUtil.getTranslateString(a)+" scale("+i+")"}}),L.heatLayer=function(t,i){return new L.HeatLayer(t,i)}; \ No newline at end of file diff --git a/src/deepfacility/viz/leaflet_template/js/leaflet-svg-shape-markers.min.js b/src/deepfacility/viz/leaflet_template/js/leaflet-svg-shape-markers.min.js new file mode 100644 index 0000000..7a5e693 --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/js/leaflet-svg-shape-markers.min.js @@ -0,0 +1 @@ +L.SVG.include({_updateShape:function(t){var i=t._point,s=t._radius,e=t.options.shape;if("diamond"===e){var n="M"+(i.x-s)+" "+i.y+" L "+i.x+" "+(i.y-s)+" L"+(i.x+s)+" "+i.y+" L"+i.x+" "+(i.y+s)+" L"+(i.x-s)+" "+i.y;this._setPath(t,n)}if("square"===e){var n="M"+(i.x-s)+" "+(i.y-s)+" L "+(i.x+s)+" "+(i.y-s)+" L"+(i.x+s)+" "+(i.y+s)+" L"+(i.x-s)+" "+(i.y+s)+" L"+(i.x-s)+" "+(i.y-s);this._setPath(t,n)}if("triangle"===e||"triangle-up"===e){var n="M"+(i.x-s)+" "+(i.y+s)+" L"+i.x+" "+(i.y-s)+" L"+(i.x+s)+" "+(i.y+s)+" Z";this._setPath(t,n)}if("triangle-down"===e){var n="M"+(i.x-s)+" "+(i.y-s)+" L"+i.x+" "+(i.y+s)+" L"+(i.x+s)+" "+(i.y-s)+" Z";this._setPath(t,n)}if("circle"===e&&this._updateCircle(t),"x"===e){s/=2;var n="M"+(i.x+s)+","+(i.y+s)+"L"+(i.x-s)+","+(i.y-s)+"M"+(i.x-s)+","+(i.y+s)+"L"+(i.x+s)+","+(i.y-s);this._setPath(t,n)}}}),L.ShapeMarker=L.Path.extend({options:{fill:!0,shape:"triangle",radius:10},initialize:function(t,i){L.setOptions(this,i),this._latlng=L.latLng(t),this._radius=this.options.radius},setLatLng:function(t){return this._latlng=L.latLng(t),this.redraw(),this.fire("move",{latlng:this._latlng})},getLatLng:function(){return this._latlng},setRadius:function(t){return this.options.radius=this._radius=t,this.redraw()},getRadius:function(){return this._radius},setStyle:function(t){var i=t&&t.radius||this._radius;return L.Path.prototype.setStyle.call(this,t),this.setRadius(i),this},_project:function(){this._point=this._map.latLngToLayerPoint(this._latlng),this._updateBounds()},_updateBounds:function(){var t=this._radius,i=this._radiusY||t,s=this._clickTolerance(),e=[t+s,i+s];this._pxBounds=new L.Bounds(this._point.subtract(e),this._point.add(e))},_update:function(){this._map&&this._updatePath()},_updatePath:function(){this._renderer._updateShape(this)},_empty:function(){return this._size&&!this._renderer._bounds.intersects(this._pxBounds)},toGeoJSON:function(){return L.GeoJSON.getFeature(this,{type:"Point",coordinates:L.GeoJSON.latLngToCoords(this.getLatLng())})}}),L.shapeMarker=function(t,i){return new L.ShapeMarker(t,i)}; \ No newline at end of file diff --git a/src/deepfacility/viz/leaflet_template/js/leaflet-tilelayer-wmts.js b/src/deepfacility/viz/leaflet_template/js/leaflet-tilelayer-wmts.js new file mode 100644 index 0000000..64433fd --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/js/leaflet-tilelayer-wmts.js @@ -0,0 +1 @@ +L.TileLayer.WMTS=L.TileLayer.extend({defaultWmtsParams:{service:"WMTS",request:"GetTile",version:"1.0.0",layer:"",style:"",tilematrixSet:"",format:"image/jpeg"},initialize:function(e,t){this._url=e;var n=L.extend({},this.defaultWmtsParams),r=t.tileSize||this.options.tileSize;if(t.detectRetina&&L.Browser.retina){n.width=n.height=r*2}else{n.width=n.height=r}for(var i in t){if(!this.options.hasOwnProperty(i)&&i!="matrixIds"){n[i]=t[i]}}this.wmtsParams=n;this.matrixIds=t.matrixIds||this.getDefaultMatrix();L.setOptions(this,t)},onAdd:function(e){L.TileLayer.prototype.onAdd.call(this,e)},getTileUrl:function(e,t){var n=this._map;crs=n.options.crs;tileSize=this.options.tileSize;nwPoint=e.multiplyBy(tileSize);nwPoint.x+=1;nwPoint.y-=1;sePoint=nwPoint.add(new L.Point(tileSize,tileSize));nw=crs.project(n.unproject(nwPoint,t));se=crs.project(n.unproject(sePoint,t));tilewidth=se.x-nw.x;t=n.getZoom();ident=this.matrixIds[t].identifier;X0=this.matrixIds[t].topLeftCorner.lng;Y0=this.matrixIds[t].topLeftCorner.lat;tilecol=Math.floor((nw.x-X0)/tilewidth);tilerow=-Math.floor((nw.y-Y0)/tilewidth);url=L.Util.template(this._url,{s:this._getSubdomain(e)});return url+L.Util.getParamString(this.wmtsParams,url)+"&tilematrix="+ident+"&tilerow="+tilerow+"&tilecol="+tilecol},setParams:function(e,t){L.extend(this.wmtsParams,e);if(!t){this.redraw()}return this},getDefaultMatrix:function(){var e=new Array(22);for(var t=0;t<22;t++){e[t]={identifier:""+t,topLeftCorner:new L.LatLng(20037508.3428,-20037508.3428)}}return e}});L.tileLayer.wmts=function(e,t){return new L.TileLayer.WMTS(e,t)} diff --git a/src/deepfacility/viz/leaflet_template/js/leaflet.geocsv.js b/src/deepfacility/viz/leaflet_template/js/leaflet.geocsv.js new file mode 100644 index 0000000..97de3c8 --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/js/leaflet.geocsv.js @@ -0,0 +1 @@ +L.GeoCSV=L.GeoJSON.extend({options:{titles:["lat","lng","popup"],latitudeTitle:"lat",longitudeTitle:"lng",fieldSeparator:",",lineSeparator:"\n",decimalSeparator:".",lineCommentSeparator:"#",firstLineTitles:false,activeWKT:false,WKTTitle:"wkt",debug:false},_propertiesNames:[],_re:{spaces:/\s+/g,notFloats:/[^0-9., -]/g,notWord:/[^\w]/g},_debug:function(msg,data){if(this.options.debug){if(msg)console.log(msg);if(data)console.log(data)}return data},_checkOptions:function(opts){if(this.options.activeWKT&&!opts.WKTTitle&&!this.options.firstLineTitles)this._debug("WARN: WKTTitle not defined",false);if(this.options.activeWKT&&this.options.decimalSeparator!=".")this._debug("WARN: In WKT mode decimalSeparator need to be a dot",false);if(this.options.firstLineTitles&&opts.titles)this._debug("WARN: With firstLineTitles titles array not apply",false);if(this.options.fieldSeparator.length!=1)this._debug("WARN: fieldSeparator need to be a single character",false);if(this.options.lineSeparator.length!=1)this._debug("WARN: lineSeparator need to be a single character",false)},initialize:function(csv,options){this._propertiesNames=[];L.Util.setOptions(this,options);this._checkOptions(options);L.GeoJSON.prototype.initialize.call(this,csv,options)},addData:function(data){if(typeof data==="string"){data=this._csv2array(data);if(this.options.firstLineTitles){this.options.titles=data[0];data.splice(0,1)}for(var i=0;i=0)prop=this._propertiesNames[pos];return prop},getPropertyTitle:function(prop){var pos=this._propertiesNames.indexOf(prop),title="";if(pos>=0)title=this.options.titles[pos];return title},_csv2array:function(str){var arr=[];var quote=false;var comment=false;for(var row=col=c=0;c90||lat<-90)return feature;if(isNaN(lng)||lng>180||lng<-180)return feature;if(groups.length==1){coordinates[i]=[lng,lat]}else if(geotype=="MULTIPOINT"){coordinates[g]=[lng,lat]}else{coordinates[g][i]=[lng,lat]}}}switch(geotype){case"POINT":feature["geometry"]["type"]="Point";feature["geometry"]["coordinates"]=coordinates[0];break;case"LINESTRING":feature["geometry"]["type"]="LineString";feature["geometry"]["coordinates"]=coordinates;break;case"POLYGON":feature["geometry"]["type"]="Polygon";feature["geometry"]["coordinates"]=[coordinates];break;case"MULTIPOINT":feature["geometry"]["type"]="MultiPoint";feature["geometry"]["coordinates"]=coordinates;break;case"MULTILINESTRING":feature["geometry"]["type"]="MultiLineString";feature["geometry"]["coordinates"]=coordinates;break;case"MULTIPOLYGON":feature["geometry"]["type"]="MultiPolygon";feature["geometry"]["coordinates"]=[coordinates];break;default:this._debug("ERR: "+geotype+" not supported",false)}return feature},_featureBlank:function(){return{type:"Feature",geometry:{},properties:{}}},_csv2json:function(csv){var titulos=this.options.titles,json={type:"FeatureCollection",features:[]},incr=this.options.firstLineTitles?2:1;for(var num_linea=0;num_linea-180&&lat<90&&lat>-90){feature["geometry"]["type"]="Point";feature["geometry"]["coordinates"]=[lng,lat]}else{this._debug("ERR: Invalid LatLon pair at line "+(num_linea+incr),false)}}if(feature.geometry&&feature.geometry.type){for(var i=0;i=this.min.x&&e.x<=this.max.x&&i.y>=this.min.y&&e.y<=this.max.y},intersects:function(t){t=R(t);var i=this.min,e=this.max,n=t.min,o=t.max,s=o.x>=i.x&&n.x<=e.x,r=o.y>=i.y&&n.y<=e.y;return s&&r},overlaps:function(t){t=R(t);var i=this.min,e=this.max,n=t.min,o=t.max,s=o.x>i.x&&n.xi.y&&n.y=n.lat&&e.lat<=o.lat&&i.lng>=n.lng&&e.lng<=o.lng},intersects:function(t){t=D(t);var i=this._southWest,e=this._northEast,n=t.getSouthWest(),o=t.getNorthEast(),s=o.lat>=i.lat&&n.lat<=e.lat,r=o.lng>=i.lng&&n.lng<=e.lng;return s&&r},overlaps:function(t){t=D(t);var i=this._southWest,e=this._northEast,n=t.getSouthWest(),o=t.getNorthEast(),s=o.lat>i.lat&&n.lati.lng&&n.lng';var i=t.firstChild;return i.style.behavior="url(#default#VML)",i&&"object"==typeof i.adj}catch(t){return!1}}();function Bt(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var At=(Object.freeze||Object)({ie:it,ielt9:et,edge:nt,webkit:ot,android:st,android23:rt,androidStock:ht,opera:ut,chrome:lt,gecko:ct,safari:_t,phantom:dt,opera12:pt,win:mt,ie3d:ft,webkit3d:gt,gecko3d:vt,any3d:yt,mobile:xt,mobileWebkit:wt,mobileWebkit3d:Pt,msPointer:Lt,pointer:bt,touch:Tt,mobileOpera:zt,mobileGecko:Mt,retina:Ct,passiveEvents:Et,canvas:St,svg:Zt,vml:kt}),It=Lt?"MSPointerDown":"pointerdown",Ot=Lt?"MSPointerMove":"pointermove",Rt=Lt?"MSPointerUp":"pointerup",Nt=Lt?"MSPointerCancel":"pointercancel",Dt=["INPUT","SELECT","OPTION"],jt={},Wt=!1,Ht=0;function Ft(t,i,e,n){return"touchstart"===i?function(t,i,e){var n=a(function(t){if("mouse"!==t.pointerType&&t.MSPOINTER_TYPE_MOUSE&&t.pointerType!==t.MSPOINTER_TYPE_MOUSE){if(!(Dt.indexOf(t.target.tagName)<0))return;ji(t)}Gt(t,i)});t["_leaflet_touchstart"+e]=n,t.addEventListener(It,n,!1),Wt||(document.documentElement.addEventListener(It,Ut,!0),document.documentElement.addEventListener(Ot,Vt,!0),document.documentElement.addEventListener(Rt,qt,!0),document.documentElement.addEventListener(Nt,qt,!0),Wt=!0)}(t,e,n):"touchmove"===i?function(t,i,e){function n(t){(t.pointerType!==t.MSPOINTER_TYPE_MOUSE&&"mouse"!==t.pointerType||0!==t.buttons)&&Gt(t,i)}t["_leaflet_touchmove"+e]=n,t.addEventListener(Ot,n,!1)}(t,e,n):"touchend"===i&&function(t,i,e){function n(t){Gt(t,i)}t["_leaflet_touchend"+e]=n,t.addEventListener(Rt,n,!1),t.addEventListener(Nt,n,!1)}(t,e,n),this}function Ut(t){jt[t.pointerId]=t,Ht++}function Vt(t){jt[t.pointerId]&&(jt[t.pointerId]=t)}function qt(t){delete jt[t.pointerId],Ht--}function Gt(t,i){for(var e in t.touches=[],jt)t.touches.push(jt[e]);t.changedTouches=[t],i(t)}var Kt=Lt?"MSPointerDown":bt?"pointerdown":"touchstart",Yt=Lt?"MSPointerUp":bt?"pointerup":"touchend",Xt="_leaflet_";function Jt(t,o,i){var s,r,a=!1;function e(t){var i;if(bt){if(!nt||"mouse"===t.pointerType)return;i=Ht}else i=t.touches.length;if(!(1this.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,i){this._enforcingBounds=!0;var e=this.getCenter(),n=this._limitCenter(e,this._zoom,D(t));return e.equals(n)||this.panTo(n,i),this._enforcingBounds=!1,this},panInside:function(t,i){var e=I((i=i||{}).paddingTopLeft||i.padding||[0,0]),n=I(i.paddingBottomRight||i.padding||[0,0]),o=this.getCenter(),s=this.project(o),r=this.project(t),a=this.getPixelBounds(),h=a.getSize().divideBy(2),u=R([a.min.add(e),a.max.subtract(n)]);if(!u.contains(r)){this._enforcingBounds=!0;var l=s.subtract(r),c=I(r.x+l.x,r.y+l.y);(r.xu.max.x)&&(c.x=s.x-l.x,0u.max.y)&&(c.y=s.y-l.y,0=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,i){for(var e,n=[],o="mouseout"===i||"mouseover"===i,s=t.target||t.srcElement,r=!1;s;){if((e=this._targets[u(s)])&&("click"===i||"preclick"===i)&&!t._simulated&&this._draggableMoved(e)){r=!0;break}if(e&&e.listens(i,!0)){if(o&&!Yi(s,t))break;if(n.push(e),o)break}if(s===this._container)break;s=s.parentNode}return n.length||r||o||!Yi(s,t)||(n=[this]),n},_handleDOMEvent:function(t){if(this._loaded&&!Ki(t)){var i=t.type;"mousedown"!==i&&"keypress"!==i&&"keyup"!==i&&"keydown"!==i||Mi(t.target||t.srcElement),this._fireDOMEvent(t,i)}},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,i,e){if("click"===t.type){var n=h({},t);n.type="preclick",this._fireDOMEvent(n,n.type,e)}if(!t._stopped&&(e=(e||[]).concat(this._findEventTargets(t,i))).length){var o=e[0];"contextmenu"===i&&o.listens(i,!0)&&ji(t);var s={originalEvent:t};if("keypress"!==t.type&&"keydown"!==t.type&&"keyup"!==t.type){var r=o.getLatLng&&(!o._radius||o._radius<=10);s.containerPoint=r?this.latLngToContainerPoint(o.getLatLng()):this.mouseEventToContainerPoint(t),s.layerPoint=this.containerPointToLayerPoint(s.containerPoint),s.latlng=r?o.getLatLng():this.layerPointToLatLng(s.layerPoint)}for(var a=0;athis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(i),o=this._getCenterOffset(t)._divideBy(1-1/n);return!(!0!==e.animate&&!this.getSize().contains(o))&&(M(function(){this._moveStart(!0,!1)._animateZoom(t,i,!0)},this),!0)},_animateZoom:function(t,i,e,n){this._mapPane&&(e&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=i,mi(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:i,noUpdate:n}),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&fi(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom),M(function(){this._moveEnd(!0)},this))}});function Qi(t){return new te(t)}var te=S.extend({options:{position:"topright"},initialize:function(t){p(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var i=this._map;return i&&i.removeControl(this),this.options.position=t,i&&i.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var i=this._container=this.onAdd(t),e=this.getPosition(),n=t._controlCorners[e];return mi(i,"leaflet-control"),-1!==e.indexOf("bottom")?n.insertBefore(i,n.firstChild):n.appendChild(i),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(li(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",n=document.createElement("div");return n.innerHTML=e,n.firstChild},_addItem:function(t){var i,e=document.createElement("label"),n=this._map.hasLayer(t.layer);t.overlay?((i=document.createElement("input")).type="checkbox",i.className="leaflet-control-layers-selector",i.defaultChecked=n):i=this._createRadioElement("leaflet-base-layers_"+u(this),n),this._layerControlInputs.push(i),i.layerId=u(t.layer),ki(i,"click",this._onInputClick,this);var o=document.createElement("span");o.innerHTML=" "+t.name;var s=document.createElement("div");return e.appendChild(s),s.appendChild(i),s.appendChild(o),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(e),this._checkDisabledLayers(),e},_onInputClick:function(){var t,i,e=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=e.length-1;0<=s;s--)t=e[s],i=this._getLayer(t.layerId).layer,t.checked?n.push(i):t.checked||o.push(i);for(s=0;si.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expand:function(){return this.expand()},_collapse:function(){return this.collapse()}}),ee=te.extend({options:{position:"topleft",zoomInText:"+",zoomInTitle:"Zoom in",zoomOutText:"−",zoomOutTitle:"Zoom out"},onAdd:function(t){var i="leaflet-control-zoom",e=ui("div",i+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,i+"-in",e,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,i+"-out",e,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),e},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,i,e,n,o){var s=ui("a",e,n);return s.innerHTML=t,s.href="#",s.title=i,s.setAttribute("role","button"),s.setAttribute("aria-label",i),Di(s),ki(s,"click",Wi),ki(s,"click",o,this),ki(s,"click",this._refocusOnMap,this),s},_updateDisabled:function(){var t=this._map,i="leaflet-disabled";fi(this._zoomInButton,i),fi(this._zoomOutButton,i),!this._disabled&&t._zoom!==t.getMinZoom()||mi(this._zoomOutButton,i),!this._disabled&&t._zoom!==t.getMaxZoom()||mi(this._zoomInButton,i)}});$i.mergeOptions({zoomControl:!0}),$i.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new ee,this.addControl(this.zoomControl))});var ne=te.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var i="leaflet-control-scale",e=ui("div",i),n=this.options;return this._addScales(n,i+"-line",e),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),e},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,i,e){t.metric&&(this._mScale=ui("div",i,e)),t.imperial&&(this._iScale=ui("div",i,e))},_update:function(){var t=this._map,i=t.getSize().y/2,e=t.distance(t.containerPointToLatLng([0,i]),t.containerPointToLatLng([this.options.maxWidth,i]));this._updateScales(e)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var i=this._getRoundNum(t),e=i<1e3?i+" m":i/1e3+" km";this._updateScale(this._mScale,e,i/t)},_updateImperial:function(t){var i,e,n,o=3.2808399*t;5280Leaflet'},initialize:function(t){p(this,t),this._attributions={}},onAdd:function(t){for(var i in(t.attributionControl=this)._container=ui("div","leaflet-control-attribution"),Di(this._container),t._layers)t._layers[i].getAttribution&&this.addAttribution(t._layers[i].getAttribution());return this._update(),this._container},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t=[];for(var i in this._attributions)this._attributions[i]&&t.push(i);var e=[];this.options.prefix&&e.push(this.options.prefix),t.length&&e.push(t.join(", ")),this._container.innerHTML=e.join(" | ")}}});$i.mergeOptions({attributionControl:!0}),$i.addInitHook(function(){this.options.attributionControl&&(new oe).addTo(this)});te.Layers=ie,te.Zoom=ee,te.Scale=ne,te.Attribution=oe,Qi.layers=function(t,i,e){return new ie(t,i,e)},Qi.zoom=function(t){return new ee(t)},Qi.scale=function(t){return new ne(t)},Qi.attribution=function(t){return new oe(t)};var se=S.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}});se.addTo=function(t,i){return t.addHandler(i,this),this};var re,ae={Events:Z},he=Tt?"touchstart mousedown":"mousedown",ue={mousedown:"mouseup",touchstart:"touchend",pointerdown:"touchend",MSPointerDown:"touchend"},le={mousedown:"mousemove",touchstart:"touchmove",pointerdown:"touchmove",MSPointerDown:"touchmove"},ce=k.extend({options:{clickTolerance:3},initialize:function(t,i,e,n){p(this,n),this._element=t,this._dragStartTarget=i||t,this._preventOutline=e},enable:function(){this._enabled||(ki(this._dragStartTarget,he,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(ce._dragging===this&&this.finishDrag(),Ai(this._dragStartTarget,he,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){if(!t._simulated&&this._enabled&&(this._moved=!1,!pi(this._element,"leaflet-zoom-anim")&&!(ce._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((ce._dragging=this)._preventOutline&&Mi(this._element),Ti(),Qt(),this._moving)))){this.fire("down");var i=t.touches?t.touches[0]:t,e=Ei(this._element);this._startPoint=new B(i.clientX,i.clientY),this._parentScale=Si(e),ki(document,le[t.type],this._onMove,this),ki(document,ue[t.type],this._onUp,this)}},_onMove:function(t){if(!t._simulated&&this._enabled)if(t.touches&&1i.max.x&&(e|=2),t.yi.max.y&&(e|=8),e}function ge(t,i,e,n){var o,s=i.x,r=i.y,a=e.x-s,h=e.y-r,u=a*a+h*h;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-e.x)*(t.y-e.y)/(n.y-e.y)+e.x&&(u=!u);return u||je.prototype._containsPoint.call(this,t,!0)}});var He=ke.extend({initialize:function(t,i){p(this,i),this._layers={},t&&this.addData(t)},addData:function(t){var i,e,n,o=v(t)?t:t.features;if(o){for(i=0,e=o.length;iu.x&&(l=s.x+n-u.x+h.x),s.x-l-a.x<0&&(l=s.x-a.x),s.y+e+h.y>u.y&&(c=s.y+e-u.y+h.y),s.y-c-a.y<0&&(c=s.y-a.y),(l||c)&&t.fire("autopanstart").panBy([l,c])}},_onCloseButtonClick:function(t){this._close(),Wi(t)},_getAnchor:function(){return I(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}});$i.mergeOptions({closePopupOnClick:!0}),$i.include({openPopup:function(t,i,e){return t instanceof sn||(t=new sn(e).setContent(t)),i&&t.setLatLng(i),this.hasLayer(t)?this:(this._popup&&this._popup.options.autoClose&&this.closePopup(),this._popup=t,this.addLayer(t))},closePopup:function(t){return t&&t!==this._popup||(t=this._popup,this._popup=null),t&&this.removeLayer(t),this}}),Se.include({bindPopup:function(t,i){return t instanceof sn?(p(t,i),(this._popup=t)._source=this):(this._popup&&!i||(this._popup=new sn(i,this)),this._popup.setContent(t)),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t,i){return this._popup&&this._map&&(i=this._popup._prepareOpen(this,t,i),this._map.openPopup(this._popup,i)),this},closePopup:function(){return this._popup&&this._popup._close(),this},togglePopup:function(t){return this._popup&&(this._popup._map?this.closePopup():this.openPopup(t)),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var i=t.layer||t.target;this._popup&&this._map&&(Wi(t),i instanceof Re?this.openPopup(t.layer||t.target,t.latlng):this._map.hasLayer(this._popup)&&this._popup._source===i?this.closePopup():this.openPopup(i,t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}});var rn=on.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,interactive:!1,opacity:.9},onAdd:function(t){on.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&this._source.fire("tooltipopen",{tooltip:this},!0)},onRemove:function(t){on.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&this._source.fire("tooltipclose",{tooltip:this},!0)},getEvents:function(){var t=on.prototype.getEvents.call(this);return Tt&&!this.options.permanent&&(t.preclick=this._close),t},_close:function(){this._map&&this._map.closeTooltip(this)},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=ui("div",t)},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var i=this._map,e=this._container,n=i.latLngToContainerPoint(i.getCenter()),o=i.layerPointToContainerPoint(t),s=this.options.direction,r=e.offsetWidth,a=e.offsetHeight,h=I(this.options.offset),u=this._getAnchor();t="top"===s?t.add(I(-r/2+h.x,-a+h.y+u.y,!0)):"bottom"===s?t.subtract(I(r/2-h.x,-h.y,!0)):"center"===s?t.subtract(I(r/2+h.x,a/2-u.y+h.y,!0)):"right"===s||"auto"===s&&o.xthis.options.maxZoom||ethis.options.maxZoom||void 0!==this.options.minZoom&&oe.max.x)||!i.wrapLat&&(t.ye.max.y))return!1}if(!this.options.bounds)return!0;var n=this._tileCoordsToBounds(t);return D(this.options.bounds).overlaps(n)},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var i=this._map,e=this.getTileSize(),n=t.scaleBy(e),o=n.add(e);return[i.unproject(n,t.z),i.unproject(o,t.z)]},_tileCoordsToBounds:function(t){var i=this._tileCoordsToNwSe(t),e=new N(i[0],i[1]);return this.options.noWrap||(e=this._map.wrapLatLngBounds(e)),e},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var i=t.split(":"),e=new B(+i[0],+i[1]);return e.z=+i[2],e},_removeTile:function(t){var i=this._tiles[t];i&&(li(i.el),delete this._tiles[t],this.fire("tileunload",{tile:i.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){mi(t,"leaflet-tile");var i=this.getTileSize();t.style.width=i.x+"px",t.style.height=i.y+"px",t.onselectstart=l,t.onmousemove=l,et&&this.options.opacity<1&&yi(t,this.options.opacity),st&&!rt&&(t.style.WebkitBackfaceVisibility="hidden")},_addTile:function(t,i){var e=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&M(a(this._tileReady,this,t,null,o)),Pi(o,e),this._tiles[n]={el:o,coords:t,current:!0},i.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,i,e){i&&this.fire("tileerror",{error:i,tile:e,coords:t});var n=this._tileCoordsToKey(t);(e=this._tiles[n])&&(e.loaded=+new Date,this._map._fadeAnimated?(yi(e.el,0),C(this._fadeFrame),this._fadeFrame=M(this._updateOpacity,this)):(e.active=!0,this._pruneTiles()),i||(mi(e.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:e.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),et||!this._map._fadeAnimated?M(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var i=new B(this._wrapX?r(t.x,this._wrapX):t.x,this._wrapY?r(t.y,this._wrapY):t.y);return i.z=t.z,i},_pxBoundsToTileRange:function(t){var i=this.getTileSize();return new O(t.min.unscaleBy(i).floor(),t.max.unscaleBy(i).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var un=hn.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1},initialize:function(t,i){this._url=t,(i=p(this,i)).detectRetina&&Ct&&0')}}catch(t){return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}}(),fn={_initContainer:function(){this._container=ui("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(_n.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var i=t._container=mn("shape");mi(i,"leaflet-vml-shape "+(this.options.className||"")),i.coordsize="1 1",t._path=mn("path"),i.appendChild(t._path),this._updateStyle(t),this._layers[u(t)]=t},_addPath:function(t){var i=t._container;this._container.appendChild(i),t.options.interactive&&t.addInteractiveTarget(i)},_removePath:function(t){var i=t._container;li(i),t.removeInteractiveTarget(i),delete this._layers[u(t)]},_updateStyle:function(t){var i=t._stroke,e=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(i||(i=t._stroke=mn("stroke")),o.appendChild(i),i.weight=n.weight+"px",i.color=n.color,i.opacity=n.opacity,n.dashArray?i.dashStyle=v(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):i.dashStyle="",i.endcap=n.lineCap.replace("butt","flat"),i.joinstyle=n.lineJoin):i&&(o.removeChild(i),t._stroke=null),n.fill?(e||(e=t._fill=mn("fill")),o.appendChild(e),e.color=n.fillColor||n.color,e.opacity=n.fillOpacity):e&&(o.removeChild(e),t._fill=null)},_updateCircle:function(t){var i=t._point.round(),e=Math.round(t._radius),n=Math.round(t._radiusY||e);this._setPath(t,t._empty()?"M0 0":"AL "+i.x+","+i.y+" "+e+","+n+" 0,23592600")},_setPath:function(t,i){t._path.v=i},_bringToFront:function(t){_i(t._container)},_bringToBack:function(t){di(t._container)}},gn=kt?mn:$,vn=_n.extend({getEvents:function(){var t=_n.prototype.getEvents.call(this);return t.zoomstart=this._onZoomStart,t},_initContainer:function(){this._container=gn("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=gn("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){li(this._container),Ai(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_onZoomStart:function(){this._update()},_update:function(){if(!this._map._animatingZoom||!this._bounds){_n.prototype._update.call(this);var t=this._bounds,i=t.getSize(),e=this._container;this._svgSize&&this._svgSize.equals(i)||(this._svgSize=i,e.setAttribute("width",i.x),e.setAttribute("height",i.y)),Pi(e,t.min),e.setAttribute("viewBox",[t.min.x,t.min.y,i.x,i.y].join(" ")),this.fire("update")}},_initPath:function(t){var i=t._path=gn("path");t.options.className&&mi(i,t.options.className),t.options.interactive&&mi(i,"leaflet-interactive"),this._updateStyle(t),this._layers[u(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){li(t._path),t.removeInteractiveTarget(t._path),delete this._layers[u(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var i=t._path,e=t.options;i&&(e.stroke?(i.setAttribute("stroke",e.color),i.setAttribute("stroke-opacity",e.opacity),i.setAttribute("stroke-width",e.weight),i.setAttribute("stroke-linecap",e.lineCap),i.setAttribute("stroke-linejoin",e.lineJoin),e.dashArray?i.setAttribute("stroke-dasharray",e.dashArray):i.removeAttribute("stroke-dasharray"),e.dashOffset?i.setAttribute("stroke-dashoffset",e.dashOffset):i.removeAttribute("stroke-dashoffset")):i.setAttribute("stroke","none"),e.fill?(i.setAttribute("fill",e.fillColor||e.color),i.setAttribute("fill-opacity",e.fillOpacity),i.setAttribute("fill-rule",e.fillRule||"evenodd")):i.setAttribute("fill","none"))},_updatePoly:function(t,i){this._setPath(t,Q(t._parts,i))},_updateCircle:function(t){var i=t._point,e=Math.max(Math.round(t._radius),1),n="a"+e+","+(Math.max(Math.round(t._radiusY),1)||e)+" 0 1,0 ",o=t._empty()?"M0 0":"M"+(i.x-e)+","+i.y+n+2*e+",0 "+n+2*-e+",0 ";this._setPath(t,o)},_setPath:function(t,i){t._path.setAttribute("d",i)},_bringToFront:function(t){_i(t._path)},_bringToBack:function(t){di(t._path)}});function yn(t){return Zt||kt?new vn(t):null}kt&&vn.include(fn),$i.include({getRenderer:function(t){var i=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer;return i||(i=this._renderer=this._createRenderer()),this.hasLayer(i)||this.addLayer(i),i},_getPaneRenderer:function(t){if("overlayPane"===t||void 0===t)return!1;var i=this._paneRenderers[t];return void 0===i&&(i=this._createRenderer({pane:t}),this._paneRenderers[t]=i),i},_createRenderer:function(t){return this.options.preferCanvas&&pn(t)||yn(t)}});var xn=We.extend({initialize:function(t,i){We.prototype.initialize.call(this,this._boundsToLatLngs(t),i)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=D(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});vn.create=gn,vn.pointsToPath=Q,He.geometryToLayer=Fe,He.coordsToLatLng=Ve,He.coordsToLatLngs=qe,He.latLngToCoords=Ge,He.latLngsToCoords=Ke,He.getFeature=Ye,He.asFeature=Xe,$i.mergeOptions({boxZoom:!0});var wn=se.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){ki(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){Ai(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){li(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),Qt(),Ti(),this._startPoint=this._map.mouseEventToContainerPoint(t),ki(document,{contextmenu:Wi,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=ui("div","leaflet-zoom-box",this._container),mi(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var i=new O(this._point,this._startPoint),e=i.getSize();Pi(this._box,i.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(li(this._box),fi(this._container,"leaflet-crosshair")),ti(),zi(),Ai(document,{contextmenu:Wi,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){if((1===t.which||1===t.button)&&(this._finish(),this._moved)){this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0);var i=new N(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point));this._map.fitBounds(i).fire("boxzoomend",{boxZoomBounds:i})}},_onKeyDown:function(t){27===t.keyCode&&this._finish()}});$i.addInitHook("addHandler","boxZoom",wn),$i.mergeOptions({doubleClickZoom:!0});var Pn=se.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var i=this._map,e=i.getZoom(),n=i.options.zoomDelta,o=t.originalEvent.shiftKey?e-n:e+n;"center"===i.options.doubleClickZoom?i.setZoom(o):i.setZoomAround(t.containerPoint,o)}});$i.addInitHook("addHandler","doubleClickZoom",Pn),$i.mergeOptions({dragging:!0,inertia:!rt,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0});var Ln=se.extend({addHooks:function(){if(!this._draggable){var t=this._map;this._draggable=new ce(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))}mi(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){fi(this._map._container,"leaflet-grab"),fi(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t=this._map;if(t._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity){var i=D(this._map.options.maxBounds);this._offsetLimit=R(this._map.latLngToContainerPoint(i.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(i.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))}else this._offsetLimit=null;t.fire("movestart").fire("dragstart"),t.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){if(this._map.options.inertia){var i=this._lastTime=+new Date,e=this._lastPos=this._draggable._absPos||this._draggable._newPos;this._positions.push(e),this._times.push(i),this._prunePositions(i)}this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1i.max.x&&(t.x=this._viscousLimit(t.x,i.max.x)),t.y>i.max.y&&(t.y=this._viscousLimit(t.y,i.max.y)),this._draggable._newPos=this._draggable._startPos.add(t)}},_onPreDragWrap:function(){var t=this._worldWidth,i=Math.round(t/2),e=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-i+e)%t+i-e,s=(n+i+e)%t-i-e,r=Math.abs(o+e)i.getMaxZoom()&&1=i;)t=t.__parent;return this._currentShownBounds.contains(t.getLatLng())&&(this.options.animateAddingMarkers?this._animationAddLayer(e,t):this._animationAddLayerNonAnimated(e,t)),this},removeLayer:function(e){return e instanceof L.LayerGroup?this.removeLayers([e]):e.getLatLng?this._map?e.__parent?(this._unspiderfy&&(this._unspiderfy(),this._unspiderfyLayer(e)),this._removeLayer(e,!0),this._topClusterLevel._recalculateBounds(),e.off("move",this._childMarkerMoved,this),this._featureGroup.hasLayer(e)&&(this._featureGroup.removeLayer(e),e.clusterShow&&e.clusterShow()),this):this:(!this._arraySplice(this._needsClustering,e)&&this.hasLayer(e)&&this._needsRemoving.push(e),this):(this._nonPointGroup.removeLayer(e),this)},addLayers:function(e){if(!L.Util.isArray(e))return this.addLayer(e);var t,i=this._featureGroup,n=this._nonPointGroup,s=this.options.chunkedLoading,r=this.options.chunkInterval,o=this.options.chunkProgress,a=e.length,h=0,u=!0;if(this._map){var l=(new Date).getTime(),_=L.bind(function(){for(var d=(new Date).getTime();a>h;h++){if(s&&0===h%200){var c=(new Date).getTime()-d;if(c>r)break}if(t=e[h],t instanceof L.LayerGroup)u&&(e=e.slice(),u=!1),this._extractNonGroupLayers(t,e),a=e.length;else if(t.getLatLng){if(!this.hasLayer(t)&&(this._addLayer(t,this._maxZoom),t.__parent&&2===t.__parent.getChildCount())){var p=t.__parent.getAllChildMarkers(),f=p[0]===t?p[1]:p[0];i.removeLayer(f)}}else n.addLayer(t)}o&&o(h,a,(new Date).getTime()-l),h===a?(this._topClusterLevel._recalculateBounds(),this._featureGroup.eachLayer(function(e){e instanceof L.MarkerCluster&&e._iconNeedsUpdate&&e._updateIcon()}),this._topClusterLevel._recursivelyAddChildrenToMap(null,this._zoom,this._currentShownBounds)):setTimeout(_,this.options.chunkDelay)},this);_()}else for(var d=this._needsClustering;a>h;h++)t=e[h],t instanceof L.LayerGroup?(u&&(e=e.slice(),u=!1),this._extractNonGroupLayers(t,e),a=e.length):t.getLatLng?this.hasLayer(t)||d.push(t):n.addLayer(t);return this},removeLayers:function(e){var t,i,n=e.length,s=this._featureGroup,r=this._nonPointGroup,o=!0;if(!this._map){for(t=0;n>t;t++)i=e[t],i instanceof L.LayerGroup?(o&&(e=e.slice(),o=!1),this._extractNonGroupLayers(i,e),n=e.length):(this._arraySplice(this._needsClustering,i),r.removeLayer(i),this.hasLayer(i)&&this._needsRemoving.push(i));return this}if(this._unspiderfy){this._unspiderfy();var a=e.slice(),h=n;for(t=0;h>t;t++)i=a[t],i instanceof L.LayerGroup?(this._extractNonGroupLayers(i,a),h=a.length):this._unspiderfyLayer(i)}for(t=0;n>t;t++)i=e[t],i instanceof L.LayerGroup?(o&&(e=e.slice(),o=!1),this._extractNonGroupLayers(i,e),n=e.length):i.__parent?(this._removeLayer(i,!0,!0),s.hasLayer(i)&&(s.removeLayer(i),i.clusterShow&&i.clusterShow())):r.removeLayer(i);return this._topClusterLevel._recalculateBounds(),this._topClusterLevel._recursivelyAddChildrenToMap(null,this._zoom,this._currentShownBounds),s.eachLayer(function(e){e instanceof L.MarkerCluster&&e._updateIcon()}),this},clearLayers:function(){return this._map||(this._needsClustering=[],delete this._gridClusters,delete this._gridUnclustered),this._noanimationUnspiderfy&&this._noanimationUnspiderfy(),this._featureGroup.clearLayers(),this._nonPointGroup.clearLayers(),this.eachLayer(function(e){e.off("move",this._childMarkerMoved,this),delete e.__parent}),this._map&&this._generateInitialClusters(),this},getBounds:function(){var e=new L.LatLngBounds;this._topClusterLevel&&e.extend(this._topClusterLevel._bounds);for(var t=this._needsClustering.length-1;t>=0;t--)e.extend(this._needsClustering[t].getLatLng());return e.extend(this._nonPointGroup.getBounds()),e},eachLayer:function(e,t){var i,n=this._needsClustering.slice(),s=this._needsRemoving;for(this._topClusterLevel&&this._topClusterLevel.getAllChildMarkers(n),i=n.length-1;i>=0;i--)-1===s.indexOf(n[i])&&e.call(t,n[i]);this._nonPointGroup.eachLayer(e,t)},getLayers:function(){var e=[];return this.eachLayer(function(t){e.push(t)}),e},getLayer:function(e){var t=null;return e=parseInt(e,10),this.eachLayer(function(i){L.stamp(i)===e&&(t=i)}),t},hasLayer:function(e){if(!e)return!1;var t,i=this._needsClustering;for(t=i.length-1;t>=0;t--)if(i[t]===e)return!0;for(i=this._needsRemoving,t=i.length-1;t>=0;t--)if(i[t]===e)return!1;return!(!e.__parent||e.__parent._group!==this)||this._nonPointGroup.hasLayer(e)},zoomToShowLayer:function(e,t){"function"!=typeof t&&(t=function(){});var i=function(){!e._icon&&!e.__parent._icon||this._inZoomAnimation||(this._map.off("moveend",i,this),this.off("animationend",i,this),e._icon?t():e.__parent._icon&&(this.once("spiderfied",t,this),e.__parent.spiderfy()))};if(e._icon&&this._map.getBounds().contains(e.getLatLng()))t();else if(e.__parent._zoomt;t++)n=this._needsRemoving[t],this._removeLayer(n,!0);this._needsRemoving=[],this._zoom=this._map.getZoom(),this._currentShownBounds=this._getExpandedVisibleBounds(),this._map.on("zoomend",this._zoomEnd,this),this._map.on("moveend",this._moveEnd,this),this._spiderfierOnAdd&&this._spiderfierOnAdd(),this._bindEvents(),i=this._needsClustering,this._needsClustering=[],this.addLayers(i)},onRemove:function(e){e.off("zoomend",this._zoomEnd,this),e.off("moveend",this._moveEnd,this),this._unbindEvents(),this._map._mapPane.className=this._map._mapPane.className.replace(" leaflet-cluster-anim",""),this._spiderfierOnRemove&&this._spiderfierOnRemove(),delete this._maxLat,this._hideCoverage(),this._featureGroup.remove(),this._nonPointGroup.remove(),this._featureGroup.clearLayers(),this._map=null},getVisibleParent:function(e){for(var t=e;t&&!t._icon;)t=t.__parent;return t||null},_arraySplice:function(e,t){for(var i=e.length-1;i>=0;i--)if(e[i]===t)return e.splice(i,1),!0},_removeFromGridUnclustered:function(e,t){for(var i=this._map,n=this._gridUnclustered;t>=0&&n[t].removeObject(e,i.project(e.getLatLng(),t));t--);},_childMarkerMoved:function(e){this._ignoreMove||(e.target._latlng=e.oldLatLng,this.removeLayer(e.target),e.target._latlng=e.latlng,this.addLayer(e.target))},_removeLayer:function(e,t,i){var n=this._gridClusters,s=this._gridUnclustered,r=this._featureGroup,o=this._map;t&&this._removeFromGridUnclustered(e,this._maxZoom);var a,h=e.__parent,u=h._markers;for(this._arraySplice(u,e);h&&(h._childCount--,h._boundsNeedUpdate=!0,!(h._zoom<0));)t&&h._childCount<=1?(a=h._markers[0]===e?h._markers[1]:h._markers[0],n[h._zoom].removeObject(h,o.project(h._cLatLng,h._zoom)),s[h._zoom].addObject(a,o.project(a.getLatLng(),h._zoom)),this._arraySplice(h.__parent._childClusters,h),h.__parent._markers.push(a),a.__parent=h.__parent,h._icon&&(r.removeLayer(h),i||r.addLayer(a))):i&&h._icon||h._updateIcon(),h=h.__parent;delete e.__parent},_isOrIsParent:function(e,t){for(;t;){if(e===t)return!0;t=t.parentNode}return!1},fire:function(e,t,i){if(t&&t.layer instanceof L.MarkerCluster){if(t.originalEvent&&this._isOrIsParent(t.layer._icon,t.originalEvent.relatedTarget))return;e="cluster"+e}L.FeatureGroup.prototype.fire.call(this,e,t,i)},listens:function(e,t){return L.FeatureGroup.prototype.listens.call(this,e,t)||L.FeatureGroup.prototype.listens.call(this,"cluster"+e,t)},_defaultIconCreateFunction:function(e){var t=e.getChildCount(),i=" marker-cluster-";return i+=10>t?"small":100>t?"medium":"large",new L.DivIcon({html:"
"+t+"
",className:"marker-cluster"+i,iconSize:new L.Point(40,40)})},_bindEvents:function(){var e=this._map,t=this.options.spiderfyOnMaxZoom,i=this.options.showCoverageOnHover,n=this.options.zoomToBoundsOnClick;(t||n)&&this.on("clusterclick",this._zoomOrSpiderfy,this),i&&(this.on("clustermouseover",this._showCoverage,this),this.on("clustermouseout",this._hideCoverage,this),e.on("zoomend",this._hideCoverage,this))},_zoomOrSpiderfy:function(e){for(var t=e.layer,i=t;1===i._childClusters.length;)i=i._childClusters[0];i._zoom===this._maxZoom&&i._childCount===t._childCount&&this.options.spiderfyOnMaxZoom?t.spiderfy():this.options.zoomToBoundsOnClick&&t.zoomToBounds(),e.originalEvent&&13===e.originalEvent.keyCode&&this._map._container.focus()},_showCoverage:function(e){var t=this._map;this._inZoomAnimation||(this._shownPolygon&&t.removeLayer(this._shownPolygon),e.layer.getChildCount()>2&&e.layer!==this._spiderfied&&(this._shownPolygon=new L.Polygon(e.layer.getConvexHull(),this.options.polygonOptions),t.addLayer(this._shownPolygon)))},_hideCoverage:function(){this._shownPolygon&&(this._map.removeLayer(this._shownPolygon),this._shownPolygon=null)},_unbindEvents:function(){var e=this.options.spiderfyOnMaxZoom,t=this.options.showCoverageOnHover,i=this.options.zoomToBoundsOnClick,n=this._map;(e||i)&&this.off("clusterclick",this._zoomOrSpiderfy,this),t&&(this.off("clustermouseover",this._showCoverage,this),this.off("clustermouseout",this._hideCoverage,this),n.off("zoomend",this._hideCoverage,this))},_zoomEnd:function(){this._map&&(this._mergeSplitClusters(),this._zoom=Math.round(this._map._zoom),this._currentShownBounds=this._getExpandedVisibleBounds())},_moveEnd:function(){if(!this._inZoomAnimation){var e=this._getExpandedVisibleBounds();this._topClusterLevel._recursivelyRemoveChildrenFromMap(this._currentShownBounds,this._zoom,e),this._topClusterLevel._recursivelyAddChildrenToMap(null,Math.round(this._map._zoom),e),this._currentShownBounds=e}},_generateInitialClusters:function(){var e=this._map.getMaxZoom(),t=this.options.maxClusterRadius,i=t;"function"!=typeof t&&(i=function(){return t}),this.options.disableClusteringAtZoom&&(e=this.options.disableClusteringAtZoom-1),this._maxZoom=e,this._gridClusters={},this._gridUnclustered={};for(var n=e;n>=0;n--)this._gridClusters[n]=new L.DistanceGrid(i(n)),this._gridUnclustered[n]=new L.DistanceGrid(i(n));this._topClusterLevel=new this._markerCluster(this,-1)},_addLayer:function(e,t){var i,n,s=this._gridClusters,r=this._gridUnclustered;for(this.options.singleMarkerMode&&this._overrideMarkerIcon(e),e.on("move",this._childMarkerMoved,this);t>=0;t--){i=this._map.project(e.getLatLng(),t);var o=s[t].getNearObject(i);if(o)return o._addChild(e),e.__parent=o,void 0;if(o=r[t].getNearObject(i)){var a=o.__parent;a&&this._removeLayer(o,!1);var h=new this._markerCluster(this,t,o,e);s[t].addObject(h,this._map.project(h._cLatLng,t)),o.__parent=h,e.__parent=h;var u=h;for(n=t-1;n>a._zoom;n--)u=new this._markerCluster(this,n,u),s[n].addObject(u,this._map.project(o.getLatLng(),n));return a._addChild(u),this._removeFromGridUnclustered(o,t),void 0}r[t].addObject(e,i)}this._topClusterLevel._addChild(e),e.__parent=this._topClusterLevel},_enqueue:function(e){this._queue.push(e),this._queueTimeout||(this._queueTimeout=setTimeout(L.bind(this._processQueue,this),300))},_processQueue:function(){for(var e=0;ee?(this._animationStart(),this._animationZoomOut(this._zoom,e)):this._moveEnd()},_getExpandedVisibleBounds:function(){return this.options.removeOutsideVisibleBounds?L.Browser.mobile?this._checkBoundsMaxLat(this._map.getBounds()):this._checkBoundsMaxLat(this._map.getBounds().pad(1)):this._mapBoundsInfinite},_checkBoundsMaxLat:function(e){var t=this._maxLat;return t!==i&&(e.getNorth()>=t&&(e._northEast.lat=1/0),e.getSouth()<=-t&&(e._southWest.lat=-1/0)),e},_animationAddLayerNonAnimated:function(e,t){if(t===e)this._featureGroup.addLayer(e);else if(2===t._childCount){t._addToMap();var i=t.getAllChildMarkers();this._featureGroup.removeLayer(i[0]),this._featureGroup.removeLayer(i[1])}else t._updateIcon()},_extractNonGroupLayers:function(e,t){var i,n=e.getLayers(),s=0;for(t=t||[];s=0;i--)o=h[i],n.contains(o._latlng)||s.removeLayer(o)}),this._forceLayout(),this._topClusterLevel._recursivelyBecomeVisible(n,t),s.eachLayer(function(e){e instanceof L.MarkerCluster||!e._icon||e.clusterShow()}),this._topClusterLevel._recursively(n,e,t,function(e){e._recursivelyRestoreChildPositions(t)}),this._ignoreMove=!1,this._enqueue(function(){this._topClusterLevel._recursively(n,e,0,function(e){s.removeLayer(e),e.clusterShow()}),this._animationEnd()})},_animationZoomOut:function(e,t){this._animationZoomOutSingle(this._topClusterLevel,e-1,t),this._topClusterLevel._recursivelyAddChildrenToMap(null,t,this._getExpandedVisibleBounds()),this._topClusterLevel._recursivelyRemoveChildrenFromMap(this._currentShownBounds,e,this._getExpandedVisibleBounds())},_animationAddLayer:function(e,t){var i=this,n=this._featureGroup;n.addLayer(e),t!==e&&(t._childCount>2?(t._updateIcon(),this._forceLayout(),this._animationStart(),e._setPos(this._map.latLngToLayerPoint(t.getLatLng())),e.clusterHide(),this._enqueue(function(){n.removeLayer(e),e.clusterShow(),i._animationEnd()})):(this._forceLayout(),i._animationStart(),i._animationZoomOutSingle(t,this._map.getMaxZoom(),this._map.getZoom())))}},_animationZoomOutSingle:function(e,t,i){var n=this._getExpandedVisibleBounds();e._recursivelyAnimateChildrenInAndAddSelfToMap(n,t+1,i);var s=this;this._forceLayout(),e._recursivelyBecomeVisible(n,i),this._enqueue(function(){if(1===e._childCount){var r=e._markers[0];this._ignoreMove=!0,r.setLatLng(r.getLatLng()),this._ignoreMove=!1,r.clusterShow&&r.clusterShow()}else e._recursively(n,i,0,function(e){e._recursivelyRemoveChildrenFromMap(n,t+1)});s._animationEnd()})},_animationEnd:function(){this._map&&(this._map._mapPane.className=this._map._mapPane.className.replace(" leaflet-cluster-anim","")),this._inZoomAnimation--,this.fire("animationend")},_forceLayout:function(){L.Util.falseFn(t.body.offsetWidth)}}),L.markerClusterGroup=function(e){return new L.MarkerClusterGroup(e)},L.MarkerCluster=L.Marker.extend({initialize:function(e,t,i,n){L.Marker.prototype.initialize.call(this,i?i._cLatLng||i.getLatLng():new L.LatLng(0,0),{icon:this}),this._group=e,this._zoom=t,this._markers=[],this._childClusters=[],this._childCount=0,this._iconNeedsUpdate=!0,this._boundsNeedUpdate=!0,this._bounds=new L.LatLngBounds,i&&this._addChild(i),n&&this._addChild(n)},getAllChildMarkers:function(e){e=e||[];for(var t=this._childClusters.length-1;t>=0;t--)this._childClusters[t].getAllChildMarkers(e);for(var i=this._markers.length-1;i>=0;i--)e.push(this._markers[i]);return e},getChildCount:function(){return this._childCount},zoomToBounds:function(){for(var e,t=this._childClusters.slice(),i=this._group._map,n=i.getBoundsZoom(this._bounds),s=this._zoom+1,r=i.getZoom();t.length>0&&n>s;){s++;var o=[];for(e=0;es?this._group._map.setView(this._latlng,s):r>=n?this._group._map.setView(this._latlng,r+1):this._group._map.fitBounds(this._bounds)},getBounds:function(){var e=new L.LatLngBounds;return e.extend(this._bounds),e},_updateIcon:function(){this._iconNeedsUpdate=!0,this._icon&&this.setIcon(this)},createIcon:function(){return this._iconNeedsUpdate&&(this._iconObj=this._group.options.iconCreateFunction(this),this._iconNeedsUpdate=!1),this._iconObj.createIcon()},createShadow:function(){return this._iconObj.createShadow()},_addChild:function(e,t){this._iconNeedsUpdate=!0,this._boundsNeedUpdate=!0,this._setClusterCenter(e),e instanceof L.MarkerCluster?(t||(this._childClusters.push(e),e.__parent=this),this._childCount+=e._childCount):(t||this._markers.push(e),this._childCount++),this.__parent&&this.__parent._addChild(e,!0)},_setClusterCenter:function(e){this._cLatLng||(this._cLatLng=e._cLatLng||e._latlng)},_resetBounds:function(){var e=this._bounds;e._southWest&&(e._southWest.lat=1/0,e._southWest.lng=1/0),e._northEast&&(e._northEast.lat=-1/0,e._northEast.lng=-1/0)},_recalculateBounds:function(){var e,t,i,n,s=this._markers,r=this._childClusters,o=0,a=0,h=this._childCount;if(0!==h){for(this._resetBounds(),e=0;e=0;i--)n=s[i],n._icon&&(n._setPos(t),n.clusterHide())},function(e){var i,n,s=e._childClusters;for(i=s.length-1;i>=0;i--)n=s[i],n._icon&&(n._setPos(t),n.clusterHide())})},_recursivelyAnimateChildrenInAndAddSelfToMap:function(e,t,i){this._recursively(e,i,0,function(n){n._recursivelyAnimateChildrenIn(e,n._group._map.latLngToLayerPoint(n.getLatLng()).round(),t),n._isSingleParent()&&t-1===i?(n.clusterShow(),n._recursivelyRemoveChildrenFromMap(e,t)):n.clusterHide(),n._addToMap()})},_recursivelyBecomeVisible:function(e,t){this._recursively(e,0,t,null,function(e){e.clusterShow()})},_recursivelyAddChildrenToMap:function(e,t,i){this._recursively(i,-1,t,function(n){if(t!==n._zoom)for(var s=n._markers.length-1;s>=0;s--){var r=n._markers[s];i.contains(r._latlng)&&(e&&(r._backupLatlng=r.getLatLng(),r.setLatLng(e),r.clusterHide&&r.clusterHide()),n._group._featureGroup.addLayer(r))}},function(t){t._addToMap(e)})},_recursivelyRestoreChildPositions:function(e){for(var t=this._markers.length-1;t>=0;t--){var i=this._markers[t];i._backupLatlng&&(i.setLatLng(i._backupLatlng),delete i._backupLatlng)}if(e-1===this._zoom)for(var n=this._childClusters.length-1;n>=0;n--)this._childClusters[n]._restorePosition();else for(var s=this._childClusters.length-1;s>=0;s--)this._childClusters[s]._recursivelyRestoreChildPositions(e)},_restorePosition:function(){this._backupLatlng&&(this.setLatLng(this._backupLatlng),delete this._backupLatlng)},_recursivelyRemoveChildrenFromMap:function(e,t,i){var n,s;this._recursively(e,-1,t-1,function(e){for(s=e._markers.length-1;s>=0;s--)n=e._markers[s],i&&i.contains(n._latlng)||(e._group._featureGroup.removeLayer(n),n.clusterShow&&n.clusterShow())},function(e){for(s=e._childClusters.length-1;s>=0;s--)n=e._childClusters[s],i&&i.contains(n._latlng)||(e._group._featureGroup.removeLayer(n),n.clusterShow&&n.clusterShow())})},_recursively:function(e,t,i,n,s){var r,o,a=this._childClusters,h=this._zoom;if(t>h)for(r=a.length-1;r>=0;r--)o=a[r],e.intersects(o._bounds)&&o._recursively(e,t,i,n,s);else if(n&&n(this),s&&this._zoom===i&&s(this),i>h)for(r=a.length-1;r>=0;r--)o=a[r],e.intersects(o._bounds)&&o._recursively(e,t,i,n,s)},_isSingleParent:function(){return this._childClusters.length>0&&this._childClusters[0]._childCount===this._childCount}}),L.Marker.include({clusterHide:function(){return this.options.opacityWhenUnclustered=this.options.opacity||1,this.setOpacity(0)},clusterShow:function(){var e=this.setOpacity(this.options.opacity||this.options.opacityWhenUnclustered);return delete this.options.opacityWhenUnclustered,e}}),L.DistanceGrid=function(e){this._cellSize=e,this._sqCellSize=e*e,this._grid={},this._objectPoint={}},L.DistanceGrid.prototype={addObject:function(e,t){var i=this._getCoord(t.x),n=this._getCoord(t.y),s=this._grid,r=s[n]=s[n]||{},o=r[i]=r[i]||[],a=L.Util.stamp(e);this._objectPoint[a]=t,o.push(e)},updateObject:function(e,t){this.removeObject(e),this.addObject(e,t)},removeObject:function(e,t){var i,n,s=this._getCoord(t.x),r=this._getCoord(t.y),o=this._grid,a=o[r]=o[r]||{},h=a[s]=a[s]||[];for(delete this._objectPoint[L.Util.stamp(e)],i=0,n=h.length;n>i;i++)if(h[i]===e)return h.splice(i,1),1===n&&delete a[s],!0},eachObject:function(e,t){var i,n,s,r,o,a,h,u=this._grid;for(i in u){o=u[i];for(n in o)for(a=o[n],s=0,r=a.length;r>s;s++)h=e.call(t,a[s]),h&&(s--,r--)}},getNearObject:function(e){var t,i,n,s,r,o,a,h,u=this._getCoord(e.x),l=this._getCoord(e.y),_=this._objectPoint,d=this._sqCellSize,c=null;for(t=l-1;l+1>=t;t++)if(s=this._grid[t])for(i=u-1;u+1>=i;i++)if(r=s[i])for(n=0,o=r.length;o>n;n++)a=r[n],h=this._sqDist(_[L.Util.stamp(a)],e),d>h&&(d=h,c=a);return c},_getCoord:function(e){return Math.floor(e/this._cellSize)},_sqDist:function(e,t){var i=t.x-e.x,n=t.y-e.y;return i*i+n*n}},function(){L.QuickHull={getDistant:function(e,t){var i=t[1].lat-t[0].lat,n=t[0].lng-t[1].lng;return n*(e.lat-t[0].lat)+i*(e.lng-t[0].lng)},findMostDistantPointFromBaseLine:function(e,t){var i,n,s,r=0,o=null,a=[];for(i=t.length-1;i>=0;i--)n=t[i],s=this.getDistant(n,e),s>0&&(a.push(n),s>r&&(r=s,o=n));return{maxPoint:o,newPoints:a}},buildConvexHull:function(e,t){var i=[],n=this.findMostDistantPointFromBaseLine(e,t);return n.maxPoint?(i=i.concat(this.buildConvexHull([e[0],n.maxPoint],n.newPoints)),i=i.concat(this.buildConvexHull([n.maxPoint,e[1]],n.newPoints))):[e[0]]},getConvexHull:function(e){var t,i=!1,n=!1,s=!1,r=!1,o=null,a=null,h=null,u=null,l=null,_=null;for(t=e.length-1;t>=0;t--){var d=e[t];(i===!1||d.lat>i)&&(o=d,i=d.lat),(n===!1||d.lats)&&(h=d,s=d.lng),(r===!1||d.lng=0;t--)e=i[t].getLatLng(),n.push(e);return L.QuickHull.getConvexHull(n)}}),L.MarkerCluster.include({_2PI:2*Math.PI,_circleFootSeparation:25,_circleStartAngle:Math.PI/6,_spiralFootSeparation:28,_spiralLengthStart:11,_spiralLengthFactor:5,_circleSpiralSwitchover:9,spiderfy:function(){if(this._group._spiderfied!==this&&!this._group._inZoomAnimation){var e,t=this.getAllChildMarkers(),i=this._group,n=i._map,s=n.latLngToLayerPoint(this._latlng);this._group._unspiderfy(),this._group._spiderfied=this,t.length>=this._circleSpiralSwitchover?e=this._generatePointsSpiral(t.length,s):(s.y+=10,e=this._generatePointsCircle(t.length,s)),this._animationSpiderfy(t,e)}},unspiderfy:function(e){this._group._inZoomAnimation||(this._animationUnspiderfy(e),this._group._spiderfied=null)},_generatePointsCircle:function(e,t){var i,n,s=this._group.options.spiderfyDistanceMultiplier*this._circleFootSeparation*(2+e),r=s/this._2PI,o=this._2PI/e,a=[];for(a.length=e,i=e-1;i>=0;i--)n=this._circleStartAngle+i*o,a[i]=new L.Point(t.x+r*Math.cos(n),t.y+r*Math.sin(n))._round();return a},_generatePointsSpiral:function(e,t){var i,n=this._group.options.spiderfyDistanceMultiplier,s=n*this._spiralLengthStart,r=n*this._spiralFootSeparation,o=n*this._spiralLengthFactor*this._2PI,a=0,h=[];for(h.length=e,i=e-1;i>=0;i--)a+=r/s+5e-4*i,h[i]=new L.Point(t.x+s*Math.cos(a),t.y+s*Math.sin(a))._round(),s+=o/a;return h},_noanimationUnspiderfy:function(){var e,t,i=this._group,n=i._map,s=i._featureGroup,r=this.getAllChildMarkers();for(i._ignoreMove=!0,this.setOpacity(1),t=r.length-1;t>=0;t--)e=r[t],s.removeLayer(e),e._preSpiderfyLatlng&&(e.setLatLng(e._preSpiderfyLatlng),delete e._preSpiderfyLatlng),e.setZIndexOffset&&e.setZIndexOffset(0),e._spiderLeg&&(n.removeLayer(e._spiderLeg),delete e._spiderLeg);i.fire("unspiderfied",{cluster:this,markers:r}),i._ignoreMove=!1,i._spiderfied=null}}),L.MarkerClusterNonAnimated=L.MarkerCluster.extend({_animationSpiderfy:function(e,t){var i,n,s,r,o=this._group,a=o._map,h=o._featureGroup,u=this._group.options.spiderLegPolylineOptions;for(o._ignoreMove=!0,i=0;i=0;n--)h=_.layerPointToLatLng(t[n]),s=e[n],s._preSpiderfyLatlng=s._latlng,s.setLatLng(h),s.clusterShow&&s.clusterShow(),f&&(r=s._spiderLeg,o=r._path,o.style.strokeDashoffset=0,r.setStyle({opacity:g}));this.setOpacity(.3),l._ignoreMove=!1,setTimeout(function(){l._animationEnd(),l.fire("spiderfied",{cluster:u,markers:e})},200)},_animationUnspiderfy:function(e){var t,i,n,s,r,o,a=this,h=this._group,u=h._map,l=h._featureGroup,_=e?u._latLngToNewLayerPoint(this._latlng,e.zoom,e.center):u.latLngToLayerPoint(this._latlng),d=this.getAllChildMarkers(),c=L.Path.SVG;for(h._ignoreMove=!0,h._animationStart(),this.setOpacity(1),i=d.length-1;i>=0;i--)t=d[i],t._preSpiderfyLatlng&&(t.setLatLng(t._preSpiderfyLatlng),delete t._preSpiderfyLatlng,o=!0,t._setPos&&(t._setPos(_),o=!1),t.clusterHide&&(t.clusterHide(),o=!1),o&&l.removeLayer(t),c&&(n=t._spiderLeg,s=n._path,r=s.getTotalLength()+.1,s.style.strokeDashoffset=r,n.setStyle({opacity:0})));h._ignoreMove=!1,setTimeout(function(){var e=0;for(i=d.length-1;i>=0;i--)t=d[i],t._spiderLeg&&e++;for(i=d.length-1;i>=0;i--)t=d[i],t._spiderLeg&&(t.clusterShow&&t.clusterShow(),t.setZIndexOffset&&t.setZIndexOffset(0),e>1&&l.removeLayer(t),u.removeLayer(t._spiderLeg),delete t._spiderLeg);h._animationEnd(),h.fire("unspiderfied",{cluster:a,markers:d})},200)}}),L.MarkerClusterGroup.include({_spiderfied:null,unspiderfy:function(){this._unspiderfy.apply(this,arguments)},_spiderfierOnAdd:function(){this._map.on("click",this._unspiderfyWrapper,this),this._map.options.zoomAnimation&&this._map.on("zoomstart",this._unspiderfyZoomStart,this),this._map.on("zoomend",this._noanimationUnspiderfy,this),L.Browser.touch||this._map.getRenderer(this)},_spiderfierOnRemove:function(){this._map.off("click",this._unspiderfyWrapper,this),this._map.off("zoomstart",this._unspiderfyZoomStart,this),this._map.off("zoomanim",this._unspiderfyZoomAnim,this),this._map.off("zoomend",this._noanimationUnspiderfy,this),this._noanimationUnspiderfy()},_unspiderfyZoomStart:function(){this._map&&this._map.on("zoomanim",this._unspiderfyZoomAnim,this)},_unspiderfyZoomAnim:function(e){L.DomUtil.hasClass(this._map._mapPane,"leaflet-touching")||(this._map.off("zoomanim",this._unspiderfyZoomAnim,this),this._unspiderfy(e))},_unspiderfyWrapper:function(){this._unspiderfy()},_unspiderfy:function(e){this._spiderfied&&this._spiderfied.unspiderfy(e)},_noanimationUnspiderfy:function(){this._spiderfied&&this._spiderfied._noanimationUnspiderfy()},_unspiderfyLayer:function(e){e._spiderLeg&&(this._featureGroup.removeLayer(e),e.clusterShow&&e.clusterShow(),e.setZIndexOffset&&e.setZIndexOffset(0),this._map.removeLayer(e._spiderLeg),delete e._spiderLeg)}}),L.MarkerClusterGroup.include({refreshClusters:function(e){return e?e instanceof L.MarkerClusterGroup?e=e._topClusterLevel.getAllChildMarkers():e instanceof L.LayerGroup?e=e._layers:e instanceof L.MarkerCluster?e=e.getAllChildMarkers():e instanceof L.Marker&&(e=[e]):e=this._topClusterLevel.getAllChildMarkers(),this._flagParentsIconsNeedUpdate(e),this._refreshClustersIcons(),this.options.singleMarkerMode&&this._refreshSingleMarkerModeMarkers(e),this},_flagParentsIconsNeedUpdate:function(e){var t,i;for(t in e)for(i=e[t].__parent;i;)i._iconNeedsUpdate=!0,i=i.__parent},_refreshClustersIcons:function(){this._featureGroup.eachLayer(function(e){e instanceof L.MarkerCluster&&e._iconNeedsUpdate&&e._updateIcon()})},_refreshSingleMarkerModeMarkers:function(e){var t,i;for(t in e)i=e[t],this.hasLayer(i)&&i.setIcon(this._overrideMarkerIcon(i))}}),L.Marker.include({refreshIconOptions:function(e,t){var i=this.options.icon;return L.setOptions(i,e),this.setIcon(i),t&&this.__parent&&this.__parent._group.refreshClusters(this),this}})}(window,document); \ No newline at end of file diff --git a/src/deepfacility/viz/leaflet_template/js/leaflet.pattern.js b/src/deepfacility/viz/leaflet_template/js/leaflet.pattern.js new file mode 100644 index 0000000..d08c185 --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/js/leaflet.pattern.js @@ -0,0 +1,6 @@ +/* + Leaflet.pattern, Provides tools to set the backgrounds of vector shapes in Leaflet to be patterns. + https://github.com/teastman/Leaflet.pattern + (c) 2015, Tyler Eastman +*/ +!function(t,e){L.Pattern=L.Class.extend({includes:[L.Mixin.Events],options:{x:0,y:0,width:8,height:8,patternUnits:"userSpaceOnUse",patternContentUnits:"userSpaceOnUse"},_addShapes:L.Util.falseFn,_update:L.Util.falseFn,initialize:function(t){this._shapes={},L.setOptions(this,t)},onAdd:function(t){this._map=t.target?t.target:t,this._map._initDefRoot(),this._initDom();for(var e in this._shapes)this._shapes[e].onAdd(this);this._addShapes(),this._addDom(),this.redraw(),this.getEvents&&this._map.on(this.getEvents(),this),this.fire("add"),this._map.fire("patternadd",{pattern:this})},onRemove:function(){this._removeDom()},redraw:function(){if(this._map){this._update();for(var t in this._shapes)this._shapes[t].redraw()}return this},setStyle:function(t){return L.setOptions(this,t),this._map&&(this._updateStyle(),this.redraw()),this},addTo:function(t){return t.addPattern(this),this},remove:function(){return this.removeFrom(this._map)},removeFrom:function(t){return t&&t.removePattern(this),this}}),L.Map.addInitHook(function(){this._patterns={}}),L.Map.include({addPattern:function(t){var e=L.stamp(t);return this._patterns[e]?t:(this._patterns[e]=t,this.whenReady(t.onAdd,t),this)},removePattern:function(t){var e=L.stamp(t);return this._patterns[e]?(this._loaded&&t.onRemove(this),t.getEvents&&this.off(t.getEvents(),t),delete this._patterns[e],this._loaded&&(this.fire("patternremove",{pattern:t}),t.fire("remove")),t._map=null,this):this},hasPattern:function(t){return!!t&&L.stamp(t)in this._patterns}}),L.Pattern.SVG_NS="http://www.w3.org/2000/svg",L.Pattern=L.Pattern.extend({_createElement:function(t){return e.createElementNS(L.Pattern.SVG_NS,t)},_initDom:function(){this._dom=this._createElement("pattern"),this.options.className&&L.DomUtil.addClass(this._dom,this.options.className),this._updateStyle()},_addDom:function(){this._map._defRoot.appendChild(this._dom)},_removeDom:function(){L.DomUtil.remove(this._dom)},_updateStyle:function(){var t=this._dom,e=this.options;if(t){if(t.setAttribute("id",L.stamp(this)),t.setAttribute("x",e.x),t.setAttribute("y",e.y),t.setAttribute("width",e.width),t.setAttribute("height",e.height),t.setAttribute("patternUnits",e.patternUnits),t.setAttribute("patternContentUnits",e.patternContentUnits),e.patternTransform||e.angle){var i=e.patternTransform?e.patternTransform+" ":"";i+=e.angle?"rotate("+e.angle+") ":"",t.setAttribute("patternTransform",i)}else t.removeAttribute("patternTransform");for(var s in this._shapes)this._shapes[s]._updateStyle()}}}),L.Map.include({_initDefRoot:function(){if(!this._defRoot)if("function"==typeof this.getRenderer){var t=this.getRenderer(this);this._defRoot=L.Pattern.prototype._createElement("defs"),t._container.appendChild(this._defRoot)}else this._pathRoot||this._initPathRoot(),this._defRoot=L.Pattern.prototype._createElement("defs"),this._pathRoot.appendChild(this._defRoot)}}),L.SVG?L.SVG.include({_superUpdateStyle:L.SVG.prototype._updateStyle,_updateStyle:function(t){this._superUpdateStyle(t),t.options.fill&&t.options.fillPattern&&t._path.setAttribute("fill","url(#"+L.stamp(t.options.fillPattern)+")")}}):L.Path.include({_superUpdateStyle:L.Path.prototype._updateStyle,_updateStyle:function(){this._superUpdateStyle(),this.options.fill&&this.options.fillPattern&&this._path.setAttribute("fill","url(#"+L.stamp(this.options.fillPattern)+")")}}),L.StripePattern=L.Pattern.extend({options:{weight:4,spaceWeight:4,color:"#000000",spaceColor:"#ffffff",opacity:1,spaceOpacity:0},_addShapes:function(){this._stripe=new L.PatternPath({stroke:!0,weight:this.options.weight,color:this.options.color,opacity:this.options.opacity}),this._space=new L.PatternPath({stroke:!0,weight:this.options.spaceWeight,color:this.options.spaceColor,opacity:this.options.spaceOpacity}),this.addShape(this._stripe),this.addShape(this._space),this._update()},_update:function(){this._stripe.options.d="M0 "+this._stripe.options.weight/2+" H "+this.options.width,this._space.options.d="M0 "+(this._stripe.options.weight+this._space.options.weight/2)+" H "+this.options.width},setStyle:L.Pattern.prototype.setStyle}),L.stripePattern=function(t){return new L.StripePattern(t)},L.PatternShape=L.Class.extend({options:{stroke:!0,color:"#3388ff",weight:3,opacity:1,lineCap:"round",lineJoin:"round",fillOpacity:.2,fillRule:"evenodd"},initialize:function(t){L.setOptions(this,t)},onAdd:function(t){this._pattern=t,this._pattern._dom&&(this._initDom(),this._addDom())},addTo:function(t){return t.addShape(this),this},redraw:function(){return this._pattern&&this._updateShape(),this},setStyle:function(t){return L.setOptions(this,t),this._pattern&&this._updateStyle(),this},setShape:function(t){this.options=L.extend({},this.options,t),this._updateShape()}}),L.Pattern.include({addShape:function(t){var e=L.stamp(t);return this._shapes[e]?t:(this._shapes[e]=t,t.onAdd(this),void 0)}}),L.PatternShape.SVG_NS="http://www.w3.org/2000/svg",L.PatternShape=L.PatternShape.extend({_createElement:function(t){return e.createElementNS(L.PatternShape.SVG_NS,t)},_initDom:L.Util.falseFn,_updateShape:L.Util.falseFn,_initDomElement:function(t){this._dom=this._createElement(t),this.options.className&&L.DomUtil.addClass(this._dom,this.options.className),this._updateStyle()},_addDom:function(){this._pattern._dom.appendChild(this._dom)},_updateStyle:function(){var t=this._dom,e=this.options;t&&(e.stroke?(t.setAttribute("stroke",e.color),t.setAttribute("stroke-opacity",e.opacity),t.setAttribute("stroke-width",e.weight),t.setAttribute("stroke-linecap",e.lineCap),t.setAttribute("stroke-linejoin",e.lineJoin),e.dashArray?t.setAttribute("stroke-dasharray",e.dashArray):t.removeAttribute("stroke-dasharray"),e.dashOffset?t.setAttribute("stroke-dashoffset",e.dashOffset):t.removeAttribute("stroke-dashoffset")):t.setAttribute("stroke","none"),e.fill?(e.fillPattern?t.setAttribute("fill","url(#"+L.stamp(e.fillPattern)+")"):t.setAttribute("fill",e.fillColor||e.color),t.setAttribute("fill-opacity",e.fillOpacity),t.setAttribute("fill-rule",e.fillRule||"evenodd")):t.setAttribute("fill","none"),t.setAttribute("pointer-events",e.pointerEvents||(e.interactive?"visiblePainted":"none")))}}),L.PatternPath=L.PatternShape.extend({_initDom:function(){this._initDomElement("path")},_updateShape:function(){this._dom&&this._dom.setAttribute("d",this.options.d)}}),L.PatternCircle=L.PatternShape.extend({options:{x:0,y:0,radius:0},_initDom:function(){this._initDomElement("circle")},_updateShape:function(){this._dom&&(this._dom.setAttribute("cx",this.options.x),this._dom.setAttribute("cy",this.options.y),this._dom.setAttribute("r",this.options.radius))}}),L.PatternRect=L.PatternShape.extend({options:{x:0,y:0,width:10,height:10},_initDom:function(){this._initDomElement("rect")},_updateShape:function(){this._dom&&(this._dom.setAttribute("x",this.options.x),this._dom.setAttribute("y",this.options.y),this._dom.setAttribute("width",this.options.width),this._dom.setAttribute("height",this.options.height),this.options.rx&&this._dom.setAttribute("rx",this.options.rx),this.options.ry&&this._dom.setAttribute("ry",this.options.ry))}})}(window,document); \ No newline at end of file diff --git a/src/deepfacility/viz/leaflet_template/js/leaflet.rotatedMarker.js b/src/deepfacility/viz/leaflet_template/js/leaflet.rotatedMarker.js new file mode 100644 index 0000000..9d9be5a --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/js/leaflet.rotatedMarker.js @@ -0,0 +1,51 @@ +(function() { + // save these original methods before they are overwritten + var proto_initIcon = L.Marker.prototype._initIcon; + var proto_setPos = L.Marker.prototype._setPos; + + var oldIE = (L.DomUtil.TRANSFORM === 'msTransform'); + + L.Marker.addInitHook(function () { + var iconOptions = this.options.icon && this.options.icon.options; + var iconAnchor = iconOptions && this.options.icon.options.iconAnchor; + if (iconAnchor) { + iconAnchor = (iconAnchor[0] + 'px ' + iconAnchor[1] + 'px'); + } + this.options.rotationOrigin = this.options.rotationOrigin || iconAnchor || 'center bottom' ; + this.options.rotationAngle = this.options.rotationAngle || 0; + }); + + L.Marker.include({ + _initIcon: function() { + proto_initIcon.call(this); + }, + + _setPos: function (pos) { + proto_setPos.call(this, pos); + + if(this.options.rotationAngle) { + this._icon.style[L.DomUtil.TRANSFORM+'Origin'] = this.options.rotationOrigin; + + if(oldIE) { + // for IE 9, use the 2D rotation + this._icon.style[L.DomUtil.TRANSFORM] = 'rotate(' + this.options.rotationAngle + 'deg)'; + } else { + // for modern browsers, prefer the 3D accelerated version + this._icon.style[L.DomUtil.TRANSFORM] += ' rotateZ(' + this.options.rotationAngle + 'deg)'; + } + } + }, + + setRotationAngle: function(angle) { + this.options.rotationAngle = angle; + this.update(); + return this; + }, + + setRotationOrigin: function(origin) { + this.options.rotationOrigin = origin; + this.update(); + return this; + } + }); +})(); diff --git a/src/deepfacility/viz/leaflet_template/js/leaflet.wms.js b/src/deepfacility/viz/leaflet_template/js/leaflet.wms.js new file mode 100644 index 0000000..e234ff6 --- /dev/null +++ b/src/deepfacility/viz/leaflet_template/js/leaflet.wms.js @@ -0,0 +1,495 @@ +/*! + * leaflet.wms.js + * A collection of Leaflet utilities for working with Web Mapping services. + * (c) 2014-2016, Houston Engineering, Inc. + * MIT License + */ + +(function (factory) { + // Module systems magic dance, Leaflet edition + if (typeof define === 'function' && define.amd) { + // AMD + define(['leaflet'], factory); + } else if (typeof module !== 'undefined') { + // Node/CommonJS + module.exports = factory(require('leaflet')); + } else { + // Browser globals + if (typeof this.L === 'undefined') + throw 'Leaflet must be loaded first!'; + // Namespace + this.L.WMS = this.L.wms = factory(this.L); + } +}(function (L) { + +// Module object +var wms = {}; + +// Quick shim for Object.keys() +if (!('keys' in Object)) { + Object.keys = function(obj) { + var result = []; + for (var i in obj) { + if (obj.hasOwnProperty(i)) { + result.push(i); + } + } + return result; + }; +} + +/* + * wms.Source + * The Source object manages a single WMS connection. Multiple "layers" can be + * created with the getLayer function, but a single request will be sent for + * each image update. Can be used in non-tiled "overlay" mode (default), or + * tiled mode, via an internal wms.Overlay or wms.TileLayer, respectively. + */ +wms.Source = L.Layer.extend({ + 'options': { + 'untiled': true, + 'identify': true + }, + + 'initialize': function(url, options) { + L.setOptions(this, options); + if (this.options.tiled) { + this.options.untiled = false; + } + this._url = url; + this._subLayers = {}; + this._overlay = this.createOverlay(this.options.untiled); + }, + + 'createOverlay': function(untiled) { + // Create overlay with all options other than untiled & identify + var overlayOptions = {}; + for (var opt in this.options) { + if (opt != 'untiled' && opt != 'identify') { + overlayOptions[opt] = this.options[opt]; + } + } + if (untiled) { + return wms.overlay(this._url, overlayOptions); + } else { + return wms.tileLayer(this._url, overlayOptions); + } + }, + + 'onAdd': function() { + this.refreshOverlay(); + }, + + 'getEvents': function() { + if (this.options.identify) { + return {'click': this.identify}; + } else { + return {}; + } + }, + + 'setOpacity': function(opacity) { + this.options.opacity = opacity; + if (this._overlay) { + this._overlay.setOpacity(opacity); + } + }, + + 'bringToBack': function() { + this.options.isBack = true; + if (this._overlay) { + this._overlay.bringToBack(); + } + }, + + 'bringToFront': function() { + this.options.isBack = false; + if (this._overlay) { + this._overlay.bringToFront(); + } + }, + + 'getLayer': function(name) { + return wms.layer(this, name); + }, + + 'addSubLayer': function(name) { + this._subLayers[name] = true; + this.refreshOverlay(); + }, + + 'removeSubLayer': function(name) { + delete this._subLayers[name]; + this.refreshOverlay(); + }, + + 'refreshOverlay': function() { + var subLayers = Object.keys(this._subLayers).join(","); + if (!this._map) { + return; + } + if (!subLayers) { + this._overlay.remove(); + } else { + this._overlay.setParams({'layers': subLayers}); + this._overlay.addTo(this._map); + } + }, + + 'identify': function(evt) { + // Identify map features in response to map clicks. To customize this + // behavior, create a class extending wms.Source and override one or + // more of the following hook functions. + + var layers = this.getIdentifyLayers(); + if (!layers.length) { + return; + } + this.getFeatureInfo( + evt.containerPoint, evt.latlng, layers, + this.showFeatureInfo + ); + }, + + 'getFeatureInfo': function(point, latlng, layers, callback) { + // Request WMS GetFeatureInfo and call callback with results + // (split from identify() to faciliate use outside of map events) + var params = this.getFeatureInfoParams(point, layers), + url = this._url + L.Util.getParamString(params, this._url); + + this.showWaiting(); + this.ajax(url, done); + + function done(result) { + this.hideWaiting(); + var text = this.parseFeatureInfo(result, url); + callback.call(this, latlng, text); + } + }, + + 'ajax': function(url, callback) { + ajax.call(this, url, callback); + }, + + 'getIdentifyLayers': function() { + // Hook to determine which layers to identify + if (this.options.identifyLayers) + return this.options.identifyLayers; + return Object.keys(this._subLayers); + }, + + 'getFeatureInfoParams': function(point, layers) { + // Hook to generate parameters for WMS service GetFeatureInfo request + var wmsParams, overlay; + if (this.options.untiled) { + // Use existing overlay + wmsParams = this._overlay.wmsParams; + } else { + // Create overlay instance to leverage updateWmsParams + overlay = this.createOverlay(true); + overlay.updateWmsParams(this._map); + wmsParams = overlay.wmsParams; + wmsParams.layers = layers.join(','); + } + var infoParams = { + 'request': 'GetFeatureInfo', + 'query_layers': layers.join(','), + 'X': Math.round(point.x), + 'Y': Math.round(point.y) + }; + return L.extend({}, wmsParams, infoParams); + }, + + 'parseFeatureInfo': function(result, url) { + // Hook to handle parsing AJAX response + if (result == "error") { + // AJAX failed, possibly due to CORS issues. + // Try loading content in