Compare commits

..

6 Commits

Author SHA1 Message Date
HarelM 6975bf29c7 Last file!!! 2023-12-24 10:20:55 +02:00
HarelM 4f1ad9f24a Migrate App to tsx 2023-12-24 10:00:03 +02:00
HarelM 90f50d98e7 Fix more types, migrate two more files 2023-12-24 00:42:02 +02:00
HarelM 08f7a427f6 More componenet migration 2023-12-23 15:40:27 +02:00
HarelM 90b692158a Another one bites the dust 2023-12-23 14:58:33 +02:00
HarelM 5a62f4ab21 Improve types, migrate another file 2023-12-22 23:59:51 +02:00
263 changed files with 33090 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
+20
View File
@@ -0,0 +1,20 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
+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
+140
View File
@@ -0,0 +1,140 @@
name: ci
on:
pull_request:
branches: [ main ]
push:
branches: [ main ]
jobs:
build-docker:
name: build/docker
runs-on: ${{ matrix.os }}
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
steps:
- uses: actions/checkout@v4
- run: docker build -t docker.pkg.github.com/maputnik/editor/editor:main .
# build the editor
build-node:
name: "build/node@${{ matrix.node-version }} (${{ matrix.os }})"
runs-on: ${{ matrix.os }}
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18.x]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- uses: actions/cache@v1
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm ci
- run: npm run build
build-artifacts:
name: "build/artifacts (${{ matrix.os }})"
runs-on: ${{ matrix.os }}
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
node-version: [18.x]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- uses: actions/cache@v1
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm ci
- run: npm run build
- run: npm run build-storybook
- name: artifacts/editor
uses: actions/upload-artifact@v1
with:
name: editor
path: dist
- name: artifacts/storybook
uses: actions/upload-artifact@v1
with:
name: storybook
path: build/storybook
# Build and upload desktop CLI artifacts
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: ^1.19.x
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v4
with:
repository: maputnik/desktop
ref: master
path: ./src/github.com/maputnik/desktop/
- name: Make
run: cd src/github.com/maputnik/desktop/ && make
- name: Artifacts/linux
uses: actions/upload-artifact@v1
with:
name: maputnik-linux
path: ./src/github.com/maputnik/desktop/bin/linux/
- name: Artifacts/darwin
uses: actions/upload-artifact@v1
with:
name: maputnik-darwin
path: ./src/github.com/maputnik/desktop/bin/darwin/
- name: Artifacts/windows
uses: actions/upload-artifact@v1
with:
name: maputnik-windows
path: ./src/github.com/maputnik/desktop/bin/windows/
cypress-run:
strategy:
fail-fast: false
matrix:
browser: [chrome, firefox]
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- run: npm ci
- name: Cypress run
uses: cypress-io/github-action@v6
with:
build: npm run build
start: npm run start
browser: ${{ matrix.browser }}
+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
+37
View File
@@ -0,0 +1,37 @@
# Logs
logs
*.log
*.swp
*.swo
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directory
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
node_modules
# Ignore build files
public
/errorShots
/old
/build
/cypress/screenshots
/dist/
View File
+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
}
}
+22
View File
@@ -0,0 +1,22 @@
FROM node:18 as builder
WORKDIR /maputnik
# Only copy package.json to prevent npm install from running on every build
COPY package.json package-lock.json ./
RUN npm install
# Build maputnik
# TODO: we should also do a npm run test here (needs more dependencies)
COPY . .
RUN npm run build
#---------------------------------------------------------------------------
# Create a clean python-based image with just the build results
FROM python:3-slim
WORKDIR /maputnik
COPY --from=builder /maputnik/dist .
EXPOSE 8888
CMD python -m http.server 8888
+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":""}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "cypress";
export default defineConfig({
e2e: {
setupNodeEvents(on, config) {
// implement node event listeners here
},
retries: {
runMode: 2,
openMode: 0,
},
},
});
+40
View File
@@ -0,0 +1,40 @@
import MaputnikDriver from "./driver";
describe("accessibility", () => {
let { beforeAndAfter, given, when, get, should } = new MaputnikDriver();
beforeAndAfter();
describe("skip links", () => {
beforeEach(() => {
when.setStyle("layer");
});
it("skip link to layer list", () => {
const selector = "root:skip:layer-list";
should.exist(selector);
when.tab();
should.beFocused(selector);
when.click(selector);
should.beFocused("skip-target-layer-list");
});
// 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";
should.exist(selector);
when.tab().tab();
should.beFocused(selector);
when.click(selector);
should.beFocused("skip-target-layer-editor");
});
it("skip link to map view", () => {
const selector = "root:skip:map-view";
should.exist(selector);
when.tab().tab().tab();
should.beFocused(selector);
when.click(selector);
should.canvasBeFocused();
});
});
});
+210
View File
@@ -0,0 +1,210 @@
import { CypressHelper } from "@shellygo/cypress-test-utils";
import { v1 as uuid } from "uuid";
export default class MaputnikDriver {
private helper = new CypressHelper({ defaultDataAttribute: "data-wd-key" });
public beforeAndAfter = () => {
beforeEach(() => {
this.given.setupInterception();
this.when.setStyle("both");
});
};
public given = {
setupInterception: () => {
cy.intercept("GET", "http://localhost:8888/example-style.json", {
fixture: "example-style.json",
}).as("example-style.json");
cy.intercept("GET", "http://localhost:8888/example-layer-style.json", {
fixture: "example-layer-style.json",
});
cy.intercept("GET", "http://localhost:8888/geojson-style.json", {
fixture: "geojson-style.json",
});
cy.intercept("GET", "http://localhost:8888/raster-style.json", {
fixture: "raster-style.json",
});
cy.intercept("GET", "http://localhost:8888/geojson-raster-style.json", {
fixture: "geojson-raster-style.json",
});
cy.intercept({ method: "GET", url: "*example.local/*" }, []);
cy.intercept({ method: "GET", url: "*example.com/*" }, []);
},
};
public when = {
within: (selector: string, fn: () => void) => {
this.helper.when.within(fn, selector);
},
tab: () => cy.get("body").tab(),
waitForExampleFileRequset: () => {
this.helper.when.waitForResponse("example-style.json");
},
chooseExampleFile: () => {
cy.get("input[type='file']").selectFile(
"cypress/fixtures/example-style.json",
{ force: true }
);
},
setStyle: (
styleProperties: "geojson" | "raster" | "both" | "layer" | "",
zoom?: number
) => {
let url = "?debug";
switch (styleProperties) {
case "geojson":
url += "&style=http://localhost:8888/geojson-style.json";
break;
case "raster":
url += "&style=http://localhost:8888/raster-style.json";
break;
case "both":
url += "&style=http://localhost:8888/geojson-raster-style.json";
break;
case "layer":
url += "&style=http://localhost:8888/example-layer-style.json";
break;
}
if (zoom) {
url += "#" + zoom + "/41.3805/2.1635";
}
cy.visit("http://localhost:8888/" + url);
if (styleProperties) {
cy.on("window:confirm", () => true);
}
cy.get(".maputnik-toolbar-link").should("be.visible");
},
fillLayersModal: (opts: {type: string, layer?: string, id?: string}) => {
var type = opts.type;
var layer = opts.layer;
var id;
if (opts.id) {
id = opts.id;
} else {
id = `${type}:${uuid()}`;
}
cy.get(
this.get.dataAttribute("add-layer.layer-type", "select")
).select(type);
cy.get(this.get.dataAttribute("add-layer.layer-id", "input")).type(id);
if (layer) {
cy.get(
this.get.dataAttribute("add-layer.layer-source-block", "input")
).type(layer);
}
this.when.click("add-layer");
return id;
},
typeKeys: (keys: string) => {
cy.get("body").type(keys);
},
click: (selector: string) => {
this.helper.when.click(selector);
},
clickZoomin: () => {
cy.get(".maplibregl-ctrl-zoom-in").click();
},
selectWithin: (selector: string, value: string) => {
this.when.within(selector, () => {
cy.get("select").select(value);
});
},
select: (selector: string, value: string) => {
this.helper.get.element(selector).select(value);
},
focus: (selector: string) => {
this.helper.when.focus(selector);
},
setValue: (selector: string, text: string) => {
cy.get(selector).clear().type(text, { parseSpecialCharSequences: false });
},
closeModal: (key: string) => {
this.helper.when.waitUntil(() => this.helper.get.element(key));
this.when.click(key + ".close-modal");
},
openLayersModal: () => {
this.helper.when.click("layer-list:add-layer");
cy.get(this.get.dataAttribute("modal:add-layer")).should("exist");
cy.get(this.get.dataAttribute("modal:add-layer")).should("be.visible");
},
};
public get = {
isMac: () => {
return Cypress.platform === "darwin";
},
styleFromWindow: (win: Window) => {
const styleId = win.localStorage.getItem("maputnik:latest_style");
const styleItem = win.localStorage.getItem(`maputnik:style:${styleId}`);
const obj = JSON.parse(styleItem || "");
return obj;
},
exampleFileUrl: () => {
return "http://localhost:8888/example-style.json";
},
dataAttribute: (key: string, selector?: string): string => {
return `*[data-wd-key='${key}'] ${selector || ""}`;
},
};
public should = {
canvasBeFocused: () => {
this.when.within("maplibre:map", () => {
cy.get("canvas").should("be.focused");
});
},
notExist: (selector: string) => {
cy.get(selector).should("not.exist");
},
beFocused: (selector: string) => {
this.helper.get.element(selector).should("have.focus");
},
notBeFocused: (selector: string) => {
this.helper.get.element(selector).should("not.have.focus");
},
beVisible: (selector: string) => {
this.helper.get.element(selector).should("be.visible");
},
notBeVisible: (selector: string) => {
this.helper.get.element(selector).should("not.be.visible");
},
equalStyleStore: (getter: (obj: any) => any, styleObj: any) => {
cy.window().then((win: any) => {
const obj = this.get.styleFromWindow(win);
assert.deepEqual(getter(obj), styleObj);
});
},
styleStoreEqualToExampleFileData: () => {
cy.window().then((win: any) => {
const obj = this.get.styleFromWindow(win);
cy.fixture("example-style.json").should("deep.equal", obj);
});
},
exist: (selector: string) => {
this.helper.get.element(selector).should("exist");
},
beSelected: (selector: string, value: string) => {
this.helper.get.element(selector).find(`option[value="${value}"]`).should("be.selected");
},
containText: (selector: string, text: string) => {
this.helper.get.element(selector).should("contain.text", text);
}
};
}
+97
View File
@@ -0,0 +1,97 @@
import MaputnikDriver from "./driver";
describe("history", () => {
let { beforeAndAfter, given, when, get, should } = new MaputnikDriver();
beforeAndAfter();
let undoKeyCombo: string;
let redoKeyCombo: string;
before(() => {
const isMac = get.isMac();
undoKeyCombo = isMac ? "{meta}z" : "{ctrl}z";
redoKeyCombo = isMac ? "{meta}{shift}z" : "{ctrl}y";
});
it("undo/redo", () => {
when.setStyle("geojson");
when.openLayersModal();
should.equalStyleStore((a: any) => a.layers, []);
when.fillLayersModal({
id: "step 1",
type: "background",
});
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: "step 1",
type: "background",
},
]
);
when.openLayersModal();
when.fillLayersModal({
id: "step 2",
type: "background",
});
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: "step 1",
type: "background",
},
{
id: "step 2",
type: "background",
},
]
);
when.typeKeys(undoKeyCombo);
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: "step 1",
type: "background",
},
]
);
when.typeKeys(undoKeyCombo);
should.equalStyleStore((a: any) => a.layers, []);
when.typeKeys(redoKeyCombo);
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: "step 1",
type: "background",
},
]
);
when.typeKeys(redoKeyCombo);
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: "step 1",
type: "background",
},
{
id: "step 2",
type: "background",
},
]
);
});
});
+61
View File
@@ -0,0 +1,61 @@
import MaputnikDriver from "./driver";
describe("keyboard", () => {
let { beforeAndAfter, given, when, get, should } = new MaputnikDriver();
beforeAndAfter();
describe("shortcuts", () => {
beforeEach(() => {
given.setupInterception();
when.setStyle("");
});
it("ESC should unfocus", () => {
const targetSelector = "maputnik-select";
when.focus(targetSelector);
should.beFocused(targetSelector);
when.typeKeys("{esc}");
expect(should.notBeFocused(targetSelector));
});
it("'?' should show shortcuts modal", () => {
when.typeKeys("?");
should.beVisible("modal:shortcuts");
});
it("'o' should show open modal", () => {
when.typeKeys("o");
should.beVisible("modal:open");
});
it("'e' should show export modal", () => {
when.typeKeys("e");
should.beVisible("modal:export");
});
it("'d' should show sources modal", () => {
when.typeKeys("d");
should.beVisible("modal:sources");
});
it("'s' should show settings modal", () => {
when.typeKeys("s");
should.beVisible("modal:settings");
});
it("'i' should change map to inspect mode", () => {
when.typeKeys("i");
should.beSelected("nav:inspect", "inspect");
});
it("'m' should focus map", () => {
when.typeKeys("m");
should.canvasBeFocused();
});
it("'!' should show debug modal", () => {
when.typeKeys("!");
should.beVisible("modal:debug");
});
});
});
+491
View File
@@ -0,0 +1,491 @@
import { v1 as uuid } from "uuid";
import MaputnikDriver from "./driver";
describe("layers", () => {
let { beforeAndAfter, given, when, get, should } = new MaputnikDriver();
beforeAndAfter();
beforeEach(() => {
when.setStyle("both");
when.openLayersModal();
});
describe("ops", () => {
it("delete", () => {
var id = when.fillLayersModal({
type: "background",
});
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: id,
type: "background",
},
]
);
when.click("layer-list-item:" + id + ":delete");
should.equalStyleStore((a: any) => a.layers, []);
});
it("duplicate", () => {
var styleObj;
var id = when.fillLayersModal({
type: "background",
});
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: id,
type: "background",
},
]
);
when.click("layer-list-item:" + id + ":copy");
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: id + "-copy",
type: "background",
},
{
id: id,
type: "background",
},
]
);
});
it("hide", () => {
var styleObj;
var id = when.fillLayersModal({
type: "background",
});
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: id,
type: "background",
},
]
);
when.click("layer-list-item:" + id + ":toggle-visibility");
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: id,
type: "background",
layout: {
visibility: "none",
},
},
]
);
when.click("layer-list-item:" + id + ":toggle-visibility");
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: id,
type: "background",
layout: {
visibility: "visible",
},
},
]
);
});
});
describe("background", () => {
it("add", () => {
var id = when.fillLayersModal({
type: "background",
});
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: id,
type: "background",
},
]
);
});
describe("modify", () => {
function createBackground() {
// Setup
var id = uuid();
when.selectWithin("add-layer.layer-type", "background");
when.setValue(
get.dataAttribute("add-layer.layer-id", "input"),
"background:" + id
);
when.click("add-layer");
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: "background:" + id,
type: "background",
},
]
);
return id;
}
// ====> THESE SHOULD BE FROM THE SPEC
describe("layer", () => {
it("expand/collapse");
it("id", () => {
var bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
var id = uuid();
when.setValue(
get.dataAttribute("layer-editor.layer-id", "input"),
"foobar:" + id
);
when.click("min-zoom");
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: "foobar:" + id,
type: "background",
},
]
);
});
it("min-zoom", () => {
var bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.setValue(
get.dataAttribute("min-zoom", 'input[type="text"]'),
"1"
);
when.click("layer-editor.layer-id");
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: "background:" + bgId,
type: "background",
minzoom: 1,
},
]
);
// AND RESET!
// driver.setValue(driver.get.dataAttribute("min-zoom", "input"), "")
// driver.click(driver.get.dataAttribute("max-zoom", "input"));
// driver.isStyleStoreEqual((a: any) => a.layers, [
// {
// "id": 'background:'+bgId,
// "type": 'background'
// }
// ]);
});
it("max-zoom", () => {
var bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.setValue(
get.dataAttribute("max-zoom", 'input[type="text"]'),
"1"
);
when.click("layer-editor.layer-id");
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: "background:" + bgId,
type: "background",
maxzoom: 1,
},
]
);
});
it("comments", () => {
var bgId = createBackground();
var id = uuid();
when.click("layer-list-item:background:" + bgId);
when.setValue(get.dataAttribute("layer-comment", "textarea"), id);
when.click("layer-editor.layer-id");
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: "background:" + bgId,
type: "background",
metadata: {
"maputnik:comment": id,
},
},
]
);
// Unset it again.
// TODO: This fails
// driver.setValue(driver.getDataAttribute("layer-comment", "textarea"), "");
// driver.click(driver.getDataAttribute("min-zoom", "input"));
// driver.isStyleStoreEqual((a: any) => a.layers, [
// {
// "id": 'background:'+bgId,
// "type": 'background'
// }
// ]);
});
it("color", () => {
var bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
when.click("spec-field:background-color");
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: "background:" + bgId,
type: "background",
},
]
);
});
});
describe("filter", () => {
it("expand/collapse");
it("compound filter");
});
describe("paint", () => {
it("expand/collapse");
it("color");
it("pattern");
it("opacity");
});
// <=====
describe("json-editor", () => {
it("expand/collapse");
it("modify");
// TODO
it.skip("parse error", () => {
var bgId = createBackground();
when.click("layer-list-item:background:" + bgId);
var errorSelector = ".CodeMirror-lint-marker-error";
should.notExist(errorSelector);
when.click(".CodeMirror");
when.typeKeys(
"\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013\uE013 {"
);
should.exist(errorSelector);
});
});
});
});
describe("fill", () => {
it("add", () => {
var id = when.fillLayersModal({
type: "fill",
layer: "example",
});
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: id,
type: "fill",
source: "example",
},
]
);
});
// TODO: Change source
it("change source");
});
describe("line", () => {
it("add", () => {
var id = when.fillLayersModal({
type: "line",
layer: "example",
});
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: id,
type: "line",
source: "example",
},
]
);
});
it("groups", () => {
// TODO
// Click each of the layer groups.
});
});
describe("symbol", () => {
it("add", () => {
var id = when.fillLayersModal({
type: "symbol",
layer: "example",
});
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: id,
type: "symbol",
source: "example",
},
]
);
});
});
describe("raster", () => {
it("add", () => {
var id = when.fillLayersModal({
type: "raster",
layer: "raster",
});
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: id,
type: "raster",
source: "raster",
},
]
);
});
});
describe("circle", () => {
it("add", () => {
var id = when.fillLayersModal({
type: "circle",
layer: "example",
});
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: id,
type: "circle",
source: "example",
},
]
);
});
});
describe("fill extrusion", () => {
it("add", () => {
var id = when.fillLayersModal({
type: "fill-extrusion",
layer: "example",
});
should.equalStyleStore(
(a: any) => a.layers,
[
{
id: id,
type: "fill-extrusion",
source: "example",
},
]
);
});
});
describe("groups", () => {
it("simple", () => {
when.setStyle("geojson");
when.openLayersModal();
when.fillLayersModal({
id: "foo",
type: "background",
});
when.openLayersModal();
when.fillLayersModal({
id: "foo_bar",
type: "background",
});
when.openLayersModal();
when.fillLayersModal({
id: "foo_bar_baz",
type: "background",
});
should.beVisible("layer-list-item:foo");
should.notBeVisible("layer-list-item:foo_bar");
should.notBeVisible("layer-list-item:foo_bar_baz");
when.click("layer-list-group:foo-0");
should.beVisible("layer-list-item:foo");
should.beVisible("layer-list-item:foo_bar");
should.beVisible("layer-list-item:foo_bar_baz");
});
});
});
+23
View File
@@ -0,0 +1,23 @@
import MaputnikDriver from "./driver";
describe("map", () => {
let { beforeAndAfter, given, when, get, should } = new MaputnikDriver();
beforeAndAfter();
describe("zoom level", () => {
it("via url", () => {
var zoomLevel = 12.37;
when.setStyle("geojson", zoomLevel);
should.beVisible("maplibre:ctrl-zoom");
should.containText("maplibre:ctrl-zoom", "Zoom: " + zoomLevel);
});
it("via map controls", () => {
var zoomLevel = 12.37;
when.setStyle("geojson", zoomLevel);
should.beVisible("maplibre:ctrl-zoom");
when.clickZoomin();
should.containText("maplibre:ctrl-zoom", "Zoom: "+(zoomLevel + 1));
});
});
});
+161
View File
@@ -0,0 +1,161 @@
import MaputnikDriver from "./driver";
describe("modals", () => {
let { beforeAndAfter, given, when, get, should } = new MaputnikDriver();
beforeAndAfter();
beforeEach(() => {
when.setStyle("");
});
describe("open", () => {
beforeEach(() => {
when.click("nav:open");
});
it("close", () => {
when.closeModal("modal:open");
should.notExist("modal:open");
});
it.skip("upload", () => {
// HM: I was not able to make the following choose file actually to select a file and close the modal...
when.chooseExampleFile();
should.styleStoreEqualToExampleFileData();
});
it("load from url", () => {
var styleFileUrl = get.exampleFileUrl();
when.setValue(get.dataAttribute("modal:open.url.input"), styleFileUrl);
when.click("modal:open.url.button");
when.waitForExampleFileRequset();
should.styleStoreEqualToExampleFileData();
});
});
describe("shortcuts", () => {
it("open/close", () => {
when.setStyle("");
when.typeKeys("?");
when.closeModal("modal:shortcuts");
should.notExist("modal:shortcuts");
});
});
describe("export", () => {
beforeEach(() => {
when.click("nav:export");
});
it("close", () => {
when.closeModal("modal:export");
should.notExist("modal:export");
});
// TODO: Work out how to download a file and check the contents
it("download");
});
describe("sources", () => {
it("active sources");
it("public source");
it("add new source");
});
describe("inspect", () => {
it("toggle", () => {
when.setStyle("geojson");
when.selectWithin("nav:inspect", "inspect");
});
});
describe("style settings", () => {
beforeEach(() => {
when.click("nav:settings");
});
it("name", () => {
when.click("field-doc-button-Name");
should.containText("spec-field-doc", "name for the style");
});
it("show name specifications", () => {
when.setValue(get.dataAttribute("modal:settings.name"), "foobar");
when.click("modal:settings.owner");
should.equalStyleStore((obj) => obj.name, "foobar");
});
it("owner", () => {
when.setValue(get.dataAttribute("modal:settings.owner"), "foobar");
when.click("modal:settings.name");
should.equalStyleStore((obj) => obj.owner, "foobar");
});
it("sprite url", () => {
when.setValue(
get.dataAttribute("modal:settings.sprite"),
"http://example.com"
);
when.click("modal:settings.name");
should.equalStyleStore((obj) => obj.sprite, "http://example.com");
});
it("glyphs url", () => {
var glyphsUrl = "http://example.com/{fontstack}/{range}.pbf";
when.setValue(get.dataAttribute("modal:settings.glyphs"), glyphsUrl);
when.click("modal:settings.name");
should.equalStyleStore((obj) => obj.glyphs, glyphsUrl);
});
it("maptiler access token", () => {
var apiKey = "testing123";
when.setValue(
get.dataAttribute(
"modal:settings.maputnik:openmaptiles_access_token"
),
apiKey
);
when.click("modal:settings.name");
should.equalStyleStore(
(obj) => obj.metadata["maputnik:openmaptiles_access_token"],
apiKey
);
});
it("thunderforest access token", () => {
var apiKey = "testing123";
when.setValue(
get.dataAttribute(
"modal:settings.maputnik:thunderforest_access_token"
),
apiKey
);
when.click("modal:settings.name");
should.equalStyleStore(
(obj) => obj.metadata["maputnik:thunderforest_access_token"],
apiKey
);
});
it("style renderer", () => {
cy.on("uncaught:exception", () => false); // this is due to the fact that this is an invalid style for openlayers
when.select("modal:settings.maputnik:renderer", "ol");
should.beSelected("modal:settings.maputnik:renderer", "ol");
when.click("modal:settings.name");
should.equalStyleStore((obj) => obj.metadata["maputnik:renderer"], "ol");
});
});
describe("sources", () => {
it("toggle");
});
});
+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>
// }
// }
// }
+21
View File
@@ -0,0 +1,21 @@
// ***********************************************************
// This example support/e2e.ts is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import "cypress-plugin-tab";
import "./commands";
// Alternatively you can use CommonJS syntax:
// require('./commands')
+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

