Compare commits

..

8 Commits

Author SHA1 Message Date
shelly_goldblit 666bc648d8 wip 2023-12-30 21:17:35 +02:00
shelly_goldblit f7a945abce wip 2023-12-30 20:36:49 +02:00
shelly_goldblit 085eda76a1 convert more tests 2023-12-30 18:48:17 +02:00
ShellyDCMS 0dc3f4bf5d fix lock file 2023-12-30 16:41:53 +00:00
shelly_goldblit efc507aecd fix failing test 2023-12-30 18:36:27 +02:00
shelly_goldblit ef902910c8 update keyboard tests 2023-12-30 18:32:19 +02:00
shelly_goldblit 6ed08c7416 modify some of the layer tests 2023-12-30 18:26:50 +02:00
shelly_goldblit 9014e12ca6 improve drivers 2023-12-30 11:49:03 +02:00
258 changed files with 35479 additions and 1006 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"packages": [],
"sandboxes": ["/"]
}
+45
View File
@@ -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
+14
View File
@@ -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
+46
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
custom: "https://maputnik.github.io/donate"
+27
View File
@@ -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. -->
+11
View File
@@ -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) -->
+14
View File
@@ -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
+137
View File
@@ -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
+26
View File
@@ -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
View File
@@ -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/
View File
+1
View File
@@ -0,0 +1 @@
18.19
+18
View File
@@ -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"]
}
+9
View File
@@ -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;
+7
View File
@@ -0,0 +1,7 @@
import { addons } from '@storybook/addons';
import { themes } from '@storybook/theming';
import theme from './maputnik.theme';
addons.setConfig({
theme: theme,
});
+8
View File
@@ -0,0 +1,8 @@
import { create } from '@storybook/theming/create';
export default create({
base: 'light',
brandTitle: 'Maputnik',
brandUrl: 'https://github.com/maputnik/editor',
});
+15
View File
@@ -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
View File
@@ -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/
+22
View File
@@ -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.
+161
View File
@@ -0,0 +1,161 @@
<img width="200" alt="Maputnik logo" src="https://cdn.jsdelivr.net/gh/maputnik/design/logos/logo-color.png" />
# Maputnik
[![GitHub CI status](https://github.com/maputnik/editor/workflows/ci/badge.svg)][github-action-ci]
[![License](https://img.shields.io/badge/license-MIT-blue.svg)][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
[![Design Map from Scratch](https://j.gifs.com/g5XMgl.gif)](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
-2
View File
@@ -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
View File
@@ -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":""}
+23
View File
@@ -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,
},
},
});
+41
View File
@@ -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();
});
});
});
+22
View File
@@ -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;
}
+97
View File
@@ -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",
},
]
);
});
});
+61
View File
@@ -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();
});
});
});
+477
View File
@@ -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();
});
});
});
+27
View File
@@ -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();
});
});
});
+211
View File
@@ -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");
},
};
}
+38
View File
@@ -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");
},
};
}
+177
View File
@@ -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");
});
});
+18
View File
@@ -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"
}
]
}
+12
View File
@@ -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": []
}
+29
View File
@@ -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": []
}
+18
View File
@@ -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": []
}
+37
View File
@@ -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>
// }
// }
// }
+22
View File
@@ -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
View File
@@ -4,8 +4,8 @@
<meta charset="utf-8">
<title>Maputnik</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="manifest" href="/maputnik/assets/manifest-BrZzkYP9.json">
<link rel="icon" href="/maputnik/assets/favicon-DBn6BKLx.ico" type="image/x-icon" />
<link rel="manifest" href="src/manifest.json">
<link rel="icon" href="src/favicon.ico" type="image/x-icon" />
<style>
html {
background-color: rgb(28, 31, 36);
@@ -37,8 +37,6 @@
}
</style>
<script type="module" crossorigin src="/maputnik/assets/index-DPDV-s6M.js"></script>
<link rel="stylesheet" crossorigin href="/maputnik/assets/index-CKLfpVt5.css">
</head>
<body>
<!-- From <https://github.com/hail2u/color-blindness-emulation> -->
@@ -125,9 +123,10 @@
<div id="app"></div>
<div class="loading">
<div class="loading__logo">
<img inline src="data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20width='1200'%20height='1200'%20viewBox='0%200%20100%20100'%3e%3cstyle%3e@keyframes%20circle-anim{0%25,40%25{fill-opacity:0}60%25,to{fill-opacity:1}}.circle0,.circle1,.circle2,.circle3,.circle4,.circle5{stroke-opacity:0;animation-name:circle-anim;will-change:transform;animation-timing-function:east-in-out;animation-duration:800ms;animation-iteration-count:infinite;animation-direction:alternate}.circle0{animation-delay:100ms}.circle1{animation-delay:200ms}.circle2{animation-delay:300ms}.circle3{animation-delay:400ms}.circle4{animation-delay:500ms}.circle5{animation-delay:600ms}%3c/style%3e%3cg%20class='map'%20stroke='%23000'%3e%3cuse%20xlink:href='%23ref-1--map__main'%20fill='%234eba6f'%3e%3c/use%3e%3cuse%20xlink:href='%23ref-1--map__line1'%20fill='none'%3e%3c/use%3e%3cuse%20xlink:href='%23ref-1--map__line2'%20fill='none'%3e%3c/use%3e%3cuse%20xlink:href='%23ref-1--map__line3'%20fill='none'%3e%3c/use%3e%3c/g%3e%3cg%20class='palette'%3e%3cuse%20xlink:href='%23ref-1--palette__main'%20fill='%23fff'%20stroke='%23000'%3e%3c/use%3e%3cuse%20xlink:href='%23ref-1--palette__inner'%20fill='none'%20stroke='%23000'%3e%3c/use%3e%3cuse%20class='circle5'%20xlink:href='%23ref-1--palette__circle5'%20fill='%23f7c44c'%3e%3c/use%3e%3cuse%20class='circle4'%20xlink:href='%23ref-1--palette__circle4'%20fill='%234eba6f'%3e%3c/use%3e%3cuse%20class='circle3'%20xlink:href='%23ref-1--palette__circle3'%20fill='%23f7c44c'%3e%3c/use%3e%3cuse%20class='circle2'%20xlink:href='%23ref-1--palette__circle2'%20fill='%234eba6f'%3e%3c/use%3e%3cuse%20class='circle1'%20xlink:href='%23ref-1--palette__circle1'%20fill='%23f7c44c'%3e%3c/use%3e%3cuse%20class='circle0'%20xlink:href='%23ref-1--palette__circle0'%20fill='%234eba6f'%3e%3c/use%3e%3c/g%3e%3cg%20class='brush'%20stroke='%23000'%3e%3cuse%20xlink:href='%23ref-1--brush__bottom'%20fill='%23f7c44c'%3e%3c/use%3e%3cuse%20xlink:href='%23ref-1--brush__top'%20fill='%23fff'%3e%3c/use%3e%3c/g%3e%3cdefs%3e%3cpath%20id='ref-1--map__main'%20stroke-width='2.366'%20stroke-linejoin='round'%20d='M18.84%207.717l15.44%207.542%2015.75-7.762%2015.7%207.857L81.005%207.67%2096.31%2054.052%2073.598%2062.12%2050.93%2053.872l-25.1%208.066-22.668-8.066z'%3e%3c/path%3e%3cpath%20id='ref-1--map__line1'%20d='M65.556%2015.07l7.647%2046.838'%20stroke-width='1.104'%3e%3c/path%3e%3cpath%20id='ref-1--map__line2'%20d='M50.261%207.422l.717%2046.6'%20stroke-width='1.104'%3e%3c/path%3e%3cpath%20id='ref-1--map__line3'%20d='M34.011%2015.07l-8.603%2046.6'%20stroke-width='1.104'%3e%3c/path%3e%3cpath%20id='ref-1--palette__main'%20stroke-width='2.3'%20d='M47.352%2030.887c7.993.226%2016.934%209.725%2017.954%2015.25%201.02%205.527-.743%2011.125-4.298%2013.875-3.554%202.75-8.6%202.905-8.723%208.302-.097%204.237%208.457%208.5%208.088%2015.653-.406%207.857-15.508%2013.15-30.943%206.102-8.556-3.906-14.249-13.653-13.385-26.238C16.833%2052.334%2022.32%2043.658%2027.382%2039c5.977-5.503%2011.977-8.337%2019.97-8.112z'%3e%3c/path%3e%3ccircle%20id='ref-1--palette__inner'%20stroke-width='2.3'%20cx='41.873'%20cy='61.901'%20r='6.389'%3e%3c/circle%3e%3ccircle%20id='ref-1--palette__circle5'%20cy='44.56'%20cx='54.347'%20r='4.336'%3e%3c/circle%3e%3ccircle%20id='ref-1--palette__circle4'%20cx='40.443'%20cy='41.555'%20r='4.336'%3e%3c/circle%3e%3ccircle%20id='ref-1--palette__circle3'%20r='4.336'%20cy='51.102'%20cx='29.651'%3e%3c/circle%3e%3ccircle%20id='ref-1--palette__circle2'%20cx='25.293'%20cy='65.836'%20r='4.336'%3e%3c/circle%3e%3ccircle%20id='ref-1--palette__circle1'%20r='4.336'%20cy='79.326'%20cx='32.764'%3e%3c/circle%3e%3ccircle%20id='ref-1--palette__circle0'%20cx='46.669'%20cy='80.571'%20r='4.336'%3e%3c/circle%3e%3cpath%20id='ref-1--brush__bottom'%20d='M76.333%2089.333c-1.645-9.794-4.375-35.26-4.32-37.887.056-2.627%202.52-4.34%205.36-4.317%202.842.022%205.098%201.87%205.314%204.27.107%201.2-1.576%2028.06-2.318%2037.844-.332%204.374-3.31%204.413-4.036.09z'%20stroke-width='2.3'%20stroke-linejoin='round'%3e%3c/path%3e%3cpath%20id='ref-1--brush__top'%20stroke-linejoin='round'%20stroke-width='2.3'%20d='M77.184%2026.428s-5.621%207.02-5.621%2011.978c0%204.957%202.206%206.878%205.81%206.878%203.606%200%205.148-1.708%205.29-6.736.142-5.028-5.479-12.12-5.479-12.12z'%3e%3c/path%3e%3c/defs%3e%3c/svg%3e" />
<img inline src="node_modules/maputnik-design/logos/logo-loading.svg" />
</div>
<div class="loading__text">Loading&hellip;</div>
</div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+18507
View File
File diff suppressed because it is too large Load Diff
+149
View File
@@ -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"
}
}
+989
View File
@@ -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}
/>
}
}
+46
View File
@@ -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
+61
View File
@@ -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 &&
<>
&nbsp;&mdash;&nbsp;
<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>
}
}
+287
View File
@@ -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>
}
}
+103
View File
@@ -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>
}
}
+34
View File
@@ -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>
)
}
}
}
+19
View File
@@ -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} />
}
}
+91
View File
@@ -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>
}
</>
);
}
}
+19
View File
@@ -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>
}
}
+18
View File
@@ -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>
}
}
+18
View File
@@ -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>
}
}
+21
View File
@@ -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>
}
}
+33
View File
@@ -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>
}
}
+65
View File
@@ -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 />
}
}
}
+16
View File
@@ -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>
}
}
+20
View File
@@ -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>
}
}
+407
View File
@@ -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>
}
}
+27
View File
@@ -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>
}
}
+13
View File
@@ -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} />
}
}
+30
View File
@@ -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>
}
}
+30
View File
@@ -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>
}
}
+18
View File
@@ -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>
}
}
+20
View File
@@ -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>
}
}
+22
View File
@@ -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>
}
}
+35
View File
@@ -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>
}
}
+37
View File
@@ -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>
}
}
+19
View File
@@ -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>
}
}
+52
View File
@@ -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>
}
}
+23
View File
@@ -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