mirror of
https://github.com/maputnik/editor.git
synced 2026-09-01 09:57:27 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 666bc648d8 | |||
| f7a945abce | |||
| 085eda76a1 | |||
| 0dc3f4bf5d | |||
| efc507aecd | |||
| ef902910c8 | |||
| 6ed08c7416 | |||
| 9014e12ca6 |
@@ -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,46 @@
|
|||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2020": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:react/jsx-runtime",
|
||||||
|
"plugin:react-hooks/recommended",
|
||||||
|
],
|
||||||
|
"ignorePatterns": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": "latest",
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"react": { "version": "16.4" }
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"@typescript-eslint",
|
||||||
|
"react-refresh"],
|
||||||
|
"rules": {
|
||||||
|
"react-refresh/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{ "allowConstantExport": true }
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{ "argsIgnorePattern": "^_" }
|
||||||
|
],
|
||||||
|
"no-unused-vars": "off",
|
||||||
|
"react/prop-types": ["off"],
|
||||||
|
// Disable no-undef. It's covered by @typescript-eslint
|
||||||
|
"no-undef": "off",
|
||||||
|
"indent": ["error", 2],
|
||||||
|
"no-var": ["error"]
|
||||||
|
},
|
||||||
|
"globals": {
|
||||||
|
"global": "readonly"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,137 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-docker:
|
||||||
|
name: build docker
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: docker build -t docker.pkg.github.com/maputnik/editor/editor:main .
|
||||||
|
|
||||||
|
# build the editor
|
||||||
|
build-node:
|
||||||
|
name: "build on ${{ 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]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: '.nvmrc'
|
||||||
|
- 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 lint
|
||||||
|
- run: npm run lint-css
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
build-artifacts:
|
||||||
|
name: "build artifacts"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version-file: '.nvmrc'
|
||||||
|
- 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/
|
||||||
|
|
||||||
|
e2e-tests:
|
||||||
|
name: "E2E tests using ${{ matrix.browser }}"
|
||||||
|
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 }}
|
||||||
|
- name: Upload coverage reports to Codecov
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
with:
|
||||||
|
files: ${{ github.workspace }}/.nyc_output/out.json
|
||||||
|
verbose: true
|
||||||
@@ -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
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# 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
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# 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/
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"all": true,
|
||||||
|
"extends": "@istanbuljs/nyc-config-typescript",
|
||||||
|
"check-coverage": false,
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": [
|
||||||
|
"cypress/**/*.*",
|
||||||
|
"**/*.d.ts",
|
||||||
|
"**/*.cy.tsx",
|
||||||
|
"**/*.cy.ts",
|
||||||
|
"./coverage/**",
|
||||||
|
"./cypress/**",
|
||||||
|
"./dist/**",
|
||||||
|
"node_modules"
|
||||||
|
],
|
||||||
|
"report-dir": "coverage",
|
||||||
|
"reporter": ["json", "lcov", "json-summary"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
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
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
#---------------------------------------------------------------------------
|
||||||
|
# Create a clean nginx-alpine slim image with just the build results
|
||||||
|
FROM nginx:alpine-slim
|
||||||
|
|
||||||
|
COPY --from=builder /maputnik/dist /usr/share/nginx/html/
|
||||||
@@ -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,23 @@
|
|||||||
|
import { defineConfig } from "cypress";
|
||||||
|
import { createRequire } from "module";
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
env: {
|
||||||
|
codeCoverage: {
|
||||||
|
exclude: "cypress/**/*.*",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
e2e: {
|
||||||
|
setupNodeEvents(on, config) {
|
||||||
|
// implement node event listeners here
|
||||||
|
require("@cypress/code-coverage/task")(on, config);
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
baseUrl: "http://localhost:8888",
|
||||||
|
retries: {
|
||||||
|
runMode: 2,
|
||||||
|
openMode: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { then } from "@shellygo/cypress-test-utils/assertable";
|
||||||
|
import MaputnikDriver from "./maputnik-driver";
|
||||||
|
|
||||||
|
describe("accessibility", () => {
|
||||||
|
let { beforeAndAfter, get, when, should } = new MaputnikDriver();
|
||||||
|
beforeAndAfter();
|
||||||
|
|
||||||
|
describe("skip links", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
when.setStyle("layer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skip link to layer list", () => {
|
||||||
|
const selector = "root:skip:layer-list";
|
||||||
|
then(get.elementByTestId(selector)).shouldExist();
|
||||||
|
when.tab();
|
||||||
|
then(get.skipTargetLayerList()).shouldBeFocused();
|
||||||
|
when.click(selector);
|
||||||
|
then(get.skipTargetLayerList()).shouldBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
// This fails for some reason only in Chrome, but passes in firefox. Adding a skip here to allow merge and later on we'll decide if we want to fix this or not.
|
||||||
|
it.skip("skip link to layer editor", () => {
|
||||||
|
const selector = "root:skip:layer-editor";
|
||||||
|
then(get.elementByTestId(selector)).shouldExist();
|
||||||
|
when.tab().tab();
|
||||||
|
should.beFocused(selector);
|
||||||
|
when.click(selector);
|
||||||
|
then(get.skipTargetLayerEditor()).shouldBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skip link to map view", () => {
|
||||||
|
const selector = "root:skip:map-view";
|
||||||
|
then(get.elementByTestId(selector)).shouldExist();
|
||||||
|
when.tab().tab().tab();
|
||||||
|
should.beFocused(selector);
|
||||||
|
when.click(selector);
|
||||||
|
then(get.canvas()).shouldBeFocused();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { CypressHelper } from "@shellygo/cypress-test-utils";
|
||||||
|
|
||||||
|
export default class CypressWrapperDriver {
|
||||||
|
private helper = new CypressHelper({ defaultDataAttribute: "data-wd-key" });
|
||||||
|
|
||||||
|
public given = {
|
||||||
|
...this.helper.given,
|
||||||
|
};
|
||||||
|
|
||||||
|
public get = {
|
||||||
|
...this.helper.get,
|
||||||
|
};
|
||||||
|
|
||||||
|
public when = {
|
||||||
|
...this.helper.when,
|
||||||
|
confirmAlert() {
|
||||||
|
cy.on("window:confirm", () => true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public beforeAndAfter = this.helper.beforeAndAfter;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import MaputnikDriver from "./maputnik-driver";
|
||||||
|
|
||||||
|
describe("history", () => {
|
||||||
|
let { beforeAndAfter, 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.modal.open();
|
||||||
|
|
||||||
|
should.equalStyleStore((a: any) => a.layers, []);
|
||||||
|
|
||||||
|
when.modal.fillLayers({
|
||||||
|
id: "step 1",
|
||||||
|
type: "background",
|
||||||
|
});
|
||||||
|
|
||||||
|
should.equalStyleStore(
|
||||||
|
(a: any) => a.layers,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "step 1",
|
||||||
|
type: "background",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
when.modal.open();
|
||||||
|
when.modal.fillLayers({
|
||||||
|
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 { then } from "@shellygo/cypress-test-utils/assertable";
|
||||||
|
import MaputnikDriver from "./maputnik-driver";
|
||||||
|
|
||||||
|
describe("keyboard", () => {
|
||||||
|
let { beforeAndAfter, given, when, get } = new MaputnikDriver();
|
||||||
|
beforeAndAfter();
|
||||||
|
describe("shortcuts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
given.setupMockBackedResponses();
|
||||||
|
when.setStyle("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ESC should unfocus", () => {
|
||||||
|
const targetSelector = "maputnik-select";
|
||||||
|
when.focus(targetSelector);
|
||||||
|
then(get.elementByTestId(targetSelector)).shouldBeFocused();
|
||||||
|
when.typeKeys("{esc}");
|
||||||
|
then(get.elementByTestId(targetSelector)).shouldNotBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'?' should show shortcuts modal", () => {
|
||||||
|
when.typeKeys("?");
|
||||||
|
then(get.elementByTestId("modal:shortcuts")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'o' should show open modal", () => {
|
||||||
|
when.typeKeys("o");
|
||||||
|
then(get.elementByTestId("modal:open")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'e' should show export modal", () => {
|
||||||
|
when.typeKeys("e");
|
||||||
|
then(get.elementByTestId("modal:export")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'d' should show sources modal", () => {
|
||||||
|
when.typeKeys("d");
|
||||||
|
then(get.elementByTestId("modal:sources")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'s' should show settings modal", () => {
|
||||||
|
when.typeKeys("s");
|
||||||
|
then(get.elementByTestId("modal:settings")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'i' should change map to inspect mode", () => {
|
||||||
|
when.typeKeys("i");
|
||||||
|
then(get.inputValue("maputnik-select")).shouldEqual("inspect");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'m' should focus map", () => {
|
||||||
|
when.typeKeys("m");
|
||||||
|
then(get.canvas()).shouldBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("'!' should show debug modal", () => {
|
||||||
|
when.typeKeys("!");
|
||||||
|
then(get.elementByTestId("modal:debug")).shouldBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
import { then } from "@shellygo/cypress-test-utils/assertable";
|
||||||
|
import { v1 as uuid } from "uuid";
|
||||||
|
import MaputnikDriver from "./maputnik-driver";
|
||||||
|
|
||||||
|
describe("layers", () => {
|
||||||
|
let { beforeAndAfter, get, when, should } = new MaputnikDriver();
|
||||||
|
beforeAndAfter();
|
||||||
|
beforeEach(() => {
|
||||||
|
when.setStyle("both");
|
||||||
|
when.modal.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ops", () => {
|
||||||
|
let id: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
id = when.modal.fillLayers({
|
||||||
|
type: "background",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update layers in local storage", () => {
|
||||||
|
then(get.maputnikStyleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: id,
|
||||||
|
type: "background",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when clicking delete", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
when.click("layer-list-item:" + id + ":delete");
|
||||||
|
});
|
||||||
|
it("should empty layers in local storage", () => {
|
||||||
|
then(get.maputnikStyleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when clicking duplicate", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
when.click("layer-list-item:" + id + ":copy");
|
||||||
|
});
|
||||||
|
it("should add copy layer in local storage", () => {
|
||||||
|
then(get.maputnikStyleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: id + "-copy",
|
||||||
|
type: "background",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: id,
|
||||||
|
type: "background",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when clicking hide", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
when.click("layer-list-item:" + id + ":toggle-visibility");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update visibility to none in local storage", () => {
|
||||||
|
then(get.maputnikStyleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: id,
|
||||||
|
type: "background",
|
||||||
|
layout: {
|
||||||
|
visibility: "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when clicking show", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
when.click("layer-list-item:" + id + ":toggle-visibility");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update visibility to visible in local storage", () => {
|
||||||
|
then(get.maputnikStyleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: id,
|
||||||
|
type: "background",
|
||||||
|
layout: {
|
||||||
|
visibility: "visible",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("background", () => {
|
||||||
|
it("add", () => {
|
||||||
|
let id = when.modal.fillLayers({
|
||||||
|
type: "background",
|
||||||
|
});
|
||||||
|
|
||||||
|
should.equalStyleStore(
|
||||||
|
(a: any) => a.layers,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: id,
|
||||||
|
type: "background",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("modify", () => {
|
||||||
|
function createBackground() {
|
||||||
|
// Setup
|
||||||
|
let id = uuid();
|
||||||
|
|
||||||
|
when.selectWithin("add-layer.layer-type", "background");
|
||||||
|
when.setValue("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", () => {
|
||||||
|
let bgId = createBackground();
|
||||||
|
|
||||||
|
when.click("layer-list-item:background:" + bgId);
|
||||||
|
|
||||||
|
let id = uuid();
|
||||||
|
when.setValue("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", () => {
|
||||||
|
let bgId = createBackground();
|
||||||
|
|
||||||
|
when.click("layer-list-item:background:" + bgId);
|
||||||
|
when.setValue("min-zoom.input-text", "1");
|
||||||
|
|
||||||
|
when.click("layer-editor.layer-id");
|
||||||
|
|
||||||
|
should.equalStyleStore(
|
||||||
|
(a: any) => a.layers,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "background:" + bgId,
|
||||||
|
type: "background",
|
||||||
|
minzoom: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// AND RESET!
|
||||||
|
when.type("min-zoom.input-text", "{backspace}");
|
||||||
|
when.click("max-zoom.input-text");
|
||||||
|
|
||||||
|
should.equalStyleStore(
|
||||||
|
(a: any) => a.layers,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "background:" + bgId,
|
||||||
|
type: "background",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("max-zoom", () => {
|
||||||
|
let bgId = createBackground();
|
||||||
|
|
||||||
|
when.click("layer-list-item:background:" + bgId);
|
||||||
|
when.setValue("max-zoom.input-text", "1");
|
||||||
|
|
||||||
|
when.click("layer-editor.layer-id");
|
||||||
|
|
||||||
|
should.equalStyleStore(
|
||||||
|
(a: any) => a.layers,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "background:" + bgId,
|
||||||
|
type: "background",
|
||||||
|
maxzoom: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("comments", () => {
|
||||||
|
let bgId = createBackground();
|
||||||
|
let comment = "42";
|
||||||
|
|
||||||
|
when.click("layer-list-item:background:" + bgId);
|
||||||
|
when.setValue("layer-comment.input", comment);
|
||||||
|
|
||||||
|
when.click("layer-editor.layer-id");
|
||||||
|
|
||||||
|
should.equalStyleStore(
|
||||||
|
(a: any) => a.layers,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "background:" + bgId,
|
||||||
|
type: "background",
|
||||||
|
metadata: {
|
||||||
|
"maputnik:comment": comment,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unset it again.
|
||||||
|
when.type("layer-comment.input", "{backspace}{backspace}");
|
||||||
|
when.click("min-zoom.input-text");
|
||||||
|
|
||||||
|
should.equalStyleStore(
|
||||||
|
(a: any) => a.layers,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "background:" + bgId,
|
||||||
|
type: "background",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("color", () => {
|
||||||
|
let 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", () => {
|
||||||
|
let bgId = createBackground();
|
||||||
|
|
||||||
|
when.click("layer-list-item:background:" + bgId);
|
||||||
|
|
||||||
|
let errorSelector = ".CodeMirror-lint-marker-error";
|
||||||
|
// Not a good idea to check that something doesn't exist. infinite things don't exist.
|
||||||
|
then(get.elementByTestId(errorSelector)).shouldNotExist();
|
||||||
|
|
||||||
|
when.click(".CodeMirror");
|
||||||
|
when.typeKeys(
|
||||||
|
"\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013 {"
|
||||||
|
);
|
||||||
|
then(get.elementByTestId(errorSelector)).shouldExist();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fill", () => {
|
||||||
|
it("add", () => {
|
||||||
|
let id = when.modal.fillLayers({
|
||||||
|
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", () => {
|
||||||
|
let id = when.modal.fillLayers({
|
||||||
|
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", () => {
|
||||||
|
let id = when.modal.fillLayers({
|
||||||
|
type: "symbol",
|
||||||
|
layer: "example",
|
||||||
|
});
|
||||||
|
|
||||||
|
should.equalStyleStore(
|
||||||
|
(a: any) => a.layers,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: id,
|
||||||
|
type: "symbol",
|
||||||
|
source: "example",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("raster", () => {
|
||||||
|
it("add", () => {
|
||||||
|
let id = when.modal.fillLayers({
|
||||||
|
type: "raster",
|
||||||
|
layer: "raster",
|
||||||
|
});
|
||||||
|
|
||||||
|
should.equalStyleStore(
|
||||||
|
(a: any) => a.layers,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: id,
|
||||||
|
type: "raster",
|
||||||
|
source: "raster",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("circle", () => {
|
||||||
|
it("add", () => {
|
||||||
|
let id = when.modal.fillLayers({
|
||||||
|
type: "circle",
|
||||||
|
layer: "example",
|
||||||
|
});
|
||||||
|
|
||||||
|
should.equalStyleStore(
|
||||||
|
(a: any) => a.layers,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: id,
|
||||||
|
type: "circle",
|
||||||
|
source: "example",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fill extrusion", () => {
|
||||||
|
it("add", () => {
|
||||||
|
let id = when.modal.fillLayers({
|
||||||
|
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.modal.open();
|
||||||
|
when.modal.fillLayers({
|
||||||
|
id: "foo",
|
||||||
|
type: "background",
|
||||||
|
});
|
||||||
|
|
||||||
|
when.modal.open();
|
||||||
|
when.modal.fillLayers({
|
||||||
|
id: "foo_bar",
|
||||||
|
type: "background",
|
||||||
|
});
|
||||||
|
|
||||||
|
when.modal.open();
|
||||||
|
when.modal.fillLayers({
|
||||||
|
id: "foo_bar_baz",
|
||||||
|
type: "background",
|
||||||
|
});
|
||||||
|
|
||||||
|
then(get.elementByTestId("layer-list-group:foo")).shouldBeVisible();
|
||||||
|
then(
|
||||||
|
get.elementByTestId("layer-list-group:foo_bar")
|
||||||
|
).shouldNotBeVisible();
|
||||||
|
then(
|
||||||
|
get.elementByTestId("layer-list-group:foo_bar_baz")
|
||||||
|
).shouldNotBeVisible();
|
||||||
|
when.click("layer-list-group:foo-0");
|
||||||
|
then(get.elementByTestId("layer-list-group:foo")).shouldBeVisible();
|
||||||
|
then(get.elementByTestId("layer-list-group:foo_bar")).shouldBeVisible();
|
||||||
|
then(
|
||||||
|
get.elementByTestId("layer-list-group:foo_bar_baz")
|
||||||
|
).shouldBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { then } from "@shellygo/cypress-test-utils/assertable";
|
||||||
|
import MaputnikDriver from "./maputnik-driver";
|
||||||
|
|
||||||
|
describe("map", () => {
|
||||||
|
let { beforeAndAfter, get, when } = new MaputnikDriver();
|
||||||
|
beforeAndAfter();
|
||||||
|
describe("zoom level", () => {
|
||||||
|
it("via url", () => {
|
||||||
|
let zoomLevel = 12.37;
|
||||||
|
when.setStyle("geojson", zoomLevel);
|
||||||
|
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
|
||||||
|
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText(
|
||||||
|
"Zoom: " + zoomLevel
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("via map controls", () => {
|
||||||
|
let zoomLevel = 12.37;
|
||||||
|
when.setStyle("geojson", zoomLevel);
|
||||||
|
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldBeVisible();
|
||||||
|
then(get.elementByTestId("maplibre:ctrl-zoom")).shouldContainText(
|
||||||
|
"Zoom: " + (zoomLevel + 1)
|
||||||
|
);
|
||||||
|
when.clickZoomIn();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import CypressWrapperDriver from "./cypress-wrapper-driver";
|
||||||
|
import ModalDriver from "./modal-driver";
|
||||||
|
|
||||||
|
const baseUrl = "http://localhost:8888/";
|
||||||
|
|
||||||
|
export default class MaputnikDriver {
|
||||||
|
private helper = new CypressWrapperDriver();
|
||||||
|
private modalDriver = new ModalDriver();
|
||||||
|
|
||||||
|
public beforeAndAfter = () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
this.given.setupMockBackedResponses();
|
||||||
|
this.when.setStyle("both");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
public given = {
|
||||||
|
...this.helper.given,
|
||||||
|
setupMockBackedResponses: () => {
|
||||||
|
this.helper.given.interceptAndMockResponse({
|
||||||
|
method: "GET",
|
||||||
|
url: baseUrl + "example-style.json",
|
||||||
|
response: {
|
||||||
|
fixture: "example-style.json",
|
||||||
|
},
|
||||||
|
alias: "example-style.json",
|
||||||
|
});
|
||||||
|
this.helper.given.interceptAndMockResponse({
|
||||||
|
method: "GET",
|
||||||
|
url: baseUrl + "example-layer-style.json",
|
||||||
|
response: {
|
||||||
|
fixture: "example-layer-style.json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.helper.given.interceptAndMockResponse({
|
||||||
|
method: "GET",
|
||||||
|
url: baseUrl + "geojson-style.json",
|
||||||
|
response: {
|
||||||
|
fixture: "geojson-style.json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.helper.given.interceptAndMockResponse({
|
||||||
|
method: "GET",
|
||||||
|
url: baseUrl + "raster-style.json",
|
||||||
|
response: {
|
||||||
|
fixture: "raster-style.json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.helper.given.interceptAndMockResponse({
|
||||||
|
method: "GET",
|
||||||
|
url: baseUrl + "geojson-raster-style.json",
|
||||||
|
response: {
|
||||||
|
fixture: "geojson-raster-style.json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.helper.given.interceptAndMockResponse({
|
||||||
|
method: "GET",
|
||||||
|
url: "*example.local/*",
|
||||||
|
response: [],
|
||||||
|
});
|
||||||
|
this.helper.given.interceptAndMockResponse({
|
||||||
|
method: "GET",
|
||||||
|
url: "*example.com/*",
|
||||||
|
response: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public when = {
|
||||||
|
...this.helper.when,
|
||||||
|
modal: this.modalDriver.when,
|
||||||
|
within: (selector: string, fn: () => void) => {
|
||||||
|
this.helper.when.within(fn, selector);
|
||||||
|
},
|
||||||
|
tab: () => this.helper.get.element("body").tab(),
|
||||||
|
waitForExampleFileResponse: () => {
|
||||||
|
this.helper.when.waitForResponse("example-style.json");
|
||||||
|
},
|
||||||
|
chooseExampleFile: () => {
|
||||||
|
this.helper.get
|
||||||
|
.bySelector("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=${baseUrl}geojson-style.json`;
|
||||||
|
break;
|
||||||
|
case "raster":
|
||||||
|
url += `&style=${baseUrl}raster-style.json`;
|
||||||
|
break;
|
||||||
|
case "both":
|
||||||
|
url += `&style=${baseUrl}geojson-raster-style.json`;
|
||||||
|
break;
|
||||||
|
case "layer":
|
||||||
|
url += `&style=${baseUrl}/example-layer-style.json`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (zoom) {
|
||||||
|
url += `#${zoom}/41.3805/2.1635`;
|
||||||
|
}
|
||||||
|
this.helper.when.visit(baseUrl + url);
|
||||||
|
if (styleProperties) {
|
||||||
|
this.helper.when.confirmAlert();
|
||||||
|
}
|
||||||
|
// when methods should not include assertions
|
||||||
|
this.helper.get.elementByTestId("toolbar:link").should("be.visible");
|
||||||
|
},
|
||||||
|
|
||||||
|
typeKeys: (keys: string, selector?: string) =>
|
||||||
|
this.helper.get.element("body").type(keys),
|
||||||
|
|
||||||
|
clickZoomIn: () => {
|
||||||
|
this.helper.get.element(".maplibregl-ctrl-zoom-in").click();
|
||||||
|
},
|
||||||
|
|
||||||
|
selectWithin: (selector: string, value: string) => {
|
||||||
|
this.when.within(selector, () => {
|
||||||
|
this.helper.get.element("select").select(value);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
select: (selector: string, value: string) => {
|
||||||
|
this.helper.get.elementByTestId(selector).select(value);
|
||||||
|
},
|
||||||
|
|
||||||
|
focus: (selector: string) => {
|
||||||
|
this.helper.when.focus(selector);
|
||||||
|
},
|
||||||
|
|
||||||
|
setValue: (selector: string, text: string) => {
|
||||||
|
this.helper.get
|
||||||
|
.elementByTestId(selector)
|
||||||
|
.clear()
|
||||||
|
.type(text, { parseSpecialCharSequences: false });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public get = {
|
||||||
|
...this.helper.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;
|
||||||
|
},
|
||||||
|
|
||||||
|
maputnikStyleFromLocalStorageObj: () => {
|
||||||
|
const styleId = localStorage.getItem("maputnik:latest_style");
|
||||||
|
const styleItem = localStorage.getItem(`maputnik:style:${styleId}`);
|
||||||
|
const obj = JSON.parse(styleItem || "");
|
||||||
|
console.log(obj);
|
||||||
|
return obj;
|
||||||
|
},
|
||||||
|
maputnikStyleFromLocalStorage: () => {
|
||||||
|
return cy.wrap(this.get.maputnikStyleFromLocalStorageObj());
|
||||||
|
},
|
||||||
|
exampleFileUrl: () => {
|
||||||
|
return baseUrl + "example-style.json";
|
||||||
|
},
|
||||||
|
skipTargetLayerList: () =>
|
||||||
|
this.helper.get.elementByTestId("skip-target-layer-list"),
|
||||||
|
skipTargetLayerEditor: () =>
|
||||||
|
this.helper.get.elementByTestId("skip-target-layer-editor"),
|
||||||
|
canvas: () => this.helper.get.element("canvas"),
|
||||||
|
};
|
||||||
|
|
||||||
|
public should = {
|
||||||
|
notExist: (selector: string) => {
|
||||||
|
this.helper.get.element(selector).should("not.exist");
|
||||||
|
},
|
||||||
|
beFocused: (selector: string) => {
|
||||||
|
this.helper.get.elementByTestId(selector).should("have.focus");
|
||||||
|
},
|
||||||
|
|
||||||
|
notBeFocused: (selector: string) => {
|
||||||
|
this.helper.get.elementByTestId(selector).should("not.have.focus");
|
||||||
|
},
|
||||||
|
|
||||||
|
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);
|
||||||
|
const bobj = this.get.maputnikStyleFromLocalStorageObj();
|
||||||
|
this.helper.given
|
||||||
|
.fixture("example-style.json", "file:example-style.json")
|
||||||
|
.should("deep.equal", obj);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
beSelected: (selector: string, value: string) => {
|
||||||
|
this.helper.get
|
||||||
|
.elementByTestId(selector)
|
||||||
|
.find(`option[value="${value}"]`)
|
||||||
|
.should("be.selected");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { v1 as uuid } from "uuid";
|
||||||
|
import CypressWrapperDriver from "./cypress-wrapper-driver";
|
||||||
|
|
||||||
|
export default class ModalDriver {
|
||||||
|
private helper = new CypressWrapperDriver();
|
||||||
|
|
||||||
|
public when = {
|
||||||
|
fillLayers: (opts: { type: string; layer?: string; id?: string }) => {
|
||||||
|
let type = opts.type;
|
||||||
|
let layer = opts.layer;
|
||||||
|
let id;
|
||||||
|
if (opts.id) {
|
||||||
|
id = opts.id;
|
||||||
|
} else {
|
||||||
|
id = `${type}:${uuid()}`;
|
||||||
|
}
|
||||||
|
this.helper.when.selectOption("add-layer.layer-type.select", type);
|
||||||
|
this.helper.when.type("add-layer.layer-id.input", id);
|
||||||
|
|
||||||
|
if (layer) {
|
||||||
|
this.helper.when.within(() => {
|
||||||
|
this.helper.get.element("input").type(layer!);
|
||||||
|
}, "add-layer.layer-source-block");
|
||||||
|
}
|
||||||
|
this.helper.when.click("add-layer");
|
||||||
|
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
open: () => {
|
||||||
|
this.helper.when.click("layer-list:add-layer");
|
||||||
|
},
|
||||||
|
|
||||||
|
close: (key: string) => {
|
||||||
|
this.helper.when.click(key + ".close-modal");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { then } from "@shellygo/cypress-test-utils/assertable";
|
||||||
|
import MaputnikDriver from "./maputnik-driver";
|
||||||
|
|
||||||
|
describe("modals", () => {
|
||||||
|
let { beforeAndAfter, when, get } = new MaputnikDriver();
|
||||||
|
beforeAndAfter();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
when.visit("/");
|
||||||
|
});
|
||||||
|
describe("open", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
when.click("nav:open");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("close", () => {
|
||||||
|
when.modal.close("modal:open");
|
||||||
|
then(get.elementByTestId("modal:open")).shouldNotExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
then(get.responseBody("example-style.json")).shouldDeepEqual(
|
||||||
|
get.maputnikStyleFromLocalStorageObj()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when click open url", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
let styleFileUrl = get.exampleFileUrl();
|
||||||
|
|
||||||
|
when.setValue("modal:open.url.input", styleFileUrl);
|
||||||
|
when.click("modal:open.url.button");
|
||||||
|
when.wait(200);
|
||||||
|
});
|
||||||
|
it("load from url", () => {
|
||||||
|
then(get.responseBody("example-style.json")).shouldDeepEqual(
|
||||||
|
get.maputnikStyleFromLocalStorageObj()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shortcuts", () => {
|
||||||
|
it("open/close", () => {
|
||||||
|
when.setStyle("");
|
||||||
|
when.typeKeys("?");
|
||||||
|
when.modal.close("modal:shortcuts");
|
||||||
|
then(get.elementByTestId("modal:shortcuts")).shouldNotExist();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("export", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
when.click("nav:export");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip("close", () => {
|
||||||
|
when.modal.close("modal:export");
|
||||||
|
then(get.elementByTestId("modal:export")).shouldNotExist();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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", () => {
|
||||||
|
// There is no assertion in this test
|
||||||
|
when.setStyle("geojson");
|
||||||
|
when.select("maputnik-select", "inspect");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("style settings", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
when.click("nav:settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when click name", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
when.click("field-doc-button-Name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("name", () => {
|
||||||
|
then(get.elementsText("spec-field-doc")).shouldInclude(
|
||||||
|
"name for the style"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when click owner", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
when.setValue("modal:settings.name", "foobar");
|
||||||
|
when.click("modal:settings.owner");
|
||||||
|
when.wait(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("show name specifications", () => {
|
||||||
|
then(get.maputnikStyleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
name: "foobar",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("owner", () => {
|
||||||
|
when.setValue("modal:settings.owner", "foobar");
|
||||||
|
when.click("modal:settings.name");
|
||||||
|
then(get.maputnikStyleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
owner: "foobar",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("sprite url", () => {
|
||||||
|
when.setValue("modal:settings.sprite", "http://example.com");
|
||||||
|
when.click("modal:settings.name");
|
||||||
|
then(get.maputnikStyleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
sprite: "http://example.com",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("glyphs url", () => {
|
||||||
|
let glyphsUrl = "http://example.com/{fontstack}/{range}.pbf";
|
||||||
|
when.setValue("modal:settings.glyphs", glyphsUrl);
|
||||||
|
when.click("modal:settings.name");
|
||||||
|
then(get.maputnikStyleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
glyphs: glyphsUrl,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maptiler access token", () => {
|
||||||
|
let apiKey = "testing123";
|
||||||
|
when.setValue(
|
||||||
|
"modal:settings.maputnik:openmaptiles_access_token",
|
||||||
|
apiKey
|
||||||
|
);
|
||||||
|
when.click("modal:settings.name");
|
||||||
|
then(get.maputnikStyleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
metadata: { "maputnik:openmaptiles_access_token": apiKey },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("thunderforest access token", () => {
|
||||||
|
let apiKey = "testing123";
|
||||||
|
when.setValue(
|
||||||
|
"modal:settings.maputnik:thunderforest_access_token",
|
||||||
|
apiKey
|
||||||
|
);
|
||||||
|
when.click("modal:settings.name");
|
||||||
|
then(get.maputnikStyleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
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");
|
||||||
|
then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual(
|
||||||
|
"ol"
|
||||||
|
);
|
||||||
|
|
||||||
|
when.click("modal:settings.name");
|
||||||
|
then(get.maputnikStyleFromLocalStorage()).shouldDeepNestedInclude({
|
||||||
|
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,22 @@
|
|||||||
|
// ***********************************************************
|
||||||
|
// 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/code-coverage/support";
|
||||||
|
import "cypress-plugin-tab";
|
||||||
|
import "./commands";
|
||||||
|
|
||||||
|
// Alternatively you can use CommonJS syntax:
|
||||||
|
// require('./commands')
|
||||||
+4
-5
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Maputnik</title>
|
<title>Maputnik</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="manifest" href="/maputnik/assets/manifest-BrZzkYP9.json">
|
<link rel="manifest" href="src/manifest.json">
|
||||||
<link rel="icon" href="/maputnik/assets/favicon-DBn6BKLx.ico" type="image/x-icon" />
|
<link rel="icon" href="src/favicon.ico" type="image/x-icon" />
|
||||||
<style>
|
<style>
|
||||||
html {
|
html {
|
||||||
background-color: rgb(28, 31, 36);
|
background-color: rgb(28, 31, 36);
|
||||||
@@ -37,8 +37,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
<script type="module" crossorigin src="/maputnik/assets/index-DPDV-s6M.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="/maputnik/assets/index-CKLfpVt5.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- From <https://github.com/hail2u/color-blindness-emulation> -->
|
<!-- From <https://github.com/hail2u/color-blindness-emulation> -->
|
||||||
@@ -125,9 +123,10 @@
|
|||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<div class="loading">
|
<div class="loading">
|
||||||
<div class="loading__logo">
|
<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>
|
||||||
<div class="loading__text">Loading…</div>
|
<div class="loading__text">Loading…</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script type="module" src="/src/index.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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
+18507
File diff suppressed because it is too large
Load Diff
+149
@@ -0,0 +1,149 @@
|
|||||||
|
{
|
||||||
|
"name": "maputnik",
|
||||||
|
"version": "2.0.0-pre.2",
|
||||||
|
"description": "A MapLibre GL visual style editor",
|
||||||
|
"type": "module",
|
||||||
|
"main": "''",
|
||||||
|
"scripts": {
|
||||||
|
"start": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint ./src ./cypress --ext ts,tsx,js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"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",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.16.0",
|
||||||
|
"@typescript-eslint/parser": "^6.16.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": {
|
||||||
|
"@cypress/code-coverage": "^3.12.15",
|
||||||
|
"@istanbuljs/nyc-config-typescript": "^1.0.2",
|
||||||
|
"@rollup/plugin-replace": "^5.0.5",
|
||||||
|
"@shellygo/cypress-test-utils": "^2.0.15",
|
||||||
|
"@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/codemirror": "^5.60.15",
|
||||||
|
"@types/color": "^3.0.6",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
|
"@types/geojson": "^7946.0.13",
|
||||||
|
"@types/json-to-ast": "^2.1.4",
|
||||||
|
"@types/lodash.capitalize": "^4.2.9",
|
||||||
|
"@types/lodash.clamp": "^4.0.9",
|
||||||
|
"@types/lodash.clonedeep": "^4.5.9",
|
||||||
|
"@types/lodash.get": "^4.4.9",
|
||||||
|
"@types/lodash.isequal": "^4.5.8",
|
||||||
|
"@types/lodash.throttle": "^4.1.9",
|
||||||
|
"@types/react": "^16.14.52",
|
||||||
|
"@types/react-aria-menubutton": "^6.2.13",
|
||||||
|
"@types/react-aria-modal": "^4.0.9",
|
||||||
|
"@types/react-autocomplete": "^1.8.9",
|
||||||
|
"@types/react-collapse": "^5.0.4",
|
||||||
|
"@types/react-color": "^3.0.10",
|
||||||
|
"@types/react-dom": "^16.9.24",
|
||||||
|
"@types/react-file-reader-input": "^2.0.4",
|
||||||
|
"@types/react-icon-base": "^2.1.6",
|
||||||
|
"@types/string-hash": "^1.1.3",
|
||||||
|
"@types/uuid": "^9.0.7",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"cypress": "^13.6.1",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-plugin-react": "^7.33.2",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
|
"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",
|
||||||
|
"vite-plugin-istanbul": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,989 @@
|
|||||||
|
// @ts-ignore - this can be easily replaced with arrow functions
|
||||||
|
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 hash from "string-hash";
|
||||||
|
import {Map, LayerSpecification, StyleSpecification, ValidationError, SourceSpecification} from 'maplibre-gl'
|
||||||
|
import {latest, validate} from '@maplibre/maplibre-gl-style-spec'
|
||||||
|
|
||||||
|
import MapMaplibreGl from './MapMaplibreGl'
|
||||||
|
import MapOpenLayers from './MapOpenLayers'
|
||||||
|
import LayerList from './LayerList'
|
||||||
|
import LayerEditor from './LayerEditor'
|
||||||
|
import AppToolbar, { MapState } 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 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 { SortEnd } from 'react-sortable-hoc';
|
||||||
|
import { MapOptions } from 'maplibre-gl';
|
||||||
|
|
||||||
|
// Buffer must be defined globally for @maplibre/maplibre-gl-style-spec validate() function to succeed.
|
||||||
|
window.Buffer = buffer.Buffer;
|
||||||
|
|
||||||
|
function setFetchAccessToken(url: string, mapStyle: StyleSpecification) {
|
||||||
|
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: any, fieldName: string, newValues: any) {
|
||||||
|
return {
|
||||||
|
...spec,
|
||||||
|
$root: {
|
||||||
|
...spec.$root,
|
||||||
|
[fieldName]: {
|
||||||
|
...spec.$root[fieldName],
|
||||||
|
values: newValues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnStyleChangedOpts = {
|
||||||
|
save?: boolean
|
||||||
|
addRevision?: boolean
|
||||||
|
initialLoad?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type MappedErrors = {
|
||||||
|
message: string
|
||||||
|
parsed?: {
|
||||||
|
type: string
|
||||||
|
data: {
|
||||||
|
index: number
|
||||||
|
key: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppState = {
|
||||||
|
errors: MappedErrors[],
|
||||||
|
infos: string[],
|
||||||
|
mapStyle: StyleSpecification & {id: string},
|
||||||
|
dirtyMapStyle?: StyleSpecification,
|
||||||
|
selectedLayerIndex: number,
|
||||||
|
selectedLayerOriginalId?: string,
|
||||||
|
sources: {[key: string]: SourceSpecification},
|
||||||
|
vectorLayers: {},
|
||||||
|
spec: any,
|
||||||
|
mapView: {
|
||||||
|
zoom: number,
|
||||||
|
center: {
|
||||||
|
lng: number,
|
||||||
|
lat: number,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
maplibreGlDebugOptions: Partial<MapOptions> & {
|
||||||
|
showTileBoundaries: boolean,
|
||||||
|
showCollisionBoxes: boolean,
|
||||||
|
showOverdrawInspector: boolean,
|
||||||
|
},
|
||||||
|
openlayersDebugOptions: {
|
||||||
|
debugToolbox: boolean,
|
||||||
|
},
|
||||||
|
mapState: MapState
|
||||||
|
isOpen: {
|
||||||
|
settings: boolean
|
||||||
|
sources: boolean
|
||||||
|
open: boolean
|
||||||
|
shortcuts: boolean
|
||||||
|
export: boolean
|
||||||
|
survey: boolean
|
||||||
|
debug: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class App extends React.Component<any, AppState> {
|
||||||
|
revisionStore: RevisionStore;
|
||||||
|
styleStore: StyleStore | ApiStyleStore;
|
||||||
|
layerWatcher: LayerWatcher;
|
||||||
|
shortcutEl: ModalShortcuts | null = null;
|
||||||
|
|
||||||
|
constructor(props: any) {
|
||||||
|
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") as HTMLCanvasElement).focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "!",
|
||||||
|
handler: () => {
|
||||||
|
this.toggleModal("debug");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
document.body.addEventListener("keyup", (e) => {
|
||||||
|
if(e.key === "Escape") {
|
||||||
|
(e.target as HTMLElement).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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: KeyboardEvent) => {
|
||||||
|
if(navigator.platform.toUpperCase().indexOf('MAC') >= 0) {
|
||||||
|
if(e.metaKey && e.shiftKey && e.keyCode === 90) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.onRedo();
|
||||||
|
}
|
||||||
|
else if(e.metaKey && e.keyCode === 90) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.onUndo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if(e.ctrlKey && e.keyCode === 90) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.onUndo();
|
||||||
|
}
|
||||||
|
else if(e.ctrlKey && e.keyCode === 89) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.onRedo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
window.addEventListener("keydown", this.handleKeyPress);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
window.removeEventListener("keydown", this.handleKeyPress);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveStyle(snapshotStyle: StyleSpecification & {id: string}) {
|
||||||
|
this.styleStore.save(snapshotStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFonts(urlTemplate: string) {
|
||||||
|
const metadata: {[key: string]: string} = this.state.mapStyle.metadata || {} as any
|
||||||
|
const accessToken = metadata['maputnik:openmaptiles_access_token'] || tokens.openmaptiles
|
||||||
|
|
||||||
|
const glyphUrl = (typeof urlTemplate === 'string')? urlTemplate.replace('{key}', accessToken): urlTemplate;
|
||||||
|
downloadGlyphsMetadata(glyphUrl, fonts => {
|
||||||
|
this.setState({ spec: updateRootSpec(this.state.spec, 'glyphs', fonts)})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateIcons(baseUrl: string) {
|
||||||
|
downloadSpriteMetadata(baseUrl, icons => {
|
||||||
|
this.setState({ spec: updateRootSpec(this.state.spec, 'sprite', icons)})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeMetadataProperty = (property: string, value: any) => {
|
||||||
|
// 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 as any).metadata,
|
||||||
|
[property]: value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.onStyleChanged(changedStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
onStyleChanged = (newStyle: StyleSpecification & {id: string}, opts: OnStyleChangedOpts={}) => {
|
||||||
|
opts = {
|
||||||
|
save: true,
|
||||||
|
addRevision: true,
|
||||||
|
initialLoad: false,
|
||||||
|
...opts,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (opts.initialLoad) {
|
||||||
|
this.getInitialStateFromUrl(newStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This "any" can be removed in latest version of maplibre where maplibre re-exported types from style-spec
|
||||||
|
const errors = validate(newStyle as any, 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: (Error | ValidationError)[] = [];
|
||||||
|
if (newStyle && newStyle.layers) {
|
||||||
|
const foundLayers = new global.Map();
|
||||||
|
newStyle.layers.forEach((layer, index) => {
|
||||||
|
if (layer.id === "" && foundLayers.has(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 [, 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 [, 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 [, 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: StyleSpecification | undefined = 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 as string)
|
||||||
|
}
|
||||||
|
if(newStyle.sprite !== this.state.mapStyle.sprite) {
|
||||||
|
this.updateIcons(newStyle.sprite as string)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.addRevision) {
|
||||||
|
this.revisionStore.addRevision(newStyle);
|
||||||
|
}
|
||||||
|
if (opts.save) {
|
||||||
|
this.saveStyle(newStyle as StyleSpecification & {id: string});
|
||||||
|
}
|
||||||
|
|
||||||
|
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: SortEnd) => {
|
||||||
|
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: LayerSpecification[]) => {
|
||||||
|
const changedStyle = {
|
||||||
|
...this.state.mapStyle,
|
||||||
|
layers: changedLayers
|
||||||
|
}
|
||||||
|
this.onStyleChanged(changedStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
onLayerDestroy = (index: number) => {
|
||||||
|
const layers = this.state.mapStyle.layers;
|
||||||
|
const remainingLayers = layers.slice(0);
|
||||||
|
remainingLayers.splice(index, 1);
|
||||||
|
this.onLayersChange(remainingLayers);
|
||||||
|
}
|
||||||
|
|
||||||
|
onLayerCopy = (index: number) => {
|
||||||
|
const 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: number) => {
|
||||||
|
const 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: number, _oldId: string, newId: string) => {
|
||||||
|
const changedLayers = this.state.mapStyle.layers.slice(0)
|
||||||
|
changedLayers[index] = {
|
||||||
|
...changedLayers[index],
|
||||||
|
id: newId
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onLayersChange(changedLayers)
|
||||||
|
}
|
||||||
|
|
||||||
|
onLayerChanged = (index: number, layer: LayerSpecification) => {
|
||||||
|
const changedLayers = this.state.mapStyle.layers.slice(0)
|
||||||
|
changedLayers[index] = layer
|
||||||
|
|
||||||
|
this.onLayersChange(changedLayers)
|
||||||
|
}
|
||||||
|
|
||||||
|
setMapState = (newState: MapState) => {
|
||||||
|
this.setState({
|
||||||
|
mapState: newState
|
||||||
|
}, this.setStateInUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDefaultValues = (styleObj: StyleSpecification & {id: string}) => {
|
||||||
|
const metadata: {[key: string]: string} = styleObj.metadata || {} as any
|
||||||
|
if(metadata['maputnik:renderer'] === undefined) {
|
||||||
|
const changedStyle = {
|
||||||
|
...styleObj,
|
||||||
|
metadata: {
|
||||||
|
...styleObj.metadata as any,
|
||||||
|
'maputnik:renderer': 'mlgljs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changedStyle
|
||||||
|
} else {
|
||||||
|
return styleObj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openStyle = (styleObj: StyleSpecification & {id: string}) => {
|
||||||
|
styleObj = this.setDefaultValues(styleObj)
|
||||||
|
this.onStyleChanged(styleObj)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSources() {
|
||||||
|
const sourceList: {[key: string]: any} = {};
|
||||||
|
|
||||||
|
for(const [key, val] of Object.entries(this.state.mapStyle.sources)) {
|
||||||
|
if(
|
||||||
|
!Object.prototype.hasOwnProperty.call(this.state.sources, key) &&
|
||||||
|
val.type === "vector" &&
|
||||||
|
Object.prototype.hasOwnProperty.call(val, "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(!Object.prototype.hasOwnProperty.call(json, "vector_layers")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new objects before setState
|
||||||
|
const sources = Object.assign({}, {
|
||||||
|
[key]: this.state.sources[key],
|
||||||
|
});
|
||||||
|
|
||||||
|
for(const layer of json.vector_layers) {
|
||||||
|
(sources[key] as any).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: {[key:string]: string} = this.state.mapStyle.metadata || {} as any;
|
||||||
|
return metadata['maputnik:renderer'] || 'mlgljs';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMapChange = (mapView: {
|
||||||
|
zoom: number,
|
||||||
|
center: {
|
||||||
|
lng: number,
|
||||||
|
lat: number,
|
||||||
|
},
|
||||||
|
}) => {
|
||||||
|
this.setState({
|
||||||
|
mapView,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
mapRenderer() {
|
||||||
|
const {mapStyle, dirtyMapStyle} = this.state;
|
||||||
|
|
||||||
|
const mapProps = {
|
||||||
|
mapStyle: (dirtyMapStyle || mapStyle),
|
||||||
|
replaceAccessTokens: (mapStyle: StyleSpecification) => {
|
||||||
|
return style.replaceAccessTokens(mapStyle, {
|
||||||
|
allowFallback: true
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onDataChange: (e: {map: Map}) => {
|
||||||
|
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: {filter?: string} = {};
|
||||||
|
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: StyleSpecification) => {
|
||||||
|
const url = new URL(location.href);
|
||||||
|
const modalParam = url.searchParams.get("modal");
|
||||||
|
if (modalParam && modalParam !== "") {
|
||||||
|
const modals = modalParam.split(",");
|
||||||
|
const modalObj: {[key: string]: boolean} = {};
|
||||||
|
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 as MapState);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: number) => {
|
||||||
|
this.setState({
|
||||||
|
selectedLayerIndex: index,
|
||||||
|
selectedLayerOriginalId: this.state.mapStyle.layers[index].id,
|
||||||
|
}, this.setStateInUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
setModal(modalName: keyof AppState["isOpen"], value: boolean) {
|
||||||
|
if(modalName === 'survey' && value === false) {
|
||||||
|
localStorage.setItem('survey', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
isOpen: {
|
||||||
|
...this.state.isOpen,
|
||||||
|
[modalName]: value
|
||||||
|
}
|
||||||
|
}, this.setStateInUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleModal(modalName: keyof AppState["isOpen"]) {
|
||||||
|
this.setModal(modalName, !this.state.isOpen[modalName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeOpenlayersDebug = (key: keyof AppState["openlayersDebugOptions"], value: boolean) => {
|
||||||
|
this.setState({
|
||||||
|
openlayersDebugOptions: {
|
||||||
|
...this.state.openlayersDebugOptions,
|
||||||
|
[key]: value,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeMaplibreGlDebug = (key: keyof AppState["maplibreGlDebugOptions"], value: any) => {
|
||||||
|
this.setState({
|
||||||
|
maplibreGlDebugOptions: {
|
||||||
|
...this.state.maplibreGlDebugOptions,
|
||||||
|
[key]: value,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const layers = this.state.mapStyle.layers || []
|
||||||
|
const selectedLayer = layers.length > 0 ? layers[this.state.selectedLayerIndex] : undefined
|
||||||
|
|
||||||
|
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}
|
||||||
|
/> : undefined
|
||||||
|
|
||||||
|
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}
|
||||||
|
/> : undefined
|
||||||
|
|
||||||
|
|
||||||
|
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')}
|
||||||
|
/>
|
||||||
|
<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'
|
||||||
|
|
||||||
|
type AppLayoutProps = {
|
||||||
|
toolbar: React.ReactElement
|
||||||
|
layerList: React.ReactElement
|
||||||
|
layerEditor?: React.ReactElement
|
||||||
|
map: React.ReactElement
|
||||||
|
bottom?: React.ReactElement
|
||||||
|
modals?: React.ReactNode
|
||||||
|
};
|
||||||
|
|
||||||
|
class AppLayout extends React.Component<AppLayoutProps> {
|
||||||
|
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,61 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {formatLayerId} from '../libs/format';
|
||||||
|
import {LayerSpecification, StyleSpecification} from 'maplibre-gl';
|
||||||
|
|
||||||
|
type AppMessagePanelProps = {
|
||||||
|
errors?: unknown[]
|
||||||
|
infos?: unknown[]
|
||||||
|
mapStyle?: StyleSpecification
|
||||||
|
onLayerSelect?(...args: unknown[]): unknown
|
||||||
|
currentLayer?: LayerSpecification
|
||||||
|
selectedLayerIndex?: number
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class AppMessagePanel extends React.Component<AppMessagePanelProps> {
|
||||||
|
static defaultProps = {
|
||||||
|
onLayerSelect: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {selectedLayerIndex} = this.props;
|
||||||
|
const errors = this.props.errors?.map((error: any, idx) => {
|
||||||
|
let content;
|
||||||
|
if (error.parsed && error.parsed.type === "layer") {
|
||||||
|
const {parsed} = error;
|
||||||
|
const layerId = this.props.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,287 @@
|
|||||||
|
import React from 'react'
|
||||||
|
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;
|
||||||
|
|
||||||
|
|
||||||
|
type IconTextProps = {
|
||||||
|
children?: React.ReactNode
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
class IconText extends React.Component<IconTextProps> {
|
||||||
|
render() {
|
||||||
|
return <span className="maputnik-icon-text">{this.props.children}</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolbarLinkProps = {
|
||||||
|
className?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
href?: string
|
||||||
|
onToggleModal?(...args: unknown[]): unknown
|
||||||
|
};
|
||||||
|
|
||||||
|
class ToolbarLink extends React.Component<ToolbarLinkProps> {
|
||||||
|
render() {
|
||||||
|
return <a
|
||||||
|
className={classnames('maputnik-toolbar-link', this.props.className)}
|
||||||
|
href={this.props.href}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
data-wd-key="toolbar:link"
|
||||||
|
>
|
||||||
|
{this.props.children}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolbarLinkHighlightedProps = {
|
||||||
|
className?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
href?: string
|
||||||
|
onToggleModal?(...args: unknown[]): unknown
|
||||||
|
};
|
||||||
|
|
||||||
|
class ToolbarLinkHighlighted extends React.Component<ToolbarLinkHighlightedProps> {
|
||||||
|
render() {
|
||||||
|
return <a
|
||||||
|
className={classnames('maputnik-toolbar-link', "maputnik-toolbar-link--highlighted", this.props.className)}
|
||||||
|
href={this.props.href}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
data-wd-key="toolbar:link-highlighted"
|
||||||
|
>
|
||||||
|
<span className="maputnik-toolbar-link-wrapper">
|
||||||
|
{this.props.children}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolbarSelectProps = {
|
||||||
|
children?: React.ReactNode
|
||||||
|
wdKey?: string
|
||||||
|
};
|
||||||
|
|
||||||
|
class ToolbarSelect extends React.Component<ToolbarSelectProps> {
|
||||||
|
render() {
|
||||||
|
return <div
|
||||||
|
className='maputnik-toolbar-select'
|
||||||
|
data-wd-key={this.props.wdKey}
|
||||||
|
>
|
||||||
|
{this.props.children}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolbarActionProps = {
|
||||||
|
children?: React.ReactNode
|
||||||
|
onClick?(...args: unknown[]): unknown
|
||||||
|
wdKey?: string
|
||||||
|
};
|
||||||
|
|
||||||
|
class ToolbarAction extends React.Component<ToolbarActionProps> {
|
||||||
|
render() {
|
||||||
|
return <button
|
||||||
|
className='maputnik-toolbar-action'
|
||||||
|
data-wd-key={this.props.wdKey}
|
||||||
|
onClick={this.props.onClick}
|
||||||
|
>
|
||||||
|
{this.props.children}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MapState = "map" | "inspect" | "filter-achromatopsia" | "filter-deuteranopia" | "filter-protanopia" | "filter-tritanopia";
|
||||||
|
|
||||||
|
type AppToolbarProps = {
|
||||||
|
mapStyle: object
|
||||||
|
inspectModeEnabled: boolean
|
||||||
|
onStyleChanged(...args: unknown[]): unknown
|
||||||
|
// A new style has been uploaded
|
||||||
|
onStyleOpen(...args: unknown[]): unknown
|
||||||
|
// A dict of source id's and the available source layers
|
||||||
|
sources: object
|
||||||
|
children?: React.ReactNode
|
||||||
|
onToggleModal(...args: unknown[]): unknown
|
||||||
|
onSetMapState(mapState: MapState): unknown
|
||||||
|
mapState?: MapState
|
||||||
|
renderer?: string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class AppToolbar extends React.Component<AppToolbarProps> {
|
||||||
|
state = {
|
||||||
|
isOpen: {
|
||||||
|
settings: false,
|
||||||
|
sources: false,
|
||||||
|
open: false,
|
||||||
|
add: false,
|
||||||
|
export: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSelection(val: MapState) {
|
||||||
|
this.props.onSetMapState(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSkip = (target: string) => {
|
||||||
|
if (target === "map") {
|
||||||
|
(document.querySelector(".maplibregl-canvas") as HTMLCanvasElement).focus();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const el = document.querySelector("#skip-target-"+target) as HTMLButtonElement;
|
||||||
|
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 as MapState)}
|
||||||
|
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, {SyntheticEvent} from 'react'
|
||||||
|
import classnames from 'classnames'
|
||||||
|
import FieldDocLabel from './FieldDocLabel'
|
||||||
|
import Doc from './Doc'
|
||||||
|
|
||||||
|
|
||||||
|
type BlockProps = {
|
||||||
|
"data-wd-key"?: string
|
||||||
|
label?: string
|
||||||
|
action?: React.ReactElement
|
||||||
|
style?: object
|
||||||
|
onChange?(...args: unknown[]): unknown
|
||||||
|
fieldSpec?: object
|
||||||
|
wideMode?: boolean
|
||||||
|
error?: {message: string}
|
||||||
|
};
|
||||||
|
|
||||||
|
type BlockState = {
|
||||||
|
showDoc: boolean
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Wrap a component with a label */
|
||||||
|
export default class Block extends React.Component<BlockProps, BlockState> {
|
||||||
|
_blockEl: HTMLDivElement | null = null;
|
||||||
|
|
||||||
|
constructor (props: BlockProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
showDoc: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(e: React.BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) {
|
||||||
|
const value = e.target.value
|
||||||
|
if (this.props.onChange) {
|
||||||
|
return this.props.onChange(value === "" ? undefined : value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggleDoc = (val: boolean) => {
|
||||||
|
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: SyntheticEvent<any, any>) => {
|
||||||
|
const el = event.nativeEvent.target;
|
||||||
|
const contains = this._blockEl?.contains(el);
|
||||||
|
|
||||||
|
if (event.nativeEvent.target.nodeName !== "INPUT" && !contains) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
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 { Collapse as ReactCollapse } from 'react-collapse'
|
||||||
|
import {reducedMotionEnabled} from '../libs/accessibility'
|
||||||
|
|
||||||
|
|
||||||
|
type CollapseProps = {
|
||||||
|
isActive: boolean
|
||||||
|
children: React.ReactElement
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default class Collapse extends React.Component<CollapseProps> {
|
||||||
|
static defaultProps = {
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (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,19 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {MdArrowDropDown, MdArrowDropUp} from 'react-icons/md'
|
||||||
|
|
||||||
|
type CollapserProps = {
|
||||||
|
isCollapsed: boolean
|
||||||
|
style?: object
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class Collapser extends React.Component<CollapserProps> {
|
||||||
|
render() {
|
||||||
|
const iconStyle = {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
...this.props.style,
|
||||||
|
}
|
||||||
|
return this.props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
js: "JS",
|
||||||
|
android: "Android",
|
||||||
|
ios: "iOS",
|
||||||
|
macos: "macOS",
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocProps = {
|
||||||
|
fieldSpec: {
|
||||||
|
doc?: string
|
||||||
|
values?: {
|
||||||
|
[key: string]: {
|
||||||
|
doc?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'sdk-support'?: {
|
||||||
|
[key: string]: typeof headers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class Doc extends React.Component<DocProps> {
|
||||||
|
render () {
|
||||||
|
const {fieldSpec} = this.props;
|
||||||
|
|
||||||
|
const {doc, values} = fieldSpec;
|
||||||
|
const sdkSupport = fieldSpec['sdk-support'];
|
||||||
|
|
||||||
|
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" data-wd-key='spec-field-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) => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(supportObj, k)) {
|
||||||
|
return <td key={k}>{supportObj[k as keyof typeof headers]}</td>;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return <td key={k}>no</td>;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import InputArray, { FieldArrayProps as InputArrayProps } from './InputArray'
|
||||||
|
import Fieldset from './Fieldset'
|
||||||
|
|
||||||
|
type FieldArrayProps = InputArrayProps & {
|
||||||
|
name?: string
|
||||||
|
fieldSpec?: {
|
||||||
|
doc: string
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class FieldArray extends React.Component<FieldArrayProps> {
|
||||||
|
render() {
|
||||||
|
return <Fieldset label={this.props.label} fieldSpec={this.props.fieldSpec}>
|
||||||
|
<InputArray {...this.props} />
|
||||||
|
</Fieldset>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Block from './Block'
|
||||||
|
import InputAutocomplete, { InputAutocompleteProps } from './InputAutocomplete'
|
||||||
|
|
||||||
|
|
||||||
|
type FieldAutocompleteProps = InputAutocompleteProps & {
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default class FieldAutocomplete extends React.Component<FieldAutocompleteProps> {
|
||||||
|
render() {
|
||||||
|
return <Block label={this.props.label}>
|
||||||
|
<InputAutocomplete {...this.props} />
|
||||||
|
</Block>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Block from './Block'
|
||||||
|
import InputCheckbox, {InputCheckboxProps} from './InputCheckbox'
|
||||||
|
|
||||||
|
|
||||||
|
type FieldCheckboxProps = InputCheckboxProps & {
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default class FieldCheckbox extends React.Component<FieldCheckboxProps> {
|
||||||
|
render() {
|
||||||
|
return <Block label={this.props.label}>
|
||||||
|
<InputCheckbox {...this.props} />
|
||||||
|
</Block>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Block from './Block'
|
||||||
|
import InputColor, {InputColorProps} from './InputColor'
|
||||||
|
|
||||||
|
|
||||||
|
type FieldColorProps = InputColorProps & {
|
||||||
|
label?: string
|
||||||
|
fieldSpec?: {
|
||||||
|
doc: string
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default class FieldColor extends React.Component<FieldColorProps> {
|
||||||
|
render() {
|
||||||
|
return <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
|
||||||
|
<InputColor {...this.props} />
|
||||||
|
</Block>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import Block from './Block'
|
||||||
|
import InputString from './InputString'
|
||||||
|
|
||||||
|
type FieldCommentProps = {
|
||||||
|
value?: string
|
||||||
|
onChange(value: string | undefined): unknown
|
||||||
|
error: {message: string}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class FieldComment extends React.Component<FieldCommentProps> {
|
||||||
|
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"
|
||||||
|
error={this.props.error}
|
||||||
|
>
|
||||||
|
<InputString
|
||||||
|
multi={true}
|
||||||
|
value={this.props.value}
|
||||||
|
onChange={this.props.onChange}
|
||||||
|
default="Comment..."
|
||||||
|
data-wd-key="layer-comment.input"
|
||||||
|
/>
|
||||||
|
</Block>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {MdInfoOutline, MdHighlightOff} from 'react-icons/md'
|
||||||
|
|
||||||
|
type FieldDocLabelProps = {
|
||||||
|
label: object | string | undefined
|
||||||
|
fieldSpec?: {
|
||||||
|
doc?: string
|
||||||
|
}
|
||||||
|
onToggleDoc?(...args: unknown[]): unknown
|
||||||
|
};
|
||||||
|
|
||||||
|
type FieldDocLabelState = {
|
||||||
|
open: boolean
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class FieldDocLabel extends React.Component<FieldDocLabelProps, FieldDocLabelState> {
|
||||||
|
constructor (props: FieldDocLabelProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggleDoc = (open: boolean) => {
|
||||||
|
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)}
|
||||||
|
data-wd-key={'field-doc-button-'+label}
|
||||||
|
>
|
||||||
|
{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,16 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import InputDynamicArray, {FieldDynamicArrayProps as InputDynamicArrayProps} from './InputDynamicArray'
|
||||||
|
import Fieldset from './Fieldset'
|
||||||
|
|
||||||
|
type FieldDynamicArrayProps = InputDynamicArrayProps & {
|
||||||
|
name?: string
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class FieldDynamicArray extends React.Component<FieldDynamicArrayProps> {
|
||||||
|
render() {
|
||||||
|
return <Fieldset label={this.props.label}>
|
||||||
|
<InputDynamicArray {...this.props} />
|
||||||
|
</Fieldset>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import InputEnum, {InputEnumProps} from './InputEnum'
|
||||||
|
import Fieldset from './Fieldset';
|
||||||
|
|
||||||
|
|
||||||
|
type FieldEnumProps = InputEnumProps & {
|
||||||
|
label?: string;
|
||||||
|
fieldSpec?: {
|
||||||
|
doc: string
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default class FieldEnum extends React.Component<FieldEnumProps> {
|
||||||
|
render() {
|
||||||
|
return <Fieldset label={this.props.label} fieldSpec={this.props.fieldSpec}>
|
||||||
|
<InputEnum {...this.props} />
|
||||||
|
</Fieldset>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import SpecProperty from './_SpecProperty'
|
||||||
|
import DataProperty, { Stop } from './_DataProperty'
|
||||||
|
import ZoomProperty from './_ZoomProperty'
|
||||||
|
import ExpressionProperty from './_ExpressionProperty'
|
||||||
|
import {function as styleFunction} from '@maplibre/maplibre-gl-style-spec';
|
||||||
|
import {findDefaultFromSpec} from '../libs/spec-helper';
|
||||||
|
|
||||||
|
|
||||||
|
function isLiteralExpression(value: any) {
|
||||||
|
return (Array.isArray(value) && value.length === 2 && value[0] === "literal");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGetExpression(value: any) {
|
||||||
|
return (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.length === 2 &&
|
||||||
|
value[0] === "get"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZoomField(value: any) {
|
||||||
|
return (
|
||||||
|
typeof(value) === 'object' &&
|
||||||
|
value.stops &&
|
||||||
|
typeof(value.property) === 'undefined' &&
|
||||||
|
Array.isArray(value.stops) &&
|
||||||
|
value.stops.length > 1 &&
|
||||||
|
value.stops.every((stop: Stop) => {
|
||||||
|
return (
|
||||||
|
Array.isArray(stop) &&
|
||||||
|
stop.length === 2
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIdentityProperty(value: any) {
|
||||||
|
return (
|
||||||
|
typeof(value) === 'object' &&
|
||||||
|
value.type === "identity" &&
|
||||||
|
Object.prototype.hasOwnProperty.call(value, "property")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDataStopProperty(value: any) {
|
||||||
|
return (
|
||||||
|
typeof(value) === 'object' &&
|
||||||
|
value.stops &&
|
||||||
|
typeof(value.property) !== 'undefined' &&
|
||||||
|
value.stops.length > 1 &&
|
||||||
|
Array.isArray(value.stops) &&
|
||||||
|
value.stops.every((stop: Stop) => {
|
||||||
|
return (
|
||||||
|
Array.isArray(stop) &&
|
||||||
|
stop.length === 2 &&
|
||||||
|
typeof(stop[0]) === 'object'
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDataField(value: any) {
|
||||||
|
return (
|
||||||
|
isIdentityProperty(value) ||
|
||||||
|
isDataStopProperty(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrimative(value: any): value is string | boolean | number {
|
||||||
|
const valid = ["string", "boolean", "number"];
|
||||||
|
return valid.includes(typeof(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isArrayOfPrimatives(values: any): values is Array<string | boolean | number> {
|
||||||
|
if (Array.isArray(values)) {
|
||||||
|
return values.every(isPrimative);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDataType(value: any, fieldSpec={} as any) {
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type FieldFunctionProps = {
|
||||||
|
onChange(fieldName: string, value: any): unknown
|
||||||
|
fieldName: string
|
||||||
|
fieldType: string
|
||||||
|
fieldSpec: any
|
||||||
|
errors?: {[key: string]: {message: string}}
|
||||||
|
value?: any
|
||||||
|
};
|
||||||
|
|
||||||
|
type FieldFunctionState = {
|
||||||
|
dataType: string
|
||||||
|
isEditing: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<FieldFunctionProps, FieldFunctionState> {
|
||||||
|
constructor (props: FieldFunctionProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
dataType: getDataType(props.value, props.fieldSpec),
|
||||||
|
isEditing: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromProps(props: FieldFunctionProps, state: FieldFunctionState) {
|
||||||
|
// 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: any) {
|
||||||
|
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: number) => {
|
||||||
|
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: 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: 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 {latest} from '@maplibre/maplibre-gl-style-spec'
|
||||||
|
import Block from './Block'
|
||||||
|
import InputString from './InputString'
|
||||||
|
|
||||||
|
type FieldIdProps = {
|
||||||
|
value: string
|
||||||
|
wdKey: string
|
||||||
|
onChange(value: string | undefined): unknown
|
||||||
|
error?: {message: string}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class FieldId extends React.Component<FieldIdProps> {
|
||||||
|
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}
|
||||||
|
data-wd-key={this.props.wdKey + ".input"}
|
||||||
|
/>
|
||||||
|
</Block>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import InputJson, {InputJsonProps} from './InputJson'
|
||||||
|
|
||||||
|
|
||||||
|
type FieldJsonProps = InputJsonProps & {};
|
||||||
|
|
||||||
|
|
||||||
|
export default class FieldJson extends React.Component<FieldJsonProps> {
|
||||||
|
render() {
|
||||||
|
return <InputJson {...this.props} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import {latest} from '@maplibre/maplibre-gl-style-spec'
|
||||||
|
import Block from './Block'
|
||||||
|
import InputNumber from './InputNumber'
|
||||||
|
|
||||||
|
type FieldMaxZoomProps = {
|
||||||
|
value?: number
|
||||||
|
onChange(value: number | undefined): unknown
|
||||||
|
error?: {message: string}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class FieldMaxZoom extends React.Component<FieldMaxZoomProps> {
|
||||||
|
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}
|
||||||
|
data-wd-key="max-zoom.input"
|
||||||
|
/>
|
||||||
|
</Block>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import {latest} from '@maplibre/maplibre-gl-style-spec'
|
||||||
|
import Block from './Block'
|
||||||
|
import InputNumber from './InputNumber'
|
||||||
|
|
||||||
|
type FieldMinZoomProps = {
|
||||||
|
value?: number
|
||||||
|
onChange(...args: unknown[]): unknown
|
||||||
|
error?: {message: string}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class FieldMinZoom extends React.Component<FieldMinZoomProps> {
|
||||||
|
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}
|
||||||
|
data-wd-key='min-zoom.input'
|
||||||
|
/>
|
||||||
|
</Block>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import InputMultiInput, {InputMultiInputProps} from './InputMultiInput'
|
||||||
|
import Fieldset from './Fieldset'
|
||||||
|
|
||||||
|
|
||||||
|
type FieldMultiInputProps = InputMultiInputProps & {
|
||||||
|
label?: string
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default class FieldMultiInput extends React.Component<FieldMultiInputProps> {
|
||||||
|
render() {
|
||||||
|
return <Fieldset label={this.props.label}>
|
||||||
|
<InputMultiInput {...this.props} />
|
||||||
|
</Fieldset>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import InputNumber, {InputNumberProps} from './InputNumber'
|
||||||
|
import Block from './Block'
|
||||||
|
|
||||||
|
|
||||||
|
type FieldNumberProps = InputNumberProps & {
|
||||||
|
label?: string
|
||||||
|
fieldSpec?: {
|
||||||
|
doc: string
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default class FieldNumber extends React.Component<FieldNumberProps> {
|
||||||
|
render() {
|
||||||
|
return <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
|
||||||
|
<InputNumber {...this.props} />
|
||||||
|
</Block>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Block from './Block'
|
||||||
|
import InputSelect, {InputSelectProps} from './InputSelect'
|
||||||
|
|
||||||
|
|
||||||
|
type FieldSelectProps = InputSelectProps & {
|
||||||
|
label?: string
|
||||||
|
fieldSpec?: {
|
||||||
|
doc: string
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default class FieldSelect extends React.Component<FieldSelectProps> {
|
||||||
|
render() {
|
||||||
|
return <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
|
||||||
|
<InputSelect {...this.props}/>
|
||||||
|
</Block>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import {latest} from '@maplibre/maplibre-gl-style-spec'
|
||||||
|
import Block from './Block'
|
||||||
|
import InputAutocomplete from './InputAutocomplete'
|
||||||
|
|
||||||
|
type FieldSourceProps = {
|
||||||
|
value?: string
|
||||||
|
wdKey?: string
|
||||||
|
onChange?(value: string| undefined): unknown
|
||||||
|
sourceIds?: unknown[]
|
||||||
|
error?: {message: string}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class FieldSource extends React.Component<FieldSourceProps> {
|
||||||
|
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,37 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import {latest} from '@maplibre/maplibre-gl-style-spec'
|
||||||
|
import Block from './Block'
|
||||||
|
import InputAutocomplete from './InputAutocomplete'
|
||||||
|
|
||||||
|
type FieldSourceLayerProps = {
|
||||||
|
value?: string
|
||||||
|
onChange?(...args: unknown[]): unknown
|
||||||
|
sourceLayerIds?: unknown[]
|
||||||
|
isFixed?: boolean
|
||||||
|
error?: {message: string}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class FieldSourceLayer extends React.Component<FieldSourceLayerProps> {
|
||||||
|
static defaultProps = {
|
||||||
|
onChange: () => {},
|
||||||
|
sourceLayerIds: [],
|
||||||
|
isFixed: false
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <Block
|
||||||
|
label={"Source Layer"}
|
||||||
|
fieldSpec={latest.layer['source-layer']}
|
||||||
|
data-wd-key="layer-source-layer"
|
||||||
|
error={this.props.error}
|
||||||
|
>
|
||||||
|
<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,19 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import Block from './Block'
|
||||||
|
import InputString, {InputStringProps} from './InputString'
|
||||||
|
|
||||||
|
type FieldStringProps = InputStringProps & {
|
||||||
|
name?: string
|
||||||
|
label?: string
|
||||||
|
fieldSpec?: {
|
||||||
|
doc: string
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class FieldString extends React.Component<FieldStringProps> {
|
||||||
|
render() {
|
||||||
|
return <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
|
||||||
|
<InputString {...this.props} />
|
||||||
|
</Block>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import {latest} from '@maplibre/maplibre-gl-style-spec'
|
||||||
|
import Block from './Block'
|
||||||
|
import InputSelect from './InputSelect'
|
||||||
|
import InputString from './InputString'
|
||||||
|
|
||||||
|
type FieldTypeProps = {
|
||||||
|
value: string
|
||||||
|
wdKey?: string
|
||||||
|
onChange(value: string): unknown
|
||||||
|
error?: {message: string}
|
||||||
|
disabled?: boolean
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class FieldType extends React.Component<FieldTypeProps> {
|
||||||
|
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}
|
||||||
|
data-wd-key={this.props.wdKey + ".select"}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</Block>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import InputUrl, {FieldUrlProps as InputUrlProps} from './InputUrl'
|
||||||
|
import Block from './Block'
|
||||||
|
|
||||||
|
|
||||||
|
type FieldUrlProps = InputUrlProps & {
|
||||||
|
label: string;
|
||||||
|
fieldSpec?: {
|
||||||
|
doc: string
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default class FieldUrl extends React.Component<FieldUrlProps> {
|
||||||
|
render () {
|
||||||
|
return (
|
||||||
|
<Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
|
||||||
|
<InputUrl {...this.props} />
|
||||||
|
</Block>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user