Compare commits

..

2 Commits

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-17 13:37:58 +00:00
136 changed files with 3192 additions and 3620 deletions
+1
View File
@@ -8,3 +8,4 @@ assignees: ''
--- ---
<!-- Thanks for reaching out! If you are having general Maputnik mapping questions, please asking them at https://gis.stackexchange.com/ using the 'maputnik' tag https://gis.stackexchange.com/questions/tagged/maputnik and read https://gis.stackexchange.com/help/how-to-ask before you do so (please keep in mind that you're asking there in a general GIS forum, not a dedicated support channel) --> <!-- Thanks for reaching out! If you are having general Maputnik mapping questions, please asking them at https://gis.stackexchange.com/ using the 'maputnik' tag https://gis.stackexchange.com/questions/tagged/maputnik and read https://gis.stackexchange.com/help/how-to-ask before you do so (please keep in mind that you're asking there in a general GIS forum, not a dedicated support channel) -->
+1
View File
@@ -8,3 +8,4 @@
- [ ] Include before/after visuals or gifs if this PR includes visual changes. - [ ] Include before/after visuals or gifs if this PR includes visual changes.
- [ ] Write tests for all new functionality. - [ ] Write tests for all new functionality.
- [ ] Add an entry to `CHANGELOG.md` under the `## main` section. - [ ] Add an entry to `CHANGELOG.md` under the `## main` section.
+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
+62 -68
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 }}
@@ -17,17 +27,17 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
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
- run: npm run build - run: npm run build
- run: npm run lint - run: npm run lint
- run: npm run lint-css - run: npm run lint-css
@@ -37,81 +47,65 @@ 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
- run: npm run build - run: npm run build
- name: artifacts/maputnik - name: artifacts/maputnik
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: maputnik name: maputnik
path: dist path: dist
# 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
id: go id: go
- name: Build desktop artifacts - name: Build desktop artifacts
run: npm run build-desktop run: npm run build-desktop
- name: Artifacts/linux - name: Artifacts/linux
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: maputnik-linux name: maputnik-linux
path: ./desktop/bin/linux/ path: ./desktop/bin/linux/
- name: Artifacts/darwin - name: Artifacts/darwin
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: maputnik-darwin name: maputnik-darwin
path: ./desktop/bin/darwin/ path: ./desktop/bin/darwin/
- name: Artifacts/windows - name: Artifacts/windows
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: maputnik-windows name: maputnik-windows
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"
+11 -11
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'
@@ -40,12 +40,12 @@ jobs:
fail-fast: false fail-fast: false
steps: steps:
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
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
+7 -6
View File
@@ -2,7 +2,7 @@ name: Release
on: on:
push: push:
branches: [ main ] branches: [main]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -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
@@ -99,3 +99,4 @@ jobs:
allowUpdates: true allowUpdates: true
draft: false draft: false
prerelease: false prerelease: false
-24
View File
@@ -1,24 +0,0 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
ci:
autoupdate_schedule: monthly
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-added-large-files
- id: check-executables-have-shebangs
- id: check-json
exclude: 'tsconfig(\.node)?\.json'
- id: check-shebang-scripts-are-executable
- id: check-symlinks
- id: check-toml
- id: check-yaml
args: [ --allow-multiple-documents ]
- id: destroyed-symlinks
- id: end-of-file-fixer
- id: mixed-line-ending
args: [ --fix=lf ]
- id: trailing-whitespace
-47
View File
@@ -1,47 +0,0 @@
Maputnik is a MapLibre style editor written using React and TypeScript.
To get started, install all npm packages:
```
npm install
```
Verify code correctness by running ESLint:
```
npm run lint
```
Or try fixing lint issues with:
```
npm run lint -- --fix
```
The project type checked and built with:
```
npm run build
```
To run the tests make sure that xvfb is installed:
```
apt install xvfb
```
Run the development server in the background with Vite:
```
nohup npm run start &
```
Then start the Cypress tests with:
```
xvfb-run -a npm run test
```
## Pull Requests
- Pull requests should update `CHANGELOG.md` with a short description of the change.
+3 -22
View File
@@ -1,16 +1,6 @@
## main ## 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
@@ -52,3 +32,4 @@
## 1.7.0 ## 1.7.0
- See release notes at https://maputnik.github.io/blog/2020/04/23/release-v1.7.0 - See release notes at https://maputnik.github.io/blog/2020/04/23/release-v1.7.0
+10 -8
View File
@@ -1,14 +1,16 @@
FROM golang:1.23-alpine AS builder FROM node:18 as builder
WORKDIR /maputnik 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/
+1
View File
@@ -20,3 +20,4 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
+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");
+1 -69
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");
@@ -257,7 +245,7 @@ describe("modals", () => {
when.click("nav:open"); when.click("nav:open");
get.elementByAttribute('aria-label', "MapTiler Basic").should('exist').click(); get.elementByAttribute('aria-label', "MapTiler Basic").should('exist').click();
when.wait(1000);
when.click("nav:settings"); when.click("nav:settings");
when.select("modal:settings.maputnik:renderer", "mlgljs"); when.select("modal:settings.maputnik:renderer", "mlgljs");
@@ -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();
});
});
}); });
@@ -15,3 +15,4 @@
} }
] ]
} }
+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"
+1423 -1628
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.13",
"@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.83.4",
"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.22.0",
"uuid": "^13.0.0", "uuid": "^11.0.5",
"vite": "^7.1.5", "vite": "^6.1.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
+1
View File
@@ -102,3 +102,4 @@ export default class Block extends React.Component<BlockProps, BlockState> {
</label> </label>
} }
} }
+1
View File
@@ -31,3 +31,4 @@ export default class Collapse extends React.Component<CollapseProps> {
} }
} }
} }
+1
View File
@@ -16,3 +16,4 @@ export default class Collapser extends React.Component<CollapserProps> {
return this.props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} /> return this.props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} />
} }
} }
+7 -7
View File
@@ -1,3 +1,4 @@
import React from 'react'
import InputArray, { FieldArrayProps as InputArrayProps } from './InputArray' import InputArray, { FieldArrayProps as InputArrayProps } from './InputArray'
import Fieldset from './Fieldset' import Fieldset from './Fieldset'
@@ -8,12 +9,11 @@ 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 -7
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,11 @@ 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 -7
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,11 @@ 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 -7
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,11 @@ 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 -7
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,11 @@ 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 -218
View File
@@ -111,268 +111,297 @@ 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 -4
View File
@@ -1,11 +1,13 @@
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 -7
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,11 @@ 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;
+8 -7
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,12 @@ 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 -8
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,13 @@ 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>
} }
} }
+2
View File
@@ -11,3 +11,5 @@ export default class IconBackground extends React.Component {
) )
} }
} }
+2
View File
@@ -11,3 +11,5 @@ export default class IconCircle extends React.Component {
) )
} }
} }
+2
View File
@@ -11,3 +11,5 @@ export default class IconFill extends React.Component {
) )
} }
} }
+1
View File
@@ -30,3 +30,4 @@ export default class IconLayer extends React.Component<IconLayerProps> {
} }
} }
} }
+2
View File
@@ -11,3 +11,5 @@ export default class IconLine extends React.Component {
) )
} }
} }
+3
View File
@@ -13,3 +13,6 @@ export default class IconSymbol extends React.Component {
) )
} }
} }
+1
View File
@@ -115,3 +115,4 @@ export default class FieldArray extends React.Component<FieldArrayProps, FieldAr
) )
} }
} }
-18
View File
@@ -1,18 +0,0 @@
import InputAutocomplete from './InputAutocomplete'
import { mount } from 'cypress/react'
const fruits = ['apple', 'banana', 'cherry'];
describe('<InputAutocomplete />', () => {
it('filters options when typing', () => {
mount(
<InputAutocomplete aria-label="Fruit" options={fruits.map(f => [f, f])} />
);
cy.get('input').focus();
cy.get('.maputnik-autocomplete-menu-item').should('have.length', 3);
cy.get('input').type('ch');
cy.get('.maputnik-autocomplete-menu-item').should('have.length', 1).and('contain', 'cherry');
cy.get('.maputnik-autocomplete-menu-item').click();
cy.get('input').should('have.value', 'cherry');
});
});
+82 -96
View File
@@ -1,116 +1,102 @@
import React from 'react' import 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
View File
@@ -133,3 +133,4 @@ export default class InputColor extends React.Component<InputColorProps> {
</div> </div>
} }
} }
+1
View File
@@ -152,3 +152,4 @@ class DeleteValueInputButton extends React.Component<DeleteValueInputButtonProps
</InputButton> </InputButton>
} }
} }
+1
View File
@@ -47,3 +47,4 @@ export default class InputEnum extends React.Component<InputEnumProps> {
} }
} }
} }
+3 -1
View File
@@ -20,7 +20,7 @@ export default class InputMultiInput extends React.Component<InputMultiInputProp
const radios = options.map(([val, label])=> { 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}
@@ -37,3 +37,5 @@ export default class InputMultiInput extends React.Component<InputMultiInputProp
</fieldset> </fieldset>
} }
} }
+2
View File
@@ -244,3 +244,5 @@ export default class InputNumber extends React.Component<InputNumberProps, Input
} }
} }
} }
+2
View File
@@ -30,3 +30,5 @@ export default class InputSelect extends React.Component<InputSelectProps> {
</select> </select>
} }
} }
+2
View File
@@ -97,3 +97,5 @@ export default class InputString extends React.Component<InputStringProps, Input
}); });
} }
} }
+88 -142
View File
@@ -1,10 +1,9 @@
import React, {type JSX} from 'react' import 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> {
+1
View File
@@ -11,3 +11,4 @@ export default class ScrollContainer extends React.Component<ScrollContainerProp
</div> </div>
} }
} }
+1
View File
@@ -90,3 +90,4 @@ export default class SingleFilterEditor extends React.Component<SingleFilterEdit
</div> </div>
} }
} }
+21 -17
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,28 @@ 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);
+5 -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
@@ -25,3 +24,4 @@ export function changeSource(mapStyle: StyleSpecificationWithId, sourceId: strin
sources: changedSources sources: changedSources
} }
} }
-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
} }
} }

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