+15940
View File
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
{
"name": "maputnik",
"version": "2.0.0-pre.2",
"description": "A MapLibre GL visual style editor",
"main": "''",
"scripts": {
"start": "vite",
"build": "tsc && vite build",
"lint": "eslint ./src --ext ts,tsx,js,jsx --report-unused-disable-directives --max-warnings 0 && npm run lint-css",
"test": "cypress run",
"cy:open": "cypress open",
"lint-css": "stylelint \"src/styles/*.scss\"",
"storybook": "storybook dev -h 0.0.0.0 -p 6006",
"build-storybook": "storybook build -o build/storybook"
},
"repository": {
"type": "git",
"url": "https://github.com/maputnik/editor"
},
"author": "Lukas Martinelli",
"license": "MIT",
"homepage": "https://github.com/maputnik/editor#readme",
"dependencies": {
"@mapbox/mapbox-gl-rtl-text": "^0.2.3",
"@maplibre/maplibre-gl-style-spec": "^17.0.1",
"@mdi/js": "^6.6.96",
"@mdi/react": "^1.5.0",
"array-move": "^4.0.0",
"buffer": "^6.0.3",
"classnames": "^2.3.1",
"codemirror": "^5.65.2",
"color": "^4.2.3",
"detect-browser": "^5.3.0",
"file-saver": "^2.0.5",
"json-stringify-pretty-compact": "^3.0.0",
"json-to-ast": "^2.1.0",
"jsonlint": "github:josdejong/jsonlint#85a19d7",
"lodash": "^4.17.21",
"lodash.capitalize": "^4.2.1",
"lodash.clamp": "^4.0.3",
"lodash.clonedeep": "^4.5.0",
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"lodash.throttle": "^4.1.1",
"mapbox-gl-inspect": "^1.3.1",
"maplibre-gl": "^2.4.0",
"maputnik-design": "github:maputnik/design#172b06c",
"ol": "^6.14.1",
"ol-mapbox-style": "^7.1.1",
"prop-types": "^15.8.1",
"react": "^16.0.0",
"react-accessible-accordion": "^4.0.0",
"react-aria-menubutton": "^7.0.3",
"react-aria-modal": "^4.0.1",
"react-autobind": "^1.0.6",
"react-autocomplete": "^1.8.1",
"react-collapse": "^5.1.1",
"react-color": "^2.19.3",
"react-dom": "^16.0.0",
"react-file-reader-input": "^2.0.0",
"react-icon-base": "^2.1.2",
"react-icons": "^4.3.1",
"react-sortable-hoc": "^2.0.0",
"reconnecting-websocket": "^4.4.0",
"sass": "^1.50.0",
"slugify": "^1.6.5",
"string-hash": "^1.1.3",
"url": "^0.11.0"
},
"jshintConfig": {
"esversion": 6
},
"stylelint": {
"extends": "stylelint-config-recommended-scss",
"rules": {
"no-descending-specificity": null,
"media-feature-name-no-unknown": [
true,
{
"ignoreMediaFeatureNames": [
"prefers-reduced-motion"
]
}
]
}
},
"devDependencies": {
"@rollup/plugin-replace": "^5.0.5",
"@shellygo/cypress-test-utils": "^2.0.9",
"@storybook/addon-a11y": "^7.6.5",
"@storybook/addon-actions": "^7.6.5",
"@storybook/addon-links": "^7.6.5",
"@storybook/addon-storysource": "^7.6.5",
"@storybook/addons": "^7.6.5",
"@storybook/builder-vite": "^7.6.5",
"@storybook/react": "^7.6.5",
"@storybook/react-vite": "^7.6.5",
"@storybook/theming": "^7.6.5",
"@types/codemirror": "^5.60.15",
"@types/color": "^3.0.6",
"@types/cors": "^2.8.17",
"@types/file-saver": "^2.0.7",
"@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.53.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"express": "^4.17.3",
"istanbul": "^0.4.5",
"istanbul-lib-coverage": "^3.2.0",
"mocha": "^9.2.2",
"postcss": "^8.4.12",
"react-hot-loader": "^4.13.0",
"storybook": "^7.6.5",
"stylelint": "^14.6.1",
"stylelint-config-recommended-scss": "^6.0.0",
"stylelint-scss": "^4.2.0",
"typescript": "^5.3.3",
"uuid": "^8.3.2",
"vite": "^5.0.0"
}
}
+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
let 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 [_matchStr, index, message] = dupMatch;
return {
message: error.message,
parsed: {
type: "layer",
data: {
index: parseInt(index, 10),
key: "id",
message,
}
}
}
}
// Special case: Invalid source
const invalidSourceMatch = error.message.match(/layers\[(\d+)\]: (source "(?:.*)" not found)/);
if (invalidSourceMatch) {
const [_matchStr, index, message] = invalidSourceMatch;
return {
message: error.message,
parsed: {
type: "layer",
data: {
index: parseInt(index, 10),
key: "source",
message,
}
}
}
}
const layerMatch = error.message.match(/layers\[(\d+)\]\.(?:(\S+)\.)?(\S+): (.*)/);
if (layerMatch) {
const [_matchStr, index, group, property, message] = layerMatch;
const key = (group && property) ? [group, property].join(".") : property;
return {
message: error.message,
parsed: {
type: "layer",
data: {
index: parseInt(index, 10),
key,
message
}
}
}
}
else {
return {
message: error.message,
};
}
});
let dirtyMapStyle: 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) => {
let layers = this.state.mapStyle.layers;
const remainingLayers = layers.slice(0);
remainingLayers.splice(index, 1);
this.onLayersChange(remainingLayers);
}
onLayerCopy = (index: number) => {
let layers = this.state.mapStyle.layers;
const changedLayers = layers.slice(0)
const clonedLayer = cloneDeep(changedLayers[index])
clonedLayer.id = clonedLayer.id + "-copy"
changedLayers.splice(index, 0, clonedLayer)
this.onLayersChange(changedLayers)
}
onLayerVisibilityToggle = (index: number) => {
let layers = this.state.mapStyle.layers;
const changedLayers = layers.slice(0)
const layer = { ...changedLayers[index] }
const changedLayout = 'layout' in layer ? {...layer.layout} : {}
changedLayout.visibility = changedLayout.visibility === 'none' ? 'visible' : 'none'
layer.layout = changedLayout
changedLayers[index] = layer
this.onLayersChange(changedLayers)
}
onLayerIdChange = (index: 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(let [key, val] of Object.entries(this.state.mapStyle.sources)) {
if(
!this.state.sources.hasOwnProperty(key) &&
val.type === "vector" &&
val.hasOwnProperty("url")
) {
sourceList[key] = {
type: val.type,
layers: []
};
let url = val.url;
try {
url = setFetchAccessToken(url!, this.state.mapStyle)
} catch(err) {
console.warn("Failed to setFetchAccessToken: ", err);
}
fetch(url!, {
mode: 'cors',
})
.then(response => response.json())
.then(json => {
if(!json.hasOwnProperty("vector_layers")) {
return;
}
// Create new objects before setState
const sources = Object.assign({}, {
[key]: this.state.sources[key],
});
for(let layer of json.vector_layers) {
(sources[key] 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 '../util/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>
}
}
+285
View File
@@ -0,0 +1,285 @@
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"
>
{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"
>
<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 (supportObj.hasOwnProperty(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>
}
}
+32
View File
@@ -0,0 +1,32 @@
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..."
/>
</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 '../util/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" &&
value.hasOwnProperty("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>
}
}
+26
View File
@@ -0,0 +1,26 @@
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}
/>
</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} />
}
}
+29
View File
@@ -0,0 +1,29 @@
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}
/>
</Block>
}
}
+29
View File
@@ -0,0 +1,29 @@
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}
/>
</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>
}
}
+51
View File
@@ -0,0 +1,51 @@
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}
/>
}
</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>
);
}
}
+66
View File
@@ -0,0 +1,66 @@
import React, { ReactElement } from 'react'
import FieldDocLabel from './FieldDocLabel'
import Doc from './Doc'
type FieldsetProps = {
label?: string,
fieldSpec?: { doc?: string },
action?: ReactElement,
};
type FieldsetState = {
showDoc: boolean
};
let IDX = 0;
export default class Fieldset extends React.Component<FieldsetProps, FieldsetState> {
_labelId: string;
constructor (props: FieldsetProps) {
super(props);
this._labelId = `fieldset_label_${(IDX++)}`;
this.state = {
showDoc: false,
}
}
onToggleDoc = (val: boolean) => {
this.setState({
showDoc: val
});
}
render () {
return <div className="maputnik-input-block" role="group" aria-labelledby={this._labelId}>
{this.props.fieldSpec &&
<div className="maputnik-input-block-label">
<FieldDocLabel
label={this.props.label}
onToggleDoc={this.onToggleDoc}
fieldSpec={this.props.fieldSpec}
/>
</div>
}
{!this.props.fieldSpec &&
<div className="maputnik-input-block-label">
{this.props.label}
</div>
}
<div className="maputnik-input-block-action">
{this.props.action}
</div>
<div className="maputnik-input-block-content">
{this.props.children}
</div>
{this.props.fieldSpec &&
<div
className="maputnik-doc-inline"
style={{display: this.state.showDoc ? '' : 'none'}}
>
<Doc fieldSpec={this.props.fieldSpec} />
</div>
}
</div>
}
}
+315
View File
@@ -0,0 +1,315 @@
import React from 'react'
import {mdiTableRowPlusAfter} from '@mdi/js';
import {isEqual} from 'lodash';
import {ExpressionSpecification, LegacyFilterSpecification, StyleSpecification} from 'maplibre-gl'
import {latest, migrate, convertFilter} from '@maplibre/maplibre-gl-style-spec'
import {mdiFunctionVariant} from '@mdi/js';
import {combiningFilterOps} from '../libs/filterops'
import InputSelect from './InputSelect'
import Block from './Block'
import SingleFilterEditor from './SingleFilterEditor'
import FilterEditorBlock from './FilterEditorBlock'
import InputButton from './InputButton'
import Doc from './Doc'
import ExpressionProperty from './_ExpressionProperty';
function combiningFilter(props: FilterEditorProps): LegacyFilterSpecification | ExpressionSpecification {
let filter = props.filter || ['all'];
if (!Array.isArray(filter)) {
return filter;
}
let combiningOp = filter[0];
let filters = filter.slice(1);
if(combiningFilterOps.indexOf(combiningOp) < 0) {
combiningOp = 'all';
filters = [filter.slice(0)];
}
return [combiningOp, ...filters] as LegacyFilterSpecification | ExpressionSpecification;
}
function migrateFilter(filter: LegacyFilterSpecification | ExpressionSpecification) {
// This "any" can be removed in latest version of maplibre where maplibre re-exported types from style-spec
return (migrate(createStyleFromFilter(filter) as any).layers[0] as any).filter;
}
function createStyleFromFilter(filter: LegacyFilterSpecification | ExpressionSpecification): StyleSpecification & {id: string} {
return {
"id": "tmp",
"version": 8,
"name": "Empty Style",
"metadata": {"maputnik:renderer": "mlgljs"},
"sources": {
"tmp": {
"type": "geojson",
"data": {}
}
},
"sprite": "",
"glyphs": "https://orangemug.github.io/font-glyphs/glyphs/{fontstack}/{range}.pbf",
"layers": [
{
id: "tmp",
type: "fill",
source: "tmp",
filter: filter,
},
],
};
}
const FILTER_OPS = [
"all",
"any",
"none"
];
// If we convert a filter that is an expression to an expression it'll remain the same in value
function checkIfSimpleFilter (filter: LegacyFilterSpecification | ExpressionSpecification) {
if (filter.length === 1 && FILTER_OPS.includes(filter[0])) {
return true;
}
const expression = convertFilter(filter);
return !isEqual(expression, filter);
}
function hasCombiningFilter(filter: LegacyFilterSpecification | ExpressionSpecification) {
return combiningFilterOps.indexOf(filter[0]) >= 0
}
function hasNestedCombiningFilter(filter: LegacyFilterSpecification | ExpressionSpecification) {
if(hasCombiningFilter(filter)) {
return filter.slice(1).map(f => hasCombiningFilter(f as any)).filter(f => f == true).length > 0
}
return false
}
type FilterEditorProps = {
/** Properties of the vector layer and the available fields */
properties?: {[key:string]: any}
filter?: any[]
errors?: {[key:string]: any}
onChange(value: LegacyFilterSpecification | ExpressionSpecification): unknown
};
type FilterEditorState = {
showDoc: boolean
displaySimpleFilter: boolean
valueIsSimpleFilter?: boolean
};
export default class FilterEditor extends React.Component<FilterEditorProps, FilterEditorState> {
static defaultProps = {
filter: ["all"],
}
constructor (props: FilterEditorProps) {
super(props);
this.state = {
showDoc: false,
displaySimpleFilter: checkIfSimpleFilter(combiningFilter(props)),
};
}
// Convert filter to combining filter
onFilterPartChanged(filterIdx: number, newPart: any[]) {
const newFilter = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification
newFilter[filterIdx] = newPart
this.props.onChange(newFilter)
}
deleteFilterItem(filterIdx: number) {
const newFilter = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification
newFilter.splice(filterIdx + 1, 1)
this.props.onChange(newFilter)
}
addFilterItem = () => {
const newFilterItem = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification
(newFilterItem as any[]).push(['==', 'name', ''])
this.props.onChange(newFilterItem)
}
onToggleDoc = (val: boolean) => {
this.setState({
showDoc: val
});
}
makeFilter = () => {
this.setState({
displaySimpleFilter: true,
})
}
makeExpression = () => {
let filter = combiningFilter(this.props);
this.props.onChange(migrateFilter(filter));
this.setState({
displaySimpleFilter: false,
})
}
static getDerivedStateFromProps(props: FilterEditorProps, currentState: FilterEditorState) {
const displaySimpleFilter = checkIfSimpleFilter(combiningFilter(props));
// Upgrade but never downgrade
if (!displaySimpleFilter && currentState.displaySimpleFilter === true) {
return {
displaySimpleFilter: false,
valueIsSimpleFilter: false,
};
}
else if (displaySimpleFilter && currentState.displaySimpleFilter === false) {
return {
valueIsSimpleFilter: true,
}
}
else {
return {
valueIsSimpleFilter: false,
};
}
}
render() {
const {errors} = this.props;
const {displaySimpleFilter} = this.state;
const fieldSpec={
doc: latest.layer.filter.doc + " Combine multiple filters together by using a compound filter."
};
const defaultFilter = ["all"] as LegacyFilterSpecification | ExpressionSpecification;
const isNestedCombiningFilter = displaySimpleFilter && hasNestedCombiningFilter(combiningFilter(this.props));
if (isNestedCombiningFilter) {
return <div className="maputnik-filter-editor-unsupported">
<p>
Nested filters are not supported.
</p>
<InputButton
onClick={this.makeExpression}
title="Convert to expression"
>
<svg style={{marginRight: "0.2em", width:"14px", height:"14px", verticalAlign: "middle"}} viewBox="0 0 24 24">
<path fill="currentColor" d={mdiFunctionVariant} />
</svg>
Upgrade to expression
</InputButton>
</div>
}
else if (displaySimpleFilter) {
const filter = combiningFilter(this.props);
let combiningOp = filter[0];
let filters = filter.slice(1) as (LegacyFilterSpecification | ExpressionSpecification)[];
const actions = (
<div>
<InputButton
onClick={this.makeExpression}
title="Convert to expression"
className="maputnik-make-zoom-function"
>
<svg style={{width:"14px", height:"14px", verticalAlign: "middle"}} viewBox="0 0 24 24">
<path fill="currentColor" d={mdiFunctionVariant} />
</svg>
</InputButton>
</div>
);
const editorBlocks = filters.map((f, idx) => {
const error = errors![`filter[${idx+1}]`];
return (
<div key={`block-${idx}`}>
<FilterEditorBlock key={idx} onDelete={this.deleteFilterItem.bind(this, idx)}>
<SingleFilterEditor
properties={this.props.properties}
filter={f}
onChange={this.onFilterPartChanged.bind(this, idx + 1)}
/>
</FilterEditorBlock>
{error &&
<div key="error" className="maputnik-inline-error">{error.message}</div>
}
</div>
);
})
return (
<>
<Block
key="top"
fieldSpec={fieldSpec}
label={"Filter"}
action={actions}
>
<InputSelect
value={combiningOp}
onChange={(v: [string, any]) => this.onFilterPartChanged(0, v)}
options={[["all", "every filter matches"], ["none", "no filter matches"], ["any", "any filter matches"]]}
/>
</Block>
{editorBlocks}
<div
key="buttons"
className="maputnik-filter-editor-add-wrapper"
>
<InputButton
data-wd-key="layer-filter-button"
className="maputnik-add-filter"
onClick={this.addFilterItem}
>
<svg style={{width:"14px", height:"14px", verticalAlign: "text-bottom"}} viewBox="0 0 24 24">
<path fill="currentColor" d={mdiTableRowPlusAfter} />
</svg> Add filter
</InputButton>
</div>
<div
key="doc"
className="maputnik-doc-inline"
style={{display: this.state.showDoc ? '' : 'none'}}
>
<Doc fieldSpec={fieldSpec} />
</div>
</>
);
}
else {
let {filter} = this.props;
return (
<>
<ExpressionProperty
onDelete={() => {
this.setState({displaySimpleFilter: true});
this.props.onChange(defaultFilter);
}}
fieldName="filter"
fieldSpec={fieldSpec}
value={filter}
errors={errors}
onChange={this.props.onChange}
/>
{this.state.valueIsSimpleFilter &&
<div className="maputnik-expr-infobox">
You&apos;ve entered a old style filter,{' '}
<button
onClick={this.makeFilter}
className="maputnik-expr-infobox__button"
>
switch to filter editor
</button>
</div>
}
</>
);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import React from 'react'
import InputButton from './InputButton'
import {MdDelete} from 'react-icons/md'
type FilterEditorBlockProps = {
onDelete(...args: unknown[]): unknown
};
export default class FilterEditorBlock extends React.Component<FilterEditorBlockProps> {
render() {
return <div className="maputnik-filter-editor-block">
<div className="maputnik-filter-editor-block-action">
<InputButton
className="maputnik-delete-filter"
onClick={this.props.onDelete}
title="Delete filter block"
>
<MdDelete />
</InputButton>
</div>
<div className="maputnik-filter-editor-block-content">
{this.props.children}
</div>
</div>
}
}
+15
View File
@@ -0,0 +1,15 @@
import React from 'react'
import IconBase from 'react-icon-base'
export default class IconBackground extends React.Component {
render() {
return (
<IconBase viewBox="0 0 20 20" {...this.props}>
<path d="m 1.821019,10.255581 7.414535,5.020197 c 0.372277,0.25206 0.958697,0.239771 1.30985,-0.02745 L 17.539255,9.926162 C 17.89041,9.658941 17.873288,9.238006 17.501015,8.985946 L 10.08648,3.9657402 C 9.714204,3.7136802 9.127782,3.7259703 8.776627,3.9931918 L 1.782775,9.315365 c -0.3511551,0.267221 -0.3340331,0.688156 0.03824,0.940216 z" />
</IconBase>
)
}
}

Some files were not shown because too many files have changed in this diff Show More