Compare commits

..

2 Commits

Author SHA1 Message Date
Yuri Astrakhan 9bd2feaa49 Merge 28f192266d into a03caed370 2025-02-25 14:38:28 +01:00
Yuri Astrakhan 28f192266d Group dependabot updates
Ensure dependabot creates updates in batches
2025-02-24 12:26:53 -05:00
88 changed files with 3506 additions and 3494 deletions
+9 -1
View File
@@ -9,8 +9,16 @@ updates:
directory: "/" # Location of package manifests directory: "/" # Location of package manifests
schedule: schedule:
interval: "daily" interval: "daily"
open-pull-requests-limit: 20
versioning-strategy: increase versioning-strategy: increase
groups:
all-actions-version-updates:
applies-to: version-updates
patterns:
- "*"
all-actions-security-updates:
applies-to: security-updates
patterns:
- "*"
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
schedule: schedule:
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
steps: steps:
- name: Dependabot metadata - name: Dependabot metadata
id: metadata id: metadata
uses: dependabot/fetch-metadata@v2.4.0 uses: dependabot/fetch-metadata@v2.3.0
with: with:
github-token: "${{ secrets.GITHUB_TOKEN }}" github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Approve Dependabot PRs - name: Approve Dependabot PRs
+23 -29
View File
@@ -7,7 +7,17 @@ on:
branches: [ main ] branches: [ main ]
jobs: 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: build-node:
name: "build on ${{ matrix.os }}" name: "build on ${{ matrix.os }}"
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
@@ -20,8 +30,8 @@ jobs:
os: [ ubuntu-latest, windows-latest, macos-latest ] os: [ ubuntu-latest, windows-latest, macos-latest ]
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- uses: actions/setup-node@v5 - uses: actions/setup-node@v4
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- run: npm ci - run: npm ci
@@ -37,8 +47,8 @@ jobs:
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }} if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- uses: actions/setup-node@v5 - uses: actions/setup-node@v4
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- run: npm ci - run: npm ci
@@ -51,7 +61,7 @@ jobs:
# Build and upload desktop CLI artifacts # Build and upload desktop CLI artifacts
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v6 uses: actions/setup-go@v5
with: with:
go-version: ^1.23.x go-version: ^1.23.x
cache-dependency-path: desktop/go.sum cache-dependency-path: desktop/go.sum
@@ -79,39 +89,23 @@ jobs:
path: ./desktop/bin/windows/ path: ./desktop/bin/windows/
e2e-tests: 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: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v4
- run: npm ci - run: npm ci
- name: Cypress run - name: Cypress run
uses: cypress-io/github-action@v6 uses: cypress-io/github-action@v6
with: with:
build: npm run build build: npm run build
start: npm run start start: npm run start
browser: chrome browser: ${{ matrix.browser }}
- 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
- name: Upload coverage reports to Codecov - name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v5
with: with:
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
+2 -2
View File
@@ -16,13 +16,13 @@ jobs:
run: run:
shell: bash shell: bash
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
ref: main ref: main
- name: Use Node.js from nvmrc - name: Use Node.js from nvmrc
uses: actions/setup-node@v5 uses: actions/setup-node@v4
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
+3 -3
View File
@@ -10,10 +10,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' }} if: ${{ github.event_name == 'push' }}
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- name: Use Node.js from nvmrc - name: Use Node.js from nvmrc
uses: actions/setup-node@v5 uses: actions/setup-node@v4
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
@@ -46,6 +46,6 @@ jobs:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- run: docker build -t ghcr.io/maplibre/maputnik:main . - run: docker build -t ghcr.io/maplibre/maputnik:main .
- run: docker push ghcr.io/maplibre/maputnik:main - run: docker push ghcr.io/maplibre/maputnik:main
+5 -5
View File
@@ -13,13 +13,13 @@ jobs:
run: run:
shell: bash shell: bash
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
ref: main ref: main
- name: Use Node.js from nvmrc - name: Use Node.js from nvmrc
uses: actions/setup-node@v5 uses: actions/setup-node@v4
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
@@ -39,19 +39,19 @@ jobs:
run: run:
shell: bash shell: bash
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
ref: main ref: main
- name: Use Node.js from nvmrc - name: Use Node.js from nvmrc
uses: actions/setup-node@v5 uses: actions/setup-node@v4
with: with:
node-version-file: ".nvmrc" node-version-file: ".nvmrc"
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
- name: Set up Go for desktop build - name: Set up Go for desktop build
uses: actions/setup-go@v6 uses: actions/setup-go@v5
with: with:
go-version: ^1.23.x go-version: ^1.23.x
cache-dependency-path: desktop/go.sum cache-dependency-path: desktop/go.sum
+1 -1
View File
@@ -6,7 +6,7 @@ ci:
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0 rev: v5.0.0
hooks: hooks:
- id: check-added-large-files - id: check-added-large-files
- id: check-executables-have-shebangs - id: check-executables-have-shebangs
-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.
+2 -22
View File
@@ -1,16 +1,6 @@
## main ## main
### ✨ Features and improvements ### ✨ 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 - Add german translation
- Use same version number for web and desktop versions - Use same version number for web and desktop versions
- Add scheme type options for vector/raster tile - Add scheme type options for vector/raster tile
@@ -20,22 +10,12 @@
- Upgrade to MapLibre LG JS v5 - Upgrade to MapLibre LG JS v5
- Upgrade Vite 6 and Cypress 14 ([#970](https://github.com/maplibre/maputnik/pull/970)) - Upgrade Vite 6 and Cypress 14 ([#970](https://github.com/maplibre/maputnik/pull/970))
- Upgrade OpenLayers from v6 to v10 - Upgrade OpenLayers from v6 to v10
- When loading a style into localStorage that causes a QuotaExceededError, purge localStorage and retry - _...Add new stuff here..._
- 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
### 🐞 Bug fixes ### 🐞 Bug fixes
- Fix incorrect handing of network error response (#944) - Fix incorrect handing of network error response (#944)
- Show an error when adding a layer with a duplicate ID - _...Add new stuff here..._
- Replace deprecated `ReactDOM.render` usage with `createRoot` and drop the
`DOMNodeRemoved` cleanup hack
## 2.1.1 ## 2.1.1
+10 -8
View File
@@ -1,14 +1,16 @@
FROM golang:1.23-alpine AS builder FROM node:18 as builder
WORKDIR /maputnik 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 # Build maputnik
COPY . . COPY . .
RUN npm ci RUN npx vite build
RUN CGO_ENABLED=1 GOOS=linux npm run build-linux
FROM alpine:latest #---------------------------------------------------------------------------
WORKDIR /app # Create a clean nginx-alpine slim image with just the build results
COPY --from=builder /maputnik/desktop/bin/linux ./ FROM nginx:alpine-slim
ENTRYPOINT ["/app/maputnik"]
COPY --from=builder /maputnik/dist /usr/share/nginx/html/
+2 -8
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. - In a Docker, run this command and browse to http://localhost:8888, Ctrl+C to stop the server.
```bash ```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 ## Documentation
The documentation can be found in the [Wiki](https://github.com/maplibre/maputnik/wiki). You are welcome to collaborate! The documentation can be found in the [Wiki](https://github.com/maplibre/maputnik/wiki). You are welcome to collaborate!
@@ -81,7 +75,7 @@ npm run sort-styles
## Tests ## Tests
For E2E testing we use [Cypress](https://www.cypress.io/) 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*: Now open a terminal and run the following using *chrome*:
-8
View File
@@ -8,7 +8,6 @@ export default defineConfig({
exclude: "cypress/**/*.*", exclude: "cypress/**/*.*",
}, },
}, },
e2e: { e2e: {
setupNodeEvents(on, config) { setupNodeEvents(on, config) {
// implement node event listeners here // implement node event listeners here
@@ -21,11 +20,4 @@ export default defineConfig({
openMode: 0, openMode: 0,
}, },
}, },
component: {
devServer: {
framework: "react",
bundler: "vite",
},
},
}); });
+1
View File
@@ -16,6 +16,7 @@ describe("history", () => {
it("undo/redo", () => { it("undo/redo", () => {
when.setStyle("geojson"); when.setStyle("geojson");
when.modal.open(); when.modal.open();
then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [] });
when.modal.fillLayers({ when.modal.fillLayers({
id: "step 1", id: "step 1",
+4 -89
View File
@@ -378,57 +378,8 @@ describe("layers", () => {
}); });
it("groups", () => { it("groups", () => {
when.modal.open(); // TODO
const id1 = when.modal.fillLayers({ // Click each of the layer groups.
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",
},
],
});
}); });
}); });
@@ -544,7 +495,9 @@ describe("layers", () => {
}); });
}); });
describe("layereditor jsonlint should error", ()=>{ describe("layereditor jsonlint should error", ()=>{
it("add", () => { it("add", () => {
const id = when.modal.fillLayers({ const id = when.modal.fillLayers({
type: "circle", type: "circle",
@@ -570,42 +523,4 @@ describe("layers", () => {
error.should('exist'); 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",
},
],
});
});
});
}); });
+12 -14
View File
@@ -8,10 +8,8 @@ const baseUrl = "http://localhost:8888/";
const styleFromWindow = (win: Window) => { const styleFromWindow = (win: Window) => {
const styleId = win.localStorage.getItem("maputnik:latest_style"); const styleId = win.localStorage.getItem("maputnik:latest_style");
const styleItemKey = `maputnik:style:${styleId}`; const styleItem = win.localStorage.getItem(`maputnik:style:${styleId}`);
const styleItem = win.localStorage.getItem(styleItemKey); const obj = JSON.parse(styleItem || "");
if (!styleItem) throw new Error("Could not get styleItem from localStorage");
const obj = JSON.parse(styleItem);
return obj; return obj;
}; };
@@ -94,8 +92,8 @@ export class MaputnikDriver {
public when = { public when = {
...this.helper.when, ...this.helper.when,
modal: this.modalDriver.when, modal: this.modalDriver.when,
doWithin: (selector: string, fn: () => void) => { within: (selector: string, fn: () => void) => {
this.helper.when.doWithin(fn, selector); this.helper.when.within(fn, selector);
}, },
tab: () => this.helper.get.element("body").tab(), tab: () => this.helper.get.element("body").tab(),
waitForExampleFileResponse: () => { waitForExampleFileResponse: () => {
@@ -110,25 +108,25 @@ export class MaputnikDriver {
styleProperties: "geojson" | "raster" | "both" | "layer" | "", styleProperties: "geojson" | "raster" | "both" | "layer" | "",
zoom?: number zoom?: number
) => { ) => {
const url = new URL(baseUrl); let url = "?debug";
switch (styleProperties) { switch (styleProperties) {
case "geojson": case "geojson":
url.searchParams.set("style", baseUrl + "geojson-style.json"); url += `&style=${baseUrl}geojson-style.json`;
break; break;
case "raster": case "raster":
url.searchParams.set("style", baseUrl + "raster-style.json"); url += `&style=${baseUrl}raster-style.json`;
break; break;
case "both": case "both":
url.searchParams.set("style", baseUrl + "geojson-raster-style.json"); url += `&style=${baseUrl}geojson-raster-style.json`;
break; break;
case "layer": case "layer":
url.searchParams.set("style", baseUrl + "example-layer-style.json"); url += `&style=${baseUrl}/example-layer-style.json`;
break; break;
} }
if (zoom) { 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) { if (styleProperties) {
this.helper.when.acceptConfirm(); this.helper.when.acceptConfirm();
} }
@@ -145,7 +143,7 @@ export class MaputnikDriver {
}, },
selectWithin: (selector: string, value: string) => { selectWithin: (selector: string, value: string) => {
this.when.doWithin(selector, () => { this.when.within(selector, () => {
this.helper.get.element("select").select(value); 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); this.helper.when.type("add-layer.layer-id.input", id);
if (layer) { if (layer) {
this.helper.when.doWithin(() => { this.helper.when.within(() => {
this.helper.get.element("input").clear().type(layer!); this.helper.get.element("input").type(layer!);
}, "add-layer.layer-source-block"); }, "add-layer.layer-source-block");
} }
this.helper.when.click("add-layer"); this.helper.when.click("add-layer");
-68
View File
@@ -224,18 +224,6 @@ describe("modals", () => {
).shouldInclude({ "maputnik:stadia_access_token": apiKey }); ).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", () => { it("style renderer", () => {
cy.on("uncaught:exception", () => false); // this is due to the fact that this is an invalid style for openlayers 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"); when.select("modal:settings.maputnik:renderer", "ol");
@@ -284,63 +272,7 @@ describe("modals", () => {
}); });
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", () => { describe("sources", () => {
it("toggle"); 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();
});
});
}); });
+10 -112
View File
@@ -1,114 +1,12 @@
{ {
"id": "test-style", "id": "test-style",
"version": 8, "version": 8,
"name": "Test Style", "name": "Test Style",
"metadata": { "metadata": {
"maputnik:renderer": "mlgljs", "maputnik:renderer": "mlgljs"
"data": [ },
0, "sources": {},
1, "glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf",
2, "sprites": "https://example.local/fonts/{fontstack}/{range}.pbf",
3, "layers": []
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": []
} }
-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') SOURCES := $(shell find $(SOURCEDIR) -name '*.go')
BINARY=maputnik BINARY=maputnik
VERSION := $(shell node -p "require('../package.json').version") 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) 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)/go-winres make --product-version=$(VERSION)
$(GOBIN)/gox -osarch "windows/amd64 linux/amd64 darwin/amd64" -output "bin/{{.OS}}/${BINARY}" $(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 winres/winres.json: winres/winres_template.json
sed 's/{{.Version}}/$(VERSION)/g' winres/winres_template.json > $@ sed 's/{{.Version}}/$(VERSION)/g' winres/winres_template.json > $@
+2 -1
View File
@@ -5,7 +5,8 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"github.com/GeertJohan/go.rice"
"github.com/GeertJohan/go.rice"
"github.com/gorilla/handlers" "github.com/gorilla/handlers"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/maputnik/desktop/filewatch" "github.com/maputnik/desktop/filewatch"
+1877 -1616
View File
File diff suppressed because it is too large Load Diff
+42 -43
View File
@@ -1,14 +1,13 @@
{ {
"name": "maputnik", "name": "maputnik",
"version": "3.0.0", "version": "2.1.1",
"description": "A MapLibre GL visual style editor", "description": "A MapLibre GL visual style editor",
"type": "module", "type": "module",
"main": "''", "main": "''",
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"build": "tsc && vite build --mode=production", "build": "tsc && vite build --base=/maputnik/",
"build-desktop": "tsc && vite build --mode=desktop && cd desktop && make", "build-desktop": "tsc && vite build --base=/ && cd desktop && make",
"build-linux": "tsc && vite build --mode=desktop && cd desktop && make bin/linux/maputnik",
"i18n:refresh": "i18next 'src/**/*.{ts,tsx,js,jsx}'", "i18n:refresh": "i18next 'src/**/*.{ts,tsx,js,jsx}'",
"lint": "eslint", "lint": "eslint",
"test": "cypress run", "test": "cypress run",
@@ -24,27 +23,23 @@
"license": "MIT", "license": "MIT",
"homepage": "https://github.com/maplibre/maputnik#readme", "homepage": "https://github.com/maplibre/maputnik#readme",
"dependencies": { "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", "@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-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/js": "^7.4.47",
"@mdi/react": "^1.6.1", "@mdi/react": "^1.6.1",
"@prantlf/jsonlint": "^16.0.0", "@prantlf/jsonlint": "^16.0.0",
"array-move": "^4.0.0", "array-move": "^4.0.0",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"codemirror": "^5.65.20", "codemirror": "^5.65.18",
"color": "^5.0.0", "color": "^5.0.0",
"detect-browser": "^5.3.0", "detect-browser": "^5.3.0",
"downshift": "^9.0.10",
"events": "^3.3.0", "events": "^3.3.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"i18next": "^25.5.2", "i18next": "^24.2.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.0.4",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"json-stringify-pretty-compact": "^4.0.0", "json-stringify-pretty-compact": "^4.0.0",
"json-to-ast": "^2.1.0", "json-to-ast": "^2.1.0",
@@ -55,23 +50,26 @@
"lodash.get": "^4.4.2", "lodash.get": "^4.4.2",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"maplibre-gl": "^5.7.1", "maplibre-gl": "^5.1.0",
"maputnik-design": "github:maputnik/design#172b06c", "maputnik-design": "github:maputnik/design#172b06c",
"ol": "^10.6.1", "ol": "^10.4.0",
"ol-mapbox-style": "^13.1.0", "ol-mapbox-style": "^12.4.0",
"pmtiles": "^4.3.0", "pmtiles": "^4.1.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-accessible-accordion": "^5.0.1", "react-accessible-accordion": "^5.0.0",
"react-aria-menubutton": "^7.0.3", "react-aria-menubutton": "^7.0.3",
"react-aria-modal": "^5.0.2", "react-aria-modal": "^5.0.2",
"react-autobind": "^1.0.6",
"react-autocomplete": "^1.8.1",
"react-collapse": "^5.1.1", "react-collapse": "^5.1.1",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-file-reader-input": "^2.0.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-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", "reconnecting-websocket": "^4.4.0",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"string-hash": "^1.1.3", "string-hash": "^1.1.3",
@@ -95,14 +93,14 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@cypress/code-coverage": "^3.14.6", "@cypress/code-coverage": "^3.13.11",
"@eslint/js": "^9.35.0", "@eslint/js": "^9.19.0",
"@istanbuljs/nyc-config-typescript": "^1.0.2", "@istanbuljs/nyc-config-typescript": "^1.0.2",
"@rollup/plugin-replace": "^6.0.2", "@rollup/plugin-replace": "^6.0.2",
"@shellygo/cypress-test-utils": "^6.0.1", "@shellygo/cypress-test-utils": "^4.1.12",
"@types/codemirror": "^5.60.16", "@types/codemirror": "^5.60.15",
"@types/color": "^4.2.0", "@types/color": "^4.2.0",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.17",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
"@types/json-to-ast": "^2.1.4", "@types/json-to-ast": "^2.1.4",
@@ -116,6 +114,7 @@
"@types/react": "^18.2.67", "@types/react": "^18.2.67",
"@types/react-aria-menubutton": "^6.2.14", "@types/react-aria-menubutton": "^6.2.14",
"@types/react-aria-modal": "^5.0.0", "@types/react-aria-modal": "^5.0.0",
"@types/react-autocomplete": "^1.8.11",
"@types/react-collapse": "^5.0.4", "@types/react-collapse": "^5.0.4",
"@types/react-color": "^3.0.13", "@types/react-color": "^3.0.13",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
@@ -123,28 +122,28 @@
"@types/react-icon-base": "^2.1.6", "@types/react-icon-base": "^2.1.6",
"@types/string-hash": "^1.1.3", "@types/string-hash": "^1.1.3",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@types/wicg-file-system-access": "^2023.10.6", "@types/wicg-file-system-access": "^2023.10.5",
"@vitejs/plugin-react": "^5.0.2", "@vitejs/plugin-react": "^4.3.4",
"cors": "^2.8.5", "cors": "^2.8.5",
"cypress": "^15.1.0", "cypress": "^14.0.2",
"cypress-plugin-tab": "^1.0.5", "cypress-plugin-tab": "^1.0.5",
"eslint": "^9.35.0", "eslint": "^9.19.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.18",
"i18next-parser": "^9.3.0", "i18next-parser": "^9.1.0",
"istanbul": "^0.4.5", "istanbul": "^0.4.5",
"istanbul-lib-coverage": "^3.2.2", "istanbul-lib-coverage": "^3.2.2",
"postcss": "^8.5.6", "postcss": "^8.5.1",
"react-hot-loader": "^4.13.1", "react-hot-loader": "^4.13.1",
"sass": "^1.92.1", "sass": "^1.85.1",
"stylelint": "^16.24.0", "stylelint": "^16.14.1",
"stylelint-config-recommended-scss": "^16.0.0", "stylelint-config-recommended-scss": "^14.1.0",
"stylelint-scss": "^6.12.1", "stylelint-scss": "^6.11.0",
"typescript": "^5.8.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.43.0", "typescript-eslint": "^8.25.0",
"uuid": "^13.0.0", "uuid": "^11.0.5",
"vite": "^7.1.5", "vite": "^6.2.0",
"vite-plugin-istanbul": "^7.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 React from 'react'
import cloneDeep from 'lodash.clonedeep' import cloneDeep from 'lodash.clonedeep'
import clamp from 'lodash.clamp' import clamp from 'lodash.clamp'
@@ -27,14 +29,17 @@ import ModalDebug from './ModalDebug'
import {downloadGlyphsMetadata, downloadSpriteMetadata} from '../libs/metadata' import {downloadGlyphsMetadata, downloadSpriteMetadata} from '../libs/metadata'
import style from '../libs/style' import style from '../libs/style'
import { initialStyleUrl, loadStyleUrl, removeStyleQuerystring } from '../libs/urlopen'
import { undoMessages, redoMessages } from '../libs/diffmessage' 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 { RevisionStore } from '../libs/revisions'
import LayerWatcher from '../libs/layerwatcher' import LayerWatcher from '../libs/layerwatcher'
import tokens from '../config/tokens.json' import tokens from '../config/tokens.json'
import isEqual from 'lodash.isequal' import isEqual from 'lodash.isequal'
import Debug from '../libs/debug'
import { SortEnd } from 'react-sortable-hoc';
import { MapOptions } from 'maplibre-gl'; 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. // Buffer must be defined globally for @maplibre/maplibre-gl-style-spec validate() function to succeed.
window.Buffer = buffer.Buffer; window.Buffer = buffer.Buffer;
@@ -43,7 +48,6 @@ function setFetchAccessToken(url: string, mapStyle: StyleSpecification) {
const matchesTilehosting = url.match(/\.tilehosting\.com/); const matchesTilehosting = url.match(/\.tilehosting\.com/);
const matchesMaptiler = url.match(/\.maptiler\.com/); const matchesMaptiler = url.match(/\.maptiler\.com/);
const matchesThunderforest = url.match(/\.thunderforest\.com/); const matchesThunderforest = url.match(/\.thunderforest\.com/);
const matchesLocationIQ = url.match(/\.locationiq\.com/);
if (matchesTilehosting || matchesMaptiler) { if (matchesTilehosting || matchesMaptiler) {
const accessToken = style.getAccessToken("openmaptiles", mapStyle, {allowFallback: true}) const accessToken = style.getAccessToken("openmaptiles", mapStyle, {allowFallback: true})
if (accessToken) { if (accessToken) {
@@ -56,12 +60,6 @@ function setFetchAccessToken(url: string, mapStyle: StyleSpecification) {
return url.replace('{key}', accessToken) return url.replace('{key}', accessToken)
} }
} }
else if (matchesLocationIQ) {
const accessToken = style.getAccessToken("locationiq", mapStyle, {allowFallback: true})
if (accessToken) {
return url.replace('{key}', accessToken)
}
}
else { else {
return url; return url;
} }
@@ -80,6 +78,12 @@ function updateRootSpec(spec: any, fieldName: string, newValues: any) {
} }
} }
type OnStyleChangedOpts = {
save?: boolean
addRevision?: boolean
initialLoad?: boolean
}
type MappedErrors = { type MappedErrors = {
message: string message: string
parsed?: { parsed?: {
@@ -95,11 +99,11 @@ type MappedErrors = {
type AppState = { type AppState = {
errors: MappedErrors[], errors: MappedErrors[],
infos: string[], infos: string[],
mapStyle: StyleSpecificationWithId, mapStyle: StyleSpecification & {id: string},
dirtyMapStyle?: StyleSpecification, dirtyMapStyle?: StyleSpecification,
selectedLayerIndex: number, selectedLayerIndex: number,
selectedLayerOriginalId?: string, selectedLayerOriginalId?: string,
sources: {[key: string]: SourceSpecification & {layers: string[]} }, sources: {[key: string]: SourceSpecification},
vectorLayers: {}, vectorLayers: {},
spec: any, spec: any,
mapView: { mapView: {
@@ -131,56 +135,26 @@ type AppState = {
export default class App extends React.Component<any, AppState> { export default class App extends React.Component<any, AppState> {
revisionStore: RevisionStore; revisionStore: RevisionStore;
styleStore: IStyleStore | null = null; styleStore: StyleStore | ApiStyleStore;
layerWatcher: LayerWatcher; layerWatcher: LayerWatcher;
constructor(props: any) { constructor(props: any) {
super(props) super(props)
autoBind(this);
this.revisionStore = new RevisionStore(); this.revisionStore = new RevisionStore()
this.configureKeyboardShortcuts(); const params = new URLSearchParams(window.location.search.substring(1))
let port = params.get("localport")
this.state = { if (port == null && (window.location.port !== "80" && window.location.port !== "443")) {
errors: [], port = window.location.port
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.styleStore = new ApiStyleStore({
this.layerWatcher = new LayerWatcher({ onLocalStyleChange: mapStyle => this.onStyleChanged(mapStyle, {save: false}),
onVectorLayersChange: v => this.setState({ vectorLayers: v }) port: port,
host: params.get("localhost")
}) })
}
configureKeyboardShortcuts = () => {
const shortcuts = [ const shortcuts = [
{ {
key: "?", 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) => { handleKeyPress = (e: KeyboardEvent) => {
@@ -275,8 +317,7 @@ export default class App extends React.Component<any, AppState> {
} }
} }
async componentDidMount() { componentDidMount() {
this.styleStore = await createStyleStore((mapStyle, opts) => this.onStyleChanged(mapStyle, opts));
window.addEventListener("keydown", this.handleKeyPress); window.addEventListener("keydown", this.handleKeyPress);
} }
@@ -284,8 +325,8 @@ export default class App extends React.Component<any, AppState> {
window.removeEventListener("keydown", this.handleKeyPress); window.removeEventListener("keydown", this.handleKeyPress);
} }
saveStyle(snapshotStyle: StyleSpecificationWithId) { saveStyle(snapshotStyle: StyleSpecification & {id: string}) {
this.styleStore?.save(snapshotStyle) this.styleStore.save(snapshotStyle)
} }
updateFonts(urlTemplate: string) { updateFonts(urlTemplate: string) {
@@ -326,7 +367,7 @@ export default class App extends React.Component<any, AppState> {
this.onStyleChanged(changedStyle) this.onStyleChanged(changedStyle)
} }
onStyleChanged = (newStyle: StyleSpecificationWithId, opts: OnStyleChangedOpts={}): void => { onStyleChanged = (newStyle: StyleSpecification & {id: string}, opts: OnStyleChangedOpts={}) => {
opts = { opts = {
save: true, save: true,
addRevision: true, addRevision: true,
@@ -357,6 +398,7 @@ export default class App extends React.Component<any, AppState> {
} }
const errors: ValidationError[] = validateStyleMin(newStyle) || []; const errors: ValidationError[] = validateStyleMin(newStyle) || [];
// The validate function doesn't give us errors for duplicate error with // The validate function doesn't give us errors for duplicate error with
// empty string for layer.id, manually deal with that here. // empty string for layer.id, manually deal with that here.
const layerErrors: (Error | ValidationError)[] = []; const layerErrors: (Error | ValidationError)[] = [];
@@ -435,7 +477,7 @@ export default class App extends React.Component<any, AppState> {
if (errors.length > 0) { if (errors.length > 0) {
dirtyMapStyle = cloneDeep(newStyle); dirtyMapStyle = cloneDeep(newStyle);
for (const error of errors) { errors.forEach(error => {
const {message} = error; const {message} = error;
if (message) { if (message) {
try { try {
@@ -445,10 +487,10 @@ export default class App extends React.Component<any, AppState> {
unset(dirtyMapStyle, unsetPath); unset(dirtyMapStyle, unsetPath);
} }
catch (err) { catch (err) {
console.warn(message + " " + err); console.warn(err);
} }
} }
} });
} }
if(newStyle.glyphs !== this.state.mapStyle.glyphs) { if(newStyle.glyphs !== this.state.mapStyle.glyphs) {
@@ -462,7 +504,7 @@ export default class App extends React.Component<any, AppState> {
this.revisionStore.addRevision(newStyle); this.revisionStore.addRevision(newStyle);
} }
if (opts.save) { if (opts.save) {
this.saveStyle(newStyle); this.saveStyle(newStyle as StyleSpecification & {id: string});
} }
this.setState({ 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 { oldIndex, newIndex } = move;
let layers = this.state.mapStyle.layers; let layers = this.state.mapStyle.layers;
oldIndex = clamp(oldIndex, 0, layers.length-1); oldIndex = clamp(oldIndex, 0, layers.length-1);
@@ -575,7 +617,7 @@ export default class App extends React.Component<any, AppState> {
}, this.setStateInUrl); }, this.setStateInUrl);
} }
setDefaultValues = (styleObj: StyleSpecificationWithId) => { setDefaultValues = (styleObj: StyleSpecification & {id: string}) => {
const metadata: {[key: string]: string} = styleObj.metadata || {} as any const metadata: {[key: string]: string} = styleObj.metadata || {} as any
if(metadata['maputnik:renderer'] === undefined) { if(metadata['maputnik:renderer'] === undefined) {
const changedStyle = { 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}); this.setState({fileHandle: fileHandle});
styleObj = this.setDefaultValues(styleObj) styleObj = this.setDefaultValues(styleObj)
this.onStyleChanged(styleObj) this.onStyleChanged(styleObj)
} }
async fetchSources() { fetchSources() {
const sourceList: {[key: string]: SourceSpecification & {layers: string[]}} = {}; const sourceList: {[key: string]: any} = {};
for(const key of Object.keys(this.state.mapStyle.sources)) {
const source = this.state.mapStyle.sources[key]; for(const [key, val] of Object.entries(this.state.mapStyle.sources)) {
if(source.type !== "vector" || !('url' in source)) { if(
sourceList[key] = this.state.sources[key] || {...this.state.mapStyle.sources[key]}; !Object.prototype.hasOwnProperty.call(this.state.sources, key) &&
if (sourceList[key].layers === undefined) { val.type === "vector" &&
sourceList[key].layers = []; Object.prototype.hasOwnProperty.call(val, "url")
} ) {
} else {
sourceList[key] = { sourceList[key] = {
type: source.type, type: val.type,
layers: [] layers: []
}; };
let url = source.url; let url = val.url;
try { try {
url = setFetchAccessToken(url!, this.state.mapStyle) url = setFetchAccessToken(url!, this.state.mapStyle)
@@ -625,28 +666,44 @@ export default class App extends React.Component<any, AppState> {
return; return;
} }
// Create new objects before setState
const sources = Object.assign({}, {
[key]: this.state.sources[key],
});
for(const layer of json.vector_layers) { 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://")) {
if (url!.startsWith("pmtiles://")) { (new PMTiles(url!.substr(10))).getTileJson("")
const json = await (new PMTiles(url!.substring(10))).getTileJson(""); .then(json => setVectorLayers(json))
setVectorLayers(json); .catch(err => {
} else { console.error("Failed to process sources for '%s'", url, err);
const response = await fetch(url!, { mode: 'cors' }); });
const json = await response.json(); } else {
setVectorLayers(json); fetch(url!, {
} mode: 'cors',
} catch(err) { })
console.error(`Failed to process source for url: '${url}', ${err}`); .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)) { if(!isEqual(this.state.sources, sourceList)) {
console.debug("Setting sources", sourceList); console.debug("Setting sources");
this.setState({ this.setState({
sources: sourceList sources: sourceList
}) })
@@ -823,8 +880,8 @@ export default class App extends React.Component<any, AppState> {
this.setModal(modalName, !this.state.isOpen[modalName]); this.setModal(modalName, !this.state.isOpen[modalName]);
} }
onSetFileHandle = (fileHandle: FileSystemFileHandle | null) => { onSetFileHandle(fileHandle: FileSystemFileHandle | null) {
this.setState({ fileHandle }); this.setState({fileHandle: fileHandle});
} }
onChangeOpenlayersDebug = (key: keyof AppState["openlayersDebugOptions"], value: boolean) => { onChangeOpenlayersDebug = (key: keyof AppState["openlayersDebugOptions"], value: boolean) => {
+26 -19
View File
@@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import PropTypes from 'prop-types'
import ScrollContainer from './ScrollContainer' import ScrollContainer from './ScrollContainer'
import { WithTranslation, withTranslation } from 'react-i18next'; import { WithTranslation, withTranslation } from 'react-i18next';
import { IconContext } from 'react-icons';
type AppLayoutInternalProps = { type AppLayoutInternalProps = {
toolbar: React.ReactElement toolbar: React.ReactElement
@@ -13,31 +13,38 @@ type AppLayoutInternalProps = {
} & WithTranslation; } & WithTranslation;
class AppLayoutInternal extends React.Component<AppLayoutInternalProps> { class AppLayoutInternal extends React.Component<AppLayoutInternalProps> {
static childContextTypes = {
reactIconBase: PropTypes.object
}
getChildContext() {
return {
reactIconBase: { size: 14 }
}
}
render() { render() {
document.body.dir = this.props.i18n.dir(); document.body.dir = this.props.i18n.dir();
return <IconContext.Provider value={{size: '14px'}}> return <div className="maputnik-layout">
<div className="maputnik-layout"> {this.props.toolbar}
{this.props.toolbar} <div className="maputnik-layout-main">
<div className="maputnik-layout-main"> <div className="maputnik-layout-list">
<div className="maputnik-layout-list"> {this.props.layerList}
{this.props.layerList}
</div>
<div className="maputnik-layout-drawer">
<ScrollContainer>
{this.props.layerEditor}
</ScrollContainer>
</div>
{this.props.map}
</div> </div>
{this.props.bottom && <div className="maputnik-layout-bottom"> <div className="maputnik-layout-drawer">
{this.props.bottom} <ScrollContainer>
{this.props.layerEditor}
</ScrollContainer>
</div> </div>
} {this.props.map}
{this.props.modals}
</div> </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 maputnikLogo from 'maputnik-design/logos/logo-color.svg?inline'
import { withTranslation, WithTranslation } from 'react-i18next'; import { withTranslation, WithTranslation } from 'react-i18next';
import { supportedLanguages } from '../i18n'; 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. // This is required because of <https://stackoverflow.com/a/49846426>, there isn't another way to detect support that I'm aware of.
const browser = detect(); const browser = detect();
@@ -94,9 +93,9 @@ export type MapState = "map" | "inspect" | "filter-achromatopsia" | "filter-deut
type AppToolbarInternalProps = { type AppToolbarInternalProps = {
mapStyle: object mapStyle: object
inspectModeEnabled: boolean inspectModeEnabled: boolean
onStyleChanged: OnStyleChangedCallback onStyleChanged(...args: unknown[]): unknown
// A new style has been uploaded // A new style has been uploaded
onStyleOpen: OnStyleChangedCallback onStyleOpen(...args: unknown[]): unknown
// A dict of source id's and the available source layers // A dict of source id's and the available source layers
sources: object sources: object
children?: React.ReactNode children?: React.ReactNode
+7 -8
View File
@@ -1,3 +1,4 @@
import React from 'react'
import InputArray, { FieldArrayProps as InputArrayProps } from './InputArray' import InputArray, { FieldArrayProps as InputArrayProps } from './InputArray'
import Fieldset from './Fieldset' import Fieldset from './Fieldset'
@@ -8,12 +9,10 @@ type FieldArrayProps = InputArrayProps & {
} }
}; };
const FieldArray: React.FC<FieldArrayProps> = (props) => { export default class FieldArray extends React.Component<FieldArrayProps> {
return ( render() {
<Fieldset label={props.label} fieldSpec={props.fieldSpec}> return <Fieldset label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputArray {...props} /> <InputArray {...this.props} />
</Fieldset> </Fieldset>
); }
}; }
export default FieldArray;
+7 -8
View File
@@ -1,3 +1,4 @@
import React from 'react'
import Block from './Block' import Block from './Block'
import InputAutocomplete, { InputAutocompleteProps } from './InputAutocomplete' import InputAutocomplete, { InputAutocompleteProps } from './InputAutocomplete'
@@ -7,12 +8,10 @@ type FieldAutocompleteProps = InputAutocompleteProps & {
}; };
const FieldAutocomplete: React.FC<FieldAutocompleteProps> = (props) => { export default class FieldAutocomplete extends React.Component<FieldAutocompleteProps> {
return ( render() {
<Block label={props.label}> return <Block label={this.props.label}>
<InputAutocomplete {...props} /> <InputAutocomplete {...this.props} />
</Block> </Block>
); }
}; }
export default FieldAutocomplete;
+7 -8
View File
@@ -1,3 +1,4 @@
import React from 'react'
import Block from './Block' import Block from './Block'
import InputCheckbox, {InputCheckboxProps} from './InputCheckbox' import InputCheckbox, {InputCheckboxProps} from './InputCheckbox'
@@ -7,12 +8,10 @@ type FieldCheckboxProps = InputCheckboxProps & {
}; };
const FieldCheckbox: React.FC<FieldCheckboxProps> = (props) => { export default class FieldCheckbox extends React.Component<FieldCheckboxProps> {
return ( render() {
<Block label={props.label}> return <Block label={this.props.label}>
<InputCheckbox {...props} /> <InputCheckbox {...this.props} />
</Block> </Block>
); }
}; }
export default FieldCheckbox;
+7 -8
View File
@@ -1,3 +1,4 @@
import React from 'react'
import Block from './Block' import Block from './Block'
import InputColor, {InputColorProps} from './InputColor' import InputColor, {InputColorProps} from './InputColor'
@@ -10,12 +11,10 @@ type FieldColorProps = InputColorProps & {
}; };
const FieldColor: React.FC<FieldColorProps> = (props) => { export default class FieldColor extends React.Component<FieldColorProps> {
return ( render() {
<Block label={props.label} fieldSpec={props.fieldSpec}> return <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputColor {...props} /> <InputColor {...this.props} />
</Block> </Block>
); }
}; }
export default FieldColor;
+12 -14
View File
@@ -10,31 +10,29 @@ type FieldCommentInternalProps = {
error: {message: string} error: {message: string}
} & WithTranslation; } & WithTranslation;
const FieldCommentInternal: React.FC<FieldCommentInternalProps> = (props) => { class FieldCommentInternal extends React.Component<FieldCommentInternalProps> {
const t = props.t; render() {
const fieldSpec = { const t = this.props.t;
doc: t( const fieldSpec = {
"Comments for the current layer. This is non-standard and not in the spec." doc: t("Comments for the current layer. This is non-standard and not in the spec."),
), };
};
return ( return <Block
<Block
label={t("Comments")} label={t("Comments")}
fieldSpec={fieldSpec} fieldSpec={fieldSpec}
data-wd-key="layer-comment" data-wd-key="layer-comment"
error={props.error} error={this.props.error}
> >
<InputString <InputString
multi={true} multi={true}
value={props.value} value={this.props.value}
onChange={props.onChange} onChange={this.props.onChange}
default={t("Comment...")} default={t("Comment...")}
data-wd-key="layer-comment.input" data-wd-key="layer-comment.input"
/> />
</Block> </Block>
); }
}; }
const FieldComment = withTranslation()(FieldCommentInternal); const FieldComment = withTranslation()(FieldCommentInternal);
export default FieldComment; export default FieldComment;
+40 -28
View File
@@ -9,45 +9,57 @@ type FieldDocLabelProps = {
onToggleDoc?(...args: unknown[]): unknown onToggleDoc?(...args: unknown[]): unknown
}; };
type FieldDocLabelState = {
open: boolean
};
const FieldDocLabel: React.FC<FieldDocLabelProps> = (props) => { export default class FieldDocLabel extends React.Component<FieldDocLabelProps, FieldDocLabelState> {
const [open, setOpen] = React.useState(false); constructor (props: FieldDocLabelProps) {
super(props);
const onToggleDoc = (state: boolean) => { this.state = {
setOpen(state); open: false,
if (props.onToggleDoc) {
props.onToggleDoc(state);
} }
}; }
const { label, fieldSpec } = props; onToggleDoc = (open: boolean) => {
const { doc } = fieldSpec || {}; this.setState({
open,
}, () => {
if (this.props.onToggleDoc) {
this.props.onToggleDoc(this.state.open);
}
});
}
if (doc) { render() {
return ( const {label, fieldSpec} = this.props;
<label className="maputnik-doc-wrapper"> const {doc} = fieldSpec || {};
if (doc) {
return <label className="maputnik-doc-wrapper">
<div className="maputnik-doc-target"> <div className="maputnik-doc-target">
{label} {label}
{'\xa0'} {'\xa0'}
<button <button
aria-label={open ? 'close property documentation' : 'open property documentation'} aria-label={this.state.open ? "close property documentation" : "open property documentation"}
className={`maputnik-doc-button maputnik-doc-button--${open ? 'open' : 'closed'}`} className={`maputnik-doc-button maputnik-doc-button--${this.state.open ? 'open' : 'closed'}`}
onClick={() => onToggleDoc(!open)} onClick={() => this.onToggleDoc(!this.state.open)}
data-wd-key={'field-doc-button-' + label} data-wd-key={'field-doc-button-'+label}
> >
{open ? <MdHighlightOff /> : <MdInfoOutline />} {this.state.open ? <MdHighlightOff /> : <MdInfoOutline />}
</button> </button>
</div> </div>
</label> </label>
); }
} else if (label) { else if (label) {
return ( return <label className="maputnik-doc-wrapper">
<label className="maputnik-doc-wrapper"> <div className="maputnik-doc-target">
<div className="maputnik-doc-target">{label}</div> {label}
</div>
</label> </label>
); }
else {
<div />
}
} }
return <div />; }
};
export default FieldDocLabel;
+7 -8
View File
@@ -1,3 +1,4 @@
import React from 'react'
import InputDynamicArray, {FieldDynamicArrayProps as InputDynamicArrayProps} from './InputDynamicArray' import InputDynamicArray, {FieldDynamicArrayProps as InputDynamicArrayProps} from './InputDynamicArray'
import Fieldset from './Fieldset' import Fieldset from './Fieldset'
@@ -5,12 +6,10 @@ type FieldDynamicArrayProps = InputDynamicArrayProps & {
name?: string name?: string
}; };
const FieldDynamicArray: React.FC<FieldDynamicArrayProps> = (props) => { export default class FieldDynamicArray extends React.Component<FieldDynamicArrayProps> {
return ( render() {
<Fieldset label={props.label}> return <Fieldset label={this.props.label}>
<InputDynamicArray {...props} /> <InputDynamicArray {...this.props} />
</Fieldset> </Fieldset>
); }
}; }
export default FieldDynamicArray;
+7 -8
View File
@@ -1,3 +1,4 @@
import React from 'react'
import InputEnum, {InputEnumProps} from './InputEnum' import InputEnum, {InputEnumProps} from './InputEnum'
import Fieldset from './Fieldset'; import Fieldset from './Fieldset';
@@ -10,12 +11,10 @@ type FieldEnumProps = InputEnumProps & {
}; };
const FieldEnum: React.FC<FieldEnumProps> = (props) => { export default class FieldEnum extends React.Component<FieldEnumProps> {
return ( render() {
<Fieldset label={props.label} fieldSpec={props.fieldSpec}> return <Fieldset label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputEnum {...props} /> <InputEnum {...this.props} />
</Fieldset> </Fieldset>
); }
}; }
export default FieldEnum;
+247 -219
View File
@@ -111,268 +111,296 @@ type FieldFunctionProps = {
value?: any value?: any
}; };
type FieldFunctionState = {
dataType: string
isEditing: boolean
}
/** Supports displaying spec field for zoom function objects /** Supports displaying spec field for zoom function objects
* https://www.mapbox.com/mapbox-gl-style-spec/#types-function-zoom-property * https://www.mapbox.com/mapbox-gl-style-spec/#types-function-zoom-property
*/ */
const FieldFunction: React.FC<FieldFunctionProps> = (props) => { export default class FieldFunction extends React.Component<FieldFunctionProps, FieldFunctionState> {
const [dataType, setDataType] = React.useState( constructor (props: FieldFunctionProps) {
getDataType(props.value, props.fieldSpec) super(props);
); this.state = {
const [isEditing, setIsEditing] = React.useState(false); dataType: getDataType(props.value, props.fieldSpec),
isEditing: false,
React.useEffect(() => {
if (!isEditing) {
setDataType(getDataType(props.value, props.fieldSpec));
} }
}, [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) { if (fieldSpec.expression.interpolated) {
return 'exponential'; return "exponential"
} }
if (fieldSpec.type === 'number') { if (fieldSpec.type === "number") {
return 'interval'; return "interval"
} }
return 'categorical'; return "categorical"
}; }
const addStop = () => { addStop = () => {
const stops = props.value.stops.slice(0); const stops = this.props.value.stops.slice(0)
const lastStop = stops[stops.length - 1]; const lastStop = stops[stops.length - 1]
if (typeof lastStop[0] === 'object') { if (typeof lastStop[0] === "object") {
stops.push([ stops.push([
{ zoom: lastStop[0].zoom + 1, value: lastStop[0].value }, {zoom: lastStop[0].zoom + 1, value: lastStop[0].value},
lastStop[1], lastStop[1]
]); ])
} else { }
stops.push([lastStop[0] + 1, lastStop[1]]); else {
stops.push([lastStop[0] + 1, lastStop[1]])
} }
const changedValue = { const changedValue = {
...props.value, ...this.props.value,
stops: stops, 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 = () => { deleteExpression = () => {
const { value } = props; const {fieldSpec, fieldName} = this.props;
this.props.onChange(fieldName, fieldSpec.default);
this.setState({
dataType: "value",
});
}
let zoomFunc: any; deleteStop = (stopIdx: number) => {
if (typeof value === 'object') { 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) { if (value.stops) {
zoomFunc = { zoomFunc = {
base: value.base, base: value.base,
stops: value.stops.map((stop: Stop) => { stops: value.stops.map((stop: Stop) => {
return [stop[0].zoom, stop[1] || findDefaultFromSpec(props.fieldSpec)]; return [stop[0].zoom, stop[1] || findDefaultFromSpec(this.props.fieldSpec)];
}), })
}; }
} else { }
else {
zoomFunc = { zoomFunc = {
base: value.base, base: value.base,
stops: [ stops: [
[6, findDefaultFromSpec(props.fieldSpec)], [6, findDefaultFromSpec(this.props.fieldSpec)],
[10, findDefaultFromSpec(props.fieldSpec)], [10, findDefaultFromSpec(this.props.fieldSpec)]
], ]
}; }
} }
} else { }
else {
zoomFunc = { zoomFunc = {
stops: [ stops: [
[6, value || findDefaultFromSpec(props.fieldSpec)], [6, value || findDefaultFromSpec(this.props.fieldSpec)],
[10, value || findDefaultFromSpec(props.fieldSpec)], [10, value || findDefaultFromSpec(this.props.fieldSpec)]
], ]
}; }
} }
props.onChange(props.fieldName, zoomFunc); this.props.onChange(this.props.fieldName, zoomFunc)
}; }
const undoExpression = () => { undoExpression = () => {
const { value, fieldName } = props; const {value, fieldName} = this.props;
if (isGetExpression(value)) { if (isGetExpression(value)) {
props.onChange(fieldName, { this.props.onChange(fieldName, {
type: 'identity', "type": "identity",
property: value[1], "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 = () => { canUndo = () => {
const { value, fieldSpec } = props; const {value, fieldSpec} = this.props;
return ( return (
isGetExpression(value) || isGetExpression(value) ||
isLiteralExpression(value) || isLiteralExpression(value) ||
isPrimative(value) || isPrimative(value) ||
(Array.isArray(value) && fieldSpec.type === 'array') (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}
/>
); );
} }
return ( makeExpression = () => {
<div className={propClass} data-wd-key={'spec-field-container:' + props.fieldName}> 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} {specField}
</div> </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 latest from '@maplibre/maplibre-gl-style-spec/dist/latest.json'
import Block from './Block' import Block from './Block'
@@ -10,19 +11,18 @@ type FieldIdProps = {
error?: {message: string} error?: {message: string}
}; };
const FieldId: React.FC<FieldIdProps> = (props) => { export default class FieldId extends React.Component<FieldIdProps> {
return ( render() {
<Block label="ID" fieldSpec={latest.layer.id} return <Block label="ID" fieldSpec={latest.layer.id}
data-wd-key={props.wdKey}
error={props.error} data-wd-key={this.props.wdKey}
error={this.props.error}
> >
<InputString <InputString
value={props.value} value={this.props.value}
onInput={props.onChange} onInput={this.props.onChange}
data-wd-key={props.wdKey + ".input"} data-wd-key={this.props.wdKey + ".input"}
/> />
</Block> </Block>
); }
}; }
export default FieldId;
+6 -5
View File
@@ -1,11 +1,12 @@
import React from 'react'
import InputJson, {InputJsonProps} from './InputJson' import InputJson, {InputJsonProps} from './InputJson'
type FieldJsonProps = InputJsonProps & {}; type FieldJsonProps = InputJsonProps & {};
const FieldJson: React.FC<FieldJsonProps> = (props) => { export default class FieldJson extends React.Component<FieldJsonProps> {
return <InputJson {...props} />; render() {
}; return <InputJson {...this.props} />
}
export default FieldJson; }
+9 -9
View File
@@ -11,25 +11,25 @@ type FieldMaxZoomInternalProps = {
error?: {message: string} error?: {message: string}
} & WithTranslation; } & WithTranslation;
const FieldMaxZoomInternal: React.FC<FieldMaxZoomInternalProps> = (props) => { class FieldMaxZoomInternal extends React.Component<FieldMaxZoomInternalProps> {
const t = props.t; render() {
return ( const t = this.props.t;
<Block label={t('Max Zoom')} fieldSpec={latest.layer.maxzoom} return <Block label={t("Max Zoom")} fieldSpec={latest.layer.maxzoom}
error={props.error} error={this.props.error}
data-wd-key="max-zoom" data-wd-key="max-zoom"
> >
<InputNumber <InputNumber
allowRange={true} allowRange={true}
value={props.value} value={this.props.value}
onChange={props.onChange} onChange={this.props.onChange}
min={latest.layer.maxzoom.minimum} min={latest.layer.maxzoom.minimum}
max={latest.layer.maxzoom.maximum} max={latest.layer.maxzoom.maximum}
default={latest.layer.maxzoom.maximum} default={latest.layer.maxzoom.maximum}
data-wd-key="max-zoom.input" data-wd-key="max-zoom.input"
/> />
</Block> </Block>
); }
}; }
const FieldMaxZoom = withTranslation()(FieldMaxZoomInternal); const FieldMaxZoom = withTranslation()(FieldMaxZoomInternal);
export default FieldMaxZoom; export default FieldMaxZoom;
+9 -9
View File
@@ -11,25 +11,25 @@ type FieldMinZoomInternalProps = {
error?: {message: string} error?: {message: string}
} & WithTranslation; } & WithTranslation;
const FieldMinZoomInternal: React.FC<FieldMinZoomInternalProps> = (props) => { class FieldMinZoomInternal extends React.Component<FieldMinZoomInternalProps> {
const t = props.t; render() {
return ( const t = this.props.t;
<Block label={t('Min Zoom')} fieldSpec={latest.layer.minzoom} return <Block label={t("Min Zoom")} fieldSpec={latest.layer.minzoom}
error={props.error} error={this.props.error}
data-wd-key="min-zoom" data-wd-key="min-zoom"
> >
<InputNumber <InputNumber
allowRange={true} allowRange={true}
value={props.value} value={this.props.value}
onChange={props.onChange} onChange={this.props.onChange}
min={latest.layer.minzoom.minimum} min={latest.layer.minzoom.minimum}
max={latest.layer.minzoom.maximum} max={latest.layer.minzoom.maximum}
default={latest.layer.minzoom.minimum} default={latest.layer.minzoom.minimum}
data-wd-key='min-zoom.input' data-wd-key='min-zoom.input'
/> />
</Block> </Block>
); }
}; }
const FieldMinZoom = withTranslation()(FieldMinZoomInternal); const FieldMinZoom = withTranslation()(FieldMinZoomInternal);
export default FieldMinZoom; export default FieldMinZoom;
+7 -8
View File
@@ -1,3 +1,4 @@
import React from 'react'
import InputMultiInput, {InputMultiInputProps} from './InputMultiInput' import InputMultiInput, {InputMultiInputProps} from './InputMultiInput'
import Fieldset from './Fieldset' import Fieldset from './Fieldset'
@@ -7,12 +8,10 @@ type FieldMultiInputProps = InputMultiInputProps & {
}; };
const FieldMultiInput: React.FC<FieldMultiInputProps> = (props) => { export default class FieldMultiInput extends React.Component<FieldMultiInputProps> {
return ( render() {
<Fieldset label={props.label}> return <Fieldset label={this.props.label}>
<InputMultiInput {...props} /> <InputMultiInput {...this.props} />
</Fieldset> </Fieldset>
); }
}; }
export default FieldMultiInput;
+7 -8
View File
@@ -1,3 +1,4 @@
import React from 'react'
import InputNumber, {InputNumberProps} from './InputNumber' import InputNumber, {InputNumberProps} from './InputNumber'
import Block from './Block' import Block from './Block'
@@ -10,12 +11,10 @@ type FieldNumberProps = InputNumberProps & {
}; };
const FieldNumber: React.FC<FieldNumberProps> = (props) => { export default class FieldNumber extends React.Component<FieldNumberProps> {
return ( render() {
<Block label={props.label} fieldSpec={props.fieldSpec}> return <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputNumber {...props} /> <InputNumber {...this.props} />
</Block> </Block>
); }
}; }
export default FieldNumber;
+7 -8
View File
@@ -1,3 +1,4 @@
import React from 'react'
import Block from './Block' import Block from './Block'
import InputSelect, {InputSelectProps} from './InputSelect' import InputSelect, {InputSelectProps} from './InputSelect'
@@ -10,12 +11,10 @@ type FieldSelectProps = InputSelectProps & {
}; };
const FieldSelect: React.FC<FieldSelectProps> = (props) => { export default class FieldSelect extends React.Component<FieldSelectProps> {
return ( render() {
<Block label={props.label} fieldSpec={props.fieldSpec}> return <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputSelect {...props} /> <InputSelect {...this.props}/>
</Block> </Block>
); }
}; }
export default FieldSelect;
+17 -19
View File
@@ -13,30 +13,28 @@ type FieldSourceInternalProps = {
error?: {message: string} error?: {message: string}
} & WithTranslation; } & WithTranslation;
const FieldSourceInternal: React.FC<FieldSourceInternalProps> = ({ class FieldSourceInternal extends React.Component<FieldSourceInternalProps> {
onChange = () => {}, static defaultProps = {
sourceIds = [], onChange: () => {},
wdKey, sourceIds: [],
value, }
error,
t render() {
}) => { const t = this.props.t;
return ( return <Block
<Block label={t("Source")}
label={t('Source')}
fieldSpec={latest.layer.source} fieldSpec={latest.layer.source}
error={error} error={this.props.error}
data-wd-key={wdKey} data-wd-key={this.props.wdKey}
> >
<InputAutocomplete <InputAutocomplete
value={value} value={this.props.value}
onChange={onChange} onChange={this.props.onChange}
options={sourceIds?.map((src) => [src, src])} options={this.props.sourceIds?.map(src => [src, src])}
/> />
</Block> </Block>
); }
}; }
const FieldSource = withTranslation()(FieldSourceInternal); const FieldSource = withTranslation()(FieldSourceInternal);
export default FieldSource; export default FieldSource;
+19 -16
View File
@@ -9,31 +9,34 @@ type FieldSourceLayerInternalProps = {
value?: string value?: string
onChange?(...args: unknown[]): unknown onChange?(...args: unknown[]): unknown
sourceLayerIds?: unknown[] sourceLayerIds?: unknown[]
isFixed?: boolean
error?: {message: string} error?: {message: string}
} & WithTranslation; } & WithTranslation;
const FieldSourceLayerInternal: React.FC<FieldSourceLayerInternalProps> = ({ class FieldSourceLayerInternal extends React.Component<FieldSourceLayerInternalProps> {
onChange = () => {}, static defaultProps = {
sourceLayerIds = [], onChange: () => {},
value, sourceLayerIds: [],
error, isFixed: false
t }
}) => {
return ( render() {
<Block const t = this.props.t;
label={t('Source Layer')} return <Block
label={t("Source Layer")}
fieldSpec={latest.layer['source-layer']} fieldSpec={latest.layer['source-layer']}
data-wd-key="layer-source-layer" data-wd-key="layer-source-layer"
error={error} error={this.props.error}
> >
<InputAutocomplete <InputAutocomplete
value={value} keepMenuWithinWindowBounds={!!this.props.isFixed}
onChange={onChange} value={this.props.value}
options={sourceLayerIds?.map((l) => [l, l])} onChange={this.props.onChange}
options={this.props.sourceLayerIds?.map(l => [l, l])}
/> />
</Block> </Block>
); }
}; }
const FieldSourceLayer = withTranslation()(FieldSourceLayerInternal); const FieldSourceLayer = withTranslation()(FieldSourceLayerInternal);
export default FieldSourceLayer; export default FieldSourceLayer;
+7 -8
View File
@@ -1,3 +1,4 @@
import React from 'react'
import Block from './Block' import Block from './Block'
import InputString, {InputStringProps} from './InputString' import InputString, {InputStringProps} from './InputString'
@@ -9,12 +10,10 @@ type FieldStringProps = InputStringProps & {
} }
}; };
const FieldString: React.FC<FieldStringProps> = (props) => { export default class FieldString extends React.Component<FieldStringProps> {
return ( render() {
<Block label={props.label} fieldSpec={props.fieldSpec}> return <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
<InputString {...props} /> <InputString {...this.props} />
</Block> </Block>
); }
}; }
export default FieldString;
+37 -27
View File
@@ -1,10 +1,10 @@
import React from 'react' 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 Block from './Block'
import InputSelect from './InputSelect' import InputSelect from './InputSelect'
import InputString from './InputString' import InputString from './InputString'
import { WithTranslation, withTranslation } from 'react-i18next'; import { WithTranslation, withTranslation } from 'react-i18next';
import { startCase } from 'lodash'
type FieldTypeInternalProps = { type FieldTypeInternalProps = {
value: string value: string
@@ -14,34 +14,44 @@ type FieldTypeInternalProps = {
disabled?: boolean disabled?: boolean
} & WithTranslation; } & WithTranslation;
const FieldTypeInternal: React.FC<FieldTypeInternalProps> = ({ class FieldTypeInternal extends React.Component<FieldTypeInternalProps> {
t, static defaultProps = {
value, disabled: false,
wdKey, }
onChange,
error, render() {
disabled = false const t = this.props.t;
}) => { return <Block label={t("Type")} fieldSpec={latest.layer.type}
const layerstypes: [string, string][] = Object.keys(v8.layer.type.values || {}).map(v => [v, startCase(v.replace(/-/g, ' '))]); data-wd-key={this.props.wdKey}
return ( error={this.props.error}
<Block label={t('Type')} fieldSpec={v8.layer.type}
data-wd-key={wdKey}
error={error}
> >
{disabled && ( {this.props.disabled &&
<InputString value={value} disabled={true} /> <InputString
)} value={this.props.value}
{!disabled && ( disabled={true}
<InputSelect
options={layerstypes}
onChange={onChange}
value={value}
data-wd-key={wdKey + '.select'}
/> />
)} }
{!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> </Block>
); }
}; }
const FieldType = withTranslation()(FieldTypeInternal); const FieldType = withTranslation()(FieldTypeInternal);
export default FieldType; export default FieldType;
+10 -9
View File
@@ -1,3 +1,4 @@
import React from 'react'
import InputUrl, {FieldUrlProps as InputUrlProps} from './InputUrl' import InputUrl, {FieldUrlProps as InputUrlProps} from './InputUrl'
import Block from './Block' import Block from './Block'
@@ -10,12 +11,12 @@ type FieldUrlProps = InputUrlProps & {
}; };
const FieldUrl: React.FC<FieldUrlProps> = (props) => { export default class FieldUrl extends React.Component<FieldUrlProps> {
return ( render () {
<Block label={props.label} fieldSpec={props.fieldSpec}> return (
<InputUrl {...props} /> <Block label={this.props.label} fieldSpec={this.props.fieldSpec}>
</Block> <InputUrl {...this.props} />
); </Block>
}; );
}
export default FieldUrl; }
+52 -35
View File
@@ -9,40 +9,57 @@ type FieldsetProps = PropsWithChildren & {
action?: ReactElement, action?: ReactElement,
}; };
type FieldsetState = {
const Fieldset: React.FC<FieldsetProps> = (props) => { showDoc: boolean
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>
);
}; };
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>
}
}
+2 -3
View File
@@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import {mdiTableRowPlusAfter} from '@mdi/js'; import {mdiTableRowPlusAfter} from '@mdi/js';
import {isEqual} from 'lodash'; 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 {latest, migrate, convertFilter} from '@maplibre/maplibre-gl-style-spec'
import {mdiFunctionVariant} from '@mdi/js'; import {mdiFunctionVariant} from '@mdi/js';
@@ -14,7 +14,6 @@ import InputButton from './InputButton'
import Doc from './Doc' import Doc from './Doc'
import ExpressionProperty from './_ExpressionProperty'; import ExpressionProperty from './_ExpressionProperty';
import { WithTranslation, withTranslation } from 'react-i18next'; import { WithTranslation, withTranslation } from 'react-i18next';
import type { StyleSpecificationWithId } from '../libs/definitions';
function combiningFilter(props: FilterEditorInternalProps): LegacyFilterSpecification | ExpressionSpecification { 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; 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 { return {
"id": "tmp", "id": "tmp",
"version": 8, "version": 8,
+4 -4
View File
@@ -11,18 +11,18 @@ class FilterEditorBlockInternal extends React.Component<FilterEditorBlockInterna
render() { render() {
const t = this.props.t; const t = this.props.t;
return <div className="maputnik-filter-editor-block"> 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"> <div className="maputnik-filter-editor-block-action">
<InputButton <InputButton
className="maputnik-icon-button" className="maputnik-delete-filter"
onClick={this.props.onDelete} onClick={this.props.onDelete}
title={t("Delete filter block")} title={t("Delete filter block")}
> >
<MdDelete /> <MdDelete />
</InputButton> </InputButton>
</div> </div>
<div className="maputnik-filter-editor-block-content">
{this.props.children}
</div>
</div> </div>
} }
} }
-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');
});
});
+80 -96
View File
@@ -1,116 +1,100 @@
import React from 'react' import React from 'react'
import classnames from 'classnames' import classnames from 'classnames'
import {useCombobox} from 'downshift' import Autocomplete from 'react-autocomplete'
const MAX_HEIGHT = 140
const MAX_HEIGHT = 140;
export type InputAutocompleteProps = { export type InputAutocompleteProps = {
value?: string value?: string
options?: any[] options: any[]
onChange?(value: string | undefined): unknown onChange(value: string | undefined): unknown
keepMenuWithinWindowBounds?: boolean
'aria-label'?: string 'aria-label'?: string
}; };
export default function InputAutocomplete({ export default class InputAutocomplete extends React.Component<InputAutocompleteProps> {
value, state = {
options = [], maxHeight: MAX_HEIGHT
onChange = () => {}, }
'aria-label': ariaLabel,
}: InputAutocompleteProps) {
const [input, setInput] = React.useState(value || '')
const menuRef = React.useRef<HTMLDivElement>(null)
const [maxHeight, setMaxHeight] = React.useState(MAX_HEIGHT)
const filteredItems = React.useMemo(() => { autocompleteMenuEl: HTMLDivElement | null = null;
const lv = input.toLowerCase()
return options.filter((item) => item[0].toLowerCase().includes(lv))
}, [options, input])
const calcMaxHeight = React.useCallback(() => { static defaultProps = {
if (menuRef.current) { onChange: () => {},
const space = window.innerHeight - menuRef.current.getBoundingClientRect().top options: [],
setMaxHeight(Math.min(space, MAX_HEIGHT)) }
}
}, [])
const { calcMaxHeight() {
isOpen, if(this.props.keepMenuWithinWindowBounds) {
getMenuProps, const maxHeight = window.innerHeight - this.autocompleteMenuEl!.getBoundingClientRect().top;
getInputProps, const limitedMaxHeight = Math.min(maxHeight, MAX_HEIGHT);
getItemProps,
highlightedIndex, if(limitedMaxHeight != this.state.maxHeight) {
openMenu, this.setState({
} = useCombobox({ maxHeight: limitedMaxHeight
items: filteredItems, })
inputValue: input,
itemToString: (item) => (item ? item[0] : ''),
stateReducer: (_state, action) => {
if (action.type === useCombobox.stateChangeTypes.InputClick) {
return {...action.changes, isOpen: true}
} }
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(() => { componentDidMount() {
window.addEventListener('resize', calcMaxHeight) this.calcMaxHeight();
return () => window.removeEventListener('resize', calcMaxHeight) }
}, [calcMaxHeight])
React.useEffect(() => { componentDidUpdate() {
setInput(value || '') this.calcMaxHeight();
}, [value]) }
return ( onChange(v: string) {
<div className="maputnik-autocomplete"> this.props.onChange(v === "" ? undefined : v);
<input }
{...getInputProps({
'aria-label': ariaLabel, render() {
className: 'maputnik-string', return <div
spellCheck: false, ref={(el) => {
onFocus: () => openMenu(), 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> </div>
) }
} }
+1 -1
View File
@@ -20,7 +20,7 @@ export default class InputMultiInput extends React.Component<InputMultiInputProp
const radios = options.map(([val, label])=> { const radios = options.map(([val, label])=> {
return <label return <label
key={val} 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" <input type="radio"
name={this.props.name} name={this.props.name}
+88 -142
View File
@@ -1,10 +1,9 @@
import React, {type JSX} from 'react' import React, {type JSX} from 'react'
import PropTypes from 'prop-types'
import { Wrapper, Button, Menu, MenuItem } from 'react-aria-menubutton' import { Wrapper, Button, Menu, MenuItem } from 'react-aria-menubutton'
import {Accordion} from 'react-accessible-accordion'; import {Accordion} from 'react-accessible-accordion';
import {MdMoreVert} from 'react-icons/md' import {MdMoreVert} from 'react-icons/md'
import { IconContext } from 'react-icons'
import {BackgroundLayerSpecification, LayerSpecification, SourceSpecification} from 'maplibre-gl'; import {BackgroundLayerSpecification, LayerSpecification, SourceSpecification} from 'maplibre-gl';
import {v8} from '@maplibre/maplibre-gl-style-spec';
import FieldJson from './FieldJson' import FieldJson from './FieldJson'
import FilterEditor from './FilterEditor' import FilterEditor from './FilterEditor'
@@ -18,79 +17,23 @@ import FieldComment from './FieldComment'
import FieldSource from './FieldSource' import FieldSource from './FieldSource'
import FieldSourceLayer from './FieldSourceLayer' import FieldSourceLayer from './FieldSourceLayer'
import { changeType, changeProperty } from '../libs/layer' import { changeType, changeProperty } from '../libs/layer'
import layout from '../config/layout.json'
import {formatLayerId} from '../libs/format'; import {formatLayerId} from '../libs/format';
import { WithTranslation, withTranslation } from 'react-i18next'; import { WithTranslation, withTranslation } from 'react-i18next';
import { TFunction } from '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[] { function getLayoutForType(type: LayerSpecification["type"], t: TFunction) {
const groups: MaputnikLayoutGroup[] = []; return layout[type] ? {
groups.push({ ...layout[type],
title: t("General layout properties"), groups: layout[type].groups.map(group => {
id: "General_layout_properties", return {
type: "properties", ...group,
fields: Object.keys(v8["layout_symbol"]).filter(f => f.startsWith("symbol-")) id: group.title.replace(/ /g, "_"),
}); title: t(group.title)
groups.push({ };
title: t("Text layout properties"), }),
id: "Text_layout_properties", } : layout.invalid;
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 layoutGroups(layerType: LayerSpecification["type"], t: TFunction): {id: string, title: string, type: string, fields?: string[]}[] { 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' type: 'jsoneditor'
} }
return [layerGroup, filterGroup] return [layerGroup, filterGroup]
.concat(getLayoutForType(layerType, t)) .concat(getLayoutForType(layerType, t).groups)
.concat([editorGroup]) .concat([editorGroup])
} }
type LayerEditorInternalProps = { type LayerEditorInternalProps = {
layer: LayerSpecification layer: LayerSpecification
sources: {[key: string]: SourceSpecification & {layers: string[]}} sources: {[key: string]: SourceSpecification}
vectorLayers: {[key: string]: any} vectorLayers: {[key: string]: any}
spec: object spec: object
onLayerChanged(...args: unknown[]): unknown onLayerChanged(...args: unknown[]): unknown
onLayerIdChange(...args: unknown[]): unknown onLayerIdChange(...args: unknown[]): unknown
onMoveLayer: OnMoveLayerCallback onMoveLayer(...args: unknown[]): unknown
onLayerDestroy(...args: unknown[]): unknown onLayerDestroy(...args: unknown[]): unknown
onLayerCopy(...args: unknown[]): unknown onLayerCopy(...args: unknown[]): unknown
onLayerVisibilityToggle(...args: unknown[]): unknown onLayerVisibilityToggle(...args: unknown[]): unknown
@@ -143,13 +86,18 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
onLayerDestroyed: () => {}, onLayerDestroyed: () => {},
} }
static childContextTypes = {
reactIconBase: PropTypes.object
}
constructor(props: LayerEditorInternalProps) { constructor(props: LayerEditorInternalProps) {
super(props) super(props)
//TODO: Clean this up and refactor into function
const editorGroups: {[keys:string]: boolean} = {} 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 editorGroups[group.title] = true
} })
this.state = { editorGroups } this.state = { editorGroups }
} }
@@ -157,17 +105,25 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
static getDerivedStateFromProps(props: Readonly<LayerEditorInternalProps>, state: LayerEditorState) { static getDerivedStateFromProps(props: Readonly<LayerEditorInternalProps>, state: LayerEditorState) {
const additionalGroups = { ...state.editorGroups } 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)) { if(!(group.title in additionalGroups)) {
additionalGroups[group.title] = true additionalGroups[group.title] = true
} }
} })
return { return {
editorGroups: additionalGroups editorGroups: additionalGroups
}; };
} }
getChildContext () {
return {
reactIconBase: {
size: 14,
color: '#8e8e8e',
}
}
}
changeProperty(group: keyof LayerSpecification | null, property: string, newValue: any) { changeProperty(group: keyof LayerSpecification | null, property: string, newValue: any) {
this.props.onLayerChanged( this.props.onLayerChanged(
@@ -209,7 +165,7 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
let sourceLayerIds; let sourceLayerIds;
const layer = this.props.layer as Exclude<LayerSpecification, BackgroundLayerSpecification>; const layer = this.props.layer as Exclude<LayerSpecification, BackgroundLayerSpecification>;
if(Object.prototype.hasOwnProperty.call(this.props.sources, layer.source)) { 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) { switch(type) {
@@ -236,7 +192,7 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
onChange={v => this.changeProperty(null, 'source', v)} 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 <FieldSourceLayer
error={errorData['source-layer']} error={errorData['source-layer']}
sourceLayerIds={sourceLayerIds} sourceLayerIds={sourceLayerIds}
@@ -323,38 +279,30 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
const layout = this.props.layer.layout || {} const layout = this.props.layer.layout || {}
const items: {[key: string]: { const items: {[key: string]: {text: string, handler: () => void, disabled?: boolean}} = {
text: string,
handler: () => void,
disabled?: boolean,
wdKey?: string
}} = {
delete: { delete: {
text: t("Delete"), text: t("Delete"),
handler: () => this.props.onLayerDestroy(this.props.layerIndex), handler: () => this.props.onLayerDestroy(this.props.layerIndex)
wdKey: "menu-delete-layer"
}, },
duplicate: { duplicate: {
text: t("Duplicate"), text: t("Duplicate"),
handler: () => this.props.onLayerCopy(this.props.layerIndex), handler: () => this.props.onLayerCopy(this.props.layerIndex)
wdKey: "menu-duplicate-layer"
}, },
hide: { hide: {
text: (layout.visibility === "none") ? t("Show") : t("Hide"), text: (layout.visibility === "none") ? t("Show") : t("Hide"),
handler: () => this.props.onLayerVisibilityToggle(this.props.layerIndex), handler: () => this.props.onLayerVisibilityToggle(this.props.layerIndex)
wdKey: "menu-hide-layer"
}, },
moveLayerUp: { moveLayerUp: {
text: t("Move layer up"), text: t("Move layer up"),
// Not actually used...
disabled: this.props.isFirstLayer, disabled: this.props.isFirstLayer,
handler: () => this.moveLayer(-1), handler: () => this.moveLayer(-1)
wdKey: "menu-move-layer-up"
}, },
moveLayerDown: { moveLayerDown: {
text: t("Move layer down"), text: t("Move layer down"),
// Not actually used...
disabled: this.props.isLastLayer, disabled: this.props.isLastLayer,
handler: () => this.moveLayer(+1), handler: () => this.moveLayer(+1)
wdKey: "menu-move-layer-down"
} }
} }
@@ -363,55 +311,53 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
items[id].handler(); items[id].handler();
} }
return <IconContext.Provider value={{size: '14px', color: '#8e8e8e'}}> return <section className="maputnik-layer-editor"
<section className="maputnik-layer-editor" role="main"
role="main" aria-label={t("Layer editor")}
aria-label={t("Layer editor")} >
> <header>
<header> <div className="layer-header">
<div className="layer-header"> <h2 className="layer-header__title">
<h2 className="layer-header__title"> {t("Layer: {{layerId}}", { layerId: formatLayerId(this.props.layer.id) })}
{t("Layer: {{layerId}}", { layerId: formatLayerId(this.props.layer.id) })} </h2>
</h2> <div className="layer-header__info">
<div className="layer-header__info"> <Wrapper
<Wrapper className='more-menu'
className='more-menu' onSelection={handleSelection}
onSelection={handleSelection} closeOnSelection={false}
closeOnSelection={false} >
> <Button
<Button id="skip-target-layer-editor"
id="skip-target-layer-editor" data-wd-key="skip-target-layer-editor"
data-wd-key="skip-target-layer-editor" className='more-menu__button'
className='more-menu__button' title={"Layer options"}>
title={"Layer options"}> <MdMoreVert className="more-menu__button__svg" />
<MdMoreVert className="more-menu__button__svg" /> </Button>
</Button> <Menu>
<Menu> <ul className="more-menu__menu">
<ul className="more-menu__menu"> {Object.keys(items).map((id) => {
{Object.keys(items).map((id) => { const item = items[id];
const item = items[id]; return <li key={id}>
return <li key={id}> <MenuItem value={id} className='more-menu__menu__item'>
<MenuItem value={id} className='more-menu__menu__item' data-wd-key={item.wdKey}> {item.text}
{item.text} </MenuItem>
</MenuItem> </li>
</li> })}
})} </ul>
</ul> </Menu>
</Menu> </Wrapper>
</Wrapper>
</div>
</div> </div>
</div>
</header> </header>
<Accordion <Accordion
allowMultipleExpanded={true} allowMultipleExpanded={true}
allowZeroExpanded={true} allowZeroExpanded={true}
preExpanded={groupIds} preExpanded={groupIds}
> >
{groups} {groups}
</Accordion> </Accordion>
</section> </section>
</IconContext.Provider>
} }
} }
+16 -42
View File
@@ -1,28 +1,16 @@
import React, {type JSX} from 'react' import React, {type JSX} from 'react'
import classnames from 'classnames' import classnames from 'classnames'
import lodash from 'lodash'; 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 LayerListGroup from './LayerListGroup'
import LayerListItem from './LayerListItem' import LayerListItem from './LayerListItem'
import ModalAdd from './ModalAdd' 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 generateUniqueId from '../libs/document-uid';
import { findClosestCommonPrefix, layerPrefix } from '../libs/layer'; import { findClosestCommonPrefix, layerPrefix } from '../libs/layer';
import { WithTranslation, withTranslation } from 'react-i18next'; import { WithTranslation, withTranslation } from 'react-i18next';
import { OnMoveLayerCallback } from '../libs/definitions';
type LayerListContainerProps = { type LayerListContainerProps = {
layers: LayerSpecification[] layers: LayerSpecification[]
@@ -32,7 +20,7 @@ type LayerListContainerProps = {
onLayerDestroy?(...args: unknown[]): unknown onLayerDestroy?(...args: unknown[]): unknown
onLayerCopy(...args: unknown[]): unknown onLayerCopy(...args: unknown[]): unknown
onLayerVisibilityToggle(...args: unknown[]): unknown onLayerVisibilityToggle(...args: unknown[]): unknown
sources: Record<string, SourceSpecification & {layers: string[]}>; sources: object
errors: any[] errors: any[]
}; };
type LayerListContainerInternalProps = LayerListContainerProps & WithTranslation; 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-group-last': idxInGroup == layers.length - 1 && layers.length > 1,
'maputnik-layer-list-item--error': !!layerError 'maputnik-layer-list-item--error': !!layerError
})} })}
index={idx}
key={layer.key} key={layer.key}
id={layer.key} id={layer.key}
layerId={layer.id} layerId={layer.id}
@@ -330,35 +319,20 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
} }
const LayerListContainer = withTranslation()(LayerListContainerInternal); const LayerListContainer = withTranslation()(LayerListContainerInternal);
const LayerListContainerSortable = SortableContainer((props: LayerListContainerProps) => <LayerListContainer {...props} />)
type LayerListProps = LayerListContainerProps & { type LayerListProps = LayerListContainerProps & {
onMoveLayer: OnMoveLayerCallback onMoveLayer: SortEndHandler
}; };
const LayerList: React.FC<LayerListProps> = (props) => { export default class LayerList extends React.Component<LayerListProps> {
const sensors = useSensors(useSensor(PointerSensor)); render() {
return <LayerListContainerSortable
const handleDragEnd = (event: DragEndEvent) => { {...this.props}
const {active, over} = event; helperClass='sortableHelper'
if (!over) return; onSortEnd={this.props.onMoveLayer.bind(this)}
useDragHandle={true}
const oldIndex = props.layers.findIndex(layer => layer.id === active.id); shouldCancelStart={() => false}
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 LayerList;
+43 -63
View File
@@ -1,23 +1,20 @@
import React from 'react' import React from 'react'
import PropTypes from 'prop-types'
import classnames from 'classnames' import classnames from 'classnames'
import {MdContentCopy, MdVisibility, MdVisibilityOff, MdDelete} from 'react-icons/md' 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 IconLayer from './IconLayer'
import {SortableElement, SortableHandle} from 'react-sortable-hoc'
type DraggableLabelProps = { type DraggableLabelProps = {
layerId: string layerId: string
layerType: string layerType: string
dragAttributes?: React.HTMLAttributes<HTMLElement>
dragListeners?: React.HTMLAttributes<HTMLElement>
}; };
const DraggableLabel: React.FC<DraggableLabelProps> = (props) => { const DraggableLabel = SortableHandle((props: DraggableLabelProps) => {
const {dragAttributes, dragListeners} = props; return <div className="maputnik-layer-list-item-handle">
return <div className="maputnik-layer-list-item-handle" {...dragAttributes} {...dragListeners}>
<IconLayer <IconLayer
className="layer-handle__icon" className="layer-handle__icon"
type={props.layerType} type={props.layerType}
@@ -26,7 +23,7 @@ const DraggableLabel: React.FC<DraggableLabelProps> = (props) => {
{props.layerId} {props.layerId}
</button> </button>
</div> </div>
}; });
type IconActionProps = { type IconActionProps = {
action: string action: string
@@ -85,80 +82,63 @@ type LayerListItemProps = {
onLayerVisibilityToggle?(...args: unknown[]): unknown onLayerVisibilityToggle?(...args: unknown[]): unknown
}; };
const LayerListItem = React.forwardRef<HTMLLIElement, LayerListItemProps>((props, ref) => { class LayerListItem extends React.Component<LayerListItemProps> {
const { static defaultProps = {
isSelected = false, isSelected: false,
visibility = 'visible', visibility: 'visible',
onLayerCopy = () => {}, onLayerCopy: () => {},
onLayerDestroy = () => {}, onLayerDestroy: () => {},
onLayerVisibilityToggle = () => {}, onLayerVisibilityToggle: () => {},
} = props; }
const { static childContextTypes = {
attributes, reactIconBase: PropTypes.object
listeners, }
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({id: props.layerId});
const style = { getChildContext() {
transform: CSS.Transform.toString(transform), return {
transition, reactIconBase: { size: 14 }
opacity: isDragging ? 0.5 : 1, }
}; }
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 return <li
const refObject = ref as React.MutableRefObject<HTMLLIElement | null> | null; id={this.props.id}
key={this.props.layerId}
return <IconContext.Provider value={{size: '14px'}}> onClick={_e => this.props.onLayerSelect(this.props.layerIndex)}
<li data-wd-key={"layer-list-item:"+this.props.layerId}
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}
className={classnames({ className={classnames({
"maputnik-layer-list-item": true, "maputnik-layer-list-item": true,
"maputnik-layer-list-item-selected": isSelected, "maputnik-layer-list-item-selected": this.props.isSelected,
[props.className!]: true, [this.props.className!]: true,
})}> })}>
<DraggableLabel <DraggableLabel {...this.props} />
layerId={props.layerId}
layerType={props.layerType}
dragAttributes={attributes}
dragListeners={listeners}
/>
<span style={{flexGrow: 1}} /> <span style={{flexGrow: 1}} />
<IconAction <IconAction
wdKey={"layer-list-item:" + props.layerId+":delete"} wdKey={"layer-list-item:"+this.props.layerId+":delete"}
action={'delete'} action={'delete'}
classBlockName="delete" classBlockName="delete"
onClick={_e => onLayerDestroy!(props.layerIndex)} onClick={_e => this.props.onLayerDestroy!(this.props.layerIndex)}
/> />
<IconAction <IconAction
wdKey={"layer-list-item:" + props.layerId+":copy"} wdKey={"layer-list-item:"+this.props.layerId+":copy"}
action={'duplicate'} action={'duplicate'}
classBlockName="duplicate" classBlockName="duplicate"
onClick={_e => onLayerCopy!(props.layerIndex)} onClick={_e => this.props.onLayerCopy!(this.props.layerIndex)}
/> />
<IconAction <IconAction
wdKey={"layer-list-item:"+props.layerId+":toggle-visibility"} wdKey={"layer-list-item:"+this.props.layerId+":toggle-visibility"}
action={visibilityAction} action={visibilityAction}
classBlockName="visibility" classBlockName="visibility"
classBlockModifier={visibilityAction} classBlockModifier={visibilityAction}
onClick={_e => onLayerVisibilityToggle!(props.layerIndex)} onClick={_e => this.props.onLayerVisibilityToggle!(this.props.layerIndex)}
/> />
</li> </li>
</IconContext.Provider> }
}); }
export default LayerListItem; const LayerListItemSortable = SortableElement<LayerListItemProps>((props: LayerListItemProps) => <LayerListItem {...props} />);
export default LayerListItemSortable;
+8 -28
View File
@@ -1,5 +1,5 @@
import React, {type JSX} from 'react' 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 MapLibreGl, {LayerSpecification, LngLat, Map, MapOptions, SourceSpecification, StyleSpecification} from 'maplibre-gl'
import MaplibreInspect from '@maplibre/maplibre-gl-inspect' import MaplibreInspect from '@maplibre/maplibre-gl-inspect'
import colors from '@maplibre/maplibre-gl-inspect/lib/colors' import colors from '@maplibre/maplibre-gl-inspect/lib/colors'
@@ -17,14 +17,8 @@ import { withTranslation, WithTranslation } from 'react-i18next'
import i18next from 'i18next' import i18next from 'i18next'
import { Protocol } from "pmtiles"; import { Protocol } from "pmtiles";
function renderPopup( function renderPopup(popup: JSX.Element, mountNode: ReactDOM.Container): HTMLElement {
popupElement: JSX.Element, ReactDOM.render(popup, mountNode);
mountNode: HTMLElement,
popup: MapLibreGl.Popup
): HTMLElement {
const root = createRoot(mountNode);
popup.once('close', () => root.unmount());
root.render(popupElement);
return mountNode as HTMLElement; return mountNode as HTMLElement;
} }
@@ -180,12 +174,10 @@ class MapMaplibreGlInternal extends React.Component<MapMaplibreGlInternalProps,
const tmpNode = document.createElement('div'); const tmpNode = document.createElement('div');
const inspectPopup = new MapLibreGl.Popup({
closeOnClick: false
});
const inspect = new MaplibreInspect({ const inspect = new MaplibreInspect({
popup: inspectPopup, popup: new MapLibreGl.Popup({
closeOnClick: false
}),
showMapPopup: true, showMapPopup: true,
showMapPopupOnHover: false, showMapPopupOnHover: false,
showInspectMapPopupOnHover: true, showInspectMapPopupOnHover: true,
@@ -197,21 +189,9 @@ class MapMaplibreGlInternal extends React.Component<MapMaplibreGlInternalProps,
buildInspectStyle: (originalMapStyle: StyleSpecification, coloredLayers: HighlightedLayer[]) => buildInspectStyle(originalMapStyle, coloredLayers, this.props.highlightedLayer), buildInspectStyle: (originalMapStyle: StyleSpecification, coloredLayers: HighlightedLayer[]) => buildInspectStyle(originalMapStyle, coloredLayers, this.props.highlightedLayer),
renderPopup: (features: InspectFeature[]) => { renderPopup: (features: InspectFeature[]) => {
if(this.props.inspectModeEnabled) { if(this.props.inspectModeEnabled) {
return renderPopup( return renderPopup(<MapMaplibreGlFeaturePropertyPopup features={features} />, tmpNode);
<MapMaplibreGlFeaturePropertyPopup features={features} />,
tmpNode,
inspectPopup
);
} else { } else {
return renderPopup( return renderPopup(<MapMaplibreGlLayerPopup features={features} onLayerSelect={this.onLayerSelectById} zoom={this.state.zoom} />, tmpNode);
<MapMaplibreGlLayerPopup
features={features}
onLayerSelect={this.onLayerSelectById}
zoom={this.state.zoom}
/>,
tmpNode,
inspectPopup
);
} }
} }
}) })
+42 -56
View File
@@ -6,9 +6,8 @@ import FieldType from './FieldType'
import FieldId from './FieldId' import FieldId from './FieldId'
import FieldSource from './FieldSource' import FieldSource from './FieldSource'
import FieldSourceLayer from './FieldSourceLayer' import FieldSourceLayer from './FieldSourceLayer'
import type {LayerSpecification, SourceSpecification} from 'maplibre-gl' import type {LayerSpecification} from 'maplibre-gl'
import { WithTranslation, withTranslation } from 'react-i18next'; import { WithTranslation, withTranslation } from 'react-i18next';
import { NON_SOURCE_LAYERS } from '../libs/non-source-layers'
type ModalAddInternalProps = { type ModalAddInternalProps = {
layers: LayerSpecification[] layers: LayerSpecification[]
@@ -16,7 +15,7 @@ type ModalAddInternalProps = {
isOpen: boolean isOpen: boolean
onOpenToggle(open: boolean): unknown onOpenToggle(open: boolean): unknown
// A dict of source id's and the available source layers // A dict of source id's and the available source layers
sources: Record<string, SourceSpecification & {layers: string[]}>; sources: any
} & WithTranslation; } & WithTranslation;
type ModalAddState = { type ModalAddState = {
@@ -24,16 +23,10 @@ type ModalAddState = {
id: string id: string
source?: string source?: string
'source-layer'?: string 'source-layer'?: string
error?: string | null
}; };
class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddState> { class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddState> {
addLayer = () => { 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 changedLayers = this.props.layers.slice(0)
const layer: ModalAddState = { const layer: ModalAddState = {
id: this.state.id, id: this.state.id,
@@ -42,16 +35,15 @@ class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddSt
if(this.state.type !== 'background') { if(this.state.type !== 'background') {
layer.source = this.state.source 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'] layer['source-layer'] = this.state['source-layer']
} }
} }
changedLayers.push(layer as LayerSpecification) changedLayers.push(layer as LayerSpecification)
this.setState({ error: null }, () => {
this.props.onLayersChange(changedLayers) this.props.onLayersChange(changedLayers)
this.props.onOpenToggle(false) this.props.onOpenToggle(false)
})
} }
constructor(props: ModalAddInternalProps) { constructor(props: ModalAddInternalProps) {
@@ -59,15 +51,11 @@ class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddSt
const state: ModalAddState = { const state: ModalAddState = {
type: 'fill', type: 'fill',
id: '', id: '',
error: null,
} }
if(Object.keys(props.sources).length > 0) { if(props.sources.length > 0) {
state.source = Object.keys(this.props.sources)[0]; state.source = Object.keys(this.props.sources)[0];
const sourceLayers = this.props.sources[state.source].layers || [] state['source-layer'] = this.props.sources[state.source as keyof ModalAddInternalProps["sources"]][0]
if (sourceLayers.length > 0) {
state['source-layer'] = sourceLayers[0];
}
} }
this.state = state; this.state = state;
} }
@@ -101,26 +89,39 @@ class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddSt
return sourceObj.layers || []; return sourceObj.layers || [];
} }
getSources(type: LayerSpecification["type"]) { getSources(type: string) {
const sources = [];
switch(type) { const types = {
case 'background': vector: [
return []; "fill",
case 'hillshade': "line",
case 'color-relief': "symbol",
return Object.entries(this.props.sources).filter(([_, v]) => v.type === 'raster-dem').map(([k, _]) => k); "circle",
case 'raster': "fill-extrusion",
return Object.entries(this.props.sources).filter(([_, v]) => v.type === 'raster').map(([k, _]) => k); "heatmap"
case 'heatmap': ],
case 'circle': raster: [
case 'fill': "raster"
case 'fill-extrusion': ],
case 'line': geojson: [
case 'symbol': "fill",
return Object.entries(this.props.sources).filter(([_, v]) => v.type === 'vector' || v.type === 'geojson').map(([k, _]) => k); "line",
default: "symbol",
return []; "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 t = this.props.t;
const sources = this.getSources(this.state.type); const sources = this.getSources(this.state.type);
const layers = this.getLayersForSource(this.state.source!); 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 return <Modal
isOpen={this.props.isOpen} isOpen={this.props.isOpen}
@@ -151,13 +137,12 @@ class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddSt
data-wd-key="modal:add-layer" data-wd-key="modal:add-layer"
className="maputnik-add-modal" className="maputnik-add-modal"
> >
{errorElement}
<div className="maputnik-add-layer"> <div className="maputnik-add-layer">
<FieldId <FieldId
value={this.state.id} value={this.state.id}
wdKey="add-layer.layer-id" wdKey="add-layer.layer-id"
onChange={(v: string) => { onChange={(v: string) => {
this.setState({ id: v, error: null }) this.setState({ id: v })
}} }}
/> />
<FieldType <FieldType
@@ -173,8 +158,9 @@ class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddSt
onChange={(v: string) => this.setState({ source: v })} onChange={(v: string) => this.setState({ source: v })}
/> />
} }
{!NON_SOURCE_LAYERS.includes(this.state.type) && {['background', 'raster', 'hillshade', 'heatmap'].indexOf(this.state.type) < 0 &&
<FieldSourceLayer <FieldSourceLayer
isFixed={true}
sourceLayerIds={layers} sourceLayerIds={layers}
value={this.state['source-layer']} value={this.state['source-layer']}
onChange={(v: string) => this.setState({ 'source-layer': v })} 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 {saveAs} from 'file-saver'
import {version} from 'maplibre-gl/package.json' import {version} from 'maplibre-gl/package.json'
import {format} from '@maplibre/maplibre-gl-style-spec' import {format} from '@maplibre/maplibre-gl-style-spec'
import type {StyleSpecification} from 'maplibre-gl'
import {MdMap, MdSave} from 'react-icons/md' import {MdMap, MdSave} from 'react-icons/md'
import {WithTranslation, withTranslation} from 'react-i18next'; import {WithTranslation, withTranslation} from 'react-i18next';
@@ -11,7 +12,6 @@ import InputButton from './InputButton'
import Modal from './Modal' import Modal from './Modal'
import style from '../libs/style' import style from '../libs/style'
import fieldSpecAdditional from '../libs/field-spec-additional' import fieldSpecAdditional from '../libs/field-spec-additional'
import type {OnStyleChangedCallback, StyleSpecificationWithId} from '../libs/definitions'
const MAPLIBRE_GL_VERSION = version; const MAPLIBRE_GL_VERSION = version;
@@ -19,8 +19,8 @@ const showSaveFilePickerAvailable = typeof window.showSaveFilePicker === "functi
type ModalExportInternalProps = { type ModalExportInternalProps = {
mapStyle: StyleSpecificationWithId mapStyle: StyleSpecification & { id: string }
onStyleChanged: OnStyleChangedCallback onStyleChanged(...args: unknown[]): unknown
isOpen: boolean isOpen: boolean
onOpenToggle(...args: unknown[]): unknown onOpenToggle(...args: unknown[]): unknown
onSetFileHandle(fileHandle: FileSystemFileHandle | null): 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']} value={(this.props.mapStyle.metadata || {} as any)['maputnik:stadia_access_token']}
onChange={this.changeMetadataProperty.bind(this, "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>
<div className="maputnik-modal-export-buttons"> <div className="maputnik-modal-export-buttons">
+3 -12
View File
@@ -12,11 +12,10 @@ import FieldEnum from './FieldEnum'
import FieldColor from './FieldColor' import FieldColor from './FieldColor'
import Modal from './Modal' import Modal from './Modal'
import fieldSpecAdditional from '../libs/field-spec-additional' import fieldSpecAdditional from '../libs/field-spec-additional'
import type {OnStyleChangedCallback, StyleSpecificationWithId} from '../libs/definitions';
type ModalSettingsInternalProps = { type ModalSettingsInternalProps = {
mapStyle: StyleSpecificationWithId mapStyle: StyleSpecification
onStyleChanged: OnStyleChangedCallback onStyleChanged(...args: unknown[]): unknown
onChangeMetadataProperty(...args: unknown[]): unknown onChangeMetadataProperty(...args: unknown[]): unknown
isOpen: boolean isOpen: boolean
onOpenToggle(...args: unknown[]): unknown onOpenToggle(...args: unknown[]): unknown
@@ -63,7 +62,7 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
changeTerrainProperty(property: keyof TerrainSpecification, value: any) { changeTerrainProperty(property: keyof TerrainSpecification, value: any) {
const terrain = { const terrain = {
...this.props.mapStyle.terrain, ...this.props.mapStyle.terrain,
} as TerrainSpecification; }
if (value === undefined) { if (value === undefined) {
delete terrain[property]; delete terrain[property];
@@ -165,14 +164,6 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
onChange={onChangeMetadataProperty.bind(this, "maputnik:stadia_access_token")} 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 <FieldArray
label={t("Center")} label={t("Center")}
fieldSpec={latest.$root.center} fieldSpec={latest.$root.center}
+3 -4
View File
@@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import {MdAddCircleOutline, MdDelete} from 'react-icons/md' import {MdAddCircleOutline, MdDelete} from 'react-icons/md'
import latest from '@maplibre/maplibre-gl-style-spec/dist/latest.json' 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 { WithTranslation, withTranslation } from 'react-i18next';
import Modal from './Modal' import Modal from './Modal'
@@ -13,7 +13,6 @@ import ModalSourcesTypeEditor, { EditorMode } from './ModalSourcesTypeEditor'
import style from '../libs/style' import style from '../libs/style'
import { deleteSource, addSource, changeSource } from '../libs/source' import { deleteSource, addSource, changeSource } from '../libs/source'
import publicSources from '../config/tilesets.json' import publicSources from '../config/tilesets.json'
import { OnStyleChangedCallback, StyleSpecificationWithId } from '../libs/definitions';
type PublicSourceProps = { type PublicSourceProps = {
@@ -271,10 +270,10 @@ class AddSource extends React.Component<AddSourceProps, AddSourceState> {
} }
type ModalSourcesInternalProps = { type ModalSourcesInternalProps = {
mapStyle: StyleSpecificationWithId mapStyle: StyleSpecification
isOpen: boolean isOpen: boolean
onOpenToggle(...args: unknown[]): unknown onOpenToggle(...args: unknown[]): unknown
onStyleChanged: OnStyleChangedCallback onStyleChanged(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
class ModalSourcesInternal extends React.Component<ModalSourcesInternalProps> { class ModalSourcesInternal extends React.Component<ModalSourcesInternalProps> {
+21 -18
View File
@@ -1,3 +1,4 @@
import React from 'react'
import Block from './Block' import Block from './Block'
import InputSpec, { SpecFieldProps as InputFieldSpecProps } from './InputSpec' import InputSpec, { SpecFieldProps as InputFieldSpecProps } from './InputSpec'
import Fieldset from './Fieldset' import Fieldset from './Fieldset'
@@ -19,25 +20,27 @@ export type SpecFieldProps = InputFieldSpecProps & {
name?: string name?: string
}; };
const SpecField: React.FC<SpecFieldProps> = (props) => { export default class SpecField extends React.Component<SpecFieldProps> {
const fieldType = props.fieldSpec?.type; render() {
const fieldType = this.props.fieldSpec?.type;
const typeBlockFn = typeMap[fieldType!]; const typeBlockFn = typeMap[fieldType!];
let TypeBlock; let TypeBlock;
if (typeBlockFn) { if (typeBlockFn) {
TypeBlock = typeBlockFn(props); TypeBlock = typeBlockFn(this.props);
} }
else { else {
console.warn("No such type for '%s'", fieldType); console.warn("No such type for '%s'", fieldType);
TypeBlock = Block; TypeBlock = Block;
} }
return ( return <TypeBlock
<TypeBlock label={props.label} action={props.action} fieldSpec={props.fieldSpec}> label={this.props.label}
<InputSpec {...props} /> action={this.props.action}
fieldSpec={this.props.fieldSpec}
>
<InputSpec {...this.props} />
</TypeBlock> </TypeBlock>
); }
}; }
export default SpecField;
+1 -1
View File
@@ -17,7 +17,7 @@ class FunctionInputButtonsInternal extends React.Component<FunctionInputButtonsI
const t = this.props.t; const t = this.props.t;
let makeZoomInputButton, makeDataInputButton, expressionInputButton; let makeZoomInputButton, makeDataInputButton, expressionInputButton;
if (this.props.fieldSpec.expression?.parameters.includes('zoom')) { if (this.props.fieldSpec.expression.parameters.includes('zoom')) {
expressionInputButton = ( expressionInputButton = (
<InputButton <InputButton
className="maputnik-make-zoom-function" className="maputnik-make-zoom-function"
+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", "url": "https://americanamap.org/style.json",
"thumbnail": "https://github.com/maplibre/maputnik/assets/649392/23fa75ad-63e6-43f5-8837-03cdb0428bac" "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", "id": "dark-matter",
"title": "Dark Matter", "title": "Dark Matter",
"url": "https://cdn.jsdelivr.net/gh/openmaptiles/dark-matter-gl-style@v1.9/style.json", "url": "https://cdn.jsdelivr.net/gh/openmaptiles/dark-matter-gl-style@v1.9/style.json",
"thumbnail": "https://maputnik.github.io/thumbnails/dark-matter.png" "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", "id": "maptiler-basic-gl-style",
"title": "MapTiler Basic", "title": "MapTiler Basic",
@@ -47,6 +29,30 @@
"url": "https://cdn.jsdelivr.net/gh/openmaptiles/toner-gl-style@v1.0/style.json", "url": "https://cdn.jsdelivr.net/gh/openmaptiles/toner-gl-style@v1.0/style.json",
"thumbnail": "https://maputnik.github.io/thumbnails/toner.png" "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", "id": "osm-bright",
"title": "OSM Bright", "title": "OSM Bright",
@@ -86,7 +92,7 @@
{ {
"id": "versatiles-colorful", "id": "versatiles-colorful",
"title": "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" "thumbnail": "https://github.com/maplibre/maputnik/assets/649392/6cd69818-c541-46e4-a920-65fb4f654931"
} }
] ]
-5
View File
@@ -18,10 +18,5 @@
"type": "vector", "type": "vector",
"url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/data/vector/open-zoomstack/config.json", "url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/data/vector/open-zoomstack/config.json",
"title": "OS Open Zoomstack v2" "title": "OS Open Zoomstack v2"
},
"locationiq": {
"type": "vector",
"url": "https://tiles.locationiq.com/v3/pbf/tiles.json?key={key}",
"title": "LocationIQ"
} }
} }
+1 -2
View File
@@ -1,5 +1,4 @@
{ {
"openmaptiles": "get_your_own_OpIi9ZULNHzrESv6T2vL", "openmaptiles": "get_your_own_OpIi9ZULNHzrESv6T2vL",
"thunderforest": "b71f7f0ba4064f5eb9e903859a9cf5c6", "thunderforest": "b71f7f0ba4064f5eb9e903859a9cf5c6"
"locationiq": "pk.put_your_api_key_here7bb23dffeb4"
} }
@@ -1,38 +1,46 @@
import style from '../style' import style from './style.js'
import {format} from '@maplibre/maplibre-gl-style-spec' import {format} from '@maplibre/maplibre-gl-style-spec'
import type {StyleSpecification} from 'maplibre-gl'
import ReconnectingWebSocket from 'reconnecting-websocket' import ReconnectingWebSocket from 'reconnecting-websocket'
import type {IStyleStore, OnStyleChangedCallback, StyleSpecificationWithId} from '../definitions'
export type ApiStyleStoreOptions = { export type ApiStyleStoreOptions = {
onLocalStyleChange?: OnStyleChangedCallback port: string | null
host: string | null
onLocalStyleChange?: (style: any) => void
} }
export class ApiStyleStore implements IStyleStore { export class ApiStyleStore {
localUrl: string; localUrl: string;
websocketUrl: string; websocketUrl: string;
latestStyleId: string | undefined = undefined; latestStyleId: string | undefined = undefined;
onLocalStyleChange: OnStyleChangedCallback; onLocalStyleChange: (style: any) => void;
constructor(opts: ApiStyleStoreOptions) { constructor(opts: ApiStyleStoreOptions) {
this.onLocalStyleChange = opts.onLocalStyleChange || (() => {}) this.onLocalStyleChange = opts.onLocalStyleChange || (() => {})
const port = window.location.port const port = opts.port || '8000'
const host = 'localhost' const host = opts.host || 'localhost'
this.localUrl = `http://${host}:${port}` this.localUrl = `http://${host}:${port}`
this.websocketUrl = `ws://${host}:${port}/ws` this.websocketUrl = `ws://${host}:${port}/ws`
this.init = this.init.bind(this) this.init = this.init.bind(this)
} }
async init(): Promise<void> { init(cb: (...args: any[]) => void) {
try { fetch(this.localUrl + '/styles', {
const response = await fetch(this.localUrl + '/styles', {mode: 'cors'}); mode: 'cors',
const body = await response.json(); })
const styleIds = body; .then((response) => {
this.latestStyleId = styleIds[0] return response.json();
this.notifyLocalChanges(); })
} catch { .then((body) => {
throw new Error('Can not connect to style API'); const styleIds = body;
} this.latestStyleId = styleIds[0]
this.notifyLocalChanges()
cb(null)
})
.catch(() => {
cb(new Error('Can not connect to style API'))
})
} }
notifyLocalChanges() { notifyLocalChanges() {
@@ -51,20 +59,24 @@ export class ApiStyleStore implements IStyleStore {
} }
} }
async getLatestStyle(): Promise<StyleSpecificationWithId> { latestStyle(cb: (...args: any[]) => void) {
if(this.latestStyleId) { if(this.latestStyleId) {
const response = await fetch(this.localUrl + '/styles/' + this.latestStyleId, { fetch(this.localUrl + '/styles/' + this.latestStyleId, {
mode: 'cors', mode: 'cors',
}); })
const body = await response.json(); .then(function(response) {
return style.ensureStyleValidity(body); return response.json();
})
.then(function(body) {
cb(style.ensureStyleValidity(body))
})
} else { } else {
throw new Error('No latest style available. You need to init the api backend first.') throw new Error('No latest style available. You need to init the api backend first.')
} }
} }
// Save current style replacing previous version // Save current style replacing previous version
save(mapStyle: StyleSpecificationWithId) { save(mapStyle: StyleSpecification & { id: string }) {
const styleJSON = format( const styleJSON = format(
style.stripAccessTokens( style.stripAccessTokens(
style.replaceAccessTokens(mapStyle) style.replaceAccessTokens(mapStyle)
+48
View File
@@ -0,0 +1,48 @@
interface DebugStore {
[namespace: string]: {
[key: string]: any
}
}
const debugStore: DebugStore = {};
function enabled() {
const qs = new URL(window.location.href).searchParams;
const debugQs = qs.get("debug");
if(debugQs) {
return !!debugQs.match(/^(|1|true)$/);
}
else {
return false;
}
}
function genErr() {
return new Error("Debug not enabled, enable by appending '?debug' to your query string");
}
function set(namespace: keyof DebugStore, key: string, value: any) {
if(!enabled()) {
throw genErr();
}
debugStore[namespace] = debugStore[namespace] || {};
debugStore[namespace][key] = value;
}
function get(namespace: keyof DebugStore, key: string) {
if(!enabled()) {
throw genErr();
}
if(Object.prototype.hasOwnProperty.call(debugStore, namespace)) {
return debugStore[namespace][key];
}
}
const mod = {
enabled,
get,
set
};
(window as any).debug = mod;
export default mod;
-18
View File
@@ -1,18 +0,0 @@
import type { StyleSpecification } from "maplibre-gl";
export type StyleSpecificationWithId = StyleSpecification & {id: string};
export type OnStyleChangedOpts = {
save?: boolean;
addRevision?: boolean;
initialLoad?: boolean;
}
export type OnStyleChangedCallback = (newStyle: StyleSpecificationWithId, opts: OnStyleChangedOpts={}) => void;
export type OnMoveLayerCallback = (move: {oldIndex: number; newIndex: number}) => void;
export interface IStyleStore {
getLatestStyle(): Promise<StyleSpecificationWithId>;
save(mapStyle: StyleSpecificationWithId): StyleSpecificationWithId;
}
-6
View File
@@ -20,12 +20,6 @@ const spec = (t: TFunction) => ({
docUrl: "https://docs.stadiamaps.com/authentication/", docUrl: "https://docs.stadiamaps.com/authentication/",
docUrlLinkText: t("Learn More") docUrlLinkText: t("Learn More")
}, },
locationiq_access_token: {
label: t("LocationIQ Access Token"),
doc: t("Public access token for LocationIQ services."),
docUrl: "https://docs.locationiq.com/docs/maps",
docUrlLinkText: t("Learn More")
},
style_renderer: { style_renderer: {
label: t("Style Renderer"), label: t("Style Renderer"),
doc: t("Choose the default Maputnik renderer for this style.") doc: t("Choose the default Maputnik renderer for this style.")
-1
View File
@@ -1 +0,0 @@
export const NON_SOURCE_LAYERS = ['background', 'raster', 'hillshade', 'heatmap', 'color-relief']
+3 -3
View File
@@ -1,7 +1,7 @@
import { StyleSpecificationWithId } from "./definitions"; import type {StyleSpecification} from "maplibre-gl";
export class RevisionStore { export class RevisionStore {
revisions: StyleSpecificationWithId[]; revisions: (StyleSpecification & {id: string})[];
currentIdx: number; currentIdx: number;
@@ -18,7 +18,7 @@ export class RevisionStore {
return this.revisions[this.currentIdx] return this.revisions[this.currentIdx]
} }
addRevision(revision: StyleSpecificationWithId) { addRevision(revision: StyleSpecification & {id: string}) {
// clear any "redo" revisions once a change is made // clear any "redo" revisions once a change is made
// and ensure current index is at end of list // and ensure current index is at end of list
this.revisions = this.revisions.slice(0, this.currentIdx + 1); this.revisions = this.revisions.slice(0, this.currentIdx + 1);
+4 -5
View File
@@ -1,7 +1,6 @@
import type {SourceSpecification} from "maplibre-gl"; import type {StyleSpecification, SourceSpecification} from "maplibre-gl";
import type {StyleSpecificationWithId} from "./definitions";
export function deleteSource(mapStyle: StyleSpecificationWithId, sourceId: string) { export function deleteSource(mapStyle: StyleSpecification, sourceId: string) {
const remainingSources = { ...mapStyle.sources} const remainingSources = { ...mapStyle.sources}
delete remainingSources[sourceId] delete remainingSources[sourceId]
return { return {
@@ -11,11 +10,11 @@ export function deleteSource(mapStyle: StyleSpecificationWithId, sourceId: strin
} }
export function addSource(mapStyle: StyleSpecificationWithId, sourceId: string, source: SourceSpecification) { export function addSource(mapStyle: StyleSpecification, sourceId: string, source: SourceSpecification) {
return changeSource(mapStyle, sourceId, source) return changeSource(mapStyle, sourceId, source)
} }
export function changeSource(mapStyle: StyleSpecificationWithId, sourceId: string, source: SourceSpecification) { export function changeSource(mapStyle: StyleSpecification, sourceId: string, source: SourceSpecification) {
const changedSources = { const changedSources = {
...mapStyle.sources, ...mapStyle.sources,
[sourceId]: source [sourceId]: source
-29
View File
@@ -1,29 +0,0 @@
/// <reference types="vite/client" />
import { IStyleStore, OnStyleChangedCallback } from "../definitions";
import { getStyleUrlFromAddressbarAndRemoveItIfNeeded, loadStyleUrl } from "../urlopen";
import { ApiStyleStore } from "./apistore";
import { StyleStore } from "./stylestore";
export async function createStyleStore(onStyleChanged: OnStyleChangedCallback): Promise<IStyleStore> {
const styleUrl = getStyleUrlFromAddressbarAndRemoveItIfNeeded();
const useStyleUrl = styleUrl && window.confirm("Load style from URL: " + styleUrl + " and discard current changes?");
let styleStore: IStyleStore;
if (import.meta.env.MODE === 'desktop' && !useStyleUrl) {
const apiStyleStore = new ApiStyleStore({
onLocalStyleChange: mapStyle => onStyleChanged(mapStyle, {save: false}),
});
try {
await apiStyleStore.init();
styleStore = apiStyleStore;
} catch {
styleStore = new StyleStore();
}
} else {
styleStore = new StyleStore();
}
const styleToLoad = useStyleUrl ? await loadStyleUrl(styleUrl) : await styleStore.getLatestStyle();
onStyleChanged(styleToLoad, {initialLoad: true, save: false});
return styleStore;
}
export type { IStyleStore };
+6 -10
View File
@@ -1,7 +1,6 @@
import {derefLayers} from '@maplibre/maplibre-gl-style-spec' import {derefLayers} from '@maplibre/maplibre-gl-style-spec'
import type {StyleSpecification, LayerSpecification} from 'maplibre-gl' import type {StyleSpecification, LayerSpecification} from 'maplibre-gl'
import tokens from '../config/tokens.json' import tokens from '../config/tokens.json'
import type {StyleSpecificationWithId} from './definitions'
// Empty style is always used if no style could be restored or fetched // Empty style is always used if no style could be restored or fetched
const emptyStyle = ensureStyleValidity({ const emptyStyle = ensureStyleValidity({
@@ -14,14 +13,15 @@ function generateId() {
return Math.random().toString(36).substring(2, 9) return Math.random().toString(36).substring(2, 9)
} }
function ensureHasId(style: StyleSpecification & { id?: string }): StyleSpecificationWithId { function ensureHasId(style: StyleSpecification & { id?: string }): StyleSpecification & { id: string } {
if(!('id' in style) || !style.id) { if(!('id' in style) || !style.id) {
style.id = generateId(); style.id = generateId();
return style as StyleSpecification & { id: string };
} }
return style as StyleSpecificationWithId; return style as StyleSpecification & { id: string };
} }
function ensureHasNoInteractive(style: StyleSpecificationWithId) { function ensureHasNoInteractive(style: StyleSpecification & {id: string}) {
const changedLayers = style.layers.map(layer => { const changedLayers = style.layers.map(layer => {
const changedLayer: LayerSpecification & { interactive?: any } = { ...layer } const changedLayer: LayerSpecification & { interactive?: any } = { ...layer }
delete changedLayer.interactive delete changedLayer.interactive
@@ -34,14 +34,14 @@ function ensureHasNoInteractive(style: StyleSpecificationWithId) {
} }
} }
function ensureHasNoRefs(style: StyleSpecificationWithId) { function ensureHasNoRefs(style: StyleSpecification & {id: string}) {
return { return {
...style, ...style,
layers: derefLayers(style.layers) layers: derefLayers(style.layers)
} }
} }
function ensureStyleValidity(style: StyleSpecification): StyleSpecificationWithId { function ensureStyleValidity(style: StyleSpecification): StyleSpecification & { id: string } {
return ensureHasNoInteractive(ensureHasNoRefs(ensureHasId(style))) return ensureHasNoInteractive(ensureHasNoRefs(ensureHasId(style)))
} }
@@ -79,9 +79,6 @@ function replaceSourceAccessToken(mapStyle: StyleSpecification, sourceName: stri
// so we need to check the source URL. // so we need to check the source URL.
authSourceName = "stadia" authSourceName = "stadia"
} }
else if (("url" in source) && source.url?.match(/\.locationiq\.com/)) {
authSourceName = "locationiq"
}
const accessToken = getAccessToken(authSourceName, mapStyle, opts) const accessToken = getAccessToken(authSourceName, mapStyle, opts)
@@ -141,7 +138,6 @@ function stripAccessTokens(mapStyle: StyleSpecification) {
delete changedMetadata['maputnik:openmaptiles_access_token']; delete changedMetadata['maputnik:openmaptiles_access_token'];
delete changedMetadata['maputnik:thunderforest_access_token']; delete changedMetadata['maputnik:thunderforest_access_token'];
delete changedMetadata['maputnik:stadia_access_token']; delete changedMetadata['maputnik:stadia_access_token'];
delete changedMetadata['maputnik:locationiq_access_token'];
return { return {
...mapStyle, ...mapStyle,
metadata: changedMetadata metadata: changedMetadata
@@ -1,7 +1,7 @@
import style from '../style' import style from './style'
import {loadStyleUrl} from '../urlopen' import {loadStyleUrl} from './urlopen'
import publicSources from '../../config/styles.json' import publicSources from '../config/styles.json'
import type {IStyleStore, StyleSpecificationWithId} from '../definitions' import type {StyleSpecification} from 'maplibre-gl'
const storagePrefix = "maputnik" const storagePrefix = "maputnik"
const stylePrefix = 'style' const stylePrefix = 'style'
@@ -13,8 +13,8 @@ const storageKeys = {
const defaultStyleUrl = publicSources[0].url const defaultStyleUrl = publicSources[0].url
// Fetch a default style via URL and return it or a fallback style via callback // Fetch a default style via URL and return it or a fallback style via callback
export function loadDefaultStyle(): Promise<StyleSpecificationWithId> { export function loadDefaultStyle(cb: (...args: any[]) => void) {
return loadStyleUrl(defaultStyleUrl); loadStyleUrl(defaultStyleUrl, cb)
} }
// Return style ids and dates of all styles stored in local storage // Return style ids and dates of all styles stored in local storage
@@ -51,7 +51,7 @@ function styleKey(styleId: string) {
} }
// Manages many possible styles that are stored in the local storage // Manages many possible styles that are stored in the local storage
export class StyleStore implements IStyleStore { export class StyleStore {
/** /**
* List of style ids * List of style ids
*/ */
@@ -63,6 +63,10 @@ export class StyleStore implements IStyleStore {
this.mapStyles = loadStoredStyles(); this.mapStyles = loadStoredStyles();
} }
init(cb: (...args: any[]) => void) {
cb(null)
}
// Delete entire style history // Delete entire style history
purge() { purge() {
for (let i = 0; i < window.localStorage.length; i++) { for (let i = 0; i < window.localStorage.length; i++) {
@@ -74,45 +78,21 @@ export class StyleStore implements IStyleStore {
} }
// Find the last edited style // Find the last edited style
async getLatestStyle(): Promise<StyleSpecificationWithId> { latestStyle(cb: (...args: any[]) => void) {
if(this.mapStyles.length === 0) { if(this.mapStyles.length === 0) return loadDefaultStyle(cb)
return loadDefaultStyle();
}
const styleId = window.localStorage.getItem(storageKeys.latest) as string; const styleId = window.localStorage.getItem(storageKeys.latest) as string;
const styleItem = window.localStorage.getItem(styleKey(styleId)) const styleItem = window.localStorage.getItem(styleKey(styleId))
if (styleItem) { if(styleItem) return cb(JSON.parse(styleItem))
return JSON.parse(styleItem) as StyleSpecificationWithId; loadDefaultStyle(cb)
}
return loadDefaultStyle();
} }
// Save current style replacing previous version // Save current style replacing previous version
save(mapStyle: StyleSpecificationWithId) { save(mapStyle: StyleSpecification & { id: string }) {
mapStyle = style.ensureStyleValidity(mapStyle) mapStyle = style.ensureStyleValidity(mapStyle)
const key = styleKey(mapStyle.id) const key = styleKey(mapStyle.id)
window.localStorage.setItem(key, JSON.stringify(mapStyle))
const saveFn = () => { window.localStorage.setItem(storageKeys.latest, mapStyle.id)
window.localStorage.setItem(key, JSON.stringify(mapStyle))
window.localStorage.setItem(storageKeys.latest, mapStyle.id)
}
try {
saveFn()
} catch (e) {
// Handle quota exceeded error
if (e instanceof DOMException && (
e.code === 22 || // Firefox
e.code === 1014 || // Firefox
e.name === 'QuotaExceededError' ||
e.name === 'NS_ERROR_DOM_QUOTA_REACHED'
)) {
this.purge()
saveFn() // Retry after clearing
} else {
throw e
}
}
return mapStyle return mapStyle
} }
} }
+23 -20
View File
@@ -1,27 +1,30 @@
import style from './style' import style from './style'
import { StyleSpecificationWithId } from './definitions';
export function getStyleUrlFromAddressbarAndRemoveItIfNeeded(): string | null { export function initialStyleUrl() {
const initialUrl = new URL(window.location.href); const initialUrl = new URL(window.location.href);
const styleUrl = initialUrl.searchParams.get('style'); return initialUrl.searchParams.get('style');
if (styleUrl) {
initialUrl.searchParams.delete('style');
window.history.replaceState({}, document.title, initialUrl.toString())
}
return styleUrl;
} }
export async function loadStyleUrl(styleUrl: string): Promise<StyleSpecificationWithId> { export function loadStyleUrl(styleUrl: string, cb: (...args: any[]) => void) {
console.log('Loading style', styleUrl) console.log('Loading style', styleUrl)
try { fetch(styleUrl, {
const response = await fetch(styleUrl, { mode: 'cors',
mode: 'cors', credentials: "same-origin"
credentials: "same-origin" })
}); .then(function(response) {
const body = await response.json(); return response.json();
return style.ensureStyleValidity(body); })
} catch { .then(function(body) {
console.warn('Could not fetch default style: ' + styleUrl) cb(style.ensureStyleValidity(body))
return style.emptyStyle })
} .catch(function() {
console.warn('Could not fetch default style', styleUrl)
cb(style.emptyStyle)
})
}
export function removeStyleQuerystring() {
const initialUrl = new URL(window.location.href);
initialUrl.searchParams.delete('style');
window.history.replaceState({}, document.title, initialUrl.toString())
} }
-18
View File
@@ -59,13 +59,6 @@
"Must provide protocol: <1>https://</1>": "Protokoll erforderlich: <1>https://</1>", "Must provide protocol: <1>https://</1>": "Protokoll erforderlich: <1>https://</1>",
"Must provide protocol: <1>http://</1> or <3>https://</3>": "Protokoll erforderlich: <1>http://</1> oder <3>https://</3>", "Must provide protocol: <1>http://</1> or <3>https://</3>": "Protokoll erforderlich: <1>http://</1> oder <3>https://</3>",
"CORS policy won't allow fetching resources served over http from https, use a <1>https://</1> domain": "Die CORS-Politik erlaubt es nicht, Ressourcen über http von https zu laden, benutze eine <1>https://</1>-Domain", "CORS policy won't allow fetching resources served over http from https, use a <1>https://</1> domain": "Die CORS-Politik erlaubt es nicht, Ressourcen über http von https zu laden, benutze eine <1>https://</1>-Domain",
"General layout properties": "Allgemeine Layouteigenschaften",
"Text layout properties": "Text-Layouteigenschaften",
"Icon layout properties": "Icon-Layouteigenschaften",
"Text paint properties": "Text-Darstellungseigenschaften",
"Icon paint properties": "Icon-Darstellungseigenschaften",
"Paint properties": "Darstellungseigenschaften",
"Layout properties": "Layouteigenschaften",
"Layer": "Ebene", "Layer": "Ebene",
"JSON Editor": "JSON-Editor", "JSON Editor": "JSON-Editor",
"Delete": "Löschen", "Delete": "Löschen",
@@ -86,7 +79,6 @@
"center:": "Zentrum:", "center:": "Zentrum:",
"rotation:": "Rotation:", "rotation:": "Rotation:",
"Close modal": "Modale Fenster schließen", "Close modal": "Modale Fenster schließen",
"Layer ID already exists": "Layer-ID existiert bereits",
"Debug": "Debug", "Debug": "Debug",
"Options": "Optionen", "Options": "Optionen",
"Save Style": "Stil Speichern", "Save Style": "Stil Speichern",
@@ -190,18 +182,8 @@
"Public access token for Thunderforest services.": "Öffentlicher Zugriffstoken für Thunderforest-Dienste.", "Public access token for Thunderforest services.": "Öffentlicher Zugriffstoken für Thunderforest-Dienste.",
"Stadia Maps API Key": "Stadia Maps API-Schlüssel", "Stadia Maps API Key": "Stadia Maps API-Schlüssel",
"API key for Stadia Maps.": "API-Schlüssel für Stadia Maps.", "API key for Stadia Maps.": "API-Schlüssel für Stadia Maps.",
"LocationIQ Access Token": "LocationIQ Zugriffstoken",
"Public access token for LocationIQ services.": "Öffentlicher Zugriffstoken für LocationIQ-Dienste.",
"Style Renderer": "Stil-Renderer", "Style Renderer": "Stil-Renderer",
"Choose the default Maputnik renderer for this style.": "Wähle den Standard-Renderer für diesen Stil aus.", "Choose the default Maputnik renderer for this style.": "Wähle den Standard-Renderer für diesen Stil aus.",
"Opacity": "Deckkraft",
"Radius": "Radius",
"Color": "Farbe",
"Outline color": "Umrissfarbe",
"Language": "Sprache", "Language": "Sprache",
"Width": "Breite",
"Height": "Höhe",
"Field": "Feld",
"Size": "Größe",
"Press <1>ESC</1> to lose focus of any input box": "Drück <1>ESC</1>, um den Fokus von jedem Eingabefeld zu verlieren" "Press <1>ESC</1> to lose focus of any input box": "Drück <1>ESC</1>, um den Fokus von jedem Eingabefeld zu verlieren"
} }
+8 -11
View File
@@ -59,13 +59,6 @@
"Must provide protocol: <1>https://</1>": "Protocole requis : <1>https://</1>", "Must provide protocol: <1>https://</1>": "Protocole requis : <1>https://</1>",
"Must provide protocol: <1>http://</1> or <3>https://</3>": "Protocole requis : <1>http://</1> ou <3>https://</3>", "Must provide protocol: <1>http://</1> or <3>https://</3>": "Protocole requis : <1>http://</1> ou <3>https://</3>",
"CORS policy won't allow fetching resources served over http from https, use a <1>https://</1> domain": "La politique CORS ne permet pas de récupérer des ressources servies sur http depuis https, utilisez un protocole <1>https://</1>", "CORS policy won't allow fetching resources served over http from https, use a <1>https://</1> domain": "La politique CORS ne permet pas de récupérer des ressources servies sur http depuis https, utilisez un protocole <1>https://</1>",
"General layout properties": "Propriétés générales de mise en page",
"Text layout properties": "Propriétés de mise en page du texte",
"Icon layout properties": "Propriétés de mise en page de l'icône",
"Text paint properties": "Propriétés de peinture du texte",
"Icon paint properties": "Propriétés de peinture de l'icône",
"Paint properties": "Propriétés de peinture",
"Layout properties": "Propriétés de mise en page",
"Layer": "Calque", "Layer": "Calque",
"JSON Editor": "Éditeur JSON", "JSON Editor": "Éditeur JSON",
"Delete": "Supprimer", "Delete": "Supprimer",
@@ -86,7 +79,6 @@
"center:": "centre :", "center:": "centre :",
"rotation:": "rotation :", "rotation:": "rotation :",
"Close modal": "Fermer la fenêtre modale", "Close modal": "Fermer la fenêtre modale",
"Layer ID already exists": "L'identifiant du calque existe déjà",
"Debug": "Déboguer", "Debug": "Déboguer",
"Options": "Options", "Options": "Options",
"Save Style": "Enregistrer le style", "Save Style": "Enregistrer le style",
@@ -190,10 +182,15 @@
"Public access token for Thunderforest services.": "Jeton d'accès public pour les services Thunderforest.", "Public access token for Thunderforest services.": "Jeton d'accès public pour les services Thunderforest.",
"Stadia Maps API Key": "Clé d'API Stadia Maps", "Stadia Maps API Key": "Clé d'API Stadia Maps",
"API key for Stadia Maps.": "Clé d'API pour Stadia Maps.", "API key for Stadia Maps.": "Clé d'API pour Stadia Maps.",
"LocationIQ Access Token": "Jeton d'accès LocationIQ",
"Public access token for LocationIQ services.": "Jeton d'accès public pour les services LocationIQ.",
"Style Renderer": "Moteur de rendu pour le style", "Style Renderer": "Moteur de rendu pour le style",
"Choose the default Maputnik renderer for this style.": "Choisissez le moteur de rendu Maputnik par défaut pour ce style.", "Choose the default Maputnik renderer for this style.": "Choisissez le moteur de rendu Maputnik par défaut pour ce style.",
"Language": "Langue", "Language": "Langue",
"Layer options": "Options du calque" "Layer options": "Options du calque",
"Paint properties": "Propriétés de peinture",
"Layout properties": "Propriétés de mise en page",
"General layout properties": "Propriétés générales de mise en page",
"Text layout properties": "Propriétés de mise en page du texte",
"Icon layout properties": "Propriétés de mise en page de l'icône",
"Text paint properties": "Propriétés de peinture du texte",
"Icon paint properties": "Propriétés de peinture de l'icône"
} }
-10
View File
@@ -59,13 +59,6 @@
"Must provide protocol: <1>https://</1>": "נדרש פרוטוקול: <1>https://</1>", "Must provide protocol: <1>https://</1>": "נדרש פרוטוקול: <1>https://</1>",
"Must provide protocol: <1>http://</1> or <3>https://</3>": "נדרשים פרוטוקולים: <1>http://</1> או <3>https://</3>", "Must provide protocol: <1>http://</1> or <3>https://</3>": "נדרשים פרוטוקולים: <1>http://</1> או <3>https://</3>",
"CORS policy won't allow fetching resources served over http from https, use a <1>https://</1> domain": "CORS policy won't allow fetching resources served over http from https, use a <1>https://</1> domain", "CORS policy won't allow fetching resources served over http from https, use a <1>https://</1> domain": "CORS policy won't allow fetching resources served over http from https, use a <1>https://</1> domain",
"General layout properties": "תכונות פריסה כלליות",
"Text layout properties": "תכונות פריסה של טקסט",
"Icon layout properties": "תכונות פריסה של סמליל",
"Text paint properties": "תכונות ציור של טקסט",
"Icon paint properties": "תכונות ציור של סמליל",
"Paint properties": "תכונות ציור",
"Layout properties": "תכונות פריסה",
"Layer": "שכבה", "Layer": "שכבה",
"JSON Editor": "עורך JSON", "JSON Editor": "עורך JSON",
"Delete": "מחיקה", "Delete": "מחיקה",
@@ -86,7 +79,6 @@
"center:": "מרכז:", "center:": "מרכז:",
"rotation:": "סיבוב:", "rotation:": "סיבוב:",
"Close modal": "סגירת חלונית", "Close modal": "סגירת חלונית",
"Layer ID already exists": "מזהה השכבה כבר קיים",
"Debug": "דיבאג", "Debug": "דיבאג",
"Options": "אפשרויות", "Options": "אפשרויות",
"Save Style": "שמירת הסטייל", "Save Style": "שמירת הסטייל",
@@ -190,8 +182,6 @@
"Public access token for Thunderforest services.": "Public access token for Thunderforest services.", "Public access token for Thunderforest services.": "Public access token for Thunderforest services.",
"Stadia Maps API Key": "Stadia Maps API Key", "Stadia Maps API Key": "Stadia Maps API Key",
"API key for Stadia Maps.": "API key for Stadia Maps", "API key for Stadia Maps.": "API key for Stadia Maps",
"LocationIQ Access Token": "LocationIQ Access Token",
"Public access token for LocationIQ services.": "Public access token for LocationIQ services.",
"Style Renderer": "צייר הסטייל", "Style Renderer": "צייר הסטייל",
"Choose the default Maputnik renderer for this style.": "בחירת צייר ברירת המחדל של מפוטניק עבור הסטייל הזה", "Choose the default Maputnik renderer for this style.": "בחירת צייר ברירת המחדל של מפוטניק עבור הסטייל הזה",
"Language": "שפה", "Language": "שפה",
-10
View File
@@ -59,13 +59,6 @@
"Must provide protocol: <1>https://</1>": "Protocollo richiesto: <1>https://</1>", "Must provide protocol: <1>https://</1>": "Protocollo richiesto: <1>https://</1>",
"Must provide protocol: <1>http://</1> or <3>https://</3>": "Protocollo richiesto: <1>http://</1> o <3>https://</3>", "Must provide protocol: <1>http://</1> or <3>https://</3>": "Protocollo richiesto: <1>http://</1> o <3>https://</3>",
"CORS policy won't allow fetching resources served over http from https, use a <1>https://</1> domain": "La politica CORS non permette di caricare risorse tramite http da https, utilizza un dominio <1>https://</1>.", "CORS policy won't allow fetching resources served over http from https, use a <1>https://</1> domain": "La politica CORS non permette di caricare risorse tramite http da https, utilizza un dominio <1>https://</1>.",
"General layout properties": "Proprietà generali del layout",
"Text layout properties": "Proprietà di layout del testo",
"Icon layout properties": "Proprietà di layout dell'icona",
"Text paint properties": "Proprietà di pittura del testo",
"Icon paint properties": "Proprietà di pittura dell'icona",
"Paint properties": "Proprietà di pittura",
"Layout properties": "Proprietà di layout",
"Layer": "Livello", "Layer": "Livello",
"JSON Editor": "Editor JSON", "JSON Editor": "Editor JSON",
"Delete": "Elimina", "Delete": "Elimina",
@@ -86,7 +79,6 @@
"center:": "centro:", "center:": "centro:",
"rotation:": "rotazione:", "rotation:": "rotazione:",
"Close modal": "Chiudi finestra modale", "Close modal": "Chiudi finestra modale",
"Layer ID already exists": "L'ID del layer esiste già",
"Debug": "Debug", "Debug": "Debug",
"Options": "Opzioni", "Options": "Opzioni",
"Save Style": "Opzioni stile", "Save Style": "Opzioni stile",
@@ -190,8 +182,6 @@
"Public access token for Thunderforest services.": "Token di accesso pubblico per i servizi Thunderforest.", "Public access token for Thunderforest services.": "Token di accesso pubblico per i servizi Thunderforest.",
"Stadia Maps API Key": "Chiave API di Stadia Maps.", "Stadia Maps API Key": "Chiave API di Stadia Maps.",
"API key for Stadia Maps.": "Chiave API per Stadia Maps.", "API key for Stadia Maps.": "Chiave API per Stadia Maps.",
"LocationIQ Access Token": "Token di accesso LocationIQ",
"Public access token for LocationIQ services.": "Token di accesso pubblico per i servizi LocationIQ.",
"Style Renderer": "Renderer dello stile", "Style Renderer": "Renderer dello stile",
"Choose the default Maputnik renderer for this style.": "Scegli il renderer predefinito di Maputnik per questo stile.", "Choose the default Maputnik renderer for this style.": "Scegli il renderer predefinito di Maputnik per questo stile.",
"Language": "Lingua", "Language": "Lingua",
+8 -11
View File
@@ -59,13 +59,6 @@
"Must provide protocol: <1>https://</1>": "プロトコルを指定してください: <1>https://</1>", "Must provide protocol: <1>https://</1>": "プロトコルを指定してください: <1>https://</1>",
"Must provide protocol: <1>http://</1> or <3>https://</3>": "プロトコルを指定してください: <1>http://</1> または <3>https://</3>", "Must provide protocol: <1>http://</1> or <3>https://</3>": "プロトコルを指定してください: <1>http://</1> または <3>https://</3>",
"CORS policy won't allow fetching resources served over http from https, use a <1>https://</1> domain": "CORS使用時は、http経由で提供されるリソースをhttpsから取得することはできません。<1>https://</1> ドメインを使用してください。", "CORS policy won't allow fetching resources served over http from https, use a <1>https://</1> domain": "CORS使用時は、http経由で提供されるリソースをhttpsから取得することはできません。<1>https://</1> ドメインを使用してください。",
"General layout properties": "一般レイアウトプロパティ",
"Text layout properties": "文字レイアウトプロパティ",
"Icon layout properties": "アイコンレイアウトプロパティ",
"Text paint properties": "文字ペイントプロパティ",
"Icon paint properties": "アイコンペイントプロパティ",
"Paint properties": "ペイントプロパティ",
"Layout properties": "レイアウトプロパティ",
"Layer": "レイヤー", "Layer": "レイヤー",
"JSON Editor": "JSONエディタ", "JSON Editor": "JSONエディタ",
"Delete": "削除", "Delete": "削除",
@@ -86,7 +79,6 @@
"center:": "中央:", "center:": "中央:",
"rotation:": "回転角度:", "rotation:": "回転角度:",
"Close modal": "モーダルを閉じる", "Close modal": "モーダルを閉じる",
"Layer ID already exists": "レイヤーIDは既に存在します",
"Debug": "デバッグ", "Debug": "デバッグ",
"Options": "設定", "Options": "設定",
"Save Style": "スタイルを保存", "Save Style": "スタイルを保存",
@@ -190,11 +182,16 @@
"Public access token for Thunderforest services.": "Thunderforest サービスの公開用アクセストークン", "Public access token for Thunderforest services.": "Thunderforest サービスの公開用アクセストークン",
"Stadia Maps API Key": "Stadia Maps API キー", "Stadia Maps API Key": "Stadia Maps API キー",
"API key for Stadia Maps.": "Stadia Maps の API キー", "API key for Stadia Maps.": "Stadia Maps の API キー",
"LocationIQ Access Token": "LocationIQ アクセストークン",
"Public access token for LocationIQ services.": "LocationIQ サービス用のパブリックアクセストークン。",
"Style Renderer": "スタイルレンダラ", "Style Renderer": "スタイルレンダラ",
"Choose the default Maputnik renderer for this style.": "このスタイルのデフォルトの Maputnik レンダラを選択してください", "Choose the default Maputnik renderer for this style.": "このスタイルのデフォルトの Maputnik レンダラを選択してください",
"Language": "言語", "Language": "言語",
"<0>Open in OSM</0> — Opens the current view on openstreetmap.org": "現在のビューを <0>openstreetmap.org で開く</0>", "<0>Open in OSM</0> — Opens the current view on openstreetmap.org": "現在のビューを <0>openstreetmap.org で開く</0>",
"Layer options": "レイヤー設定" "Layer options": "レイヤー設定",
"Paint properties": "ペイントプロパティ",
"Layout properties": "レイアウトプロパティ",
"General layout properties": "一般レイアウトプロパティ",
"Text layout properties": "文字レイアウトプロパティ",
"Icon layout properties": "アイコンレイアウトプロパティ",
"Text paint properties": "文字ペイントプロパティ",
"Icon paint properties": "アイコンペイントプロパティ"
} }
+13 -16
View File
@@ -59,13 +59,6 @@
"Must provide protocol: <1>https://</1>": "必须提供协议:<1>https://</1>", "Must provide protocol: <1>https://</1>": "必须提供协议:<1>https://</1>",
"Must provide protocol: <1>http://</1> or <3>https://</3>": "必须提供协议:<1>http://</1> 或 <3>https://</3>", "Must provide protocol: <1>http://</1> or <3>https://</3>": "必须提供协议:<1>http://</1> 或 <3>https://</3>",
"CORS policy won't allow fetching resources served over http from https, use a <1>https://</1> domain": "CORS 策略不允许从 https 访问通过 http 提供的资源,请使用 <1>https://</1> 域名。", "CORS policy won't allow fetching resources served over http from https, use a <1>https://</1> domain": "CORS 策略不允许从 https 访问通过 http 提供的资源,请使用 <1>https://</1> 域名。",
"General layout properties": "常规布局属性",
"Text layout properties": "文本布局属性",
"Icon layout properties": "图标布局属性",
"Text paint properties": "文本绘制属性",
"Icon paint properties": "图标绘制属性",
"Paint properties": "绘制属性",
"Layout properties": "布局属性",
"Layer": "图层", "Layer": "图层",
"JSON Editor": "JSON编辑器", "JSON Editor": "JSON编辑器",
"Delete": "删除", "Delete": "删除",
@@ -86,7 +79,6 @@
"center:": "中心:", "center:": "中心:",
"rotation:": "旋转:", "rotation:": "旋转:",
"Close modal": "关闭模态框", "Close modal": "关闭模态框",
"Layer ID already exists": "图层ID已存在",
"Debug": "调试", "Debug": "调试",
"Options": "选项", "Options": "选项",
"Save Style": "保存样式", "Save Style": "保存样式",
@@ -157,7 +149,7 @@
"Raster (Tile URLs)": "栅格数据 (Tile URLs)", "Raster (Tile URLs)": "栅格数据 (Tile URLs)",
"Raster DEM (TileJSON URL)": "栅格高程数据 (TileJSON URL)", "Raster DEM (TileJSON URL)": "栅格高程数据 (TileJSON URL)",
"Raster DEM (XYZ URLs)": "栅格高程数据 (XYZ URLs)", "Raster DEM (XYZ URLs)": "栅格高程数据 (XYZ URLs)",
"Vector (PMTiles)": "矢量数据 (PMTiles)", "Vector (PMTiles)": "__STRING_NOT_TRANSLATED__",
"Image": "图像", "Image": "图像",
"Video": "视频", "Video": "视频",
"Add Source": "添加源", "Add Source": "添加源",
@@ -169,7 +161,7 @@
"Add a new source to your style. You can only choose the source type and id at creation time!": "向您的样式添加新源。在创建时,您只能选择源类型和ID!", "Add a new source to your style. You can only choose the source type and id at creation time!": "向您的样式添加新源。在创建时,您只能选择源类型和ID!",
"TileJSON URL": "TileJSON URL", "TileJSON URL": "TileJSON URL",
"Tile URL": "瓦片URL", "Tile URL": "瓦片URL",
"Scheme Type": "瓦片方案", "Scheme Type": "__STRING_NOT_TRANSLATED__",
"Coord top left": "左上角坐标", "Coord top left": "左上角坐标",
"Coord top right": "右上角坐标", "Coord top right": "右上角坐标",
"Coord bottom right": "右下角坐标", "Coord bottom right": "右下角坐标",
@@ -179,22 +171,27 @@
"GeoJSON URL": "GeoJSON URL", "GeoJSON URL": "GeoJSON URL",
"GeoJSON": "GeoJSON", "GeoJSON": "GeoJSON",
"Cluster": "聚合", "Cluster": "聚合",
"PMTiles URL": "PMTiles URL", "PMTiles URL": "__STRING_NOT_TRANSLATED__",
"Tile Size": "瓦片大小", "Tile Size": "__STRING_NOT_TRANSLATED__",
"Encoding": "编码", "Encoding": "编码",
"Error:": "错误:", "Error:": "错误:",
"MapTiler Access Token": "MapTiler 访问令牌", "MapTiler Access Token": "MapTiler 访问令牌",
"Public access token for MapTiler Cloud.": "MapTiler Cloud 的公共访问令牌。", "Public access token for MapTiler Cloud.": "MapTiler Cloud 的公共访问令牌。",
"Learn More": "了解更多", "Learn More": "__STRING_NOT_TRANSLATED__",
"Thunderforest Access Token": "Thunderforest 访问令牌", "Thunderforest Access Token": "Thunderforest 访问令牌",
"Public access token for Thunderforest services.": "Thunderforest 服务的公共访问令牌。", "Public access token for Thunderforest services.": "Thunderforest 服务的公共访问令牌。",
"Stadia Maps API Key": "Stadia Maps API 密钥", "Stadia Maps API Key": "Stadia Maps API 密钥",
"API key for Stadia Maps.": "Stadia Maps 的 API 密钥", "API key for Stadia Maps.": "Stadia Maps 的 API 密钥",
"LocationIQ Access Token": "LocationIQ 访问令牌",
"Public access token for LocationIQ services.": "LocationIQ 服务的公共访问令牌。",
"Style Renderer": "样式渲染器", "Style Renderer": "样式渲染器",
"Choose the default Maputnik renderer for this style.": "为这种样式选择默认的Maputnik渲染器。", "Choose the default Maputnik renderer for this style.": "为这种样式选择默认的Maputnik渲染器。",
"Language": "语言", "Language": "语言",
"<0>Open in OSM</0> — Opens the current view on openstreetmap.org": "在 openstreetmap.org 打开当前视图", "<0>Open in OSM</0> — Opens the current view on openstreetmap.org": "在 openstreetmap.org 打开当前视图",
"Layer options": "图层选项" "Layer options": "图层选项",
"Paint properties": "绘制属性",
"Layout properties": "布局属性",
"General layout properties": "常规布局属性",
"Text layout properties": "文本布局属性",
"Icon layout properties": "图标布局属性",
"Text paint properties": "文本绘制属性",
"Icon paint properties": "图标绘制属性"
} }
+28 -7
View File
@@ -14,18 +14,26 @@
color: vars.$color-lowgray; color: vars.$color-lowgray;
} }
.maputnik-filter-editor-single { .maputnik-filter-editor-property {
display: flex; display: inline-block;
column-gap: 6px; width: 25%;
} }
.maputnik-filter-editor-operator { .maputnik-filter-editor-operator {
margin: 0 2%;
display: inline-block;
width: 17%;
.maputnik-select { .maputnik-select {
width: 100%; width: 100%;
} }
} }
.maputnik-filter-editor-args { .maputnik-filter-editor-args {
display: inline-block;
width: 54%;
margin: 0 2%;
.maputnik-string, .maputnik-string,
.maputnik-number { .maputnik-number {
width: 100%; width: 100%;
@@ -55,13 +63,26 @@
margin-top: vars.$margin-3; margin-top: vars.$margin-3;
} }
.maputnik-filter-editor-block { .maputnik-delete-filter {
display: flex; @extend .maputnik-icon-button !optional; /* stylelint-disable-line */
column-gap: 6px; }
margin-bottom: 2px;
.maputnik-filter-editor-block-action {
margin-top: vars.$margin-2;
margin-bottom: vars.$margin-2;
display: inline-block;
width: 6%;
margin-right: 1.5%;
}
.maputnik-filter-editor-block-content {
display: inline-block;
width: 92.5%;
} }
.maputnik-radio-as-button { .maputnik-radio-as-button {
@extend .maputnik-button !optional; /* stylelint-disable-line */
border: solid 1px transparent; border: solid 1px transparent;
&:focus-within { &:focus-within {
+3 -4
View File
@@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import istanbul from "vite-plugin-istanbul"; import istanbul from "vite-plugin-istanbul";
export default defineConfig(({ mode }) => ({ export default defineConfig({
server: { server: {
port: 8888, port: 8888,
}, },
@@ -27,8 +27,7 @@ export default defineConfig(({ mode }) => ({
forceBuildInstrument: true, //Instrument the source code for cypress runs forceBuildInstrument: true, //Instrument the source code for cypress runs
}), }),
], ],
base: mode === "desktop" ? "/" : "/maputnik/",
define: { define: {
global: "window" global: "window",
}, },
})); });