mirror of
https://github.com/maputnik/editor.git
synced 2026-09-01 09:57:27 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4eb1ece618 | |||
| 3ba3a20848 | |||
| 84adbe6eb2 |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"packages": [],
|
||||
"sandboxes": ["/"]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile
|
||||
|
||||
#
|
||||
#
|
||||
# COPIED FROM .gitignore , please keep it in sync
|
||||
#
|
||||
#
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directory
|
||||
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
|
||||
node_modules
|
||||
|
||||
# Ignore build files
|
||||
public
|
||||
/errorShots
|
||||
/old
|
||||
/build
|
||||
@@ -0,0 +1,14 @@
|
||||
root = true
|
||||
|
||||
# Unix-style newlines with a newline ending every file
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
|
||||
# Matches multiple files with brace expansion notation
|
||||
# Set default charset
|
||||
[*.{js,jsx,html,sass}]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
@@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
settings: { react: { version: '18.2' } },
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
custom: "https://maputnik.github.io/donate"
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve Maputnik
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- Thanks for your feedback! Please complete the following information: -->
|
||||
|
||||
**Maputnik version**:<!-- e.g v1.7.0, main -->
|
||||
**Browser**:
|
||||
**OS**:<!-- (Windows, macOS, Linux) -->
|
||||
|
||||
**Description of the bug**:
|
||||
|
||||
**Steps to reproduce the behavior**:
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Style file or style URL**:
|
||||
<!-- If applicable, attach a style file (zip) or provide a style URL. -->
|
||||
|
||||
**Screenshots**:
|
||||
<!-- If applicable, add screenshots to help explain your problem. -->
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: Other issue
|
||||
about: Feature request or other issue which is no bug report
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!-- Thanks for reaching out! If you are having general Maputnik mapping questions, please asking them at https://gis.stackexchange.com/ using the 'maputnik' tag https://gis.stackexchange.com/questions/tagged/maputnik and read https://gis.stackexchange.com/help/how-to-ask before you do so (please keep in mind that you're asking there in a general GIS forum, not a dedicated support channel) -->
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 2
|
||||
versioning-strategy: increase
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 2
|
||||
versioning-strategy: increase
|
||||
@@ -0,0 +1,140 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build-docker:
|
||||
name: build/docker
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: docker build -t docker.pkg.github.com/maputnik/editor/editor:main .
|
||||
|
||||
# build the editor
|
||||
build-node:
|
||||
name: "build/node@${{ matrix.node-version }} (${{ matrix.os }})"
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
node-version: [18.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
|
||||
|
||||
build-artifacts:
|
||||
name: "build/artifacts (${{ matrix.os }})"
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
node-version: [18.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- uses: actions/cache@v1
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm run build-storybook
|
||||
- name: artifacts/editor
|
||||
uses: actions/upload-artifact@v1
|
||||
with:
|
||||
name: editor
|
||||
path: dist
|
||||
- name: artifacts/storybook
|
||||
uses: actions/upload-artifact@v1
|
||||
with:
|
||||
name: storybook
|
||||
path: build/storybook
|
||||
|
||||
# Build and upload desktop CLI artifacts
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ^1.19.x
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: maputnik/desktop
|
||||
ref: master
|
||||
path: ./src/github.com/maputnik/desktop/
|
||||
|
||||
- name: Make
|
||||
run: cd src/github.com/maputnik/desktop/ && make
|
||||
|
||||
- name: Artifacts/linux
|
||||
uses: actions/upload-artifact@v1
|
||||
with:
|
||||
name: maputnik-linux
|
||||
path: ./src/github.com/maputnik/desktop/bin/linux/
|
||||
|
||||
- name: Artifacts/darwin
|
||||
uses: actions/upload-artifact@v1
|
||||
with:
|
||||
name: maputnik-darwin
|
||||
path: ./src/github.com/maputnik/desktop/bin/darwin/
|
||||
|
||||
- name: Artifacts/windows
|
||||
uses: actions/upload-artifact@v1
|
||||
with:
|
||||
name: maputnik-windows
|
||||
path: ./src/github.com/maputnik/desktop/bin/windows/
|
||||
|
||||
cypress-run:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
browser: [chrome, firefox]
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- run: npm ci
|
||||
- name: Cypress run
|
||||
uses: cypress-io/github-action@v6
|
||||
with:
|
||||
build: npm run build
|
||||
start: npm run start
|
||||
browser: ${{ matrix.browser }}
|
||||
@@ -0,0 +1,26 @@
|
||||
name: deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
# publish docker to GitHub registry
|
||||
deploy-docker:
|
||||
name: deploy/docker
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u orangemug --password-stdin
|
||||
- run: docker build -t docker.pkg.github.com/maputnik/editor/editor:main .
|
||||
- run: docker push docker.pkg.github.com/maputnik/editor/editor:main
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directory
|
||||
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
|
||||
node_modules
|
||||
|
||||
# Ignore build files
|
||||
public
|
||||
/errorShots
|
||||
/old
|
||||
/build
|
||||
/cypress/screenshots
|
||||
/dist/
|
||||
@@ -0,0 +1,9 @@
|
||||
const config = {
|
||||
stories: ['../stories/**/*.stories.jsx'],
|
||||
addons: ['@storybook/addon-actions', '@storybook/addon-links', '@storybook/addon-a11y/register', '@storybook/addon-storysource'],
|
||||
framework: {
|
||||
name: '@storybook/react-vite',
|
||||
options: {}
|
||||
}
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { addons } from '@storybook/addons';
|
||||
import { themes } from '@storybook/theming';
|
||||
import theme from './maputnik.theme';
|
||||
|
||||
addons.setConfig({
|
||||
theme: theme,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { create } from '@storybook/theming/create';
|
||||
|
||||
export default create({
|
||||
base: 'light',
|
||||
|
||||
brandTitle: 'Maputnik',
|
||||
brandUrl: 'https://github.com/maputnik/editor',
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"labels": {
|
||||
"bug": 5,
|
||||
"maintenance": 3,
|
||||
"mentioned in the 1st survey": 2
|
||||
},
|
||||
"reactions": {
|
||||
"+1": 2,
|
||||
"-1": -1,
|
||||
"laugh": 1,
|
||||
"hooray": 2,
|
||||
"confused": 1,
|
||||
"heart": 2
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
FROM node:18 as builder
|
||||
WORKDIR /maputnik
|
||||
|
||||
# Only copy package.json to prevent npm install from running on every build
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install
|
||||
|
||||
# Build maputnik
|
||||
# TODO: we should also do a npm run test here (needs more dependencies)
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
#---------------------------------------------------------------------------
|
||||
|
||||
# Create a clean python-based image with just the build results
|
||||
FROM python:3-slim
|
||||
WORKDIR /maputnik
|
||||
|
||||
COPY --from=builder /maputnik/dist .
|
||||
|
||||
EXPOSE 8888
|
||||
CMD python -m http.server 8888
|
||||
@@ -0,0 +1,22 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Lukas Martinelli
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<img width="200" alt="Maputnik logo" src="https://cdn.jsdelivr.net/gh/maputnik/design/logos/logo-color.png" />
|
||||
|
||||
# Maputnik
|
||||
[][github-action-ci]
|
||||
[][license]
|
||||
|
||||
[github-action-ci]: https://github.com/maputnik/editor/actions?query=workflow%3Aci
|
||||
[license]: https://tldrlegal.com/license/mit-license
|
||||
|
||||
A free and open visual editor for the [MapLibre GL styles](https://maplibre.org/maplibre-style-spec/)
|
||||
targeted at developers and map designers.
|
||||
|
||||
|
||||
## Usage
|
||||
|
||||
- :link: Design your maps online at **<https://maputnik.github.io/editor/>** (all in local storage)
|
||||
- :link: Use the [Maputnik CLI](https://github.com/maputnik/editor/wiki/Maputnik-CLI) for local style development
|
||||
- In a Docker, run this command and browse to http://localhost:8888, Ctrl+C to stop the server.
|
||||
|
||||
```bash
|
||||
docker run -it --rm -p 8888:8888 maputnik/editor
|
||||
```
|
||||
|
||||
## Donations
|
||||
Mapbox has built one of the best and most amazing OSS ecosystems. A key component to ensure its longevity and independence is an OSS map designer.
|
||||
If you or your organisation has seen value from Maputnik, please consider donating at <https://maputnik.github.io/donate>
|
||||
|
||||
## Documentation
|
||||
|
||||
The documentation can be found in the [Wiki](https://github.com/maputnik/editor/wiki). You are welcome to collaborate!
|
||||
|
||||
- :link: **Study the [Maputnik Wiki](https://github.com/maputnik/editor/wiki)**
|
||||
- :video_camera: Design a map from Scratch https://youtu.be/XoDh0gEnBQo
|
||||
|
||||
[](https://youtu.be/XoDh0gEnBQo)
|
||||
|
||||
## Develop
|
||||
|
||||
Maputnik is written in ES6 and is using [React](https://github.com/facebook/react) and [MapLibre GL JS](https://maplibre.org/projects/maplibre-gl-js/).
|
||||
|
||||
We ensure building and developing Maputnik works with the [current active LTS Node.js version and above](https://github.com/nodejs/Release#release-schedule).
|
||||
|
||||
Install the deps, start the dev server and open the web browser on `http://localhost:8888/`.
|
||||
|
||||
```bash
|
||||
# install dependencies
|
||||
npm install
|
||||
# start dev server
|
||||
npm run start
|
||||
```
|
||||
|
||||
If you want Maputnik to be accessible externally use the [`--host` option](https://vitejs.dev/config/server-options.html#server-host):
|
||||
|
||||
```bash
|
||||
# start externally accessible dev server
|
||||
npm run start -- --host 0.0.0.0
|
||||
```
|
||||
|
||||
The build process will watch for changes to the filesystem, rebuild and autoreload the editor.
|
||||
|
||||
```
|
||||
npm run build
|
||||
```
|
||||
|
||||
Lint the JavaScript code.
|
||||
|
||||
```
|
||||
# run linter
|
||||
npm run lint
|
||||
npm run lint-styles
|
||||
```
|
||||
|
||||
|
||||
## Tests
|
||||
For E2E testing we use [Cypress](https://www.cypress.io/)
|
||||
|
||||
[Cypress](https://www.cypress.io/) doesn't starts a server so you'll need to start one manually by running `npm run start`.
|
||||
|
||||
Now open a terminal and run the following using *chrome*:
|
||||
|
||||
```
|
||||
npm run test
|
||||
```
|
||||
or *firefox*:
|
||||
```
|
||||
npm run test -- --browser firefox
|
||||
```
|
||||
|
||||
See the following docs for more info: (Launching Browsers)[https://docs.cypress.io/guides/guides/launching-browsers]
|
||||
|
||||
You can also see the tests as they run or select which suites to run by executing:
|
||||
|
||||
```
|
||||
npm run cy:open
|
||||
```
|
||||
|
||||
|
||||
## Related Projects
|
||||
|
||||
- [maputnik-dev-server](https://github.com/nycplanning/labs-maputnik-dev-server) - An express.js server that allows for quickly loading the style from any mapboxGL map into mapuntnik.
|
||||
|
||||
## Sponsors
|
||||
|
||||
Thanks to the supporters of the **[Kickstarter campaign](https://www.kickstarter.com/projects/174808720/maputnik-visual-map-editor-for-mapbox-gl)**. This project would not be possible without these commercial and individual sponsors.
|
||||
|
||||
### Gold
|
||||
|
||||
- [Wemap](https://getwemap.com/)
|
||||
- [Orbicon Informatik](https://www.orbiconinformatik.dk/)
|
||||
- [Terranodo](http://terranodo.io/)
|
||||
|
||||
<a href="https://getwemap.com/">
|
||||
<img width="33%" alt="Wemap" style="display:inline" src="https://cdn.jsdelivr.net/gh/maputnik/editor@1.5.0/media/sponsors/wemap.jpg" />
|
||||
</a>
|
||||
<a href="http://terranodo.io/">
|
||||
<img width="33%" alt="Terranodo" style="display:inline" src="https://cdn.jsdelivr.net/gh/maputnik/editor@1.5.0/media/sponsors/terranodo.png" />
|
||||
</a>
|
||||
<a href="https://www.orbiconinformatik.dk/">
|
||||
<img width="32%" alt="Terranodo" style="display:inline" src="https://cdn.jsdelivr.net/gh/maputnik/editor@1.5.0/media/sponsors/orbicon_informatik.png" />
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
|
||||
### Silver
|
||||
|
||||
- [Klokan Technologies](https://www.klokantech.com/)
|
||||
- [Geofabrik](http://www.geofabrik.de/)
|
||||
- [Dreipol](https://www.dreipol.ch/)
|
||||
|
||||
<a href="https://www.klokantech.com/">
|
||||
<img width="18%" alt="Klokan Technologies" style="display:inline-block" src="https://cdn.jsdelivr.net/gh/maputnik/editor@1.5.0/media/sponsors/klokantech.png" />
|
||||
</a>
|
||||
<a href="http://www.geofabrik.de/">
|
||||
<img width="18%" alt="Geofabrik" style="display:inline-block" src="https://cdn.jsdelivr.net/gh/maputnik/editor@1.5.0/media/sponsors/geofabrik.png" />
|
||||
</a>
|
||||
<a href="https://www.dreipol.ch/">
|
||||
<img width="18%" alt="Dreipol" style="display:inline-block" src="https://cdn.jsdelivr.net/gh/maputnik/editor@1.5.0/media/sponsors/dreipol.png" />
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
|
||||
### Individuals
|
||||
|
||||
**Influential Stakeholder**
|
||||
|
||||
Alan McConchie, Odi, Mats Norén, Uli [geOps](http://geops.ch/), Helge Fahrnberger ([Toursprung](http://www.toursprung.com/)), Kirusanth Poopalasingam
|
||||
|
||||
**Stakeholder**
|
||||
|
||||
Brian Flood, Vasile Coțovanu, Andreas Kalkbrenner, Christian Mäder, Gregor Wassmann, Lee Armstrong, Rafel, Jon Burgess, Lukas Lehmann, Joachim Ungar, Alois Ackermann, Zsolt Ero, Jordan Meek
|
||||
|
||||
**Supporter**
|
||||
|
||||
Sina Martinelli, Nicholas Doiron, Neil Cawse, Urs42, Benedikt Groß, Manuel Roth, Janko Mihelić, Moritz Stefaner, Sebastian Ahoi, Juerg Uhlmann, Tom Wider, Nadia Panchaud, Oliver Snowden, Stephan Heuel, Tobin Bradley, Adrian Herzog, Antti Lehto, Pascal Mages, Marc Gehling, Imre Samu, Lauri K., Visahavel Parthasarathy, Christophe Waterlot-Buisine, Max Galka, ubahnverleih, Wouter van Dam, Jakob Lobensteiner, Samuel Kurath, Brian Bancroft
|
||||
|
||||
## License
|
||||
|
||||
Maputnik is [licensed under MIT](LICENSE) and is Copyright (c) Lukas Martinelli and contributors.
|
||||
|
||||
**Disclaimer** This is an independent style editor.
|
||||
As contributor please take extra care of not violating any Mapbox trademarks. Do not get inspired by Mapbox Studio and make your own decisions for a good style editor.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +0,0 @@
|
||||
var e={"Drag and drop a style JSON file here or click to browse":`Drag and drop a style JSON file here or click to browse`};export{e as default};
|
||||
//# sourceMappingURL=translation-kg7OItc9.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"translation-kg7OItc9.js","names":[],"sources":["../../src/locales/en/translation.json"],"sourcesContent":["{\n \"Drag and drop a style JSON file here or click to browse\": \"Drag and drop a style JSON file here or click to browse\"\n}\n"],"mappings":""}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "cypress";
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
setupNodeEvents(on, config) {
|
||||
// implement node event listeners here
|
||||
},
|
||||
retries: {
|
||||
runMode: 2,
|
||||
openMode: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import MaputnikDriver from "./driver";
|
||||
|
||||
describe("accessibility", () => {
|
||||
let { beforeAndAfter, given, when, get, should } = new MaputnikDriver();
|
||||
beforeAndAfter();
|
||||
|
||||
describe("skip links", () => {
|
||||
beforeEach(() => {
|
||||
when.setStyle("layer");
|
||||
});
|
||||
|
||||
it("skip link to layer list", () => {
|
||||
const selector = "root:skip:layer-list";
|
||||
should.exist(selector);
|
||||
when.tab();
|
||||
should.beFocused(selector);
|
||||
when.click(selector);
|
||||
should.beFocused("skip-target-layer-list");
|
||||
});
|
||||
|
||||
it("skip link to layer editor", () => {
|
||||
const selector = "root:skip:layer-editor";
|
||||
should.exist(selector);
|
||||
when.tab().tab();
|
||||
should.beFocused(selector);
|
||||
when.click(selector);
|
||||
should.beFocused("skip-target-layer-editor");
|
||||
});
|
||||
|
||||
it("skip link to map view", () => {
|
||||
const selector = "root:skip:map-view";
|
||||
should.exist(selector);
|
||||
when.tab().tab().tab();
|
||||
should.beFocused(selector);
|
||||
when.click(selector);
|
||||
should.canvasBeFocused();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import { CypressHelper } from "@shellygo/cypress-test-utils";
|
||||
import { v1 as uuid } from "uuid";
|
||||
export default class MaputnikDriver {
|
||||
private helper = new CypressHelper({ defaultDataAttribute: "data-wd-key" });
|
||||
public beforeAndAfter = () => {
|
||||
beforeEach(() => {
|
||||
this.given.setupInterception();
|
||||
this.when.setStyle("both");
|
||||
});
|
||||
};
|
||||
|
||||
public given = {
|
||||
setupInterception: () => {
|
||||
cy.intercept("GET", "http://localhost:8888/example-style.json", {
|
||||
fixture: "example-style.json",
|
||||
}).as("example-style.json");
|
||||
cy.intercept("GET", "http://localhost:8888/example-layer-style.json", {
|
||||
fixture: "example-layer-style.json",
|
||||
});
|
||||
cy.intercept("GET", "http://localhost:8888/geojson-style.json", {
|
||||
fixture: "geojson-style.json",
|
||||
});
|
||||
cy.intercept("GET", "http://localhost:8888/raster-style.json", {
|
||||
fixture: "raster-style.json",
|
||||
});
|
||||
cy.intercept("GET", "http://localhost:8888/geojson-raster-style.json", {
|
||||
fixture: "geojson-raster-style.json",
|
||||
});
|
||||
cy.intercept({ method: "GET", url: "*example.local/*" }, []);
|
||||
cy.intercept({ method: "GET", url: "*example.com/*" }, []);
|
||||
},
|
||||
};
|
||||
|
||||
public when = {
|
||||
within: (selector: string, fn: () => void) => {
|
||||
this.helper.when.within(fn, selector);
|
||||
},
|
||||
tab: () => cy.get("body").tab(),
|
||||
waitForExampleFileRequset: () => {
|
||||
this.helper.when.waitForResponse("example-style.json");
|
||||
},
|
||||
chooseExampleFile: () => {
|
||||
cy.get("input[type='file']").selectFile(
|
||||
"cypress/fixtures/example-style.json",
|
||||
{ force: true }
|
||||
);
|
||||
},
|
||||
setStyle: (
|
||||
styleProperties: "geojson" | "raster" | "both" | "layer" | "",
|
||||
zoom?: number
|
||||
) => {
|
||||
let url = "?debug";
|
||||
switch (styleProperties) {
|
||||
case "geojson":
|
||||
url += "&style=http://localhost:8888/geojson-style.json";
|
||||
break;
|
||||
case "raster":
|
||||
url += "&style=http://localhost:8888/raster-style.json";
|
||||
break;
|
||||
case "both":
|
||||
url += "&style=http://localhost:8888/geojson-raster-style.json";
|
||||
break;
|
||||
case "layer":
|
||||
url += "&style=http://localhost:8888/example-layer-style.json";
|
||||
break;
|
||||
}
|
||||
if (zoom) {
|
||||
url += "#" + zoom + "/41.3805/2.1635";
|
||||
}
|
||||
cy.visit("http://localhost:8888/" + url);
|
||||
if (styleProperties) {
|
||||
cy.on("window:confirm", () => true);
|
||||
}
|
||||
cy.get(".maputnik-toolbar-link").should("be.visible");
|
||||
},
|
||||
fillLayersModal: (opts: {type: string, layer?: string, id?: string}) => {
|
||||
var type = opts.type;
|
||||
var layer = opts.layer;
|
||||
var id;
|
||||
if (opts.id) {
|
||||
id = opts.id;
|
||||
} else {
|
||||
id = `${type}:${uuid()}`;
|
||||
}
|
||||
|
||||
cy.get(
|
||||
this.get.dataAttribute("add-layer.layer-type", "select")
|
||||
).select(type);
|
||||
cy.get(this.get.dataAttribute("add-layer.layer-id", "input")).type(id);
|
||||
if (layer) {
|
||||
cy.get(
|
||||
this.get.dataAttribute("add-layer.layer-source-block", "input")
|
||||
).type(layer);
|
||||
}
|
||||
this.when.click("add-layer");
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
typeKeys: (keys: string) => {
|
||||
cy.get("body").type(keys);
|
||||
},
|
||||
|
||||
click: (selector: string) => {
|
||||
this.helper.when.click(selector);
|
||||
},
|
||||
|
||||
clickZoomin: () => {
|
||||
cy.get(".maplibregl-ctrl-zoom-in").click();
|
||||
},
|
||||
|
||||
selectWithin: (selector: string, value: string) => {
|
||||
this.when.within(selector, () => {
|
||||
cy.get("select").select(value);
|
||||
});
|
||||
},
|
||||
|
||||
select: (selector: string, value: string) => {
|
||||
this.helper.get.element(selector).select(value);
|
||||
},
|
||||
|
||||
focus: (selector: string) => {
|
||||
this.helper.when.focus(selector);
|
||||
},
|
||||
|
||||
setValue: (selector: string, text: string) => {
|
||||
cy.get(selector).clear().type(text, { parseSpecialCharSequences: false });
|
||||
},
|
||||
|
||||
closeModal: (key: string) => {
|
||||
this.helper.when.waitUntil(() => this.helper.get.element(key));
|
||||
this.when.click(key + ".close-modal");
|
||||
},
|
||||
|
||||
openLayersModal: () => {
|
||||
this.helper.when.click("layer-list:add-layer");
|
||||
|
||||
cy.get(this.get.dataAttribute("modal:add-layer")).should("exist");
|
||||
cy.get(this.get.dataAttribute("modal:add-layer")).should("be.visible");
|
||||
},
|
||||
};
|
||||
|
||||
public get = {
|
||||
isMac: () => {
|
||||
return Cypress.platform === "darwin";
|
||||
},
|
||||
styleFromWindow: (win: Window) => {
|
||||
const styleId = win.localStorage.getItem("maputnik:latest_style");
|
||||
const styleItem = win.localStorage.getItem(`maputnik:style:${styleId}`);
|
||||
const obj = JSON.parse(styleItem || "");
|
||||
return obj;
|
||||
},
|
||||
exampleFileUrl: () => {
|
||||
return "http://localhost:8888/example-style.json";
|
||||
},
|
||||
dataAttribute: (key: string, selector?: string): string => {
|
||||
return `*[data-wd-key='${key}'] ${selector || ""}`;
|
||||
},
|
||||
};
|
||||
|
||||
public should = {
|
||||
canvasBeFocused: () => {
|
||||
this.when.within("maplibre:map", () => {
|
||||
cy.get("canvas").should("be.focused");
|
||||
});
|
||||
},
|
||||
notExist: (selector: string) => {
|
||||
cy.get(selector).should("not.exist");
|
||||
},
|
||||
beFocused: (selector: string) => {
|
||||
this.helper.get.element(selector).should("have.focus");
|
||||
},
|
||||
|
||||
notBeFocused: (selector: string) => {
|
||||
this.helper.get.element(selector).should("not.have.focus");
|
||||
},
|
||||
|
||||
beVisible: (selector: string) => {
|
||||
this.helper.get.element(selector).should("be.visible");
|
||||
},
|
||||
|
||||
notBeVisible: (selector: string) => {
|
||||
this.helper.get.element(selector).should("not.be.visible");
|
||||
},
|
||||
|
||||
equalStyleStore: (getter: (obj: any) => any, styleObj: any) => {
|
||||
cy.window().then((win: any) => {
|
||||
const obj = this.get.styleFromWindow(win);
|
||||
assert.deepEqual(getter(obj), styleObj);
|
||||
});
|
||||
},
|
||||
|
||||
styleStoreEqualToExampleFileData: () => {
|
||||
cy.window().then((win: any) => {
|
||||
const obj = this.get.styleFromWindow(win);
|
||||
cy.fixture("example-style.json").should("deep.equal", obj);
|
||||
});
|
||||
},
|
||||
|
||||
exist: (selector: string) => {
|
||||
this.helper.get.element(selector).should("exist");
|
||||
},
|
||||
beSelected: (selector: string, value: string) => {
|
||||
this.helper.get.element(selector).find(`option[value="${value}"]`).should("be.selected");
|
||||
},
|
||||
containText: (selector: string, text: string) => {
|
||||
this.helper.get.element(selector).should("contain.text", text);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import MaputnikDriver from "./driver";
|
||||
|
||||
describe("history", () => {
|
||||
let { beforeAndAfter, given, when, get, should } = new MaputnikDriver();
|
||||
beforeAndAfter();
|
||||
|
||||
let undoKeyCombo: string;
|
||||
let redoKeyCombo: string;
|
||||
|
||||
before(() => {
|
||||
const isMac = get.isMac();
|
||||
undoKeyCombo = isMac ? "{meta}z" : "{ctrl}z";
|
||||
redoKeyCombo = isMac ? "{meta}{shift}z" : "{ctrl}y";
|
||||
});
|
||||
|
||||
it("undo/redo", () => {
|
||||
when.setStyle("geojson");
|
||||
when.openLayersModal();
|
||||
|
||||
should.equalStyleStore((a: any) => a.layers, []);
|
||||
|
||||
when.fillLayersModal({
|
||||
id: "step 1",
|
||||
type: "background",
|
||||
});
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: "step 1",
|
||||
type: "background",
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
when.openLayersModal();
|
||||
when.fillLayersModal({
|
||||
id: "step 2",
|
||||
type: "background",
|
||||
});
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: "step 1",
|
||||
type: "background",
|
||||
},
|
||||
{
|
||||
id: "step 2",
|
||||
type: "background",
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
when.typeKeys(undoKeyCombo);
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: "step 1",
|
||||
type: "background",
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
when.typeKeys(undoKeyCombo);
|
||||
should.equalStyleStore((a: any) => a.layers, []);
|
||||
|
||||
when.typeKeys(redoKeyCombo);
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: "step 1",
|
||||
type: "background",
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
when.typeKeys(redoKeyCombo);
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: "step 1",
|
||||
type: "background",
|
||||
},
|
||||
{
|
||||
id: "step 2",
|
||||
type: "background",
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import MaputnikDriver from "./driver";
|
||||
|
||||
describe("keyboard", () => {
|
||||
let { beforeAndAfter, given, when, get, should } = new MaputnikDriver();
|
||||
beforeAndAfter();
|
||||
describe("shortcuts", () => {
|
||||
beforeEach(() => {
|
||||
given.setupInterception();
|
||||
when.setStyle("");
|
||||
});
|
||||
|
||||
it("ESC should unfocus", () => {
|
||||
const targetSelector = "maputnik-select";
|
||||
when.focus(targetSelector);
|
||||
should.beFocused(targetSelector);
|
||||
|
||||
when.typeKeys("{esc}");
|
||||
expect(should.notBeFocused(targetSelector));
|
||||
});
|
||||
|
||||
it("'?' should show shortcuts modal", () => {
|
||||
when.typeKeys("?");
|
||||
should.beVisible("modal:shortcuts");
|
||||
});
|
||||
|
||||
it("'o' should show open modal", () => {
|
||||
when.typeKeys("o");
|
||||
should.beVisible("modal:open");
|
||||
});
|
||||
|
||||
it("'e' should show export modal", () => {
|
||||
when.typeKeys("e");
|
||||
should.beVisible("modal:export");
|
||||
});
|
||||
|
||||
it("'d' should show sources modal", () => {
|
||||
when.typeKeys("d");
|
||||
should.beVisible("modal:sources");
|
||||
});
|
||||
|
||||
it("'s' should show settings modal", () => {
|
||||
when.typeKeys("s");
|
||||
should.beVisible("modal:settings");
|
||||
});
|
||||
|
||||
it("'i' should change map to inspect mode", () => {
|
||||
when.typeKeys("i");
|
||||
should.beSelected("nav:inspect", "inspect");
|
||||
});
|
||||
|
||||
it("'m' should focus map", () => {
|
||||
when.typeKeys("m");
|
||||
should.canvasBeFocused();
|
||||
});
|
||||
|
||||
it("'!' should show debug modal", () => {
|
||||
when.typeKeys("!");
|
||||
should.beVisible("modal:debug");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,491 @@
|
||||
import { v1 as uuid } from "uuid";
|
||||
import MaputnikDriver from "./driver";
|
||||
|
||||
describe("layers", () => {
|
||||
let { beforeAndAfter, given, when, get, should } = new MaputnikDriver();
|
||||
beforeAndAfter();
|
||||
beforeEach(() => {
|
||||
when.setStyle("both");
|
||||
when.openLayersModal();
|
||||
});
|
||||
|
||||
describe("ops", () => {
|
||||
it("delete", () => {
|
||||
var id = when.fillLayersModal({
|
||||
type: "background",
|
||||
});
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: id,
|
||||
type: "background",
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
when.click("layer-list-item:" + id + ":delete");
|
||||
|
||||
should.equalStyleStore((a: any) => a.layers, []);
|
||||
});
|
||||
|
||||
it("duplicate", () => {
|
||||
var styleObj;
|
||||
var id = when.fillLayersModal({
|
||||
type: "background",
|
||||
});
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: id,
|
||||
type: "background",
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
when.click("layer-list-item:" + id + ":copy");
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: id + "-copy",
|
||||
type: "background",
|
||||
},
|
||||
{
|
||||
id: id,
|
||||
type: "background",
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("hide", () => {
|
||||
var styleObj;
|
||||
var id = when.fillLayersModal({
|
||||
type: "background",
|
||||
});
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: id,
|
||||
type: "background",
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
when.click("layer-list-item:" + id + ":toggle-visibility");
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: id,
|
||||
type: "background",
|
||||
layout: {
|
||||
visibility: "none",
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
when.click("layer-list-item:" + id + ":toggle-visibility");
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: id,
|
||||
type: "background",
|
||||
layout: {
|
||||
visibility: "visible",
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("background", () => {
|
||||
it("add", () => {
|
||||
var id = when.fillLayersModal({
|
||||
type: "background",
|
||||
});
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: id,
|
||||
type: "background",
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
describe("modify", () => {
|
||||
function createBackground() {
|
||||
// Setup
|
||||
var id = uuid();
|
||||
|
||||
when.selectWithin("add-layer.layer-type", "background");
|
||||
when.setValue(
|
||||
get.dataAttribute("add-layer.layer-id", "input"),
|
||||
"background:" + id
|
||||
);
|
||||
|
||||
when.click("add-layer");
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: "background:" + id,
|
||||
type: "background",
|
||||
},
|
||||
]
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
// ====> THESE SHOULD BE FROM THE SPEC
|
||||
describe("layer", () => {
|
||||
it("expand/collapse");
|
||||
it("id", () => {
|
||||
var bgId = createBackground();
|
||||
|
||||
when.click("layer-list-item:background:" + bgId);
|
||||
|
||||
var id = uuid();
|
||||
when.setValue(
|
||||
get.dataAttribute("layer-editor.layer-id", "input"),
|
||||
"foobar:" + id
|
||||
);
|
||||
when.click("min-zoom");
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: "foobar:" + id,
|
||||
type: "background",
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("min-zoom", () => {
|
||||
var bgId = createBackground();
|
||||
|
||||
when.click("layer-list-item:background:" + bgId);
|
||||
when.setValue(
|
||||
get.dataAttribute("min-zoom", 'input[type="text"]'),
|
||||
"1"
|
||||
);
|
||||
|
||||
when.click("layer-editor.layer-id");
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: "background:" + bgId,
|
||||
type: "background",
|
||||
minzoom: 1,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// AND RESET!
|
||||
// driver.setValue(driver.get.dataAttribute("min-zoom", "input"), "")
|
||||
// driver.click(driver.get.dataAttribute("max-zoom", "input"));
|
||||
|
||||
// driver.isStyleStoreEqual((a: any) => a.layers, [
|
||||
// {
|
||||
// "id": 'background:'+bgId,
|
||||
// "type": 'background'
|
||||
// }
|
||||
// ]);
|
||||
});
|
||||
|
||||
it("max-zoom", () => {
|
||||
var bgId = createBackground();
|
||||
|
||||
when.click("layer-list-item:background:" + bgId);
|
||||
when.setValue(
|
||||
get.dataAttribute("max-zoom", 'input[type="text"]'),
|
||||
"1"
|
||||
);
|
||||
|
||||
when.click("layer-editor.layer-id");
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: "background:" + bgId,
|
||||
type: "background",
|
||||
maxzoom: 1,
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("comments", () => {
|
||||
var bgId = createBackground();
|
||||
var id = uuid();
|
||||
|
||||
when.click("layer-list-item:background:" + bgId);
|
||||
when.setValue(get.dataAttribute("layer-comment", "textarea"), id);
|
||||
|
||||
when.click("layer-editor.layer-id");
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: "background:" + bgId,
|
||||
type: "background",
|
||||
metadata: {
|
||||
"maputnik:comment": id,
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// Unset it again.
|
||||
// TODO: This fails
|
||||
// driver.setValue(driver.getDataAttribute("layer-comment", "textarea"), "");
|
||||
// driver.click(driver.getDataAttribute("min-zoom", "input"));
|
||||
|
||||
// driver.isStyleStoreEqual((a: any) => a.layers, [
|
||||
// {
|
||||
// "id": 'background:'+bgId,
|
||||
// "type": 'background'
|
||||
// }
|
||||
// ]);
|
||||
});
|
||||
|
||||
it("color", () => {
|
||||
var bgId = createBackground();
|
||||
|
||||
when.click("layer-list-item:background:" + bgId);
|
||||
|
||||
when.click("spec-field:background-color");
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: "background:" + bgId,
|
||||
type: "background",
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filter", () => {
|
||||
it("expand/collapse");
|
||||
it("compound filter");
|
||||
});
|
||||
|
||||
describe("paint", () => {
|
||||
it("expand/collapse");
|
||||
it("color");
|
||||
it("pattern");
|
||||
it("opacity");
|
||||
});
|
||||
// <=====
|
||||
|
||||
describe("json-editor", () => {
|
||||
it("expand/collapse");
|
||||
it("modify");
|
||||
|
||||
// TODO
|
||||
it.skip("parse error", () => {
|
||||
var bgId = createBackground();
|
||||
|
||||
when.click("layer-list-item:background:" + bgId);
|
||||
|
||||
var errorSelector = ".CodeMirror-lint-marker-error";
|
||||
should.notExist(errorSelector);
|
||||
|
||||
when.click(".CodeMirror");
|
||||
when.typeKeys(
|
||||
"\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013 {"
|
||||
);
|
||||
should.exist(errorSelector);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fill", () => {
|
||||
it("add", () => {
|
||||
var id = when.fillLayersModal({
|
||||
type: "fill",
|
||||
layer: "example",
|
||||
});
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: id,
|
||||
type: "fill",
|
||||
source: "example",
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
// TODO: Change source
|
||||
it("change source");
|
||||
});
|
||||
|
||||
describe("line", () => {
|
||||
it("add", () => {
|
||||
var id = when.fillLayersModal({
|
||||
type: "line",
|
||||
layer: "example",
|
||||
});
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: id,
|
||||
type: "line",
|
||||
source: "example",
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("groups", () => {
|
||||
// TODO
|
||||
// Click each of the layer groups.
|
||||
});
|
||||
});
|
||||
|
||||
describe("symbol", () => {
|
||||
it("add", () => {
|
||||
var id = when.fillLayersModal({
|
||||
type: "symbol",
|
||||
layer: "example",
|
||||
});
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: id,
|
||||
type: "symbol",
|
||||
source: "example",
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("raster", () => {
|
||||
it("add", () => {
|
||||
var id = when.fillLayersModal({
|
||||
type: "raster",
|
||||
layer: "raster",
|
||||
});
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: id,
|
||||
type: "raster",
|
||||
source: "raster",
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("circle", () => {
|
||||
it("add", () => {
|
||||
var id = when.fillLayersModal({
|
||||
type: "circle",
|
||||
layer: "example",
|
||||
});
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: id,
|
||||
type: "circle",
|
||||
source: "example",
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fill extrusion", () => {
|
||||
it("add", () => {
|
||||
var id = when.fillLayersModal({
|
||||
type: "fill-extrusion",
|
||||
layer: "example",
|
||||
});
|
||||
|
||||
should.equalStyleStore(
|
||||
(a: any) => a.layers,
|
||||
[
|
||||
{
|
||||
id: id,
|
||||
type: "fill-extrusion",
|
||||
source: "example",
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groups", () => {
|
||||
it("simple", () => {
|
||||
when.setStyle("geojson");
|
||||
|
||||
when.openLayersModal();
|
||||
when.fillLayersModal({
|
||||
id: "foo",
|
||||
type: "background",
|
||||
});
|
||||
|
||||
when.openLayersModal();
|
||||
when.fillLayersModal({
|
||||
id: "foo_bar",
|
||||
type: "background",
|
||||
});
|
||||
|
||||
when.openLayersModal();
|
||||
when.fillLayersModal({
|
||||
id: "foo_bar_baz",
|
||||
type: "background",
|
||||
});
|
||||
|
||||
should.beVisible("layer-list-item:foo");
|
||||
|
||||
should.notBeVisible("layer-list-item:foo_bar");
|
||||
should.notBeVisible("layer-list-item:foo_bar_baz");
|
||||
|
||||
when.click("layer-list-group:foo-0");
|
||||
|
||||
should.beVisible("layer-list-item:foo");
|
||||
should.beVisible("layer-list-item:foo_bar");
|
||||
should.beVisible("layer-list-item:foo_bar_baz");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import MaputnikDriver from "./driver";
|
||||
|
||||
describe("map", () => {
|
||||
let { beforeAndAfter, given, when, get, should } = new MaputnikDriver();
|
||||
beforeAndAfter();
|
||||
describe("zoom level", () => {
|
||||
it("via url", () => {
|
||||
var zoomLevel = 12.37;
|
||||
when.setStyle("geojson", zoomLevel);
|
||||
should.beVisible("maplibre:ctrl-zoom");
|
||||
should.containText("maplibre:ctrl-zoom", "Zoom: " + zoomLevel);
|
||||
});
|
||||
|
||||
it("via map controls", () => {
|
||||
var zoomLevel = 12.37;
|
||||
when.setStyle("geojson", zoomLevel);
|
||||
|
||||
should.beVisible("maplibre:ctrl-zoom");
|
||||
when.clickZoomin();
|
||||
should.containText("maplibre:ctrl-zoom", "Zoom: "+(zoomLevel + 1));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import MaputnikDriver from "./driver";
|
||||
|
||||
describe("modals", () => {
|
||||
let { beforeAndAfter, given, when, get, should } = new MaputnikDriver();
|
||||
beforeAndAfter();
|
||||
beforeEach(() => {
|
||||
when.setStyle("");
|
||||
});
|
||||
describe("open", () => {
|
||||
beforeEach(() => {
|
||||
when.click("nav:open");
|
||||
});
|
||||
|
||||
it("close", () => {
|
||||
when.closeModal("modal:open");
|
||||
should.notExist("modal:open");
|
||||
});
|
||||
|
||||
it.skip("upload", () => {
|
||||
// HM: I was not able to make the following choose file actually to select a file and close the modal...
|
||||
when.chooseExampleFile();
|
||||
|
||||
should.styleStoreEqualToExampleFileData();
|
||||
});
|
||||
|
||||
it("load from url", () => {
|
||||
var styleFileUrl = get.exampleFileUrl();
|
||||
|
||||
when.setValue(get.dataAttribute("modal:open.url.input"), styleFileUrl);
|
||||
when.click("modal:open.url.button");
|
||||
when.waitForExampleFileRequset();
|
||||
|
||||
should.styleStoreEqualToExampleFileData();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shortcuts", () => {
|
||||
it("open/close", () => {
|
||||
when.setStyle("");
|
||||
when.typeKeys("?");
|
||||
when.closeModal("modal:shortcuts");
|
||||
should.notExist("modal:shortcuts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("export", () => {
|
||||
beforeEach(() => {
|
||||
when.click("nav:export");
|
||||
});
|
||||
|
||||
it("close", () => {
|
||||
when.closeModal("modal:export");
|
||||
should.notExist("modal:export");
|
||||
});
|
||||
|
||||
// TODO: Work out how to download a file and check the contents
|
||||
it("download");
|
||||
});
|
||||
|
||||
describe("sources", () => {
|
||||
it("active sources");
|
||||
it("public source");
|
||||
it("add new source");
|
||||
});
|
||||
|
||||
describe("inspect", () => {
|
||||
it("toggle", () => {
|
||||
when.setStyle("geojson");
|
||||
|
||||
when.selectWithin("nav:inspect", "inspect");
|
||||
});
|
||||
});
|
||||
|
||||
describe("style settings", () => {
|
||||
beforeEach(() => {
|
||||
when.click("nav:settings");
|
||||
});
|
||||
|
||||
it("name", () => {
|
||||
when.setValue(get.dataAttribute("modal:settings.name"), "foobar");
|
||||
when.click("modal:settings.owner");
|
||||
|
||||
should.equalStyleStore((obj) => obj.name, "foobar");
|
||||
});
|
||||
it("owner", () => {
|
||||
when.setValue(get.dataAttribute("modal:settings.owner"), "foobar");
|
||||
when.click("modal:settings.name");
|
||||
|
||||
should.equalStyleStore((obj) => obj.owner, "foobar");
|
||||
});
|
||||
it("sprite url", () => {
|
||||
when.setValue(
|
||||
get.dataAttribute("modal:settings.sprite"),
|
||||
"http://example.com"
|
||||
);
|
||||
when.click("modal:settings.name");
|
||||
|
||||
should.equalStyleStore((obj) => obj.sprite, "http://example.com");
|
||||
});
|
||||
it("glyphs url", () => {
|
||||
var glyphsUrl = "http://example.com/{fontstack}/{range}.pbf";
|
||||
when.setValue(get.dataAttribute("modal:settings.glyphs"), glyphsUrl);
|
||||
when.click("modal:settings.name");
|
||||
|
||||
should.equalStyleStore((obj) => obj.glyphs, glyphsUrl);
|
||||
});
|
||||
|
||||
it("maptiler access token", () => {
|
||||
var apiKey = "testing123";
|
||||
when.setValue(
|
||||
get.dataAttribute(
|
||||
"modal:settings.maputnik:openmaptiles_access_token"
|
||||
),
|
||||
apiKey
|
||||
);
|
||||
when.click("modal:settings.name");
|
||||
|
||||
should.equalStyleStore(
|
||||
(obj) => obj.metadata["maputnik:openmaptiles_access_token"],
|
||||
apiKey
|
||||
);
|
||||
});
|
||||
|
||||
it("thunderforest access token", () => {
|
||||
var apiKey = "testing123";
|
||||
when.setValue(
|
||||
get.dataAttribute(
|
||||
"modal:settings.maputnik:thunderforest_access_token"
|
||||
),
|
||||
apiKey
|
||||
);
|
||||
when.click("modal:settings.name");
|
||||
|
||||
should.equalStyleStore(
|
||||
(obj) => obj.metadata["maputnik:thunderforest_access_token"],
|
||||
apiKey
|
||||
);
|
||||
});
|
||||
|
||||
it("style renderer", () => {
|
||||
cy.on("uncaught:exception", () => false); // this is due to the fact that this is an invalid style for openlayers
|
||||
when.select("modal:settings.maputnik:renderer", "ol");
|
||||
should.beSelected("modal:settings.maputnik:renderer", "ol");
|
||||
|
||||
when.click("modal:settings.name");
|
||||
|
||||
should.equalStyleStore((obj) => obj.metadata["maputnik:renderer"], "ol");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sources", () => {
|
||||
it("toggle");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "test-style",
|
||||
"version": 8,
|
||||
"name": "Test Style",
|
||||
"metadata": {
|
||||
"maputnik:renderer": "mlgljs"
|
||||
},
|
||||
"sources": {},
|
||||
"glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf",
|
||||
"sprites": "https://example.local/fonts/{fontstack}/{range}.pbf",
|
||||
"layers": [
|
||||
{
|
||||
"id": "background",
|
||||
"type": "background"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "test-style",
|
||||
"version": 8,
|
||||
"name": "Test Style",
|
||||
"metadata": {
|
||||
"maputnik:renderer": "mlgljs"
|
||||
},
|
||||
"sources": {},
|
||||
"glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf",
|
||||
"sprites": "https://example.local/fonts/{fontstack}/{range}.pbf",
|
||||
"layers": []
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"id": "test-style",
|
||||
"version": 8,
|
||||
"name": "Test Style",
|
||||
"metadata": {
|
||||
"maputnik:renderer": "mlgljs"
|
||||
},
|
||||
"sources": {
|
||||
"example": {
|
||||
"type": "vector",
|
||||
"data": {
|
||||
"type": "FeatureCollection",
|
||||
"features":[{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Dinagat Islands"
|
||||
},
|
||||
"geometry":{
|
||||
"type": "Point",
|
||||
"coordinates": [125.6, 10.1]
|
||||
}
|
||||
}]
|
||||
}
|
||||
},
|
||||
"raster": {
|
||||
"tileSize": 256,
|
||||
"tiles": ["http://localhost/example/{x}/{y}/{z}"],
|
||||
"type": "raster"
|
||||
}
|
||||
},
|
||||
"glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf",
|
||||
"sprites": "https://example.local/fonts/{fontstack}/{range}.pbf",
|
||||
"layers": []
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"id": "test-style",
|
||||
"version": 8,
|
||||
"name": "Test Style",
|
||||
"metadata": {
|
||||
"maputnik:renderer": "mlgljs"
|
||||
},
|
||||
"sources": {
|
||||
"example": {
|
||||
"type": "vector",
|
||||
"data": {
|
||||
"type": "FeatureCollection",
|
||||
"features":[{
|
||||
"type": "Feature",
|
||||
"properties": {
|
||||
"name": "Dinagat Islands"
|
||||
},
|
||||
"geometry":{
|
||||
"type": "Point",
|
||||
"coordinates": [125.6, 10.1]
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf",
|
||||
"sprites": "https://example.local/fonts/{fontstack}/{range}.pbf",
|
||||
"layers": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "test-style",
|
||||
"version": 8,
|
||||
"name": "Test Style",
|
||||
"metadata": {
|
||||
"maputnik:renderer": "mlgljs"
|
||||
},
|
||||
"sources": {
|
||||
"raster": {
|
||||
"tileSize": 256,
|
||||
"tiles": ["http://localhost/example/{x}/{y}/{z}"],
|
||||
"type": "raster"
|
||||
}
|
||||
},
|
||||
"glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf",
|
||||
"sprites": "https://example.local/fonts/{fontstack}/{range}.pbf",
|
||||
"layers": []
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// <reference types="cypress" />
|
||||
// ***********************************************
|
||||
// This example commands.ts shows you how to
|
||||
// create various custom commands and overwrite
|
||||
// existing commands.
|
||||
//
|
||||
// For more comprehensive examples of custom
|
||||
// commands please read more here:
|
||||
// https://on.cypress.io/custom-commands
|
||||
// ***********************************************
|
||||
//
|
||||
//
|
||||
// -- This is a parent command --
|
||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
//
|
||||
// declare global {
|
||||
// namespace Cypress {
|
||||
// interface Chainable {
|
||||
// login(email: string, password: string): Chainable<void>
|
||||
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,21 @@
|
||||
// ***********************************************************
|
||||
// This example support/e2e.ts is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import "cypress-plugin-tab";
|
||||
import "./commands";
|
||||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
+3
-5
@@ -4,8 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<title>Maputnik</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="manifest" href="/maputnik/assets/manifest-BrZzkYP9.json">
|
||||
<link rel="icon" href="/maputnik/assets/favicon-DBn6BKLx.ico" type="image/x-icon" />
|
||||
<link rel="manifest" href="src/manifest.json">
|
||||
<style>
|
||||
html {
|
||||
background-color: rgb(28, 31, 36);
|
||||
@@ -37,8 +36,6 @@
|
||||
}
|
||||
|
||||
</style>
|
||||
<script type="module" crossorigin src="/maputnik/assets/index-DPDV-s6M.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/maputnik/assets/index-CKLfpVt5.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- From <https://github.com/hail2u/color-blindness-emulation> -->
|
||||
@@ -125,9 +122,10 @@
|
||||
<div id="app"></div>
|
||||
<div class="loading">
|
||||
<div class="loading__logo">
|
||||
<img inline src="data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20width='1200'%20height='1200'%20viewBox='0%200%20100%20100'%3e%3cstyle%3e@keyframes%20circle-anim{0%25,40%25{fill-opacity:0}60%25,to{fill-opacity:1}}.circle0,.circle1,.circle2,.circle3,.circle4,.circle5{stroke-opacity:0;animation-name:circle-anim;will-change:transform;animation-timing-function:east-in-out;animation-duration:800ms;animation-iteration-count:infinite;animation-direction:alternate}.circle0{animation-delay:100ms}.circle1{animation-delay:200ms}.circle2{animation-delay:300ms}.circle3{animation-delay:400ms}.circle4{animation-delay:500ms}.circle5{animation-delay:600ms}%3c/style%3e%3cg%20class='map'%20stroke='%23000'%3e%3cuse%20xlink:href='%23ref-1--map__main'%20fill='%234eba6f'%3e%3c/use%3e%3cuse%20xlink:href='%23ref-1--map__line1'%20fill='none'%3e%3c/use%3e%3cuse%20xlink:href='%23ref-1--map__line2'%20fill='none'%3e%3c/use%3e%3cuse%20xlink:href='%23ref-1--map__line3'%20fill='none'%3e%3c/use%3e%3c/g%3e%3cg%20class='palette'%3e%3cuse%20xlink:href='%23ref-1--palette__main'%20fill='%23fff'%20stroke='%23000'%3e%3c/use%3e%3cuse%20xlink:href='%23ref-1--palette__inner'%20fill='none'%20stroke='%23000'%3e%3c/use%3e%3cuse%20class='circle5'%20xlink:href='%23ref-1--palette__circle5'%20fill='%23f7c44c'%3e%3c/use%3e%3cuse%20class='circle4'%20xlink:href='%23ref-1--palette__circle4'%20fill='%234eba6f'%3e%3c/use%3e%3cuse%20class='circle3'%20xlink:href='%23ref-1--palette__circle3'%20fill='%23f7c44c'%3e%3c/use%3e%3cuse%20class='circle2'%20xlink:href='%23ref-1--palette__circle2'%20fill='%234eba6f'%3e%3c/use%3e%3cuse%20class='circle1'%20xlink:href='%23ref-1--palette__circle1'%20fill='%23f7c44c'%3e%3c/use%3e%3cuse%20class='circle0'%20xlink:href='%23ref-1--palette__circle0'%20fill='%234eba6f'%3e%3c/use%3e%3c/g%3e%3cg%20class='brush'%20stroke='%23000'%3e%3cuse%20xlink:href='%23ref-1--brush__bottom'%20fill='%23f7c44c'%3e%3c/use%3e%3cuse%20xlink:href='%23ref-1--brush__top'%20fill='%23fff'%3e%3c/use%3e%3c/g%3e%3cdefs%3e%3cpath%20id='ref-1--map__main'%20stroke-width='2.366'%20stroke-linejoin='round'%20d='M18.84%207.717l15.44%207.542%2015.75-7.762%2015.7%207.857L81.005%207.67%2096.31%2054.052%2073.598%2062.12%2050.93%2053.872l-25.1%208.066-22.668-8.066z'%3e%3c/path%3e%3cpath%20id='ref-1--map__line1'%20d='M65.556%2015.07l7.647%2046.838'%20stroke-width='1.104'%3e%3c/path%3e%3cpath%20id='ref-1--map__line2'%20d='M50.261%207.422l.717%2046.6'%20stroke-width='1.104'%3e%3c/path%3e%3cpath%20id='ref-1--map__line3'%20d='M34.011%2015.07l-8.603%2046.6'%20stroke-width='1.104'%3e%3c/path%3e%3cpath%20id='ref-1--palette__main'%20stroke-width='2.3'%20d='M47.352%2030.887c7.993.226%2016.934%209.725%2017.954%2015.25%201.02%205.527-.743%2011.125-4.298%2013.875-3.554%202.75-8.6%202.905-8.723%208.302-.097%204.237%208.457%208.5%208.088%2015.653-.406%207.857-15.508%2013.15-30.943%206.102-8.556-3.906-14.249-13.653-13.385-26.238C16.833%2052.334%2022.32%2043.658%2027.382%2039c5.977-5.503%2011.977-8.337%2019.97-8.112z'%3e%3c/path%3e%3ccircle%20id='ref-1--palette__inner'%20stroke-width='2.3'%20cx='41.873'%20cy='61.901'%20r='6.389'%3e%3c/circle%3e%3ccircle%20id='ref-1--palette__circle5'%20cy='44.56'%20cx='54.347'%20r='4.336'%3e%3c/circle%3e%3ccircle%20id='ref-1--palette__circle4'%20cx='40.443'%20cy='41.555'%20r='4.336'%3e%3c/circle%3e%3ccircle%20id='ref-1--palette__circle3'%20r='4.336'%20cy='51.102'%20cx='29.651'%3e%3c/circle%3e%3ccircle%20id='ref-1--palette__circle2'%20cx='25.293'%20cy='65.836'%20r='4.336'%3e%3c/circle%3e%3ccircle%20id='ref-1--palette__circle1'%20r='4.336'%20cy='79.326'%20cx='32.764'%3e%3c/circle%3e%3ccircle%20id='ref-1--palette__circle0'%20cx='46.669'%20cy='80.571'%20r='4.336'%3e%3c/circle%3e%3cpath%20id='ref-1--brush__bottom'%20d='M76.333%2089.333c-1.645-9.794-4.375-35.26-4.32-37.887.056-2.627%202.52-4.34%205.36-4.317%202.842.022%205.098%201.87%205.314%204.27.107%201.2-1.576%2028.06-2.318%2037.844-.332%204.374-3.31%204.413-4.036.09z'%20stroke-width='2.3'%20stroke-linejoin='round'%3e%3c/path%3e%3cpath%20id='ref-1--brush__top'%20stroke-linejoin='round'%20stroke-width='2.3'%20d='M77.184%2026.428s-5.621%207.02-5.621%2011.978c0%204.957%202.206%206.878%205.81%206.878%203.606%200%205.148-1.708%205.29-6.736.142-5.028-5.479-12.12-5.479-12.12z'%3e%3c/path%3e%3c/defs%3e%3c/svg%3e" />
|
||||
<img inline src="node_modules/maputnik-design/logos/logo-loading.svg" />
|
||||
</div>
|
||||
<div class="loading__text">Loading…</div>
|
||||
</div>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Generated
+15729
File diff suppressed because it is too large
Load Diff
+124
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"name": "maputnik",
|
||||
"version": "2.0.0-pre.2",
|
||||
"description": "A MapLibre GL visual style editor",
|
||||
"main": "''",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint ./src --ext ts,tsx,js,jsx --report-unused-disable-directives --max-warnings 0 && npm run lint-css",
|
||||
"test": "cypress run",
|
||||
"cy:open": "cypress open",
|
||||
"lint-css": "stylelint \"src/styles/*.scss\"",
|
||||
"storybook": "storybook dev -h 0.0.0.0 -p 6006",
|
||||
"build-storybook": "storybook build -o build/storybook"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/maputnik/editor"
|
||||
},
|
||||
"author": "Lukas Martinelli",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/maputnik/editor#readme",
|
||||
"dependencies": {
|
||||
"@mapbox/mapbox-gl-rtl-text": "^0.2.3",
|
||||
"@maplibre/maplibre-gl-style-spec": "^17.0.1",
|
||||
"@mdi/js": "^6.6.96",
|
||||
"@mdi/react": "^1.5.0",
|
||||
"array-move": "^4.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "^2.3.1",
|
||||
"codemirror": "^5.65.2",
|
||||
"color": "^4.2.3",
|
||||
"detect-browser": "^5.3.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"json-stringify-pretty-compact": "^3.0.0",
|
||||
"json-to-ast": "^2.1.0",
|
||||
"jsonlint": "github:josdejong/jsonlint#85a19d7",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash.capitalize": "^4.2.1",
|
||||
"lodash.clamp": "^4.0.3",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.get": "^4.4.2",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"mapbox-gl-inspect": "^1.3.1",
|
||||
"maplibre-gl": "^2.4.0",
|
||||
"maputnik-design": "github:maputnik/design#172b06c",
|
||||
"ol": "^6.14.1",
|
||||
"ol-mapbox-style": "^7.1.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^16.0.0",
|
||||
"react-accessible-accordion": "^4.0.0",
|
||||
"react-aria-menubutton": "^7.0.3",
|
||||
"react-aria-modal": "^4.0.1",
|
||||
"react-autobind": "^1.0.6",
|
||||
"react-autocomplete": "^1.8.1",
|
||||
"react-collapse": "^5.1.1",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "^16.0.0",
|
||||
"react-file-reader-input": "^2.0.0",
|
||||
"react-icon-base": "^2.1.2",
|
||||
"react-icons": "^4.3.1",
|
||||
"react-sortable-hoc": "^2.0.0",
|
||||
"reconnecting-websocket": "^4.4.0",
|
||||
"sass": "^1.50.0",
|
||||
"slugify": "^1.6.5",
|
||||
"string-hash": "^1.1.3",
|
||||
"url": "^0.11.0"
|
||||
},
|
||||
"jshintConfig": {
|
||||
"esversion": 6
|
||||
},
|
||||
"stylelint": {
|
||||
"extends": "stylelint-config-recommended-scss",
|
||||
"rules": {
|
||||
"no-descending-specificity": null,
|
||||
"media-feature-name-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignoreMediaFeatureNames": [
|
||||
"prefers-reduced-motion"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-replace": "^5.0.5",
|
||||
"@shellygo/cypress-test-utils": "^2.0.9",
|
||||
"@storybook/addon-a11y": "^7.6.5",
|
||||
"@storybook/addon-actions": "^7.6.5",
|
||||
"@storybook/addon-links": "^7.6.5",
|
||||
"@storybook/addon-storysource": "^7.6.5",
|
||||
"@storybook/addons": "^7.6.5",
|
||||
"@storybook/builder-vite": "^7.6.5",
|
||||
"@storybook/react": "^7.6.5",
|
||||
"@storybook/react-vite": "^7.6.5",
|
||||
"@storybook/theming": "^7.6.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/react": "^16.14.52",
|
||||
"@types/react-dom": "^16.9.24",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"cypress": "^13.6.1",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"express": "^4.17.3",
|
||||
"istanbul": "^0.4.5",
|
||||
"istanbul-lib-coverage": "^3.2.0",
|
||||
"mocha": "^9.2.2",
|
||||
"postcss": "^8.4.12",
|
||||
"react-hot-loader": "^4.13.0",
|
||||
"storybook": "^7.6.5",
|
||||
"stylelint": "^14.6.1",
|
||||
"stylelint-config-recommended-scss": "^6.0.0",
|
||||
"stylelint-scss": "^4.2.0",
|
||||
"typescript": "^5.3.3",
|
||||
"uuid": "^8.3.2",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,926 @@
|
||||
import autoBind from 'react-autobind';
|
||||
import React from 'react'
|
||||
import cloneDeep from 'lodash.clonedeep'
|
||||
import clamp from 'lodash.clamp'
|
||||
import buffer from 'buffer'
|
||||
import get from 'lodash.get'
|
||||
import {unset} from 'lodash'
|
||||
import {arrayMoveMutable} from 'array-move'
|
||||
import url from 'url'
|
||||
import hash from "string-hash";
|
||||
|
||||
import MapMaplibreGl from './MapMaplibreGl'
|
||||
import MapOpenLayers from './MapOpenLayers'
|
||||
import LayerList from './LayerList'
|
||||
import LayerEditor from './LayerEditor'
|
||||
import AppToolbar from './AppToolbar'
|
||||
import AppLayout from './AppLayout'
|
||||
import MessagePanel from './AppMessagePanel'
|
||||
|
||||
import ModalSettings from './ModalSettings'
|
||||
import ModalExport from './ModalExport'
|
||||
import ModalSources from './ModalSources'
|
||||
import ModalOpen from './ModalOpen'
|
||||
import ModalShortcuts from './ModalShortcuts'
|
||||
import ModalSurvey from './ModalSurvey'
|
||||
import ModalDebug from './ModalDebug'
|
||||
|
||||
import { downloadGlyphsMetadata, downloadSpriteMetadata } from '../libs/metadata'
|
||||
import {latest, validate} from '@maplibre/maplibre-gl-style-spec'
|
||||
import style from '../libs/style'
|
||||
import { initialStyleUrl, loadStyleUrl, removeStyleQuerystring } from '../libs/urlopen'
|
||||
import { undoMessages, redoMessages } from '../libs/diffmessage'
|
||||
import { StyleStore } from '../libs/stylestore'
|
||||
import { ApiStyleStore } from '../libs/apistore'
|
||||
import { RevisionStore } from '../libs/revisions'
|
||||
import LayerWatcher from '../libs/layerwatcher'
|
||||
import tokens from '../config/tokens.json'
|
||||
import isEqual from 'lodash.isequal'
|
||||
import Debug from '../libs/debug'
|
||||
import {formatLayerId} from '../util/format';
|
||||
|
||||
// Buffer must be defined globally for @maplibre/maplibre-gl-style-spec validate() function to succeed.
|
||||
window.Buffer = buffer.Buffer;
|
||||
|
||||
function setFetchAccessToken(url, mapStyle) {
|
||||
const matchesTilehosting = url.match(/\.tilehosting\.com/);
|
||||
const matchesMaptiler = url.match(/\.maptiler\.com/);
|
||||
const matchesThunderforest = url.match(/\.thunderforest\.com/);
|
||||
if (matchesTilehosting || matchesMaptiler) {
|
||||
const accessToken = style.getAccessToken("openmaptiles", mapStyle, {allowFallback: true})
|
||||
if (accessToken) {
|
||||
return url.replace('{key}', accessToken)
|
||||
}
|
||||
}
|
||||
else if (matchesThunderforest) {
|
||||
const accessToken = style.getAccessToken("thunderforest", mapStyle, {allowFallback: true})
|
||||
if (accessToken) {
|
||||
return url.replace('{key}', accessToken)
|
||||
}
|
||||
}
|
||||
else {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function updateRootSpec(spec, fieldName, newValues) {
|
||||
return {
|
||||
...spec,
|
||||
$root: {
|
||||
...spec.$root,
|
||||
[fieldName]: {
|
||||
...spec.$root[fieldName],
|
||||
values: newValues
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default class App extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
autoBind(this);
|
||||
|
||||
this.revisionStore = new RevisionStore()
|
||||
const params = new URLSearchParams(window.location.search.substring(1))
|
||||
let port = params.get("localport")
|
||||
if (port == null && (window.location.port != 80 && window.location.port != 443)) {
|
||||
port = window.location.port
|
||||
}
|
||||
this.styleStore = new ApiStyleStore({
|
||||
onLocalStyleChange: mapStyle => this.onStyleChanged(mapStyle, {save: false}),
|
||||
port: port,
|
||||
host: params.get("localhost")
|
||||
})
|
||||
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
key: "?",
|
||||
handler: () => {
|
||||
this.toggleModal("shortcuts");
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "o",
|
||||
handler: () => {
|
||||
this.toggleModal("open");
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "e",
|
||||
handler: () => {
|
||||
this.toggleModal("export");
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "d",
|
||||
handler: () => {
|
||||
this.toggleModal("sources");
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "s",
|
||||
handler: () => {
|
||||
this.toggleModal("settings");
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "i",
|
||||
handler: () => {
|
||||
this.setMapState(
|
||||
this.state.mapState === "map" ? "inspect" : "map"
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "m",
|
||||
handler: () => {
|
||||
document.querySelector(".maplibregl-canvas").focus();
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "!",
|
||||
handler: () => {
|
||||
this.toggleModal("debug");
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
document.body.addEventListener("keyup", (e) => {
|
||||
if(e.key === "Escape") {
|
||||
e.target.blur();
|
||||
document.body.focus();
|
||||
}
|
||||
else if(this.state.isOpen.shortcuts || document.activeElement === document.body) {
|
||||
const shortcut = shortcuts.find((shortcut) => {
|
||||
return (shortcut.key === e.key)
|
||||
})
|
||||
|
||||
if(shortcut) {
|
||||
this.setModal("shortcuts", false);
|
||||
shortcut.handler(e);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const styleUrl = initialStyleUrl()
|
||||
if(styleUrl && window.confirm("Load style from URL: " + styleUrl + " and discard current changes?")) {
|
||||
this.styleStore = new StyleStore()
|
||||
loadStyleUrl(styleUrl, mapStyle => this.onStyleChanged(mapStyle))
|
||||
removeStyleQuerystring()
|
||||
} else {
|
||||
if(styleUrl) {
|
||||
removeStyleQuerystring()
|
||||
}
|
||||
this.styleStore.init(err => {
|
||||
if(err) {
|
||||
console.log('Falling back to local storage for storing styles')
|
||||
this.styleStore = new StyleStore()
|
||||
}
|
||||
this.styleStore.latestStyle(mapStyle => this.onStyleChanged(mapStyle, {initialLoad: true}))
|
||||
|
||||
if(Debug.enabled()) {
|
||||
Debug.set("maputnik", "styleStore", this.styleStore);
|
||||
Debug.set("maputnik", "revisionStore", this.revisionStore);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if(Debug.enabled()) {
|
||||
Debug.set("maputnik", "revisionStore", this.revisionStore);
|
||||
Debug.set("maputnik", "styleStore", this.styleStore);
|
||||
}
|
||||
|
||||
const queryObj = url.parse(window.location.href, true).query;
|
||||
|
||||
this.state = {
|
||||
errors: [],
|
||||
infos: [],
|
||||
mapStyle: style.emptyStyle,
|
||||
selectedLayerIndex: 0,
|
||||
sources: {},
|
||||
vectorLayers: {},
|
||||
mapState: "map",
|
||||
spec: latest,
|
||||
mapView: {
|
||||
zoom: 0,
|
||||
center: {
|
||||
lng: 0,
|
||||
lat: 0,
|
||||
},
|
||||
},
|
||||
isOpen: {
|
||||
settings: false,
|
||||
sources: false,
|
||||
open: false,
|
||||
shortcuts: false,
|
||||
export: false,
|
||||
// TODO: Disabled for now, this should be opened on the Nth visit to the editor
|
||||
survey: false,
|
||||
debug: false,
|
||||
},
|
||||
maplibreGlDebugOptions: {
|
||||
showTileBoundaries: false,
|
||||
showCollisionBoxes: false,
|
||||
showOverdrawInspector: false,
|
||||
},
|
||||
openlayersDebugOptions: {
|
||||
debugToolbox: false,
|
||||
},
|
||||
}
|
||||
|
||||
this.layerWatcher = new LayerWatcher({
|
||||
onVectorLayersChange: v => this.setState({ vectorLayers: v })
|
||||
})
|
||||
}
|
||||
|
||||
handleKeyPress = (e) => {
|
||||
if(navigator.platform.toUpperCase().indexOf('MAC') >= 0) {
|
||||
if(e.metaKey && e.shiftKey && e.keyCode === 90) {
|
||||
e.preventDefault();
|
||||
this.onRedo(e);
|
||||
}
|
||||
else if(e.metaKey && e.keyCode === 90) {
|
||||
e.preventDefault();
|
||||
this.onUndo(e);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if(e.ctrlKey && e.keyCode === 90) {
|
||||
e.preventDefault();
|
||||
this.onUndo(e);
|
||||
}
|
||||
else if(e.ctrlKey && e.keyCode === 89) {
|
||||
e.preventDefault();
|
||||
this.onRedo(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("keydown", this.handleKeyPress);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("keydown", this.handleKeyPress);
|
||||
}
|
||||
|
||||
saveStyle(snapshotStyle) {
|
||||
this.styleStore.save(snapshotStyle)
|
||||
}
|
||||
|
||||
updateFonts(urlTemplate) {
|
||||
const metadata = this.state.mapStyle.metadata || {}
|
||||
const accessToken = metadata['maputnik:openmaptiles_access_token'] || tokens.openmaptiles
|
||||
|
||||
let glyphUrl = (typeof urlTemplate === 'string')? urlTemplate.replace('{key}', accessToken): urlTemplate;
|
||||
downloadGlyphsMetadata(glyphUrl, fonts => {
|
||||
this.setState({ spec: updateRootSpec(this.state.spec, 'glyphs', fonts)})
|
||||
})
|
||||
}
|
||||
|
||||
updateIcons(baseUrl) {
|
||||
downloadSpriteMetadata(baseUrl, icons => {
|
||||
this.setState({ spec: updateRootSpec(this.state.spec, 'sprite', icons)})
|
||||
})
|
||||
}
|
||||
|
||||
onChangeMetadataProperty = (property, value) => {
|
||||
// If we're changing renderer reset the map state.
|
||||
if (
|
||||
property === 'maputnik:renderer' &&
|
||||
value !== get(this.state.mapStyle, ['metadata', 'maputnik:renderer'], 'mlgljs')
|
||||
) {
|
||||
this.setState({
|
||||
mapState: 'map'
|
||||
});
|
||||
}
|
||||
|
||||
const changedStyle = {
|
||||
...this.state.mapStyle,
|
||||
metadata: {
|
||||
...this.state.mapStyle.metadata,
|
||||
[property]: value
|
||||
}
|
||||
}
|
||||
this.onStyleChanged(changedStyle)
|
||||
}
|
||||
|
||||
onStyleChanged = (newStyle, opts={}) => {
|
||||
opts = {
|
||||
save: true,
|
||||
addRevision: true,
|
||||
initialLoad: false,
|
||||
...opts,
|
||||
};
|
||||
|
||||
if (opts.initialLoad) {
|
||||
this.getInitialStateFromUrl(newStyle);
|
||||
}
|
||||
|
||||
const errors = validate(newStyle, latest) || [];
|
||||
|
||||
// The validate function doesn't give us errors for duplicate error with
|
||||
// empty string for layer.id, manually deal with that here.
|
||||
const layerErrors = [];
|
||||
if (newStyle && newStyle.layers) {
|
||||
const foundLayers = new Map();
|
||||
newStyle.layers.forEach((layer, index) => {
|
||||
if (layer.id === "" && foundLayers.has(layer.id)) {
|
||||
const message = `Duplicate layer: ${formatLayerId(layer.id)}`;
|
||||
const error = new Error(
|
||||
`layers[${index}]: duplicate layer id [empty_string], previously used`
|
||||
);
|
||||
layerErrors.push(error);
|
||||
}
|
||||
foundLayers.set(layer.id, true);
|
||||
});
|
||||
}
|
||||
|
||||
const mappedErrors = layerErrors.concat(errors).map(error => {
|
||||
// Special case: Duplicate layer id
|
||||
const dupMatch = error.message.match(/layers\[(\d+)\]: (duplicate layer id "?(.*)"?, previously used)/);
|
||||
if (dupMatch) {
|
||||
const [matchStr, index, message] = dupMatch;
|
||||
return {
|
||||
message: error.message,
|
||||
parsed: {
|
||||
type: "layer",
|
||||
data: {
|
||||
index: parseInt(index, 10),
|
||||
key: "id",
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: Invalid source
|
||||
const invalidSourceMatch = error.message.match(/layers\[(\d+)\]: (source "(?:.*)" not found)/);
|
||||
if (invalidSourceMatch) {
|
||||
const [matchStr, index, message] = invalidSourceMatch;
|
||||
return {
|
||||
message: error.message,
|
||||
parsed: {
|
||||
type: "layer",
|
||||
data: {
|
||||
index: parseInt(index, 10),
|
||||
key: "source",
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const layerMatch = error.message.match(/layers\[(\d+)\]\.(?:(\S+)\.)?(\S+): (.*)/);
|
||||
if (layerMatch) {
|
||||
const [matchStr, index, group, property, message] = layerMatch;
|
||||
const key = (group && property) ? [group, property].join(".") : property;
|
||||
return {
|
||||
message: error.message,
|
||||
parsed: {
|
||||
type: "layer",
|
||||
data: {
|
||||
index: parseInt(index, 10),
|
||||
key,
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
return {
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
let dirtyMapStyle = undefined;
|
||||
if (errors.length > 0) {
|
||||
dirtyMapStyle = cloneDeep(newStyle);
|
||||
|
||||
errors.forEach(error => {
|
||||
const {message} = error;
|
||||
if (message) {
|
||||
try {
|
||||
const objPath = message.split(":")[0];
|
||||
// Errors can be deply nested for example 'layers[0].filter[1][1][0]' we only care upto the property 'layers[0].filter'
|
||||
const unsetPath = objPath.match(/^\S+?\[\d+\]\.[^\[]+/)[0];
|
||||
unset(dirtyMapStyle, unsetPath);
|
||||
}
|
||||
catch (err) {
|
||||
console.warn(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if(newStyle.glyphs !== this.state.mapStyle.glyphs) {
|
||||
this.updateFonts(newStyle.glyphs)
|
||||
}
|
||||
if(newStyle.sprite !== this.state.mapStyle.sprite) {
|
||||
this.updateIcons(newStyle.sprite)
|
||||
}
|
||||
|
||||
if (opts.addRevision) {
|
||||
this.revisionStore.addRevision(newStyle);
|
||||
}
|
||||
if (opts.save) {
|
||||
this.saveStyle(newStyle);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
mapStyle: newStyle,
|
||||
dirtyMapStyle: dirtyMapStyle,
|
||||
errors: mappedErrors,
|
||||
}, () => {
|
||||
this.fetchSources();
|
||||
this.setStateInUrl();
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
onUndo = () => {
|
||||
const activeStyle = this.revisionStore.undo()
|
||||
|
||||
const messages = undoMessages(this.state.mapStyle, activeStyle)
|
||||
this.onStyleChanged(activeStyle, {addRevision: false});
|
||||
this.setState({
|
||||
infos: messages,
|
||||
})
|
||||
}
|
||||
|
||||
onRedo = () => {
|
||||
const activeStyle = this.revisionStore.redo()
|
||||
const messages = redoMessages(this.state.mapStyle, activeStyle)
|
||||
this.onStyleChanged(activeStyle, {addRevision: false});
|
||||
this.setState({
|
||||
infos: messages,
|
||||
})
|
||||
}
|
||||
|
||||
onMoveLayer = (move) => {
|
||||
let { oldIndex, newIndex } = move;
|
||||
let layers = this.state.mapStyle.layers;
|
||||
oldIndex = clamp(oldIndex, 0, layers.length-1);
|
||||
newIndex = clamp(newIndex, 0, layers.length-1);
|
||||
if(oldIndex === newIndex) return;
|
||||
|
||||
if (oldIndex === this.state.selectedLayerIndex) {
|
||||
this.setState({
|
||||
selectedLayerIndex: newIndex
|
||||
});
|
||||
}
|
||||
|
||||
layers = layers.slice(0);
|
||||
arrayMoveMutable(layers, oldIndex, newIndex);
|
||||
this.onLayersChange(layers);
|
||||
}
|
||||
|
||||
onLayersChange = (changedLayers) => {
|
||||
const changedStyle = {
|
||||
...this.state.mapStyle,
|
||||
layers: changedLayers
|
||||
}
|
||||
this.onStyleChanged(changedStyle)
|
||||
}
|
||||
|
||||
onLayerDestroy = (index) => {
|
||||
let layers = this.state.mapStyle.layers;
|
||||
const remainingLayers = layers.slice(0);
|
||||
remainingLayers.splice(index, 1);
|
||||
this.onLayersChange(remainingLayers);
|
||||
}
|
||||
|
||||
onLayerCopy = (index) => {
|
||||
let layers = this.state.mapStyle.layers;
|
||||
const changedLayers = layers.slice(0)
|
||||
|
||||
const clonedLayer = cloneDeep(changedLayers[index])
|
||||
clonedLayer.id = clonedLayer.id + "-copy"
|
||||
changedLayers.splice(index, 0, clonedLayer)
|
||||
this.onLayersChange(changedLayers)
|
||||
}
|
||||
|
||||
onLayerVisibilityToggle = (index) => {
|
||||
let layers = this.state.mapStyle.layers;
|
||||
const changedLayers = layers.slice(0)
|
||||
|
||||
const layer = { ...changedLayers[index] }
|
||||
const changedLayout = 'layout' in layer ? {...layer.layout} : {}
|
||||
changedLayout.visibility = changedLayout.visibility === 'none' ? 'visible' : 'none'
|
||||
|
||||
layer.layout = changedLayout
|
||||
changedLayers[index] = layer
|
||||
this.onLayersChange(changedLayers)
|
||||
}
|
||||
|
||||
|
||||
onLayerIdChange = (index, oldId, newId) => {
|
||||
const changedLayers = this.state.mapStyle.layers.slice(0)
|
||||
changedLayers[index] = {
|
||||
...changedLayers[index],
|
||||
id: newId
|
||||
}
|
||||
|
||||
this.onLayersChange(changedLayers)
|
||||
}
|
||||
|
||||
onLayerChanged = (index, layer) => {
|
||||
const changedLayers = this.state.mapStyle.layers.slice(0)
|
||||
changedLayers[index] = layer
|
||||
|
||||
this.onLayersChange(changedLayers)
|
||||
}
|
||||
|
||||
setMapState = (newState) => {
|
||||
this.setState({
|
||||
mapState: newState
|
||||
}, this.setStateInUrl);
|
||||
}
|
||||
|
||||
setDefaultValues = (styleObj) => {
|
||||
const metadata = styleObj.metadata || {}
|
||||
if(metadata['maputnik:renderer'] === undefined) {
|
||||
const changedStyle = {
|
||||
...styleObj,
|
||||
metadata: {
|
||||
...styleObj.metadata,
|
||||
'maputnik:renderer': 'mlgljs'
|
||||
}
|
||||
}
|
||||
return changedStyle
|
||||
} else {
|
||||
return styleObj
|
||||
}
|
||||
}
|
||||
|
||||
openStyle = (styleObj) => {
|
||||
styleObj = this.setDefaultValues(styleObj)
|
||||
this.onStyleChanged(styleObj)
|
||||
}
|
||||
|
||||
fetchSources() {
|
||||
const sourceList = {};
|
||||
|
||||
for(let [key, val] of Object.entries(this.state.mapStyle.sources)) {
|
||||
if(
|
||||
!this.state.sources.hasOwnProperty(key) &&
|
||||
val.type === "vector" &&
|
||||
val.hasOwnProperty("url")
|
||||
) {
|
||||
sourceList[key] = {
|
||||
type: val.type,
|
||||
layers: []
|
||||
};
|
||||
|
||||
let url = val.url;
|
||||
|
||||
try {
|
||||
url = setFetchAccessToken(url, this.state.mapStyle)
|
||||
} catch(err) {
|
||||
console.warn("Failed to setFetchAccessToken: ", err);
|
||||
}
|
||||
|
||||
fetch(url, {
|
||||
mode: 'cors',
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(json => {
|
||||
|
||||
if(!json.hasOwnProperty("vector_layers")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new objects before setState
|
||||
const sources = Object.assign({}, {
|
||||
[key]: this.state.sources[key],
|
||||
});
|
||||
|
||||
for(let layer of json.vector_layers) {
|
||||
sources[key].layers.push(layer.id)
|
||||
}
|
||||
|
||||
console.debug("Updating source: "+key);
|
||||
this.setState({
|
||||
sources: sources
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to process sources for '%s'", url, err);
|
||||
});
|
||||
}
|
||||
else {
|
||||
sourceList[key] = this.state.sources[key] || this.state.mapStyle.sources[key];
|
||||
}
|
||||
}
|
||||
|
||||
if(!isEqual(this.state.sources, sourceList)) {
|
||||
console.debug("Setting sources");
|
||||
this.setState({
|
||||
sources: sourceList
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_getRenderer () {
|
||||
const metadata = this.state.mapStyle.metadata || {};
|
||||
return metadata['maputnik:renderer'] || 'mlgljs';
|
||||
}
|
||||
|
||||
onMapChange = (mapView) => {
|
||||
this.setState({
|
||||
mapView,
|
||||
});
|
||||
}
|
||||
|
||||
mapRenderer() {
|
||||
const {mapStyle, dirtyMapStyle} = this.state;
|
||||
const metadata = this.state.mapStyle.metadata || {};
|
||||
|
||||
const mapProps = {
|
||||
mapStyle: (dirtyMapStyle || mapStyle),
|
||||
replaceAccessTokens: (mapStyle) => {
|
||||
return style.replaceAccessTokens(mapStyle, {
|
||||
allowFallback: true
|
||||
});
|
||||
},
|
||||
onDataChange: (e) => {
|
||||
this.layerWatcher.analyzeMap(e.map)
|
||||
this.fetchSources();
|
||||
},
|
||||
}
|
||||
|
||||
const renderer = this._getRenderer();
|
||||
|
||||
let mapElement;
|
||||
|
||||
// Check if OL code has been loaded?
|
||||
if(renderer === 'ol') {
|
||||
mapElement = <MapOpenLayers
|
||||
{...mapProps}
|
||||
onChange={this.onMapChange}
|
||||
debugToolbox={this.state.openlayersDebugOptions.debugToolbox}
|
||||
onLayerSelect={this.onLayerSelect}
|
||||
/>
|
||||
} else {
|
||||
mapElement = <MapMaplibreGl {...mapProps}
|
||||
onChange={this.onMapChange}
|
||||
options={this.state.maplibreGlDebugOptions}
|
||||
inspectModeEnabled={this.state.mapState === "inspect"}
|
||||
highlightedLayer={this.state.mapStyle.layers[this.state.selectedLayerIndex]}
|
||||
onLayerSelect={this.onLayerSelect} />
|
||||
}
|
||||
|
||||
let filterName;
|
||||
if(this.state.mapState.match(/^filter-/)) {
|
||||
filterName = this.state.mapState.replace(/^filter-/, "");
|
||||
}
|
||||
const elementStyle = {};
|
||||
if (filterName) {
|
||||
elementStyle.filter = `url('#${filterName}')`;
|
||||
}
|
||||
|
||||
return <div style={elementStyle} className="maputnik-map__container" data-wd-key="maplibre:container">
|
||||
{mapElement}
|
||||
</div>
|
||||
}
|
||||
|
||||
setStateInUrl = () => {
|
||||
const {mapState, mapStyle, isOpen} = this.state;
|
||||
const {selectedLayerIndex} = this.state;
|
||||
const url = new URL(location.href);
|
||||
const hashVal = hash(JSON.stringify(mapStyle));
|
||||
url.searchParams.set("layer", `${hashVal}~${selectedLayerIndex}`);
|
||||
|
||||
const openModals = Object.entries(isOpen)
|
||||
.map(([key, val]) => (val === true ? key : null))
|
||||
.filter(val => val !== null);
|
||||
|
||||
if (openModals.length > 0) {
|
||||
url.searchParams.set("modal", openModals.join(","));
|
||||
}
|
||||
else {
|
||||
url.searchParams.delete("modal");
|
||||
}
|
||||
|
||||
if (mapState === "map") {
|
||||
url.searchParams.delete("view");
|
||||
}
|
||||
else if (mapState === "inspect") {
|
||||
url.searchParams.set("view", "inspect");
|
||||
}
|
||||
|
||||
history.replaceState({selectedLayerIndex}, "Maputnik", url.href);
|
||||
}
|
||||
|
||||
getInitialStateFromUrl = (mapStyle) => {
|
||||
const url = new URL(location.href);
|
||||
const modalParam = url.searchParams.get("modal");
|
||||
if (modalParam && modalParam !== "") {
|
||||
const modals = modalParam.split(",");
|
||||
const modalObj = {};
|
||||
modals.forEach(modalName => {
|
||||
modalObj[modalName] = true;
|
||||
});
|
||||
|
||||
this.setState({
|
||||
isOpen: {
|
||||
...this.state.isOpen,
|
||||
...modalObj,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const view = url.searchParams.get("view");
|
||||
if (view && view !== "") {
|
||||
this.setMapState(view);
|
||||
}
|
||||
|
||||
const path = url.searchParams.get("layer");
|
||||
if (path) {
|
||||
try {
|
||||
const parts = path.split("~");
|
||||
const [hashVal, selectedLayerIndex] = [
|
||||
parts[0],
|
||||
parseInt(parts[1], 10),
|
||||
];
|
||||
|
||||
let valid = true;
|
||||
if (hashVal !== "-") {
|
||||
const currentHashVal = hash(JSON.stringify(mapStyle));
|
||||
if (currentHashVal !== parseInt(hashVal, 10)) {
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
if (valid) {
|
||||
this.setState({
|
||||
selectedLayerIndex,
|
||||
selectedLayerOriginalId: mapStyle.layers[selectedLayerIndex].id,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.warn(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onLayerSelect = (index) => {
|
||||
this.setState({
|
||||
selectedLayerIndex: index,
|
||||
selectedLayerOriginalId: this.state.mapStyle.layers[index].id,
|
||||
}, this.setStateInUrl);
|
||||
}
|
||||
|
||||
setModal(modalName, value) {
|
||||
if(modalName === 'survey' && value === false) {
|
||||
localStorage.setItem('survey', '');
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isOpen: {
|
||||
...this.state.isOpen,
|
||||
[modalName]: value
|
||||
}
|
||||
}, this.setStateInUrl)
|
||||
}
|
||||
|
||||
toggleModal(modalName) {
|
||||
this.setModal(modalName, !this.state.isOpen[modalName]);
|
||||
}
|
||||
|
||||
onChangeOpenlayersDebug = (key, value) => {
|
||||
this.setState({
|
||||
openlayersDebugOptions: {
|
||||
...this.state.openlayersDebugOptions,
|
||||
[key]: value,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onChangeMaplibreGlDebug = (key, value) => {
|
||||
this.setState({
|
||||
maplibreGlDebugOptions: {
|
||||
...this.state.maplibreGlDebugOptions,
|
||||
[key]: value,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const layers = this.state.mapStyle.layers || []
|
||||
const selectedLayer = layers.length > 0 ? layers[this.state.selectedLayerIndex] : null
|
||||
const metadata = this.state.mapStyle.metadata || {}
|
||||
|
||||
const toolbar = <AppToolbar
|
||||
renderer={this._getRenderer()}
|
||||
mapState={this.state.mapState}
|
||||
mapStyle={this.state.mapStyle}
|
||||
inspectModeEnabled={this.state.mapState === "inspect"}
|
||||
sources={this.state.sources}
|
||||
onStyleChanged={this.onStyleChanged}
|
||||
onStyleOpen={this.onStyleChanged}
|
||||
onSetMapState={this.setMapState}
|
||||
onToggleModal={this.toggleModal.bind(this)}
|
||||
/>
|
||||
|
||||
const layerList = <LayerList
|
||||
onMoveLayer={this.onMoveLayer}
|
||||
onLayerDestroy={this.onLayerDestroy}
|
||||
onLayerCopy={this.onLayerCopy}
|
||||
onLayerVisibilityToggle={this.onLayerVisibilityToggle}
|
||||
onLayersChange={this.onLayersChange}
|
||||
onLayerSelect={this.onLayerSelect}
|
||||
selectedLayerIndex={this.state.selectedLayerIndex}
|
||||
layers={layers}
|
||||
sources={this.state.sources}
|
||||
errors={this.state.errors}
|
||||
/>
|
||||
|
||||
const layerEditor = selectedLayer ? <LayerEditor
|
||||
key={this.state.selectedLayerOriginalId}
|
||||
layer={selectedLayer}
|
||||
layerIndex={this.state.selectedLayerIndex}
|
||||
isFirstLayer={this.state.selectedLayerIndex < 1}
|
||||
isLastLayer={this.state.selectedLayerIndex === this.state.mapStyle.layers.length-1}
|
||||
sources={this.state.sources}
|
||||
vectorLayers={this.state.vectorLayers}
|
||||
spec={this.state.spec}
|
||||
onMoveLayer={this.onMoveLayer}
|
||||
onLayerChanged={this.onLayerChanged}
|
||||
onLayerDestroy={this.onLayerDestroy}
|
||||
onLayerCopy={this.onLayerCopy}
|
||||
onLayerVisibilityToggle={this.onLayerVisibilityToggle}
|
||||
onLayerIdChange={this.onLayerIdChange}
|
||||
errors={this.state.errors}
|
||||
/> : null
|
||||
|
||||
const bottomPanel = (this.state.errors.length + this.state.infos.length) > 0 ? <MessagePanel
|
||||
currentLayer={selectedLayer}
|
||||
selectedLayerIndex={this.state.selectedLayerIndex}
|
||||
onLayerSelect={this.onLayerSelect}
|
||||
mapStyle={this.state.mapStyle}
|
||||
errors={this.state.errors}
|
||||
infos={this.state.infos}
|
||||
/> : null
|
||||
|
||||
|
||||
const modals = <div>
|
||||
<ModalDebug
|
||||
renderer={this._getRenderer()}
|
||||
maplibreGlDebugOptions={this.state.maplibreGlDebugOptions}
|
||||
openlayersDebugOptions={this.state.openlayersDebugOptions}
|
||||
onChangeMaplibreGlDebug={this.onChangeMaplibreGlDebug}
|
||||
onChangeOpenlayersDebug={this.onChangeOpenlayersDebug}
|
||||
isOpen={this.state.isOpen.debug}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'debug')}
|
||||
mapView={this.state.mapView}
|
||||
/>
|
||||
<ModalShortcuts
|
||||
ref={(el) => this.shortcutEl = el}
|
||||
isOpen={this.state.isOpen.shortcuts}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'shortcuts')}
|
||||
/>
|
||||
<ModalSettings
|
||||
mapStyle={this.state.mapStyle}
|
||||
onStyleChanged={this.onStyleChanged}
|
||||
onChangeMetadataProperty={this.onChangeMetadataProperty}
|
||||
isOpen={this.state.isOpen.settings}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'settings')}
|
||||
openlayersDebugOptions={this.state.openlayersDebugOptions}
|
||||
/>
|
||||
<ModalExport
|
||||
mapStyle={this.state.mapStyle}
|
||||
onStyleChanged={this.onStyleChanged}
|
||||
isOpen={this.state.isOpen.export}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'export')}
|
||||
/>
|
||||
<ModalOpen
|
||||
isOpen={this.state.isOpen.open}
|
||||
onStyleOpen={this.openStyle}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'open')}
|
||||
/>
|
||||
<ModalSources
|
||||
mapStyle={this.state.mapStyle}
|
||||
onStyleChanged={this.onStyleChanged}
|
||||
isOpen={this.state.isOpen.sources}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'sources')}
|
||||
/>
|
||||
<ModalSurvey
|
||||
isOpen={this.state.isOpen.survey}
|
||||
onOpenToggle={this.toggleModal.bind(this, 'survey')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
return <AppLayout
|
||||
toolbar={toolbar}
|
||||
layerList={layerList}
|
||||
layerEditor={layerEditor}
|
||||
map={this.mapRenderer()}
|
||||
bottom={bottomPanel}
|
||||
modals={modals}
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import ScrollContainer from './ScrollContainer'
|
||||
|
||||
class AppLayout extends React.Component {
|
||||
static propTypes = {
|
||||
toolbar: PropTypes.element.isRequired,
|
||||
layerList: PropTypes.element.isRequired,
|
||||
layerEditor: PropTypes.element,
|
||||
map: PropTypes.element.isRequired,
|
||||
bottom: PropTypes.element,
|
||||
modals: PropTypes.node,
|
||||
}
|
||||
|
||||
static childContextTypes = {
|
||||
reactIconBase: PropTypes.object
|
||||
}
|
||||
|
||||
getChildContext() {
|
||||
return {
|
||||
reactIconBase: { size: 14 }
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div className="maputnik-layout">
|
||||
{this.props.toolbar}
|
||||
<div className="maputnik-layout-list">
|
||||
{this.props.layerList}
|
||||
</div>
|
||||
<div className="maputnik-layout-drawer">
|
||||
<ScrollContainer>
|
||||
{this.props.layerEditor}
|
||||
</ScrollContainer>
|
||||
</div>
|
||||
{this.props.map}
|
||||
{this.props.bottom && <div className="maputnik-layout-bottom">
|
||||
{this.props.bottom}
|
||||
</div>
|
||||
}
|
||||
{this.props.modals}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
export default AppLayout
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {formatLayerId} from '../util/format';
|
||||
|
||||
export default class AppMessagePanel extends React.Component {
|
||||
static propTypes = {
|
||||
errors: PropTypes.array,
|
||||
infos: PropTypes.array,
|
||||
mapStyle: PropTypes.object,
|
||||
onLayerSelect: PropTypes.func,
|
||||
currentLayer: PropTypes.object,
|
||||
selectedLayerIndex: PropTypes.number,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
onLayerSelect: () => {},
|
||||
}
|
||||
|
||||
render() {
|
||||
const {selectedLayerIndex} = this.props;
|
||||
const errors = this.props.errors.map((error, idx) => {
|
||||
let content;
|
||||
if (error.parsed && error.parsed.type === "layer") {
|
||||
const {parsed} = error;
|
||||
const {mapStyle, currentLayer} = this.props;
|
||||
const layerId = mapStyle.layers[parsed.data.index].id;
|
||||
content = (
|
||||
<>
|
||||
Layer <span>{formatLayerId(layerId)}</span>: {parsed.data.message}
|
||||
{selectedLayerIndex !== parsed.data.index &&
|
||||
<>
|
||||
—
|
||||
<button
|
||||
className="maputnik-message-panel__switch-button"
|
||||
onClick={() => this.props.onLayerSelect(parsed.data.index)}
|
||||
>
|
||||
switch to layer
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
else {
|
||||
content = error.message;
|
||||
}
|
||||
return <p key={"error-"+idx} className="maputnik-message-panel-error">
|
||||
{content}
|
||||
</p>
|
||||
})
|
||||
|
||||
const infos = this.props.infos.map((m, i) => {
|
||||
return <p key={"info-"+i}>{m}</p>
|
||||
})
|
||||
|
||||
return <div className="maputnik-message-panel">
|
||||
{errors}
|
||||
{infos}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import classnames from 'classnames'
|
||||
import {detect} from 'detect-browser';
|
||||
|
||||
import {MdFileDownload, MdOpenInBrowser, MdSettings, MdLayers, MdHelpOutline, MdFindInPage, MdAssignmentTurnedIn} from 'react-icons/md'
|
||||
import pkgJson from '../../package.json'
|
||||
|
||||
|
||||
// This is required because of <https://stackoverflow.com/a/49846426>, there isn't another way to detect support that I'm aware of.
|
||||
const browser = detect();
|
||||
const colorAccessibilityFiltersEnabled = ['chrome', 'firefox'].indexOf(browser.name) > -1;
|
||||
|
||||
|
||||
class IconText extends React.Component {
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
}
|
||||
|
||||
render() {
|
||||
return <span className="maputnik-icon-text">{this.props.children}</span>
|
||||
}
|
||||
}
|
||||
|
||||
class ToolbarLink extends React.Component {
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
href: PropTypes.string,
|
||||
onToggleModal: PropTypes.func,
|
||||
}
|
||||
|
||||
render() {
|
||||
return <a
|
||||
className={classnames('maputnik-toolbar-link', this.props.className)}
|
||||
href={this.props.href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{this.props.children}
|
||||
</a>
|
||||
}
|
||||
}
|
||||
|
||||
class ToolbarLinkHighlighted extends React.Component {
|
||||
static propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
href: PropTypes.string,
|
||||
onToggleModal: PropTypes.func
|
||||
}
|
||||
|
||||
render() {
|
||||
return <a
|
||||
className={classnames('maputnik-toolbar-link', "maputnik-toolbar-link--highlighted", this.props.className)}
|
||||
href={this.props.href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="maputnik-toolbar-link-wrapper">
|
||||
{this.props.children}
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
|
||||
class ToolbarSelect extends React.Component {
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
wdKey: PropTypes.string
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div
|
||||
className='maputnik-toolbar-select'
|
||||
data-wd-key={this.props.wdKey}
|
||||
>
|
||||
{this.props.children}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
class ToolbarAction extends React.Component {
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
onClick: PropTypes.func,
|
||||
wdKey: PropTypes.string
|
||||
}
|
||||
|
||||
render() {
|
||||
return <button
|
||||
className='maputnik-toolbar-action'
|
||||
data-wd-key={this.props.wdKey}
|
||||
onClick={this.props.onClick}
|
||||
>
|
||||
{this.props.children}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
export default class AppToolbar extends React.Component {
|
||||
static propTypes = {
|
||||
mapStyle: PropTypes.object.isRequired,
|
||||
inspectModeEnabled: PropTypes.bool.isRequired,
|
||||
onStyleChanged: PropTypes.func.isRequired,
|
||||
// A new style has been uploaded
|
||||
onStyleOpen: PropTypes.func.isRequired,
|
||||
// A dict of source id's and the available source layers
|
||||
sources: PropTypes.object.isRequired,
|
||||
children: PropTypes.node,
|
||||
onToggleModal: PropTypes.func,
|
||||
onSetMapState: PropTypes.func,
|
||||
mapState: PropTypes.string,
|
||||
renderer: PropTypes.string,
|
||||
}
|
||||
|
||||
state = {
|
||||
isOpen: {
|
||||
settings: false,
|
||||
sources: false,
|
||||
open: false,
|
||||
add: false,
|
||||
export: false,
|
||||
}
|
||||
}
|
||||
|
||||
handleSelection(val) {
|
||||
this.props.onSetMapState(val);
|
||||
}
|
||||
|
||||
onSkip = (target) => {
|
||||
if (target === "map") {
|
||||
document.querySelector(".maplibregl-canvas").focus();
|
||||
}
|
||||
else {
|
||||
const el = document.querySelector("#skip-target-"+target);
|
||||
el.focus();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const views = [
|
||||
{
|
||||
id: "map",
|
||||
group: "general",
|
||||
title: "Map",
|
||||
},
|
||||
{
|
||||
id: "inspect",
|
||||
group: "general",
|
||||
title: "Inspect",
|
||||
disabled: this.props.renderer === 'ol',
|
||||
},
|
||||
{
|
||||
id: "filter-deuteranopia",
|
||||
group: "color-accessibility",
|
||||
title: "Deuteranopia filter",
|
||||
disabled: !colorAccessibilityFiltersEnabled,
|
||||
},
|
||||
{
|
||||
id: "filter-protanopia",
|
||||
group: "color-accessibility",
|
||||
title: "Protanopia filter",
|
||||
disabled: !colorAccessibilityFiltersEnabled,
|
||||
},
|
||||
{
|
||||
id: "filter-tritanopia",
|
||||
group: "color-accessibility",
|
||||
title: "Tritanopia filter",
|
||||
disabled: !colorAccessibilityFiltersEnabled,
|
||||
},
|
||||
{
|
||||
id: "filter-achromatopsia",
|
||||
group: "color-accessibility",
|
||||
title: "Achromatopsia filter",
|
||||
disabled: !colorAccessibilityFiltersEnabled,
|
||||
},
|
||||
];
|
||||
|
||||
const currentView = views.find((view) => {
|
||||
return view.id === this.props.mapState;
|
||||
});
|
||||
|
||||
return <nav className='maputnik-toolbar'>
|
||||
<div className="maputnik-toolbar__inner">
|
||||
<div
|
||||
className="maputnik-toolbar-logo-container"
|
||||
>
|
||||
{/* Keyboard accessible quick links */}
|
||||
<button
|
||||
data-wd-key="root:skip:layer-list"
|
||||
className="maputnik-toolbar-skip"
|
||||
onClick={e => this.onSkip("layer-list")}
|
||||
>
|
||||
Layers list
|
||||
</button>
|
||||
<button
|
||||
data-wd-key="root:skip:layer-editor"
|
||||
className="maputnik-toolbar-skip"
|
||||
onClick={e => this.onSkip("layer-editor")}
|
||||
>
|
||||
Layer editor
|
||||
</button>
|
||||
<button
|
||||
data-wd-key="root:skip:map-view"
|
||||
className="maputnik-toolbar-skip"
|
||||
onClick={e => this.onSkip("map")}
|
||||
>
|
||||
Map view
|
||||
</button>
|
||||
<a
|
||||
className="maputnik-toolbar-logo"
|
||||
target="blank"
|
||||
rel="noreferrer noopener"
|
||||
href="https://github.com/maputnik/editor"
|
||||
>
|
||||
<img src="node_modules/maputnik-design/logos/logo-color.svg" />
|
||||
<h1>
|
||||
<span className="maputnik-toolbar-name">{pkgJson.name}</span>
|
||||
<span className="maputnik-toolbar-version">v{pkgJson.version}</span>
|
||||
</h1>
|
||||
</a>
|
||||
</div>
|
||||
<div className="maputnik-toolbar__actions" role="navigation" aria-label="Toolbar">
|
||||
<ToolbarAction wdKey="nav:open" onClick={this.props.onToggleModal.bind(this, 'open')}>
|
||||
<MdOpenInBrowser />
|
||||
<IconText>Open</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction wdKey="nav:export" onClick={this.props.onToggleModal.bind(this, 'export')}>
|
||||
<MdFileDownload />
|
||||
<IconText>Export</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction wdKey="nav:sources" onClick={this.props.onToggleModal.bind(this, 'sources')}>
|
||||
<MdLayers />
|
||||
<IconText>Data Sources</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction wdKey="nav:settings" onClick={this.props.onToggleModal.bind(this, 'settings')}>
|
||||
<MdSettings />
|
||||
<IconText>Style Settings</IconText>
|
||||
</ToolbarAction>
|
||||
|
||||
<ToolbarSelect wdKey="nav:inspect">
|
||||
<MdFindInPage />
|
||||
<label>View
|
||||
<select
|
||||
className="maputnik-select"
|
||||
data-wd-key="maputnik-select"
|
||||
onChange={(e) => this.handleSelection(e.target.value)}
|
||||
value={currentView.id}
|
||||
>
|
||||
{views.filter(v => v.group === "general").map((item) => {
|
||||
return (
|
||||
<option key={item.id} value={item.id} disabled={item.disabled} data-wd-key={item.id}>
|
||||
{item.title}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
<optgroup label="Color accessibility">
|
||||
{views.filter(v => v.group === "color-accessibility").map((item) => {
|
||||
return (
|
||||
<option key={item.id} value={item.id} disabled={item.disabled}>
|
||||
{item.title}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
</ToolbarSelect>
|
||||
|
||||
<ToolbarLink href={"https://github.com/maputnik/editor/wiki"}>
|
||||
<MdHelpOutline />
|
||||
<IconText>Help</IconText>
|
||||
</ToolbarLink>
|
||||
<ToolbarLinkHighlighted href={"https://gregorywolanski.typeform.com/to/cPgaSY"}>
|
||||
<MdAssignmentTurnedIn />
|
||||
<IconText>Take the Maputnik Survey</IconText>
|
||||
</ToolbarLinkHighlighted>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import classnames from 'classnames'
|
||||
import FieldDocLabel from './FieldDocLabel'
|
||||
import Doc from './Doc'
|
||||
|
||||
|
||||
/** Wrap a component with a label */
|
||||
export default class Block extends React.Component {
|
||||
static propTypes = {
|
||||
"data-wd-key": PropTypes.string,
|
||||
label: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.element,
|
||||
]),
|
||||
action: PropTypes.element,
|
||||
children: PropTypes.node.isRequired,
|
||||
style: PropTypes.object,
|
||||
onChange: PropTypes.func,
|
||||
fieldSpec: PropTypes.object,
|
||||
wideMode: PropTypes.bool,
|
||||
error: PropTypes.array,
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
showDoc: false,
|
||||
}
|
||||
}
|
||||
|
||||
onChange(e) {
|
||||
const value = e.target.value
|
||||
return this.props.onChange(value === "" ? undefined : value)
|
||||
}
|
||||
|
||||
onToggleDoc = (val) => {
|
||||
this.setState({
|
||||
showDoc: val
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Some fields for example <InputColor/> bind click events inside the element
|
||||
* to close the picker. This in turn propagates to the <label/> element
|
||||
* causing the picker to reopen. This causes a scenario where the picker can
|
||||
* never be closed once open.
|
||||
*/
|
||||
onLabelClick = (event) => {
|
||||
const el = event.nativeEvent.target;
|
||||
const nativeEvent = event.nativeEvent;
|
||||
const contains = this._blockEl.contains(el);
|
||||
|
||||
if (event.nativeEvent.target.nodeName !== "INPUT" && !contains) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
render() {
|
||||
const errors = [].concat(this.props.error || []);
|
||||
|
||||
return <label style={this.props.style}
|
||||
data-wd-key={this.props["data-wd-key"]}
|
||||
className={classnames({
|
||||
"maputnik-input-block": true,
|
||||
"maputnik-input-block--wide": this.props.wideMode,
|
||||
"maputnik-action-block": this.props.action
|
||||
})}
|
||||
onClick={this.onLabelClick}
|
||||
>
|
||||
{this.props.fieldSpec &&
|
||||
<div className="maputnik-input-block-label">
|
||||
<FieldDocLabel
|
||||
label={this.props.label}
|
||||
onToggleDoc={this.onToggleDoc}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{!this.props.fieldSpec &&
|
||||
<div className="maputnik-input-block-label">
|
||||
{this.props.label}
|
||||
</div>
|
||||
}
|
||||
<div className="maputnik-input-block-action">
|
||||
{this.props.action}
|
||||
</div>
|
||||
<div className="maputnik-input-block-content" ref={el => this._blockEl = el}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
{this.props.fieldSpec &&
|
||||
<div
|
||||
className="maputnik-doc-inline"
|
||||
style={{display: this.state.showDoc ? '' : 'none'}}
|
||||
>
|
||||
<Doc fieldSpec={this.props.fieldSpec} />
|
||||
</div>
|
||||
}
|
||||
</label>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Collapse as ReactCollapse } from 'react-collapse'
|
||||
import accessibility from '../../libs/accessibility'
|
||||
|
||||
|
||||
export default class Collapse extends React.Component {
|
||||
static propTypes = {
|
||||
isActive: PropTypes.bool.isRequired,
|
||||
children: PropTypes.element.isRequired
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
isActive: true
|
||||
}
|
||||
|
||||
render() {
|
||||
if (accessibility.reducedMotionEnabled()) {
|
||||
return (
|
||||
<div style={{display: this.props.isActive ? "block" : "none"}}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
else {
|
||||
return (
|
||||
<ReactCollapse isOpened={this.props.isActive}>
|
||||
{this.props.children}
|
||||
</ReactCollapse>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import {MdArrowDropDown, MdArrowDropUp} from 'react-icons/md'
|
||||
|
||||
export default class Collapser extends React.Component {
|
||||
static propTypes = {
|
||||
isCollapsed: PropTypes.bool.isRequired,
|
||||
style: PropTypes.object,
|
||||
}
|
||||
|
||||
render() {
|
||||
const iconStyle = {
|
||||
width: 20,
|
||||
height: 20,
|
||||
...this.props.style,
|
||||
}
|
||||
return this.props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
export default class Doc extends React.Component {
|
||||
static propTypes = {
|
||||
fieldSpec: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
render () {
|
||||
const {fieldSpec} = this.props;
|
||||
|
||||
const {doc, values} = fieldSpec;
|
||||
const sdkSupport = fieldSpec['sdk-support'];
|
||||
|
||||
const headers = {
|
||||
js: "JS",
|
||||
android: "Android",
|
||||
ios: "iOS",
|
||||
macos: "macOS",
|
||||
};
|
||||
|
||||
const renderValues = (
|
||||
!!values &&
|
||||
// HACK: Currently we merge additional values into the style spec, so this is required
|
||||
// See <https://github.com/maputnik/editor/blob/main/src/components/PropertyGroup.jsx#L16>
|
||||
!Array.isArray(values)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{doc &&
|
||||
<div className="SpecDoc">
|
||||
<div className="SpecDoc__doc">{doc}</div>
|
||||
{renderValues &&
|
||||
<ul className="SpecDoc__values">
|
||||
{Object.entries(values).map(([key, value]) => {
|
||||
return (
|
||||
<li key={key}>
|
||||
<code>{JSON.stringify(key)}</code>
|
||||
<div>{value.doc}</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
{sdkSupport &&
|
||||
<div className="SpecDoc__sdk-support">
|
||||
<table className="SpecDoc__sdk-support__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
{Object.values(headers).map(header => {
|
||||
return <th key={header}>{header}</th>;
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(sdkSupport).map(([key, supportObj]) => {
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>{key}</td>
|
||||
{Object.keys(headers).map(k => {
|
||||
const value = supportObj[k];
|
||||
if (supportObj.hasOwnProperty(k)) {
|
||||
return <td key={k}>{supportObj[k]}</td>;
|
||||
}
|
||||
else {
|
||||
return <td key={k}>no</td>;
|
||||
}
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Block from './Block'
|
||||
import InputArray from './InputArray'
|
||||
import Fieldset from './Fieldset'
|
||||
|
||||
export default class FieldArray extends React.Component {
|
||||
static propTypes = {
|
||||
...InputArray.propTypes,
|
||||
name: PropTypes.string,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
|
||||
return <Fieldset label={props.label}>
|
||||
<InputArray {...props} />
|
||||
</Fieldset>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Block from './Block'
|
||||
import InputAutocomplete from './InputAutocomplete'
|
||||
|
||||
|
||||
export default class FieldAutocomplete extends React.Component {
|
||||
static propTypes = {
|
||||
...InputAutocomplete.propTypes,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
|
||||
return <Block label={props.label}>
|
||||
<InputAutocomplete {...props} />
|
||||
</Block>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Block from './Block'
|
||||
import InputCheckbox from './InputCheckbox'
|
||||
|
||||
|
||||
export default class FieldCheckbox extends React.Component {
|
||||
static propTypes = {
|
||||
...InputCheckbox.propTypes,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
|
||||
return <Block label={this.props.label}>
|
||||
<InputCheckbox {...props} />
|
||||
</Block>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Block from './Block'
|
||||
import InputColor from './InputColor'
|
||||
|
||||
|
||||
export default class FieldColor extends React.Component {
|
||||
static propTypes = {
|
||||
...InputColor.propTypes,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
|
||||
return <Block label={props.label}>
|
||||
<InputColor {...props} />
|
||||
</Block>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import Block from './Block'
|
||||
import InputString from './InputString'
|
||||
|
||||
export default class FieldComment extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
render() {
|
||||
const fieldSpec = {
|
||||
doc: "Comments for the current layer. This is non-standard and not in the spec."
|
||||
};
|
||||
|
||||
return <Block
|
||||
label={"Comments"}
|
||||
fieldSpec={fieldSpec}
|
||||
data-wd-key="layer-comment"
|
||||
>
|
||||
<InputString
|
||||
multi={true}
|
||||
value={this.props.value}
|
||||
onChange={this.props.onChange}
|
||||
default="Comment..."
|
||||
/>
|
||||
</Block>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import {MdInfoOutline, MdHighlightOff} from 'react-icons/md'
|
||||
|
||||
export default class FieldDocLabel extends React.Component {
|
||||
static propTypes = {
|
||||
label: PropTypes.oneOfType([
|
||||
PropTypes.object,
|
||||
PropTypes.string
|
||||
]).isRequired,
|
||||
fieldSpec: PropTypes.object,
|
||||
onToggleDoc: PropTypes.func,
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
open: false,
|
||||
}
|
||||
}
|
||||
|
||||
onToggleDoc = (open) => {
|
||||
this.setState({
|
||||
open,
|
||||
}, () => {
|
||||
if (this.props.onToggleDoc) {
|
||||
this.props.onToggleDoc(this.state.open);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {label, fieldSpec} = this.props;
|
||||
const {doc} = fieldSpec || {};
|
||||
|
||||
if (doc) {
|
||||
return <label className="maputnik-doc-wrapper">
|
||||
<div className="maputnik-doc-target">
|
||||
{label}
|
||||
{'\xa0'}
|
||||
<button
|
||||
aria-label={this.state.open ? "close property documentation" : "open property documentation"}
|
||||
className={`maputnik-doc-button maputnik-doc-button--${this.state.open ? 'open' : 'closed'}`}
|
||||
onClick={() => this.onToggleDoc(!this.state.open)}
|
||||
>
|
||||
{this.state.open ? <MdHighlightOff /> : <MdInfoOutline />}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
else if (label) {
|
||||
return <label className="maputnik-doc-wrapper">
|
||||
<div className="maputnik-doc-target">
|
||||
{label}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
else {
|
||||
<div />
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Block from './Block'
|
||||
import InputDynamicArray from './InputDynamicArray'
|
||||
import Fieldset from './Fieldset'
|
||||
|
||||
export default class FieldDynamicArray extends React.Component {
|
||||
static propTypes = {
|
||||
...InputDynamicArray.propTypes,
|
||||
name: PropTypes.string,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
|
||||
return <Fieldset label={props.label}>
|
||||
<InputDynamicArray {...props} />
|
||||
</Fieldset>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import InputEnum from './InputEnum'
|
||||
import Block from './Block';
|
||||
import Fieldset from './Fieldset';
|
||||
|
||||
|
||||
export default class FieldEnum extends React.Component {
|
||||
static propTypes = {
|
||||
...InputEnum.propTypes,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
|
||||
return <Fieldset label={props.label}>
|
||||
<InputEnum {...props} />
|
||||
</Fieldset>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import SpecProperty from './_SpecProperty'
|
||||
import DataProperty from './_DataProperty'
|
||||
import ZoomProperty from './_ZoomProperty'
|
||||
import ExpressionProperty from './_ExpressionProperty'
|
||||
import {function as styleFunction} from '@maplibre/maplibre-gl-style-spec';
|
||||
import {findDefaultFromSpec} from '../util/spec-helper';
|
||||
|
||||
|
||||
function isLiteralExpression (value) {
|
||||
return (Array.isArray(value) && value.length === 2 && value[0] === "literal");
|
||||
}
|
||||
|
||||
function isGetExpression (value) {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length === 2 &&
|
||||
value[0] === "get"
|
||||
);
|
||||
}
|
||||
|
||||
function isZoomField(value) {
|
||||
return (
|
||||
typeof(value) === 'object' &&
|
||||
value.stops &&
|
||||
typeof(value.property) === 'undefined' &&
|
||||
Array.isArray(value.stops) &&
|
||||
value.stops.length > 1 &&
|
||||
value.stops.every(stop => {
|
||||
return (
|
||||
Array.isArray(stop) &&
|
||||
stop.length === 2
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isIdentityProperty (value) {
|
||||
return (
|
||||
typeof(value) === 'object' &&
|
||||
value.type === "identity" &&
|
||||
value.hasOwnProperty("property")
|
||||
);
|
||||
}
|
||||
|
||||
function isDataStopProperty (value) {
|
||||
return (
|
||||
typeof(value) === 'object' &&
|
||||
value.stops &&
|
||||
typeof(value.property) !== 'undefined' &&
|
||||
value.stops.length > 1 &&
|
||||
Array.isArray(value.stops) &&
|
||||
value.stops.every(stop => {
|
||||
return (
|
||||
Array.isArray(stop) &&
|
||||
stop.length === 2 &&
|
||||
typeof(stop[0]) === 'object'
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function isDataField(value) {
|
||||
return (
|
||||
isIdentityProperty(value) ||
|
||||
isDataStopProperty(value)
|
||||
);
|
||||
}
|
||||
|
||||
function isPrimative (value) {
|
||||
const valid = ["string", "boolean", "number"];
|
||||
return valid.includes(typeof(value));
|
||||
}
|
||||
|
||||
function isArrayOfPrimatives (values) {
|
||||
if (Array.isArray(values)) {
|
||||
return values.every(isPrimative);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getDataType (value, fieldSpec={}) {
|
||||
if (value === undefined) {
|
||||
return "value";
|
||||
}
|
||||
else if (isPrimative(value)) {
|
||||
return "value";
|
||||
}
|
||||
else if (fieldSpec.type === "array" && isArrayOfPrimatives(value)) {
|
||||
return "value";
|
||||
}
|
||||
else if (isZoomField(value)) {
|
||||
return "zoom_function";
|
||||
}
|
||||
else if (isDataField(value)) {
|
||||
return "data_function";
|
||||
}
|
||||
else {
|
||||
return "expression";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Supports displaying spec field for zoom function objects
|
||||
* https://www.mapbox.com/mapbox-gl-style-spec/#types-function-zoom-property
|
||||
*/
|
||||
export default class FieldFunction extends React.Component {
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
fieldName: PropTypes.string.isRequired,
|
||||
fieldType: PropTypes.string.isRequired,
|
||||
fieldSpec: PropTypes.object.isRequired,
|
||||
errors: PropTypes.object,
|
||||
|
||||
value: PropTypes.oneOfType([
|
||||
PropTypes.object,
|
||||
PropTypes.string,
|
||||
PropTypes.number,
|
||||
PropTypes.bool,
|
||||
PropTypes.array
|
||||
]),
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super();
|
||||
this.state = {
|
||||
dataType: getDataType(props.value, props.fieldSpec),
|
||||
isEditing: false,
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
// Because otherwise when editing values we end up accidentally changing field type.
|
||||
if (state.isEditing) {
|
||||
return {};
|
||||
}
|
||||
else {
|
||||
return {
|
||||
isEditing: false,
|
||||
dataType: getDataType(props.value, props.fieldSpec)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getFieldFunctionType(fieldSpec) {
|
||||
if (fieldSpec.expression.interpolated) {
|
||||
return "exponential"
|
||||
}
|
||||
if (fieldSpec.type === "number") {
|
||||
return "interval"
|
||||
}
|
||||
return "categorical"
|
||||
}
|
||||
|
||||
addStop = () => {
|
||||
const stops = this.props.value.stops.slice(0)
|
||||
const lastStop = stops[stops.length - 1]
|
||||
if (typeof lastStop[0] === "object") {
|
||||
stops.push([
|
||||
{zoom: lastStop[0].zoom + 1, value: lastStop[0].value},
|
||||
lastStop[1]
|
||||
])
|
||||
}
|
||||
else {
|
||||
stops.push([lastStop[0] + 1, lastStop[1]])
|
||||
}
|
||||
|
||||
const changedValue = {
|
||||
...this.props.value,
|
||||
stops: stops,
|
||||
}
|
||||
|
||||
this.props.onChange(this.props.fieldName, changedValue)
|
||||
}
|
||||
|
||||
deleteExpression = () => {
|
||||
const {fieldSpec, fieldName} = this.props;
|
||||
this.props.onChange(fieldName, fieldSpec.default);
|
||||
this.setState({
|
||||
dataType: "value",
|
||||
});
|
||||
}
|
||||
|
||||
deleteStop = (stopIdx) => {
|
||||
const stops = this.props.value.stops.slice(0)
|
||||
stops.splice(stopIdx, 1)
|
||||
|
||||
let changedValue = {
|
||||
...this.props.value,
|
||||
stops: stops,
|
||||
}
|
||||
|
||||
if(stops.length === 1) {
|
||||
changedValue = stops[0][1]
|
||||
}
|
||||
|
||||
this.props.onChange(this.props.fieldName, changedValue)
|
||||
}
|
||||
|
||||
makeZoomFunction = () => {
|
||||
const {value} = this.props;
|
||||
|
||||
let zoomFunc;
|
||||
if (typeof(value) === "object") {
|
||||
if (value.stops) {
|
||||
zoomFunc = {
|
||||
base: value.base,
|
||||
stops: value.stops.map(stop => {
|
||||
return [stop[0].zoom, stop[1] || findDefaultFromSpec(this.props.fieldSpec)];
|
||||
})
|
||||
}
|
||||
}
|
||||
else {
|
||||
zoomFunc = {
|
||||
base: value.base,
|
||||
stops: [
|
||||
[6, findDefaultFromSpec(this.props.fieldSpec)],
|
||||
[10, findDefaultFromSpec(this.props.fieldSpec)]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
zoomFunc = {
|
||||
stops: [
|
||||
[6, value || findDefaultFromSpec(this.props.fieldSpec)],
|
||||
[10, value || findDefaultFromSpec(this.props.fieldSpec)]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
this.props.onChange(this.props.fieldName, zoomFunc)
|
||||
}
|
||||
|
||||
undoExpression = () => {
|
||||
const {value, fieldName} = this.props;
|
||||
|
||||
if (isGetExpression(value)) {
|
||||
this.props.onChange(fieldName, {
|
||||
"type": "identity",
|
||||
"property": value[1]
|
||||
});
|
||||
this.setState({
|
||||
dataType: "value",
|
||||
});
|
||||
}
|
||||
else if (isLiteralExpression(value)) {
|
||||
this.props.onChange(fieldName, value[1]);
|
||||
this.setState({
|
||||
dataType: "value",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
canUndo = () => {
|
||||
const {value, fieldSpec} = this.props;
|
||||
return (
|
||||
isGetExpression(value) ||
|
||||
isLiteralExpression(value) ||
|
||||
isPrimative(value) ||
|
||||
(Array.isArray(value) && fieldSpec.type === "array")
|
||||
);
|
||||
}
|
||||
|
||||
makeExpression = () => {
|
||||
const {value, fieldSpec} = this.props;
|
||||
let expression;
|
||||
|
||||
if (typeof(value) === "object" && 'stops' in value) {
|
||||
expression = styleFunction.convertFunction(value, fieldSpec);
|
||||
}
|
||||
else if (isIdentityProperty(value)) {
|
||||
expression = ["get", value.property];
|
||||
}
|
||||
else {
|
||||
expression = ["literal", value || this.props.fieldSpec.default];
|
||||
}
|
||||
this.props.onChange(this.props.fieldName, expression);
|
||||
}
|
||||
|
||||
makeDataFunction = () => {
|
||||
const functionType = this.getFieldFunctionType(this.props.fieldSpec);
|
||||
const stopValue = functionType === 'categorical' ? '' : 0;
|
||||
const {value} = this.props;
|
||||
let dataFunc;
|
||||
|
||||
if (typeof(value) === "object") {
|
||||
if (value.stops) {
|
||||
dataFunc = {
|
||||
property: "",
|
||||
type: functionType,
|
||||
base: value.base,
|
||||
stops: value.stops.map(stop => {
|
||||
return [{zoom: stop[0], value: stopValue}, stop[1] || findDefaultFromSpec(this.props.fieldSpec)];
|
||||
})
|
||||
}
|
||||
}
|
||||
else {
|
||||
dataFunc = {
|
||||
property: "",
|
||||
type: functionType,
|
||||
base: value.base,
|
||||
stops: [
|
||||
[{zoom: 6, value: stopValue}, findDefaultFromSpec(this.props.fieldSpec)],
|
||||
[{zoom: 10, value: stopValue}, findDefaultFromSpec(this.props.fieldSpec)]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
dataFunc = {
|
||||
property: "",
|
||||
type: functionType,
|
||||
base: value.base,
|
||||
stops: [
|
||||
[{zoom: 6, value: stopValue}, this.props.value || findDefaultFromSpec(this.props.fieldSpec)],
|
||||
[{zoom: 10, value: stopValue}, this.props.value || findDefaultFromSpec(this.props.fieldSpec)]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
this.props.onChange(this.props.fieldName, dataFunc)
|
||||
}
|
||||
|
||||
onMarkEditing = () => {
|
||||
this.setState({isEditing: true});
|
||||
}
|
||||
|
||||
onUnmarkEditing = () => {
|
||||
this.setState({isEditing: false});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {dataType} = this.state;
|
||||
const propClass = this.props.fieldSpec.default === this.props.value ? "maputnik-default-property" : "maputnik-modified-property"
|
||||
let specField;
|
||||
|
||||
if (dataType === "expression") {
|
||||
specField = (
|
||||
<ExpressionProperty
|
||||
errors={this.props.errors}
|
||||
onChange={this.props.onChange.bind(this, this.props.fieldName)}
|
||||
canUndo={this.canUndo}
|
||||
onUndo={this.undoExpression}
|
||||
onDelete={this.deleteExpression}
|
||||
fieldType={this.props.fieldType}
|
||||
fieldName={this.props.fieldName}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
value={this.props.value}
|
||||
onFocus={this.onMarkEditing}
|
||||
onBlur={this.onUnmarkEditing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
else if (dataType === "zoom_function") {
|
||||
specField = (
|
||||
<ZoomProperty
|
||||
errors={this.props.errors}
|
||||
onChange={this.props.onChange.bind(this)}
|
||||
fieldType={this.props.fieldType}
|
||||
fieldName={this.props.fieldName}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
value={this.props.value}
|
||||
onDeleteStop={this.deleteStop}
|
||||
onAddStop={this.addStop}
|
||||
onChangeToDataFunction={this.makeDataFunction}
|
||||
onExpressionClick={this.makeExpression}
|
||||
/>
|
||||
)
|
||||
}
|
||||
else if (dataType === "data_function") {
|
||||
// TODO: Rename to FieldFunction **this file** shouldn't be called that
|
||||
specField = (
|
||||
<DataProperty
|
||||
errors={this.props.errors}
|
||||
onChange={this.props.onChange.bind(this)}
|
||||
fieldType={this.props.fieldType}
|
||||
fieldName={this.props.fieldName}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
value={this.props.value}
|
||||
onDeleteStop={this.deleteStop}
|
||||
onAddStop={this.addStop}
|
||||
onChangeToZoomFunction={this.makeZoomFunction}
|
||||
onExpressionClick={this.makeExpression}
|
||||
/>
|
||||
)
|
||||
}
|
||||
else {
|
||||
specField = (
|
||||
<SpecProperty
|
||||
errors={this.props.errors}
|
||||
onChange={this.props.onChange.bind(this)}
|
||||
fieldType={this.props.fieldType}
|
||||
fieldName={this.props.fieldName}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
value={this.props.value}
|
||||
onZoomClick={this.makeZoomFunction}
|
||||
onDataClick={this.makeDataFunction}
|
||||
onExpressionClick={this.makeExpression}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <div className={propClass} data-wd-key={"spec-field:"+this.props.fieldName}>
|
||||
{specField}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import {latest} from '@maplibre/maplibre-gl-style-spec'
|
||||
import Block from './Block'
|
||||
import InputString from './InputString'
|
||||
|
||||
export default class FieldId extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
wdKey: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
error: PropTypes.object,
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Block label={"ID"} fieldSpec={latest.layer.id}
|
||||
data-wd-key={this.props.wdKey}
|
||||
error={this.props.error}
|
||||
>
|
||||
<InputString
|
||||
value={this.props.value}
|
||||
onInput={this.props.onChange}
|
||||
/>
|
||||
</Block>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import InputJson from './InputJson'
|
||||
|
||||
|
||||
export default class FieldJson extends React.Component {
|
||||
static propTypes = {
|
||||
...InputJson.propTypes,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
return <InputJson {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import {latest} from '@maplibre/maplibre-gl-style-spec'
|
||||
import Block from './Block'
|
||||
import InputNumber from './InputNumber'
|
||||
|
||||
export default class FieldMaxZoom extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.number,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
error: PropTypes.object,
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Block label={"Max Zoom"} fieldSpec={latest.layer.maxzoom}
|
||||
error={this.props.error}
|
||||
data-wd-key="max-zoom"
|
||||
>
|
||||
<InputNumber
|
||||
allowRange={true}
|
||||
value={this.props.value}
|
||||
onChange={this.props.onChange}
|
||||
min={latest.layer.maxzoom.minimum}
|
||||
max={latest.layer.maxzoom.maximum}
|
||||
default={latest.layer.maxzoom.maximum}
|
||||
/>
|
||||
</Block>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import {latest} from '@maplibre/maplibre-gl-style-spec'
|
||||
import Block from './Block'
|
||||
import InputNumber from './InputNumber'
|
||||
|
||||
export default class FieldMinZoom extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.number,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
error: PropTypes.object,
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Block label={"Min Zoom"} fieldSpec={latest.layer.minzoom}
|
||||
error={this.props.error}
|
||||
data-wd-key="min-zoom"
|
||||
>
|
||||
<InputNumber
|
||||
allowRange={true}
|
||||
value={this.props.value}
|
||||
onChange={this.props.onChange}
|
||||
min={latest.layer.minzoom.minimum}
|
||||
max={latest.layer.minzoom.maximum}
|
||||
default={latest.layer.minzoom.minimum}
|
||||
/>
|
||||
</Block>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Block from './Block'
|
||||
import InputMultiInput from './InputMultiInput'
|
||||
import Fieldset from './Fieldset'
|
||||
|
||||
|
||||
export default class FieldMultiInput extends React.Component {
|
||||
static propTypes = {
|
||||
...InputMultiInput.propTypes,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
|
||||
return <Fieldset label={props.label}>
|
||||
<InputMultiInput {...props} />
|
||||
</Fieldset>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import InputNumber from './InputNumber'
|
||||
import Block from './Block'
|
||||
|
||||
|
||||
export default class FieldNumber extends React.Component {
|
||||
static propTypes = {
|
||||
...InputNumber.propTypes,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
return <Block label={props.label}>
|
||||
<InputNumber {...props} />
|
||||
</Block>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Block from './Block'
|
||||
import InputSelect from './InputSelect'
|
||||
|
||||
|
||||
export default class FieldSelect extends React.Component {
|
||||
static propTypes = {
|
||||
...InputSelect.propTypes,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
|
||||
return <Block label={props.label}>
|
||||
<InputSelect {...props}/>
|
||||
</Block>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import {latest} from '@maplibre/maplibre-gl-style-spec'
|
||||
import Block from './Block'
|
||||
import InputAutocomplete from './InputAutocomplete'
|
||||
|
||||
export default class FieldSource extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
wdKey: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
sourceIds: PropTypes.array,
|
||||
error: PropTypes.object,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
onChange: () => {},
|
||||
sourceIds: [],
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Block
|
||||
label={"Source"}
|
||||
fieldSpec={latest.layer.source}
|
||||
error={this.props.error}
|
||||
data-wd-key={this.props.wdKey}
|
||||
>
|
||||
<InputAutocomplete
|
||||
value={this.props.value}
|
||||
onChange={this.props.onChange}
|
||||
options={this.props.sourceIds.map(src => [src, src])}
|
||||
/>
|
||||
</Block>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import {latest} from '@maplibre/maplibre-gl-style-spec'
|
||||
import Block from './Block'
|
||||
import InputAutocomplete from './InputAutocomplete'
|
||||
|
||||
export default class FieldSourceLayer extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
sourceLayerIds: PropTypes.array,
|
||||
isFixed: PropTypes.bool,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
onChange: () => {},
|
||||
sourceLayerIds: [],
|
||||
isFixed: false
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Block label={"Source Layer"} fieldSpec={latest.layer['source-layer']}
|
||||
data-wd-key="layer-source-layer"
|
||||
>
|
||||
<InputAutocomplete
|
||||
keepMenuWithinWindowBounds={!!this.props.isFixed}
|
||||
value={this.props.value}
|
||||
onChange={this.props.onChange}
|
||||
options={this.props.sourceLayerIds.map(l => [l, l])}
|
||||
/>
|
||||
</Block>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Block from './Block'
|
||||
import InputString from './InputString'
|
||||
|
||||
export default class FieldString extends React.Component {
|
||||
static propTypes = {
|
||||
...InputString.propTypes,
|
||||
name: PropTypes.string,
|
||||
}
|
||||
|
||||
render() {
|
||||
const {props} = this;
|
||||
|
||||
return <Block label={props.label}>
|
||||
<InputString {...props} />
|
||||
</Block>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
|
||||
import {latest} from '@maplibre/maplibre-gl-style-spec'
|
||||
import Block from './Block'
|
||||
import InputSelect from './InputSelect'
|
||||
import InputString from './InputString'
|
||||
|
||||
export default class FieldType extends React.Component {
|
||||
static propTypes = {
|
||||
value: PropTypes.string.isRequired,
|
||||
wdKey: PropTypes.string,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
error: PropTypes.object,
|
||||
disabled: PropTypes.bool,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
disabled: false,
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Block label={"Type"} fieldSpec={latest.layer.type}
|
||||
data-wd-key={this.props.wdKey}
|
||||
error={this.props.error}
|
||||
>
|
||||
{this.props.disabled &&
|
||||
<InputString
|
||||
value={this.props.value}
|
||||
disabled={true}
|
||||
/>
|
||||
}
|
||||
{!this.props.disabled &&
|
||||
<InputSelect
|
||||
options={[
|
||||
['background', 'Background'],
|
||||
['fill', 'Fill'],
|
||||
['line', 'Line'],
|
||||
['symbol', 'Symbol'],
|
||||
['raster', 'Raster'],
|
||||
['circle', 'Circle'],
|
||||
['fill-extrusion', 'Fill Extrusion'],
|
||||
['hillshade', 'Hillshade'],
|
||||
['heatmap', 'Heatmap'],
|
||||
]}
|
||||
onChange={this.props.onChange}
|
||||
value={this.props.value}
|
||||
/>
|
||||
}
|
||||
</Block>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React, {Fragment} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import InputUrl from './InputUrl'
|
||||
import Block from './Block'
|
||||
|
||||
|
||||
export default class FieldUrl extends React.Component {
|
||||
static propTypes = {
|
||||
...InputUrl.propTypes,
|
||||
}
|
||||
|
||||
render () {
|
||||
const {props} = this;
|
||||
|
||||
return (
|
||||
<Block label={this.props.label}>
|
||||
<InputUrl {...props} />
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import FieldDocLabel from './FieldDocLabel'
|
||||
import Doc from './Doc'
|
||||
|
||||
|
||||
let IDX = 0;
|
||||
|
||||
export default class Fieldset extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this._labelId = `fieldset_label_${(IDX++)}`;
|
||||
this.state = {
|
||||
showDoc: false,
|
||||
}
|
||||
}
|
||||
|
||||
onToggleDoc = (val) => {
|
||||
this.setState({
|
||||
showDoc: val
|
||||
});
|
||||
}
|
||||
|
||||
render () {
|
||||
const {props} = this;
|
||||
|
||||
return <div className="maputnik-input-block" role="group" aria-labelledby={this._labelId}>
|
||||
{this.props.fieldSpec &&
|
||||
<div className="maputnik-input-block-label">
|
||||
<FieldDocLabel
|
||||
label={this.props.label}
|
||||
onToggleDoc={this.onToggleDoc}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{!this.props.fieldSpec &&
|
||||
<div className="maputnik-input-block-label">
|
||||
{props.label}
|
||||
</div>
|
||||
}
|
||||
<div className="maputnik-input-block-action">
|
||||
{this.props.action}
|
||||
</div>
|
||||
<div className="maputnik-input-block-content">
|
||||
{props.children}
|
||||
</div>
|
||||
{this.props.fieldSpec &&
|
||||
<div
|
||||
className="maputnik-doc-inline"
|
||||
style={{display: this.state.showDoc ? '' : 'none'}}
|
||||
>
|
||||
<Doc fieldSpec={this.props.fieldSpec} />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { combiningFilterOps } from '../libs/filterops.js'
|
||||
import {mdiTableRowPlusAfter} from '@mdi/js';
|
||||
import {isEqual} from 'lodash';
|
||||
|
||||
import {latest, migrate, convertFilter} from '@maplibre/maplibre-gl-style-spec'
|
||||
import InputSelect from './InputSelect'
|
||||
import Block from './Block'
|
||||
import SingleFilterEditor from './SingleFilterEditor'
|
||||
import FilterEditorBlock from './FilterEditorBlock'
|
||||
import InputButton from './InputButton'
|
||||
import Doc from './Doc'
|
||||
import ExpressionProperty from './_ExpressionProperty';
|
||||
import {mdiFunctionVariant} from '@mdi/js';
|
||||
|
||||
|
||||
function combiningFilter (props) {
|
||||
let filter = props.filter || ['all'];
|
||||
|
||||
if (!Array.isArray(filter)) {
|
||||
return filter;
|
||||
}
|
||||
|
||||
let combiningOp = filter[0];
|
||||
let filters = filter.slice(1);
|
||||
|
||||
if(combiningFilterOps.indexOf(combiningOp) < 0) {
|
||||
combiningOp = 'all';
|
||||
filters = [filter.slice(0)];
|
||||
}
|
||||
|
||||
return [combiningOp, ...filters];
|
||||
}
|
||||
|
||||
function migrateFilter (filter) {
|
||||
return migrate(createStyleFromFilter(filter)).layers[0].filter;
|
||||
}
|
||||
|
||||
function createStyleFromFilter (filter) {
|
||||
return {
|
||||
"id": "tmp",
|
||||
"version": 8,
|
||||
"name": "Empty Style",
|
||||
"metadata": {"maputnik:renderer": "mlgljs"},
|
||||
"sources": {
|
||||
"tmp": {
|
||||
"type": "geojson",
|
||||
"data": {}
|
||||
}
|
||||
},
|
||||
"sprite": "",
|
||||
"glyphs": "https://orangemug.github.io/font-glyphs/glyphs/{fontstack}/{range}.pbf",
|
||||
"layers": [
|
||||
{
|
||||
id: "tmp",
|
||||
type: "fill",
|
||||
source: "tmp",
|
||||
filter: filter,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const FILTER_OPS = [
|
||||
"all",
|
||||
"any",
|
||||
"none"
|
||||
];
|
||||
|
||||
// If we convert a filter that is an expression to an expression it'll remain the same in value
|
||||
function checkIfSimpleFilter (filter) {
|
||||
if (filter.length === 1 && FILTER_OPS.includes(filter[0])) {
|
||||
return true;
|
||||
}
|
||||
const expression = convertFilter(filter);
|
||||
return !isEqual(expression, filter);
|
||||
}
|
||||
|
||||
function hasCombiningFilter(filter) {
|
||||
return combiningFilterOps.indexOf(filter[0]) >= 0
|
||||
}
|
||||
|
||||
function hasNestedCombiningFilter(filter) {
|
||||
if(hasCombiningFilter(filter)) {
|
||||
const combinedFilters = filter.slice(1)
|
||||
return filter.slice(1).map(f => hasCombiningFilter(f)).filter(f => f == true).length > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export default class FilterEditor extends React.Component {
|
||||
static propTypes = {
|
||||
/** Properties of the vector layer and the available fields */
|
||||
properties: PropTypes.object,
|
||||
filter: PropTypes.array,
|
||||
errors: PropTypes.object,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
filter: ["all"],
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
super();
|
||||
this.state = {
|
||||
showDoc: false,
|
||||
displaySimpleFilter: checkIfSimpleFilter(combiningFilter(props)),
|
||||
};
|
||||
}
|
||||
|
||||
// Convert filter to combining filter
|
||||
onFilterPartChanged(filterIdx, newPart) {
|
||||
const newFilter = combiningFilter(this.props).slice(0)
|
||||
newFilter[filterIdx] = newPart
|
||||
this.props.onChange(newFilter)
|
||||
}
|
||||
|
||||
deleteFilterItem(filterIdx) {
|
||||
const newFilter = combiningFilter(this.props).slice(0)
|
||||
newFilter.splice(filterIdx + 1, 1)
|
||||
this.props.onChange(newFilter)
|
||||
}
|
||||
|
||||
addFilterItem = () => {
|
||||
const newFilterItem = combiningFilter(this.props).slice(0)
|
||||
newFilterItem.push(['==', 'name', ''])
|
||||
this.props.onChange(newFilterItem)
|
||||
}
|
||||
|
||||
onToggleDoc = (val) => {
|
||||
this.setState({
|
||||
showDoc: val
|
||||
});
|
||||
}
|
||||
|
||||
makeFilter = () => {
|
||||
this.setState({
|
||||
displaySimpleFilter: true,
|
||||
})
|
||||
}
|
||||
|
||||
makeExpression = () => {
|
||||
let filter = combiningFilter(this.props);
|
||||
this.props.onChange(migrateFilter(filter));
|
||||
this.setState({
|
||||
displaySimpleFilter: false,
|
||||
})
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps (props, currentState) {
|
||||
const {filter} = props;
|
||||
const displaySimpleFilter = checkIfSimpleFilter(combiningFilter(props));
|
||||
|
||||
// Upgrade but never downgrade
|
||||
if (!displaySimpleFilter && currentState.displaySimpleFilter === true) {
|
||||
return {
|
||||
displaySimpleFilter: false,
|
||||
valueIsSimpleFilter: false,
|
||||
};
|
||||
}
|
||||
else if (displaySimpleFilter && currentState.displaySimpleFilter === false) {
|
||||
return {
|
||||
valueIsSimpleFilter: true,
|
||||
}
|
||||
}
|
||||
else {
|
||||
return {
|
||||
valueIsSimpleFilter: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {errors} = this.props;
|
||||
const {displaySimpleFilter} = this.state;
|
||||
const fieldSpec={
|
||||
doc: latest.layer.filter.doc + " Combine multiple filters together by using a compound filter."
|
||||
};
|
||||
const defaultFilter = ["all"];
|
||||
|
||||
const isNestedCombiningFilter = displaySimpleFilter && hasNestedCombiningFilter(combiningFilter(this.props));
|
||||
|
||||
if (isNestedCombiningFilter) {
|
||||
return <div className="maputnik-filter-editor-unsupported">
|
||||
<p>
|
||||
Nested filters are not supported.
|
||||
</p>
|
||||
<InputButton
|
||||
onClick={this.makeExpression}
|
||||
title="Convert to expression"
|
||||
>
|
||||
<svg style={{marginRight: "0.2em", width:"14px", height:"14px", verticalAlign: "middle"}} viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d={mdiFunctionVariant} />
|
||||
</svg>
|
||||
Upgrade to expression
|
||||
</InputButton>
|
||||
</div>
|
||||
}
|
||||
else if (displaySimpleFilter) {
|
||||
const filter = combiningFilter(this.props);
|
||||
let combiningOp = filter[0];
|
||||
let filters = filter.slice(1)
|
||||
|
||||
const actions = (
|
||||
<div>
|
||||
<InputButton
|
||||
onClick={this.makeExpression}
|
||||
title="Convert to expression"
|
||||
className="maputnik-make-zoom-function"
|
||||
>
|
||||
<svg style={{width:"14px", height:"14px", verticalAlign: "middle"}} viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d={mdiFunctionVariant} />
|
||||
</svg>
|
||||
</InputButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
const editorBlocks = filters.map((f, idx) => {
|
||||
const error = errors[`filter[${idx+1}]`];
|
||||
|
||||
return (
|
||||
<div key={`block-${idx}`}>
|
||||
<FilterEditorBlock key={idx} onDelete={this.deleteFilterItem.bind(this, idx)}>
|
||||
<SingleFilterEditor
|
||||
properties={this.props.properties}
|
||||
filter={f}
|
||||
onChange={this.onFilterPartChanged.bind(this, idx + 1)}
|
||||
/>
|
||||
</FilterEditorBlock>
|
||||
{error &&
|
||||
<div key="error" className="maputnik-inline-error">{error.message}</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Block
|
||||
key="top"
|
||||
fieldSpec={fieldSpec}
|
||||
label={"Filter"}
|
||||
action={actions}
|
||||
>
|
||||
<InputSelect
|
||||
value={combiningOp}
|
||||
onChange={this.onFilterPartChanged.bind(this, 0)}
|
||||
options={[["all", "every filter matches"], ["none", "no filter matches"], ["any", "any filter matches"]]}
|
||||
/>
|
||||
</Block>
|
||||
{editorBlocks}
|
||||
<div
|
||||
key="buttons"
|
||||
className="maputnik-filter-editor-add-wrapper"
|
||||
>
|
||||
<InputButton
|
||||
data-wd-key="layer-filter-button"
|
||||
className="maputnik-add-filter"
|
||||
onClick={this.addFilterItem}
|
||||
>
|
||||
<svg style={{width:"14px", height:"14px", verticalAlign: "text-bottom"}} viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d={mdiTableRowPlusAfter} />
|
||||
</svg> Add filter
|
||||
</InputButton>
|
||||
</div>
|
||||
<div
|
||||
key="doc"
|
||||
className="maputnik-doc-inline"
|
||||
style={{display: this.state.showDoc ? '' : 'none'}}
|
||||
>
|
||||
<Doc fieldSpec={fieldSpec} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
else {
|
||||
let {filter} = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExpressionProperty
|
||||
onDelete={() => {
|
||||
this.setState({displaySimpleFilter: true});
|
||||
this.props.onChange(defaultFilter);
|
||||
}}
|
||||
fieldName="filter"
|
||||
fieldSpec={fieldSpec}
|
||||
value={filter}
|
||||
errors={errors}
|
||||
onChange={this.props.onChange}
|
||||
/>
|
||||
{this.state.valueIsSimpleFilter &&
|
||||
<div className="maputnik-expr-infobox">
|
||||
You've entered a old style filter,{' '}
|
||||
<button
|
||||
onClick={this.makeFilter}
|
||||
className="maputnik-expr-infobox__button"
|
||||
>
|
||||
switch to filter editor
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import InputButton from './InputButton'
|
||||
import {MdDelete} from 'react-icons/md'
|
||||
|
||||
export default class FilterEditorBlock extends React.Component {
|
||||
static propTypes = {
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
children: PropTypes.element.isRequired,
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div className="maputnik-filter-editor-block">
|
||||
<div className="maputnik-filter-editor-block-action">
|
||||
<InputButton
|
||||
className="maputnik-delete-filter"
|
||||
onClick={this.props.onDelete}
|
||||
title="Delete filter block"
|
||||
>
|
||||
<MdDelete />
|
||||
</InputButton>
|
||||
</div>
|
||||
<div className="maputnik-filter-editor-block-content">
|
||||
{this.props.children}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import IconBase from 'react-icon-base'
|
||||
|
||||
|
||||
export default class IconBackground extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<IconBase viewBox="0 0 20 20" {...this.props}>
|
||||
<path d="m 1.821019,10.255581 7.414535,5.020197 c 0.372277,0.25206 0.958697,0.239771 1.30985,-0.02745 L 17.539255,9.926162 C 17.89041,9.658941 17.873288,9.238006 17.501015,8.985946 L 10.08648,3.9657402 C 9.714204,3.7136802 9.127782,3.7259703 8.776627,3.9931918 L 1.782775,9.315365 c -0.3511551,0.267221 -0.3340331,0.688156 0.03824,0.940216 z" />
|
||||
</IconBase>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import IconBase from 'react-icon-base'
|
||||
|
||||
|
||||
export default class IconCircle extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<IconBase viewBox="0 0 20 20" {...this.props}>
|
||||
<path transform="translate(2 2)" d="M7.5,0C11.6422,0,15,3.3578,15,7.5S11.6422,15,7.5,15 S0,11.6422,0,7.5S3.3578,0,7.5,0z M7.5,1.6666c-3.2217,0-5.8333,2.6117-5.8333,5.8334S4.2783,13.3334,7.5,13.3334 s5.8333-2.6117,5.8333-5.8334S10.7217,1.6666,7.5,1.6666z"></path>
|
||||
</IconBase>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user