Compare commits

..

2 Commits

Author SHA1 Message Date
dependabot[bot] ebece40308 Merge 3e7e1e6ce9 into c58191ff62 2025-02-20 14:04:30 +00:00
dependabot[bot] 3e7e1e6ce9 Bump @shellygo/cypress-test-utils from 4.1.12 to 4.1.13
Bumps [@shellygo/cypress-test-utils](https://github.com/shellyDCMS/cypress-test-utils) from 4.1.12 to 4.1.13.
- [Commits](https://github.com/shellyDCMS/cypress-test-utils/commits)

---
updated-dependencies:
- dependency-name: "@shellygo/cypress-test-utils"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-17 13:37:58 +00:00
136 changed files with 3192 additions and 3620 deletions
+1
View File
@@ -8,3 +8,4 @@ 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) -->
+1
View File
@@ -8,3 +8,4 @@
- [ ] Include before/after visuals or gifs if this PR includes visual changes.
- [ ] Write tests for all new functionality.
- [ ] Add an entry to `CHANGELOG.md` under the `## main` section.
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2.4.0
uses: dependabot/fetch-metadata@v2.3.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Approve Dependabot PRs
+63 -69
View File
@@ -7,7 +7,17 @@ on:
branches: [ main ]
jobs:
build-docker:
name: build docker
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@v4
- run: docker build -t test-docker-image-build .
# build the editor
build-node:
name: "build on ${{ matrix.os }}"
runs-on: ${{ matrix.os }}
@@ -17,17 +27,17 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest, windows-latest, macos-latest ]
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
- run: npm ci
- run: npm run build
- run: npm run lint
- run: npm run lint-css
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
- run: npm ci
- run: npm run build
- run: npm run lint
- run: npm run lint-css
@@ -37,81 +47,65 @@ jobs:
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
- run: npm ci
- run: npm run build
- name: artifacts/maputnik
uses: actions/upload-artifact@v4
with:
name: maputnik
path: dist
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
- run: npm ci
- run: npm run build
- name: artifacts/maputnik
uses: actions/upload-artifact@v4
with:
name: maputnik
path: dist
# Build and upload desktop CLI artifacts
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ^1.23.x
cache-dependency-path: desktop/go.sum
id: go
# Build and upload desktop CLI artifacts
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ^1.23.x
cache-dependency-path: desktop/go.sum
id: go
- name: Build desktop artifacts
run: npm run build-desktop
- name: Build desktop artifacts
run: npm run build-desktop
- name: Artifacts/linux
uses: actions/upload-artifact@v4
with:
name: maputnik-linux
path: ./desktop/bin/linux/
- name: Artifacts/linux
uses: actions/upload-artifact@v4
with:
name: maputnik-linux
path: ./desktop/bin/linux/
- name: Artifacts/darwin
uses: actions/upload-artifact@v4
with:
name: maputnik-darwin
path: ./desktop/bin/darwin/
- name: Artifacts/windows
uses: actions/upload-artifact@v4
with:
name: maputnik-windows
path: ./desktop/bin/windows/
- name: Artifacts/darwin
uses: actions/upload-artifact@v4
with:
name: maputnik-darwin
path: ./desktop/bin/darwin/
- name: Artifacts/windows
uses: actions/upload-artifact@v4
with:
name: maputnik-windows
path: ./desktop/bin/windows/
e2e-tests:
name: "E2E tests using chrome"
name: "E2E tests using ${{ matrix.browser }}"
strategy:
fail-fast: false
matrix:
browser: [chrome]
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v5
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: chrome
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
files: ${{ github.workspace }}/.nyc_output/out.json
verbose: true
e2e-tests-docker:
name: "E2E tests using chrome and docker"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- run: npm ci
- name: Cypress run
uses: cypress-io/github-action@v6
with:
build: docker build -t maputnik .
start: docker run --rm --network host maputnik --port=8888
browser: chrome
browser: ${{ matrix.browser }}
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
+2 -2
View File
@@ -16,13 +16,13 @@ jobs:
run:
shell: bash
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: main
- name: Use Node.js from nvmrc
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
+13 -13
View File
@@ -10,17 +10,17 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Use Node.js from nvmrc
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
- name: Install
run: npm ci
- name: Build
- name: Build
run: npm run build
- name: Upload to GitHub Pages
@@ -28,7 +28,7 @@ jobs:
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: dist
# publish docker to GitHub registry
deploy-docker:
name: deploy/docker
@@ -40,12 +40,12 @@ jobs:
fail-fast: false
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/checkout@v5
- run: docker build -t ghcr.io/maplibre/maputnik:main .
- run: docker push ghcr.io/maplibre/maputnik:main
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/checkout@v4
- run: docker build -t ghcr.io/maplibre/maputnik:main .
- run: docker push ghcr.io/maplibre/maputnik:main
+7 -6
View File
@@ -2,7 +2,7 @@ name: Release
on:
push:
branches: [ main ]
branches: [main]
workflow_dispatch:
jobs:
@@ -13,13 +13,13 @@ jobs:
run:
shell: bash
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: main
- name: Use Node.js from nvmrc
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
@@ -39,19 +39,19 @@ jobs:
run:
shell: bash
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: main
- name: Use Node.js from nvmrc
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
registry-url: "https://registry.npmjs.org"
- name: Set up Go for desktop build
uses: actions/setup-go@v6
uses: actions/setup-go@v5
with:
go-version: ^1.23.x
cache-dependency-path: desktop/go.sum
@@ -99,3 +99,4 @@ jobs:
allowUpdates: true
draft: false
prerelease: false
-24
View File
@@ -1,24 +0,0 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
ci:
autoupdate_schedule: monthly
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-added-large-files
- id: check-executables-have-shebangs
- id: check-json
exclude: 'tsconfig(\.node)?\.json'
- id: check-shebang-scripts-are-executable
- id: check-symlinks
- id: check-toml
- id: check-yaml
args: [ --allow-multiple-documents ]
- id: destroyed-symlinks
- id: end-of-file-fixer
- id: mixed-line-ending
args: [ --fix=lf ]
- id: trailing-whitespace
-47
View File
@@ -1,47 +0,0 @@
Maputnik is a MapLibre style editor written using React and TypeScript.
To get started, install all npm packages:
```
npm install
```
Verify code correctness by running ESLint:
```
npm run lint
```
Or try fixing lint issues with:
```
npm run lint -- --fix
```
The project type checked and built with:
```
npm run build
```
To run the tests make sure that xvfb is installed:
```
apt install xvfb
```
Run the development server in the background with Vite:
```
nohup npm run start &
```
Then start the Cypress tests with:
```
xvfb-run -a npm run test
```
## Pull Requests
- Pull requests should update `CHANGELOG.md` with a short description of the change.
+3 -22
View File
@@ -1,16 +1,6 @@
## main
### ✨ Features and improvements
- _...Add new stuff here..._
### 🐞 Bug fixes
- _...Add new stuff here..._
## 3.0.0
### ✨ Features and improvements
- Fix radio/delete filter buttons styling regression
- Add german translation
- Use same version number for web and desktop versions
- Add scheme type options for vector/raster tile
@@ -20,22 +10,12 @@
- Upgrade to MapLibre LG JS v5
- Upgrade Vite 6 and Cypress 14 ([#970](https://github.com/maplibre/maputnik/pull/970))
- Upgrade OpenLayers from v6 to v10
- When loading a style into localStorage that causes a QuotaExceededError, purge localStorage and retry
- Remove react-autobind dependency
- Remove usage of legacy `childContextTypes` API
- Refactor Field components to use arrow function syntax
- Replace react-autocomplete with Downshift in the autocomplete component
- Add LocationIQ as supported map provider with access token field and gallery style
- Use maputnik go binary for the docker image to allow file watching
- Revmove support for `debug` and `localport` url parameters
- Replace react-sortable-hoc with dnd-kit to avoid react console warnings and also use a maintained library
- _...Add new stuff here..._
### 🐞 Bug fixes
- Fix incorrect handing of network error response (#944)
- Show an error when adding a layer with a duplicate ID
- Replace deprecated `ReactDOM.render` usage with `createRoot` and drop the
`DOMNodeRemoved` cleanup hack
- _...Add new stuff here..._
## 2.1.1
@@ -52,3 +32,4 @@
## 1.7.0
- See release notes at https://maputnik.github.io/blog/2020/04/23/release-v1.7.0
+10 -8
View File
@@ -1,14 +1,16 @@
FROM golang:1.23-alpine AS builder
FROM node:18 as builder
WORKDIR /maputnik
RUN apk add --no-cache nodejs npm make git gcc g++ libc-dev
# Only copy package.json to prevent npm install from running on every build
COPY package.json package-lock.json .npmrc ./
RUN npm ci
# Build maputnik
COPY . .
RUN npm ci
RUN CGO_ENABLED=1 GOOS=linux npm run build-linux
RUN npx vite build
FROM alpine:latest
WORKDIR /app
COPY --from=builder /maputnik/desktop/bin/linux ./
ENTRYPOINT ["/app/maputnik"]
#---------------------------------------------------------------------------
# Create a clean nginx-alpine slim image with just the build results
FROM nginx:alpine-slim
COPY --from=builder /maputnik/dist /usr/share/nginx/html/
+1
View File
@@ -20,3 +20,4 @@ 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.
+3 -9
View File
@@ -18,15 +18,9 @@ targeted at developers and map designers.
- 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:8000 ghcr.io/maplibre/maputnik:main
docker run -it --rm -p 8888:80 ghcr.io/maplibre/maputnik:main
```
To see the CLI options (for example file watching or style serving) run:
```bash
docker run -it --rm -p 8888:8000 ghcr.io/maplibre/maputnik:main --help
```
You might need to mount a volume (`-v`) to be able to use these options.
## Documentation
The documentation can be found in the [Wiki](https://github.com/maplibre/maputnik/wiki). You are welcome to collaborate!
@@ -42,7 +36,7 @@ Maputnik is written in typescript and is using [React](https://github.com/facebo
We ensure building and developing Maputnik works with the [current active LTS Node.js version and above](https://github.com/nodejs/Release#release-schedule).
Check out our [Internationalization guide](./src/locales/README.md) for UI text related changes.
Check out our [Internationalization guide](./src/locales/README.md) for UI text related changes.
### Getting Involved
Join the #maplibre or #maputnik slack channel at OSMUS: get an invite at https://slack.openstreetmap.us/ Read the the below guide in order to get familiar with how we do things around here.
@@ -81,7 +75,7 @@ npm run sort-styles
## Tests
For E2E testing we use [Cypress](https://www.cypress.io/)
[Cypress](https://www.cypress.io/) doesn't start a server so you'll need to start one manually by running `npm run start`.
[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*:
+1 -1
View File
@@ -1,6 +1,6 @@
# Build Scripts
This folder holds common build scripts used by some of the Github workflows.
This folder holds common build scripts used by some of the Github workflows.
The scripts are borrowed from [maplibre/maplibre-gl-js](https://github.com/maplibre/maplibre-gl-js/tree/bc70bc559cea5c987fa1b79fd44766cef68bbe28/build).
-8
View File
@@ -8,7 +8,6 @@ export default defineConfig({
exclude: "cypress/**/*.*",
},
},
e2e: {
setupNodeEvents(on, config) {
// implement node event listeners here
@@ -21,11 +20,4 @@ export default defineConfig({
openMode: 0,
},
},
component: {
devServer: {
framework: "react",
bundler: "vite",
},
},
});
+1
View File
@@ -16,6 +16,7 @@ describe("history", () => {
it("undo/redo", () => {
when.setStyle("geojson");
when.modal.open();
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] });
when.modal.fillLayers({
id: "step 1",
+6 -91
View File
@@ -378,57 +378,8 @@ describe("layers", () => {
});
it("groups", () => {
when.modal.open();
const id1 = when.modal.fillLayers({
id: "aa",
type: "line",
layer: "example",
});
when.modal.open();
const id2 = when.modal.fillLayers({
id: "aa-2",
type: "line",
layer: "example",
});
when.modal.open();
const id3 = when.modal.fillLayers({
id: "b",
type: "line",
layer: "example",
});
then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
then(get.elementByTestId("layer-list-item:" + id2)).shouldNotBeVisible();
then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
when.click("layer-list-group:aa-0");
then(get.elementByTestId("layer-list-item:" + id1)).shouldBeVisible();
then(get.elementByTestId("layer-list-item:" + id2)).shouldBeVisible();
then(get.elementByTestId("layer-list-item:" + id3)).shouldBeVisible();
when.click("layer-list-item:" + id2);
when.click("skip-target-layer-editor");
when.click("menu-move-layer-down");
then(get.elementByTestId("layer-list-group:aa-0")).shouldNotExist();
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: "aa",
type: "line",
source: "example",
},
{
id: "b",
type: "line",
source: "example",
},
{
id: "aa-2",
type: "line",
source: "example",
},
],
});
// TODO
// Click each of the layer groups.
});
});
@@ -544,7 +495,9 @@ describe("layers", () => {
});
});
describe("layereditor jsonlint should error", ()=>{
it("add", () => {
const id = when.modal.fillLayers({
type: "circle",
@@ -562,50 +515,12 @@ describe("layers", () => {
});
const sourceText = get.elementByText('"source"');
sourceText.click();
sourceText.type("\"");
const error = get.element('.CodeMirror-lint-marker-error');
error.should('exist');
});
});
describe("drag and drop", () => {
it("move layer should update local storage", () => {
when.modal.open();
const firstId = when.modal.fillLayers({
id: "a",
type: "background",
});
when.modal.open();
const secondId = when.modal.fillLayers({
id: "b",
type: "background",
});
when.modal.open();
const thirdId = when.modal.fillLayers({
id: "c",
type: "background",
});
when.dragAndDrop(get.elementByTestId("layer-list-item:" + firstId), get.elementByTestId("layer-list-item:" + thirdId));
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id: secondId,
type: "background",
},
{
id: thirdId,
type: "background",
},
{
id: firstId,
type: "background",
},
],
});
});
});
});
+1 -1
View File
@@ -23,7 +23,7 @@ describe("map", () => {
);
});
});
describe("search", () => {
it('should exist', () => {
then(get.searchControl()).shouldBeVisible();
+12 -14
View File
@@ -8,10 +8,8 @@ const baseUrl = "http://localhost:8888/";
const styleFromWindow = (win: Window) => {
const styleId = win.localStorage.getItem("maputnik:latest_style");
const styleItemKey = `maputnik:style:${styleId}`;
const styleItem = win.localStorage.getItem(styleItemKey);
if (!styleItem) throw new Error("Could not get styleItem from localStorage");
const obj = JSON.parse(styleItem);
const styleItem = win.localStorage.getItem(`maputnik:style:${styleId}`);
const obj = JSON.parse(styleItem || "");
return obj;
};
@@ -94,8 +92,8 @@ export class MaputnikDriver {
public when = {
...this.helper.when,
modal: this.modalDriver.when,
doWithin: (selector: string, fn: () => void) => {
this.helper.when.doWithin(fn, selector);
within: (selector: string, fn: () => void) => {
this.helper.when.within(fn, selector);
},
tab: () => this.helper.get.element("body").tab(),
waitForExampleFileResponse: () => {
@@ -110,25 +108,25 @@ export class MaputnikDriver {
styleProperties: "geojson" | "raster" | "both" | "layer" | "",
zoom?: number
) => {
const url = new URL(baseUrl);
let url = "?debug";
switch (styleProperties) {
case "geojson":
url.searchParams.set("style", baseUrl + "geojson-style.json");
url += `&style=${baseUrl}geojson-style.json`;
break;
case "raster":
url.searchParams.set("style", baseUrl + "raster-style.json");
url += `&style=${baseUrl}raster-style.json`;
break;
case "both":
url.searchParams.set("style", baseUrl + "geojson-raster-style.json");
url += `&style=${baseUrl}geojson-raster-style.json`;
break;
case "layer":
url.searchParams.set("style", baseUrl + "example-layer-style.json");
url += `&style=${baseUrl}/example-layer-style.json`;
break;
}
if (zoom) {
url.hash = `${zoom}/41.3805/2.1635`;
url += `#${zoom}/41.3805/2.1635`;
}
this.helper.when.visit(url.toString());
this.helper.when.visit(baseUrl + url);
if (styleProperties) {
this.helper.when.acceptConfirm();
}
@@ -145,7 +143,7 @@ export class MaputnikDriver {
},
selectWithin: (selector: string, value: string) => {
this.when.doWithin(selector, () => {
this.when.within(selector, () => {
this.helper.get.element("select").select(value);
});
},
+2 -2
View File
@@ -20,8 +20,8 @@ export default class ModalDriver {
this.helper.when.type("add-layer.layer-id.input", id);
if (layer) {
this.helper.when.doWithin(() => {
this.helper.get.element("input").clear().type(layer!);
this.helper.when.within(() => {
this.helper.get.element("input").type(layer!);
}, "add-layer.layer-source-block");
}
this.helper.when.click("add-layer");
+5 -73
View File
@@ -224,18 +224,6 @@ describe("modals", () => {
).shouldInclude({ "maputnik:stadia_access_token": apiKey });
});
it("locationiq access token", () => {
const apiKey = "testing123";
when.setValue(
"modal:settings.maputnik:locationiq_access_token",
apiKey
);
when.click("modal:settings.name");
then(
get.styleFromLocalStorage().then((style) => style.metadata)
).shouldInclude({ "maputnik:locationiq_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");
@@ -252,14 +240,14 @@ describe("modals", () => {
it("inlcude API key when change renderer", () => {
when.click("modal:settings.close-modal")
when.click("nav:open");
get.elementByAttribute('aria-label', "MapTiler Basic").should('exist').click();
when.wait(1000);
when.click("nav:settings");
when.click("nav:settings");
when.select("modal:settings.maputnik:renderer", "mlgljs");
then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual(
"mlgljs"
@@ -271,7 +259,7 @@ describe("modals", () => {
);
given.intercept("https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=*", "tileRequest", "GET");
when.select("modal:settings.maputnik:renderer", "mlgljs");
then(get.inputValue("modal:settings.maputnik:renderer")).shouldEqual(
"mlgljs"
@@ -281,66 +269,10 @@ describe("modals", () => {
when.waitForResponse("tileRequest").its("request").its("url").should("include", `https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`);
when.waitForResponse("tileRequest").its("request").its("url").should("include", `https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`);
});
});
describe("add layer", () => {
beforeEach(() => {
when.setStyle("layer");
when.modal.open();
});
it("shows duplicate id error", () => {
when.setValue("add-layer.layer-id.input", "background");
when.click("add-layer");
then(get.elementByTestId("modal:add-layer")).shouldExist();
then(get.element(".maputnik-modal-error")).shouldContainText(
"Layer ID already exists"
);
});
});
describe("sources", () => {
it("toggle");
});
describe("Handle localStorage QuotaExceededError", () => {
it("handles quota exceeded error when opening style from URL", () => {
// Clear localStorage to start fresh
cy.clearLocalStorage();
// fill localStorage until we get a QuotaExceededError
cy.window().then(win => {
let chunkSize = 1000;
const chunk = new Array(chunkSize).join("x");
let index = 0;
// Keep adding until we hit the quota
while (true) {
try {
const key = `maputnik:fill-${index++}`;
win.localStorage.setItem(key, chunk);
} catch (e: any) {
// Verify it's a quota error
if (e.name === 'QuotaExceededError') {
if (chunkSize <= 1) return;
else {
chunkSize /= 2;
continue;
}
}
throw e; // Unexpected error
}
}
});
// Open the style via URL input
when.click("nav:open");
when.setValue("modal:open.url.input", get.exampleFileUrl());
when.click("modal:open.url.button");
then(get.responseBody("example-style.json")).shouldEqualToStoredStyle();
then(get.styleFromLocalStorage()).shouldExist();
});
});
});
@@ -15,3 +15,4 @@
}
]
}
+10 -112
View File
@@ -1,114 +1,12 @@
{
"id": "test-style",
"version": 8,
"name": "Test Style",
"metadata": {
"maputnik:renderer": "mlgljs",
"data": [
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
25,
26,
27,
28,
29,
30,
31,
32,
33,
34,
35,
36,
37,
38,
39,
40,
41,
42,
43,
44,
45,
46,
47,
48,
49,
50,
51,
52,
53,
54,
55,
56,
57,
58,
59,
60,
61,
62,
63,
64,
65,
66,
67,
68,
69,
70,
71,
72,
73,
74,
75,
76,
77,
78,
79,
80,
81,
82,
83,
84,
85,
86,
87,
88,
89,
90,
91,
92,
93,
94,
95,
96,
97,
98,
99
]
},
"sources": {},
"glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf",
"sprites": "https://example.local/fonts/{fontstack}/{range}.pbf",
"layers": []
"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": []
}
+1 -1
View File
@@ -31,4 +31,4 @@
"glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf",
"sprites": "https://example.local/fonts/{fontstack}/{range}.pbf",
"layers": []
}
}
+1 -1
View File
@@ -26,4 +26,4 @@
"glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf",
"sprites": "https://example.local/fonts/{fontstack}/{range}.pbf",
"layers": []
}
}
+1 -1
View File
@@ -15,4 +15,4 @@
"glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf",
"sprites": "https://example.local/fonts/{fontstack}/{range}.pbf",
"layers": []
}
}
+1 -1
View File
@@ -34,4 +34,4 @@
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }
// }
-12
View File
@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>
-37
View File
@@ -1,37 +0,0 @@
// ***********************************************************
// This example support/component.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 './commands'
import { mount } from 'cypress/react'
// Augment the Cypress namespace to include type definitions for
// your custom command.
// Alternatively, can be defined in cypress/support/component.d.ts
// with a <reference path="./component" /> at the top of your spec.
declare global {
/* eslint-disable @typescript-eslint/no-namespace */
namespace Cypress {
interface Chainable {
mount: typeof mount
}
}
}
Cypress.Commands.add('mount', mount)
// Example use:
// cy.mount(<MyComponent />)
+2 -5
View File
@@ -2,7 +2,8 @@ SOURCEDIR=.
SOURCES := $(shell find $(SOURCEDIR) -name '*.go')
BINARY=maputnik
VERSION := $(shell node -p "require('../package.json').version")
GOBIN := $(or $(shell if [ -d /go/bin ]; then echo "/go/bin"; fi),$(HOME)/go/bin)
GOPATH := $(if $(GOPATH),$(GOPATH),$(HOME)/go)
GOBIN := $(if $(GOBIN),$(GOBIN),$(HOME)/go/bin)
all: $(BINARY)
@@ -10,10 +11,6 @@ $(BINARY): $(GOBIN)/gox $(GOBIN)/go-winres $(SOURCES) version.go rice-box.go win
$(GOBIN)/go-winres make --product-version=$(VERSION)
$(GOBIN)/gox -osarch "windows/amd64 linux/amd64 darwin/amd64" -output "bin/{{.OS}}/${BINARY}"
bin/linux/$(BINARY): $(GOBIN)/gox $(GOBIN)/go-winres $(SOURCES) version.go rice-box.go winres/winres.json
$(GOBIN)/go-winres make --product-version=$(VERSION)
$(GOBIN)/gox -osarch "linux/amd64" -output "bin/{{.OS}}/${BINARY}"
winres/winres.json: winres/winres_template.json
sed 's/{{.Version}}/$(VERSION)/g' winres/winres_template.json > $@
+2 -1
View File
@@ -5,7 +5,8 @@ import (
"net/http"
"os"
"path/filepath"
"github.com/GeertJohan/go.rice"
"github.com/GeertJohan/go.rice"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/maputnik/desktop/filewatch"
+1423 -1628
View File
File diff suppressed because it is too large Load Diff
+42 -43
View File
@@ -1,14 +1,13 @@
{
"name": "maputnik",
"version": "3.0.0",
"version": "2.1.1",
"description": "A MapLibre GL visual style editor",
"type": "module",
"main": "''",
"scripts": {
"start": "vite",
"build": "tsc && vite build --mode=production",
"build-desktop": "tsc && vite build --mode=desktop && cd desktop && make",
"build-linux": "tsc && vite build --mode=desktop && cd desktop && make bin/linux/maputnik",
"build": "tsc && vite build --base=/maputnik/",
"build-desktop": "tsc && vite build --base=/ && cd desktop && make",
"i18n:refresh": "i18next 'src/**/*.{ts,tsx,js,jsx}'",
"lint": "eslint",
"test": "cypress run",
@@ -24,27 +23,23 @@
"license": "MIT",
"homepage": "https://github.com/maplibre/maputnik#readme",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@mapbox/mapbox-gl-rtl-text": "^0.3.0",
"@maplibre/maplibre-gl-geocoder": "^1.9.0",
"@maplibre/maplibre-gl-geocoder": "^1.7.1",
"@maplibre/maplibre-gl-inspect": "^1.7.1",
"@maplibre/maplibre-gl-style-spec": "^23.3.0",
"@maplibre/maplibre-gl-style-spec": "^23.1.0",
"@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1",
"@prantlf/jsonlint": "^16.0.0",
"array-move": "^4.0.0",
"buffer": "^6.0.3",
"classnames": "^2.5.1",
"codemirror": "^5.65.20",
"codemirror": "^5.65.18",
"color": "^5.0.0",
"detect-browser": "^5.3.0",
"downshift": "^9.0.10",
"events": "^3.3.0",
"file-saver": "^2.0.5",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.0.4",
"i18next-resources-to-backend": "^1.2.1",
"json-stringify-pretty-compact": "^4.0.0",
"json-to-ast": "^2.1.0",
@@ -55,23 +50,26 @@
"lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0",
"lodash.throttle": "^4.1.1",
"maplibre-gl": "^5.7.1",
"maplibre-gl": "^5.1.0",
"maputnik-design": "github:maputnik/design#172b06c",
"ol": "^10.6.1",
"ol-mapbox-style": "^13.1.0",
"pmtiles": "^4.3.0",
"ol": "^10.4.0",
"ol-mapbox-style": "^12.4.0",
"pmtiles": "^4.1.0",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-accessible-accordion": "^5.0.1",
"react-accessible-accordion": "^5.0.0",
"react-aria-menubutton": "^7.0.3",
"react-aria-modal": "^5.0.2",
"react-autobind": "^1.0.6",
"react-autocomplete": "^1.8.1",
"react-collapse": "^5.1.1",
"react-color": "^2.19.3",
"react-dom": "^18.2.0",
"react-file-reader-input": "^2.0.0",
"react-i18next": "^15.7.3",
"react-i18next": "^15.4.0",
"react-icon-base": "^2.1.2",
"react-icons": "^5.5.0",
"react-icons": "^5.4.0",
"react-sortable-hoc": "^2.0.0",
"reconnecting-websocket": "^4.4.0",
"slugify": "^1.6.6",
"string-hash": "^1.1.3",
@@ -95,14 +93,14 @@
}
},
"devDependencies": {
"@cypress/code-coverage": "^3.14.6",
"@eslint/js": "^9.35.0",
"@cypress/code-coverage": "^3.13.11",
"@eslint/js": "^9.19.0",
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@rollup/plugin-replace": "^6.0.2",
"@shellygo/cypress-test-utils": "^6.0.1",
"@types/codemirror": "^5.60.16",
"@shellygo/cypress-test-utils": "^4.1.13",
"@types/codemirror": "^5.60.15",
"@types/color": "^4.2.0",
"@types/cors": "^2.8.19",
"@types/cors": "^2.8.17",
"@types/file-saver": "^2.0.7",
"@types/geojson": "^7946.0.16",
"@types/json-to-ast": "^2.1.4",
@@ -116,6 +114,7 @@
"@types/react": "^18.2.67",
"@types/react-aria-menubutton": "^6.2.14",
"@types/react-aria-modal": "^5.0.0",
"@types/react-autocomplete": "^1.8.11",
"@types/react-collapse": "^5.0.4",
"@types/react-color": "^3.0.13",
"@types/react-dom": "^18.2.22",
@@ -123,28 +122,28 @@
"@types/react-icon-base": "^2.1.6",
"@types/string-hash": "^1.1.3",
"@types/uuid": "^10.0.0",
"@types/wicg-file-system-access": "^2023.10.6",
"@vitejs/plugin-react": "^5.0.2",
"@types/wicg-file-system-access": "^2023.10.5",
"@vitejs/plugin-react": "^4.3.4",
"cors": "^2.8.5",
"cypress": "^15.1.0",
"cypress": "^14.0.2",
"cypress-plugin-tab": "^1.0.5",
"eslint": "^9.35.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"i18next-parser": "^9.3.0",
"eslint": "^9.19.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.18",
"i18next-parser": "^9.1.0",
"istanbul": "^0.4.5",
"istanbul-lib-coverage": "^3.2.2",
"postcss": "^8.5.6",
"postcss": "^8.5.1",
"react-hot-loader": "^4.13.1",
"sass": "^1.92.1",
"stylelint": "^16.24.0",
"stylelint-config-recommended-scss": "^16.0.0",
"stylelint-scss": "^6.12.1",
"typescript": "^5.8.3",
"typescript-eslint": "^8.43.0",
"uuid": "^13.0.0",
"vite": "^7.1.5",
"vite-plugin-istanbul": "^7.1.0"
"sass": "^1.83.4",
"stylelint": "^16.14.1",
"stylelint-config-recommended-scss": "^14.1.0",
"stylelint-scss": "^6.11.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.22.0",
"uuid": "^11.0.5",
"vite": "^6.1.0",
"vite-plugin-istanbul": "^6.0.2"
}
}
+149 -92
View File
@@ -1,3 +1,5 @@
// @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'
@@ -27,14 +29,17 @@ 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 { createStyleStore, type IStyleStore } from '../libs/store/style-store-factory'
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';
import { OnStyleChangedOpts, StyleSpecificationWithId } from '../libs/definitions'
// Buffer must be defined globally for @maplibre/maplibre-gl-style-spec validate() function to succeed.
window.Buffer = buffer.Buffer;
@@ -43,7 +48,6 @@ function setFetchAccessToken(url: string, mapStyle: StyleSpecification) {
const matchesTilehosting = url.match(/\.tilehosting\.com/);
const matchesMaptiler = url.match(/\.maptiler\.com/);
const matchesThunderforest = url.match(/\.thunderforest\.com/);
const matchesLocationIQ = url.match(/\.locationiq\.com/);
if (matchesTilehosting || matchesMaptiler) {
const accessToken = style.getAccessToken("openmaptiles", mapStyle, {allowFallback: true})
if (accessToken) {
@@ -56,12 +60,6 @@ function setFetchAccessToken(url: string, mapStyle: StyleSpecification) {
return url.replace('{key}', accessToken)
}
}
else if (matchesLocationIQ) {
const accessToken = style.getAccessToken("locationiq", mapStyle, {allowFallback: true})
if (accessToken) {
return url.replace('{key}', accessToken)
}
}
else {
return url;
}
@@ -80,6 +78,12 @@ function updateRootSpec(spec: any, fieldName: string, newValues: any) {
}
}
type OnStyleChangedOpts = {
save?: boolean
addRevision?: boolean
initialLoad?: boolean
}
type MappedErrors = {
message: string
parsed?: {
@@ -95,11 +99,11 @@ type MappedErrors = {
type AppState = {
errors: MappedErrors[],
infos: string[],
mapStyle: StyleSpecificationWithId,
mapStyle: StyleSpecification & {id: string},
dirtyMapStyle?: StyleSpecification,
selectedLayerIndex: number,
selectedLayerOriginalId?: string,
sources: {[key: string]: SourceSpecification & {layers: string[]} },
sources: {[key: string]: SourceSpecification},
vectorLayers: {},
spec: any,
mapView: {
@@ -131,56 +135,26 @@ type AppState = {
export default class App extends React.Component<any, AppState> {
revisionStore: RevisionStore;
styleStore: IStyleStore | null = null;
styleStore: StyleStore | ApiStyleStore;
layerWatcher: LayerWatcher;
constructor(props: any) {
super(props)
autoBind(this);
this.revisionStore = new RevisionStore();
this.configureKeyboardShortcuts();
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,
debug: false,
},
maplibreGlDebugOptions: {
showTileBoundaries: false,
showCollisionBoxes: false,
showOverdrawInspector: false,
},
openlayersDebugOptions: {
debugToolbox: false,
},
fileHandle: null,
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.layerWatcher = new LayerWatcher({
onVectorLayersChange: v => this.setState({ vectorLayers: v })
this.styleStore = new ApiStyleStore({
onLocalStyleChange: mapStyle => this.onStyleChanged(mapStyle, {save: false}),
port: port,
host: params.get("localhost")
})
}
configureKeyboardShortcuts = () => {
const shortcuts = [
{
key: "?",
@@ -250,6 +224,74 @@ export default class App extends React.Component<any, AppState> {
}
}
})
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
debug: false,
},
maplibreGlDebugOptions: {
showTileBoundaries: false,
showCollisionBoxes: false,
showOverdrawInspector: false,
},
openlayersDebugOptions: {
debugToolbox: false,
},
fileHandle: null,
}
this.layerWatcher = new LayerWatcher({
onVectorLayersChange: v => this.setState({ vectorLayers: v })
})
}
handleKeyPress = (e: KeyboardEvent) => {
@@ -275,8 +317,7 @@ export default class App extends React.Component<any, AppState> {
}
}
async componentDidMount() {
this.styleStore = await createStyleStore((mapStyle, opts) => this.onStyleChanged(mapStyle, opts));
componentDidMount() {
window.addEventListener("keydown", this.handleKeyPress);
}
@@ -284,8 +325,8 @@ export default class App extends React.Component<any, AppState> {
window.removeEventListener("keydown", this.handleKeyPress);
}
saveStyle(snapshotStyle: StyleSpecificationWithId) {
this.styleStore?.save(snapshotStyle)
saveStyle(snapshotStyle: StyleSpecification & {id: string}) {
this.styleStore.save(snapshotStyle)
}
updateFonts(urlTemplate: string) {
@@ -326,7 +367,7 @@ export default class App extends React.Component<any, AppState> {
this.onStyleChanged(changedStyle)
}
onStyleChanged = (newStyle: StyleSpecificationWithId, opts: OnStyleChangedOpts={}): void => {
onStyleChanged = (newStyle: StyleSpecification & {id: string}, opts: OnStyleChangedOpts={}) => {
opts = {
save: true,
addRevision: true,
@@ -357,6 +398,7 @@ export default class App extends React.Component<any, AppState> {
}
const errors: ValidationError[] = validateStyleMin(newStyle) || [];
// 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)[] = [];
@@ -435,7 +477,7 @@ export default class App extends React.Component<any, AppState> {
if (errors.length > 0) {
dirtyMapStyle = cloneDeep(newStyle);
for (const error of errors) {
errors.forEach(error => {
const {message} = error;
if (message) {
try {
@@ -445,10 +487,10 @@ export default class App extends React.Component<any, AppState> {
unset(dirtyMapStyle, unsetPath);
}
catch (err) {
console.warn(message + " " + err);
console.warn(err);
}
}
}
});
}
if(newStyle.glyphs !== this.state.mapStyle.glyphs) {
@@ -462,7 +504,7 @@ export default class App extends React.Component<any, AppState> {
this.revisionStore.addRevision(newStyle);
}
if (opts.save) {
this.saveStyle(newStyle);
this.saveStyle(newStyle as StyleSpecification & {id: string});
}
this.setState({
@@ -495,7 +537,7 @@ export default class App extends React.Component<any, AppState> {
})
}
onMoveLayer = (move: {oldIndex: number; newIndex: number}) => {
onMoveLayer = (move: SortEnd) => {
let { oldIndex, newIndex } = move;
let layers = this.state.mapStyle.layers;
oldIndex = clamp(oldIndex, 0, layers.length-1);
@@ -575,7 +617,7 @@ export default class App extends React.Component<any, AppState> {
}, this.setStateInUrl);
}
setDefaultValues = (styleObj: StyleSpecificationWithId) => {
setDefaultValues = (styleObj: StyleSpecification & {id: string}) => {
const metadata: {[key: string]: string} = styleObj.metadata || {} as any
if(metadata['maputnik:renderer'] === undefined) {
const changedStyle = {
@@ -591,28 +633,27 @@ export default class App extends React.Component<any, AppState> {
}
}
openStyle = (styleObj: StyleSpecificationWithId, fileHandle: FileSystemFileHandle | null) => {
openStyle = (styleObj: StyleSpecification & {id: string}, fileHandle: FileSystemFileHandle | null) => {
this.setState({fileHandle: fileHandle});
styleObj = this.setDefaultValues(styleObj)
this.onStyleChanged(styleObj)
}
async fetchSources() {
const sourceList: {[key: string]: SourceSpecification & {layers: string[]}} = {};
for(const key of Object.keys(this.state.mapStyle.sources)) {
const source = this.state.mapStyle.sources[key];
if(source.type !== "vector" || !('url' in source)) {
sourceList[key] = this.state.sources[key] || {...this.state.mapStyle.sources[key]};
if (sourceList[key].layers === undefined) {
sourceList[key].layers = [];
}
} else {
fetchSources() {
const sourceList: {[key: string]: any} = {};
for(const [key, val] of Object.entries(this.state.mapStyle.sources)) {
if(
!Object.prototype.hasOwnProperty.call(this.state.sources, key) &&
val.type === "vector" &&
Object.prototype.hasOwnProperty.call(val, "url")
) {
sourceList[key] = {
type: source.type,
type: val.type,
layers: []
};
let url = source.url;
let url = val.url;
try {
url = setFetchAccessToken(url!, this.state.mapStyle)
@@ -625,28 +666,44 @@ export default class App extends React.Component<any, AppState> {
return;
}
// Create new objects before setState
const sources = Object.assign({}, {
[key]: this.state.sources[key],
});
for(const layer of json.vector_layers) {
sourceList[key].layers.push(layer.id)
(sources[key] as any).layers.push(layer.id)
}
this.setState({
sources: sources
});
};
try {
if (url!.startsWith("pmtiles://")) {
const json = await (new PMTiles(url!.substring(10))).getTileJson("");
setVectorLayers(json);
} else {
const response = await fetch(url!, { mode: 'cors' });
const json = await response.json();
setVectorLayers(json);
}
} catch(err) {
console.error(`Failed to process source for url: '${url}', ${err}`);
if (url!.startsWith("pmtiles://")) {
(new PMTiles(url!.substr(10))).getTileJson("")
.then(json => setVectorLayers(json))
.catch(err => {
console.error("Failed to process sources for '%s'", url, err);
});
} else {
fetch(url!, {
mode: 'cors',
})
.then(response => response.json())
.then(json => setVectorLayers(json))
.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", sourceList);
console.debug("Setting sources");
this.setState({
sources: sourceList
})
@@ -823,8 +880,8 @@ export default class App extends React.Component<any, AppState> {
this.setModal(modalName, !this.state.isOpen[modalName]);
}
onSetFileHandle = (fileHandle: FileSystemFileHandle | null) => {
this.setState({ fileHandle });
onSetFileHandle(fileHandle: FileSystemFileHandle | null) {
this.setState({fileHandle: fileHandle});
}
onChangeOpenlayersDebug = (key: keyof AppState["openlayersDebugOptions"], value: boolean) => {
+26 -19
View File
@@ -1,7 +1,7 @@
import React from 'react'
import PropTypes from 'prop-types'
import ScrollContainer from './ScrollContainer'
import { WithTranslation, withTranslation } from 'react-i18next';
import { IconContext } from 'react-icons';
type AppLayoutInternalProps = {
toolbar: React.ReactElement
@@ -13,31 +13,38 @@ type AppLayoutInternalProps = {
} & WithTranslation;
class AppLayoutInternal extends React.Component<AppLayoutInternalProps> {
static childContextTypes = {
reactIconBase: PropTypes.object
}
getChildContext() {
return {
reactIconBase: { size: 14 }
}
}
render() {
document.body.dir = this.props.i18n.dir();
return <IconContext.Provider value={{size: '14px'}}>
<div className="maputnik-layout">
{this.props.toolbar}
<div className="maputnik-layout-main">
<div className="maputnik-layout-list">
{this.props.layerList}
</div>
<div className="maputnik-layout-drawer">
<ScrollContainer>
{this.props.layerEditor}
</ScrollContainer>
</div>
{this.props.map}
return <div className="maputnik-layout">
{this.props.toolbar}
<div className="maputnik-layout-main">
<div className="maputnik-layout-list">
{this.props.layerList}
</div>
{this.props.bottom && <div className="maputnik-layout-bottom">
{this.props.bottom}
<div className="maputnik-layout-drawer">
<ScrollContainer>
{this.props.layerEditor}
</ScrollContainer>
</div>
}
{this.props.modals}
{this.props.map}
</div>
</IconContext.Provider>
{this.props.bottom && <div className="maputnik-layout-bottom">
{this.props.bottom}
</div>
}
{this.props.modals}
</div>
}
}
+2 -3
View File
@@ -16,7 +16,6 @@ import pkgJson from '../../package.json'
import maputnikLogo from 'maputnik-design/logos/logo-color.svg?inline'
import { withTranslation, WithTranslation } from 'react-i18next';
import { supportedLanguages } from '../i18n';
import type { OnStyleChangedCallback } from '../libs/definitions';
// 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();
@@ -94,9 +93,9 @@ export type MapState = "map" | "inspect" | "filter-achromatopsia" | "filter-deut
type AppToolbarInternalProps = {
mapStyle: object
inspectModeEnabled: boolean
onStyleChanged: OnStyleChangedCallback
onStyleChanged(...args: unknown[]): unknown
// A new style has been uploaded
onStyleOpen: OnStyleChangedCallback
onStyleOpen(...args: unknown[]): unknown
// A dict of source id's and the available source layers
sources: object
children?: React.ReactNode
+1
View File
@@ -102,3 +102,4 @@ export default class Block extends React.Component<BlockProps, BlockState> {
</label>
}
}
+1
View File
@@ -31,3 +31,4 @@ export default class Collapse extends React.Component<CollapseProps> {
}
}
}
+1
View File
@@ -16,3 +16,4 @@ export default class Collapser extends React.Component<CollapserProps> {
return this.props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} />
}
}
+7 -7
View File
@@ -1,3 +1,4 @@
import React from 'react'
import InputArray, { FieldArrayProps as InputArrayProps } from './InputArray'
import Fieldset from './Fieldset'
@@ -8,12 +9,11 @@ type FieldArrayProps = InputArrayProps & {
}
};
const FieldArray: React.FC<FieldArrayProps> = (props) => {
return (
<Fieldset label={props.label} fieldSpec={props.fieldSpec}>
<InputArray {...props} />
export default class FieldArray extends React.Component<FieldArrayProps> {
render() {
return <Fieldset label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputArray {...this.props} />
</Fieldset>
);
};
}
}
export default FieldArray;
+7 -7
View File
@@ -1,3 +1,4 @@
import React from 'react'
import Block from './Block'
import InputAutocomplete, { InputAutocompleteProps } from './InputAutocomplete'
@@ -7,12 +8,11 @@ type FieldAutocompleteProps = InputAutocompleteProps & {
};
const FieldAutocomplete: React.FC<FieldAutocompleteProps> = (props) => {
return (
<Block label={props.label}>
<InputAutocomplete {...props} />
export default class FieldAutocomplete extends React.Component<FieldAutocompleteProps> {
render() {
return <Block label={this.props.label}>
<InputAutocomplete {...this.props} />
</Block>
);
};
}
}
export default FieldAutocomplete;
+7 -7
View File
@@ -1,3 +1,4 @@
import React from 'react'
import Block from './Block'
import InputCheckbox, {InputCheckboxProps} from './InputCheckbox'
@@ -7,12 +8,11 @@ type FieldCheckboxProps = InputCheckboxProps & {
};
const FieldCheckbox: React.FC<FieldCheckboxProps> = (props) => {
return (
<Block label={props.label}>
<InputCheckbox {...props} />
export default class FieldCheckbox extends React.Component<FieldCheckboxProps> {
render() {
return <Block label={this.props.label}>
<InputCheckbox {...this.props} />
</Block>
);
};
}
}
export default FieldCheckbox;
+7 -7
View File
@@ -1,3 +1,4 @@
import React from 'react'
import Block from './Block'
import InputColor, {InputColorProps} from './InputColor'
@@ -10,12 +11,11 @@ type FieldColorProps = InputColorProps & {
};
const FieldColor: React.FC<FieldColorProps> = (props) => {
return (
<Block label={props.label} fieldSpec={props.fieldSpec}>
<InputColor {...props} />
export default class FieldColor extends React.Component<FieldColorProps> {
render() {
return <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputColor {...this.props} />
</Block>
);
};
}
}
export default FieldColor;
+12 -14
View File
@@ -10,31 +10,29 @@ type FieldCommentInternalProps = {
error: {message: string}
} & WithTranslation;
const FieldCommentInternal: React.FC<FieldCommentInternalProps> = (props) => {
const t = props.t;
const fieldSpec = {
doc: t(
"Comments for the current layer. This is non-standard and not in the spec."
),
};
class FieldCommentInternal extends React.Component<FieldCommentInternalProps> {
render() {
const t = this.props.t;
const fieldSpec = {
doc: t("Comments for the current layer. This is non-standard and not in the spec."),
};
return (
<Block
return <Block
label={t("Comments")}
fieldSpec={fieldSpec}
data-wd-key="layer-comment"
error={props.error}
error={this.props.error}
>
<InputString
multi={true}
value={props.value}
onChange={props.onChange}
value={this.props.value}
onChange={this.props.onChange}
default={t("Comment...")}
data-wd-key="layer-comment.input"
/>
</Block>
);
};
}
}
const FieldComment = withTranslation()(FieldCommentInternal);
export default FieldComment;
+40 -28
View File
@@ -9,45 +9,57 @@ type FieldDocLabelProps = {
onToggleDoc?(...args: unknown[]): unknown
};
type FieldDocLabelState = {
open: boolean
};
const FieldDocLabel: React.FC<FieldDocLabelProps> = (props) => {
const [open, setOpen] = React.useState(false);
const onToggleDoc = (state: boolean) => {
setOpen(state);
if (props.onToggleDoc) {
props.onToggleDoc(state);
export default class FieldDocLabel extends React.Component<FieldDocLabelProps, FieldDocLabelState> {
constructor (props: FieldDocLabelProps) {
super(props);
this.state = {
open: false,
}
};
}
const { label, fieldSpec } = props;
const { doc } = fieldSpec || {};
onToggleDoc = (open: boolean) => {
this.setState({
open,
}, () => {
if (this.props.onToggleDoc) {
this.props.onToggleDoc(this.state.open);
}
});
}
if (doc) {
return (
<label className="maputnik-doc-wrapper">
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={open ? 'close property documentation' : 'open property documentation'}
className={`maputnik-doc-button maputnik-doc-button--${open ? 'open' : 'closed'}`}
onClick={() => onToggleDoc(!open)}
data-wd-key={'field-doc-button-' + label}
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}
>
{open ? <MdHighlightOff /> : <MdInfoOutline />}
{this.state.open ? <MdHighlightOff /> : <MdInfoOutline />}
</button>
</div>
</label>
);
} else if (label) {
return (
<label className="maputnik-doc-wrapper">
<div className="maputnik-doc-target">{label}</div>
}
else if (label) {
return <label className="maputnik-doc-wrapper">
<div className="maputnik-doc-target">
{label}
</div>
</label>
);
}
else {
<div />
}
}
return <div />;
};
export default FieldDocLabel;
}
+7 -7
View File
@@ -1,3 +1,4 @@
import React from 'react'
import InputDynamicArray, {FieldDynamicArrayProps as InputDynamicArrayProps} from './InputDynamicArray'
import Fieldset from './Fieldset'
@@ -5,12 +6,11 @@ type FieldDynamicArrayProps = InputDynamicArrayProps & {
name?: string
};
const FieldDynamicArray: React.FC<FieldDynamicArrayProps> = (props) => {
return (
<Fieldset label={props.label}>
<InputDynamicArray {...props} />
export default class FieldDynamicArray extends React.Component<FieldDynamicArrayProps> {
render() {
return <Fieldset label={this.props.label}>
<InputDynamicArray {...this.props} />
</Fieldset>
);
};
}
}
export default FieldDynamicArray;
+8 -9
View File
@@ -1,8 +1,9 @@
import React from 'react'
import InputEnum, {InputEnumProps} from './InputEnum'
import Fieldset from './Fieldset';
type FieldEnumProps = InputEnumProps & {
type FieldEnumProps = InputEnumProps & {
label?: string;
fieldSpec?: {
doc: string
@@ -10,12 +11,10 @@ type FieldEnumProps = InputEnumProps & {
};
const FieldEnum: React.FC<FieldEnumProps> = (props) => {
return (
<Fieldset label={props.label} fieldSpec={props.fieldSpec}>
<InputEnum {...props} />
export default class FieldEnum extends React.Component<FieldEnumProps> {
render() {
return <Fieldset label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputEnum {...this.props} />
</Fieldset>
);
};
export default FieldEnum;
}
}
+247 -218
View File
@@ -111,268 +111,297 @@ type FieldFunctionProps = {
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
*/
const FieldFunction: React.FC<FieldFunctionProps> = (props) => {
const [dataType, setDataType] = React.useState(
getDataType(props.value, props.fieldSpec)
);
const [isEditing, setIsEditing] = React.useState(false);
React.useEffect(() => {
if (!isEditing) {
setDataType(getDataType(props.value, props.fieldSpec));
export default class FieldFunction extends React.Component<FieldFunctionProps, FieldFunctionState> {
constructor (props: FieldFunctionProps) {
super(props);
this.state = {
dataType: getDataType(props.value, props.fieldSpec),
isEditing: false,
}
}, [props.value, props.fieldSpec, isEditing]);
}
const getFieldFunctionType = (fieldSpec: any) => {
static getDerivedStateFromProps(props: Readonly<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';
return "exponential"
}
if (fieldSpec.type === 'number') {
return 'interval';
if (fieldSpec.type === "number") {
return "interval"
}
return 'categorical';
};
return "categorical"
}
const addStop = () => {
const stops = props.value.stops.slice(0);
const lastStop = stops[stops.length - 1];
if (typeof lastStop[0] === 'object') {
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]]);
{zoom: lastStop[0].zoom + 1, value: lastStop[0].value},
lastStop[1]
])
}
else {
stops.push([lastStop[0] + 1, lastStop[1]])
}
const changedValue = {
...props.value,
...this.props.value,
stops: stops,
};
props.onChange(props.fieldName, changedValue);
};
const deleteExpression = () => {
const { fieldSpec, fieldName } = props;
props.onChange(fieldName, fieldSpec.default);
setDataType('value');
};
const deleteStop = (stopIdx: number) => {
const stops = props.value.stops.slice(0);
stops.splice(stopIdx, 1);
let changedValue: any = {
...props.value,
stops: stops,
};
if (stops.length === 1) {
changedValue = stops[0][1];
}
props.onChange(props.fieldName, changedValue);
};
this.props.onChange(this.props.fieldName, changedValue)
}
const makeZoomFunction = () => {
const { value } = props;
deleteExpression = () => {
const {fieldSpec, fieldName} = this.props;
this.props.onChange(fieldName, fieldSpec.default);
this.setState({
dataType: "value",
});
}
let zoomFunc: any;
if (typeof value === 'object') {
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(props.fieldSpec)];
}),
};
} else {
return [stop[0].zoom, stop[1] || findDefaultFromSpec(this.props.fieldSpec)];
})
}
}
else {
zoomFunc = {
base: value.base,
stops: [
[6, findDefaultFromSpec(props.fieldSpec)],
[10, findDefaultFromSpec(props.fieldSpec)],
],
};
[6, findDefaultFromSpec(this.props.fieldSpec)],
[10, findDefaultFromSpec(this.props.fieldSpec)]
]
}
}
} else {
}
else {
zoomFunc = {
stops: [
[6, value || findDefaultFromSpec(props.fieldSpec)],
[10, value || findDefaultFromSpec(props.fieldSpec)],
],
};
[6, value || findDefaultFromSpec(this.props.fieldSpec)],
[10, value || findDefaultFromSpec(this.props.fieldSpec)]
]
}
}
props.onChange(props.fieldName, zoomFunc);
};
this.props.onChange(this.props.fieldName, zoomFunc)
}
const undoExpression = () => {
const { value, fieldName } = props;
undoExpression = () => {
const {value, fieldName} = this.props;
if (isGetExpression(value)) {
props.onChange(fieldName, {
type: 'identity',
property: value[1],
this.props.onChange(fieldName, {
"type": "identity",
"property": value[1]
});
this.setState({
dataType: "value",
});
setDataType('value');
} else if (isLiteralExpression(value)) {
props.onChange(fieldName, value[1]);
setDataType('value');
}
};
else if (isLiteralExpression(value)) {
this.props.onChange(fieldName, value[1]);
this.setState({
dataType: "value",
});
}
}
const canUndo = () => {
const { value, fieldSpec } = props;
canUndo = () => {
const {value, fieldSpec} = this.props;
return (
isGetExpression(value) ||
isLiteralExpression(value) ||
isPrimative(value) ||
(Array.isArray(value) && fieldSpec.type === 'array')
);
};
const makeExpression = () => {
const { value, fieldSpec } = 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 || props.fieldSpec.default];
}
props.onChange(props.fieldName, expression);
};
const makeDataFunction = () => {
const functionType = getFieldFunctionType(props.fieldSpec);
const stopValue = functionType === 'categorical' ? '' : 0;
const { value } = 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(props.fieldSpec)];
}),
};
} else {
dataFunc = {
property: '',
type: functionType,
base: value.base,
stops: [
[{ zoom: 6, value: stopValue }, findDefaultFromSpec(props.fieldSpec)],
[{ zoom: 10, value: stopValue }, findDefaultFromSpec(props.fieldSpec)],
],
};
}
} else {
dataFunc = {
property: '',
type: functionType,
base: value.base,
stops: [
[{ zoom: 6, value: stopValue }, props.value || findDefaultFromSpec(props.fieldSpec)],
[{ zoom: 10, value: stopValue }, props.value || findDefaultFromSpec(props.fieldSpec)],
],
};
}
props.onChange(props.fieldName, dataFunc);
};
const onMarkEditing = () => {
setIsEditing(true);
};
const onUnmarkEditing = () => {
setIsEditing(false);
};
const propClass =
props.fieldSpec.default === props.value ? 'maputnik-default-property' : 'maputnik-modified-property';
let specField;
if (dataType === 'expression') {
specField = (
<ExpressionProperty
errors={props.errors}
onChange={props.onChange.bind(null, props.fieldName)}
canUndo={canUndo}
onUndo={undoExpression}
onDelete={deleteExpression}
fieldType={props.fieldType}
fieldName={props.fieldName}
fieldSpec={props.fieldSpec}
value={props.value}
onFocus={onMarkEditing}
onBlur={onUnmarkEditing}
/>
);
} else if (dataType === 'zoom_function') {
specField = (
<ZoomProperty
errors={props.errors}
onChange={props.onChange.bind(null)}
fieldType={props.fieldType}
fieldName={props.fieldName}
fieldSpec={props.fieldSpec}
value={props.value}
onDeleteStop={deleteStop}
onAddStop={addStop}
onChangeToDataFunction={makeDataFunction}
onExpressionClick={makeExpression}
/>
);
} else if (dataType === 'data_function') {
specField = (
<DataProperty
errors={props.errors}
onChange={props.onChange.bind(null)}
fieldType={props.fieldType}
fieldName={props.fieldName}
fieldSpec={props.fieldSpec}
value={props.value}
onDeleteStop={deleteStop}
onAddStop={addStop}
onChangeToZoomFunction={makeZoomFunction}
onExpressionClick={makeExpression}
/>
);
} else {
specField = (
<SpecProperty
errors={props.errors}
onChange={props.onChange.bind(null)}
fieldType={props.fieldType}
fieldName={props.fieldName}
fieldSpec={props.fieldSpec}
value={props.value}
onZoomClick={makeZoomFunction}
onDataClick={makeDataFunction}
onExpressionClick={makeExpression}
/>
(Array.isArray(value) && fieldSpec.type === "array")
);
}
return (
<div className={propClass} data-wd-key={'spec-field-container:' + props.fieldName}>
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-container:"+this.props.fieldName}>
{specField}
</div>
);
};
}
}
export default FieldFunction;
+12 -12
View File
@@ -1,3 +1,4 @@
import React from 'react'
import latest from '@maplibre/maplibre-gl-style-spec/dist/latest.json'
import Block from './Block'
@@ -10,19 +11,18 @@ type FieldIdProps = {
error?: {message: string}
};
const FieldId: React.FC<FieldIdProps> = (props) => {
return (
<Block label="ID" fieldSpec={latest.layer.id}
data-wd-key={props.wdKey}
error={props.error}
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={props.value}
onInput={props.onChange}
data-wd-key={props.wdKey + ".input"}
value={this.props.value}
onInput={this.props.onChange}
data-wd-key={this.props.wdKey + ".input"}
/>
</Block>
);
};
export default FieldId;
}
}
+6 -4
View File
@@ -1,11 +1,13 @@
import React from 'react'
import InputJson, {InputJsonProps} from './InputJson'
type FieldJsonProps = InputJsonProps & {};
const FieldJson: React.FC<FieldJsonProps> = (props) => {
return <InputJson {...props} />;
};
export default class FieldJson extends React.Component<FieldJsonProps> {
render() {
return <InputJson {...this.props} />
}
}
export default FieldJson;
+9 -9
View File
@@ -11,25 +11,25 @@ type FieldMaxZoomInternalProps = {
error?: {message: string}
} & WithTranslation;
const FieldMaxZoomInternal: React.FC<FieldMaxZoomInternalProps> = (props) => {
const t = props.t;
return (
<Block label={t('Max Zoom')} fieldSpec={latest.layer.maxzoom}
error={props.error}
class FieldMaxZoomInternal extends React.Component<FieldMaxZoomInternalProps> {
render() {
const t = this.props.t;
return <Block label={t("Max Zoom")} fieldSpec={latest.layer.maxzoom}
error={this.props.error}
data-wd-key="max-zoom"
>
<InputNumber
allowRange={true}
value={props.value}
onChange={props.onChange}
value={this.props.value}
onChange={this.props.onChange}
min={latest.layer.maxzoom.minimum}
max={latest.layer.maxzoom.maximum}
default={latest.layer.maxzoom.maximum}
data-wd-key="max-zoom.input"
/>
</Block>
);
};
}
}
const FieldMaxZoom = withTranslation()(FieldMaxZoomInternal);
export default FieldMaxZoom;
+9 -9
View File
@@ -11,25 +11,25 @@ type FieldMinZoomInternalProps = {
error?: {message: string}
} & WithTranslation;
const FieldMinZoomInternal: React.FC<FieldMinZoomInternalProps> = (props) => {
const t = props.t;
return (
<Block label={t('Min Zoom')} fieldSpec={latest.layer.minzoom}
error={props.error}
class FieldMinZoomInternal extends React.Component<FieldMinZoomInternalProps> {
render() {
const t = this.props.t;
return <Block label={t("Min Zoom")} fieldSpec={latest.layer.minzoom}
error={this.props.error}
data-wd-key="min-zoom"
>
<InputNumber
allowRange={true}
value={props.value}
onChange={props.onChange}
value={this.props.value}
onChange={this.props.onChange}
min={latest.layer.minzoom.minimum}
max={latest.layer.minzoom.maximum}
default={latest.layer.minzoom.minimum}
data-wd-key='min-zoom.input'
/>
</Block>
);
};
}
}
const FieldMinZoom = withTranslation()(FieldMinZoomInternal);
export default FieldMinZoom;
+7 -7
View File
@@ -1,3 +1,4 @@
import React from 'react'
import InputMultiInput, {InputMultiInputProps} from './InputMultiInput'
import Fieldset from './Fieldset'
@@ -7,12 +8,11 @@ type FieldMultiInputProps = InputMultiInputProps & {
};
const FieldMultiInput: React.FC<FieldMultiInputProps> = (props) => {
return (
<Fieldset label={props.label}>
<InputMultiInput {...props} />
export default class FieldMultiInput extends React.Component<FieldMultiInputProps> {
render() {
return <Fieldset label={this.props.label}>
<InputMultiInput {...this.props} />
</Fieldset>
);
};
}
}
export default FieldMultiInput;
+7 -8
View File
@@ -1,3 +1,4 @@
import React from 'react'
import InputNumber, {InputNumberProps} from './InputNumber'
import Block from './Block'
@@ -10,12 +11,10 @@ type FieldNumberProps = InputNumberProps & {
};
const FieldNumber: React.FC<FieldNumberProps> = (props) => {
return (
<Block label={props.label} fieldSpec={props.fieldSpec}>
<InputNumber {...props} />
export default class FieldNumber extends React.Component<FieldNumberProps> {
render() {
return <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputNumber {...this.props} />
</Block>
);
};
export default FieldNumber;
}
}
+8 -7
View File
@@ -1,3 +1,4 @@
import React from 'react'
import Block from './Block'
import InputSelect, {InputSelectProps} from './InputSelect'
@@ -10,12 +11,12 @@ type FieldSelectProps = InputSelectProps & {
};
const FieldSelect: React.FC<FieldSelectProps> = (props) => {
return (
<Block label={props.label} fieldSpec={props.fieldSpec}>
<InputSelect {...props} />
export default class FieldSelect extends React.Component<FieldSelectProps> {
render() {
return <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputSelect {...this.props}/>
</Block>
);
};
}
}
export default FieldSelect;
+17 -19
View File
@@ -13,30 +13,28 @@ type FieldSourceInternalProps = {
error?: {message: string}
} & WithTranslation;
const FieldSourceInternal: React.FC<FieldSourceInternalProps> = ({
onChange = () => {},
sourceIds = [],
wdKey,
value,
error,
t
}) => {
return (
<Block
label={t('Source')}
class FieldSourceInternal extends React.Component<FieldSourceInternalProps> {
static defaultProps = {
onChange: () => {},
sourceIds: [],
}
render() {
const t = this.props.t;
return <Block
label={t("Source")}
fieldSpec={latest.layer.source}
error={error}
data-wd-key={wdKey}
error={this.props.error}
data-wd-key={this.props.wdKey}
>
<InputAutocomplete
value={value}
onChange={onChange}
options={sourceIds?.map((src) => [src, src])}
value={this.props.value}
onChange={this.props.onChange}
options={this.props.sourceIds?.map(src => [src, src])}
/>
</Block>
);
};
}
}
const FieldSource = withTranslation()(FieldSourceInternal);
export default FieldSource;
+19 -16
View File
@@ -9,31 +9,34 @@ type FieldSourceLayerInternalProps = {
value?: string
onChange?(...args: unknown[]): unknown
sourceLayerIds?: unknown[]
isFixed?: boolean
error?: {message: string}
} & WithTranslation;
const FieldSourceLayerInternal: React.FC<FieldSourceLayerInternalProps> = ({
onChange = () => {},
sourceLayerIds = [],
value,
error,
t
}) => {
return (
<Block
label={t('Source Layer')}
class FieldSourceLayerInternal extends React.Component<FieldSourceLayerInternalProps> {
static defaultProps = {
onChange: () => {},
sourceLayerIds: [],
isFixed: false
}
render() {
const t = this.props.t;
return <Block
label={t("Source Layer")}
fieldSpec={latest.layer['source-layer']}
data-wd-key="layer-source-layer"
error={error}
error={this.props.error}
>
<InputAutocomplete
value={value}
onChange={onChange}
options={sourceLayerIds?.map((l) => [l, l])}
keepMenuWithinWindowBounds={!!this.props.isFixed}
value={this.props.value}
onChange={this.props.onChange}
options={this.props.sourceLayerIds?.map(l => [l, l])}
/>
</Block>
);
};
}
}
const FieldSourceLayer = withTranslation()(FieldSourceLayerInternal);
export default FieldSourceLayer;
+7 -8
View File
@@ -1,3 +1,4 @@
import React from 'react'
import Block from './Block'
import InputString, {InputStringProps} from './InputString'
@@ -9,12 +10,10 @@ type FieldStringProps = InputStringProps & {
}
};
const FieldString: React.FC<FieldStringProps> = (props) => {
return (
<Block label={props.label} fieldSpec={props.fieldSpec}>
<InputString {...props} />
export default class FieldString extends React.Component<FieldStringProps> {
render() {
return <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputString {...this.props} />
</Block>
);
};
export default FieldString;
}
}
+37 -27
View File
@@ -1,10 +1,10 @@
import React from 'react'
import {v8} from '@maplibre/maplibre-gl-style-spec'
import latest from '@maplibre/maplibre-gl-style-spec/dist/latest.json'
import Block from './Block'
import InputSelect from './InputSelect'
import InputString from './InputString'
import { WithTranslation, withTranslation } from 'react-i18next';
import { startCase } from 'lodash'
type FieldTypeInternalProps = {
value: string
@@ -14,34 +14,44 @@ type FieldTypeInternalProps = {
disabled?: boolean
} & WithTranslation;
const FieldTypeInternal: React.FC<FieldTypeInternalProps> = ({
t,
value,
wdKey,
onChange,
error,
disabled = false
}) => {
const layerstypes: [string, string][] = Object.keys(v8.layer.type.values || {}).map(v => [v, startCase(v.replace(/-/g, ' '))]);
return (
<Block label={t('Type')} fieldSpec={v8.layer.type}
data-wd-key={wdKey}
error={error}
class FieldTypeInternal extends React.Component<FieldTypeInternalProps> {
static defaultProps = {
disabled: false,
}
render() {
const t = this.props.t;
return <Block label={t("Type")} fieldSpec={latest.layer.type}
data-wd-key={this.props.wdKey}
error={this.props.error}
>
{disabled && (
<InputString value={value} disabled={true} />
)}
{!disabled && (
<InputSelect
options={layerstypes}
onChange={onChange}
value={value}
data-wd-key={wdKey + '.select'}
{this.props.disabled &&
<InputString
value={this.props.value}
disabled={true}
/>
)}
}
{!this.props.disabled &&
<InputSelect
options={[
['background', 'Background'],
['fill', 'Fill'],
['line', 'Line'],
['symbol', 'Symbol'],
['raster', 'Raster'],
['circle', 'Circle'],
['fill-extrusion', 'Fill Extrusion'],
['hillshade', 'Hillshade'],
['heatmap', 'Heatmap'],
]}
onChange={this.props.onChange}
value={this.props.value}
data-wd-key={this.props.wdKey + ".select"}
/>
}
</Block>
);
};
}
}
const FieldType = withTranslation()(FieldTypeInternal);
export default FieldType;
+10 -8
View File
@@ -1,3 +1,4 @@
import React from 'react'
import InputUrl, {FieldUrlProps as InputUrlProps} from './InputUrl'
import Block from './Block'
@@ -10,12 +11,13 @@ type FieldUrlProps = InputUrlProps & {
};
const FieldUrl: React.FC<FieldUrlProps> = (props) => {
return (
<Block label={props.label} fieldSpec={props.fieldSpec}>
<InputUrl {...props} />
</Block>
);
};
export default class FieldUrl extends React.Component<FieldUrlProps> {
render () {
return (
<Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputUrl {...this.props} />
</Block>
);
}
}
export default FieldUrl;
+52 -35
View File
@@ -9,40 +9,57 @@ type FieldsetProps = PropsWithChildren & {
action?: ReactElement,
};
const Fieldset: React.FC<FieldsetProps> = (props) => {
const [showDoc, setShowDoc] = React.useState(false);
const labelId = React.useRef(generateUniqueId('fieldset_label_'));
const onToggleDoc = (val: boolean) => {
setShowDoc(val);
};
return (
<div className="maputnik-input-block" role="group" aria-labelledby={labelId.current}>
{props.fieldSpec && (
<div className="maputnik-input-block-label">
<FieldDocLabel
label={props.label}
onToggleDoc={onToggleDoc}
fieldSpec={props.fieldSpec}
/>
</div>
)}
{!props.fieldSpec && (
<div className="maputnik-input-block-label">
{props.label}
</div>
)}
<div className="maputnik-input-block-action">{props.action}</div>
<div className="maputnik-input-block-content">{props.children}</div>
{props.fieldSpec && (
<div className="maputnik-doc-inline" style={{ display: showDoc ? '' : 'none' }}>
<Doc fieldSpec={props.fieldSpec} />
</div>
)}
</div>
);
type FieldsetState = {
showDoc: boolean
};
export default Fieldset;
export default class Fieldset extends React.Component<FieldsetProps, FieldsetState> {
_labelId: string;
constructor (props: FieldsetProps) {
super(props);
this._labelId = generateUniqueId(`fieldset_label_`);
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>
}
}
+4 -5
View File
@@ -1,7 +1,7 @@
import React from 'react'
import {mdiTableRowPlusAfter} from '@mdi/js';
import {isEqual} from 'lodash';
import {ExpressionSpecification, LegacyFilterSpecification} from 'maplibre-gl'
import {ExpressionSpecification, LegacyFilterSpecification, StyleSpecification} from 'maplibre-gl'
import {latest, migrate, convertFilter} from '@maplibre/maplibre-gl-style-spec'
import {mdiFunctionVariant} from '@mdi/js';
@@ -14,7 +14,6 @@ import InputButton from './InputButton'
import Doc from './Doc'
import ExpressionProperty from './_ExpressionProperty';
import { WithTranslation, withTranslation } from 'react-i18next';
import type { StyleSpecificationWithId } from '../libs/definitions';
function combiningFilter(props: FilterEditorInternalProps): LegacyFilterSpecification | ExpressionSpecification {
@@ -40,7 +39,7 @@ function migrateFilter(filter: LegacyFilterSpecification | ExpressionSpecificati
return (migrate(createStyleFromFilter(filter) as any).layers[0] as any).filter;
}
function createStyleFromFilter(filter: LegacyFilterSpecification | ExpressionSpecification): StyleSpecificationWithId {
function createStyleFromFilter(filter: LegacyFilterSpecification | ExpressionSpecification): StyleSpecification & {id: string} {
return {
"id": "tmp",
"version": 8,
@@ -256,8 +255,8 @@ class FilterEditorInternal extends React.Component<FilterEditorInternalProps, Fi
value={combiningOp}
onChange={(v: [string, any]) => this.onFilterPartChanged(0, v)}
options={[
["all", t("every filter matches")],
["none", t("no filter matches")],
["all", t("every filter matches")],
["none", t("no filter matches")],
["any", t("any filter matches")]
]}
/>
+4 -4
View File
@@ -11,18 +11,18 @@ class FilterEditorBlockInternal extends React.Component<FilterEditorBlockInterna
render() {
const t = this.props.t;
return <div className="maputnik-filter-editor-block">
<div className="maputnik-filter-editor-block-content">
{this.props.children}
</div>
<div className="maputnik-filter-editor-block-action">
<InputButton
className="maputnik-icon-button"
className="maputnik-delete-filter"
onClick={this.props.onDelete}
title={t("Delete filter block")}
>
<MdDelete />
</InputButton>
</div>
<div className="maputnik-filter-editor-block-content">
{this.props.children}
</div>
</div>
}
}
+2
View File
@@ -11,3 +11,5 @@ export default class IconBackground extends React.Component {
)
}
}
+2
View File
@@ -11,3 +11,5 @@ export default class IconCircle extends React.Component {
)
}
}
+2
View File
@@ -11,3 +11,5 @@ export default class IconFill extends React.Component {
)
}
}
+1
View File
@@ -30,3 +30,4 @@ export default class IconLayer extends React.Component<IconLayerProps> {
}
}
}
+2
View File
@@ -11,3 +11,5 @@ export default class IconLine extends React.Component {
)
}
}
+3
View File
@@ -13,3 +13,6 @@ export default class IconSymbol extends React.Component {
)
}
}
+1
View File
@@ -115,3 +115,4 @@ export default class FieldArray extends React.Component<FieldArrayProps, FieldAr
)
}
}
-18
View File
@@ -1,18 +0,0 @@
import InputAutocomplete from './InputAutocomplete'
import { mount } from 'cypress/react'
const fruits = ['apple', 'banana', 'cherry'];
describe('<InputAutocomplete />', () => {
it('filters options when typing', () => {
mount(
<InputAutocomplete aria-label="Fruit" options={fruits.map(f => [f, f])} />
);
cy.get('input').focus();
cy.get('.maputnik-autocomplete-menu-item').should('have.length', 3);
cy.get('input').type('ch');
cy.get('.maputnik-autocomplete-menu-item').should('have.length', 1).and('contain', 'cherry');
cy.get('.maputnik-autocomplete-menu-item').click();
cy.get('input').should('have.value', 'cherry');
});
});
+82 -96
View File
@@ -1,116 +1,102 @@
import React from 'react'
import classnames from 'classnames'
import {useCombobox} from 'downshift'
import Autocomplete from 'react-autocomplete'
const MAX_HEIGHT = 140
const MAX_HEIGHT = 140;
export type InputAutocompleteProps = {
value?: string
options?: any[]
onChange?(value: string | undefined): unknown
options: any[]
onChange(value: string | undefined): unknown
keepMenuWithinWindowBounds?: boolean
'aria-label'?: string
};
export default function InputAutocomplete({
value,
options = [],
onChange = () => {},
'aria-label': ariaLabel,
}: InputAutocompleteProps) {
const [input, setInput] = React.useState(value || '')
const menuRef = React.useRef<HTMLDivElement>(null)
const [maxHeight, setMaxHeight] = React.useState(MAX_HEIGHT)
export default class InputAutocomplete extends React.Component<InputAutocompleteProps> {
state = {
maxHeight: MAX_HEIGHT
}
const filteredItems = React.useMemo(() => {
const lv = input.toLowerCase()
return options.filter((item) => item[0].toLowerCase().includes(lv))
}, [options, input])
autocompleteMenuEl: HTMLDivElement | null = null;
const calcMaxHeight = React.useCallback(() => {
if (menuRef.current) {
const space = window.innerHeight - menuRef.current.getBoundingClientRect().top
setMaxHeight(Math.min(space, MAX_HEIGHT))
}
}, [])
static defaultProps = {
onChange: () => {},
options: [],
}
const {
isOpen,
getMenuProps,
getInputProps,
getItemProps,
highlightedIndex,
openMenu,
} = useCombobox({
items: filteredItems,
inputValue: input,
itemToString: (item) => (item ? item[0] : ''),
stateReducer: (_state, action) => {
if (action.type === useCombobox.stateChangeTypes.InputClick) {
return {...action.changes, isOpen: true}
calcMaxHeight() {
if(this.props.keepMenuWithinWindowBounds) {
const maxHeight = window.innerHeight - this.autocompleteMenuEl!.getBoundingClientRect().top;
const limitedMaxHeight = Math.min(maxHeight, MAX_HEIGHT);
if(limitedMaxHeight != this.state.maxHeight) {
this.setState({
maxHeight: limitedMaxHeight
})
}
return action.changes
},
onSelectedItemChange: ({selectedItem}) => {
const v = selectedItem ? selectedItem[0] : ''
setInput(v)
onChange(selectedItem ? selectedItem[0] : undefined)
},
onInputValueChange: ({inputValue: v}) => {
if (typeof v === 'string') {
setInput(v)
onChange(v === '' ? undefined : v)
openMenu()
}
},
})
React.useEffect(() => {
if (isOpen) {
calcMaxHeight()
}
}, [isOpen, calcMaxHeight])
}
React.useEffect(() => {
window.addEventListener('resize', calcMaxHeight)
return () => window.removeEventListener('resize', calcMaxHeight)
}, [calcMaxHeight])
componentDidMount() {
this.calcMaxHeight();
}
React.useEffect(() => {
setInput(value || '')
}, [value])
componentDidUpdate() {
this.calcMaxHeight();
}
return (
<div className="maputnik-autocomplete">
<input
{...getInputProps({
'aria-label': ariaLabel,
className: 'maputnik-string',
spellCheck: false,
onFocus: () => openMenu(),
})}
onChange(v: string) {
this.props.onChange(v === "" ? undefined : v);
}
render() {
return <div
ref={(el) => {
this.autocompleteMenuEl = el;
}}
>
<Autocomplete
menuStyle={{
position: "fixed",
overflow: "auto",
maxHeight: this.state.maxHeight,
zIndex: '998'
}}
wrapperProps={{
className: "maputnik-autocomplete",
style: {}
}}
inputProps={{
'aria-label': this.props['aria-label'],
className: "maputnik-string",
spellCheck: false
}}
value={this.props.value}
items={this.props.options}
getItemValue={(item) => item[0]}
onSelect={v => this.onChange(v)}
onChange={(_e, v) => this.onChange(v)}
shouldItemRender={(item, value="") => {
if (typeof(value) === "string") {
return item[0].toLowerCase().indexOf(value.toLowerCase()) > -1
}
return false
}}
renderItem={(item, isHighlighted) => (
<div
key={item[0]}
className={classnames({
"maputnik-autocomplete-menu-item": true,
"maputnik-autocomplete-menu-item-selected": isHighlighted,
})}
>
{item[1]}
</div>
)}
/>
<div
{...getMenuProps({}, {suppressRefError: true})}
ref={menuRef}
style={{position: 'fixed', overflow: 'auto', maxHeight, zIndex: 998}}
className="maputnik-autocomplete-menu"
>
{isOpen &&
filteredItems.map((item, index) => (
<div
key={item[0]}
{...getItemProps({
item,
index,
className: classnames('maputnik-autocomplete-menu-item', {
'maputnik-autocomplete-menu-item-selected': highlightedIndex === index,
}),
})}
>
{item[1]}
</div>
))}
</div>
</div>
)
}
}
+1 -1
View File
@@ -30,4 +30,4 @@ export default class InputButton extends React.Component<InputButtonProps> {
{this.props.children}
</button>
}
}
}
+1 -1
View File
@@ -34,4 +34,4 @@ export default class InputCheckbox extends React.Component<InputCheckboxProps> {
</div>
</div>
}
}
}
+1
View File
@@ -133,3 +133,4 @@ export default class InputColor extends React.Component<InputColorProps> {
</div>
}
}
+3 -2
View File
@@ -22,7 +22,7 @@ export type FieldDynamicArrayProps = {
}
'aria-label'?: string
label: string
}
}
type FieldDynamicArrayInternalProps = FieldDynamicArrayProps & WithTranslation;
@@ -67,7 +67,7 @@ class FieldDynamicArrayInternal extends React.Component<FieldDynamicArrayInterna
const t = this.props.t;
const i18nProps = { t, i18n: this.props.i18n, tReady: this.props.tReady };
const inputs = this.values.map((v, i) => {
const deleteValueBtn= <DeleteValueInputButton
const deleteValueBtn= <DeleteValueInputButton
onClick={this.deleteValue.bind(this, i)}
{...i18nProps}
/>;
@@ -152,3 +152,4 @@ class DeleteValueInputButton extends React.Component<DeleteValueInputButtonProps
</InputButton>
}
}
+1
View File
@@ -47,3 +47,4 @@ export default class InputEnum extends React.Component<InputEnumProps> {
}
}
}
+3 -1
View File
@@ -20,7 +20,7 @@ export default class InputMultiInput extends React.Component<InputMultiInputProp
const radios = options.map(([val, label])=> {
return <label
key={val}
className={classnames("maputnik-button", "maputnik-radio-as-button", {"maputnik-button-selected": val === selectedValue})}
className={classnames("maputnik-radio-as-button", {"maputnik-button-selected": val === selectedValue})}
>
<input type="radio"
name={this.props.name}
@@ -37,3 +37,5 @@ export default class InputMultiInput extends React.Component<InputMultiInputProp
</fieldset>
}
}
+3 -1
View File
@@ -157,7 +157,7 @@ export default class InputNumber extends React.Component<InputNumberProps, Input
render() {
if(
Object.prototype.hasOwnProperty.call(this.props, "min") &&
Object.prototype.hasOwnProperty.call(this.props, "min") &&
Object.prototype.hasOwnProperty.call(this.props, "max") &&
this.props.min !== undefined && this.props.max !== undefined &&
this.props.allowRange
@@ -244,3 +244,5 @@ export default class InputNumber extends React.Component<InputNumberProps, Input
}
}
}
+2
View File
@@ -30,3 +30,5 @@ export default class InputSelect extends React.Component<InputSelectProps> {
</select>
}
}
+2
View File
@@ -97,3 +97,5 @@ export default class InputString extends React.Component<InputStringProps, Input
});
}
}
+88 -142
View File
@@ -1,10 +1,9 @@
import React, {type JSX} from 'react'
import PropTypes from 'prop-types'
import { Wrapper, Button, Menu, MenuItem } from 'react-aria-menubutton'
import {Accordion} from 'react-accessible-accordion';
import {MdMoreVert} from 'react-icons/md'
import { IconContext } from 'react-icons'
import {BackgroundLayerSpecification, LayerSpecification, SourceSpecification} from 'maplibre-gl';
import {v8} from '@maplibre/maplibre-gl-style-spec';
import FieldJson from './FieldJson'
import FilterEditor from './FilterEditor'
@@ -18,79 +17,23 @@ import FieldComment from './FieldComment'
import FieldSource from './FieldSource'
import FieldSourceLayer from './FieldSourceLayer'
import { changeType, changeProperty } from '../libs/layer'
import layout from '../config/layout.json'
import {formatLayerId} from '../libs/format';
import { WithTranslation, withTranslation } from 'react-i18next';
import { TFunction } from 'i18next';
import { NON_SOURCE_LAYERS } from '../libs/non-source-layers';
import { OnMoveLayerCallback } from '../libs/definitions';
type MaputnikLayoutGroup = {
id: string;
title: string;
type: string;
fields: string[];
}
function getLayoutForSymbolType(t: TFunction): MaputnikLayoutGroup[] {
const groups: MaputnikLayoutGroup[] = [];
groups.push({
title: t("General layout properties"),
id: "General_layout_properties",
type: "properties",
fields: Object.keys(v8["layout_symbol"]).filter(f => f.startsWith("symbol-"))
});
groups.push({
title: t("Text layout properties"),
id: "Text_layout_properties",
type: "properties",
fields: Object.keys(v8["layout_symbol"]).filter(f => f.startsWith("text-"))
});
groups.push({
title: t("Icon layout properties"),
id: "Icon_layout_properties",
type: "properties",
fields: Object.keys(v8["layout_symbol"]).filter(f => f.startsWith("icon-"))
});
groups.push({
title: t("Text paint properties"),
id: "Text_paint_properties",
type: "properties",
fields: Object.keys(v8["paint_symbol"]).filter(f => f.startsWith("text-"))
});
groups.push({
title: t("Icon paint properties"),
id: "Icon_paint_properties",
type: "properties",
fields: Object.keys(v8["paint_symbol"]).filter(f => f.startsWith("icon-"))
});
return groups;
}
function getLayoutForType(type: LayerSpecification["type"], t: TFunction): MaputnikLayoutGroup[] {
if (Object.keys(v8.layer.type.values).indexOf(type) < 0) {
return []
}
if (type === "symbol") {
return getLayoutForSymbolType(t);
}
const groups: MaputnikLayoutGroup[] = [];
if (Object.keys(v8["paint_" + type]).length > 0) {
groups.push({
title: t("Paint properties"),
id: "Paint_properties",
type: "properties",
fields: Object.keys(v8["paint_" + type]),
});
}
if (Object.keys(v8["layout_" + type]).length > 0) {
groups.push({
title: t("Layout properties"),
id: "Layout_properties",
type: "properties",
fields: Object.keys(v8["layout_" + type])
});
}
return groups;
function getLayoutForType(type: LayerSpecification["type"], t: TFunction) {
return layout[type] ? {
...layout[type],
groups: layout[type].groups.map(group => {
return {
...group,
id: group.title.replace(/ /g, "_"),
title: t(group.title)
};
}),
} : layout.invalid;
}
function layoutGroups(layerType: LayerSpecification["type"], t: TFunction): {id: string, title: string, type: string, fields?: string[]}[] {
@@ -110,18 +53,18 @@ function layoutGroups(layerType: LayerSpecification["type"], t: TFunction): {id:
type: 'jsoneditor'
}
return [layerGroup, filterGroup]
.concat(getLayoutForType(layerType, t))
.concat(getLayoutForType(layerType, t).groups)
.concat([editorGroup])
}
type LayerEditorInternalProps = {
layer: LayerSpecification
sources: {[key: string]: SourceSpecification & {layers: string[]}}
sources: {[key: string]: SourceSpecification}
vectorLayers: {[key: string]: any}
spec: object
onLayerChanged(...args: unknown[]): unknown
onLayerIdChange(...args: unknown[]): unknown
onMoveLayer: OnMoveLayerCallback
onMoveLayer(...args: unknown[]): unknown
onLayerDestroy(...args: unknown[]): unknown
onLayerCopy(...args: unknown[]): unknown
onLayerVisibilityToggle(...args: unknown[]): unknown
@@ -143,13 +86,18 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
onLayerDestroyed: () => {},
}
static childContextTypes = {
reactIconBase: PropTypes.object
}
constructor(props: LayerEditorInternalProps) {
super(props)
//TODO: Clean this up and refactor into function
const editorGroups: {[keys:string]: boolean} = {}
for (const group of layoutGroups(this.props.layer.type, props.t)) {
layoutGroups(this.props.layer.type, props.t).forEach(group => {
editorGroups[group.title] = true
}
})
this.state = { editorGroups }
}
@@ -157,17 +105,25 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
static getDerivedStateFromProps(props: Readonly<LayerEditorInternalProps>, state: LayerEditorState) {
const additionalGroups = { ...state.editorGroups }
for (const group of getLayoutForType(props.layer.type, props.t)) {
getLayoutForType(props.layer.type, props.t).groups.forEach(group => {
if(!(group.title in additionalGroups)) {
additionalGroups[group.title] = true
}
}
})
return {
editorGroups: additionalGroups
};
}
getChildContext () {
return {
reactIconBase: {
size: 14,
color: '#8e8e8e',
}
}
}
changeProperty(group: keyof LayerSpecification | null, property: string, newValue: any) {
this.props.onLayerChanged(
@@ -209,7 +165,7 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
let sourceLayerIds;
const layer = this.props.layer as Exclude<LayerSpecification, BackgroundLayerSpecification>;
if(Object.prototype.hasOwnProperty.call(this.props.sources, layer.source)) {
sourceLayerIds = this.props.sources[layer.source].layers;
sourceLayerIds = (this.props.sources[layer.source] as any).layers;
}
switch(type) {
@@ -236,7 +192,7 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
onChange={v => this.changeProperty(null, 'source', v)}
/>
}
{!NON_SOURCE_LAYERS.includes(this.props.layer.type) &&
{['background', 'raster', 'hillshade', 'heatmap'].indexOf(this.props.layer.type) < 0 &&
<FieldSourceLayer
error={errorData['source-layer']}
sourceLayerIds={sourceLayerIds}
@@ -323,38 +279,30 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
const layout = this.props.layer.layout || {}
const items: {[key: string]: {
text: string,
handler: () => void,
disabled?: boolean,
wdKey?: string
}} = {
const items: {[key: string]: {text: string, handler: () => void, disabled?: boolean}} = {
delete: {
text: t("Delete"),
handler: () => this.props.onLayerDestroy(this.props.layerIndex),
wdKey: "menu-delete-layer"
handler: () => this.props.onLayerDestroy(this.props.layerIndex)
},
duplicate: {
text: t("Duplicate"),
handler: () => this.props.onLayerCopy(this.props.layerIndex),
wdKey: "menu-duplicate-layer"
handler: () => this.props.onLayerCopy(this.props.layerIndex)
},
hide: {
text: (layout.visibility === "none") ? t("Show") : t("Hide"),
handler: () => this.props.onLayerVisibilityToggle(this.props.layerIndex),
wdKey: "menu-hide-layer"
handler: () => this.props.onLayerVisibilityToggle(this.props.layerIndex)
},
moveLayerUp: {
text: t("Move layer up"),
// Not actually used...
disabled: this.props.isFirstLayer,
handler: () => this.moveLayer(-1),
wdKey: "menu-move-layer-up"
handler: () => this.moveLayer(-1)
},
moveLayerDown: {
text: t("Move layer down"),
// Not actually used...
disabled: this.props.isLastLayer,
handler: () => this.moveLayer(+1),
wdKey: "menu-move-layer-down"
handler: () => this.moveLayer(+1)
}
}
@@ -363,55 +311,53 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
items[id].handler();
}
return <IconContext.Provider value={{size: '14px', color: '#8e8e8e'}}>
<section className="maputnik-layer-editor"
role="main"
aria-label={t("Layer editor")}
>
<header>
<div className="layer-header">
<h2 className="layer-header__title">
{t("Layer: {{layerId}}", { layerId: formatLayerId(this.props.layer.id) })}
</h2>
<div className="layer-header__info">
<Wrapper
className='more-menu'
onSelection={handleSelection}
closeOnSelection={false}
>
<Button
id="skip-target-layer-editor"
data-wd-key="skip-target-layer-editor"
className='more-menu__button'
title={"Layer options"}>
<MdMoreVert className="more-menu__button__svg" />
</Button>
<Menu>
<ul className="more-menu__menu">
{Object.keys(items).map((id) => {
const item = items[id];
return <li key={id}>
<MenuItem value={id} className='more-menu__menu__item' data-wd-key={item.wdKey}>
{item.text}
</MenuItem>
</li>
})}
</ul>
</Menu>
</Wrapper>
</div>
return <section className="maputnik-layer-editor"
role="main"
aria-label={t("Layer editor")}
>
<header>
<div className="layer-header">
<h2 className="layer-header__title">
{t("Layer: {{layerId}}", { layerId: formatLayerId(this.props.layer.id) })}
</h2>
<div className="layer-header__info">
<Wrapper
className='more-menu'
onSelection={handleSelection}
closeOnSelection={false}
>
<Button
id="skip-target-layer-editor"
data-wd-key="skip-target-layer-editor"
className='more-menu__button'
title={"Layer options"}>
<MdMoreVert className="more-menu__button__svg" />
</Button>
<Menu>
<ul className="more-menu__menu">
{Object.keys(items).map((id) => {
const item = items[id];
return <li key={id}>
<MenuItem value={id} className='more-menu__menu__item'>
{item.text}
</MenuItem>
</li>
})}
</ul>
</Menu>
</Wrapper>
</div>
</div>
</header>
<Accordion
allowMultipleExpanded={true}
allowZeroExpanded={true}
preExpanded={groupIds}
>
{groups}
</Accordion>
</section>
</IconContext.Provider>
</header>
<Accordion
allowMultipleExpanded={true}
allowZeroExpanded={true}
preExpanded={groupIds}
>
{groups}
</Accordion>
</section>
}
}
+19 -45
View File
@@ -1,28 +1,16 @@
import React, {type JSX} from 'react'
import classnames from 'classnames'
import lodash from 'lodash';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import LayerListGroup from './LayerListGroup'
import LayerListItem from './LayerListItem'
import ModalAdd from './ModalAdd'
import type {LayerSpecification, SourceSpecification} from 'maplibre-gl';
import {SortEndHandler, SortableContainer} from 'react-sortable-hoc';
import type {LayerSpecification} from 'maplibre-gl';
import generateUniqueId from '../libs/document-uid';
import { findClosestCommonPrefix, layerPrefix } from '../libs/layer';
import { WithTranslation, withTranslation } from 'react-i18next';
import { OnMoveLayerCallback } from '../libs/definitions';
type LayerListContainerProps = {
layers: LayerSpecification[]
@@ -32,7 +20,7 @@ type LayerListContainerProps = {
onLayerDestroy?(...args: unknown[]): unknown
onLayerCopy(...args: unknown[]): unknown
onLayerVisibilityToggle(...args: unknown[]): unknown
sources: Record<string, SourceSpecification & {layers: string[]}>;
sources: object
errors: any[]
};
type LayerListContainerInternalProps = LayerListContainerProps & WithTranslation;
@@ -254,6 +242,7 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
'maputnik-layer-list-item-group-last': idxInGroup == layers.length - 1 && layers.length > 1,
'maputnik-layer-list-item--error': !!layerError
})}
index={idx}
key={layer.key}
id={layer.key}
layerId={layer.id}
@@ -298,9 +287,9 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
data-wd-key="skip-target-layer-list"
onClick={this.toggleLayers}
className="maputnik-button">
{this.state.areAllGroupsExpanded === true ?
t("Collapse")
:
{this.state.areAllGroupsExpanded === true ?
t("Collapse")
:
t("Expand")
}
</button>
@@ -330,35 +319,20 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
}
const LayerListContainer = withTranslation()(LayerListContainerInternal);
const LayerListContainerSortable = SortableContainer((props: LayerListContainerProps) => <LayerListContainer {...props} />)
type LayerListProps = LayerListContainerProps & {
onMoveLayer: OnMoveLayerCallback
onMoveLayer: SortEndHandler
};
const LayerList: React.FC<LayerListProps> = (props) => {
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = (event: DragEndEvent) => {
const {active, over} = event;
if (!over) return;
const oldIndex = props.layers.findIndex(layer => layer.id === active.id);
const newIndex = props.layers.findIndex(layer => layer.id === over.id);
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
props.onMoveLayer({oldIndex, newIndex});
}
};
const layerIds = props.layers.map(layer => layer.id);
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={layerIds} strategy={verticalListSortingStrategy}>
<LayerListContainer {...props} />
</SortableContext>
</DndContext>
);
export default class LayerList extends React.Component<LayerListProps> {
render() {
return <LayerListContainerSortable
{...this.props}
helperClass='sortableHelper'
onSortEnd={this.props.onMoveLayer.bind(this)}
useDragHandle={true}
shouldCancelStart={() => false}
/>
}
}
export default LayerList;
+43 -63
View File
@@ -1,23 +1,20 @@
import React from 'react'
import PropTypes from 'prop-types'
import classnames from 'classnames'
import {MdContentCopy, MdVisibility, MdVisibilityOff, MdDelete} from 'react-icons/md'
import { IconContext } from 'react-icons'
import {useSortable} from '@dnd-kit/sortable'
import {CSS} from '@dnd-kit/utilities'
import IconLayer from './IconLayer'
import {SortableElement, SortableHandle} from 'react-sortable-hoc'
type DraggableLabelProps = {
layerId: string
layerType: string
dragAttributes?: React.HTMLAttributes<HTMLElement>
dragListeners?: React.HTMLAttributes<HTMLElement>
};
const DraggableLabel: React.FC<DraggableLabelProps> = (props) => {
const {dragAttributes, dragListeners} = props;
return <div className="maputnik-layer-list-item-handle" {...dragAttributes} {...dragListeners}>
const DraggableLabel = SortableHandle((props: DraggableLabelProps) => {
return <div className="maputnik-layer-list-item-handle">
<IconLayer
className="layer-handle__icon"
type={props.layerType}
@@ -26,7 +23,7 @@ const DraggableLabel: React.FC<DraggableLabelProps> = (props) => {
{props.layerId}
</button>
</div>
};
});
type IconActionProps = {
action: string
@@ -85,80 +82,63 @@ type LayerListItemProps = {
onLayerVisibilityToggle?(...args: unknown[]): unknown
};
const LayerListItem = React.forwardRef<HTMLLIElement, LayerListItemProps>((props, ref) => {
const {
isSelected = false,
visibility = 'visible',
onLayerCopy = () => {},
onLayerDestroy = () => {},
onLayerVisibilityToggle = () => {},
} = props;
class LayerListItem extends React.Component<LayerListItemProps> {
static defaultProps = {
isSelected: false,
visibility: 'visible',
onLayerCopy: () => {},
onLayerDestroy: () => {},
onLayerVisibilityToggle: () => {},
}
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({id: props.layerId});
static childContextTypes = {
reactIconBase: PropTypes.object
}
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
getChildContext() {
return {
reactIconBase: { size: 14 }
}
}
const visibilityAction = visibility === 'visible' ? 'show' : 'hide';
render() {
const visibilityAction = this.props.visibility === 'visible' ? 'show' : 'hide';
// Cast ref to MutableRefObject since we know from the codebase that's what's always passed
const refObject = ref as React.MutableRefObject<HTMLLIElement | null> | null;
return <IconContext.Provider value={{size: '14px'}}>
<li
ref={(node) => {
setNodeRef(node);
if (refObject) {
refObject.current = node;
}
}}
style={style}
id={props.id}
onClick={_e => props.onLayerSelect(props.layerIndex)}
data-wd-key={"layer-list-item:" + props.layerId}
return <li
id={this.props.id}
key={this.props.layerId}
onClick={_e => this.props.onLayerSelect(this.props.layerIndex)}
data-wd-key={"layer-list-item:"+this.props.layerId}
className={classnames({
"maputnik-layer-list-item": true,
"maputnik-layer-list-item-selected": isSelected,
[props.className!]: true,
"maputnik-layer-list-item-selected": this.props.isSelected,
[this.props.className!]: true,
})}>
<DraggableLabel
layerId={props.layerId}
layerType={props.layerType}
dragAttributes={attributes}
dragListeners={listeners}
/>
<DraggableLabel {...this.props} />
<span style={{flexGrow: 1}} />
<IconAction
wdKey={"layer-list-item:" + props.layerId+":delete"}
wdKey={"layer-list-item:"+this.props.layerId+":delete"}
action={'delete'}
classBlockName="delete"
onClick={_e => onLayerDestroy!(props.layerIndex)}
onClick={_e => this.props.onLayerDestroy!(this.props.layerIndex)}
/>
<IconAction
wdKey={"layer-list-item:" + props.layerId+":copy"}
wdKey={"layer-list-item:"+this.props.layerId+":copy"}
action={'duplicate'}
classBlockName="duplicate"
onClick={_e => onLayerCopy!(props.layerIndex)}
onClick={_e => this.props.onLayerCopy!(this.props.layerIndex)}
/>
<IconAction
wdKey={"layer-list-item:"+props.layerId+":toggle-visibility"}
wdKey={"layer-list-item:"+this.props.layerId+":toggle-visibility"}
action={visibilityAction}
classBlockName="visibility"
classBlockModifier={visibilityAction}
onClick={_e => onLayerVisibilityToggle!(props.layerIndex)}
onClick={_e => this.props.onLayerVisibilityToggle!(this.props.layerIndex)}
/>
</li>
</IconContext.Provider>
});
}
}
export default LayerListItem;
const LayerListItemSortable = SortableElement<LayerListItemProps>((props: LayerListItemProps) => <LayerListItem {...props} />);
export default LayerListItemSortable;
+9 -29
View File
@@ -1,5 +1,5 @@
import React, {type JSX} from 'react'
import {createRoot} from 'react-dom/client'
import ReactDOM from 'react-dom'
import MapLibreGl, {LayerSpecification, LngLat, Map, MapOptions, SourceSpecification, StyleSpecification} from 'maplibre-gl'
import MaplibreInspect from '@maplibre/maplibre-gl-inspect'
import colors from '@maplibre/maplibre-gl-inspect/lib/colors'
@@ -17,14 +17,8 @@ import { withTranslation, WithTranslation } from 'react-i18next'
import i18next from 'i18next'
import { Protocol } from "pmtiles";
function renderPopup(
popupElement: JSX.Element,
mountNode: HTMLElement,
popup: MapLibreGl.Popup
): HTMLElement {
const root = createRoot(mountNode);
popup.once('close', () => root.unmount());
root.render(popupElement);
function renderPopup(popup: JSX.Element, mountNode: ReactDOM.Container): HTMLElement {
ReactDOM.render(popup, mountNode);
return mountNode as HTMLElement;
}
@@ -140,7 +134,7 @@ class MapMaplibreGlInternal extends React.Component<MapMaplibreGlInternalProps,
this.state.inspect!.render();
}, 500);
}
}
componentDidMount() {
@@ -180,12 +174,10 @@ class MapMaplibreGlInternal extends React.Component<MapMaplibreGlInternalProps,
const tmpNode = document.createElement('div');
const inspectPopup = new MapLibreGl.Popup({
closeOnClick: false
});
const inspect = new MaplibreInspect({
popup: inspectPopup,
popup: new MapLibreGl.Popup({
closeOnClick: false
}),
showMapPopup: true,
showMapPopupOnHover: false,
showInspectMapPopupOnHover: true,
@@ -197,21 +189,9 @@ class MapMaplibreGlInternal extends React.Component<MapMaplibreGlInternalProps,
buildInspectStyle: (originalMapStyle: StyleSpecification, coloredLayers: HighlightedLayer[]) => buildInspectStyle(originalMapStyle, coloredLayers, this.props.highlightedLayer),
renderPopup: (features: InspectFeature[]) => {
if(this.props.inspectModeEnabled) {
return renderPopup(
<MapMaplibreGlFeaturePropertyPopup features={features} />,
tmpNode,
inspectPopup
);
return renderPopup(<MapMaplibreGlFeaturePropertyPopup features={features} />, tmpNode);
} else {
return renderPopup(
<MapMaplibreGlLayerPopup
features={features}
onLayerSelect={this.onLayerSelectById}
zoom={this.state.zoom}
/>,
tmpNode,
inspectPopup
);
return renderPopup(<MapMaplibreGlLayerPopup features={features} onLayerSelect={this.onLayerSelectById} zoom={this.state.zoom} />, tmpNode);
}
}
})
+42 -56
View File
@@ -6,9 +6,8 @@ import FieldType from './FieldType'
import FieldId from './FieldId'
import FieldSource from './FieldSource'
import FieldSourceLayer from './FieldSourceLayer'
import type {LayerSpecification, SourceSpecification} from 'maplibre-gl'
import type {LayerSpecification} from 'maplibre-gl'
import { WithTranslation, withTranslation } from 'react-i18next';
import { NON_SOURCE_LAYERS } from '../libs/non-source-layers'
type ModalAddInternalProps = {
layers: LayerSpecification[]
@@ -16,7 +15,7 @@ type ModalAddInternalProps = {
isOpen: boolean
onOpenToggle(open: boolean): unknown
// A dict of source id's and the available source layers
sources: Record<string, SourceSpecification & {layers: string[]}>;
sources: any
} & WithTranslation;
type ModalAddState = {
@@ -24,16 +23,10 @@ type ModalAddState = {
id: string
source?: string
'source-layer'?: string
error?: string | null
};
class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddState> {
addLayer = () => {
if (this.props.layers.some(l => l.id === this.state.id)) {
this.setState({ error: this.props.t('Layer ID already exists') })
return
}
const changedLayers = this.props.layers.slice(0)
const layer: ModalAddState = {
id: this.state.id,
@@ -42,16 +35,15 @@ class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddSt
if(this.state.type !== 'background') {
layer.source = this.state.source
if(!NON_SOURCE_LAYERS.includes(this.state.type) && this.state['source-layer']) {
if(this.state.type !== 'raster' && this.state['source-layer']) {
layer['source-layer'] = this.state['source-layer']
}
}
changedLayers.push(layer as LayerSpecification)
this.setState({ error: null }, () => {
this.props.onLayersChange(changedLayers)
this.props.onOpenToggle(false)
})
this.props.onLayersChange(changedLayers)
this.props.onOpenToggle(false)
}
constructor(props: ModalAddInternalProps) {
@@ -59,15 +51,11 @@ class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddSt
const state: ModalAddState = {
type: 'fill',
id: '',
error: null,
}
if(Object.keys(props.sources).length > 0) {
if(props.sources.length > 0) {
state.source = Object.keys(this.props.sources)[0];
const sourceLayers = this.props.sources[state.source].layers || []
if (sourceLayers.length > 0) {
state['source-layer'] = sourceLayers[0];
}
state['source-layer'] = this.props.sources[state.source as keyof ModalAddInternalProps["sources"]][0]
}
this.state = state;
}
@@ -101,26 +89,39 @@ class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddSt
return sourceObj.layers || [];
}
getSources(type: LayerSpecification["type"]) {
getSources(type: string) {
const sources = [];
switch(type) {
case 'background':
return [];
case 'hillshade':
case 'color-relief':
return Object.entries(this.props.sources).filter(([_, v]) => v.type === 'raster-dem').map(([k, _]) => k);
case 'raster':
return Object.entries(this.props.sources).filter(([_, v]) => v.type === 'raster').map(([k, _]) => k);
case 'heatmap':
case 'circle':
case 'fill':
case 'fill-extrusion':
case 'line':
case 'symbol':
return Object.entries(this.props.sources).filter(([_, v]) => v.type === 'vector' || v.type === 'geojson').map(([k, _]) => k);
default:
return [];
const types = {
vector: [
"fill",
"line",
"symbol",
"circle",
"fill-extrusion",
"heatmap"
],
raster: [
"raster"
],
geojson: [
"fill",
"line",
"symbol",
"circle",
"fill-extrusion",
"heatmap"
]
}
for(const [key, val] of Object.entries(this.props.sources) as any) {
const valType = val.type as keyof typeof types;
if(types[valType] && types[valType].indexOf(type) > -1) {
sources.push(key);
}
}
return sources;
}
@@ -128,21 +129,6 @@ class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddSt
const t = this.props.t;
const sources = this.getSources(this.state.type);
const layers = this.getLayersForSource(this.state.source!);
let errorElement;
if (this.state.error) {
errorElement = (
<div className="maputnik-modal-error">
{this.state.error}
<a
href="#"
onClick={() => this.setState({ error: null })}
className="maputnik-modal-error-close"
>
×
</a>
</div>
);
}
return <Modal
isOpen={this.props.isOpen}
@@ -151,13 +137,12 @@ class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddSt
data-wd-key="modal:add-layer"
className="maputnik-add-modal"
>
{errorElement}
<div className="maputnik-add-layer">
<FieldId
value={this.state.id}
wdKey="add-layer.layer-id"
onChange={(v: string) => {
this.setState({ id: v, error: null })
this.setState({ id: v })
}}
/>
<FieldType
@@ -173,8 +158,9 @@ class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddSt
onChange={(v: string) => this.setState({ source: v })}
/>
}
{!NON_SOURCE_LAYERS.includes(this.state.type) &&
{['background', 'raster', 'hillshade', 'heatmap'].indexOf(this.state.type) < 0 &&
<FieldSourceLayer
isFixed={true}
sourceLayerIds={layers}
value={this.state['source-layer']}
onChange={(v: string) => this.setState({ 'source-layer': v })}
+3 -9
View File
@@ -3,6 +3,7 @@ import Slugify from 'slugify'
import {saveAs} from 'file-saver'
import {version} from 'maplibre-gl/package.json'
import {format} from '@maplibre/maplibre-gl-style-spec'
import type {StyleSpecification} from 'maplibre-gl'
import {MdMap, MdSave} from 'react-icons/md'
import {WithTranslation, withTranslation} from 'react-i18next';
@@ -11,7 +12,6 @@ import InputButton from './InputButton'
import Modal from './Modal'
import style from '../libs/style'
import fieldSpecAdditional from '../libs/field-spec-additional'
import type {OnStyleChangedCallback, StyleSpecificationWithId} from '../libs/definitions'
const MAPLIBRE_GL_VERSION = version;
@@ -19,8 +19,8 @@ const showSaveFilePickerAvailable = typeof window.showSaveFilePicker === "functi
type ModalExportInternalProps = {
mapStyle: StyleSpecificationWithId
onStyleChanged: OnStyleChangedCallback
mapStyle: StyleSpecification & { id: string }
onStyleChanged(...args: unknown[]): unknown
isOpen: boolean
onOpenToggle(...args: unknown[]): unknown
onSetFileHandle(fileHandle: FileSystemFileHandle | null): unknown
@@ -186,12 +186,6 @@ class ModalExportInternal extends React.Component<ModalExportInternalProps> {
value={(this.props.mapStyle.metadata || {} as any)['maputnik:stadia_access_token']}
onChange={this.changeMetadataProperty.bind(this, "maputnik:stadia_access_token")}
/>
<FieldString
label={fsa.maputnik.locationiq_access_token.label}
fieldSpec={fsa.maputnik.locationiq_access_token}
value={(this.props.mapStyle.metadata || {} as any)['maputnik:locationiq_access_token']}
onChange={this.changeMetadataProperty.bind(this, "maputnik:locationiq_access_token")}
/>
</div>
<div className="maputnik-modal-export-buttons">
+1 -1
View File
@@ -27,7 +27,7 @@ class ModalLoadingInternal extends React.Component<ModalLoadingInternalProps> {
underlayClickExits={false}
underlayProps={{
// @ts-ignore
onClick: (e: Event) => underlayProps(e)
onClick: (e: Event) => underlayProps(e)
}}
title={this.props.title}
onOpenToggle={() => this.props.onCancel()}
+3 -12
View File
@@ -12,11 +12,10 @@ import FieldEnum from './FieldEnum'
import FieldColor from './FieldColor'
import Modal from './Modal'
import fieldSpecAdditional from '../libs/field-spec-additional'
import type {OnStyleChangedCallback, StyleSpecificationWithId} from '../libs/definitions';
type ModalSettingsInternalProps = {
mapStyle: StyleSpecificationWithId
onStyleChanged: OnStyleChangedCallback
mapStyle: StyleSpecification
onStyleChanged(...args: unknown[]): unknown
onChangeMetadataProperty(...args: unknown[]): unknown
isOpen: boolean
onOpenToggle(...args: unknown[]): unknown
@@ -63,7 +62,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
changeTerrainProperty(property: keyof TerrainSpecification, value: any) {
const terrain = {
...this.props.mapStyle.terrain,
} as TerrainSpecification;
}
if (value === undefined) {
delete terrain[property];
@@ -165,14 +164,6 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
onChange={onChangeMetadataProperty.bind(this, "maputnik:stadia_access_token")}
/>
<FieldString
label={fsa.maputnik.locationiq_access_token.label}
fieldSpec={fsa.maputnik.locationiq_access_token}
data-wd-key="modal:settings.maputnik:locationiq_access_token"
value={metadata['maputnik:locationiq_access_token']}
onChange={onChangeMetadataProperty.bind(this, "maputnik:locationiq_access_token")}
/>
<FieldArray
label={t("Center")}
fieldSpec={latest.$root.center}
+3 -4
View File
@@ -1,7 +1,7 @@
import React from 'react'
import {MdAddCircleOutline, MdDelete} from 'react-icons/md'
import latest from '@maplibre/maplibre-gl-style-spec/dist/latest.json'
import type {GeoJSONSourceSpecification, RasterDEMSourceSpecification, RasterSourceSpecification, SourceSpecification, VectorSourceSpecification} from 'maplibre-gl'
import type {GeoJSONSourceSpecification, RasterDEMSourceSpecification, RasterSourceSpecification, SourceSpecification, StyleSpecification, VectorSourceSpecification} from 'maplibre-gl'
import { WithTranslation, withTranslation } from 'react-i18next';
import Modal from './Modal'
@@ -13,7 +13,6 @@ import ModalSourcesTypeEditor, { EditorMode } from './ModalSourcesTypeEditor'
import style from '../libs/style'
import { deleteSource, addSource, changeSource } from '../libs/source'
import publicSources from '../config/tilesets.json'
import { OnStyleChangedCallback, StyleSpecificationWithId } from '../libs/definitions';
type PublicSourceProps = {
@@ -271,10 +270,10 @@ class AddSource extends React.Component<AddSourceProps, AddSourceState> {
}
type ModalSourcesInternalProps = {
mapStyle: StyleSpecificationWithId
mapStyle: StyleSpecification
isOpen: boolean
onOpenToggle(...args: unknown[]): unknown
onStyleChanged: OnStyleChangedCallback
onStyleChanged(...args: unknown[]): unknown
} & WithTranslation;
class ModalSourcesInternal extends React.Component<ModalSourcesInternalProps> {
+2 -2
View File
@@ -56,8 +56,8 @@ export default class PropertyGroup extends React.Component<PropertyGroupProps> {
const paint = this.props.layer.paint || {}
const layout = this.props.layer.layout || {}
const fieldValue = fieldName in paint
? paint[fieldName as keyof typeof paint]
const fieldValue = fieldName in paint
? paint[fieldName as keyof typeof paint]
: layout[fieldName as keyof typeof layout]
const fieldType = fieldName in paint ? 'paint' : 'layout';
+1
View File
@@ -11,3 +11,4 @@ export default class ScrollContainer extends React.Component<ScrollContainerProp
</div>
}
}
+1
View File
@@ -90,3 +90,4 @@ export default class SingleFilterEditor extends React.Component<SingleFilterEdit
</div>
}
}
+21 -17
View File
@@ -1,3 +1,4 @@
import React from 'react'
import Block from './Block'
import InputSpec, { SpecFieldProps as InputFieldSpecProps } from './InputSpec'
import Fieldset from './Fieldset'
@@ -19,25 +20,28 @@ export type SpecFieldProps = InputFieldSpecProps & {
name?: string
};
const SpecField: React.FC<SpecFieldProps> = (props) => {
const fieldType = props.fieldSpec?.type;
export default class SpecField extends React.Component<SpecFieldProps> {
render() {
const fieldType = this.props.fieldSpec?.type;
const typeBlockFn = typeMap[fieldType!];
const typeBlockFn = typeMap[fieldType!];
let TypeBlock;
if (typeBlockFn) {
TypeBlock = typeBlockFn(props);
}
else {
console.warn("No such type for '%s'", fieldType);
TypeBlock = Block;
}
let TypeBlock;
if (typeBlockFn) {
TypeBlock = typeBlockFn(this.props);
}
else {
console.warn("No such type for '%s'", fieldType);
TypeBlock = Block;
}
return (
<TypeBlock label={props.label} action={props.action} fieldSpec={props.fieldSpec}>
<InputSpec {...props} />
return <TypeBlock
label={this.props.label}
action={this.props.action}
fieldSpec={this.props.fieldSpec}
>
<InputSpec {...this.props} />
</TypeBlock>
);
};
}
}
export default SpecField;
+1 -1
View File
@@ -91,7 +91,7 @@ class ExpressionPropertyInternal extends React.Component<ExpressionPropertyInter
function getValue(data: any) {
return stringifyPretty(data, {indent: 2, maxLength: 38})
}
if (jsonError) {
foundErrors.push({message: "Invalid JSON"});
}
+1 -1
View File
@@ -17,7 +17,7 @@ class FunctionInputButtonsInternal extends React.Component<FunctionInputButtonsI
const t = this.props.t;
let makeZoomInputButton, makeDataInputButton, expressionInputButton;
if (this.props.fieldSpec.expression?.parameters.includes('zoom')) {
if (this.props.fieldSpec.expression.parameters.includes('zoom')) {
expressionInputButton = (
<InputButton
className="maputnik-make-zoom-function"
+2 -2
View File
@@ -29,8 +29,8 @@ export default class SpecProperty extends React.Component<SpecPropertyProps> {
const functionBtn = <FunctionButtons
fieldSpec={this.props.fieldSpec}
onZoomClick={this.props.onZoomClick}
onDataClick={this.props.onDataClick}
onExpressionClick={this.props.onExpressionClick}
onDataClick={this.props.onDataClick}
onExpressionClick={this.props.onExpressionClick}
/>
const error = errors![fieldType+"."+fieldName as any] as any;
+240
View File
@@ -0,0 +1,240 @@
{
"line": {
"groups": [
{
"title": "Paint properties",
"type": "properties",
"fields": [
"line-opacity",
"line-color",
"line-width",
"line-offset",
"line-blur",
"line-dasharray",
"line-pattern",
"line-translate",
"line-translate-anchor",
"line-gap-width"
]
},
{
"title": "Layout properties",
"type": "properties",
"fields": [
"line-cap",
"line-join",
"line-miter-limit",
"line-round-limit"
]
}
]
},
"background": {
"groups": [
{
"title": "Paint properties",
"type": "properties",
"fields": [
"background-color",
"background-pattern",
"background-opacity"
]
}
]
},
"fill": {
"groups": [
{
"title": "Paint properties",
"type": "properties",
"fields": [
"fill-opacity",
"fill-color",
"fill-antialias",
"fill-outline-color",
"fill-pattern",
"fill-translate",
"fill-translate-anchor"
]
}
]
},
"fill-extrusion": {
"groups": [
{
"title": "Paint properties",
"type": "properties",
"fields": [
"fill-extrusion-opacity",
"fill-extrusion-color",
"fill-extrusion-translate",
"fill-extrusion-translate-anchor",
"fill-extrusion-pattern",
"fill-extrusion-height",
"fill-extrusion-base",
"fill-extrusion-vertical-gradient"
]
}
]
},
"circle": {
"groups": [
{
"title": "Paint properties",
"type": "properties",
"fields": [
"circle-color",
"circle-opacity",
"circle-stroke-color",
"circle-stroke-opacity",
"circle-blur",
"circle-radius",
"circle-stroke-width",
"circle-pitch-scale",
"circle-translate",
"circle-translate-anchor",
"circle-pitch-alignment"
]
}
]
},
"symbol": {
"groups": [
{
"title": "General layout properties",
"type": "properties",
"fields": [
"symbol-placement",
"symbol-spacing",
"symbol-avoid-edges",
"symbol-z-order"
]
},
{
"title": "Text layout properties",
"type": "properties",
"fields": [
"text-field",
"text-font",
"text-size",
"text-line-height",
"text-padding",
"text-allow-overlap",
"text-ignore-placement",
"text-pitch-alignment",
"text-rotation-alignment",
"text-max-width",
"text-letter-spacing",
"text-justify",
"text-anchor",
"text-max-angle",
"text-writing-mode",
"text-rotate",
"text-keep-upright",
"text-transform",
"text-offset",
"text-optional",
"text-variable-anchor",
"text-radial-offset"
]
},
{
"title": "Icon layout properties",
"type": "properties",
"fields": [
"icon-image",
"icon-allow-overlap",
"icon-ignore-placement",
"icon-optional",
"icon-rotation-alignment",
"icon-size",
"icon-text-fit",
"icon-text-fit-padding",
"icon-rotate",
"icon-padding",
"icon-keep-upright",
"icon-offset",
"icon-anchor",
"icon-pitch-alignment"
]
},
{
"title": "Text paint properties",
"type": "properties",
"fields": [
"text-color",
"text-opacity",
"text-halo-color",
"text-halo-width",
"text-halo-blur",
"text-translate",
"text-translate-anchor"
]
},
{
"title": "Icon paint properties",
"type": "properties",
"fields": [
"icon-color",
"icon-opacity",
"icon-halo-color",
"icon-halo-width",
"icon-halo-blur",
"icon-translate",
"icon-translate-anchor"
]
}
]
},
"raster": {
"groups": [
{
"title": "Paint properties",
"type": "properties",
"fields": [
"raster-opacity",
"raster-hue-rotate",
"raster-brightness-min",
"raster-brightness-max",
"raster-saturation",
"raster-contrast",
"raster-fade-duration",
"raster-resampling"
]
}
]
},
"hillshade": {
"groups": [
{
"title": "Paint properties",
"type": "properties",
"fields": [
"hillshade-illumination-direction",
"hillshade-illumination-anchor",
"hillshade-exaggeration",
"hillshade-shadow-color",
"hillshade-highlight-color",
"hillshade-accent-color"
]
}
]
},
"heatmap": {
"groups": [
{
"title": "Paint properties",
"type": "properties",
"fields": [
"heatmap-radius",
"heatmap-weight",
"heatmap-intensity",
"heatmap-opacity"
]
}
]
},
"invalid": {
"groups": []
}
}
+25 -19
View File
@@ -11,30 +11,12 @@
"url": "https://americanamap.org/style.json",
"thumbnail": "https://github.com/maplibre/maputnik/assets/649392/23fa75ad-63e6-43f5-8837-03cdb0428bac"
},
{
"id": "aws-hybrid",
"title": "AWS Hybrid",
"url": "https://maps.geo.eu-west-1.amazonaws.com/v2/styles/Hybrid/descriptor?key=v1.public.eyJqdGkiOiJiOTNkYjBlZi04OWUzLTQxMGUtODFhMC0zYjZjZjVmZWZmMDgifYtukap0NBaJpcrS6Vit9j03GJgK9Bn-RSu5UCe3jkdSql2kKp3IEgLPtyLssbmKUdVO11sXddjK3ZOZy8V6QG0olv0K_1tOxyMIe4DAO3IV6H4VzHWiaXlbSakGiEgFLuHBdcfLDeMotye7N6rSRxuZb0CN9ytH9VjLly6-NEBRZezO_qPQyvdTFdeZsARIpL0f9YVpxPxPVvUcAWYCk5LpaPseRCDPrY5SlCdA1ZKqUA4F9RzxSTxB73Fel_SoNDkCNaux1VposBu791-uUpDzUpr7leKckrPXrpZ2hwnFbafVxFV9vq4fLTpB5KoBksuLfGNIwAx1RLLxWuMhE4c.ZGQzZDY2OGQtMWQxMy00ZTEwLWIyZGUtOGVjYzUzMjU3OGE4&color-scheme=Light",
"thumbnail": "https://maputnik.s3.eu-west-1.amazonaws.com/thumbnails/aws-hybrid.jpg"
},
{
"id": "aws-standard",
"title": "AWS Standard",
"url": "https://maps.geo.eu-west-1.amazonaws.com/v2/styles/Standard/descriptor?key=v1.public.eyJqdGkiOiJiOTNkYjBlZi04OWUzLTQxMGUtODFhMC0zYjZjZjVmZWZmMDgifYtukap0NBaJpcrS6Vit9j03GJgK9Bn-RSu5UCe3jkdSql2kKp3IEgLPtyLssbmKUdVO11sXddjK3ZOZy8V6QG0olv0K_1tOxyMIe4DAO3IV6H4VzHWiaXlbSakGiEgFLuHBdcfLDeMotye7N6rSRxuZb0CN9ytH9VjLly6-NEBRZezO_qPQyvdTFdeZsARIpL0f9YVpxPxPVvUcAWYCk5LpaPseRCDPrY5SlCdA1ZKqUA4F9RzxSTxB73Fel_SoNDkCNaux1VposBu791-uUpDzUpr7leKckrPXrpZ2hwnFbafVxFV9vq4fLTpB5KoBksuLfGNIwAx1RLLxWuMhE4c.ZGQzZDY2OGQtMWQxMy00ZTEwLWIyZGUtOGVjYzUzMjU3OGE4&color-scheme=Light",
"thumbnail": "https://maputnik.s3.eu-west-1.amazonaws.com/thumbnails/aws-standard.jpg"
},
{
"id": "dark-matter",
"title": "Dark Matter",
"url": "https://cdn.jsdelivr.net/gh/openmaptiles/dark-matter-gl-style@v1.9/style.json",
"thumbnail": "https://maputnik.github.io/thumbnails/dark-matter.png"
},
{
"id": "locationiq-streets",
"title": "LocationIQ Streets",
"url": "https://tiles.locationiq.com/v3/streets/vector.json?key={key}",
"thumbnail": "https://static-assets.locationiq.com/maputnik/locationiq-streets.png"
},
{
"id": "maptiler-basic-gl-style",
"title": "MapTiler Basic",
@@ -47,6 +29,30 @@
"url": "https://cdn.jsdelivr.net/gh/openmaptiles/toner-gl-style@v1.0/style.json",
"thumbnail": "https://maputnik.github.io/thumbnails/toner.png"
},
{
"id": "os-zoomstack-light",
"title": "Zoomstack Light",
"url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/styles/open-zoomstack-light/style.json",
"thumbnail": "https://maputnik.github.io/thumbnails/os-zoomstack-light.png"
},
{
"id": "os-zoomstack-night",
"title": "Zoomstack Night",
"url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/styles/open-zoomstack-night/style.json",
"thumbnail": "https://maputnik.github.io/thumbnails/os-zoomstack-night.png"
},
{
"id": "os-zoomstack-outdoor",
"title": "Zoomstack Outdoor",
"url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/styles/open-zoomstack-outdoor/style.json",
"thumbnail": "https://maputnik.github.io/thumbnails/os-zoomstack-outdoor.png"
},
{
"id": "os-zoomstack-road",
"title": "Zoomstack Road",
"url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/styles/open-zoomstack-road/style.json",
"thumbnail": "https://maputnik.github.io/thumbnails/os-zoomstack-road.png"
},
{
"id": "osm-bright",
"title": "OSM Bright",
@@ -86,7 +92,7 @@
{
"id": "versatiles-colorful",
"title": "Versatiles Colorful",
"url": "https://tiles.versatiles.org/assets/styles/colorful/style.json",
"url": "https://tiles.versatiles.org/assets/styles/colorful.json",
"thumbnail": "https://github.com/maplibre/maputnik/assets/649392/6cd69818-c541-46e4-a920-65fb4f654931"
}
]

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