Compare commits

..

42 Commits

Author SHA1 Message Date
HarelM 1a5c879c75 Improve tests. 2026-07-11 15:33:31 +03:00
HarelM b16776fed3 Merge branch 'main' into increase-coverage 2026-07-10 21:40:37 +03:00
HarelM d039dc73a5 Increase coverage to about 80% 2026-07-10 21:11:41 +03:00
HarelM 70a2db580f replace for ;; with while true. 2026-07-09 16:31:27 +03:00
HarelM b76d9cbe29 Remove circular dependency. 2026-07-09 15:38:41 +03:00
HarelM dd04c14d96 Remove maputnik assertable 2026-07-09 15:28:36 +03:00
HarelM 6c31add041 Reduce export surface 2026-07-09 15:22:41 +03:00
HarelM 60ed80651c Improve abstraction 2026-07-09 15:05:18 +03:00
HarelM c642ec5325 Remove comments 2026-07-09 15:05:18 +03:00
pre-commit-ci[bot] 0c98378243 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-09 11:44:08 +00:00
HarelM 95ba3c4428 Remove cypress related comments. 2026-07-09 14:43:58 +03:00
HarelM b112c6a2bf Split dirver and helper 2026-07-09 14:40:06 +03:00
HarelM a251faf8f7 improve abstraction 2026-07-09 14:27:33 +03:00
HarelM 8580499ec3 Fix incorrect quotes 2026-07-09 14:19:47 +03:00
HarelM 56a9840def Remove unused page 2026-07-09 14:19:23 +03:00
HarelM 3d1919738b Improve test readability 2026-07-09 14:16:12 +03:00
HarelM cc317c20a1 Move fill local storage to driver 2026-07-09 14:11:49 +03:00
HarelM a9c3da0f40 Remove hack from modal driver 2026-07-09 13:59:55 +03:00
HarelM dc5d6dd77d Remove unneeded comments. 2026-07-09 13:50:16 +03:00
HarelM 95d87ad7ef Remove export for a method that isn't used outside the file. 2026-07-09 13:41:51 +03:00
HarelM 09efbb5cd6 Fix utils path 2026-07-09 13:40:45 +03:00
HarelM 4f046cb83e Fix build 2026-07-09 13:38:54 +03:00
HarelM fb00dea285 Move playwrite stuff to utils folder 2026-07-09 13:33:29 +03:00
HarelM c00395ee11 Remove coverage file and have everything inside the fixtures.ts file. 2026-07-09 13:29:42 +03:00
pre-commit-ci[bot] 5092a924aa [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-09 10:27:14 +00:00
HarelM 7d80dccb81 Remote test related entries. 2026-07-09 13:26:24 +03:00
HarelM a93fe4d78b Fix test for regexp 2026-07-09 12:35:29 +03:00
Harel M 603a30cba8 Potential fix for pull request finding 'CodeQL / Incomplete regular expression for hostnames'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-07-09 12:19:45 +03:00
HarelM 6f55fda7d4 Remove docker coverage upload. 2026-07-09 11:47:40 +03:00
HarelM 8f0ee898ff Merge branch 'main' into replace-cypress-with-playwright 2026-07-09 11:43:28 +03:00
HarelM 3896c4c187 Merge branch 'relocate-e2e-tests' into replace-cypress-with-playwright 2026-07-08 22:44:19 +03:00
HarelM c0f1a72d80 Fix failing unit tests in CI 2026-07-08 22:43:34 +03:00
HarelM ac9186e7f8 test: instantiate MaputnikDriver once per describe block
Make the driver page-lazy (it resolves the running test's page on demand via
an auto fixture) so it can be created a single time at describe scope and
reused across the block's tests, matching the pre-migration ergonomics:

  const { given, get, when, then } = new MaputnikDriver();

instead of pulling `driver` out of a fixture and destructuring it in every
test. Coverage collection and dialog handling move into the auto fixture.

Also inject a bare invalid token ("zzz") in the json-editor parse-error test:
CodeMirror auto-closes brackets/quotes, so " {" no longer reliably breaks the
JSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:48:44 +03:00
Harel M 8c86607100 Add alias for 'it' as 'test' in layers list spec 2026-07-08 15:35:34 +03:00
HarelM 7c5e5358cb Merge branch 'relocate-e2e-tests' into replace-cypress-with-playwright 2026-07-08 15:17:09 +03:00
HarelM 18b34d3ecd Fix lint 2026-07-08 15:16:28 +03:00
HarelM 08ebca266c Merge branch 'relocate-e2e-tests' into replace-cypress-with-playwright 2026-07-08 15:15:39 +03:00
HarelM 8344a30f5d Move things back to where this will still work. 2026-07-08 15:05:35 +03:00
Harel M 4249e9beb9 Merge branch 'main' into relocate-e2e-tests 2026-07-08 14:32:25 +03:00
HarelM c3dd253737 test: migrate e2e to Playwright and component test to Vitest browser mode
Replace Cypress with Playwright for the end-to-end suite and drop
@shellygo/cypress-test-utils in favour of a hand-written MaputnikDriver
page object that keeps the fluent then(...).shouldX() assertion style
(now async). The InputAutocomplete component test moves to Vitest browser
mode using the Playwright provider.

- e2e/maputnik-driver.ts: driver + MaputnikAssertable over Playwright
- e2e/{fixtures,coverage,global-setup,global-teardown}.ts: test fixture,
  istanbul coverage collection, and nyc report generation
- playwright.config.ts / vitest.config.ts
- Code coverage preserved: dev server is istanbul-instrumented, per-test
  window.__coverage__ is merged via nyc into coverage/coverage-final.json
- CI: Cypress jobs replaced with Playwright; docker e2e runs against the
  container via E2E_NO_WEBSERVER
- Remove Cypress deps, config and support files; update docs and .nycrc

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:42:32 +03:00
HarelM f7b48139a4 test: adopt Playwright test()/test.describe() naming in e2e specs
Mechanical rename of it() -> test() and describe() -> test.describe() so the
subsequent Playwright migration diff only touches test bodies, not the
suite/case wrappers.
2026-07-08 12:42:13 +03:00
HarelM 8af1cfd5f8 test: relocate Cypress e2e suite to e2e/ ahead of Playwright migration
Pure file moves (no content changes) so git records them as renames and
history/blame is preserved through the migration that follows:
  cypress/e2e/*.cy.ts        -> e2e/*.spec.ts
  cypress/e2e/*-driver.ts    -> e2e/*-driver.ts
  cypress/fixtures/          -> e2e/fixtures/
  cypress.config.ts          -> playwright.config.ts
  InputAutocomplete.cy.tsx   -> InputAutocomplete.browser.test.tsx

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:24:03 +03:00
116 changed files with 6083 additions and 6236 deletions
+1 -104
View File
@@ -36,7 +36,7 @@ Then run the end-to-end tests (Playwright starts the dev server automatically):
npm run test npm run test
``` ```
Run the unit tests with Vitest: Run the unit and component tests with Vitest:
``` ```
npm run test-unit npm run test-unit
@@ -45,106 +45,3 @@ npm run test-unit
## Pull Requests ## Pull Requests
- Pull requests should update `CHANGELOG.md` with a short description of the change. - Pull requests should update `CHANGELOG.md` with a short description of the change.
## Testing
### Prefer end-to-end tests
Most of this codebase is React components, and they are only reachable from an
end-to-end test. E2E coverage is the primary signal.
Reach for a unit test only for pure logic that e2e cannot cheaply reach (parsers,
sorting, watchers, stores). Before writing one, check whether e2e already covers
the file — a unit test that duplicates existing e2e coverage adds test code and
almost no coverage:
```
npx nyc report --reporter=text --include="src/libs/style.ts"
```
Do **not** merge the Vitest (v8) and e2e (istanbul) coverage reports locally. They
produce conflicting statement maps for the same files and the combined percentage
is meaningless. Codecov merges the two uploads server-side; that is the number to
trust. Locally, read them separately:
- e2e: `npx playwright test` then `npx nyc report --reporter=text-summary` (reads `.nyc_output/`)
- unit: `npx vitest run --coverage` (writes `coverage/`)
### E2E layering
Three layers, and the boundaries matter:
- `e2e/playwright-helper.ts` — generic, app-agnostic browser actions. **The only
file allowed to import `@playwright/test`** (besides `e2e/utils/fixtures.ts`).
- `e2e/maputnik-driver.ts` — domain actions (layers, filters, functions, the
style). Knows nothing about `page` or Playwright.
- `e2e/modal-driver.ts` — actions scoped to a modal, exposed as `when.modal.*`.
Specs get a driver at describe scope and assert fluently:
```ts
describe("layer editor", () => {
const { given, get, when, then } = new MaputnikDriver();
...
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ layers: [{ id, type: "fill" }] });
});
```
New UI interactions belong in a driver, not inline in a spec.
### Writing assertions
- `shouldDeepNestedInclude` is a recursive partial match (`toMatchObject`): nested
objects are matched as subsets, arrays and primitives must match exactly
(including array length).
- Assert against the whole style, not an extracted slice. Avoid
`get.styleFromLocalStorage().then(style => style.layers.find(...))` — it moves
test logic into the test. Compare the real object instead.
- `Query.then()` is lazy and returns a new `Query`, **not** a Promise. `await
get.styleFromLocalStorage()` hangs forever. Use `.get()` to await it directly,
or pass the Query to `then(...)`.
### One behaviour per test
If a test needs comments narrating "and now this…", it is several tests. Split it,
and hoist the shared setup into a nested `describe` + `beforeEach`.
### Test ids
Test ids use the `data-wd-key` attribute and are read via `get.elementByTestId`.
The `Input*` components already accept `data-wd-key` and render it on the real
`<input>`; the `Field*` wrappers forward it through their `{...props}` spread. So
passing `data-wd-key` to a `Field*` component is usually enough. Do **not** also
add it to `Block`/`Fieldset` — the id then matches two elements and locators fail
in strict mode.
Note `InputNumber` renders `<key>-text` and `<key>-range` when `allowRange` is set,
and `<key>` otherwise.
### Input commit semantics (common source of "the value didn't save")
- `InputString` only fires its `onChange` on **blur** or **Enter**. Typing alone
fires `onInput`. A driver that calls `fill()` must then call `blur()`, or the
value never reaches the style.
- `InputNumber` commits on every change; no blur needed.
- The autocomplete inputs (layer source, add-layer source) are controlled
downshift comboboxes. Keystroke typing is dropped/reordered — `{selectall}` then
typing `raster` yields `"exampleaster"`. Use `fill()`, which dispatches a single
input event, then pick from the filtered menu.
- CodeMirror auto-closes brackets and quotes, and types over its own closers, so
inserting a well-formed JSON fragment stays well-formed. To break JSON on
purpose, insert a bare word.
### Fixtures
Style fixtures live in `e2e/fixtures/`. A new one must be registered in two places
in `maputnik-driver.ts`: the list in `given.setupMockBackedResponses` and the
`styleFileByKey` map in `when.setStyle`.
### Verify a new test can fail
A test that passes for the wrong reason is worse than no test. After writing one,
mutate the expected value and confirm it fails. This has caught real mistakes
(e.g. a driver that never committed its input, so the assertion was matching a
value written by the *previous* step).
-60
View File
@@ -1,60 +0,0 @@
import { test, expect, describe, beforeEach } from "./utils/fixtures";
import { MaputnikDriver } from "./maputnik-driver";
import tokens from "../src/config/tokens.json" with { type: "json" };
describe("access tokens", () => {
const { given, when } = new MaputnikDriver();
const tileJson = {
tilejson: "2.2.0",
tiles: ["https://example.local/{z}/{x}/{y}.pbf"],
minzoom: 0,
maxzoom: 14,
};
beforeEach(async () => {
await given.setupMockBackedResponses();
});
test("uses the thunderforest token for a thunderforest source", async () => {
await given.interceptAndMockResponse({
method: "GET",
url: /tile\.thunderforest\.com\/.*/,
response: tileJson,
alias: "thunderforest",
});
await when.setStyle("access_tokens");
const request = await when.waitForResponse("thunderforest");
expect(request.url()).toContain(`apikey=${tokens.thunderforest}`);
});
test("uses the locationiq token for a locationiq source", async () => {
await given.interceptAndMockResponse({
method: "GET",
url: /tiles\.locationiq\.com\/.*/,
response: tileJson,
alias: "locationiq",
});
await when.setStyle("access_tokens");
const request = await when.waitForResponse("locationiq");
expect(request.url()).toContain(`key=${tokens.locationiq}`);
});
test("appends the stadia token as a query parameter", async () => {
await given.interceptAndMockResponse({
method: "GET",
url: /tiles\.stadiamaps\.com\/.*/,
response: tileJson,
alias: "stadia",
});
await when.setStyle("access_tokens");
const request = await when.waitForResponse("stadia");
expect(request.url()).toContain("?api_key=stadia-test-token");
});
});
-22
View File
@@ -1,22 +0,0 @@
{
"version": 8,
"name": "Access token style",
"metadata": {
"maputnik:stadia_access_token": "stadia-test-token"
},
"sources": {
"thunderforest_transport": {
"type": "vector",
"url": "https://tile.thunderforest.com/thunderforest.transport-v2.json?apikey={key}"
},
"stadia_outdoors": {
"type": "vector",
"url": "https://tiles.stadiamaps.com/data/openmaptiles.json"
},
"locationiq": {
"type": "vector",
"url": "https://tiles.locationiq.com/v3/pbf/tiles.json?key={key}"
}
},
"layers": []
}
+31 -276
View File
@@ -25,18 +25,7 @@ describe("layer editor", () => {
return id; return id;
} }
test("expand/collapse", async () => { test.skip("expand/collapse", () => {});
const bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId);
await then(get.elementByTestId("layer-editor.layer-id.input")).shouldBeVisible();
await when.toggleGroupInLayerEditor("Layer");
await then(get.elementByTestId("layer-editor.layer-id.input")).shouldNotBeVisible();
await when.toggleGroupInLayerEditor("Layer");
await then(get.elementByTestId("layer-editor.layer-id.input")).shouldBeVisible();
});
test("id", async () => { test("id", async () => {
const bgId = await createBackground(); const bgId = await createBackground();
@@ -89,6 +78,7 @@ describe("layer editor", () => {
}); });
test("the range slider adjusts min-zoom", async () => { test("the range slider adjusts min-zoom", async () => {
// The slider starts at 1 (set via the text input above); one step right lands on 2.
await when.focus("min-zoom.input-range"); await when.focus("min-zoom.input-range");
await when.typeKeys("{rightarrow}"); await when.typeKeys("{rightarrow}");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
@@ -192,171 +182,52 @@ describe("layer editor", () => {
}); });
describe("filter", () => { describe("filter", () => {
let id: string; test("compound filter", async () => {
const id = await when.modal.fillLayers({ type: "fill", layer: "example" });
beforeEach(async () => {
id = await when.modal.fillLayers({ type: "fill", layer: "example" });
await when.addFilter(); await when.addFilter();
});
test("should add a filter item", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "example", filter: ["all", ["==", "name", ""]] }], layers: [{ id, type: "fill", source: "example", filter: ["all", ["==", "name", ""]] }],
}); });
});
test("should change the filter operator", async () => { // Changing the operator updates the compound filter.
await when.selectFilterOperator("!="); await when.selectFilterOperator("!=");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, filter: ["all", ["!=", "name", ""]] }], layers: [{ id, type: "fill", source: "example", filter: ["all", ["!=", "name", ""]] }],
});
}); });
test("should extend the compound filter with a second item", async () => { // A second filter item extends the compound filter.
await when.addFilter(); await when.addFilter();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, filter: ["all", ["==", "name", ""], ["==", "name", ""]] }], layers: [{ id, type: "fill", source: "example", filter: ["all", ["!=", "name", ""], ["==", "name", ""]] }],
});
});
test("should change the combining operator", async () => {
await when.selectFilterCombiningOperator("any");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, filter: ["any", ["==", "name", ""]] }],
});
});
test("should delete a filter item", async () => {
await when.addFilter();
await when.deleteFilterItem();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, filter: ["all", ["==", "name", ""]] }],
});
});
describe("when converted to an expression", () => {
beforeEach(async () => {
await when.convertFilterToExpression();
});
test("should migrate the filter to an expression", async () => {
// A single-item "all" collapses to the bare comparison when migrated.
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, filter: ["==", ["get", "name"], ""] }],
});
});
test("should restore the default filter when the expression is deleted", async () => {
await when.deleteFilterExpression();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, filter: ["all"] }],
});
}); });
}); });
}); });
describe("functions", () => { describe("functions", () => {
let id: string; test("convert a property to a zoom function and add a stop", async () => {
const id = await when.modal.fillLayers({ type: "circle", layer: "example" });
describe("zoom function", () => {
beforeEach(async () => {
id = await when.modal.fillLayers({ type: "circle", layer: "example" });
await when.makeZoomFunction("circle-radius"); await when.makeZoomFunction("circle-radius");
});
test("should convert the property to a zoom function", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[6, 5], [10, 5]] } } }],
{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[6, 5], [10, 5]] } } },
],
});
}); });
test("should add a stop", async () => {
await when.addFunctionStop("circle-radius"); await when.addFunctionStop("circle-radius");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-radius": { stops: [[6, 5], [10, 5], [11, 5]] } } }], layers: [{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[6, 5], [10, 5], [11, 5]] } } }],
});
}); });
test("should delete the first stop", async () => { // Deleting the first stop leaves the rest.
// A function needs more than two stops, otherwise deleting one collapses
// it back into a plain value.
await when.addFunctionStop("circle-radius");
await when.deleteFunctionStop("circle-radius"); await when.deleteFunctionStop("circle-radius");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-radius": { stops: [[10, 5], [11, 5]] } } }], layers: [{ id, type: "circle", source: "example", paint: { "circle-radius": { stops: [[10, 5], [11, 5]] } } }],
}); });
}); });
test("should set the base", async () => { test("convert a property to a data function and edit stops", async () => {
await when.setFunctionBase("circle-radius", "2"); const id = await when.modal.fillLayers({ type: "circle", layer: "example" });
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ // The property needs a value before it can be turned into a data function.
layers: [{ id, paint: { "circle-radius": { base: 2, stops: [[6, 5], [10, 5]] } } }],
});
});
test("should edit the zoom of a stop", async () => {
await when.setFunctionStopValue("circle-radius", "Zoom", 0, "3");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-radius": { stops: [[3, 5], [10, 5]] } } }],
});
});
test("should edit the output value of a stop", async () => {
await when.setFunctionStopValue("circle-radius", "Output value", 0, "9");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-radius": { stops: [[6, 9], [10, 5]] } } }],
});
});
test("should convert to an expression", async () => {
await when.makeExpression("circle-radius");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-radius": ["interpolate", ["linear"], ["zoom"], 6, 5, 10, 5] } }],
});
});
describe("when converted to a data function", () => {
beforeEach(async () => {
// Any non-interpolate scale turns the zoom function into a data one.
await when.selectFunctionType("circle-radius", "categorical");
});
test("should carry the stops over as zoom/value pairs", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id,
paint: {
"circle-radius": {
property: "",
type: "exponential",
stops: [[{ zoom: 6, value: 0 }, 5], [{ zoom: 10, value: 0 }, 5]],
},
},
},
],
});
});
test("should convert back to a zoom function", async () => {
await when.selectFunctionType("circle-radius", "interpolate");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-radius": { stops: [[6, 5], [10, 5]] } } }],
});
});
});
});
describe("data function", () => {
beforeEach(async () => {
id = await when.modal.fillLayers({ type: "circle", layer: "example" });
await when.setValue("spec-field-input:circle-blur", "1"); await when.setValue("spec-field-input:circle-blur", "1");
await when.makeDataFunction("circle-blur"); await when.makeDataFunction("circle-blur");
});
test("should convert the property to a data function", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [
{ {
@@ -373,33 +244,36 @@ describe("layer editor", () => {
}, },
], ],
}); });
});
test("should add a stop", async () => {
await when.addFunctionStop("circle-blur"); await when.addFunctionStop("circle-blur");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [
{ {
id, id,
type: "circle",
source: "example",
paint: { paint: {
"circle-blur": { "circle-blur": {
property: "",
type: "exponential",
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]], stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]],
}, },
}, },
}, },
], ],
}); });
});
test("should delete the first stop", async () => {
await when.addFunctionStop("circle-blur");
await when.deleteFunctionStop("circle-blur"); await when.deleteFunctionStop("circle-blur");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [ layers: [
{ {
id, id,
type: "circle",
source: "example",
paint: { paint: {
"circle-blur": { "circle-blur": {
property: "",
type: "exponential",
stops: [[{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]], stops: [[{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]],
}, },
}, },
@@ -407,72 +281,6 @@ describe("layer editor", () => {
], ],
}); });
}); });
test("should set the property", async () => {
await when.setFunctionProperty("circle-blur", "myprop");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-blur": { property: "myprop" } } }],
});
});
test("should set the default", async () => {
await when.setFunctionDefault("circle-blur", "0.5");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-blur": { default: 0.5 } } }],
});
});
test("should edit the input value of a stop", async () => {
await when.setFunctionStopValue("circle-blur", "Input value", 0, "7");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [
{
id,
paint: {
"circle-blur": {
stops: [[{ zoom: 6, value: 7 }, 1], [{ zoom: 10, value: 0 }, 1]],
},
},
},
],
});
});
test("should change the function type", async () => {
await when.selectFunctionType("circle-blur", "categorical");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-blur": { type: "categorical" } } }],
});
});
});
describe("expression", () => {
beforeEach(async () => {
id = await when.modal.fillLayers({ type: "circle", layer: "example" });
await when.setValue("spec-field-input:circle-blur", "1");
await when.makeExpression("circle-blur");
});
test("should wrap the property value in a literal expression", async () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-blur": ["literal", 1] } }],
});
});
test("should restore the plain value when reverted", async () => {
await when.undoExpression("circle-blur");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-blur": 1 } }],
});
});
test("should fall back to the spec default when deleted", async () => {
await when.deleteExpression("circle-blur");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, paint: { "circle-blur": 0 } }],
});
});
});
}); });
describe("layout", () => { describe("layout", () => {
@@ -488,43 +296,10 @@ describe("layer editor", () => {
}); });
describe("paint", () => { describe("paint", () => {
let id: string; test.skip("expand/collapse", () => {});
test.skip("color", () => {});
beforeEach(async () => { test.skip("pattern", () => {});
id = await when.modal.fillLayers({ type: "fill", layer: "example" }); test.skip("opacity", () => {});
});
test("expand/collapse", async () => {
await then(get.elementByTestId("spec-field:fill-color")).shouldBeVisible();
await when.toggleGroupInLayerEditor("Paint properties");
await then(get.elementByTestId("spec-field:fill-color")).shouldNotBeVisible();
await when.toggleGroupInLayerEditor("Paint properties");
await then(get.elementByTestId("spec-field:fill-color")).shouldBeVisible();
});
test("color", async () => {
await when.setColorValue("fill-color", "#ff0000");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "example", paint: { "fill-color": "#ff0000" } }],
});
});
test("pattern", async () => {
await when.setStringValue("fill-pattern", "some-pattern");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "example", paint: { "fill-pattern": "some-pattern" } }],
});
});
test("opacity", async () => {
await when.setValue("spec-field-input:fill-opacity", "0.4");
await when.click("layer-editor.layer-id");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "example", paint: { "fill-opacity": 0.4 } }],
});
});
}); });
describe("json-editor", () => { describe("json-editor", () => {
@@ -544,28 +319,8 @@ describe("layer editor", () => {
await then(get.element(".cm-lint-marker-error")).shouldExist(); await then(get.element(".cm-lint-marker-error")).shouldExist();
}); });
test("expand/collapse", async () => { test.skip("expand/collapse", () => {});
const bgId = await createBackground(); test.skip("modify", () => {});
await when.click("layer-list-item:background:" + bgId);
await then(get.element(".cm-content")).shouldBeVisible();
await when.toggleGroupInLayerEditor("JSON Editor");
await then(get.element(".cm-content")).shouldNotBeVisible();
await when.toggleGroupInLayerEditor("JSON Editor");
await then(get.element(".cm-content")).shouldBeVisible();
});
test("modify", async () => {
const bgId = await createBackground();
await when.click("layer-list-item:background:" + bgId);
await when.appendToJsonEditorLine('"background"', ',\n"minzoom": 5');
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id: "background:" + bgId, type: "background", minzoom: 5 }],
});
});
test("parse error", async () => { test("parse error", async () => {
const bgId = await createBackground(); const bgId = await createBackground();
+3 -16
View File
@@ -97,15 +97,7 @@ describe("layers list", () => {
}); });
}); });
test("modify", async () => { test.skip("modify", () => {});
const id = await when.modal.fillLayers({ type: "background" });
await when.click("layer-list-item:" + id);
await when.setValue("spec-field-input:background-opacity", "0.4");
await when.click("layer-editor.layer-id");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "background", paint: { "background-opacity": 0.4 } }],
});
});
}); });
describe("fill", () => { describe("fill", () => {
@@ -116,13 +108,8 @@ describe("layers list", () => {
}); });
}); });
test("change source", async () => { // TODO: Change source
const id = await when.modal.fillLayers({ type: "fill", layer: "example" }); test.skip("change source", () => {});
await when.changeLayerSource("raster");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
layers: [{ id, type: "fill", source: "raster" }],
});
});
}); });
describe("line", () => { describe("line", () => {
+8 -124
View File
@@ -38,7 +38,6 @@ export class MaputnikDriver {
"example-style-with-fonts.json", "example-style-with-fonts.json",
"example-style-with-zoom-7-and-center-0-51.json", "example-style-with-zoom-7-and-center-0-51.json",
"example-style-with-zoom-5-and-center-50-50.json", "example-style-with-zoom-5-and-center-50-50.json",
"access-token-style.json",
]; ];
for (const fixture of styleFixtures) { for (const fixture of styleFixtures) {
await this.helper.given.interceptAndMockResponse({ await this.helper.given.interceptAndMockResponse({
@@ -72,7 +71,6 @@ export class MaputnikDriver {
| "rectangles" | "rectangles"
| "font" | "font"
| "zoom_7_center_0_51" | "zoom_7_center_0_51"
| "access_tokens"
| "", | "",
zoom?: number zoom?: number
) => { ) => {
@@ -84,7 +82,6 @@ export class MaputnikDriver {
rectangles: "rectangles-style.json", rectangles: "rectangles-style.json",
font: "example-style-with-fonts.json", font: "example-style-with-fonts.json",
zoom_7_center_0_51: "example-style-with-zoom-7-and-center-0-51.json", zoom_7_center_0_51: "example-style-with-zoom-7-and-center-0-51.json",
access_tokens: "access-token-style.json",
}; };
const url = new URL(baseUrl); const url = new URL(baseUrl);
@@ -110,13 +107,7 @@ export class MaputnikDriver {
}, },
chooseExampleFile: async () => { chooseExampleFile: async () => {
await this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone"); await this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone", "modal:open.file.input");
await this.helper.when.wait(200);
},
/** Picks the example style through the browser's native file chooser. */
chooseExampleFileFromPicker: async () => {
await this.helper.when.chooseFileFromPicker("example-style.json", "modal:open.dropzone");
await this.helper.when.wait(200); await this.helper.when.wait(200);
}, },
@@ -137,22 +128,6 @@ export class MaputnikDriver {
await this.helper.get.element(".maputnik-layer-editor-group__button").nth(index).click(); await this.helper.get.element(".maputnik-layer-editor-group__button").nth(index).click();
}, },
/** Expands/collapses a layer-editor group by its title, e.g. "Paint properties". */
toggleGroupInLayerEditor: async (title: string) => {
await this.helper.when.click("layer-editor-group:" + title);
},
/**
* Picks a source for the selected layer from the source autocomplete.
* The autocomplete is a controlled (downshift) input, so the value has to be
* filled rather than typed key by key, then chosen from the filtered menu.
*/
changeLayerSource: async (sourceId: string) => {
const input = this.helper.get.elementByTestId("layer-editor.layer-source").locator("input");
await input.fill(sourceId);
await this.helper.get.element(".maputnik-autocomplete-menu-item").first().click();
},
appendTextInJsonEditor: async (text: string) => { appendTextInJsonEditor: async (text: string) => {
await this.helper.get.element(".cm-line").first().click(); await this.helper.get.element(".cm-line").first().click();
// Move to the very start of the document so the inserted text breaks the // Move to the very start of the document so the inserted text breaks the
@@ -205,67 +180,6 @@ export class MaputnikDriver {
await container.locator(".maputnik-delete-stop").first().click({ force: true }); await container.locator(".maputnik-delete-stop").first().click({ force: true });
}, },
/** Turns the property into a raw style expression. */
makeExpression: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.scrollIntoViewIfNeeded();
// In the plain spec field the expression button shares the zoom-function
// class and comes first; inside a function editor it has its own test id.
const inFunctionEditor = container.locator("[data-wd-key='convert-to-expression']");
const button =
(await inFunctionEditor.count()) > 0
? inFunctionEditor
: container.locator(".maputnik-make-zoom-function").first();
await button.click({ force: true });
},
/** Reverts an expression back to a plain value. */
undoExpression: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator("[data-wd-key='undo-expression']").click({ force: true });
},
/** Removes an expression, restoring the property's spec default. */
deleteExpression: async (fieldName: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator("[data-wd-key='delete-expression']").click({ force: true });
},
/** Picks the function scale (categorical/interval/exponential/identity/interpolate). */
selectFunctionType: async (fieldName: string, type: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator("[data-wd-key='function-type'] select").selectOption(type);
},
/** Sets the "Base" input of a zoom/data function. */
setFunctionBase: async (fieldName: string, value: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator("[data-wd-key='function-base'] input").fill(value);
},
/**
* Sets the data property a data function keys off of. This is an InputString,
* which only commits its value on blur, so typing alone is not enough.
*/
setFunctionProperty: async (fieldName: string, value: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
const input = container.locator("[data-wd-key='function-property'] input");
await input.fill(value);
await input.blur();
},
/** Sets the fallback value used when a feature has no matching stop. */
setFunctionDefault: async (fieldName: string, value: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator("[data-wd-key='function-default'] input").fill(value);
},
/** Edits one cell of a function's stop table ("Zoom", "Input value" or "Output value"). */
setFunctionStopValue: async (fieldName: string, column: string, index: number, value: string) => {
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
await container.locator(`[aria-label="${column}"]`).nth(index).fill(value);
},
addFilter: async () => { addFilter: async () => {
const button = this.helper.get.elementByTestId("layer-filter-button"); const button = this.helper.get.elementByTestId("layer-filter-button");
await button.scrollIntoViewIfNeeded(); await button.scrollIntoViewIfNeeded();
@@ -276,29 +190,8 @@ export class MaputnikDriver {
await this.helper.get.element(".maputnik-filter-editor-operator select").first().selectOption(value); await this.helper.get.element(".maputnik-filter-editor-operator select").first().selectOption(value);
}, },
/** Chooses how the filter items combine: all / none / any. */ deleteFirstActiveSource: async () => {
selectFilterCombiningOperator: async (value: string) => { await this.helper.get.element(".maputnik-active-source-type-editor-header-delete").first().click();
await this.helper.when.selectWithin("filter-combining-operator", value);
},
deleteFilterItem: async (index = 0) => {
await this.helper.get
.element(".maputnik-filter-editor-block-action .maputnik-icon-button")
.nth(index)
.click();
},
/** Converts the simple filter editor into a raw expression editor. */
convertFilterToExpression: async () => {
await this.helper.when.click("filter-convert-to-expression");
},
/**
* Deletes the filter expression, restoring the simple filter editor.
* The filter group precedes the paint group, so its button comes first.
*/
deleteFilterExpression: async () => {
await this.helper.get.element("[data-wd-key='delete-expression']").first().click();
}, },
setColorValue: async (fieldName: string, value: string) => { setColorValue: async (fieldName: string, value: string) => {
@@ -306,22 +199,13 @@ export class MaputnikDriver {
await input.fill(value); await input.fill(value);
}, },
/** Sets a plain string spec field (e.g. a pattern), which has no dedicated input test id. */ exportCreateHtml: async () => {
setStringValue: async (fieldName: string, value: string) => { await this.helper.get.element(".maputnik-modal-export-buttons button").last().click();
const input = this.helper.get.elementByTestId("spec-field:" + fieldName).locator("input.maputnik-string");
await input.fill(value);
await input.blur();
}, },
/** exportSaveStyle: async () => {
* Appends text to the end of the JSON editor line holding `lineText`. await this.helper.stubSaveFilePicker();
* CodeMirror types over its own auto-inserted closing quotes/brackets, so a await this.helper.get.element(".maputnik-modal-export-buttons button").first().click();
* well-formed fragment stays well-formed.
*/
appendToJsonEditorLine: async (lineText: string, text: string) => {
await this.helper.when.clickByText(lineText);
await this.helper.when.typeKeys("{end}");
await this.helper.when.typeText(text);
}, },
waitForExampleFileResponse: () => this.helper.when.waitForResponse("example-style.json"), waitForExampleFileResponse: () => this.helper.when.waitForResponse("example-style.json"),
-40
View File
@@ -34,45 +34,5 @@ export class ModalDriver {
close: async (key: string) => { close: async (key: string) => {
await this.helper.when.click(key + ".close-modal"); await this.helper.when.click(key + ".close-modal");
}, },
/**
* Adds a source of the given type from the sources modal, keeping whatever
* defaults that type's editor prefills.
*/
addSource: async (sourceId: string, sourceType: string) => {
const { when } = this.helper;
await when.setValue("modal:sources.add.source_id", sourceId);
await when.select("modal:sources.add.source_type", sourceType);
await when.click("modal:sources.add.add_source");
await when.wait(200);
},
/** Adds one of the predefined public sources listed in the sources modal. */
addPublicSource: async (index = 0) => {
await this.helper.get.element(".maputnik-public-source-select").nth(index).click();
},
deleteFirstActiveSource: async () => {
await this.helper.get.element(".maputnik-active-source-type-editor-header-delete").first().click();
},
/** Fills one number box of a coordinate pair in the image/video source editor. */
setCoordinateValue: async (index: number, value: string) => {
const input = this.helper.get
.elementByTestId("modal:sources")
.locator(".maputnik-array input")
.nth(index);
await input.fill(value);
await input.blur();
},
exportCreateHtml: async () => {
await this.helper.get.element(".maputnik-modal-export-buttons button").last().click();
},
exportSaveStyle: async () => {
await this.helper.stubSaveFilePicker();
await this.helper.get.element(".maputnik-modal-export-buttons button").first().click();
},
}; };
} }
+9 -164
View File
@@ -43,16 +43,6 @@ describe("modals", () => {
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.responseBody("example-style.json")); await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.responseBody("example-style.json"));
}); });
}); });
describe("without the File System Access API", () => {
test("upload via the file chooser", async () => {
await given.noFileSystemAccessApi();
await when.setStyle("");
await when.click("nav:open");
await when.chooseExampleFileFromPicker();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude(get.fixture("example-style.json"));
});
});
}); });
describe("shortcuts", () => { describe("shortcuts", () => {
@@ -76,11 +66,11 @@ describe("modals", () => {
test("download HTML and save the style", async () => { test("download HTML and save the style", async () => {
// Generate the standalone HTML export (triggers a file download). // Generate the standalone HTML export (triggers a file download).
await when.modal.exportCreateHtml(); await when.exportCreateHtml();
await then(get.elementByTestId("modal:export")).shouldExist(); await then(get.elementByTestId("modal:export")).shouldExist();
// Saving the style closes the export modal. // Saving the style closes the export modal.
await when.modal.exportSaveStyle(); await when.exportSaveStyle();
await then(get.elementByTestId("modal:export")).shouldNotExist(); await then(get.elementByTestId("modal:export")).shouldNotExist();
}); });
}); });
@@ -92,26 +82,17 @@ describe("modals", () => {
}); });
test("active sources are listed and can be deleted", async () => { test("active sources are listed and can be deleted", async () => {
// The "both" style ships with active sources; reopen the modal against it.
await when.setStyle("both"); await when.setStyle("both");
await when.click("nav:sources"); await when.click("nav:sources");
const before = Object.keys(get.fixture("geojson-raster-style.json").sources).length; const before = Object.keys(get.fixture("geojson-raster-style.json").sources).length;
await when.modal.deleteFirstActiveSource(); await when.deleteFirstActiveSource();
await then( await then(
get.styleFromLocalStorage().then((style) => Object.keys(style.sources).length) get.styleFromLocalStorage().then((style) => Object.keys(style.sources).length)
).shouldEqual(before - 1); ).shouldEqual(before - 1);
}); });
test("public source", async () => { test.skip("public source", () => {});
await when.modal.addPublicSource();
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
openmaptiles: {
type: "vector",
url: `https://api.maptiler.com/tiles/v3-openmaptiles/tiles.json?key=${tokens.openmaptiles}`,
},
},
});
});
test("add new source", async () => { test("add new source", async () => {
const sourceId = "n1z2v3r"; const sourceId = "n1z2v3r";
@@ -158,108 +139,6 @@ describe("modals", () => {
sources: { [sourceId]: { tileSize: 128 } }, sources: { [sourceId]: { tileSize: 128 } },
}); });
}); });
test("add new geojson url source", async () => {
await when.modal.addSource("geojsonurl", "geojson_url");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
geojsonurl: { type: "geojson", data: "http://localhost:3000/geojson.json" },
},
});
});
test("add new geojson json source", async () => {
await when.modal.addSource("geojsonjson", "geojson_json");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
geojsonjson: { type: "geojson", cluster: false, data: "" },
},
});
});
test("add new tilejson vector source", async () => {
await when.modal.addSource("tilejsonvector", "tilejson_vector");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
tilejsonvector: { type: "vector", url: "http://localhost:3000/tilejson.json" },
},
});
});
test("add new tilejson raster source", async () => {
await when.modal.addSource("tilejsonraster", "tilejson_raster");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
tilejsonraster: { type: "raster", url: "http://localhost:3000/tilejson.json" },
},
});
});
test("add new tilejson raster-dem source", async () => {
await when.modal.addSource("tilejsonrasterdem", "tilejson_raster-dem");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
tilejsonrasterdem: { type: "raster-dem", url: "http://localhost:3000/tilejson.json" },
},
});
});
test("add new tile xyz raster-dem source", async () => {
await when.modal.addSource("tilexyzrasterdem", "tilexyz_raster-dem");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
tilexyzrasterdem: {
type: "raster-dem",
tiles: ["http://localhost:3000/{x}/{y}/{z}.png"],
minzoom: 0,
maxzoom: 14,
tileSize: 512,
},
},
});
});
test("add new image source", async () => {
await when.modal.addSource("imagesource", "image");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
imagesource: {
type: "image",
url: "http://localhost:3000/image.png",
coordinates: [[0, 0], [0, 0], [0, 0], [0, 0]],
},
},
});
});
test("add new video source", async () => {
await when.modal.addSource("videosource", "video");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
videosource: {
type: "video",
urls: ["http://localhost:3000/movie.mp4"],
coordinates: [[0, 0], [0, 0], [0, 0], [0, 0]],
},
},
});
});
test("edit the corner coordinates of an image source", async () => {
const sourceId = "imagecoords";
await when.setValue("modal:sources.add.source_id", sourceId);
await when.select("modal:sources.add.source_type", "image");
// The first corner is the first two number boxes of the coordinate arrays.
await when.modal.setCoordinateValue(0, "1");
await when.modal.setCoordinateValue(1, "2");
await when.click("modal:sources.add.add_source");
await when.wait(200);
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
sources: {
[sourceId]: { type: "image", coordinates: [[1, 2], [0, 0], [0, 0], [0, 0]] },
},
});
});
}); });
describe("inspect", () => { describe("inspect", () => {
@@ -374,44 +253,6 @@ describe("modals", () => {
}); });
}); });
test("map view defaults", async () => {
await when.setValue("modal:settings.zoom", "4");
await when.setValue("modal:settings.bearing", "12");
await when.setValue("modal:settings.pitch", "30");
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
zoom: 4,
bearing: 12,
pitch: 30,
});
});
test("light intensity", async () => {
await when.setValue("modal:settings.light-intensity", "0.7");
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
light: { intensity: 0.7 },
});
});
test("terrain source and exaggeration", async () => {
await when.setValue("modal:settings.maputnik:terrain_source", "terrain");
await when.setValue("modal:settings.terrain-exaggeration", "1.5");
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
terrain: { source: "terrain", exaggeration: 1.5 },
});
});
test("transition delay and duration", async () => {
await when.setValue("modal:settings.transition-delay", "100");
await when.setValue("modal:settings.transition-duration", "500");
await when.click("modal:settings.name");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
transition: { delay: 100, duration: 500 },
});
});
test("style projection mercator", async () => { test("style projection mercator", async () => {
await when.select("modal:settings.projection", "mercator"); await when.select("modal:settings.projection", "mercator");
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({ await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
@@ -487,6 +328,10 @@ describe("modals", () => {
}); });
}); });
describe("sources placeholder", () => {
test.skip("toggle", () => {});
});
describe("global state", () => { describe("global state", () => {
beforeEach(async () => { beforeEach(async () => {
await when.click("nav:global-state"); await when.click("nav:global-state");
+6 -34
View File
@@ -145,7 +145,6 @@ async function typeSequence(page: Page, text: string): Promise<void> {
del: "Delete", del: "Delete",
tab: "Tab", tab: "Tab",
home: "Home", home: "Home",
end: "End",
rightarrow: "ArrowRight", rightarrow: "ArrowRight",
}; };
@@ -218,18 +217,6 @@ export class PlaywrightHelper {
public then = <T>(target: T): Assertable<T> => new Assertable(target); public then = <T>(target: T): Assertable<T> => new Assertable(target);
public given = { public given = {
/**
* Removes the File System Access API so the app falls back to a plain
* <input type="file">, the way Firefox and Safari behave. Must be called
* before the page under test is loaded.
*/
noFileSystemAccessApi: async () => {
await this.page.addInitScript(() => {
delete (window as any).showOpenFilePicker;
delete (window as any).showSaveFilePicker;
});
},
intercept: async (pattern: RegExp, alias: string, _method = "GET") => { intercept: async (pattern: RegExp, alias: string, _method = "GET") => {
this.recordedRequests.set(alias, []); this.recordedRequests.set(alias, []);
await this.page.route(pattern, (route) => { await this.page.route(pattern, (route) => {
@@ -357,38 +344,23 @@ export class PlaywrightHelper {
await this.page.mouse.up(); await this.page.mouse.up();
}, },
/** openFileByFixture: async (fixture: string, buttonTestId: string, inputTestId: string) => {
* Opens a fixture through the File System Access API, which raises no
* "filechooser" event and so has to be stubbed. For the <input type="file">
* fallback that browsers without the API use, see chooseFileFromPicker.
*/
openFileByFixture: async (fixture: string, buttonTestId: string) => {
const content = JSON.stringify(this.readFixture(fixture)); const content = JSON.stringify(this.readFixture(fixture));
const hasPicker = await this.page.evaluate(() => "showOpenFilePicker" in window);
if (hasPicker) {
await this.page.evaluate((fileContent) => { await this.page.evaluate((fileContent) => {
(window as any).showOpenFilePicker = async () => [ (window as any).showOpenFilePicker = async () => [
{ getFile: async () => ({ text: async () => fileContent }) }, { getFile: async () => ({ text: async () => fileContent }) },
]; ];
}, content); }, content);
await this.testId(buttonTestId).click(); await this.testId(buttonTestId).click();
}, } else {
await this.testId(inputTestId).setInputFiles({
/**
* Clicks a control that opens the browser's native file chooser and answers
* it with a fixture. Only works on the <input type="file"> path — the File
* System Access API does not raise a "filechooser" event, so pair this with
* given.noFileSystemAccessApi().
*/
chooseFileFromPicker: async (fixture: string, triggerTestId: string) => {
const content = JSON.stringify(this.readFixture(fixture));
const [fileChooser] = await Promise.all([
this.page.waitForEvent("filechooser"),
this.testId(triggerTestId).click(),
]);
await fileChooser.setFiles({
name: fixture, name: fixture,
mimeType: "application/json", mimeType: "application/json",
buffer: Buffer.from(content), buffer: Buffer.from(content),
}); });
}
}, },
dropFileByFixture: async (fixture: string, dropzoneTestId: string) => { dropFileByFixture: async (fixture: string, dropzoneTestId: string) => {
+1 -3
View File
@@ -32,11 +32,9 @@ export default defineConfig({
"@stylistic": stylisticTs "@stylistic": stylisticTs
}, },
rules: { rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"react-refresh/only-export-components": [ "react-refresh/only-export-components": [
"warn", "warn",
{ allowConstantExport: true, extraHOCs: ["withTranslation"] } { allowConstantExport: true }
], ],
"@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [ "@typescript-eslint/no-unused-vars": [
+529 -572
View File
File diff suppressed because it is too large Load Diff
+19 -15
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { ScrollContainer } from "./ScrollContainer"; import ScrollContainer from "./ScrollContainer";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import { IconContext } from "react-icons"; import { IconContext } from "react-icons";
@@ -13,38 +13,42 @@ type AppLayoutInternalProps = {
modals?: React.ReactNode modals?: React.ReactNode
} & WithTranslation; } & WithTranslation;
const AppLayoutInternal: React.FC<AppLayoutInternalProps> = (props) => { class AppLayoutInternal extends React.Component<AppLayoutInternalProps> {
document.body.dir = props.i18n.dir();
render() {
document.body.dir = this.props.i18n.dir();
return <IconContext.Provider value={{size: "14px"}}> return <IconContext.Provider value={{size: "14px"}}>
<div className="maputnik-layout"> <div className="maputnik-layout">
{props.toolbar} {this.props.toolbar}
<div className="maputnik-layout-main"> <div className="maputnik-layout-main">
{props.codeEditor && <div className="maputnik-layout-code-editor"> {this.props.codeEditor && <div className="maputnik-layout-code-editor">
<ScrollContainer> <ScrollContainer>
{props.codeEditor} {this.props.codeEditor}
</ScrollContainer> </ScrollContainer>
</div> </div>
} }
{!props.codeEditor && <> {!this.props.codeEditor && <>
<div className="maputnik-layout-list"> <div className="maputnik-layout-list">
{props.layerList} {this.props.layerList}
</div> </div>
<div className="maputnik-layout-drawer"> <div className="maputnik-layout-drawer">
<ScrollContainer> <ScrollContainer>
{props.layerEditor} {this.props.layerEditor}
</ScrollContainer> </ScrollContainer>
</div> </div>
</>} </>}
{props.map} {this.props.map}
</div> </div>
{props.bottom && <div className="maputnik-layout-bottom"> {this.props.bottom && <div className="maputnik-layout-bottom">
{props.bottom} {this.props.bottom}
</div> </div>
} }
{props.modals} {this.props.modals}
</div> </div>
</IconContext.Provider>; </IconContext.Provider>;
}; }
}
export const AppLayout = withTranslation()(AppLayoutInternal); const AppLayout = withTranslation()(AppLayoutInternal);
export default AppLayout;
+15 -11
View File
@@ -13,16 +13,18 @@ type AppMessagePanelInternalProps = {
selectedLayerIndex?: number selectedLayerIndex?: number
} & WithTranslation; } & WithTranslation;
const AppMessagePanelInternal: React.FC<AppMessagePanelInternalProps> = ({ class AppMessagePanelInternal extends React.Component<AppMessagePanelInternalProps> {
onLayerSelect = () => { }, static defaultProps = {
...props onLayerSelect: () => { },
}) => { };
const { t, selectedLayerIndex } = props;
const errors = props.errors?.map((error, idx) => { render() {
const { t, selectedLayerIndex } = this.props;
const errors = this.props.errors?.map((error, idx) => {
let content; let content;
if (error.parsed && error.parsed.type === "layer") { if (error.parsed && error.parsed.type === "layer") {
const { parsed } = error; const { parsed } = error;
const layerId = props.mapStyle?.layers[parsed.data.index].id; const layerId = this.props.mapStyle?.layers[parsed.data.index].id;
content = ( content = (
<> <>
{t("Layer")} <span>{formatLayerId(layerId)}</span>: {parsed.data.message} {t("Layer")} <span>{formatLayerId(layerId)}</span>: {parsed.data.message}
@@ -31,7 +33,7 @@ const AppMessagePanelInternal: React.FC<AppMessagePanelInternalProps> = ({
&nbsp;&mdash;&nbsp; &nbsp;&mdash;&nbsp;
<button <button
className="maputnik-message-panel__switch-button" className="maputnik-message-panel__switch-button"
onClick={() => onLayerSelect!(parsed.data.index)} onClick={() => this.props.onLayerSelect!(parsed.data.index)}
> >
{t("switch to layer")} {t("switch to layer")}
</button> </button>
@@ -48,7 +50,7 @@ const AppMessagePanelInternal: React.FC<AppMessagePanelInternalProps> = ({
</p>; </p>;
}); });
const infos = props.infos?.map((m, i) => { const infos = this.props.infos?.map((m, i) => {
return <p key={"info-" + i}>{m}</p>; return <p key={"info-" + i}>{m}</p>;
}); });
@@ -56,6 +58,8 @@ const AppMessagePanelInternal: React.FC<AppMessagePanelInternalProps> = ({
{errors} {errors}
{infos} {infos}
</div>; </div>;
}; }
}
export const AppMessagePanel = withTranslation()(AppMessagePanelInternal); const AppMessagePanel = withTranslation()(AppMessagePanelInternal);
export default AppMessagePanel;
+61 -40
View File
@@ -31,9 +31,11 @@ type IconTextProps = {
}; };
const IconText: React.FC<IconTextProps> = (props) => { class IconText extends React.Component<IconTextProps> {
return <span className="maputnik-icon-text">{props.children}</span>; render() {
}; return <span className="maputnik-icon-text">{this.props.children}</span>;
}
}
type ToolbarLinkProps = { type ToolbarLinkProps = {
className?: string className?: string
@@ -41,31 +43,35 @@ type ToolbarLinkProps = {
href?: string href?: string
}; };
const ToolbarLink: React.FC<ToolbarLinkProps> = (props) => { class ToolbarLink extends React.Component<ToolbarLinkProps> {
render() {
return <a return <a
className={classnames("maputnik-toolbar-link", props.className)} className={classnames("maputnik-toolbar-link", this.props.className)}
href={props.href} href={this.props.href}
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
data-wd-key="toolbar:link" data-wd-key="toolbar:link"
> >
{props.children} {this.props.children}
</a>; </a>;
}; }
}
type ToolbarSelectProps = { type ToolbarSelectProps = {
children?: React.ReactNode children?: React.ReactNode
wdKey?: string wdKey?: string
}; };
const ToolbarSelect: React.FC<ToolbarSelectProps> = (props) => { class ToolbarSelect extends React.Component<ToolbarSelectProps> {
render() {
return <div return <div
className='maputnik-toolbar-select' className='maputnik-toolbar-select'
data-wd-key={props.wdKey} data-wd-key={this.props.wdKey}
> >
{props.children} {this.props.children}
</div>; </div>;
}; }
}
type ToolbarActionProps = { type ToolbarActionProps = {
children?: React.ReactNode children?: React.ReactNode
@@ -73,15 +79,17 @@ type ToolbarActionProps = {
wdKey?: string wdKey?: string
}; };
const ToolbarAction: React.FC<ToolbarActionProps> = (props) => { class ToolbarAction extends React.Component<ToolbarActionProps> {
render() {
return <button return <button
className='maputnik-toolbar-action' className='maputnik-toolbar-action'
data-wd-key={props.wdKey} data-wd-key={this.props.wdKey}
onClick={props.onClick} onClick={this.props.onClick}
> >
{props.children} {this.props.children}
</button>; </button>;
}; }
}
export type MapState = "map" | "inspect" | "filter-achromatopsia" | "filter-deuteranopia" | "filter-protanopia" | "filter-tritanopia"; export type MapState = "map" | "inspect" | "filter-achromatopsia" | "filter-deuteranopia" | "filter-protanopia" | "filter-tritanopia";
@@ -100,16 +108,26 @@ type AppToolbarInternalProps = {
renderer?: string renderer?: string
} & WithTranslation; } & WithTranslation;
const AppToolbarInternal: React.FC<AppToolbarInternalProps> = (props) => { class AppToolbarInternal extends React.Component<AppToolbarInternalProps> {
function handleSelection(val: MapState) { state = {
props.onSetMapState(val); isOpen: {
settings: false,
sources: false,
open: false,
add: false,
export: false,
}
};
handleSelection(val: MapState) {
this.props.onSetMapState(val);
} }
function handleLanguageChange(val: string) { handleLanguageChange(val: string) {
props.i18n.changeLanguage(val); this.props.i18n.changeLanguage(val);
} }
const onSkip = (target: string) => { onSkip = (target: string) => {
if (target === "map") { if (target === "map") {
(document.querySelector(".maplibregl-canvas") as HTMLCanvasElement).focus(); (document.querySelector(".maplibregl-canvas") as HTMLCanvasElement).focus();
} }
@@ -119,7 +137,8 @@ const AppToolbarInternal: React.FC<AppToolbarInternalProps> = (props) => {
} }
}; };
const t = props.t; render() {
const t = this.props.t;
const views = [ const views = [
{ {
id: "map", id: "map",
@@ -130,7 +149,7 @@ const AppToolbarInternal: React.FC<AppToolbarInternalProps> = (props) => {
id: "inspect", id: "inspect",
group: "general", group: "general",
title: t("Inspect"), title: t("Inspect"),
disabled: props.renderer === "ol", disabled: this.props.renderer === "ol",
}, },
{ {
id: "filter-deuteranopia", id: "filter-deuteranopia",
@@ -159,7 +178,7 @@ const AppToolbarInternal: React.FC<AppToolbarInternalProps> = (props) => {
]; ];
const currentView = views.find((view) => { const currentView = views.find((view) => {
return view.id === props.mapState; return view.id === this.props.mapState;
}); });
return <nav className='maputnik-toolbar'> return <nav className='maputnik-toolbar'>
@@ -171,21 +190,21 @@ const AppToolbarInternal: React.FC<AppToolbarInternalProps> = (props) => {
<button <button
data-wd-key="root:skip:layer-list" data-wd-key="root:skip:layer-list"
className="maputnik-toolbar-skip" className="maputnik-toolbar-skip"
onClick={_e => onSkip("layer-list")} onClick={_e => this.onSkip("layer-list")}
> >
{t("Layers list")} {t("Layers list")}
</button> </button>
<button <button
data-wd-key="root:skip:layer-editor" data-wd-key="root:skip:layer-editor"
className="maputnik-toolbar-skip" className="maputnik-toolbar-skip"
onClick={_e => onSkip("layer-editor")} onClick={_e => this.onSkip("layer-editor")}
> >
{t("Layer editor")} {t("Layer editor")}
</button> </button>
<button <button
data-wd-key="root:skip:map-view" data-wd-key="root:skip:map-view"
className="maputnik-toolbar-skip" className="maputnik-toolbar-skip"
onClick={_e => onSkip("map")} onClick={_e => this.onSkip("map")}
> >
{t("Map view")} {t("Map view")}
</button> </button>
@@ -203,27 +222,27 @@ const AppToolbarInternal: React.FC<AppToolbarInternalProps> = (props) => {
</a> </a>
</div> </div>
<div className="maputnik-toolbar__actions" role="navigation" aria-label="Toolbar"> <div className="maputnik-toolbar__actions" role="navigation" aria-label="Toolbar">
<ToolbarAction wdKey="nav:open" onClick={() => props.onToggleModal("open")}> <ToolbarAction wdKey="nav:open" onClick={() => this.props.onToggleModal("open")}>
<MdOpenInBrowser /> <MdOpenInBrowser />
<IconText>{t("Open")}</IconText> <IconText>{t("Open")}</IconText>
</ToolbarAction> </ToolbarAction>
<ToolbarAction wdKey="nav:export" onClick={() => props.onToggleModal("export")}> <ToolbarAction wdKey="nav:export" onClick={() => this.props.onToggleModal("export")}>
<MdSave /> <MdSave />
<IconText>{t("Save")}</IconText> <IconText>{t("Save")}</IconText>
</ToolbarAction> </ToolbarAction>
<ToolbarAction wdKey="nav:code-editor" onClick={() => props.onToggleModal("codeEditor")}> <ToolbarAction wdKey="nav:code-editor" onClick={() => this.props.onToggleModal("codeEditor")}>
<MdCode /> <MdCode />
<IconText>{t("Code Editor")}</IconText> <IconText>{t("Code Editor")}</IconText>
</ToolbarAction> </ToolbarAction>
<ToolbarAction wdKey="nav:sources" onClick={() => props.onToggleModal("sources")}> <ToolbarAction wdKey="nav:sources" onClick={() => this.props.onToggleModal("sources")}>
<MdLayers /> <MdLayers />
<IconText>{t("Data Sources")}</IconText> <IconText>{t("Data Sources")}</IconText>
</ToolbarAction> </ToolbarAction>
<ToolbarAction wdKey="nav:settings" onClick={() => props.onToggleModal("settings")}> <ToolbarAction wdKey="nav:settings" onClick={() => this.props.onToggleModal("settings")}>
<MdSettings /> <MdSettings />
<IconText>{t("Style Settings")}</IconText> <IconText>{t("Style Settings")}</IconText>
</ToolbarAction> </ToolbarAction>
<ToolbarAction wdKey="nav:global-state" onClick={() => props.onToggleModal("globalState")}> <ToolbarAction wdKey="nav:global-state" onClick={() => this.props.onToggleModal("globalState")}>
<MdPublic /> <MdPublic />
<IconText>{t("Global State")}</IconText> <IconText>{t("Global State")}</IconText>
</ToolbarAction> </ToolbarAction>
@@ -234,7 +253,7 @@ const AppToolbarInternal: React.FC<AppToolbarInternalProps> = (props) => {
<select <select
className="maputnik-select" className="maputnik-select"
data-wd-key="maputnik-select" data-wd-key="maputnik-select"
onChange={(e) => handleSelection(e.target.value as MapState)} onChange={(e) => this.handleSelection(e.target.value as MapState)}
value={currentView?.id} value={currentView?.id}
> >
{views.filter(v => v.group === "general").map((item) => { {views.filter(v => v.group === "general").map((item) => {
@@ -263,8 +282,8 @@ const AppToolbarInternal: React.FC<AppToolbarInternalProps> = (props) => {
<select <select
className="maputnik-select" className="maputnik-select"
data-wd-key="maputnik-lang-select" data-wd-key="maputnik-lang-select"
onChange={(e) => handleLanguageChange(e.target.value)} onChange={(e) => this.handleLanguageChange(e.target.value)}
value={props.i18n.language} value={this.props.i18n.language}
> >
{Object.entries(supportedLanguages).map(([code, name]) => { {Object.entries(supportedLanguages).map(([code, name]) => {
return ( return (
@@ -284,6 +303,8 @@ const AppToolbarInternal: React.FC<AppToolbarInternalProps> = (props) => {
</div> </div>
</div> </div>
</nav>; </nav>;
}; }
}
export const AppToolbar = withTranslation()(AppToolbarInternal); const AppToolbar = withTranslation()(AppToolbarInternal);
export default AppToolbar;
+51 -30
View File
@@ -1,7 +1,7 @@
import React, {type CSSProperties, type PropsWithChildren, type SyntheticEvent, useRef, useState} from "react"; import React, {type CSSProperties, type PropsWithChildren, type SyntheticEvent} from "react";
import classnames from "classnames"; import classnames from "classnames";
import { FieldDocLabel } from "./FieldDocLabel"; import FieldDocLabel from "./FieldDocLabel";
import { Doc } from "./Doc"; import Doc from "./Doc";
export type BlockProps = PropsWithChildren & { export type BlockProps = PropsWithChildren & {
"data-wd-key"?: string "data-wd-key"?: string
@@ -14,13 +14,32 @@ export type BlockProps = PropsWithChildren & {
error?: {message: string} error?: {message: string}
}; };
/** Wrap a component with a label */ type BlockState = {
export const Block: React.FC<BlockProps> = (props) => { showDoc: boolean
const [showDoc, setShowDoc] = useState(false); };
const blockEl = useRef<HTMLDivElement | null>(null);
const onToggleDoc = (val: boolean) => { /** Wrap a component with a label */
setShowDoc(val); export default class Block extends React.Component<BlockProps, BlockState> {
_blockEl: HTMLDivElement | null = null;
constructor (props: BlockProps) {
super(props);
this.state = {
showDoc: false,
};
}
onChange(e: React.BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) {
const value = e.target.value;
if (this.props.onChange) {
return this.props.onChange(value === "" ? undefined : value);
}
}
onToggleDoc = (val: boolean) => {
this.setState({
showDoc: val
});
}; };
/** /**
@@ -29,9 +48,9 @@ export const Block: React.FC<BlockProps> = (props) => {
* causing the picker to reopen. This causes a scenario where the picker can * causing the picker to reopen. This causes a scenario where the picker can
* never be closed once open. * never be closed once open.
*/ */
const onLabelClick = (event: SyntheticEvent<any, any>) => { onLabelClick = (event: SyntheticEvent<any, any>) => {
const el = event.nativeEvent.target; const el = event.nativeEvent.target;
const contains = blockEl.current?.contains(el); const contains = this._blockEl?.contains(el);
if (event.nativeEvent.target.nodeName !== "INPUT" && !contains) { if (event.nativeEvent.target.nodeName !== "INPUT" && !contains) {
event.stopPropagation(); event.stopPropagation();
@@ -41,43 +60,45 @@ export const Block: React.FC<BlockProps> = (props) => {
} }
}; };
return <label style={props.style} render() {
data-wd-key={props["data-wd-key"]} return <label style={this.props.style}
data-wd-key={this.props["data-wd-key"]}
className={classnames({ className={classnames({
"maputnik-input-block": true, "maputnik-input-block": true,
"maputnik-input-block--wide": props.wideMode, "maputnik-input-block--wide": this.props.wideMode,
"maputnik-action-block": props.action, "maputnik-action-block": this.props.action,
"maputnik-input-block--error": props.error "maputnik-input-block--error": this.props.error
})} })}
onClick={onLabelClick} onClick={this.onLabelClick}
> >
{props.fieldSpec && {this.props.fieldSpec &&
<div className="maputnik-input-block-label"> <div className="maputnik-input-block-label">
<FieldDocLabel <FieldDocLabel
label={props.label} label={this.props.label}
onToggleDoc={onToggleDoc} onToggleDoc={this.onToggleDoc}
fieldSpec={props.fieldSpec} fieldSpec={this.props.fieldSpec}
/> />
</div> </div>
} }
{!props.fieldSpec && {!this.props.fieldSpec &&
<div className="maputnik-input-block-label"> <div className="maputnik-input-block-label">
{props.label} {this.props.label}
</div> </div>
} }
<div className="maputnik-input-block-action"> <div className="maputnik-input-block-action">
{props.action} {this.props.action}
</div> </div>
<div className="maputnik-input-block-content" ref={blockEl}> <div className="maputnik-input-block-content" ref={el => {this._blockEl = el;}}>
{props.children} {this.props.children}
</div> </div>
{props.fieldSpec && {this.props.fieldSpec &&
<div <div
className="maputnik-doc-inline" className="maputnik-doc-inline"
style={{display: showDoc ? "" : "none"}} style={{display: this.state.showDoc ? "" : "none"}}
> >
<Doc fieldSpec={props.fieldSpec} /> <Doc fieldSpec={this.props.fieldSpec} />
</div> </div>
} }
</label>; </label>;
}; }
}
+4 -2
View File
@@ -1,4 +1,4 @@
import { InputJson } from "./InputJson"; import InputJson from "./InputJson";
import React from "react"; import React from "react";
import { withTranslation, type WithTranslation } from "react-i18next"; import { withTranslation, type WithTranslation } from "react-i18next";
import { type StyleSpecification } from "maplibre-gl"; import { type StyleSpecification } from "maplibre-gl";
@@ -24,4 +24,6 @@ const CodeEditorInternal: React.FC<CodeEditorProps> = (props) => {
</>; </>;
}; };
export const CodeEditor = withTranslation()(CodeEditorInternal); const CodeEditor = withTranslation()(CodeEditorInternal);
export default CodeEditor;
+12 -6
View File
@@ -9,19 +9,25 @@ type CollapseProps = {
}; };
export const Collapse: React.FC<CollapseProps> = ({isActive = true, children}) => { export default class Collapse extends React.Component<CollapseProps> {
static defaultProps = {
isActive: true
};
render() {
if (reducedMotionEnabled()) { if (reducedMotionEnabled()) {
return ( return (
<div style={{display: isActive ? "block" : "none"}}> <div style={{display: this.props.isActive ? "block" : "none"}}>
{children} {this.props.children}
</div> </div>
); );
} }
else { else {
return ( return (
<ReactCollapse isOpened={isActive}> <ReactCollapse isOpened={this.props.isActive}>
{children} {this.props.children}
</ReactCollapse> </ReactCollapse>
); );
} }
}; }
}
+6 -4
View File
@@ -6,11 +6,13 @@ type CollapserProps = {
style?: object style?: object
}; };
export const Collapser: React.FC<CollapserProps> = (props) => { export default class Collapser extends React.Component<CollapserProps> {
render() {
const iconStyle = { const iconStyle = {
width: 20, width: 20,
height: 20, height: 20,
...props.style, ...this.props.style,
};
return props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} />;
}; };
return this.props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} />;
}
}
-371
View File
@@ -1,371 +0,0 @@
import React, { useRef } from "react";
import {PiListPlusBold} from "react-icons/pi";
import {TbMathFunction} from "react-icons/tb";
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import { InputButton } from "./InputButton";
import { InputSpec } from "./InputSpec";
import { InputNumber } from "./InputNumber";
import { InputString } from "./InputString";
import { InputSelect } from "./InputSelect";
import { Block } from "./Block";
import { generateUniqueId as docUid } from "../libs/document-uid";
import { sortNumerically } from "../libs/sort-numerically";
import {findDefaultFromSpec} from "../libs/spec-helper";
import { type WithTranslation, withTranslation } from "react-i18next";
import { labelFromFieldName } from "../libs/label-from-field-name";
import { DeleteStopButton } from "./DeleteStopButton";
import { type MappedLayerErrors } from "../libs/definitions";
function setStopRefs(props: DataPropertyInternalProps, state: DataPropertyState) {
// This is initialised below only if required to improved performance.
let newRefs: {[key: number]: string} | undefined;
if(props.value && props.value.stops) {
props.value.stops.forEach((_val, idx) => {
if(!Object.prototype.hasOwnProperty.call(state.refs, idx)) {
if(!newRefs) {
newRefs = {...state};
}
newRefs[idx] = docUid("stop-");
}
});
}
return newRefs;
}
type DataPropertyInternalProps = {
onChange?(fieldName: string, value: any): unknown
onDeleteStop?(...args: unknown[]): unknown
onAddStop?(...args: unknown[]): unknown
onExpressionClick?(...args: unknown[]): unknown
onChangeToZoomFunction?(...args: unknown[]): unknown
fieldName: string
fieldType?: string
fieldSpec?: object
value?: DataPropertyValue
errors?: MappedLayerErrors
} & WithTranslation;
type DataPropertyState = {
refs: {[key: number]: string}
};
type DataPropertyValue = {
default?: any
property?: string
base?: number
type?: string
stops: Stop[]
};
export type Stop = [{
zoom: number
value: number
}, number];
const DataPropertyInternal: React.FC<DataPropertyInternalProps> = (props) => {
// Kept in a ref rather than state: the original recomputed these on every
// render via getDerivedStateFromProps, which as state would mean setting
// state during render on every pass.
const refs = useRef<{[key: number]: string}>({});
// setStopRefs returns undefined when no new stop needs a ref; as in the
// original, only replace the map when it actually produced one.
const newStopRefs = setStopRefs(props, { refs: refs.current });
if (newStopRefs) {
refs.current = newStopRefs;
}
function getFieldFunctionType(fieldSpec: any) {
if (fieldSpec.expression.interpolated) {
return "exponential";
}
if (fieldSpec.type === "number") {
return "interval";
}
return "categorical";
}
function getDataFunctionTypes(fieldSpec: any) {
if (fieldSpec.expression.interpolated) {
return ["interpolate", "categorical", "interval", "exponential", "identity"];
}
else {
return ["categorical", "interval", "identity"];
}
}
// Order the stops altering the refs to reflect their new position.
function orderStopsByZoom(stops: Stop[]) {
const mappedWithRef = stops
.map((stop, idx) => {
return {
ref: refs.current[idx],
data: stop
};
})
// Sort by zoom
.sort((a, b) => sortNumerically(a.data[0].zoom, b.data[0].zoom));
// Fetch the new position of the stops
const newRefs = {} as {[key: number]: string};
mappedWithRef
.forEach((stop, idx) =>{
newRefs[idx] = stop.ref;
});
refs.current = newRefs;
return mappedWithRef.map((item) => item.data);
}
const onChange = (fieldName: string, value: any) => {
if (value.type === "identity") {
value = {
type: value.type,
property: value.property,
};
}
else {
const stopValue = value.type === "categorical" ? "" : 0;
value = {
property: "",
type: value.type,
// Default props if they don't already exist.
stops: [
[{zoom: 6, value: stopValue}, findDefaultFromSpec(props.fieldSpec as any)],
[{zoom: 10, value: stopValue}, findDefaultFromSpec(props.fieldSpec as any)]
],
...value,
};
}
props.onChange!(fieldName, value);
};
function changeStop(changeIdx: number, stopData: { zoom: number | undefined, value: number }, value: number) {
const stops = props.value?.stops.slice(0) || [];
// const changedStop = stopData.zoom === undefined ? stopData.value : stopData
stops[changeIdx] = [
{
value: stopData.value,
zoom: (stopData.zoom === undefined) ? 0 : stopData.zoom,
},
value
];
const orderedStops = orderStopsByZoom(stops);
const changedValue = {
...props.value,
stops: orderedStops,
};
onChange(props.fieldName, changedValue);
}
function changeBase(newValue: number | undefined) {
const changedValue = {
...props.value,
base: newValue
};
if (changedValue.base === undefined) {
delete changedValue["base"];
}
props.onChange!(props.fieldName, changedValue);
}
function changeDataType(propVal: string) {
if (propVal === "interpolate" && props.onChangeToZoomFunction) {
props.onChangeToZoomFunction();
}
else {
onChange(props.fieldName, {
...props.value,
type: propVal,
});
}
}
function changeDataProperty(propName: "property" | "default", propVal: any) {
if (propVal) {
props.value![propName] = propVal;
}
else {
delete props.value![propName];
}
onChange(props.fieldName, props.value);
}
const t = props.t;
if (typeof props.value?.type === "undefined") {
props.value!.type = getFieldFunctionType(props.fieldSpec);
}
let dataFields;
if (props.value?.stops) {
dataFields = props.value.stops.map((stop, idx) => {
const zoomLevel = typeof stop[0] === "object" ? stop[0].zoom : undefined;
const key = refs.current[idx];
const dataLevel = typeof stop[0] === "object" ? stop[0].value : stop[0];
const value = stop[1];
const deleteStopBtn = <DeleteStopButton onClick={props.onDeleteStop?.bind(null, idx)} />;
const dataProps = {
"aria-label": t("Input value"),
label: t("Data value"),
value: dataLevel as any,
onChange: (newData: string | number | undefined) => changeStop(idx, { zoom: zoomLevel, value: newData as number }, value)
};
let dataInput;
if(props.value?.type === "categorical") {
dataInput = <InputString {...dataProps} />;
}
else {
dataInput = <InputNumber {...dataProps} />;
}
let zoomInput = null;
if(zoomLevel !== undefined) {
zoomInput = <div>
<InputNumber
aria-label="Zoom"
value={zoomLevel}
onChange={newZoom => changeStop(idx, {zoom: newZoom, value: dataLevel}, value)}
min={0}
max={22}
/>
</div>;
}
return <tr key={key}>
<td>
{zoomInput}
</td>
<td>
{dataInput}
</td>
<td>
<InputSpec
aria-label={t("Output value")}
fieldName={props.fieldName}
fieldSpec={props.fieldSpec}
value={value}
onChange={(_, newValue) => changeStop(idx, {zoom: zoomLevel, value: dataLevel}, newValue as number)}
/>
</td>
<td>
{deleteStopBtn}
</td>
</tr>;
});
}
return <div className="maputnik-data-spec-block">
<fieldset className="maputnik-data-spec-property">
<legend>{labelFromFieldName(props.fieldName)}</legend>
<div className="maputnik-data-fieldset-inner">
<Block
label={t("Function")}
key="function"
data-wd-key="function-type"
>
<div className="maputnik-data-spec-property-input">
<InputSelect
value={props.value!.type}
onChange={(propVal: string) => changeDataType(propVal)}
title={t("Select a type of data scale (default is 'categorical').")}
options={getDataFunctionTypes(props.fieldSpec)}
/>
</div>
</Block>
{props.value?.type !== "identity" &&
<Block
label={t("Base")}
key="base"
data-wd-key="function-base"
>
<div className="maputnik-data-spec-property-input">
<InputSpec
fieldName={"base"}
fieldSpec={latest.function.base as typeof latest.function.base & { type: "number" }}
value={props.value?.base}
onChange={(_, newValue) => changeBase(newValue as number)}
/>
</div>
</Block>
}
<Block
label={"Property"}
key="property"
data-wd-key="function-property"
>
<div className="maputnik-data-spec-property-input">
<InputString
value={props.value?.property}
title={t("Input a data property to base styles off of.")}
onChange={propVal => changeDataProperty("property", propVal)}
/>
</div>
</Block>
{dataFields &&
<Block
label={t("Default")}
key="default"
data-wd-key="function-default"
>
<InputSpec
fieldName={props.fieldName}
fieldSpec={props.fieldSpec}
value={props.value?.default}
onChange={(_, propVal) => changeDataProperty("default", propVal)}
/>
</Block>
}
{dataFields &&
<div className="maputnik-function-stop">
<table className="maputnik-function-stop-table">
<caption>{t("Stops")}</caption>
<thead>
<tr>
<th>{t("Zoom")}</th>
<th>{t("Input value")}</th>
<th rowSpan={2}>{t("Output value")}</th>
</tr>
</thead>
<tbody>
{dataFields}
</tbody>
</table>
</div>
}
<div className="maputnik-toolbox">
{dataFields &&
<InputButton
className="maputnik-add-stop"
onClick={props.onAddStop?.bind(null)}
>
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} />
{t("Add stop")}
</InputButton>
}
<InputButton
className="maputnik-add-stop"
data-wd-key="convert-to-expression"
onClick={props.onExpressionClick?.bind(null)}
>
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
{t("Convert to expression")}
</InputButton>
</div>
</div>
</fieldset>
</div>;
};
export const DataProperty = withTranslation()(DataPropertyInternal);
-24
View File
@@ -1,24 +0,0 @@
import React from "react";
import { InputButton } from "./InputButton";
import {MdDelete} from "react-icons/md";
import { type WithTranslation, withTranslation } from "react-i18next";
type DeleteStopButtonInternalProps = {
onClick?(...args: unknown[]): unknown
} & WithTranslation;
const DeleteStopButtonInternal: React.FC<DeleteStopButtonInternalProps> = (props) => {
const t = props.t;
return <InputButton
className="maputnik-delete-stop"
onClick={props.onClick}
title={t("Remove zoom level from stop")}
>
<MdDelete />
</InputButton>;
};
export const DeleteStopButton = withTranslation()(DeleteStopButtonInternal);
+6 -2
View File
@@ -23,7 +23,10 @@ type DocProps = {
} }
}; };
export const Doc: React.FC<DocProps> = ({fieldSpec}) => { export default class Doc extends React.Component<DocProps> {
render () {
const {fieldSpec} = this.props;
const {doc, values, docUrl, docUrlLinkText} = fieldSpec; const {doc, values, docUrl, docUrlLinkText} = fieldSpec;
const sdkSupport = fieldSpec["sdk-support"]; const sdkSupport = fieldSpec["sdk-support"];
@@ -103,4 +106,5 @@ export const Doc: React.FC<DocProps> = ({fieldSpec}) => {
} }
</> </>
); );
}; }
}
-85
View File
@@ -1,85 +0,0 @@
import React from "react";
import {MdDelete, MdUndo} from "react-icons/md";
import { type WithTranslation, withTranslation } from "react-i18next";
import { Block } from "./Block";
import { InputButton } from "./InputButton";
import { labelFromFieldName } from "../libs/label-from-field-name";
import { FieldJson } from "./FieldJson";
import type { StylePropertySpecification } from "maplibre-gl";
import { type MappedLayerErrors } from "../libs/definitions";
type ExpressionPropertyInternalProps = {
fieldName: string
fieldType?: string
fieldSpec?: StylePropertySpecification
value?: any
errors?: MappedLayerErrors
onDelete?(...args: unknown[]): unknown
onChange(value: object): void
onUndo?(...args: unknown[]): unknown
canUndo?(...args: unknown[]): unknown
onFocus?(...args: unknown[]): unknown
onBlur?(...args: unknown[]): unknown
} & WithTranslation;
const ExpressionPropertyInternal: React.FC<ExpressionPropertyInternalProps> = ({
errors = {},
onFocus = () => {},
onBlur = () => {},
...props
}) => {
const {t, value, canUndo} = props;
const undoDisabled = canUndo ? !canUndo() : true;
const deleteStopBtn = (
<>
{props.onUndo &&
<InputButton
key="undo_action"
onClick={props.onUndo}
disabled={undoDisabled}
className="maputnik-delete-stop"
data-wd-key="undo-expression"
title={t("Revert from expression")}
>
<MdUndo />
</InputButton>
}
<InputButton
key="delete_action"
onClick={props.onDelete}
className="maputnik-delete-stop"
data-wd-key="delete-expression"
title={t("Delete expression")}
>
<MdDelete />
</InputButton>
</>
);
let error = undefined;
if (errors) {
const fieldKey = props.fieldType ? props.fieldType + "." + props.fieldName : props.fieldName;
error = errors[fieldKey];
}
return <Block
fieldSpec={props.fieldSpec}
label={t(labelFromFieldName(props.fieldName))}
action={deleteStopBtn}
wideMode={true}
error={error}
>
<FieldJson
lintType="expression"
spec={props.fieldSpec}
className="maputnik-expression-editor"
onFocus={onFocus}
onBlur={onBlur}
value={value}
onChange={props.onChange}
/>
</Block>;
};
export const ExpressionProperty = withTranslation()(ExpressionPropertyInternal);
+5 -3
View File
@@ -1,5 +1,5 @@
import { InputArray, type InputArrayProps } from "./InputArray"; import InputArray, { type InputArrayProps } from "./InputArray";
import { Fieldset } from "./Fieldset"; import Fieldset from "./Fieldset";
type FieldArrayProps = InputArrayProps & { type FieldArrayProps = InputArrayProps & {
name?: string name?: string
@@ -8,10 +8,12 @@ type FieldArrayProps = InputArrayProps & {
} }
}; };
export const FieldArray: React.FC<FieldArrayProps> = (props) => { const FieldArray: React.FC<FieldArrayProps> = (props) => {
return ( return (
<Fieldset label={props.label} fieldSpec={props.fieldSpec}> <Fieldset label={props.label} fieldSpec={props.fieldSpec}>
<InputArray {...props} /> <InputArray {...props} />
</Fieldset> </Fieldset>
); );
}; };
export default FieldArray;
+5 -3
View File
@@ -1,5 +1,5 @@
import { Block } from "./Block"; import Block from "./Block";
import { InputAutocomplete, type InputAutocompleteProps } from "./InputAutocomplete"; import InputAutocomplete, { type InputAutocompleteProps } from "./InputAutocomplete";
type FieldAutocompleteProps = InputAutocompleteProps & { type FieldAutocompleteProps = InputAutocompleteProps & {
@@ -7,10 +7,12 @@ type FieldAutocompleteProps = InputAutocompleteProps & {
}; };
export const FieldAutocomplete: React.FC<FieldAutocompleteProps> = (props) => { const FieldAutocomplete: React.FC<FieldAutocompleteProps> = (props) => {
return ( return (
<Block label={props.label}> <Block label={props.label}>
<InputAutocomplete {...props} /> <InputAutocomplete {...props} />
</Block> </Block>
); );
}; };
export default FieldAutocomplete;
+5 -3
View File
@@ -1,5 +1,5 @@
import { Block } from "./Block"; import Block from "./Block";
import { InputCheckbox, type InputCheckboxProps } from "./InputCheckbox"; import InputCheckbox, {type InputCheckboxProps} from "./InputCheckbox";
type FieldCheckboxProps = InputCheckboxProps & { type FieldCheckboxProps = InputCheckboxProps & {
@@ -7,10 +7,12 @@ type FieldCheckboxProps = InputCheckboxProps & {
}; };
export const FieldCheckbox: React.FC<FieldCheckboxProps> = (props) => { const FieldCheckbox: React.FC<FieldCheckboxProps> = (props) => {
return ( return (
<Block label={props.label}> <Block label={props.label}>
<InputCheckbox {...props} /> <InputCheckbox {...props} />
</Block> </Block>
); );
}; };
export default FieldCheckbox;
+5 -3
View File
@@ -1,5 +1,5 @@
import { Block } from "./Block"; import Block from "./Block";
import { InputColor, type InputColorProps } from "./InputColor"; import InputColor, {type InputColorProps} from "./InputColor";
type FieldColorProps = InputColorProps & { type FieldColorProps = InputColorProps & {
@@ -10,10 +10,12 @@ type FieldColorProps = InputColorProps & {
}; };
export const FieldColor: React.FC<FieldColorProps> = (props) => { const FieldColor: React.FC<FieldColorProps> = (props) => {
return ( return (
<Block label={props.label} fieldSpec={props.fieldSpec}> <Block label={props.label} fieldSpec={props.fieldSpec}>
<InputColor {...props} /> <InputColor {...props} />
</Block> </Block>
); );
}; };
export default FieldColor;
+4 -3
View File
@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { Block } from "./Block"; import Block from "./Block";
import { InputString } from "./InputString"; import InputString from "./InputString";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
type FieldCommentInternalProps = { type FieldCommentInternalProps = {
@@ -36,4 +36,5 @@ const FieldCommentInternal: React.FC<FieldCommentInternalProps> = (props) => {
); );
}; };
export const FieldComment = withTranslation()(FieldCommentInternal); const FieldComment = withTranslation()(FieldCommentInternal);
export default FieldComment;
+3 -1
View File
@@ -10,7 +10,7 @@ type FieldDocLabelProps = {
}; };
export const FieldDocLabel: React.FC<FieldDocLabelProps> = (props) => { const FieldDocLabel: React.FC<FieldDocLabelProps> = (props) => {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const onToggleDoc = (state: boolean) => { const onToggleDoc = (state: boolean) => {
@@ -49,3 +49,5 @@ export const FieldDocLabel: React.FC<FieldDocLabelProps> = (props) => {
} }
return <div />; return <div />;
}; };
export default FieldDocLabel;
+5 -3
View File
@@ -1,14 +1,16 @@
import { InputDynamicArray, type InputDynamicArrayProps } from "./InputDynamicArray"; import InputDynamicArray, {type InputDynamicArrayProps} from "./InputDynamicArray";
import { Fieldset } from "./Fieldset"; import Fieldset from "./Fieldset";
type FieldDynamicArrayProps = InputDynamicArrayProps & { type FieldDynamicArrayProps = InputDynamicArrayProps & {
name?: string name?: string
}; };
export const FieldDynamicArray: React.FC<FieldDynamicArrayProps> = (props) => { const FieldDynamicArray: React.FC<FieldDynamicArrayProps> = (props) => {
return ( return (
<Fieldset label={props.label}> <Fieldset label={props.label}>
<InputDynamicArray {...props} /> <InputDynamicArray {...props} />
</Fieldset> </Fieldset>
); );
}; };
export default FieldDynamicArray;
+5 -3
View File
@@ -1,5 +1,5 @@
import { InputEnum, type InputEnumProps } from "./InputEnum"; import InputEnum, {type InputEnumProps} from "./InputEnum";
import { Fieldset } from "./Fieldset"; import Fieldset from "./Fieldset";
type FieldEnumProps = InputEnumProps & { type FieldEnumProps = InputEnumProps & {
@@ -10,10 +10,12 @@ type FieldEnumProps = InputEnumProps & {
}; };
export const FieldEnum: React.FC<FieldEnumProps> = (props) => { const FieldEnum: React.FC<FieldEnumProps> = (props) => {
return ( return (
<Fieldset label={props.label} fieldSpec={props.fieldSpec}> <Fieldset label={props.label} fieldSpec={props.fieldSpec}>
<InputEnum {...props} /> <InputEnum {...props} />
</Fieldset> </Fieldset>
); );
}; };
export default FieldEnum;
+7 -5
View File
@@ -1,9 +1,9 @@
import React from "react"; import React from "react";
import { SpecProperty } from "./SpecProperty"; import SpecProperty from "./_SpecProperty";
import { DataProperty, type Stop } from "./DataProperty"; import DataProperty, { type Stop } from "./_DataProperty";
import { ZoomProperty } from "./ZoomProperty"; import ZoomProperty from "./_ZoomProperty";
import { ExpressionProperty } from "./ExpressionProperty"; import ExpressionProperty from "./_ExpressionProperty";
import {function as styleFunction} from "@maplibre/maplibre-gl-style-spec"; import {function as styleFunction} from "@maplibre/maplibre-gl-style-spec";
import {findDefaultFromSpec} from "../libs/spec-helper"; import {findDefaultFromSpec} from "../libs/spec-helper";
import { type MappedLayerErrors } from "../libs/definitions"; import { type MappedLayerErrors } from "../libs/definitions";
@@ -128,7 +128,7 @@ type FieldFunctionProps = {
/** 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
*/ */
export const FieldFunction: React.FC<FieldFunctionProps> = (props) => { const FieldFunction: React.FC<FieldFunctionProps> = (props) => {
const [dataType, setDataType] = React.useState( const [dataType, setDataType] = React.useState(
getDataType(props.value, props.fieldSpec) getDataType(props.value, props.fieldSpec)
); );
@@ -402,3 +402,5 @@ export const FieldFunction: React.FC<FieldFunctionProps> = (props) => {
</div> </div>
); );
}; };
export default FieldFunction;
+5 -3
View File
@@ -1,7 +1,7 @@
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";
import { InputString } from "./InputString"; import InputString from "./InputString";
type FieldIdProps = { type FieldIdProps = {
value: string value: string
@@ -10,7 +10,7 @@ type FieldIdProps = {
error?: {message: string} error?: {message: string}
}; };
export const FieldId: React.FC<FieldIdProps> = (props) => { const FieldId: React.FC<FieldIdProps> = (props) => {
return ( return (
<Block label="ID" fieldSpec={latest.layer.id} <Block label="ID" fieldSpec={latest.layer.id}
data-wd-key={props.wdKey} data-wd-key={props.wdKey}
@@ -24,3 +24,5 @@ export const FieldId: React.FC<FieldIdProps> = (props) => {
</Block> </Block>
); );
}; };
export default FieldId;
+4 -2
View File
@@ -1,9 +1,11 @@
import { InputJson, type InputJsonProps } from "./InputJson"; import InputJson, {type InputJsonProps} from "./InputJson";
type FieldJsonProps = InputJsonProps & {}; type FieldJsonProps = InputJsonProps & {};
export const FieldJson: React.FC<FieldJsonProps> = (props) => { const FieldJson: React.FC<FieldJsonProps> = (props) => {
return <InputJson {...props} />; return <InputJson {...props} />;
}; };
export default FieldJson;
+4 -3
View File
@@ -1,8 +1,8 @@
import React from "react"; 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";
import { InputNumber } from "./InputNumber"; import InputNumber from "./InputNumber";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
type FieldMaxZoomInternalProps = { type FieldMaxZoomInternalProps = {
@@ -31,4 +31,5 @@ const FieldMaxZoomInternal: React.FC<FieldMaxZoomInternalProps> = (props) => {
); );
}; };
export const FieldMaxZoom = withTranslation()(FieldMaxZoomInternal); const FieldMaxZoom = withTranslation()(FieldMaxZoomInternal);
export default FieldMaxZoom;
+4 -3
View File
@@ -1,8 +1,8 @@
import React from "react"; 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";
import { InputNumber } from "./InputNumber"; import InputNumber from "./InputNumber";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
type FieldMinZoomInternalProps = { type FieldMinZoomInternalProps = {
@@ -31,4 +31,5 @@ const FieldMinZoomInternal: React.FC<FieldMinZoomInternalProps> = (props) => {
); );
}; };
export const FieldMinZoom = withTranslation()(FieldMinZoomInternal); const FieldMinZoom = withTranslation()(FieldMinZoomInternal);
export default FieldMinZoom;
+5 -3
View File
@@ -1,5 +1,5 @@
import { InputMultiInput, type InputMultiInputProps } from "./InputMultiInput"; import InputMultiInput, {type InputMultiInputProps} from "./InputMultiInput";
import { Fieldset } from "./Fieldset"; import Fieldset from "./Fieldset";
type FieldMultiInputProps = InputMultiInputProps & { type FieldMultiInputProps = InputMultiInputProps & {
@@ -7,10 +7,12 @@ type FieldMultiInputProps = InputMultiInputProps & {
}; };
export const FieldMultiInput: React.FC<FieldMultiInputProps> = (props) => { const FieldMultiInput: React.FC<FieldMultiInputProps> = (props) => {
return ( return (
<Fieldset label={props.label}> <Fieldset label={props.label}>
<InputMultiInput {...props} /> <InputMultiInput {...props} />
</Fieldset> </Fieldset>
); );
}; };
export default FieldMultiInput;
+5 -3
View File
@@ -1,5 +1,5 @@
import { InputNumber, type InputNumberProps } from "./InputNumber"; import InputNumber, {type InputNumberProps} from "./InputNumber";
import { Block } from "./Block"; import Block from "./Block";
type FieldNumberProps = InputNumberProps & { type FieldNumberProps = InputNumberProps & {
@@ -10,10 +10,12 @@ type FieldNumberProps = InputNumberProps & {
}; };
export const FieldNumber: React.FC<FieldNumberProps> = (props) => { const FieldNumber: React.FC<FieldNumberProps> = (props) => {
return ( return (
<Block label={props.label} fieldSpec={props.fieldSpec}> <Block label={props.label} fieldSpec={props.fieldSpec}>
<InputNumber {...props} /> <InputNumber {...props} />
</Block> </Block>
); );
}; };
export default FieldNumber;
+5 -3
View File
@@ -1,5 +1,5 @@
import { Block } from "./Block"; import Block from "./Block";
import { InputSelect, type InputSelectProps } from "./InputSelect"; import InputSelect, {type InputSelectProps} from "./InputSelect";
type FieldSelectProps = InputSelectProps & { type FieldSelectProps = InputSelectProps & {
@@ -10,10 +10,12 @@ type FieldSelectProps = InputSelectProps & {
}; };
export const FieldSelect: React.FC<FieldSelectProps> = (props) => { const FieldSelect: React.FC<FieldSelectProps> = (props) => {
return ( return (
<Block label={props.label} fieldSpec={props.fieldSpec}> <Block label={props.label} fieldSpec={props.fieldSpec}>
<InputSelect {...props} /> <InputSelect {...props} />
</Block> </Block>
); );
}; };
export default FieldSelect;
+4 -3
View File
@@ -1,8 +1,8 @@
import React from "react"; 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";
import { InputAutocomplete } from "./InputAutocomplete"; import InputAutocomplete from "./InputAutocomplete";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
type FieldSourceInternalProps = { type FieldSourceInternalProps = {
@@ -38,4 +38,5 @@ const FieldSourceInternal: React.FC<FieldSourceInternalProps> = ({
}; };
export const FieldSource = withTranslation()(FieldSourceInternal); const FieldSource = withTranslation()(FieldSourceInternal);
export default FieldSource;
+4 -3
View File
@@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import {latest} from "@maplibre/maplibre-gl-style-spec"; import {latest} from "@maplibre/maplibre-gl-style-spec";
import { Block } from "./Block"; import Block from "./Block";
import { InputAutocomplete } from "./InputAutocomplete"; import InputAutocomplete from "./InputAutocomplete";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
type FieldSourceLayerInternalProps = { type FieldSourceLayerInternalProps = {
@@ -35,4 +35,5 @@ const FieldSourceLayerInternal: React.FC<FieldSourceLayerInternalProps> = ({
); );
}; };
export const FieldSourceLayer = withTranslation()(FieldSourceLayerInternal); const FieldSourceLayer = withTranslation()(FieldSourceLayerInternal);
export default FieldSourceLayer;
+6 -4
View File
@@ -1,6 +1,6 @@
import { Block, type BlockProps } from "./Block"; import Block, { type BlockProps } from "./Block";
import { InputSpec, type FieldSpecType, type InputSpecProps } from "./InputSpec"; import InputSpec, { type FieldSpecType, type InputSpecProps } from "./InputSpec";
import { Fieldset, type FieldsetProps } from "./Fieldset"; import Fieldset, { type FieldsetProps } from "./Fieldset";
function getElementFromType(fieldSpec: { type?: FieldSpecType, values?: unknown[] }): typeof Fieldset | typeof Block { function getElementFromType(fieldSpec: { type?: FieldSpecType, values?: unknown[] }): typeof Fieldset | typeof Block {
switch(fieldSpec.type) { switch(fieldSpec.type) {
@@ -36,7 +36,7 @@ function getElementFromType(fieldSpec: { type?: FieldSpecType, values?: unknown[
export type FieldSpecProps = InputSpecProps & BlockProps & FieldsetProps; export type FieldSpecProps = InputSpecProps & BlockProps & FieldsetProps;
export const FieldSpec: React.FC<FieldSpecProps> = (props) => { const FieldSpec: React.FC<FieldSpecProps> = (props) => {
const TypeBlock = getElementFromType(props.fieldSpec!); const TypeBlock = getElementFromType(props.fieldSpec!);
return ( return (
@@ -45,3 +45,5 @@ export const FieldSpec: React.FC<FieldSpecProps> = (props) => {
</TypeBlock> </TypeBlock>
); );
}; };
export default FieldSpec;
+5 -3
View File
@@ -1,5 +1,5 @@
import { Block } from "./Block"; import Block from "./Block";
import { InputString, type InputStringProps } from "./InputString"; import InputString, {type InputStringProps} from "./InputString";
type FieldStringProps = InputStringProps & { type FieldStringProps = InputStringProps & {
name?: string name?: string
@@ -9,10 +9,12 @@ type FieldStringProps = InputStringProps & {
} }
}; };
export const FieldString: React.FC<FieldStringProps> = (props) => { const FieldString: React.FC<FieldStringProps> = (props) => {
return ( return (
<Block label={props.label} fieldSpec={props.fieldSpec}> <Block label={props.label} fieldSpec={props.fieldSpec}>
<InputString {...props} /> <InputString {...props} />
</Block> </Block>
); );
}; };
export default FieldString;
+5 -4
View File
@@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import {v8} from "@maplibre/maplibre-gl-style-spec"; import {v8} from "@maplibre/maplibre-gl-style-spec";
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 { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import { startCase } from "lodash"; import { startCase } from "lodash";
@@ -43,4 +43,5 @@ const FieldTypeInternal: React.FC<FieldTypeInternalProps> = ({
); );
}; };
export const FieldType = withTranslation()(FieldTypeInternal); const FieldType = withTranslation()(FieldTypeInternal);
export default FieldType;
+5 -3
View File
@@ -1,5 +1,5 @@
import { InputUrl, type FieldUrlProps as InputUrlProps } from "./InputUrl"; import InputUrl, {type FieldUrlProps as InputUrlProps} from "./InputUrl";
import { Block } from "./Block"; import Block from "./Block";
type FieldUrlProps = InputUrlProps & { type FieldUrlProps = InputUrlProps & {
@@ -10,10 +10,12 @@ type FieldUrlProps = InputUrlProps & {
}; };
export const FieldUrl: React.FC<FieldUrlProps> = (props) => { const FieldUrl: React.FC<FieldUrlProps> = (props) => {
return ( return (
<Block label={props.label} fieldSpec={props.fieldSpec}> <Block label={props.label} fieldSpec={props.fieldSpec}>
<InputUrl {...props} /> <InputUrl {...props} />
</Block> </Block>
); );
}; };
export default FieldUrl;
+6 -4
View File
@@ -1,8 +1,8 @@
import React, { type PropsWithChildren, type ReactElement } from "react"; import React, { type PropsWithChildren, type ReactElement } from "react";
import classnames from "classnames"; import classnames from "classnames";
import { FieldDocLabel } from "./FieldDocLabel"; import FieldDocLabel from "./FieldDocLabel";
import { Doc } from "./Doc"; import Doc from "./Doc";
import { generateUniqueId } from "../libs/document-uid"; import generateUniqueId from "../libs/document-uid";
export type FieldsetProps = PropsWithChildren & { export type FieldsetProps = PropsWithChildren & {
label?: string, label?: string,
@@ -12,7 +12,7 @@ export type FieldsetProps = PropsWithChildren & {
}; };
export const Fieldset: React.FC<FieldsetProps> = (props) => { const Fieldset: React.FC<FieldsetProps> = (props) => {
const [showDoc, setShowDoc] = React.useState(false); const [showDoc, setShowDoc] = React.useState(false);
const labelId = React.useRef(generateUniqueId("fieldset_label_")); const labelId = React.useRef(generateUniqueId("fieldset_label_"));
@@ -49,3 +49,5 @@ export const Fieldset: React.FC<FieldsetProps> = (props) => {
</div> </div>
); );
}; };
export default Fieldset;
+92 -61
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React from "react";
import { TbMathFunction } from "react-icons/tb"; import { TbMathFunction } from "react-icons/tb";
import { PiListPlusBold } from "react-icons/pi"; import { PiListPlusBold } from "react-icons/pi";
import {isEqual} from "lodash"; import {isEqual} from "lodash";
@@ -7,13 +7,13 @@ import {migrate, convertFilter} from "@maplibre/maplibre-gl-style-spec";
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json"; import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import {combiningFilterOps} from "../libs/filterops"; import {combiningFilterOps} from "../libs/filterops";
import { InputSelect } from "./InputSelect"; import InputSelect from "./InputSelect";
import { Block } from "./Block"; import Block from "./Block";
import { SingleFilterEditor } from "./SingleFilterEditor"; import SingleFilterEditor from "./SingleFilterEditor";
import { FilterEditorBlock } from "./FilterEditorBlock"; import FilterEditorBlock from "./FilterEditorBlock";
import { InputButton } from "./InputButton"; import InputButton from "./InputButton";
import { Doc } from "./Doc"; import Doc from "./Doc";
import { ExpressionProperty } from "./ExpressionProperty"; import ExpressionProperty from "./_ExpressionProperty";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import type { MappedLayerErrors, StyleSpecificationWithId } from "../libs/definitions"; import type { MappedLayerErrors, StyleSpecificationWithId } from "../libs/definitions";
@@ -100,64 +100,95 @@ type FilterEditorInternalProps = {
onChange(value: LegacyFilterSpecification | ExpressionSpecification): void onChange(value: LegacyFilterSpecification | ExpressionSpecification): void
} & WithTranslation; } & WithTranslation;
const FilterEditorInternal: React.FC<FilterEditorInternalProps> = ({ filter = ["all"], ...rest }) => { type FilterEditorState = {
const props = { filter, ...rest } as FilterEditorInternalProps; showDoc: boolean
displaySimpleFilter: boolean
valueIsSimpleFilter?: boolean
};
// Nothing ever toggles this: the Block below renders its own documentation class FilterEditorInternal extends React.Component<FilterEditorInternalProps, FilterEditorState> {
// toggle, so this component's inline doc panel stays hidden (as it did before). static defaultProps = {
const [showDoc] = useState(false); filter: ["all"],
const [displaySimpleFilter, setDisplaySimpleFilter] = useState(() => };
checkIfSimpleFilter(combiningFilter(props))
);
// Replaces getDerivedStateFromProps. "Upgrade but never downgrade": once the constructor (props: FilterEditorInternalProps) {
// filter stops being expressible in the simple editor, switch to the super(props);
// expression editor and stay there. this.state = {
const isSimpleFilter = checkIfSimpleFilter(combiningFilter(props)); showDoc: false,
if (!isSimpleFilter && displaySimpleFilter) { displaySimpleFilter: checkIfSimpleFilter(combiningFilter(props)),
setDisplaySimpleFilter(false); };
} }
// In the original this was state, but every branch derived it from these two
// values alone.
const valueIsSimpleFilter = isSimpleFilter && !displaySimpleFilter;
// Convert filter to combining filter // Convert filter to combining filter
function onFilterPartChanged(filterIdx: number, newPart: any[]) { onFilterPartChanged(filterIdx: number, newPart: any[]) {
const newFilter = combiningFilter(props).slice(0) as LegacyFilterSpecification | ExpressionSpecification; const newFilter = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
newFilter[filterIdx] = newPart; newFilter[filterIdx] = newPart;
props.onChange(newFilter); this.props.onChange(newFilter);
} }
function deleteFilterItem(filterIdx: number) { deleteFilterItem(filterIdx: number) {
const newFilter = combiningFilter(props).slice(0) as LegacyFilterSpecification | ExpressionSpecification; const newFilter = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
newFilter.splice(filterIdx + 1, 1); newFilter.splice(filterIdx + 1, 1);
props.onChange(newFilter); this.props.onChange(newFilter);
} }
const addFilterItem = () => { addFilterItem = () => {
const newFilterItem = combiningFilter(props).slice(0) as LegacyFilterSpecification | ExpressionSpecification; const newFilterItem = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
(newFilterItem as any[]).push(["==", "name", ""]); (newFilterItem as any[]).push(["==", "name", ""]);
props.onChange(newFilterItem); this.props.onChange(newFilterItem);
}; };
const makeFilter = () => { onToggleDoc = (val: boolean) => {
setDisplaySimpleFilter(true); this.setState({
showDoc: val
});
}; };
const makeExpression = () => { makeFilter = () => {
const currentFilter = combiningFilter(props); this.setState({
props.onChange(migrateFilter(currentFilter)); displaySimpleFilter: true,
setDisplaySimpleFilter(false); });
}; };
makeExpression = () => {
const filter = combiningFilter(this.props);
this.props.onChange(migrateFilter(filter));
this.setState({
displaySimpleFilter: false,
});
};
const {errors, t} = props; static getDerivedStateFromProps(props: Readonly<FilterEditorInternalProps>, state: FilterEditorState) {
const displaySimpleFilter = checkIfSimpleFilter(combiningFilter(props));
// Upgrade but never downgrade
if (!displaySimpleFilter && state.displaySimpleFilter === true) {
return {
displaySimpleFilter: false,
valueIsSimpleFilter: false,
};
}
else if (displaySimpleFilter && state.displaySimpleFilter === false) {
return {
valueIsSimpleFilter: true,
};
}
else {
return {
valueIsSimpleFilter: false,
};
}
}
render() {
const {errors, t} = this.props;
const {displaySimpleFilter} = this.state;
const fieldSpec={ const fieldSpec={
doc: latest.layer.filter.doc + " Combine multiple filters together by using a compound filter." doc: latest.layer.filter.doc + " Combine multiple filters together by using a compound filter."
}; };
const defaultFilter = ["all"] as LegacyFilterSpecification | ExpressionSpecification; const defaultFilter = ["all"] as LegacyFilterSpecification | ExpressionSpecification;
const isNestedCombiningFilter = displaySimpleFilter && hasNestedCombiningFilter(combiningFilter(props)); const isNestedCombiningFilter = displaySimpleFilter && hasNestedCombiningFilter(combiningFilter(this.props));
if (isNestedCombiningFilter) { if (isNestedCombiningFilter) {
return <div className="maputnik-filter-editor-unsupported"> return <div className="maputnik-filter-editor-unsupported">
@@ -165,7 +196,7 @@ const FilterEditorInternal: React.FC<FilterEditorInternalProps> = ({ filter = ["
{t("Nested filters are not supported.")} {t("Nested filters are not supported.")}
</p> </p>
<InputButton <InputButton
onClick={makeExpression} onClick={this.makeExpression}
title={t("Convert to expression")} title={t("Convert to expression")}
> >
<TbMathFunction /> <TbMathFunction />
@@ -174,17 +205,16 @@ const FilterEditorInternal: React.FC<FilterEditorInternalProps> = ({ filter = ["
</div>; </div>;
} }
else if (displaySimpleFilter) { else if (displaySimpleFilter) {
const filter = combiningFilter(props); const filter = combiningFilter(this.props);
const combiningOp = filter[0]; const combiningOp = filter[0];
const filters = filter.slice(1) as (LegacyFilterSpecification | ExpressionSpecification)[]; const filters = filter.slice(1) as (LegacyFilterSpecification | ExpressionSpecification)[];
const actions = ( const actions = (
<div> <div>
<InputButton <InputButton
onClick={makeExpression} onClick={this.makeExpression}
title={t("Convert to expression")} title={t("Convert to expression")}
className="maputnik-make-zoom-function" className="maputnik-make-zoom-function"
data-wd-key="filter-convert-to-expression"
> >
<TbMathFunction /> <TbMathFunction />
</InputButton> </InputButton>
@@ -196,11 +226,11 @@ const FilterEditorInternal: React.FC<FilterEditorInternalProps> = ({ filter = ["
return ( return (
<div key={`block-${idx}`}> <div key={`block-${idx}`}>
<FilterEditorBlock key={idx} onDelete={deleteFilterItem.bind(null, idx)}> <FilterEditorBlock key={idx} onDelete={this.deleteFilterItem.bind(this, idx)}>
<SingleFilterEditor <SingleFilterEditor
properties={props.properties} properties={this.props.properties}
filter={f} filter={f}
onChange={onFilterPartChanged.bind(null, idx + 1)} onChange={this.onFilterPartChanged.bind(this, idx + 1)}
/> />
</FilterEditorBlock> </FilterEditorBlock>
{error && {error &&
@@ -218,11 +248,10 @@ const FilterEditorInternal: React.FC<FilterEditorInternalProps> = ({ filter = ["
fieldSpec={fieldSpec} fieldSpec={fieldSpec}
label={t("Filter")} label={t("Filter")}
action={actions} action={actions}
data-wd-key="filter-combining-operator"
> >
<InputSelect <InputSelect
value={combiningOp} value={combiningOp}
onChange={(v: [string, any]) => onFilterPartChanged(0, v)} onChange={(v: [string, any]) => this.onFilterPartChanged(0, v)}
options={[ options={[
["all", t("every filter matches")], ["all", t("every filter matches")],
["none", t("no filter matches")], ["none", t("no filter matches")],
@@ -238,7 +267,7 @@ const FilterEditorInternal: React.FC<FilterEditorInternalProps> = ({ filter = ["
<InputButton <InputButton
data-wd-key="layer-filter-button" data-wd-key="layer-filter-button"
className="maputnik-add-filter" className="maputnik-add-filter"
onClick={addFilterItem} onClick={this.addFilterItem}
> >
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} /> <PiListPlusBold style={{ verticalAlign: "text-bottom" }} />
{t("Add filter")} {t("Add filter")}
@@ -247,7 +276,7 @@ const FilterEditorInternal: React.FC<FilterEditorInternalProps> = ({ filter = ["
<div <div
key="doc" key="doc"
className="maputnik-doc-inline" className="maputnik-doc-inline"
style={{display: showDoc ? "" : "none"}} style={{display: this.state.showDoc ? "" : "none"}}
> >
<Doc fieldSpec={fieldSpec} /> <Doc fieldSpec={fieldSpec} />
</div> </div>
@@ -255,26 +284,26 @@ const FilterEditorInternal: React.FC<FilterEditorInternalProps> = ({ filter = ["
); );
} }
else { else {
const {filter} = props; const {filter} = this.props;
return ( return (
<> <>
<ExpressionProperty <ExpressionProperty
onDelete={() => { onDelete={() => {
setDisplaySimpleFilter(true); this.setState({displaySimpleFilter: true});
props.onChange(defaultFilter); this.props.onChange(defaultFilter);
}} }}
fieldName="filter" fieldName="filter"
value={filter} value={filter}
errors={errors} errors={errors}
onChange={props.onChange} onChange={this.props.onChange}
/> />
{valueIsSimpleFilter && {this.state.valueIsSimpleFilter &&
<div className="maputnik-expr-infobox"> <div className="maputnik-expr-infobox">
{t("You've entered an old style filter.")} {t("You've entered an old style filter.")}
{" "} {" "}
<button <button
onClick={makeFilter} onClick={this.makeFilter}
className="maputnik-expr-infobox__button" className="maputnik-expr-infobox__button"
> >
{t("Switch to filter editor.")} {t("Switch to filter editor.")}
@@ -284,6 +313,8 @@ const FilterEditorInternal: React.FC<FilterEditorInternalProps> = ({ filter = ["
</> </>
); );
} }
}; }
}
export const FilterEditor = withTranslation()(FilterEditorInternal); const FilterEditor = withTranslation()(FilterEditorInternal);
export default FilterEditor;
+10 -7
View File
@@ -1,5 +1,5 @@
import React, { type PropsWithChildren } from "react"; import React, { type PropsWithChildren } from "react";
import { InputButton } from "./InputButton"; import InputButton from "./InputButton";
import {MdDelete} from "react-icons/md"; import {MdDelete} from "react-icons/md";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
@@ -7,22 +7,25 @@ type FilterEditorBlockInternalProps = PropsWithChildren & {
onDelete(...args: unknown[]): unknown onDelete(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const FilterEditorBlockInternal: React.FC<FilterEditorBlockInternalProps> = (props) => { class FilterEditorBlockInternal extends React.Component<FilterEditorBlockInternalProps> {
const t = props.t; render() {
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"> <div className="maputnik-filter-editor-block-content">
{props.children} {this.props.children}
</div> </div>
<div className="maputnik-filter-editor-block-action"> <div className="maputnik-filter-editor-block-action">
<InputButton <InputButton
className="maputnik-icon-button" className="maputnik-icon-button"
onClick={props.onDelete} onClick={this.props.onDelete}
title={t("Delete filter block")} title={t("Delete filter block")}
> >
<MdDelete /> <MdDelete />
</InputButton> </InputButton>
</div> </div>
</div>; </div>;
}; }
}
export const FilterEditorBlock = withTranslation()(FilterEditorBlockInternal); const FilterEditorBlock = withTranslation()(FilterEditorBlockInternal);
export default FilterEditorBlock;
-68
View File
@@ -1,68 +0,0 @@
import React from "react";
import { InputButton } from "./InputButton";
import {MdFunctions, MdInsertChart} from "react-icons/md";
import { TbMathFunction } from "react-icons/tb";
import { type WithTranslation, withTranslation } from "react-i18next";
type FunctionInputButtonsInternalProps = {
fieldSpec?: any
onZoomClick?(): void
onDataClick?(): void
onExpressionClick?(): void
onElevationClick?(): void
} & WithTranslation;
const FunctionInputButtonsInternal: React.FC<FunctionInputButtonsInternalProps> = (props) => {
const t = props.t;
if (props.fieldSpec.expression?.parameters.includes("zoom")) {
const expressionInputButton = (
<InputButton
className="maputnik-make-zoom-function"
onClick={props.onExpressionClick}
title={t("Convert to expression")}
>
<TbMathFunction />
</InputButton>
);
const makeZoomInputButton = <InputButton
className="maputnik-make-zoom-function"
onClick={props.onZoomClick}
title={t("Convert property into a zoom function")}
>
<MdFunctions />
</InputButton>;
let makeDataInputButton;
if (props.fieldSpec["property-type"] === "data-driven") {
makeDataInputButton = <InputButton
className="maputnik-make-data-function"
onClick={props.onDataClick}
title={t("Convert property to data function")}
>
<MdInsertChart />
</InputButton>;
}
return <div>
{expressionInputButton}
{makeDataInputButton}
{makeZoomInputButton}
</div>;
} else if (props.fieldSpec.expression?.parameters.includes("elevation")) {
const inputElevationButton = <InputButton
className="maputnik-make-elevation-function"
onClick={props.onElevationClick}
title={t("Convert property into a elevation function")}
data-wd-key='make-elevation-function'
>
<MdFunctions />
</InputButton>;
return <div>{inputElevationButton}</div>;
} else {
return <div></div>;
}
};
export const FunctionInputButtons = withTranslation()(FunctionInputButtonsInternal);
+3 -1
View File
@@ -13,7 +13,7 @@ type IconLayerProps = {
className?: string className?: string
}; };
export const IconLayer: React.FC<IconLayerProps> = (props) => { const IconLayer: React.FC<IconLayerProps> = (props) => {
const iconProps = { style: props.style }; const iconProps = { style: props.style };
switch(props.type) { switch(props.type) {
case "fill-extrusion": return <IoMdCube {...iconProps} />; case "fill-extrusion": return <IoMdCube {...iconProps} />;
@@ -29,3 +29,5 @@ export const IconLayer: React.FC<IconLayerProps> = (props) => {
default: return <MdPriorityHigh {...iconProps} />; default: return <MdPriorityHigh {...iconProps} />;
} }
}; };
export default IconLayer;
+69 -35
View File
@@ -1,6 +1,6 @@
import React, { useState } from "react"; import React from "react";
import { InputString } from "./InputString"; import InputString from "./InputString";
import { InputNumber } from "./InputNumber"; import InputNumber from "./InputNumber";
export type InputArrayProps = { export type InputArrayProps = {
value: (string | number | undefined)[] value: (string | number | undefined)[]
@@ -12,40 +12,73 @@ export type InputArrayProps = {
label?: string label?: string
}; };
export const InputArray: React.FC<InputArrayProps> = ({ type InputArrayState = {
value: propsValue = [], value: (string | number | undefined)[]
default: propsDefault = [], initialPropsValue: unknown[]
...rest };
}) => {
const props = { value: propsValue, default: propsDefault, ...rest };
// The original seeded this from props and then never let props overwrite it export default class InputArray extends React.Component<InputArrayProps, InputArrayState> {
// again (its getDerivedStateFromProps assigned the existing state back in static defaultProps = {
// both branches), so the value is owned by this component after mount. value: [],
const [value, setValue] = useState<(string | number | undefined)[]>(() => propsValue.slice(0)); default: [],
};
function isComplete(val: unknown[]) { constructor (props: InputArrayProps) {
return Array(props.length).fill(null).every((_, i) => { super(props);
const v = val[i]; this.state = {
return !(v === undefined || v === ""); value: this.props.value.slice(0),
// This is so we can compare changes in getDerivedStateFromProps
initialPropsValue: this.props.value.slice(0),
};
}
static getDerivedStateFromProps(props: Readonly<InputArrayProps>, state: InputArrayState) {
const value: any[] = [];
const initialPropsValue = state.initialPropsValue.slice(0);
Array(props.length).fill(null).map((_, i) => {
if (props.value[i] === state.initialPropsValue[i]) {
value[i] = state.value[i];
}
else {
value[i] = state.value[i];
initialPropsValue[i] = state.value[i];
}
});
return {
value,
initialPropsValue,
};
}
isComplete(value: unknown[]) {
return Array(this.props.length).fill(null).every((_, i) => {
const val = value[i];
return !(val === undefined || val === "");
}); });
} }
function changeValue(idx: number, newValue: string | number | undefined) { changeValue(idx: number, newValue: string | number | undefined) {
const nextValue = value.slice(0); const value = this.state.value.slice(0);
nextValue[idx] = newValue; value[idx] = newValue;
setValue(nextValue); this.setState({
value,
if (isComplete(nextValue) && props.onChange) { }, () => {
props.onChange(nextValue); if (this.isComplete(value) && this.props.onChange) {
this.props.onChange(value);
} }
else if (props.onChange) { else if (this.props.onChange){
// Unset until complete // Unset until complete
props.onChange(undefined); this.props.onChange(undefined);
} }
});
} }
render() {
const {value} = this.state;
const containsValues = ( const containsValues = (
value.length > 0 && value.length > 0 &&
!value.every(val => { !value.every(val => {
@@ -53,24 +86,24 @@ export const InputArray: React.FC<InputArrayProps> = ({
}) })
); );
const inputs = Array(props.length).fill(null).map((_, i) => { const inputs = Array(this.props.length).fill(null).map((_, i) => {
if(props.type === "number") { if(this.props.type === "number") {
return <InputNumber return <InputNumber
key={i} key={i}
default={containsValues || !props.default ? undefined : props.default[i] as number} default={containsValues || !this.props.default ? undefined : this.props.default[i] as number}
value={value[i] as number} value={value[i] as number}
required={containsValues ? true : false} required={containsValues ? true : false}
onChange={(v) => changeValue(i, v)} onChange={(v) => this.changeValue(i, v)}
aria-label={props["aria-label"] || props.label} aria-label={this.props["aria-label"] || this.props.label}
/>; />;
} else { } else {
return <InputString return <InputString
key={i} key={i}
default={containsValues || !props.default ? undefined : props.default[i] as string} default={containsValues || !this.props.default ? undefined : this.props.default[i] as string}
value={value[i] as string} value={value[i] as string}
required={containsValues ? true : false} required={containsValues ? true : false}
onChange={(v) => changeValue(i, v)} onChange={this.changeValue.bind(this, i)}
aria-label={props["aria-label"] || props.label} aria-label={this.props["aria-label"] || this.props.label}
/>; />;
} }
}); });
@@ -80,4 +113,5 @@ export const InputArray: React.FC<InputArrayProps> = ({
{inputs} {inputs}
</div> </div>
); );
}; }
}
+1 -1
View File
@@ -11,7 +11,7 @@ export type InputAutocompleteProps = {
"aria-label"?: string "aria-label"?: string
}; };
export function InputAutocomplete({ export default function InputAutocomplete({
value, value,
options = [], options = [],
onChange = () => {}, onChange = () => {},
+14 -12
View File
@@ -14,18 +14,20 @@ type InputButtonProps = {
title?: string title?: string
}; };
export const InputButton: React.FC<InputButtonProps> = (props) => { export default class InputButton extends React.Component<InputButtonProps> {
render() {
return <button return <button
id={props.id} id={this.props.id}
title={props.title} title={this.props.title}
type={props.type} type={this.props.type}
onClick={props.onClick} onClick={this.props.onClick}
disabled={props.disabled} disabled={this.props.disabled}
aria-label={props["aria-label"]} aria-label={this.props["aria-label"]}
className={classnames("maputnik-button", props.className)} className={classnames("maputnik-button", this.props.className)}
data-wd-key={props["data-wd-key"]} data-wd-key={this.props["data-wd-key"]}
style={props.style} style={this.props.style}
> >
{props.children} {this.props.children}
</button>; </button>;
}; }
}
+15 -9
View File
@@ -6,26 +6,32 @@ export type InputCheckboxProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
}; };
export const InputCheckbox: React.FC<InputCheckboxProps> = ({value = false, ...props}) => { export default class InputCheckbox extends React.Component<InputCheckboxProps> {
const onChange = () => { static defaultProps = {
props.onChange(!value); value: false,
}; };
onChange = () => {
this.props.onChange(!this.props.value);
};
render() {
return <div className="maputnik-checkbox-wrapper"> return <div className="maputnik-checkbox-wrapper">
<input <input
className="maputnik-checkbox" className="maputnik-checkbox"
type="checkbox" type="checkbox"
style={props.style} style={this.props.style}
onChange={onChange} onChange={this.onChange}
onClick={onChange} onClick={this.onChange}
checked={value} checked={this.props.value}
/> />
<div className="maputnik-checkbox-box"> <div className="maputnik-checkbox-box">
<svg style={{ <svg style={{
display: value ? "inline" : "none" display: this.props.value ? "inline" : "none"
}} className="maputnik-checkbox-icon" viewBox='0 0 32 32'> }} className="maputnik-checkbox-icon" viewBox='0 0 32 32'>
<path d='M1 14 L5 10 L13 18 L27 4 L31 8 L13 26 z' /> <path d='M1 14 L5 10 L13 18 L27 4 L31 8 L13 26 z' />
</svg> </svg>
</div> </div>
</div>; </div>;
}; }
}
+40 -39
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; import React from "react";
import Color from "color"; import Color from "color";
import ChromePicker from "react-color/lib/components/chrome/Chrome"; import ChromePicker from "react-color/lib/components/chrome/Chrome";
import {type ColorResult} from "react-color"; import {type ColorResult} from "react-color";
@@ -20,27 +20,26 @@ export type InputColorProps = {
}; };
/*** Number fields with support for min, max and units and documentation*/ /*** Number fields with support for min, max and units and documentation*/
export const InputColor: React.FC<InputColorProps> = (props) => { export default class InputColor extends React.Component<InputColorProps> {
const [pickerOpened, setPickerOpened] = useState(false); state = {
const colorInput = useRef<HTMLInputElement | null>(null); pickerOpened: false
};
colorInput: HTMLInputElement | null = null;
// Keep the latest `onChange` available to the throttled callback, which is constructor (props: InputColorProps) {
// created only once so that throttling actually takes effect. super(props);
const onChangeProp = useRef(props.onChange); this.onChangeNoCheck = lodash.throttle(this.onChangeNoCheck, 1000/30);
useEffect(() => { }
onChangeProp.current = props.onChange;
});
const onChangeNoCheck = useMemo( onChangeNoCheck(v: string) {
() => lodash.throttle((v: string) => onChangeProp.current(v), 1000/30), this.props.onChange(v);
[] }
);
//TODO: I much rather would do this with absolute positioning //TODO: I much rather would do this with absolute positioning
//but I am too stupid to get it to work together with fixed position //but I am too stupid to get it to work together with fixed position
//and scrollbars so I have to fallback to JavaScript //and scrollbars so I have to fallback to JavaScript
const calcPickerOffset = () => { calcPickerOffset = () => {
const elem = colorInput.current; const elem = this.colorInput;
if(elem) { if(elem) {
const pos = elem.getBoundingClientRect(); const pos = elem.getBoundingClientRect();
return { return {
@@ -55,27 +54,28 @@ export const InputColor: React.FC<InputColorProps> = (props) => {
} }
}; };
const togglePicker = () => { togglePicker = () => {
setPickerOpened(opened => !opened); this.setState({ pickerOpened: !this.state.pickerOpened });
}; };
const getColor = () => { get color() {
// Catch invalid color. // Catch invalid color.
try { try {
return Color(props.value).rgb(); return Color(this.props.value).rgb();
} }
catch(err) { catch(err) {
console.warn("Error parsing color: ", err); console.warn("Error parsing color: ", err);
return Color("rgb(255,255,255)"); return Color("rgb(255,255,255)");
} }
}; }
const onChange = (v: string) => { onChange (v: string) {
props.onChange(v === "" ? undefined : v); this.props.onChange(v === "" ? undefined : v);
}; }
const offset = calcPickerOffset(); render() {
const currentColor = getColor().object(); const offset = this.calcPickerOffset();
const currentColor = this.color.object();
const currentChromeColor = { const currentChromeColor = {
r: currentColor.r, r: currentColor.r,
g: currentColor.g, g: currentColor.g,
@@ -94,11 +94,11 @@ export const InputColor: React.FC<InputColorProps> = (props) => {
}}> }}>
<ChromePicker <ChromePicker
color={currentChromeColor} color={currentChromeColor}
onChange={c => onChangeNoCheck(formatColor(c))} onChange={c => this.onChangeNoCheck(formatColor(c))}
/> />
<div <div
className="maputnik-color-picker-offset" className="maputnik-color-picker-offset"
onClick={togglePicker} onClick={this.togglePicker}
style={{ style={{
zIndex: -1, zIndex: -1,
position: "fixed", position: "fixed",
@@ -111,24 +111,25 @@ export const InputColor: React.FC<InputColorProps> = (props) => {
</div>; </div>;
const swatchStyle = { const swatchStyle = {
backgroundColor: props.value backgroundColor: this.props.value
}; };
return <div className="maputnik-color-wrapper"> return <div className="maputnik-color-wrapper">
{pickerOpened && picker} {this.state.pickerOpened && picker}
<div className="maputnik-color-swatch" style={swatchStyle}></div> <div className="maputnik-color-swatch" style={swatchStyle}></div>
<input <input
aria-label={props["aria-label"]} aria-label={this.props["aria-label"]}
spellCheck="false" spellCheck="false"
autoComplete="off" autoComplete="off"
className="maputnik-color" className="maputnik-color"
ref={colorInput} ref={(input) => {this.colorInput = input;}}
onClick={togglePicker} onClick={this.togglePicker}
style={props.style} style={this.props.style}
name={props.name} name={this.props.name}
placeholder={props.default} placeholder={this.props.default}
value={props.value ? props.value : ""} value={this.props.value ? this.props.value : ""}
onChange={(e) => onChange(e.target.value)} onChange={(e) => this.onChange(e.target.value)}
/> />
</div>; </div>;
}; }
}
+69 -61
View File
@@ -3,13 +3,13 @@ import capitalize from "lodash.capitalize";
import {MdDelete} from "react-icons/md"; import {MdDelete} from "react-icons/md";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import { InputString } from "./InputString"; import InputString from "./InputString";
import { InputNumber } from "./InputNumber"; import InputNumber from "./InputNumber";
import { InputButton } from "./InputButton"; import InputButton from "./InputButton";
import { FieldDocLabel } from "./FieldDocLabel"; import FieldDocLabel from "./FieldDocLabel";
import { InputEnum } from "./InputEnum"; import InputEnum from "./InputEnum";
import { InputUrl } from "./InputUrl"; import InputUrl from "./InputUrl";
import { InputColor } from "./InputColor"; import InputColor from "./InputColor";
export type InputDynamicArrayProps = { export type InputDynamicArrayProps = {
@@ -27,91 +27,94 @@ export type InputDynamicArrayProps = {
type InputDynamicArrayInternalProps = InputDynamicArrayProps & WithTranslation; type InputDynamicArrayInternalProps = InputDynamicArrayProps & WithTranslation;
const InputDynamicArrayInternal: React.FC<InputDynamicArrayInternalProps> = (props) => { class InputDynamicArrayInternal extends React.Component<InputDynamicArrayInternalProps> {
const values = props.value || props.default || []; changeValue(idx: number, newValue: string | number | undefined) {
const values = this.values.slice(0);
const changeValue = (idx: number, newValue: string | number | undefined) => { values[idx] = newValue;
const newValues = values.slice(0); if (this.props.onChange) this.props.onChange(values);
newValues[idx] = newValue;
if (props.onChange) props.onChange(newValues);
};
const addValue = () => {
const newValues = values.slice(0);
if (props.type === "number") {
newValues.push(0);
} }
else if (props.type === "url") {
newValues.push(""); get values() {
return this.props.value || this.props.default || [];
} }
else if (props.type === "enum") {
const {fieldSpec} = props; addValue = () => {
const values = this.values.slice(0);
if (this.props.type === "number") {
values.push(0);
}
else if (this.props.type === "url") {
values.push("");
}
else if (this.props.type === "enum") {
const {fieldSpec} = this.props;
const defaultValue = Object.keys(fieldSpec!.values)[0]; const defaultValue = Object.keys(fieldSpec!.values)[0];
newValues.push(defaultValue); values.push(defaultValue);
} else if (props.type === "color") { } else if (this.props.type === "color") {
newValues.push("#000000"); values.push("#000000");
} else { } else {
newValues.push(""); values.push("");
} }
if (props.onChange) props.onChange(newValues); if (this.props.onChange) this.props.onChange(values);
}; };
const deleteValue = (valueIdx: number) => { deleteValue(valueIdx: number) {
const newValues = values.slice(0); const values = this.values.slice(0);
newValues.splice(valueIdx, 1); values.splice(valueIdx, 1);
if (props.onChange) props.onChange(newValues.length > 0 ? newValues : undefined); if (this.props.onChange) this.props.onChange(values.length > 0 ? values : undefined);
}; }
const t = props.t; render() {
const i18nProps = { t, i18n: props.i18n, tReady: props.tReady }; const t = this.props.t;
const inputs = values.map((v, i) => { const i18nProps = { t, i18n: this.props.i18n, tReady: this.props.tReady };
const inputs = this.values.map((v, i) => {
const deleteValueBtn= <DeleteValueInputButton const deleteValueBtn= <DeleteValueInputButton
onClick={deleteValue.bind(null, i)} onClick={this.deleteValue.bind(this, i)}
{...i18nProps} {...i18nProps}
/>; />;
let input; let input;
if(props.type === "url") { if(this.props.type === "url") {
input = <InputUrl input = <InputUrl
value={v as string} value={v as string}
onChange={changeValue.bind(null, i)} onChange={this.changeValue.bind(this, i)}
aria-label={props["aria-label"] || props.label} aria-label={this.props["aria-label"] || this.props.label}
/>; />;
} }
else if (props.type === "number") { else if (this.props.type === "number") {
input = <InputNumber input = <InputNumber
value={v as number} value={v as number}
onChange={changeValue.bind(null, i)} onChange={this.changeValue.bind(this, i)}
aria-label={props["aria-label"] || props.label} aria-label={this.props["aria-label"] || this.props.label}
/>; />;
} }
else if (props.type === "enum") { else if (this.props.type === "enum") {
const options = Object.keys(props.fieldSpec?.values).map(v => [v, capitalize(v)]); const options = Object.keys(this.props.fieldSpec?.values).map(v => [v, capitalize(v)]);
input = <InputEnum input = <InputEnum
options={options} options={options}
value={v as string} value={v as string}
onChange={changeValue.bind(null, i)} onChange={this.changeValue.bind(this, i)}
aria-label={props["aria-label"] || props.label} aria-label={this.props["aria-label"] || this.props.label}
/>; />;
} }
else if (props.type === "color") { else if (this.props.type === "color") {
input = <InputColor input = <InputColor
value={v as string} value={v as string}
onChange={changeValue.bind(null, i)} onChange={this.changeValue.bind(this, i)}
aria-label={props["aria-label"] || props.label} aria-label={this.props["aria-label"] || this.props.label}
/>; />;
} }
else { else {
input = <InputString input = <InputString
value={v as string} value={v as string}
onChange={changeValue.bind(null, i)} onChange={this.changeValue.bind(this, i)}
aria-label={props["aria-label"] || props.label} aria-label={this.props["aria-label"] || this.props.label}
/>; />;
} }
return <div return <div
style={props.style} style={this.props.style}
key={i} key={i}
className="maputnik-array-block" className="maputnik-array-block"
> >
@@ -129,28 +132,33 @@ const InputDynamicArrayInternal: React.FC<InputDynamicArrayInternalProps> = (pro
{inputs} {inputs}
<InputButton <InputButton
className="maputnik-array-add-value" className="maputnik-array-add-value"
onClick={addValue} onClick={this.addValue}
> >
{t("Add value")} {t("Add value")}
</InputButton> </InputButton>
</div> </div>
); );
}; }
}
const InputDynamicArray = withTranslation()(InputDynamicArrayInternal);
export default InputDynamicArray;
export const InputDynamicArray = withTranslation()(InputDynamicArrayInternal);
type DeleteValueInputButtonProps = { type DeleteValueInputButtonProps = {
onClick?(...args: unknown[]): unknown onClick?(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const DeleteValueInputButton: React.FC<DeleteValueInputButtonProps> = (props) => { class DeleteValueInputButton extends React.Component<DeleteValueInputButtonProps> {
const t = props.t; render() {
const t = this.props.t;
return <InputButton return <InputButton
className="maputnik-delete-stop" className="maputnik-delete-stop"
onClick={props.onClick} onClick={this.props.onClick}
title={t("Remove array item")} title={t("Remove array item")}
> >
<FieldDocLabel <FieldDocLabel
label={<MdDelete />} label={<MdDelete />}
/> />
</InputButton>; </InputButton>;
}; }
}
+11 -9
View File
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { InputSelect } from "./InputSelect"; import InputSelect from "./InputSelect";
import { InputMultiInput } from "./InputMultiInput"; import InputMultiInput from "./InputMultiInput";
function optionsLabelLength(options: any[]) { function optionsLabelLength(options: any[]) {
@@ -25,23 +25,25 @@ export type InputEnumProps = {
}; };
export const InputEnum: React.FC<InputEnumProps> = (props) => { export default class InputEnum extends React.Component<InputEnumProps> {
const {options, value, onChange, name, label} = props; render() {
const {options, value, onChange, name, label} = this.props;
if(options.length <= 3 && optionsLabelLength(options) <= 20) { if(options.length <= 3 && optionsLabelLength(options) <= 20) {
return <InputMultiInput return <InputMultiInput
name={name} name={name}
options={options} options={options}
value={(value || props.default)!} value={(value || this.props.default)!}
onChange={onChange} onChange={onChange}
aria-label={props["aria-label"] || label} aria-label={this.props["aria-label"] || label}
/>; />;
} else { } else {
return <InputSelect return <InputSelect
options={options} options={options}
value={(value || props.default)!} value={(value || this.props.default)!}
onChange={onChange} onChange={onChange}
aria-label={props["aria-label"] || label} aria-label={this.props["aria-label"] || label}
/>; />;
} }
}; }
}
+20 -16
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { InputAutocomplete } from "./InputAutocomplete"; import InputAutocomplete from "./InputAutocomplete";
export type InputFontProps = { export type InputFontProps = {
name: string name: string
@@ -11,9 +11,13 @@ export type InputFontProps = {
"aria-label"?: string "aria-label"?: string
}; };
export const InputFont: React.FC<InputFontProps> = ({fonts = [], ...props}) => { export default class InputFont extends React.Component<InputFontProps> {
const getValues = () => { static defaultProps = {
const out = props.value || props.default || []; fonts: []
};
get values() {
const out = this.props.value || this.props.default || [];
// Always put a "" in the last field to you can keep adding entries // Always put a "" in the last field to you can keep adding entries
if (out[out.length-1] !== ""){ if (out[out.length-1] !== ""){
@@ -22,29 +26,28 @@ export const InputFont: React.FC<InputFontProps> = ({fonts = [], ...props}) => {
else { else {
return out; return out;
} }
}; }
const values = getValues(); changeFont(idx: number, newValue: string) {
const changedValues = this.values.slice(0);
const changeFont = (idx: number, newValue: string) => {
const changedValues = values.slice(0);
changedValues[idx] = newValue; changedValues[idx] = newValue;
const filteredValues = changedValues const filteredValues = changedValues
.filter(v => v !== undefined) .filter(v => v !== undefined)
.filter(v => v !== ""); .filter(v => v !== "");
props.onChange(filteredValues); this.props.onChange(filteredValues);
}; }
const inputs = values.map((value, i) => { render() {
const inputs = this.values.map((value, i) => {
return <li return <li
key={i} key={i}
> >
<InputAutocomplete <InputAutocomplete
aria-label={props["aria-label"] || props.name} aria-label={this.props["aria-label"] || this.props.name}
value={value} value={value}
options={fonts.map(f => [f, f])} options={this.props.fonts?.map(f => [f, f])}
onChange={changeFont.bind(null, i)} onChange={this.changeFont.bind(this, i)}
/> />
</li>; </li>;
}); });
@@ -54,4 +57,5 @@ export const InputFont: React.FC<InputFontProps> = ({fonts = [], ...props}) => {
{inputs} {inputs}
</ul> </ul>
); );
}; }
}
+83 -83
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from "react"; import React from "react";
import classnames from "classnames"; import classnames from "classnames";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
@@ -25,49 +25,88 @@ export type InputJsonProps = {
}; };
type InputJsonInternalProps = InputJsonProps & WithTranslation; type InputJsonInternalProps = InputJsonProps & WithTranslation;
function getPrettyJson(data: any) { type InputJsonState = {
isEditing: boolean
prevValue: string
};
class InputJsonInternal extends React.Component<InputJsonInternalProps, InputJsonState> {
static defaultProps = {
onFocus: () => {},
onBlur: () => {},
withScroll: false
};
_view: EditorView | undefined;
_el: HTMLDivElement | null = null;
_cancelNextChange: boolean = false;
constructor(props: InputJsonInternalProps) {
super(props);
this.state = {
isEditing: false,
prevValue: this.getPrettyJson(this.props.value),
};
}
getPrettyJson(data: any) {
return stringifyPretty(data, {indent: 2, maxLength: 40}); return stringifyPretty(data, {indent: 2, maxLength: 40});
} }
const InputJsonInternal: React.FC<InputJsonInternalProps> = ({ componentDidMount () {
value, this._view = createEditor({
className, parent: this._el!,
onChange, value: this.getPrettyJson(this.props.value),
onFocus = () => {}, lintType: this.props.lintType || "layer",
onBlur = () => {}, onChange: (value:string) => this.onChange(value),
lintType, onFocus: () => this.onFocus(),
spec, onBlur: () => this.onBlur(),
withScroll = false, spec: this.props.spec
}) => { });
const el = useRef<HTMLDivElement | null>(null); }
const view = useRef<EditorView | undefined>(undefined);
const cancelNextChange = useRef<boolean>(false);
// `isEditing` and `prevValue` are never rendered, they are only read from the
// CodeMirror callbacks, so refs keep them up to date without re-rendering.
const isEditing = useRef<boolean>(false);
const prevValue = useRef<string>(getPrettyJson(value));
// Mirrors `prevProps.value` from the previous `componentDidUpdate`.
const prevValueProp = useRef<object>(value);
const handleFocus = () => { onFocus = () => {
if (onFocus) onFocus(); if (this.props.onFocus) this.props.onFocus();
isEditing.current = true; this.setState({
isEditing: true,
});
}; };
const handleBlur = () => { onBlur = () => {
if (onBlur) onBlur(); if (this.props.onBlur) this.props.onBlur();
isEditing.current = false; this.setState({
isEditing: false,
});
}; };
const handleChange = () => { componentDidUpdate(prevProps: InputJsonProps) {
if (cancelNextChange.current) { if (!this.state.isEditing && prevProps.value !== this.props.value) {
cancelNextChange.current = false; this._cancelNextChange = true;
prevValue.current = view.current!.state.doc.toString(); const transactionSpec: TransactionSpec = {
changes: {
from: 0,
to: this._view!.state.doc.length,
insert: this.getPrettyJson(this.props.value)
}
};
if (this.props.withScroll) {
transactionSpec.selection = this._view!.state.selection;
transactionSpec.scrollIntoView = true;
}
this._view!.dispatch(transactionSpec);
}
}
onChange = (_e: unknown) => {
if (this._cancelNextChange) {
this._cancelNextChange = false;
this.setState({
prevValue: this._view!.state.doc.toString(),
});
return; return;
} }
const newCode = view.current!.state.doc.toString(); const newCode = this._view!.state.doc.toString();
if (prevValue.current !== newCode) { if (this.state.prevValue !== newCode) {
let parsedLayer, err; let parsedLayer, err;
try { try {
parsedLayer = JSON.parse(newCode); parsedLayer = JSON.parse(newCode);
@@ -77,63 +116,24 @@ const InputJsonInternal: React.FC<InputJsonInternalProps> = ({
} }
if (!err) { if (!err) {
if (onChange) onChange(parsedLayer); if (this.props.onChange) this.props.onChange(parsedLayer);
} }
} }
prevValue.current = newCode; this.setState({
prevValue: newCode,
});
}; };
// The editor is created once on mount, so its callbacks have to go through a render() {
// ref to always see the latest props.
const handlers = useRef({handleChange, handleFocus, handleBlur});
useEffect(() => {
handlers.current = {handleChange, handleFocus, handleBlur};
});
useEffect(() => {
view.current = createEditor({
parent: el.current!,
value: getPrettyJson(value),
lintType: lintType || "layer",
onChange: () => handlers.current.handleChange(),
onFocus: () => handlers.current.handleFocus(),
onBlur: () => handlers.current.handleBlur(),
spec: spec
});
// Runs once on mount, mirroring the previous componentDidMount.
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only: adding deps would rebuild the editor on every change
}, []);
useEffect(() => {
// Only react to an actual change of the `value` prop, which also skips the
// initial mount (the editor is created with the value already).
if (prevValueProp.current === value) return;
prevValueProp.current = value;
if (isEditing.current) return;
cancelNextChange.current = true;
const transactionSpec: TransactionSpec = {
changes: {
from: 0,
to: view.current!.state.doc.length,
insert: getPrettyJson(value)
}
};
if (withScroll) {
transactionSpec.selection = view.current!.state.selection;
transactionSpec.scrollIntoView = true;
}
view.current!.dispatch(transactionSpec);
}, [value, withScroll]);
return <div className="json-editor" data-wd-key="json-editor" aria-hidden="true" style={{cursor: "text"}}> return <div className="json-editor" data-wd-key="json-editor" aria-hidden="true" style={{cursor: "text"}}>
<div <div
className={classnames("codemirror-container", className)} className={classnames("codemirror-container", this.props.className)}
ref={el} ref={(el) => {this._el = el;}}
/> />
</div>; </div>;
}; }
}
export const InputJson = withTranslation()(InputJsonInternal); const InputJson = withTranslation()(InputJsonInternal);
export default InputJson;
+9 -7
View File
@@ -9,21 +9,22 @@ export type InputMultiInputProps = {
"aria-label"?: string "aria-label"?: string
}; };
export const InputMultiInput: React.FC<InputMultiInputProps> = (props) => { export default class InputMultiInput extends React.Component<InputMultiInputProps> {
let options = props.options; render() {
let options = this.props.options;
if(options.length > 0 && !Array.isArray(options[0])) { if(options.length > 0 && !Array.isArray(options[0])) {
options = options.map(v => [v, v]); options = options.map(v => [v, v]);
} }
const selectedValue = props.value || options[0][0]; const selectedValue = this.props.value || options[0][0];
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-button", "maputnik-radio-as-button", {"maputnik-button-selected": val === selectedValue})}
> >
<input type="radio" <input type="radio"
name={props.name} name={this.props.name}
onChange={_e => props.onChange(val)} onChange={_e => this.props.onChange(val)}
value={val} value={val}
checked={val === selectedValue} checked={val === selectedValue}
/> />
@@ -31,7 +32,8 @@ export const InputMultiInput: React.FC<InputMultiInputProps> = (props) => {
</label>; </label>;
}); });
return <fieldset className="maputnik-multibutton" aria-label={props["aria-label"]}> return <fieldset className="maputnik-multibutton" aria-label={this.props["aria-label"]}>
{radios} {radios}
</fieldset>; </fieldset>;
}; }
}
+127 -99
View File
@@ -1,4 +1,5 @@
import React, { type BaseSyntheticEvent, useRef, useState } from "react"; import React, { type BaseSyntheticEvent } from "react";
import generateUniqueId from "../libs/document-uid";
export type InputNumberProps = { export type InputNumberProps = {
value?: number value?: number
@@ -13,136 +14,162 @@ export type InputNumberProps = {
"aria-label"?: string "aria-label"?: string
}; };
export const InputNumber: React.FC<InputNumberProps> = (props) => { type InputNumberState = {
const { rangeStep = 1 } = props; uuid: number
editing: boolean
editingRange?: boolean
value?: number
/**
* This is the value that is currently being edited. It can be an invalid value.
*/
dirtyValue?: number | string | undefined
};
const [editing, setEditing] = useState(false); export default class InputNumber extends React.Component<InputNumberProps, InputNumberState> {
const [editingRange, setEditingRange] = useState(false); static defaultProps = {
const [value, setValue] = useState<number | undefined>(props.value); rangeStep: 1
/** The value currently being edited. It can be an invalid value. */ };
const [dirtyValue, setDirtyValue] = useState<number | string | undefined>(props.value); _keyboardEvent: boolean = false;
const keyboardEvent = useRef(false); constructor(props: InputNumberProps) {
super(props);
// Replaces getDerivedStateFromProps: while not editing, track the prop. this.state = {
if (!editing && props.value !== value) { uuid: +generateUniqueId(),
setValue(props.value); editing: false,
setDirtyValue(props.value); value: props.value,
dirtyValue: props.value,
};
} }
function isValid(v: number | string | undefined) { static getDerivedStateFromProps(props: Readonly<InputNumberProps>, state: InputNumberState) {
if (!state.editing && props.value !== state.value) {
return {
value: props.value,
dirtyValue: props.value,
};
}
return null;
}
changeValue(newValue: number | string | undefined) {
const value = (newValue === "" || newValue === undefined) ?
undefined : +newValue;
const hasChanged = this.props.value !== value;
if(this.isValid(value) && hasChanged) {
if (this.props.onChange) this.props.onChange(value);
this.setState({
value: value,
});
}
else if (!this.isValid(value) && hasChanged) {
this.setState({
value: undefined,
});
}
this.setState({
dirtyValue: newValue === "" ? undefined : newValue,
});
}
isValid(v: number | string | undefined) {
if (v === undefined) { if (v === undefined) {
return true; return true;
} }
const val = +v; const value = +v;
if(isNaN(val)) { if(isNaN(value)) {
return false; return false;
} }
if(!isNaN(props.min!) && val < props.min!) { if(!isNaN(this.props.min!) && value < this.props.min!) {
return false; return false;
} }
if(!isNaN(props.max!) && val > props.max!) { if(!isNaN(this.props.max!) && value > this.props.max!) {
return false; return false;
} }
return true; return true;
} }
function changeValue(newValue: number | string | undefined) { resetValue = () => {
const val = (newValue === "" || newValue === undefined) ? this.setState({editing: false});
undefined : +newValue;
const hasChanged = props.value !== val;
if(isValid(val) && hasChanged) {
if (props.onChange) props.onChange(val);
setValue(val);
}
else if (!isValid(val) && hasChanged) {
setValue(undefined);
}
setDirtyValue(newValue === "" ? undefined : newValue);
}
const resetValue = () => {
setEditing(false);
// Reset explicitly to default value if value has been cleared // Reset explicitly to default value if value has been cleared
if(!value) { if(!this.state.value) {
return; return;
} }
// If set value is invalid fall back to the last valid value from props or at last resort the default value // If set value is invalid fall back to the last valid value from props or at last resort the default value
if (!isValid(value)) { if (!this.isValid(this.state.value)) {
if(isValid(props.value)) { if(this.isValid(this.props.value)) {
changeValue(props.value); this.changeValue(this.props.value);
setDirtyValue(props.value); this.setState({dirtyValue: this.props.value});
} else { } else {
changeValue(undefined); this.changeValue(undefined);
setDirtyValue(undefined); this.setState({dirtyValue: undefined});
} }
} }
}; };
const onChangeRange = (e: BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => { onChangeRange = (e: BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => {
let newValue = parseFloat(e.target.value); let value = parseFloat(e.target.value);
const step = rangeStep; const step = this.props.rangeStep;
let newDirtyValue = newValue; let dirtyValue = value;
if(step) { if(step) {
// Can't do this with the <input/> range step attribute else we won't be able to set a high precision value via the text input. // Can't do this with the <input/> range step attribute else we won't be able to set a high precision value via the text input.
const snap = newValue % step; const snap = value % step;
// Round up/down to step // Round up/down to step
if (keyboardEvent.current) { if (this._keyboardEvent) {
// If it's keyboard event we might get a low positive/negative value, // If it's keyboard event we might get a low positive/negative value,
// for example we might go from 13 to 13.23, however because we know // for example we might go from 13 to 13.23, however because we know
// that came from a keyboard event we always want to increase by a // that came from a keyboard event we always want to increase by a
// single step value. // single step value.
if (newValue < +dirtyValue!) { if (value < +this.state.dirtyValue!) {
newValue = value! - step; value = this.state.value! - step;
} }
else { else {
newValue = value! + step; value = this.state.value! + step;
} }
newDirtyValue = newValue; dirtyValue = value;
} }
else { else {
if (snap < step/2) { if (snap < step/2) {
newValue = newValue - snap; value = value - snap;
} }
else { else {
newValue = newValue + (step - snap); value = value + (step - snap);
} }
} }
} }
keyboardEvent.current = false; this._keyboardEvent = false;
// Clamp between min/max // Clamp between min/max
newValue = Math.max(props.min!, Math.min(props.max!, newValue)); value = Math.max(this.props.min!, Math.min(this.props.max!, value));
setValue(newValue); this.setState({value, dirtyValue});
setDirtyValue(newDirtyValue); if (this.props.onChange) this.props.onChange(value);
if (props.onChange) props.onChange(newValue);
}; };
render() {
if( if(
Object.prototype.hasOwnProperty.call(props, "min") && Object.prototype.hasOwnProperty.call(this.props, "min") &&
Object.prototype.hasOwnProperty.call(props, "max") && Object.prototype.hasOwnProperty.call(this.props, "max") &&
props.min !== undefined && props.max !== undefined && this.props.min !== undefined && this.props.max !== undefined &&
props.allowRange this.props.allowRange
) { ) {
const currentValue = editing ? dirtyValue : value; const value = this.state.editing ? this.state.dirtyValue : this.state.value;
const defaultValue = props.default === undefined ? "" : props.default; const defaultValue = this.props.default === undefined ? "" : this.props.default;
let inputValue; let inputValue;
if (editingRange) { if (this.state.editingRange) {
inputValue = value; inputValue = this.state.value;
} }
else { else {
inputValue = currentValue; inputValue = value;
} }
return <div className="maputnik-number-container"> return <div className="maputnik-number-container">
@@ -150,69 +177,70 @@ export const InputNumber: React.FC<InputNumberProps> = (props) => {
className="maputnik-number-range" className="maputnik-number-range"
key="range" key="range"
type="range" type="range"
max={props.max} max={this.props.max}
min={props.min} min={this.props.min}
step="any" step="any"
spellCheck="false" spellCheck="false"
value={currentValue === undefined ? defaultValue : currentValue} value={value === undefined ? defaultValue : value}
onChange={onChangeRange} onChange={this.onChangeRange}
onKeyDown={() => { onKeyDown={() => {
keyboardEvent.current = true; this._keyboardEvent = true;
}} }}
onPointerDown={() => { onPointerDown={() => {
setEditing(true); this.setState({editing: true, editingRange: true});
setEditingRange(true);
}} }}
onPointerUp={() => { onPointerUp={() => {
// Safari doesn't get onBlur event // Safari doesn't get onBlur event
setEditing(false); this.setState({editing: false, editingRange: false});
setEditingRange(false);
}} }}
onBlur={() => { onBlur={() => {
setEditing(false); this.setState({
setEditingRange(false); editing: false,
setDirtyValue(value); editingRange: false,
dirtyValue: this.state.value,
});
}} }}
data-wd-key={props["data-wd-key"] + "-range"} data-wd-key={this.props["data-wd-key"] + "-range"}
/> />
<input <input
key="text" key="text"
type="text" type="text"
spellCheck="false" spellCheck="false"
className="maputnik-number" className="maputnik-number"
placeholder={props.default?.toString()} placeholder={this.props.default?.toString()}
value={inputValue === undefined ? "" : inputValue} value={inputValue === undefined ? "" : inputValue}
onFocus={_e => { onFocus={_e => {
setEditing(true); this.setState({editing: true});
}} }}
onChange={e => { onChange={e => {
changeValue(e.target.value); this.changeValue(e.target.value);
}} }}
onBlur={_e => { onBlur={_e => {
setEditing(false); this.setState({editing: false});
resetValue(); this.resetValue();
}} }}
data-wd-key={props["data-wd-key"] + "-text"} data-wd-key={this.props["data-wd-key"] + "-text"}
/> />
</div>; </div>;
} }
else { else {
const currentValue = editing ? dirtyValue : value; const value = this.state.editing ? this.state.dirtyValue : this.state.value;
return <input return <input
aria-label={props["aria-label"]} aria-label={this.props["aria-label"]}
spellCheck="false" spellCheck="false"
className="maputnik-number" className="maputnik-number"
placeholder={props.default?.toString()} placeholder={this.props.default?.toString()}
value={currentValue === undefined ? "" : currentValue} value={value === undefined ? "" : value}
onChange={e => changeValue(e.target.value)} onChange={e => this.changeValue(e.target.value)}
onFocus={() => { onFocus={() => {
setEditing(true); this.setState({editing: true});
}} }}
onBlur={resetValue} onBlur={this.resetValue}
required={props.required} required={this.props.required}
data-wd-key={props["data-wd-key"]} data-wd-key={this.props["data-wd-key"]}
/>; />;
} }
}; }
}
+11 -9
View File
@@ -10,21 +10,23 @@ export type InputSelectProps = {
"aria-label"?: string "aria-label"?: string
}; };
export const InputSelect: React.FC<InputSelectProps> = (props) => { export default class InputSelect extends React.Component<InputSelectProps> {
let options = props.options; render() {
let options = this.props.options;
if(options.length > 0 && !Array.isArray(options[0])) { if(options.length > 0 && !Array.isArray(options[0])) {
options = options.map((v) => [v, v]) as [string, any][]; options = options.map((v) => [v, v]) as [string, any][];
} }
return <select return <select
className="maputnik-select" className="maputnik-select"
data-wd-key={props["data-wd-key"]} data-wd-key={this.props["data-wd-key"]}
style={props.style} style={this.props.style}
title={props.title} title={this.props.title}
value={props.value} value={this.props.value}
onChange={e => props.onChange(e.target.value)} onChange={e => this.props.onChange(e.target.value)}
aria-label={props["aria-label"]} aria-label={this.props["aria-label"]}
> >
{ options.map(([val, label]) => <option key={val} value={val}>{label}</option>) } { options.map(([val, label]) => <option key={val} value={val}>{label}</option>) }
</select>; </select>;
}; }
}
+46 -44
View File
@@ -1,14 +1,14 @@
import React, { type ReactElement } from "react"; import React, { type ReactElement } from "react";
import { InputColor, type InputColorProps } from "./InputColor"; import InputColor, { type InputColorProps } from "./InputColor";
import { InputNumber, type InputNumberProps } from "./InputNumber"; import InputNumber, { type InputNumberProps } from "./InputNumber";
import { InputCheckbox, type InputCheckboxProps } from "./InputCheckbox"; import InputCheckbox, { type InputCheckboxProps } from "./InputCheckbox";
import { InputString, type InputStringProps } from "./InputString"; import InputString, { type InputStringProps } from "./InputString";
import { InputArray, type InputArrayProps } from "./InputArray"; import InputArray, { type InputArrayProps } from "./InputArray";
import { InputDynamicArray, type InputDynamicArrayProps } from "./InputDynamicArray"; import InputDynamicArray, { type InputDynamicArrayProps } from "./InputDynamicArray";
import { InputFont, type InputFontProps } from "./InputFont"; import InputFont, { type InputFontProps } from "./InputFont";
import { InputAutocomplete, type InputAutocompleteProps } from "./InputAutocomplete"; import InputAutocomplete, { type InputAutocompleteProps } from "./InputAutocomplete";
import { InputEnum, type InputEnumProps } from "./InputEnum"; import InputEnum, { type InputEnumProps } from "./InputEnum";
import capitalize from "lodash.capitalize"; import capitalize from "lodash.capitalize";
const iconProperties = ["background-pattern", "fill-pattern", "line-pattern", "fill-extrusion-pattern", "icon-image"]; const iconProperties = ["background-pattern", "fill-pattern", "line-pattern", "fill-extrusion-pattern", "icon-image"];
@@ -38,31 +38,31 @@ export type InputSpecProps = {
/** Display any field from the Maplibre GL style spec and /** Display any field from the Maplibre GL style spec and
* choose the correct field component based on the @{fieldSpec} * choose the correct field component based on the @{fieldSpec}
* to display @{value}. */ * to display @{value}. */
export const InputSpec: React.FC<InputSpecProps> = (props) => { export default class InputSpec extends React.Component<InputSpecProps> {
const childNodes = () => { childNodes() {
const commonProps = { const commonProps = {
fieldSpec: props.fieldSpec, fieldSpec: this.props.fieldSpec,
label: props.label, label: this.props.label,
action: props.action, action: this.props.action,
style: props.style, style: this.props.style,
value: props.value, value: this.props.value,
default: props.fieldSpec?.default, default: this.props.fieldSpec?.default,
name: props.fieldName, name: this.props.fieldName,
"data-wd-key": "spec-field-input:" + props.fieldName, "data-wd-key": "spec-field-input:" + this.props.fieldName,
onChange: (newValue: number | undefined | (string | number | undefined)[]) => props.onChange!(props.fieldName, newValue), onChange: (newValue: number | undefined | (string | number | undefined)[]) => this.props.onChange!(this.props.fieldName, newValue),
"aria-label": props["aria-label"], "aria-label": this.props["aria-label"],
}; };
switch(props.fieldSpec?.type) { switch(this.props.fieldSpec?.type) {
case "number": return ( case "number": return (
<InputNumber <InputNumber
{...commonProps as InputNumberProps} {...commonProps as InputNumberProps}
min={props.fieldSpec.minimum} min={this.props.fieldSpec.minimum}
max={props.fieldSpec.maximum} max={this.props.fieldSpec.maximum}
/> />
); );
case "enum": { case "enum": {
const options = Object.keys(props.fieldSpec.values || []).map(v => [v, capitalize(v)]); const options = Object.keys(this.props.fieldSpec.values || []).map(v => [v, capitalize(v)]);
return <InputEnum return <InputEnum
{...commonProps as Omit<InputEnumProps, "options">} {...commonProps as Omit<InputEnumProps, "options">}
@@ -72,8 +72,8 @@ export const InputSpec: React.FC<InputSpecProps> = (props) => {
case "resolvedImage": case "resolvedImage":
case "formatted": case "formatted":
case "string": case "string":
if (iconProperties.indexOf(props.fieldName!) >= 0) { if (iconProperties.indexOf(this.props.fieldName!) >= 0) {
const options = props.fieldSpec.values || []; const options = this.props.fieldSpec.values || [];
return <InputAutocomplete return <InputAutocomplete
{...commonProps as Omit<InputAutocompleteProps, "options">} {...commonProps as Omit<InputAutocompleteProps, "options">}
options={options.map(f => [f, f])} options={options.map(f => [f, f])}
@@ -94,59 +94,61 @@ export const InputSpec: React.FC<InputSpecProps> = (props) => {
/> />
); );
case "array": case "array":
if(props.fieldName === "text-font") { if(this.props.fieldName === "text-font") {
return <InputFont return <InputFont
{...commonProps as InputFontProps} {...commonProps as InputFontProps}
fonts={props.fieldSpec.values} fonts={this.props.fieldSpec.values}
/>; />;
} else { } else {
if (props.fieldSpec.length) { if (this.props.fieldSpec.length) {
return <InputArray return <InputArray
{...commonProps as InputArrayProps} {...commonProps as InputArrayProps}
type={props.fieldSpec.value} type={this.props.fieldSpec.value}
length={props.fieldSpec.length} length={this.props.fieldSpec.length}
/>; />;
} else { } else {
return <InputDynamicArray return <InputDynamicArray
{...commonProps as InputDynamicArrayProps} {...commonProps as InputDynamicArrayProps}
fieldSpec={props.fieldSpec} fieldSpec={this.props.fieldSpec}
type={props.fieldSpec.value as InputDynamicArrayProps["type"]} type={this.props.fieldSpec.value as InputDynamicArrayProps["type"]}
/>; />;
} }
} }
case "numberArray": return ( case "numberArray": return (
<InputDynamicArray <InputDynamicArray
{...commonProps as InputDynamicArrayProps} {...commonProps as InputDynamicArrayProps}
fieldSpec={props.fieldSpec} fieldSpec={this.props.fieldSpec}
type="number" type="number"
value={(Array.isArray(props.value) ? props.value : [props.value]) as (string | number | undefined)[]} value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]}
/> />
); );
case "colorArray": return ( case "colorArray": return (
<InputDynamicArray <InputDynamicArray
{...commonProps as InputDynamicArrayProps} {...commonProps as InputDynamicArrayProps}
fieldSpec={props.fieldSpec} fieldSpec={this.props.fieldSpec}
type="color" type="color"
value={(Array.isArray(props.value) ? props.value : [props.value]) as (string | number | undefined)[]} value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]}
/> />
); );
case "padding": return ( case "padding": return (
<InputArray <InputArray
{...commonProps as InputArrayProps} {...commonProps as InputArrayProps}
type="number" type="number"
value={(Array.isArray(props.value) ? props.value : [props.value]) as (string | number | undefined)[]} value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]}
length={4} length={4}
/> />
); );
default: default:
console.warn(`No proper field input for ${props.fieldName} type: ${props.fieldSpec?.type}`); console.warn(`No proper field input for ${this.props.fieldName} type: ${this.props.fieldSpec?.type}`);
return null; return null;
} }
}; }
render() {
return ( return (
<div data-wd-key={"spec-field:"+props.fieldName}> <div data-wd-key={"spec-field:"+this.props.fieldName}>
{childNodes()} {this.childNodes()}
</div> </div>
); );
}; }
}
+51 -32
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React from "react";
export type InputStringProps = { export type InputStringProps = {
"data-wd-key"?: string "data-wd-key"?: string
@@ -15,21 +15,38 @@ export type InputStringProps = {
title?: string title?: string
}; };
export const InputString: React.FC<InputStringProps> = (props) => { type InputStringState = {
const { onInput = () => {} } = props; editing: boolean
const [editing, setEditing] = useState(false); value?: string
const [value, setValue] = useState<string | undefined>(props.value); };
// Replaces getDerivedStateFromProps: while the field is not being edited its export default class InputString extends React.Component<InputStringProps, InputStringState> {
// value tracks the prop, so an external change to the style is picked up. static defaultProps = {
if (!editing && value !== props.value) { onInput: () => {},
setValue(props.value); };
constructor(props: InputStringProps) {
super(props);
this.state = {
editing: false,
value: props.value || ""
};
} }
static getDerivedStateFromProps(props: Readonly<InputStringProps>, state: InputStringState) {
if (!state.editing) {
return {
value: props.value
};
}
return {};
}
render() {
let tag; let tag;
let classes; let classes;
if(props.multi) { if(this.props.multi) {
tag = "textarea"; tag = "textarea";
classes = [ classes = [
"maputnik-string", "maputnik-string",
@@ -43,38 +60,40 @@ export const InputString: React.FC<InputStringProps> = (props) => {
]; ];
} }
if(props.disabled) { if(this.props.disabled) {
classes.push("maputnik-string--disabled"); classes.push("maputnik-string--disabled");
} }
return React.createElement(tag, { return React.createElement(tag, {
"aria-label": props["aria-label"], "aria-label": this.props["aria-label"],
"data-wd-key": props["data-wd-key"], "data-wd-key": this.props["data-wd-key"],
spellCheck: Object.prototype.hasOwnProperty.call(props, "spellCheck") ? props.spellCheck : !(tag === "input"), spellCheck: Object.prototype.hasOwnProperty.call(this.props, "spellCheck") ? this.props.spellCheck : !(tag === "input"),
disabled: props.disabled, disabled: this.props.disabled,
className: classes.join(" "), className: classes.join(" "),
style: props.style, style: this.props.style,
value: value === undefined ? "" : value, value: this.state.value === undefined ? "" : this.state.value,
placeholder: props.default, placeholder: this.props.default,
title: props.title, title: this.props.title,
onChange: (e: React.BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => { onChange: (e: React.BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => {
setEditing(true); this.setState({
setValue(e.target.value); editing: true,
onInput(e.target.value); value: e.target.value
}, () => {
if (this.props.onInput) this.props.onInput(this.state.value);
});
}, },
onBlur: () => { onBlur: () => {
// Note: editing is only cleared when the value actually changed; this if(this.state.value!==this.props.value) {
// mirrors the original and keeps a no-op blur from resyncing the value. this.setState({editing: false});
if(value !== props.value) { if (this.props.onChange) this.props.onChange(this.state.value);
setEditing(false);
if (props.onChange) props.onChange(value);
} }
}, },
onKeyDown: (e: React.KeyboardEvent) => { onKeyDown: (e) => {
if (e.keyCode === 13 && props.onChange) { if (e.keyCode === 13 && this.props.onChange) {
props.onChange(value); this.props.onChange(this.state.value);
} }
}, },
required: props.required, required: this.props.required,
}); });
}; }
}
+39 -19
View File
@@ -1,6 +1,6 @@
import React, { type JSX, useState } from "react"; import React, { type JSX } from "react";
import { InputString } from "./InputString"; import InputString from "./InputString";
import { SmallError } from "./SmallError"; import SmallError from "./SmallError";
import { Trans, type WithTranslation, withTranslation } from "react-i18next"; import { Trans, type WithTranslation, withTranslation } from "react-i18next";
import { type TFunction } from "i18next"; import { type TFunction } from "i18next";
import { ErrorType, validate } from "../libs/urlopen"; import { ErrorType, validate } from "../libs/urlopen";
@@ -46,30 +46,50 @@ export type FieldUrlProps = {
type InputUrlInternalProps = FieldUrlProps & WithTranslation; type InputUrlInternalProps = FieldUrlProps & WithTranslation;
const InputUrlInternal: React.FC<InputUrlInternalProps> = ({onInput = () => {}, ...props}) => { type InputUrlState = {
const [error, setError] = useState<ErrorType | undefined>(() => validate(props.value)); error?: ErrorType
const handleInput = (url: string) => {
setError(validate(url));
onInput(url);
}; };
const handleChange = (url: string) => { class InputUrlInternal extends React.Component<InputUrlInternalProps, InputUrlState> {
setError(validate(url)); static defaultProps = {
props.onChange(url); onInput: () => {},
}; };
constructor (props: InputUrlInternalProps) {
super(props);
this.state = {
error: validate(props.value),
};
}
onInput = (url: string) => {
this.setState({
error: validate(url),
});
if (this.props.onInput) this.props.onInput(url);
};
onChange = (url: string) => {
this.setState({
error: validate(url),
});
this.props.onChange(url);
};
render () {
return ( return (
<div> <div>
<InputString <InputString
{...props} {...this.props}
onInput={handleInput} onInput={this.onInput}
onChange={handleChange} onChange={this.onChange}
aria-label={props["aria-label"]} aria-label={this.props["aria-label"]}
/> />
{errorTypeToJsx(error, props.t)} {errorTypeToJsx(this.state.error, this.props.t)}
</div> </div>
); );
}; }
}
export const InputUrl = withTranslation()(InputUrlInternal); const InputUrl = withTranslation()(InputUrlInternal);
export default InputUrl;
+109 -98
View File
@@ -1,4 +1,4 @@
import React, { type JSX, useState } from "react"; import React, { type JSX } from "react";
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";
@@ -6,20 +6,18 @@ import { IconContext } from "react-icons";
import { type BackgroundLayerSpecification, type LayerSpecification, type SourceSpecification } from "maplibre-gl"; import { type BackgroundLayerSpecification, type LayerSpecification, type SourceSpecification } from "maplibre-gl";
import { v8 } from "@maplibre/maplibre-gl-style-spec"; import { v8 } from "@maplibre/maplibre-gl-style-spec";
import { FieldJson } from "./FieldJson"; import FieldJson from "./FieldJson";
import { FilterEditor } from "./FilterEditor"; import FilterEditor from "./FilterEditor";
import { PropertyGroup } from "./PropertyGroup"; import PropertyGroup from "./PropertyGroup";
import { LayerEditorGroup } from "./LayerEditorGroup"; import LayerEditorGroup from "./LayerEditorGroup";
import { FieldType } from "./FieldType"; import FieldType from "./FieldType";
import { FieldId } from "./FieldId"; import FieldId from "./FieldId";
import { FieldMinZoom } from "./FieldMinZoom"; import FieldMinZoom from "./FieldMinZoom";
import { FieldMaxZoom } from "./FieldMaxZoom"; import FieldMaxZoom from "./FieldMaxZoom";
import { FieldComment } from "./FieldComment"; import FieldComment from "./FieldComment";
import { FieldSource } from "./FieldSource"; import FieldSource from "./FieldSource";
import { FieldSourceLayer } from "./FieldSourceLayer"; import FieldSourceLayer from "./FieldSourceLayer";
// Aliased: the component defines its own changeProperty, which would otherwise import { changeType, changeProperty } from "../libs/layer";
// shadow this import (as a class method there was no collision).
import { changeType, changeProperty as changeLayerProperty } from "../libs/layer";
import { formatLayerId } from "../libs/format"; import { formatLayerId } from "../libs/format";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import { type TFunction } from "i18next"; import { type TFunction } from "i18next";
@@ -133,56 +131,67 @@ type LayerEditorInternalProps = {
errors?: MappedError[] errors?: MappedError[]
} & WithTranslation; } & WithTranslation;
type LayerEditorState = {
editorGroups: { [keys: string]: boolean }
};
/** Layer editor supporting multiple types of layers. */ /** Layer editor supporting multiple types of layers. */
const LayerEditorInternal: React.FC<LayerEditorInternalProps> = ({ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, LayerEditorState> {
onLayerChanged = () => { }, static defaultProps = {
onLayerIdChange = () => { }, onLayerChanged: () => { },
...rest onLayerIdChange: () => { },
}) => { onLayerDestroyed: () => { },
const props = { onLayerChanged, onLayerIdChange, ...rest } as LayerEditorInternalProps; };
const [editorGroups, setEditorGroups] = useState<{ [keys: string]: boolean }>(() => { constructor(props: LayerEditorInternalProps) {
const groups: { [keys: string]: boolean } = {}; super(props);
for (const group of layoutGroups(props.layer.type, props.t)) {
groups[group.title] = true; const editorGroups: { [keys: string]: boolean } = {};
for (const group of layoutGroups(this.props.layer.type, props.t)) {
editorGroups[group.title] = true;
} }
return groups;
});
// Replaces getDerivedStateFromProps: groups that appear after mount (because this.state = { editorGroups };
// the layer type changed) start out expanded. Guarded so it only sets state }
// when a group is genuinely new, otherwise this would loop every render.
const newGroups = getLayoutForType(props.layer.type, props.t) static getDerivedStateFromProps(props: Readonly<LayerEditorInternalProps>, state: LayerEditorState) {
.filter(group => !(group.title in editorGroups)); const additionalGroups = { ...state.editorGroups };
if (newGroups.length > 0) {
const additionalGroups = { ...editorGroups }; for (const group of getLayoutForType(props.layer.type, props.t)) {
for (const group of newGroups) { if (!(group.title in additionalGroups)) {
additionalGroups[group.title] = true; additionalGroups[group.title] = true;
} }
setEditorGroups(additionalGroups);
} }
function changeProperty(group: keyof LayerSpecification | null, property: string, newValue: any) { return {
props.onLayerChanged( editorGroups: additionalGroups
props.layerIndex, };
changeLayerProperty(props.layer, group, property, newValue) }
changeProperty(group: keyof LayerSpecification | null, property: string, newValue: any) {
this.props.onLayerChanged(
this.props.layerIndex,
changeProperty(this.props.layer, group, property, newValue)
); );
} }
function onGroupToggle(groupTitle: string, active: boolean) { onGroupToggle(groupTitle: string, active: boolean) {
const changedActiveGroups = { const changedActiveGroups = {
...editorGroups, ...this.state.editorGroups,
[groupTitle]: active, [groupTitle]: active,
}; };
setEditorGroups(changedActiveGroups); this.setState({
editorGroups: changedActiveGroups
});
} }
function renderGroupType(type: string, fields?: string[]): JSX.Element { renderGroupType(type: string, fields?: string[]): JSX.Element {
let comment = ""; let comment = "";
if (props.layer.metadata) { if (this.props.layer.metadata) {
comment = (props.layer.metadata as any)["maputnik:comment"]; comment = (this.props.layer.metadata as any)["maputnik:comment"];
} }
const { errors, layerIndex } = props; const { errors, layerIndex } = this.props;
const errorData: MappedLayerErrors = {}; const errorData: MappedLayerErrors = {};
errors!.forEach(error => { errors!.forEach(error => {
@@ -198,85 +207,84 @@ const LayerEditorInternal: React.FC<LayerEditorInternalProps> = ({
}); });
let sourceLayerIds; let sourceLayerIds;
const layer = props.layer as Exclude<LayerSpecification, BackgroundLayerSpecification>; const layer = this.props.layer as Exclude<LayerSpecification, BackgroundLayerSpecification>;
if (Object.prototype.hasOwnProperty.call(props.sources, layer.source)) { if (Object.prototype.hasOwnProperty.call(this.props.sources, layer.source)) {
sourceLayerIds = props.sources[layer.source].layers; sourceLayerIds = this.props.sources[layer.source].layers;
} }
switch (type) { switch (type) {
case "layer": return <div> case "layer": return <div>
<FieldId <FieldId
value={props.layer.id} value={this.props.layer.id}
wdKey="layer-editor.layer-id" wdKey="layer-editor.layer-id"
error={errorData.id} error={errorData.id}
onChange={newId => props.onLayerIdChange(props.layerIndex, props.layer.id, newId)} onChange={newId => this.props.onLayerIdChange(this.props.layerIndex, this.props.layer.id, newId)}
/> />
<FieldType <FieldType
disabled={true} disabled={true}
error={errorData.type} error={errorData.type}
value={props.layer.type} value={this.props.layer.type}
onChange={newType => props.onLayerChanged( onChange={newType => this.props.onLayerChanged(
props.layerIndex, this.props.layerIndex,
changeType(props.layer, newType) changeType(this.props.layer, newType)
)} )}
/> />
{props.layer.type !== "background" && <FieldSource {this.props.layer.type !== "background" && <FieldSource
wdKey="layer-editor.layer-source"
error={errorData.source} error={errorData.source}
sourceIds={Object.keys(props.sources!)} sourceIds={Object.keys(this.props.sources!)}
value={props.layer.source} value={this.props.layer.source}
onChange={v => changeProperty(null, "source", v)} onChange={v => this.changeProperty(null, "source", v)}
/> />
} }
{!NON_SOURCE_LAYERS.includes(props.layer.type) && {!NON_SOURCE_LAYERS.includes(this.props.layer.type) &&
<FieldSourceLayer <FieldSourceLayer
error={errorData["source-layer"]} error={errorData["source-layer"]}
sourceLayerIds={sourceLayerIds} sourceLayerIds={sourceLayerIds}
value={(props.layer as any)["source-layer"]} value={(this.props.layer as any)["source-layer"]}
onChange={v => changeProperty(null, "source-layer", v)} onChange={v => this.changeProperty(null, "source-layer", v)}
/> />
} }
<FieldMinZoom <FieldMinZoom
error={errorData.minzoom} error={errorData.minzoom}
value={props.layer.minzoom} value={this.props.layer.minzoom}
onChange={v => changeProperty(null, "minzoom", v)} onChange={v => this.changeProperty(null, "minzoom", v)}
/> />
<FieldMaxZoom <FieldMaxZoom
error={errorData.maxzoom} error={errorData.maxzoom}
value={props.layer.maxzoom} value={this.props.layer.maxzoom}
onChange={v => changeProperty(null, "maxzoom", v)} onChange={v => this.changeProperty(null, "maxzoom", v)}
/> />
<FieldComment <FieldComment
error={errorData.comment} error={errorData.comment}
value={comment} value={comment}
onChange={v => changeProperty("metadata", "maputnik:comment", v == "" ? undefined : v)} onChange={v => this.changeProperty("metadata", "maputnik:comment", v == "" ? undefined : v)}
/> />
</div>; </div>;
case "filter": return <div> case "filter": return <div>
<div className="maputnik-filter-editor-wrapper"> <div className="maputnik-filter-editor-wrapper">
<FilterEditor <FilterEditor
errors={errorData} errors={errorData}
filter={(props.layer as any).filter} filter={(this.props.layer as any).filter}
properties={props.vectorLayers[(props.layer as any)["source-layer"]]} properties={this.props.vectorLayers[(this.props.layer as any)["source-layer"]]}
onChange={f => changeProperty(null, "filter", f)} onChange={f => this.changeProperty(null, "filter", f)}
/> />
</div> </div>
</div>; </div>;
case "properties": case "properties":
return <PropertyGroup return <PropertyGroup
errors={errorData} errors={errorData}
layer={props.layer} layer={this.props.layer}
groupFields={fields!} groupFields={fields!}
spec={props.spec} spec={this.props.spec}
onChange={changeProperty.bind(null)} onChange={this.changeProperty.bind(this)}
/>; />;
case "jsoneditor": case "jsoneditor":
return <FieldJson return <FieldJson
lintType="layer" lintType="layer"
value={props.layer} value={this.props.layer}
onChange={(layer: LayerSpecification) => { onChange={(layer: LayerSpecification) => {
props.onLayerChanged( this.props.onLayerChanged(
props.layerIndex, this.props.layerIndex,
layer layer
); );
}} }}
@@ -285,17 +293,18 @@ const LayerEditorInternal: React.FC<LayerEditorInternalProps> = ({
} }
} }
function moveLayer(offset: number) { moveLayer(offset: number) {
props.onMoveLayer({ this.props.onMoveLayer({
oldIndex: props.layerIndex, oldIndex: this.props.layerIndex,
newIndex: props.layerIndex + offset newIndex: this.props.layerIndex + offset
}); });
} }
const t = props.t; render() {
const t = this.props.t;
const groupIds: string[] = []; const groupIds: string[] = [];
const layerType = props.layer.type; const layerType = this.props.layer.type;
const groups = layoutGroups(layerType, t).filter(group => { const groups = layoutGroups(layerType, t).filter(group => {
return !(layerType === "background" && group.type === "source"); return !(layerType === "background" && group.type === "source");
}).map(group => { }).map(group => {
@@ -306,14 +315,14 @@ const LayerEditorInternal: React.FC<LayerEditorInternalProps> = ({
id={groupId} id={groupId}
key={groupId} key={groupId}
title={group.title} title={group.title}
isActive={editorGroups[group.title]} isActive={this.state.editorGroups[group.title]}
onActiveToggle={onGroupToggle.bind(null, group.title)} onActiveToggle={this.onGroupToggle.bind(this, group.title)}
> >
{renderGroupType(group.type, group.fields)} {this.renderGroupType(group.type, group.fields)}
</LayerEditorGroup>; </LayerEditorGroup>;
}); });
const layout = props.layer.layout || {}; const layout = this.props.layer.layout || {};
const items: { const items: {
[key: string]: { [key: string]: {
@@ -325,29 +334,29 @@ const LayerEditorInternal: React.FC<LayerEditorInternalProps> = ({
} = { } = {
delete: { delete: {
text: t("Delete"), text: t("Delete"),
handler: () => props.onLayerDestroy(props.layerIndex), handler: () => this.props.onLayerDestroy(this.props.layerIndex),
wdKey: "menu-delete-layer" wdKey: "menu-delete-layer"
}, },
duplicate: { duplicate: {
text: t("Duplicate"), text: t("Duplicate"),
handler: () => props.onLayerCopy(props.layerIndex), handler: () => this.props.onLayerCopy(this.props.layerIndex),
wdKey: "menu-duplicate-layer" wdKey: "menu-duplicate-layer"
}, },
hide: { hide: {
text: (layout.visibility === "none") ? t("Show") : t("Hide"), text: (layout.visibility === "none") ? t("Show") : t("Hide"),
handler: () => props.onLayerVisibilityToggle(props.layerIndex), handler: () => this.props.onLayerVisibilityToggle(this.props.layerIndex),
wdKey: "menu-hide-layer" wdKey: "menu-hide-layer"
}, },
moveLayerUp: { moveLayerUp: {
text: t("Move layer up"), text: t("Move layer up"),
disabled: props.isFirstLayer, disabled: this.props.isFirstLayer,
handler: () => moveLayer(-1), handler: () => this.moveLayer(-1),
wdKey: "menu-move-layer-up" wdKey: "menu-move-layer-up"
}, },
moveLayerDown: { moveLayerDown: {
text: t("Move layer down"), text: t("Move layer down"),
disabled: props.isLastLayer, disabled: this.props.isLastLayer,
handler: () => moveLayer(+1), handler: () => this.moveLayer(+1),
wdKey: "menu-move-layer-down" wdKey: "menu-move-layer-down"
} }
}; };
@@ -366,7 +375,7 @@ const LayerEditorInternal: React.FC<LayerEditorInternalProps> = ({
<header data-wd-key="layer-editor.header"> <header data-wd-key="layer-editor.header">
<div className="layer-header"> <div className="layer-header">
<h2 className="layer-header__title"> <h2 className="layer-header__title">
{t("Layer")}: {formatLayerId(props.layer.id)} {t("Layer")}: {formatLayerId(this.props.layer.id)}
</h2> </h2>
<div className="layer-header__info"> <div className="layer-header__info">
<Wrapper <Wrapper
@@ -407,6 +416,8 @@ const LayerEditorInternal: React.FC<LayerEditorInternalProps> = ({
</Accordion> </Accordion>
</section> </section>
</IconContext.Provider>; </IconContext.Provider>;
}; }
}
export const LayerEditor = withTranslation()(LayerEditorInternal); const LayerEditor = withTranslation()(LayerEditorInternal);
export default LayerEditor;
+9 -7
View File
@@ -18,20 +18,22 @@ type LayerEditorGroupProps = {
}; };
export const LayerEditorGroup: React.FC<LayerEditorGroupProps> = (props) => { export default class LayerEditorGroup extends React.Component<LayerEditorGroupProps> {
return <AccordionItem uuid={props.id}> render() {
return <AccordionItem uuid={this.props.id}>
<AccordionItemHeading className="maputnik-layer-editor-group" <AccordionItemHeading className="maputnik-layer-editor-group"
data-wd-key={"layer-editor-group:"+props["data-wd-key"]} data-wd-key={"layer-editor-group:"+this.props["data-wd-key"]}
onClick={_e => props.onActiveToggle(!props.isActive)} onClick={_e => this.props.onActiveToggle(!this.props.isActive)}
> >
<AccordionItemButton className="maputnik-layer-editor-group__button"> <AccordionItemButton className="maputnik-layer-editor-group__button">
<span style={{flexGrow: 1, alignContent: "center"}}>{props.title}</span> <span style={{flexGrow: 1, alignContent: "center"}}>{this.props.title}</span>
<MdArrowDropUp size={"2em"} className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--up"></MdArrowDropUp> <MdArrowDropUp size={"2em"} className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--up"></MdArrowDropUp>
<MdArrowDropDown size={"2em"} className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--down"></MdArrowDropDown> <MdArrowDropDown size={"2em"} className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--down"></MdArrowDropDown>
</AccordionItemButton> </AccordionItemButton>
</AccordionItemHeading> </AccordionItemHeading>
<AccordionItemPanel> <AccordionItemPanel>
{props.children} {this.props.children}
</AccordionItemPanel> </AccordionItemPanel>
</AccordionItem>; </AccordionItem>;
}; }
}
+141 -128
View File
@@ -1,4 +1,4 @@
import React, {type JSX, useEffect, useRef, useState} 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 { import {
@@ -14,12 +14,12 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { LayerListGroup } from "./LayerListGroup"; import LayerListGroup from "./LayerListGroup";
import { LayerListItem } from "./LayerListItem"; import LayerListItem from "./LayerListItem";
import { ModalAdd } from "./modals/ModalAdd"; import ModalAdd from "./modals/ModalAdd";
import type {LayerSpecification, SourceSpecification} from "maplibre-gl"; import type {LayerSpecification, SourceSpecification} 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 { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import { type MappedError, type OnMoveLayerCallback } from "../libs/definitions"; import { type MappedError, type OnMoveLayerCallback } from "../libs/definitions";
@@ -37,98 +37,62 @@ type LayerListContainerProps = {
}; };
type LayerListContainerInternalProps = LayerListContainerProps & WithTranslation; type LayerListContainerInternalProps = LayerListContainerProps & WithTranslation;
const noopLayerSelect = () => {}; type LayerListContainerState = {
collapsedGroups: {[ket: string]: boolean}
// Replaces the previous `shouldComponentUpdate`. Note the inversion: this areAllGroupsExpanded: boolean
// returns true when the props are EQUAL (i.e. no re-render is needed), whereas keys: {[key: string]: number}
// `shouldComponentUpdate` returned true when a re-render WAS needed. isOpen: {[key: string]: boolean}
function arePropsEqual(prevProps: LayerListContainerInternalProps, nextProps: LayerListContainerInternalProps) {
// This component tree only requires id and visibility from the layers
// objects
function getRequiredProps(layer: LayerSpecification) {
const out: {id: string, layout?: { visibility: any}} = {
id: layer.id,
}; };
if (layer.layout) {
out.layout = {
visibility: layer.layout.visibility
};
}
return out;
}
const layersEqual = lodash.isEqual(
nextProps.layers.map(getRequiredProps),
prevProps.layers.map(getRequiredProps),
);
function withoutLayers(props: LayerListContainerInternalProps) {
const out = {
...props
} as LayerListContainerInternalProps & { layers?: any };
delete out["layers"];
return out;
}
// Compare the props without layers because we've already compared them
// efficiently above.
const propsEqual = lodash.isEqual(
withoutLayers(prevProps),
withoutLayers(nextProps)
);
return layersEqual && propsEqual;
}
// List of collapsible layer editors // List of collapsible layer editors
function LayerListContainerInternal({ class LayerListContainerInternal extends React.Component<LayerListContainerInternalProps, LayerListContainerState> {
layers: propsLayers, static defaultProps = {
selectedLayerIndex, onLayerSelect: () => {},
onLayersChange, };
onLayerSelect = noopLayerSelect, selectedItemRef: React.RefObject<any>;
onLayerDestroy, scrollContainerRef: React.RefObject<HTMLElement | null>;
onLayerCopy,
onLayerVisibilityToggle,
sources,
errors,
t,
}: LayerListContainerInternalProps) {
const selectedItemRef = useRef<any>(null);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const hasMountedRef = useRef(false);
const [collapsedGroups, setCollapsedGroups] = useState<{[key: string]: boolean}>({}); constructor(props: LayerListContainerInternalProps) {
const [areAllGroupsExpanded, setAreAllGroupsExpanded] = useState(false); super(props);
const [keys, setKeys] = useState<{[key: string]: number}>(() => ({ this.selectedItemRef = React.createRef();
this.scrollContainerRef = React.createRef();
this.state = {
collapsedGroups: {},
areAllGroupsExpanded: false,
keys: {
add: +generateUniqueId(), add: +generateUniqueId(),
})); },
const [isOpen, setIsOpen] = useState<{[key: string]: boolean}>({ isOpen: {
add: false, add: false,
}); }
};
function toggleModal(modalName: string) {
setKeys(prevKeys => ({
...prevKeys,
[modalName]: +generateUniqueId(),
}));
setIsOpen(prevIsOpen => ({
...prevIsOpen,
[modalName]: !prevIsOpen[modalName]
}));
} }
const toggleLayers = () => { toggleModal(modalName: string) {
this.setState({
keys: {
...this.state.keys,
[modalName]: +generateUniqueId(),
},
isOpen: {
...this.state.isOpen,
[modalName]: !this.state.isOpen[modalName]
}
});
}
toggleLayers = () => {
let idx = 0; let idx = 0;
const newGroups: {[key:string]: boolean} = {}; const newGroups: {[key:string]: boolean} = {};
groupedLayers().forEach(layers => { this.groupedLayers().forEach(layers => {
const groupPrefix = layerPrefix(layers[0].id); const groupPrefix = layerPrefix(layers[0].id);
const lookupKey = [groupPrefix, idx].join("-"); const lookupKey = [groupPrefix, idx].join("-");
if (layers.length > 1) { if (layers.length > 1) {
newGroups[lookupKey] = areAllGroupsExpanded; newGroups[lookupKey] = this.state.areAllGroupsExpanded;
} }
layers.forEach((_layer) => { layers.forEach((_layer) => {
@@ -136,17 +100,19 @@ function LayerListContainerInternal({
}); });
}); });
setCollapsedGroups(newGroups); this.setState({
setAreAllGroupsExpanded(!areAllGroupsExpanded); collapsedGroups: newGroups,
areAllGroupsExpanded: !this.state.areAllGroupsExpanded
});
}; };
function groupedLayers(): (LayerSpecification & {key: string})[][] { groupedLayers(): (LayerSpecification & {key: string})[][] {
const groups = []; const groups = [];
const layerIdCount = new Map(); const layerIdCount = new Map();
for (let i = 0; i < propsLayers.length; i++) { for (let i = 0; i < this.props.layers.length; i++) {
const origLayer = propsLayers[i]; const origLayer = this.props.layers[i];
const previousLayer = propsLayers[i-1]; const previousLayer = this.props.layers[i-1];
layerIdCount.set(origLayer.id, layerIdCount.set(origLayer.id,
layerIdCount.has(origLayer.id) ? layerIdCount.get(origLayer.id) + 1 : 0 layerIdCount.has(origLayer.id) ? layerIdCount.get(origLayer.id) + 1 : 0
); );
@@ -164,35 +130,75 @@ function LayerListContainerInternal({
return groups; return groups;
} }
function toggleLayerGroup(groupPrefix: string, idx: number) { toggleLayerGroup(groupPrefix: string, idx: number) {
const lookupKey = [groupPrefix, idx].join("-"); const lookupKey = [groupPrefix, idx].join("-");
setCollapsedGroups(prevCollapsedGroups => { const newGroups = { ...this.state.collapsedGroups };
const newGroups = { ...prevCollapsedGroups }; if(lookupKey in this.state.collapsedGroups) {
if(lookupKey in prevCollapsedGroups) { newGroups[lookupKey] = !this.state.collapsedGroups[lookupKey];
newGroups[lookupKey] = !prevCollapsedGroups[lookupKey];
} else { } else {
newGroups[lookupKey] = false; newGroups[lookupKey] = false;
} }
return newGroups; this.setState({
collapsedGroups: newGroups
}); });
} }
function isCollapsed(groupPrefix: string, idx: number) { isCollapsed(groupPrefix: string, idx: number) {
const collapsed = collapsedGroups[[groupPrefix, idx].join("-")]; const collapsed = this.state.collapsedGroups[[groupPrefix, idx].join("-")];
return collapsed === undefined ? true : collapsed; return collapsed === undefined ? true : collapsed;
} }
useEffect(() => { shouldComponentUpdate (nextProps: LayerListContainerProps, nextState: LayerListContainerState) {
// `componentDidUpdate` did not run on mount, so skip the first run here too. // Always update on state change
if (!hasMountedRef.current) { if (this.state !== nextState) {
hasMountedRef.current = true; return true;
return;
} }
const selectedItemNode = selectedItemRef.current;
// This component tree only requires id and visibility from the layers
// objects
function getRequiredProps(layer: LayerSpecification) {
const out: {id: string, layout?: { visibility: any}} = {
id: layer.id,
};
if (layer.layout) {
out.layout = {
visibility: layer.layout.visibility
};
}
return out;
}
const layersEqual = lodash.isEqual(
nextProps.layers.map(getRequiredProps),
this.props.layers.map(getRequiredProps),
);
function withoutLayers(props: LayerListContainerProps) {
const out = {
...props
} as LayerListContainerProps & { layers?: any };
delete out["layers"];
return out;
}
// Compare the props without layers because we've already compared them
// efficiently above.
const propsEqual = lodash.isEqual(
withoutLayers(this.props),
withoutLayers(nextProps)
);
const propsChanged = !(layersEqual && propsEqual);
return propsChanged;
}
componentDidUpdate (prevProps: LayerListContainerProps) {
if (prevProps.selectedLayerIndex !== this.props.selectedLayerIndex) {
const selectedItemNode = this.selectedItemRef.current;
if (selectedItemNode && selectedItemNode.node) { if (selectedItemNode && selectedItemNode.node) {
const target = selectedItemNode.node; const target = selectedItemNode.node;
const options = { const options = {
root: scrollContainerRef.current, root: this.scrollContainerRef.current,
threshold: 1.0 threshold: 1.0
}; };
const observer = new IntersectionObserver(entries => { const observer = new IntersectionObserver(entries => {
@@ -204,30 +210,32 @@ function LayerListContainerInternal({
observer.observe(target); observer.observe(target);
} }
}, [selectedLayerIndex]); }
}
render() {
const listItems: JSX.Element[] = []; const listItems: JSX.Element[] = [];
let idx = 0; let idx = 0;
const layersByGroup = groupedLayers(); const layersByGroup = this.groupedLayers();
layersByGroup.forEach(layers => { layersByGroup.forEach(layers => {
const groupPrefix = layerPrefix(layers[0].id); const groupPrefix = layerPrefix(layers[0].id);
if(layers.length > 1) { if(layers.length > 1) {
const currentIdx = idx;
const grp = <LayerListGroup const grp = <LayerListGroup
data-wd-key={[groupPrefix, idx].join("-")} data-wd-key={[groupPrefix, idx].join("-")}
aria-controls={layers.map(l => l.key).join(" ")} aria-controls={layers.map(l => l.key).join(" ")}
key={`group-${groupPrefix}-${idx}`} key={`group-${groupPrefix}-${idx}`}
title={groupPrefix} title={groupPrefix}
isActive={!isCollapsed(groupPrefix, idx) || idx === selectedLayerIndex} isActive={!this.isCollapsed(groupPrefix, idx) || idx === this.props.selectedLayerIndex}
onActiveToggle={() => toggleLayerGroup(groupPrefix, currentIdx)} onActiveToggle={this.toggleLayerGroup.bind(this, groupPrefix, idx)}
/>; />;
listItems.push(grp); listItems.push(grp);
} }
layers.forEach((layer, idxInGroup) => { layers.forEach((layer, idxInGroup) => {
const groupIdx = findClosestCommonPrefix(propsLayers, idx); const groupIdx = findClosestCommonPrefix(this.props.layers, idx);
const layerError = errors.find(error => { const layerError = this.props.errors.find(error => {
return ( return (
error.parsed && error.parsed &&
error.parsed.type === "layer" && error.parsed.type === "layer" &&
@@ -236,13 +244,13 @@ function LayerListContainerInternal({
}); });
const additionalProps: {ref?: React.RefObject<any>} = {}; const additionalProps: {ref?: React.RefObject<any>} = {};
if (idx === selectedLayerIndex) { if (idx === this.props.selectedLayerIndex) {
additionalProps.ref = selectedItemRef; additionalProps.ref = this.selectedItemRef;
} }
const listItem = <LayerListItem const listItem = <LayerListItem
className={classnames({ className={classnames({
"maputnik-layer-list-item-collapsed": layers.length > 1 && isCollapsed(groupPrefix, groupIdx) && idx !== selectedLayerIndex, "maputnik-layer-list-item-collapsed": layers.length > 1 && this.isCollapsed(groupPrefix, groupIdx) && idx !== this.props.selectedLayerIndex,
"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
})} })}
@@ -252,11 +260,11 @@ function LayerListContainerInternal({
layerIndex={idx} layerIndex={idx}
layerType={layer.type} layerType={layer.type}
visibility={(layer.layout || {}).visibility} visibility={(layer.layout || {}).visibility}
isSelected={idx === selectedLayerIndex} isSelected={idx === this.props.selectedLayerIndex}
onLayerSelect={onLayerSelect} onLayerSelect={this.props.onLayerSelect}
onLayerDestroy={onLayerDestroy} onLayerDestroy={this.props.onLayerDestroy?.bind(this)}
onLayerCopy={onLayerCopy} onLayerCopy={this.props.onLayerCopy.bind(this)}
onLayerVisibilityToggle={onLayerVisibilityToggle} onLayerVisibilityToggle={this.props.onLayerVisibilityToggle.bind(this)}
{...additionalProps} {...additionalProps}
/>; />;
listItems.push(listItem); listItems.push(listItem);
@@ -264,20 +272,22 @@ function LayerListContainerInternal({
}); });
}); });
const t = this.props.t;
return <section return <section
className="maputnik-layer-list" className="maputnik-layer-list"
data-wd-key="layer-list" data-wd-key="layer-list"
role="complementary" role="complementary"
aria-label={t("Layers list")} aria-label={t("Layers list")}
ref={scrollContainerRef} ref={this.scrollContainerRef}
> >
<ModalAdd <ModalAdd
key={keys.add} key={this.state.keys.add}
layers={propsLayers} layers={this.props.layers}
sources={sources} sources={this.props.sources}
isOpen={isOpen.add} isOpen={this.state.isOpen.add}
onOpenToggle={() => toggleModal("add")} onOpenToggle={this.toggleModal.bind(this, "add")}
onLayersChange={onLayersChange} onLayersChange={this.props.onLayersChange}
/> />
<header className="maputnik-layer-list-header" data-wd-key="layer-list.header"> <header className="maputnik-layer-list-header" data-wd-key="layer-list.header">
<span className="maputnik-layer-list-header-title">{t("Layers")}</span> <span className="maputnik-layer-list-header-title">{t("Layers")}</span>
@@ -287,9 +297,9 @@ function LayerListContainerInternal({
<button <button
id="skip-target-layer-list" id="skip-target-layer-list"
data-wd-key="skip-target-layer-list" data-wd-key="skip-target-layer-list"
onClick={toggleLayers} onClick={this.toggleLayers}
className="maputnik-button"> className="maputnik-button">
{areAllGroupsExpanded === true ? {this.state.areAllGroupsExpanded === true ?
t("Collapse") t("Collapse")
: :
t("Expand") t("Expand")
@@ -300,7 +310,7 @@ function LayerListContainerInternal({
<div className="maputnik-default-property"> <div className="maputnik-default-property">
<div className="maputnik-multibutton"> <div className="maputnik-multibutton">
<button <button
onClick={() => toggleModal("add")} onClick={this.toggleModal.bind(this, "add")}
data-wd-key="layer-list:add-layer" data-wd-key="layer-list:add-layer"
className="maputnik-button maputnik-button-selected"> className="maputnik-button maputnik-button-selected">
{t("Add Layer")} {t("Add Layer")}
@@ -318,14 +328,15 @@ function LayerListContainerInternal({
</div> </div>
</section>; </section>;
} }
}
const LayerListContainer = withTranslation()(React.memo(LayerListContainerInternal, arePropsEqual)); const LayerListContainer = withTranslation()(LayerListContainerInternal);
type LayerListProps = LayerListContainerProps & { type LayerListProps = LayerListContainerProps & {
onMoveLayer: OnMoveLayerCallback onMoveLayer: OnMoveLayerCallback
}; };
export const LayerList: React.FC<LayerListProps> = (props) => { const LayerList: React.FC<LayerListProps> = (props) => {
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
@@ -350,3 +361,5 @@ export const LayerList: React.FC<LayerListProps> = (props) => {
</DndContext> </DndContext>
); );
}; };
export default LayerList;
+11 -9
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Collapser } from "./Collapser"; import Collapser from "./Collapser";
type LayerListGroupProps = { type LayerListGroupProps = {
title: string title: string
@@ -9,24 +9,26 @@ type LayerListGroupProps = {
"aria-controls"?: string "aria-controls"?: string
}; };
export const LayerListGroup: React.FC<LayerListGroupProps> = (props) => { export default class LayerListGroup extends React.Component<LayerListGroupProps> {
render() {
return <li className="maputnik-layer-list-group"> return <li className="maputnik-layer-list-group">
<div className="maputnik-layer-list-group-header" <div className="maputnik-layer-list-group-header"
data-wd-key={"layer-list-group:"+props["data-wd-key"]} data-wd-key={"layer-list-group:"+this.props["data-wd-key"]}
onClick={_e => props.onActiveToggle(!props.isActive)} onClick={_e => this.props.onActiveToggle(!this.props.isActive)}
> >
<button <button
className="maputnik-layer-list-group-title" className="maputnik-layer-list-group-title"
aria-controls={props["aria-controls"]} aria-controls={this.props["aria-controls"]}
aria-expanded={props.isActive} aria-expanded={this.props.isActive}
> >
{props.title} {this.props.title}
</button> </button>
<span className="maputnik-space" /> <span className="maputnik-space" />
<Collapser <Collapser
style={{ height: 14, width: 14 }} style={{ height: 14, width: 14 }}
isCollapsed={props.isActive} isCollapsed={this.props.isActive}
/> />
</div> </div>
</li>; </li>;
}; }
}
+15 -11
View File
@@ -5,7 +5,7 @@ import { IconContext } from "react-icons";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { IconLayer } from "./IconLayer"; import IconLayer from "./IconLayer";
import type { VisibilitySpecification } from "maplibre-gl"; import type { VisibilitySpecification } from "maplibre-gl";
@@ -38,9 +38,9 @@ type IconActionProps = {
classBlockModifier?: string classBlockModifier?: string
}; };
const IconAction: React.FC<IconActionProps> = (props) => { class IconAction extends React.Component<IconActionProps> {
function renderIcon() { renderIcon() {
switch (props.action) { switch (this.props.action) {
case "duplicate": return <MdContentCopy />; case "duplicate": return <MdContentCopy />;
case "show": return <MdVisibility />; case "show": return <MdVisibility />;
case "hide": return <MdVisibilityOff />; case "hide": return <MdVisibilityOff />;
@@ -48,7 +48,8 @@ const IconAction: React.FC<IconActionProps> = (props) => {
} }
} }
const { classBlockName, classBlockModifier } = props; render() {
const { classBlockName, classBlockModifier } = this.props;
let classAdditions = ""; let classAdditions = "";
if (classBlockName) { if (classBlockName) {
@@ -61,15 +62,16 @@ const IconAction: React.FC<IconActionProps> = (props) => {
return <button return <button
tabIndex={-1} tabIndex={-1}
title={props.action} title={this.props.action}
className={`maputnik-layer-list-icon-action ${classAdditions}`} className={`maputnik-layer-list-icon-action ${classAdditions}`}
data-wd-key={props.wdKey} data-wd-key={this.props.wdKey}
onClick={props.onClick} onClick={this.props.onClick}
aria-hidden="true" aria-hidden="true"
> >
{renderIcon()} {this.renderIcon()}
</button>; </button>;
}; }
}
type LayerListItemProps = { type LayerListItemProps = {
id?: string id?: string
@@ -85,7 +87,7 @@ type LayerListItemProps = {
onLayerVisibilityToggle?(...args: unknown[]): unknown onLayerVisibilityToggle?(...args: unknown[]): unknown
}; };
export const LayerListItem = React.forwardRef<HTMLLIElement, LayerListItemProps>((props, ref) => { const LayerListItem = React.forwardRef<HTMLLIElement, LayerListItemProps>((props, ref) => {
const { const {
isSelected = false, isSelected = false,
visibility = "visible", visibility = "visible",
@@ -160,3 +162,5 @@ export const LayerListItem = React.forwardRef<HTMLLIElement, LayerListItemProps>
</li> </li>
</IconContext.Provider>; </IconContext.Provider>;
}); });
export default LayerListItem;
+197 -226
View File
@@ -1,12 +1,12 @@
import React, {useCallback, useEffect, useReducer, useRef} from "react"; import React from "react";
import {createRoot} from "react-dom/client"; import {createRoot} from "react-dom/client";
import MapLibreGl, {type LayerSpecification, type LngLat, type Map, type MapOptions, type SourceSpecification, type StyleSpecification} from "maplibre-gl"; import MapLibreGl, {type LayerSpecification, type LngLat, type Map, type MapOptions, type SourceSpecification, type 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";
import { FeatureLayerPopup as MapMaplibreGlLayerPopup } from "./MapMaplibreGlLayerPopup"; import MapMaplibreGlLayerPopup from "./MapMaplibreGlLayerPopup";
import { FeaturePropertyPopup as MapMaplibreGlFeaturePropertyPopup, type InspectFeature } from "./MapMaplibreGlFeaturePropertyPopup"; import MapMaplibreGlFeaturePropertyPopup, { type InspectFeature } from "./MapMaplibreGlFeaturePropertyPopup";
import Color from "color"; import Color from "color";
import { ZoomControl } from "../libs/zoomcontrol"; import ZoomControl from "../libs/zoomcontrol";
import { type HighlightedLayer, colorHighlightedLayer } from "../libs/highlight"; import { type HighlightedLayer, colorHighlightedLayer } from "../libs/highlight";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import "../maplibregl.css"; import "../maplibregl.css";
@@ -71,65 +71,199 @@ type MapMaplibreGlInternalProps = {
onChange(value: {center: LngLat, zoom: number, _from: "map" | "app"}): unknown onChange(value: {center: LngLat, zoom: number, _from: "map" | "app"}): unknown
} & WithTranslation; } & WithTranslation;
/** type MapMaplibreGlState = {
* Replacement for the previous `shouldComponentUpdate`. `React.memo` expects the map: Map | null;
* inverse: `true` means "props are equal, skip the render", whereas inspect: MaplibreInspect | null;
* `shouldComponentUpdate` returned `true` when it *should* re-render. geocoder: MaplibreGeocoder | null;
* As before, if the props cannot be serialized we treat them as equal and skip zoomControl: ZoomControl | null;
* the render ("no biggie, carry on"). zoom?: number;
*/ };
function arePropsEqual(prevProps: MapMaplibreGlInternalProps, nextProps: MapMaplibreGlInternalProps) {
class MapMaplibreGlInternal extends React.Component<MapMaplibreGlInternalProps, MapMaplibreGlState> {
static defaultProps = {
onMapLoaded: () => {},
onDataChange: () => {},
onLayerSelect: () => {},
onChange: () => {},
options: {} as MapOptions,
};
container: HTMLDivElement | null = null;
constructor(props: MapMaplibreGlInternalProps) {
super(props);
this.state = {
map: null,
inspect: null,
geocoder: null,
zoomControl: null,
};
i18next.on("languageChanged", () => {
this.forceUpdate();
});
}
shouldComponentUpdate(nextProps: MapMaplibreGlInternalProps, nextState: MapMaplibreGlState) {
let should = false; let should = false;
try { try {
should = JSON.stringify(prevProps) !== JSON.stringify(nextProps); should = JSON.stringify(this.props) !== JSON.stringify(nextProps) || JSON.stringify(this.state) !== JSON.stringify(nextState);
} catch(_e) { } catch(_e) {
// no biggie, carry on // no biggie, carry on
} }
return !should; return should;
} }
const MapMaplibreGlInternal = ({ componentDidUpdate() {
onDataChange = () => {}, const map = this.state.map;
onLayerSelect = () => {},
onChange = () => {},
options = {},
mapStyle,
mapView,
inspectModeEnabled,
highlightedLayer,
replaceAccessTokens,
t,
}: MapMaplibreGlInternalProps) => {
const container = useRef<HTMLDivElement | null>(null);
// These used to live in `this.state`, but were never able to trigger a const styleWithTokens = this.props.replaceAccessTokens(this.props.mapStyle);
// re-render: `shouldComponentUpdate` stringified the state, which throws for if (map) {
// the (circular) maplibre `Map`, so the comparison was swallowed and the // Maplibre GL now does diffing natively so we don't need to calculate
// component only ever re-rendered on prop changes. They are imperative // the necessary operations ourselves!
// handles, so they are refs here and mutating them does not re-render. // We also need to update the style for inspect to work properly
const map = useRef<Map | null>(null); map.setStyle(styleWithTokens, {diff: true});
const inspect = useRef<MaplibreInspect | null>(null); map.showTileBoundaries = this.props.options?.showTileBoundaries!;
const geocoder = useRef<MaplibreGeocoder | null>(null); map.showCollisionBoxes = this.props.options?.showCollisionBoxes!;
const zoomControl = useRef<ZoomControl | null>(null); map.showOverdrawInspector = this.props.options?.showOverdrawInspector!;
const zoom = useRef<number | undefined>(undefined);
// `componentDidUpdate` did not run on mount, so neither may the effect below. // set the map view when the prop was updated from outside
const hasMounted = useRef(false); if (this.props.mapView._from === "app") {
map.jumpTo(this.props.mapView);
}
}
const [, forceUpdate] = useReducer((tick: number) => tick + 1, 0); if(this.state.inspect && this.props.inspectModeEnabled !== this.state.inspect._showInspectMap) {
this.state.inspect.toggleInspector();
}
if (this.state.inspect && this.props.inspectModeEnabled) {
this.state.inspect.setOriginalStyle(styleWithTokens);
// In case the sources are the same, there's a need to refresh the style
setTimeout(() => {
this.state.inspect!.render();
}, 500);
}
// The maplibre event handlers/callbacks below are registered once (on mount) }
// but read `this.props` at call time in the class version, so they must not
// close over the props of the first render.
const latestProps = useRef({onDataChange, onLayerSelect, onChange, mapStyle, inspectModeEnabled, highlightedLayer, t});
latestProps.current = {onDataChange, onLayerSelect, onChange, mapStyle, inspectModeEnabled, highlightedLayer, t};
const onLayerSelectById = useCallback((id: string) => { componentDidMount() {
const index = latestProps.current.mapStyle.layers.findIndex(layer => layer.id === id); const mapOpts = {
latestProps.current.onLayerSelect(index); ...this.props.options,
}, []); container: this.container!,
style: this.props.mapStyle,
hash: true,
maxZoom: 24,
// make root relative urls in stylefiles work as maplibre gl js does
// not support this for everything:
// https://github.com/maplibre/maplibre-gl-js/issues/6818
transformRequest: (url) => {
if (url.startsWith("/")) {
url = `${window.location.origin}${url}`;
}
return { url };
},
// setting to always load glyphs of CJK fonts from server
// https://maplibre.org/maplibre-gl-js/docs/examples/local-ideographs/
localIdeographFontFamily: false
} satisfies MapOptions;
const initGeocoder = useCallback((mapInstance: Map) => { const protocol = new Protocol({metadata: true});
MapLibreGl.addProtocol("pmtiles",protocol.tile);
const map = new MapLibreGl.Map(mapOpts);
const mapViewChange = () => {
const center = map.getCenter();
const zoom = map.getZoom();
this.props.onChange({center, zoom, _from: "map"});
};
mapViewChange();
map.showTileBoundaries = mapOpts.showTileBoundaries!;
map.showCollisionBoxes = mapOpts.showCollisionBoxes!;
map.showOverdrawInspector = mapOpts.showOverdrawInspector!;
const geocoder = this.initGeocoder(map);
const zoomControl = new ZoomControl();
map.addControl(zoomControl, "top-right");
const nav = new MapLibreGl.NavigationControl({visualizePitch:true});
map.addControl(nav, "top-right");
const tmpNode = document.createElement("div");
const root = createRoot(tmpNode);
const inspectPopup = new MapLibreGl.Popup({
closeOnClick: false
});
const inspect = new MaplibreInspect({
popup: inspectPopup,
showMapPopup: true,
showMapPopupOnHover: false,
showInspectMapPopupOnHover: true,
showInspectButton: false,
blockHoverPopupOnClick: true,
assignLayerColor: (layerId: string, alpha: number) => {
return Color(colors.brightColor(layerId, alpha)).desaturate(0.5).string();
},
buildInspectStyle: (originalMapStyle: StyleSpecification, coloredLayers: HighlightedLayer[]) => buildInspectStyle(originalMapStyle, coloredLayers, this.props.highlightedLayer),
renderPopup: (features: InspectFeature[]) => {
if(this.props.inspectModeEnabled) {
inspectPopup.once("open", () => {
root.render(<MapMaplibreGlFeaturePropertyPopup features={features} />);
});
return tmpNode;
} else {
inspectPopup.once("open", () => {
root.render(<MapMaplibreGlLayerPopup
features={features}
onLayerSelect={this.onLayerSelectById}
zoom={this.state.zoom}
/>,);
});
return tmpNode;
}
}
});
map.addControl(inspect);
map.on("style.load", () => {
this.setState({
map,
inspect,
geocoder,
zoomControl,
zoom: map.getZoom()
});
});
map.on("data", e => {
if(e.dataType !== "tile") return;
this.props.onDataChange!({
map: this.state.map
});
});
map.on("error", e => {
console.log("ERROR", e);
});
map.on("zoom", _e => {
this.setState({
zoom: map.getZoom()
});
});
map.on("dragend", mapViewChange);
map.on("zoomend", mapViewChange);
}
onLayerSelectById = (id: string) => {
const index = this.props.mapStyle.layers.findIndex(layer => layer.id === id);
this.props.onLayerSelect(index);
};
initGeocoder(map: Map) {
const geocoderConfig = { const geocoderConfig = {
forwardGeocode: async (config: MaplibreGeocoderApiConfig) => { forwardGeocode: async (config: MaplibreGeocoderApiConfig) => {
const features = []; const features = [];
@@ -166,190 +300,27 @@ const MapMaplibreGlInternal = ({
}; };
}, },
} as unknown as MaplibreGeocoderApi; } as unknown as MaplibreGeocoderApi;
const geocoderInstance = new MaplibreGeocoder(geocoderConfig, { const geocoder = new MaplibreGeocoder(geocoderConfig, {
placeholder: latestProps.current.t("Search"), placeholder: this.props.t("Search"),
maplibregl: MapLibreGl, maplibregl: MapLibreGl,
}); });
mapInstance.addControl(geocoderInstance, "top-left"); map.addControl(geocoder, "top-left");
return geocoderInstance; return geocoder;
}, []);
// Was the `i18next.on("languageChanged", () => this.forceUpdate())` in the
// constructor. `forceUpdate` bypassed `shouldComponentUpdate`; a state update
// likewise bypasses `React.memo`.
useEffect(() => {
const onLanguageChanged = () => {
forceUpdate();
};
i18next.on("languageChanged", onLanguageChanged);
return () => {
i18next.off("languageChanged", onLanguageChanged);
};
}, []);
// componentDidMount
useEffect(() => {
const mapOpts = {
...options,
container: container.current!,
style: mapStyle,
hash: true,
maxZoom: 24,
// make root relative urls in stylefiles work as maplibre gl js does
// not support this for everything:
// https://github.com/maplibre/maplibre-gl-js/issues/6818
transformRequest: (url) => {
if (url.startsWith("/")) {
url = `${window.location.origin}${url}`;
}
return { url };
},
// setting to always load glyphs of CJK fonts from server
// https://maplibre.org/maplibre-gl-js/docs/examples/local-ideographs/
localIdeographFontFamily: false
} satisfies MapOptions;
const protocol = new Protocol({metadata: true});
MapLibreGl.addProtocol("pmtiles",protocol.tile);
const mapInstance = new MapLibreGl.Map(mapOpts);
const mapViewChange = () => {
const center = mapInstance.getCenter();
const currentZoom = mapInstance.getZoom();
latestProps.current.onChange({center, zoom: currentZoom, _from: "map"});
};
mapViewChange();
mapInstance.showTileBoundaries = mapOpts.showTileBoundaries!;
mapInstance.showCollisionBoxes = mapOpts.showCollisionBoxes!;
mapInstance.showOverdrawInspector = mapOpts.showOverdrawInspector!;
const geocoderInstance = initGeocoder(mapInstance);
const zoomControlInstance = new ZoomControl();
mapInstance.addControl(zoomControlInstance, "top-right");
const nav = new MapLibreGl.NavigationControl({visualizePitch:true});
mapInstance.addControl(nav, "top-right");
const tmpNode = document.createElement("div");
const root = createRoot(tmpNode);
const inspectPopup = new MapLibreGl.Popup({
closeOnClick: false
});
const inspectInstance = new MaplibreInspect({
popup: inspectPopup,
showMapPopup: true,
showMapPopupOnHover: false,
showInspectMapPopupOnHover: true,
showInspectButton: false,
blockHoverPopupOnClick: true,
assignLayerColor: (layerId: string, alpha: number) => {
return Color(colors.brightColor(layerId, alpha)).desaturate(0.5).string();
},
buildInspectStyle: (originalMapStyle: StyleSpecification, coloredLayers: HighlightedLayer[]) => buildInspectStyle(originalMapStyle, coloredLayers, latestProps.current.highlightedLayer),
renderPopup: (features: InspectFeature[]) => {
if(latestProps.current.inspectModeEnabled) {
inspectPopup.once("open", () => {
root.render(<MapMaplibreGlFeaturePropertyPopup features={features} />);
});
return tmpNode;
} else {
inspectPopup.once("open", () => {
root.render(<MapMaplibreGlLayerPopup
features={features}
onLayerSelect={onLayerSelectById}
zoom={zoom.current}
/>,);
});
return tmpNode;
}
}
});
mapInstance.addControl(inspectInstance);
mapInstance.on("style.load", () => {
map.current = mapInstance;
inspect.current = inspectInstance;
geocoder.current = geocoderInstance;
zoomControl.current = zoomControlInstance;
zoom.current = mapInstance.getZoom();
});
mapInstance.on("data", e => {
if(e.dataType !== "tile") return;
latestProps.current.onDataChange!({
map: map.current
});
});
mapInstance.on("error", e => {
console.log("ERROR", e);
});
mapInstance.on("zoom", _e => {
zoom.current = mapInstance.getZoom();
});
mapInstance.on("dragend", mapViewChange);
mapInstance.on("zoomend", mapViewChange);
// Mount only, exactly like componentDidMount: the map is created from the
// props of the first render and kept up to date imperatively below (the
// handlers read `latestProps`, so no prop belongs in the dependencies). The
// class had no componentWillUnmount, so there is no teardown here either.
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only: adding mapStyle/options would re-create the map
}, [initGeocoder, onLayerSelectById]);
// componentDidUpdate. It had no `prevProps` guards, so it ran after *every*
// re-render; the equivalent is an effect without a dependency array. Since the
// component only re-renders when `arePropsEqual` reports a prop change (or on
// a language change, as before), this runs exactly as often as it used to.
useEffect(() => {
if (!hasMounted.current) {
// componentDidUpdate does not run on mount.
hasMounted.current = true;
return;
} }
const styleWithTokens = replaceAccessTokens(mapStyle); render() {
if (map.current) { const t = this.props.t;
// Maplibre GL now does diffing natively so we don't need to calculate this.state.geocoder?.setPlaceholder(t("Search"));
// the necessary operations ourselves! this.state.zoomControl?.setLabel(t("Zoom:"));
// We also need to update the style for inspect to work properly
map.current.setStyle(styleWithTokens, {diff: true});
map.current.showTileBoundaries = options?.showTileBoundaries!;
map.current.showCollisionBoxes = options?.showCollisionBoxes!;
map.current.showOverdrawInspector = options?.showOverdrawInspector!;
// set the map view when the prop was updated from outside
if (mapView._from === "app") {
map.current.jumpTo(mapView);
}
}
if(inspect.current && inspectModeEnabled !== inspect.current._showInspectMap) {
inspect.current.toggleInspector();
}
if (inspect.current && inspectModeEnabled) {
inspect.current.setOriginalStyle(styleWithTokens);
// In case the sources are the same, there's a need to refresh the style
setTimeout(() => {
inspect.current!.render();
}, 500);
}
});
geocoder.current?.setPlaceholder(t("Search"));
zoomControl.current?.setLabel(t("Zoom:"));
return <div return <div
className="maputnik-map__map" className="maputnik-map__map"
role="region" role="region"
aria-label={t("Map view")} aria-label={t("Map view")}
ref={container} ref={x => {this.container = x;}}
data-wd-key="maplibre:map" data-wd-key="maplibre:map"
></div>; ></div>;
}; }
}
export const MapMaplibreGl = withTranslation()(React.memo(MapMaplibreGlInternal, arePropsEqual)); const MapMaplibreGl = withTranslation()(MapMaplibreGlInternal);
export default MapMaplibreGl;
@@ -63,8 +63,9 @@ type FeaturePropertyPopupProps = {
features: InspectFeature[] features: InspectFeature[]
}; };
export const FeaturePropertyPopup: React.FC<FeaturePropertyPopupProps> = (props) => { class FeaturePropertyPopup extends React.Component<FeaturePropertyPopupProps> {
const features = removeDuplicatedFeatures(props.features); render() {
const features = removeDuplicatedFeatures(this.props.features);
return <div className="maputnik-feature-property-popup" dir="ltr" data-wd-key="feature-property-popup"> return <div className="maputnik-feature-property-popup" dir="ltr" data-wd-key="feature-property-popup">
<table className="maputnik-popup-table"> <table className="maputnik-popup-table">
<tbody> <tbody>
@@ -72,4 +73,8 @@ export const FeaturePropertyPopup: React.FC<FeaturePropertyPopupProps> = (props)
</tbody> </tbody>
</table> </table>
</div>; </div>;
}; }
}
export default FeaturePropertyPopup;
+12 -7
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { IconLayer } from "./IconLayer"; import IconLayer from "./IconLayer";
import type {InspectFeature} from "./MapMaplibreGlFeaturePropertyPopup"; import type {InspectFeature} from "./MapMaplibreGlFeaturePropertyPopup";
function groupFeaturesBySourceLayer(features: InspectFeature[]) { function groupFeaturesBySourceLayer(features: InspectFeature[]) {
@@ -32,8 +32,8 @@ type FeatureLayerPopupProps = {
zoom?: number zoom?: number
}; };
export const FeatureLayerPopup: React.FC<FeatureLayerPopupProps> = (props) => { class FeatureLayerPopup extends React.Component<FeatureLayerPopupProps> {
function _getFeatureColor(feature: InspectFeature, _zoom?: number) { _getFeatureColor(feature: InspectFeature, _zoom?: number) {
// Guard because openlayers won't have this // Guard because openlayers won't have this
if (!feature.layer.paint) { if (!feature.layer.paint) {
return; return;
@@ -65,11 +65,12 @@ export const FeatureLayerPopup: React.FC<FeatureLayerPopupProps> = (props) => {
} }
} }
const sources = groupFeaturesBySourceLayer(props.features); render() {
const sources = groupFeaturesBySourceLayer(this.props.features);
const items = Object.keys(sources).map(vectorLayerId => { const items = Object.keys(sources).map(vectorLayerId => {
const layers = sources[vectorLayerId].map((feature: InspectFeature, idx: number) => { const layers = sources[vectorLayerId].map((feature: InspectFeature, idx: number) => {
const featureColor = _getFeatureColor(feature, props.zoom); const featureColor = this._getFeatureColor(feature, this.props.zoom);
return <div return <div
key={idx} key={idx}
@@ -82,7 +83,7 @@ export const FeatureLayerPopup: React.FC<FeatureLayerPopupProps> = (props) => {
<label <label
className="maputnik-popup-layer__label" className="maputnik-popup-layer__label"
onClick={() => { onClick={() => {
props.onLayerSelect(feature.layer.id); this.props.onLayerSelect(feature.layer.id);
}} }}
> >
{feature.layer.type && {feature.layer.type &&
@@ -106,4 +107,8 @@ export const FeatureLayerPopup: React.FC<FeatureLayerPopupProps> = (props) => {
return <div className="maputnik-feature-layer-popup" data-wd-key="feature-layer-popup" dir="ltr"> return <div className="maputnik-feature-layer-popup" data-wd-key="feature-layer-popup" dir="ltr">
{items} {items}
</div>; </div>;
}; }
}
export default FeatureLayerPopup;
+94 -104
View File
@@ -1,8 +1,8 @@
import {useCallback, useEffect, useMemo, useRef, useState} from "react"; import React from "react";
import {throttle} from "lodash"; import {throttle} from "lodash";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import { FeatureLayerPopup as MapMaplibreGlLayerPopup } from "./MapMaplibreGlLayerPopup"; import MapMaplibreGlLayerPopup from "./MapMaplibreGlLayerPopup";
import "ol/ol.css"; import "ol/ol.css";
//@ts-ignore //@ts-ignore
@@ -35,52 +35,56 @@ type MapOpenLayersInternalProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const MapOpenLayersInternal = ({ type MapOpenLayersState = {
onLayerSelect = () => {}, zoom: string
mapStyle, rotation: string
style, cursor: string[]
debugToolbox, center: string[]
replaceAccessTokens, selectedFeatures?: any[]
onChange, };
t,
}: MapOpenLayersInternalProps) => {
const [zoom, setZoom] = useState("0");
const [rotation, setRotation] = useState("0");
const [cursor, setCursor] = useState<string[]>([]);
const [center, setCenter] = useState<string[]>([]);
// Never assigned in the class version either, kept for the popup below.
const [selectedFeatures] = useState<any[] | undefined>(undefined);
// Imperative handles: they were plain instance fields, so they are refs and class MapOpenLayersInternal extends React.Component<MapOpenLayersInternalProps, MapOpenLayersState> {
// mutating them must not re-render. static defaultProps = {
const map = useRef<Map | null>(null); onMapLoaded: () => {},
const container = useRef<HTMLDivElement | null>(null); onDataChange: () => {},
const overlay = useRef<Overlay | undefined>(undefined); onLayerSelect: () => {},
const popupContainer = useRef<HTMLDivElement | null>(null); };
updateStyle: any;
map: any;
container: HTMLDivElement | null = null;
overlay: Overlay | undefined;
popupContainer: HTMLElement | null = null;
// componentDidUpdate did not run on mount, and componentDidMount already constructor(props: MapOpenLayersInternalProps) {
// applies the initial style, so the style effect below must skip the mount. super(props);
const hasMounted = useRef(false); this.state = {
zoom: "0",
rotation: "0",
cursor: [] as string[],
center: [],
};
this.updateStyle = throttle(this._updateStyle.bind(this), 200);
}
// The openlayers event handlers are registered on mount but read `this.props` _updateStyle(newMapStyle: StyleSpecification) {
// at call time in the class version. if(!this.map) return;
const latestProps = useRef({mapStyle, replaceAccessTokens, onChange});
latestProps.current = {mapStyle, replaceAccessTokens, onChange};
// Was `this.updateStyle = throttle(this._updateStyle.bind(this), 200)` in the
// constructor: created once per instance, so it is memoized once here.
const updateStyle = useMemo(() => throttle((newMapStyle: StyleSpecification) => {
if(!map.current) return;
// See <https://github.com/openlayers/ol-mapbox-style/issues/215#issuecomment-493198815> // See <https://github.com/openlayers/ol-mapbox-style/issues/215#issuecomment-493198815>
map.current.getLayers().clear(); this.map.getLayers().clear();
apply(map.current, newMapStyle); apply(this.map, newMapStyle);
}, 200), []); }
// componentDidMount componentDidUpdate(prevProps: MapOpenLayersInternalProps) {
useEffect(() => { if (this.props.mapStyle !== prevProps.mapStyle) {
overlay.current = new Overlay({ this.updateStyle(
element: popupContainer.current!, this.props.replaceAccessTokens(this.props.mapStyle)
);
}
}
componentDidMount() {
this.overlay = new Overlay({
element: this.popupContainer!,
autoPan: { autoPan: {
animation: { animation: {
duration: 250 duration: 250
@@ -88,131 +92,117 @@ const MapOpenLayersInternal = ({
}, },
}); });
const mapInstance = new Map({ const map = new Map({
target: container.current!, target: this.container!,
overlays: [overlay.current], overlays: [this.overlay],
view: new View({ view: new View({
zoom: 1, zoom: 1,
center: [180, -90], center: [180, -90],
}) })
}); });
mapInstance.on("pointermove", (evt) => { map.on("pointermove", (evt) => {
const coords = toLonLat(evt.coordinate); const coords = toLonLat(evt.coordinate);
setCursor([ this.setState({
cursor: [
coords[0].toFixed(2), coords[0].toFixed(2),
coords[1].toFixed(2) coords[1].toFixed(2)
]); ]
});
}); });
const onMoveEnd = () => { const onMoveEnd = () => {
const currentZoom = mapInstance.getView().getZoom(); const zoom = map.getView().getZoom();
const currentCenter = toLonLat(mapInstance.getView().getCenter()!); const center = toLonLat(map.getView().getCenter()!);
latestProps.current.onChange({ this.props.onChange({
zoom: currentZoom, zoom,
center: { center: {
lng: currentCenter[0], lng: center[0],
lat: currentCenter[1], lat: center[1],
}, },
}); });
}; };
onMoveEnd(); onMoveEnd();
mapInstance.on("moveend", onMoveEnd); map.on("moveend", onMoveEnd);
mapInstance.on("postrender", (_e) => { map.on("postrender", (_e) => {
const currentCenter = toLonLat(mapInstance.getView().getCenter()!); const center = toLonLat(map.getView().getCenter()!);
setCenter([ this.setState({
currentCenter[0].toFixed(2), center: [
currentCenter[1].toFixed(2), center[0].toFixed(2),
]); center[1].toFixed(2),
setRotation(mapInstance.getView().getRotation().toFixed(2)); ],
setZoom(mapInstance.getView().getZoom()!.toFixed(2)); rotation: map.getView().getRotation().toFixed(2),
zoom: map.getView().getZoom()!.toFixed(2)
});
}); });
map.current = mapInstance; this.map = map;
updateStyle( this.updateStyle(
latestProps.current.replaceAccessTokens(latestProps.current.mapStyle) as StyleSpecification this.props.replaceAccessTokens(this.props.mapStyle)
); );
// Mount only, like componentDidMount. The class had no componentWillUnmount,
// so there is no teardown here either. The props read above are read from
// latestProps/the first render on purpose, so they are not dependencies.
}, [updateStyle]);
// componentDidUpdate: `if (this.props.mapStyle !== prevProps.mapStyle)`.
useEffect(() => {
if (!hasMounted.current) {
// componentDidUpdate does not run on mount; componentDidMount already
// applied the initial style, and applying it twice would push a second
// (throttled) clear + apply through ol-mapbox-style.
hasMounted.current = true;
return;
} }
updateStyle(
replaceAccessTokens(mapStyle) as StyleSpecification
);
// `replaceAccessTokens` is deliberately *not* a dependency: the parent
// re-creates it on every render, so depending on it would re-apply the style
// on every render instead of only when `mapStyle` changes, which is exactly
// what the `this.props.mapStyle !== prevProps.mapStyle` guard checked.
// eslint-disable-next-line react-hooks/exhaustive-deps -- see above: replaceAccessTokens is a new identity each render
}, [mapStyle, updateStyle]);
const closeOverlay = useCallback((e: any) => { closeOverlay = (e: any) => {
e.target.blur(); e.target.blur();
overlay.current!.setPosition(undefined); this.overlay!.setPosition(undefined);
}, []); };
render() {
const t = this.props.t;
return <div className="maputnik-ol-container"> return <div className="maputnik-ol-container">
<div <div
ref={popupContainer} ref={x => {this.popupContainer = x;}}
style={{background: "black"}} style={{background: "black"}}
className="maputnik-popup" className="maputnik-popup"
> >
<button <button
className="maplibregl-popup-close-button" className="maplibregl-popup-close-button"
onClick={closeOverlay} onClick={this.closeOverlay}
aria-label={t("Close popup")} aria-label={t("Close popup")}
> >
× ×
</button> </button>
<MapMaplibreGlLayerPopup <MapMaplibreGlLayerPopup
features={selectedFeatures || []} features={this.state.selectedFeatures || []}
onLayerSelect={onLayerSelect} onLayerSelect={this.props.onLayerSelect}
/> />
</div> </div>
<div className="maputnik-ol-zoom"> <div className="maputnik-ol-zoom">
{t("Zoom:")} {zoom} {t("Zoom:")} {this.state.zoom}
</div> </div>
{debugToolbox && {this.props.debugToolbox &&
<div className="maputnik-ol-debug"> <div className="maputnik-ol-debug">
<div> <div>
<label>{t("cursor:")} </label> <label>{t("cursor:")} </label>
<span>{renderCoords(cursor)}</span> <span>{renderCoords(this.state.cursor)}</span>
</div> </div>
<div> <div>
<label>{t("center:")} </label> <label>{t("center:")} </label>
<span>{renderCoords(center)}</span> <span>{renderCoords(this.state.center)}</span>
</div> </div>
<div> <div>
<label>{t("rotation:")} </label> <label>{t("rotation:")} </label>
<span>{rotation}</span> <span>{this.state.rotation}</span>
</div> </div>
</div> </div>
} }
<div <div
className="maputnik-ol" className="maputnik-ol"
ref={container} ref={x => {this.container = x;}}
role="region" role="region"
aria-label={t("Map view")} aria-label={t("Map view")}
style={{ style={{
...style, ...this.props.style,
}}> }}>
</div> </div>
</div>; </div>;
}; }
}
export const MapOpenLayers = withTranslation()(MapOpenLayersInternal); const MapOpenLayers = withTranslation()(MapOpenLayersInternal);
export default MapOpenLayers;
+14 -12
View File
@@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import { FieldFunction } from "./FieldFunction"; import FieldFunction from "./FieldFunction";
import type {LayerSpecification} from "maplibre-gl"; import type {LayerSpecification} from "maplibre-gl";
import { type MappedLayerErrors } from "../libs/definitions"; import { type MappedLayerErrors } from "../libs/definitions";
@@ -40,18 +40,19 @@ type PropertyGroupProps = {
errors?: MappedLayerErrors errors?: MappedLayerErrors
}; };
export const PropertyGroup: React.FC<PropertyGroupProps> = (props) => { export default class PropertyGroup extends React.Component<PropertyGroupProps> {
const onPropertyChange = (property: string, newValue: any) => { onPropertyChange = (property: string, newValue: any) => {
const group = getGroupName(props.spec, props.layer.type, property); const group = getGroupName(this.props.spec, this.props.layer.type, property);
props.onChange(group ,property, newValue); this.props.onChange(group ,property, newValue);
}; };
const {errors} = props; render() {
const fields = props.groupFields.map(fieldName => { const {errors} = this.props;
const fieldSpec = getFieldSpec(props.spec, props.layer.type, fieldName); const fields = this.props.groupFields.map(fieldName => {
const fieldSpec = getFieldSpec(this.props.spec, this.props.layer.type, fieldName);
const paint = props.layer.paint || {}; const paint = this.props.layer.paint || {};
const layout = props.layer.layout || {}; const layout = this.props.layer.layout || {};
const fieldValue = fieldName in paint const fieldValue = fieldName in paint
? paint[fieldName as keyof typeof paint] ? paint[fieldName as keyof typeof paint]
: layout[fieldName as keyof typeof layout]; : layout[fieldName as keyof typeof layout];
@@ -59,7 +60,7 @@ export const PropertyGroup: React.FC<PropertyGroupProps> = (props) => {
return <FieldFunction return <FieldFunction
errors={errors} errors={errors}
onChange={onPropertyChange} onChange={this.onPropertyChange}
key={fieldName} key={fieldName}
fieldName={fieldName} fieldName={fieldName}
value={fieldValue} value={fieldValue}
@@ -71,4 +72,5 @@ export const PropertyGroup: React.FC<PropertyGroupProps> = (props) => {
return <div className="maputnik-property-group"> return <div className="maputnik-property-group">
{fields} {fields}
</div>; </div>;
}; }
}
+5 -3
View File
@@ -4,8 +4,10 @@ type ScrollContainerProps = {
children?: React.ReactNode children?: React.ReactNode
}; };
export const ScrollContainer: React.FC<ScrollContainerProps> = (props) => { export default class ScrollContainer extends React.Component<ScrollContainerProps> {
render() {
return <div className="maputnik-scroll-container"> return <div className="maputnik-scroll-container">
{props.children} {this.props.children}
</div>; </div>;
}; }
}
+18 -15
View File
@@ -1,9 +1,9 @@
import React from "react"; import React from "react";
import {otherFilterOps} from "../libs/filterops"; import {otherFilterOps} from "../libs/filterops";
import { InputString } from "./InputString"; import InputString from "./InputString";
import { InputAutocomplete } from "./InputAutocomplete"; import InputAutocomplete from "./InputAutocomplete";
import { InputSelect } from "./InputSelect"; import InputSelect from "./InputSelect";
function tryParseInt(v: string | number) { function tryParseInt(v: string | number) {
if (v === "") return v; if (v === "") return v;
@@ -40,21 +40,23 @@ type SingleFilterEditorProps = {
properties?: {[key: string]: string} properties?: {[key: string]: string}
}; };
export const SingleFilterEditor: React.FC<SingleFilterEditorProps> = ({ export default class SingleFilterEditor extends React.Component<SingleFilterEditorProps> {
properties = {}, static defaultProps = {
...props properties: {},
}) => { };
function onFilterPartChanged(filterOp: string, propertyName: string, filterArgs: string[]) {
onFilterPartChanged(filterOp: string, propertyName: string, filterArgs: string[]) {
let newFilter = [filterOp, propertyName, ...filterArgs.map(parseFilter)]; let newFilter = [filterOp, propertyName, ...filterArgs.map(parseFilter)];
if(filterOp === "has" || filterOp === "!has") { if(filterOp === "has" || filterOp === "!has") {
newFilter = [filterOp, propertyName]; newFilter = [filterOp, propertyName];
} else if(filterArgs.length === 0) { } else if(filterArgs.length === 0) {
newFilter = [filterOp, propertyName, ""]; newFilter = [filterOp, propertyName, ""];
} }
props.onChange(newFilter); this.props.onChange(newFilter);
} }
const f = props.filter; render() {
const f = this.props.filter;
const filterOp = f[0]; const filterOp = f[0];
const propertyName = f[1]; const propertyName = f[1];
const filterArgs = f.slice(2); const filterArgs = f.slice(2);
@@ -64,15 +66,15 @@ export const SingleFilterEditor: React.FC<SingleFilterEditorProps> = ({
<InputAutocomplete <InputAutocomplete
aria-label="key" aria-label="key"
value={propertyName} value={propertyName}
options={Object.keys(properties).map(propName => [propName, propName])} options={Object.keys(this.props.properties!).map(propName => [propName, propName])}
onChange={(newPropertyName: string) => onFilterPartChanged(filterOp, newPropertyName, filterArgs)} onChange={(newPropertyName: string) => this.onFilterPartChanged(filterOp, newPropertyName, filterArgs)}
/> />
</div> </div>
<div className="maputnik-filter-editor-operator"> <div className="maputnik-filter-editor-operator">
<InputSelect <InputSelect
aria-label="function" aria-label="function"
value={filterOp} value={filterOp}
onChange={(newFilterOp: string) => onFilterPartChanged(newFilterOp, propertyName, filterArgs)} onChange={(newFilterOp: string) => this.onFilterPartChanged(newFilterOp, propertyName, filterArgs)}
options={otherFilterOps} options={otherFilterOps}
/> />
</div> </div>
@@ -81,9 +83,10 @@ export const SingleFilterEditor: React.FC<SingleFilterEditorProps> = ({
<InputString <InputString
aria-label="value" aria-label="value"
value={filterArgs.join(",")} value={filterArgs.join(",")}
onChange={(v: string) => onFilterPartChanged(filterOp, propertyName, v.split(","))} onChange={(v: string) => this.onFilterPartChanged(filterOp, propertyName, v.split(","))}
/> />
</div> </div>
} }
</div>; </div>;
}; }
}
+8 -5
View File
@@ -7,13 +7,16 @@ type SmallErrorInternalProps = {
children?: React.ReactNode children?: React.ReactNode
} & WithTranslation; } & WithTranslation;
const SmallErrorInternal: React.FC<SmallErrorInternalProps> = (props) => { class SmallErrorInternal extends React.Component<SmallErrorInternalProps> {
const t = props.t; render () {
const t = this.props.t;
return ( return (
<div className="SmallError"> <div className="SmallError">
{t("Error:")} {props.children} {t("Error:")} {this.props.children}
</div> </div>
); );
}; }
}
export const SmallError = withTranslation()(SmallErrorInternal); const SmallError = withTranslation()(SmallErrorInternal);
export default SmallError;
-44
View File
@@ -1,44 +0,0 @@
import React from "react";
import { FieldSpec, type FieldSpecProps } from "./FieldSpec";
import { FunctionInputButtons as FunctionButtons } from "./FunctionButtons";
import { labelFromFieldName } from "../libs/label-from-field-name";
type SpecPropertyProps = FieldSpecProps & {
fieldName?: string
fieldType?: string
fieldSpec?: any
value?: any
errors?: {[key: string]: {message: string}}
onZoomClick(): void
onDataClick(): void
onExpressionClick?(): void
onElevationClick?(): void
};
export const SpecProperty: React.FC<SpecPropertyProps> = (props) => {
const {errors = {}, fieldName, fieldType} = props;
const functionBtn = <FunctionButtons
fieldSpec={props.fieldSpec}
onZoomClick={props.onZoomClick}
onDataClick={props.onDataClick}
onExpressionClick={props.onExpressionClick}
onElevationClick={props.onElevationClick}
/>;
const error = errors[fieldType+"."+fieldName as any] as any;
const propsWithDefaults = {...props, errors};
return <FieldSpec
{...propsWithDefaults}
error={error}
fieldSpec={props.fieldSpec}
label={labelFromFieldName(props.fieldName || "")}
action={functionBtn}
/>;
};
-246
View File
@@ -1,246 +0,0 @@
import React, { useRef } from "react";
import { PiListPlusBold } from "react-icons/pi";
import { TbMathFunction } from "react-icons/tb";
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import { type WithTranslation, withTranslation } from "react-i18next";
import { InputButton } from "./InputButton";
import { InputSpec } from "./InputSpec";
import { InputNumber } from "./InputNumber";
import { InputSelect } from "./InputSelect";
import { Block } from "./Block";
import { DeleteStopButton } from "./DeleteStopButton";
import { labelFromFieldName } from "../libs/label-from-field-name";
import { generateUniqueId as docUid } from "../libs/document-uid";
import { sortNumerically } from "../libs/sort-numerically";
import { type MappedLayerErrors } from "../libs/definitions";
/**
* We cache a reference for each stop by its index.
*
* When the stops are reordered the references are also updated (see this.orderStops) this allows React to use the same key for the element and keep keyboard focus.
*/
function setStopRefs(props: ZoomPropertyInternalProps, state: ZoomPropertyState) {
// This is initialised below only if required to improved performance.
let newRefs: {[key: number]: string} = {};
if(props.value && (props.value as ZoomWithStops).stops) {
(props.value as ZoomWithStops).stops.forEach((_val, idx: number) => {
if(Object.prototype.hasOwnProperty.call(!state.refs, idx)) {
if(!newRefs) {
newRefs = {...state};
}
newRefs[idx] = docUid("stop-");
} else {
newRefs[idx] = state.refs[idx];
}
});
}
return newRefs;
}
type ZoomWithStops = {
stops: [number | undefined, number][]
base?: number
};
type ZoomPropertyInternalProps = {
onChange?(...args: unknown[]): unknown
onChangeToDataFunction?(...args: unknown[]): unknown
onDeleteStop?(...args: unknown[]): unknown
onAddStop?(...args: unknown[]): unknown
onExpressionClick?(...args: unknown[]): unknown
fieldType?: string
fieldName: string
fieldSpec?: {
"property-type"?: string
"function-type"?: string
}
errors?: MappedLayerErrors
value?: ZoomWithStops
} & WithTranslation;
type ZoomPropertyState = {
refs: {[key: number]: string}
};
const ZoomPropertyInternal: React.FC<ZoomPropertyInternalProps> = ({ errors = {}, ...rest }) => {
const props = { errors, ...rest } as ZoomPropertyInternalProps;
// The stop refs never reach the rendered output (the row key is derived from
// the stop itself), so they live in a ref rather than state: keeping them in
// state would mean setting state during render on every pass.
const refs = useRef<{[key: number]: string}>({});
refs.current = setStopRefs(props, { refs: refs.current });
// Order the stops altering the refs to reflect their new position.
function orderStopsByZoom(stops: ZoomWithStops["stops"]) {
const mappedWithRef = stops
.map((stop, idx) => {
return {
ref: refs.current[idx],
data: stop
};
})
// Sort by zoom
.sort((a, b) => sortNumerically(a.data[0]!, b.data[0]!));
// Fetch the new position of the stops
const newRefs: {[key:number]: string} = {};
mappedWithRef
.forEach((stop, idx) =>{
newRefs[idx] = stop.ref;
});
refs.current = newRefs;
return mappedWithRef.map((item) => item.data);
}
function changeZoomStop(changeIdx: number, stopData: number | undefined, value: number) {
const stops = (props.value as ZoomWithStops).stops.slice(0);
stops[changeIdx] = [stopData, value];
const orderedStops = orderStopsByZoom(stops);
const changedValue = {
...props.value as ZoomWithStops,
stops: orderedStops
};
props.onChange!(props.fieldName, changedValue);
}
function changeBase(newValue: number | undefined) {
const changedValue = {
...props.value,
base: newValue
};
if (changedValue.base === undefined) {
delete changedValue["base"];
}
props.onChange!(props.fieldName, changedValue);
}
const changeDataType = (type: string) => {
if (type !== "interpolate" && props.onChangeToDataFunction) {
props.onChangeToDataFunction(type);
}
};
function getDataFunctionTypes(fieldSpec: {
"property-type"?: string
"function-type"?: string
}) {
if (fieldSpec["property-type"] === "data-driven") {
return ["interpolate", "categorical", "interval", "exponential", "identity"];
}
else {
return ["interpolate"];
}
}
const t = props.t;
const zoomFields = props.value?.stops.map((stop, idx) => {
const zoomLevel = stop[0];
const value = stop[1];
const deleteStopBtn = <DeleteStopButton onClick={props.onDeleteStop?.bind(null, idx)} />;
return <tr
key={`${stop[0]}-${stop[1]}`}
>
<td>
<InputNumber
aria-label={t("Zoom")}
value={zoomLevel}
onChange={changedStop => changeZoomStop(idx, changedStop, value)}
min={0}
max={22}
/>
</td>
<td>
<InputSpec
aria-label={t("Output value")}
fieldName={props.fieldName}
fieldSpec={props.fieldSpec as any}
value={value}
onChange={(_, newValue) => changeZoomStop(idx, zoomLevel, newValue as number)}
/>
</td>
<td>
{deleteStopBtn}
</td>
</tr>;
});
// return <div className="maputnik-zoom-spec-property">
return <div className="maputnik-data-spec-block">
<fieldset className="maputnik-data-spec-property">
<legend>{labelFromFieldName(props.fieldName)}</legend>
<div className="maputnik-data-fieldset-inner">
<Block
label={t("Function")}
data-wd-key="function-type"
>
<div className="maputnik-data-spec-property-input">
<InputSelect
value={"interpolate"}
onChange={(propVal: string) => changeDataType(propVal)}
title={t("Select a type of data scale (default is 'categorical').")}
options={getDataFunctionTypes(props.fieldSpec!)}
/>
</div>
</Block>
<Block
label={t("Base")}
data-wd-key="function-base"
>
<div className="maputnik-data-spec-property-input">
<InputSpec
fieldName={"base"}
fieldSpec={latest.function.base as typeof latest.function.base & { type: "number" }}
value={props.value?.base}
onChange={(_, newValue) => changeBase(newValue as number | undefined)}
/>
</div>
</Block>
<div className="maputnik-function-stop">
<table className="maputnik-function-stop-table maputnik-function-stop-table--zoom">
<caption>{t("Stops")}</caption>
<thead>
<tr>
<th>{t("Zoom")}</th>
<th rowSpan={2}>{t("Output value")}</th>
</tr>
</thead>
<tbody>
{zoomFields}
</tbody>
</table>
</div>
<div className="maputnik-toolbox">
<InputButton
className="maputnik-add-stop"
onClick={props.onAddStop?.bind(null)}
>
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} />
{t("Add stop")}
</InputButton>
<InputButton
className="maputnik-add-stop"
data-wd-key="convert-to-expression"
onClick={props.onExpressionClick?.bind(null)}
>
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
{t("Convert to expression")}
</InputButton>
</div>
</div>
</fieldset>
</div>;
};
export const ZoomProperty = withTranslation()(ZoomPropertyInternal);
+384
View File
@@ -0,0 +1,384 @@
import React from "react";
import {PiListPlusBold} from "react-icons/pi";
import {TbMathFunction} from "react-icons/tb";
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import InputButton from "./InputButton";
import InputSpec from "./InputSpec";
import InputNumber from "./InputNumber";
import InputString from "./InputString";
import InputSelect from "./InputSelect";
import Block from "./Block";
import docUid from "../libs/document-uid";
import sortNumerically from "../libs/sort-numerically";
import {findDefaultFromSpec} from "../libs/spec-helper";
import { type WithTranslation, withTranslation } from "react-i18next";
import labelFromFieldName from "../libs/label-from-field-name";
import DeleteStopButton from "./_DeleteStopButton";
import { type MappedLayerErrors } from "../libs/definitions";
function setStopRefs(props: DataPropertyInternalProps, state: DataPropertyState) {
// This is initialised below only if required to improved performance.
let newRefs: {[key: number]: string} | undefined;
if(props.value && props.value.stops) {
props.value.stops.forEach((_val, idx) => {
if(!Object.prototype.hasOwnProperty.call(state.refs, idx)) {
if(!newRefs) {
newRefs = {...state};
}
newRefs[idx] = docUid("stop-");
}
});
}
return newRefs;
}
type DataPropertyInternalProps = {
onChange?(fieldName: string, value: any): unknown
onDeleteStop?(...args: unknown[]): unknown
onAddStop?(...args: unknown[]): unknown
onExpressionClick?(...args: unknown[]): unknown
onChangeToZoomFunction?(...args: unknown[]): unknown
fieldName: string
fieldType?: string
fieldSpec?: object
value?: DataPropertyValue
errors?: MappedLayerErrors
} & WithTranslation;
type DataPropertyState = {
refs: {[key: number]: string}
};
type DataPropertyValue = {
default?: any
property?: string
base?: number
type?: string
stops: Stop[]
};
export type Stop = [{
zoom: number
value: number
}, number];
class DataPropertyInternal extends React.Component<DataPropertyInternalProps, DataPropertyState> {
state = {
refs: {} as {[key: number]: string}
};
componentDidMount() {
const newRefs = setStopRefs(this.props, this.state);
if(newRefs) {
this.setState({
refs: newRefs
});
}
}
static getDerivedStateFromProps(props: Readonly<DataPropertyInternalProps>, state: DataPropertyState) {
const newRefs = setStopRefs(props, state);
if(newRefs) {
return {
refs: newRefs
};
}
return null;
}
getFieldFunctionType(fieldSpec: any) {
if (fieldSpec.expression.interpolated) {
return "exponential";
}
if (fieldSpec.type === "number") {
return "interval";
}
return "categorical";
}
getDataFunctionTypes(fieldSpec: any) {
if (fieldSpec.expression.interpolated) {
return ["interpolate", "categorical", "interval", "exponential", "identity"];
}
else {
return ["categorical", "interval", "identity"];
}
}
// Order the stops altering the refs to reflect their new position.
orderStopsByZoom(stops: Stop[]) {
const mappedWithRef = stops
.map((stop, idx) => {
return {
ref: this.state.refs[idx],
data: stop
};
})
// Sort by zoom
.sort((a, b) => sortNumerically(a.data[0].zoom, b.data[0].zoom));
// Fetch the new position of the stops
const newRefs = {} as {[key: number]: string};
mappedWithRef
.forEach((stop, idx) =>{
newRefs[idx] = stop.ref;
});
this.setState({
refs: newRefs
});
return mappedWithRef.map((item) => item.data);
}
onChange = (fieldName: string, value: any) => {
if (value.type === "identity") {
value = {
type: value.type,
property: value.property,
};
}
else {
const stopValue = value.type === "categorical" ? "" : 0;
value = {
property: "",
type: value.type,
// Default props if they don't already exist.
stops: [
[{zoom: 6, value: stopValue}, findDefaultFromSpec(this.props.fieldSpec as any)],
[{zoom: 10, value: stopValue}, findDefaultFromSpec(this.props.fieldSpec as any)]
],
...value,
};
}
this.props.onChange!(fieldName, value);
};
changeStop(changeIdx: number, stopData: { zoom: number | undefined, value: number }, value: number) {
const stops = this.props.value?.stops.slice(0) || [];
// const changedStop = stopData.zoom === undefined ? stopData.value : stopData
stops[changeIdx] = [
{
value: stopData.value,
zoom: (stopData.zoom === undefined) ? 0 : stopData.zoom,
},
value
];
const orderedStops = this.orderStopsByZoom(stops);
const changedValue = {
...this.props.value,
stops: orderedStops,
};
this.onChange(this.props.fieldName, changedValue);
}
changeBase(newValue: number | undefined) {
const changedValue = {
...this.props.value,
base: newValue
};
if (changedValue.base === undefined) {
delete changedValue["base"];
}
this.props.onChange!(this.props.fieldName, changedValue);
}
changeDataType(propVal: string) {
if (propVal === "interpolate" && this.props.onChangeToZoomFunction) {
this.props.onChangeToZoomFunction();
}
else {
this.onChange(this.props.fieldName, {
...this.props.value,
type: propVal,
});
}
}
changeDataProperty(propName: "property" | "default", propVal: any) {
if (propVal) {
this.props.value![propName] = propVal;
}
else {
delete this.props.value![propName];
}
this.onChange(this.props.fieldName, this.props.value);
}
render() {
const t = this.props.t;
if (typeof this.props.value?.type === "undefined") {
this.props.value!.type = this.getFieldFunctionType(this.props.fieldSpec);
}
let dataFields;
if (this.props.value?.stops) {
dataFields = this.props.value.stops.map((stop, idx) => {
const zoomLevel = typeof stop[0] === "object" ? stop[0].zoom : undefined;
const key = this.state.refs[idx];
const dataLevel = typeof stop[0] === "object" ? stop[0].value : stop[0];
const value = stop[1];
const deleteStopBtn = <DeleteStopButton onClick={this.props.onDeleteStop?.bind(this, idx)} />;
const dataProps = {
"aria-label": t("Input value"),
label: t("Data value"),
value: dataLevel as any,
onChange: (newData: string | number | undefined) => this.changeStop(idx, { zoom: zoomLevel, value: newData as number }, value)
};
let dataInput;
if(this.props.value?.type === "categorical") {
dataInput = <InputString {...dataProps} />;
}
else {
dataInput = <InputNumber {...dataProps} />;
}
let zoomInput = null;
if(zoomLevel !== undefined) {
zoomInput = <div>
<InputNumber
aria-label="Zoom"
value={zoomLevel}
onChange={newZoom => this.changeStop(idx, {zoom: newZoom, value: dataLevel}, value)}
min={0}
max={22}
/>
</div>;
}
return <tr key={key}>
<td>
{zoomInput}
</td>
<td>
{dataInput}
</td>
<td>
<InputSpec
aria-label={t("Output value")}
fieldName={this.props.fieldName}
fieldSpec={this.props.fieldSpec}
value={value}
onChange={(_, newValue) => this.changeStop(idx, {zoom: zoomLevel, value: dataLevel}, newValue as number)}
/>
</td>
<td>
{deleteStopBtn}
</td>
</tr>;
});
}
return <div className="maputnik-data-spec-block">
<fieldset className="maputnik-data-spec-property">
<legend>{labelFromFieldName(this.props.fieldName)}</legend>
<div className="maputnik-data-fieldset-inner">
<Block
label={t("Function")}
key="function"
>
<div className="maputnik-data-spec-property-input">
<InputSelect
value={this.props.value!.type}
onChange={(propVal: string) => this.changeDataType(propVal)}
title={t("Select a type of data scale (default is 'categorical').")}
options={this.getDataFunctionTypes(this.props.fieldSpec)}
/>
</div>
</Block>
{this.props.value?.type !== "identity" &&
<Block
label={t("Base")}
key="base"
>
<div className="maputnik-data-spec-property-input">
<InputSpec
fieldName={"base"}
fieldSpec={latest.function.base as typeof latest.function.base & { type: "number" }}
value={this.props.value?.base}
onChange={(_, newValue) => this.changeBase(newValue as number)}
/>
</div>
</Block>
}
<Block
label={"Property"}
key="property"
>
<div className="maputnik-data-spec-property-input">
<InputString
value={this.props.value?.property}
title={t("Input a data property to base styles off of.")}
onChange={propVal => this.changeDataProperty("property", propVal)}
/>
</div>
</Block>
{dataFields &&
<Block
label={t("Default")}
key="default"
>
<InputSpec
fieldName={this.props.fieldName}
fieldSpec={this.props.fieldSpec}
value={this.props.value?.default}
onChange={(_, propVal) => this.changeDataProperty("default", propVal)}
/>
</Block>
}
{dataFields &&
<div className="maputnik-function-stop">
<table className="maputnik-function-stop-table">
<caption>{t("Stops")}</caption>
<thead>
<tr>
<th>{t("Zoom")}</th>
<th>{t("Input value")}</th>
<th rowSpan={2}>{t("Output value")}</th>
</tr>
</thead>
<tbody>
{dataFields}
</tbody>
</table>
</div>
}
<div className="maputnik-toolbox">
{dataFields &&
<InputButton
className="maputnik-add-stop"
onClick={this.props.onAddStop?.bind(this)}
>
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} />
{t("Add stop")}
</InputButton>
}
<InputButton
className="maputnik-add-stop"
onClick={this.props.onExpressionClick?.bind(this)}
>
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
{t("Convert to expression")}
</InputButton>
</div>
</div>
</fieldset>
</div>;
}
}
const DataProperty = withTranslation()(DataPropertyInternal);
export default DataProperty;
+27
View File
@@ -0,0 +1,27 @@
import React from "react";
import InputButton from "./InputButton";
import {MdDelete} from "react-icons/md";
import { type WithTranslation, withTranslation } from "react-i18next";
type DeleteStopButtonInternalProps = {
onClick?(...args: unknown[]): unknown
} & WithTranslation;
class DeleteStopButtonInternal extends React.Component<DeleteStopButtonInternalProps> {
render() {
const t = this.props.t;
return <InputButton
className="maputnik-delete-stop"
onClick={this.props.onClick}
title={t("Remove zoom level from stop")}
>
<MdDelete />
</InputButton>;
}
}
const DeleteStopButton = withTranslation()(DeleteStopButtonInternal);
export default DeleteStopButton;
+94
View File
@@ -0,0 +1,94 @@
import React from "react";
import {MdDelete, MdUndo} from "react-icons/md";
import { type WithTranslation, withTranslation } from "react-i18next";
import Block from "./Block";
import InputButton from "./InputButton";
import labelFromFieldName from "../libs/label-from-field-name";
import FieldJson from "./FieldJson";
import type { StylePropertySpecification } from "maplibre-gl";
import { type MappedLayerErrors } from "../libs/definitions";
type ExpressionPropertyInternalProps = {
fieldName: string
fieldType?: string
fieldSpec?: StylePropertySpecification
value?: any
errors?: MappedLayerErrors
onDelete?(...args: unknown[]): unknown
onChange(value: object): void
onUndo?(...args: unknown[]): unknown
canUndo?(...args: unknown[]): unknown
onFocus?(...args: unknown[]): unknown
onBlur?(...args: unknown[]): unknown
} & WithTranslation;
class ExpressionPropertyInternal extends React.Component<ExpressionPropertyInternalProps> {
static defaultProps = {
errors: {},
onFocus: () => {},
onBlur: () => {},
};
constructor(props: ExpressionPropertyInternalProps) {
super(props);
this.state = {
jsonError: false,
};
}
render() {
const {t, value, canUndo} = this.props;
const undoDisabled = canUndo ? !canUndo() : true;
const deleteStopBtn = (
<>
{this.props.onUndo &&
<InputButton
key="undo_action"
onClick={this.props.onUndo}
disabled={undoDisabled}
className="maputnik-delete-stop"
title={t("Revert from expression")}
>
<MdUndo />
</InputButton>
}
<InputButton
key="delete_action"
onClick={this.props.onDelete}
className="maputnik-delete-stop"
title={t("Delete expression")}
>
<MdDelete />
</InputButton>
</>
);
let error = undefined;
if (this.props.errors) {
const fieldKey = this.props.fieldType ? this.props.fieldType + "." + this.props.fieldName : this.props.fieldName;
error = this.props.errors[fieldKey];
}
return <Block
fieldSpec={this.props.fieldSpec}
label={t(labelFromFieldName(this.props.fieldName))}
action={deleteStopBtn}
wideMode={true}
error={error}
>
<FieldJson
lintType="expression"
spec={this.props.fieldSpec}
className="maputnik-expression-editor"
onFocus={this.props.onFocus}
onBlur={this.props.onBlur}
value={value}
onChange={this.props.onChange}
/>
</Block>;
}
}
const ExpressionProperty = withTranslation()(ExpressionPropertyInternal);
export default ExpressionProperty;
+71
View File
@@ -0,0 +1,71 @@
import React from "react";
import InputButton from "./InputButton";
import {MdFunctions, MdInsertChart} from "react-icons/md";
import { TbMathFunction } from "react-icons/tb";
import { type WithTranslation, withTranslation } from "react-i18next";
type FunctionInputButtonsInternalProps = {
fieldSpec?: any
onZoomClick?(): void
onDataClick?(): void
onExpressionClick?(): void
onElevationClick?(): void
} & WithTranslation;
class FunctionInputButtonsInternal extends React.Component<FunctionInputButtonsInternalProps> {
render() {
const t = this.props.t;
if (this.props.fieldSpec.expression?.parameters.includes("zoom")) {
const expressionInputButton = (
<InputButton
className="maputnik-make-zoom-function"
onClick={this.props.onExpressionClick}
title={t("Convert to expression")}
>
<TbMathFunction />
</InputButton>
);
const makeZoomInputButton = <InputButton
className="maputnik-make-zoom-function"
onClick={this.props.onZoomClick}
title={t("Convert property into a zoom function")}
>
<MdFunctions />
</InputButton>;
let makeDataInputButton;
if (this.props.fieldSpec["property-type"] === "data-driven") {
makeDataInputButton = <InputButton
className="maputnik-make-data-function"
onClick={this.props.onDataClick}
title={t("Convert property to data function")}
>
<MdInsertChart />
</InputButton>;
}
return <div>
{expressionInputButton}
{makeDataInputButton}
{makeZoomInputButton}
</div>;
} else if (this.props.fieldSpec.expression?.parameters.includes("elevation")) {
const inputElevationButton = <InputButton
className="maputnik-make-elevation-function"
onClick={this.props.onElevationClick}
title={t("Convert property into a elevation function")}
data-wd-key='make-elevation-function'
>
<MdFunctions />
</InputButton>;
return <div>{inputElevationButton}</div>;
} else {
return <div></div>;
}
}
}
const FunctionInputButtons = withTranslation()(FunctionInputButtonsInternal);
export default FunctionInputButtons;
+48
View File
@@ -0,0 +1,48 @@
import React from "react";
import FieldSpec, {type FieldSpecProps} from "./FieldSpec";
import FunctionButtons from "./_FunctionButtons";
import labelFromFieldName from "../libs/label-from-field-name";
type SpecPropertyProps = FieldSpecProps & {
fieldName?: string
fieldType?: string
fieldSpec?: any
value?: any
errors?: {[key: string]: {message: string}}
onZoomClick(): void
onDataClick(): void
onExpressionClick?(): void
onElevationClick?(): void
};
export default class SpecProperty extends React.Component<SpecPropertyProps> {
static defaultProps = {
errors: {},
};
render() {
const {errors, fieldName, fieldType} = this.props;
const functionBtn = <FunctionButtons
fieldSpec={this.props.fieldSpec}
onZoomClick={this.props.onZoomClick}
onDataClick={this.props.onDataClick}
onExpressionClick={this.props.onExpressionClick}
onElevationClick={this.props.onElevationClick}
/>;
const error = errors![fieldType+"."+fieldName as any] as any;
return <FieldSpec
{...this.props}
error={error}
fieldSpec={this.props.fieldSpec}
label={labelFromFieldName(this.props.fieldName || "")}
action={functionBtn}
/>;
}
}
+268
View File
@@ -0,0 +1,268 @@
import React from "react";
import { PiListPlusBold } from "react-icons/pi";
import { TbMathFunction } from "react-icons/tb";
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import { type WithTranslation, withTranslation } from "react-i18next";
import InputButton from "./InputButton";
import InputSpec from "./InputSpec";
import InputNumber from "./InputNumber";
import InputSelect from "./InputSelect";
import Block from "./Block";
import DeleteStopButton from "./_DeleteStopButton";
import labelFromFieldName from "../libs/label-from-field-name";
import docUid from "../libs/document-uid";
import sortNumerically from "../libs/sort-numerically";
import { type MappedLayerErrors } from "../libs/definitions";
/**
* We cache a reference for each stop by its index.
*
* When the stops are reordered the references are also updated (see this.orderStops) this allows React to use the same key for the element and keep keyboard focus.
*/
function setStopRefs(props: ZoomPropertyInternalProps, state: ZoomPropertyState) {
// This is initialised below only if required to improved performance.
let newRefs: {[key: number]: string} = {};
if(props.value && (props.value as ZoomWithStops).stops) {
(props.value as ZoomWithStops).stops.forEach((_val, idx: number) => {
if(Object.prototype.hasOwnProperty.call(!state.refs, idx)) {
if(!newRefs) {
newRefs = {...state};
}
newRefs[idx] = docUid("stop-");
} else {
newRefs[idx] = state.refs[idx];
}
});
}
return newRefs;
}
type ZoomWithStops = {
stops: [number | undefined, number][]
base?: number
};
type ZoomPropertyInternalProps = {
onChange?(...args: unknown[]): unknown
onChangeToDataFunction?(...args: unknown[]): unknown
onDeleteStop?(...args: unknown[]): unknown
onAddStop?(...args: unknown[]): unknown
onExpressionClick?(...args: unknown[]): unknown
fieldType?: string
fieldName: string
fieldSpec?: {
"property-type"?: string
"function-type"?: string
}
errors?: MappedLayerErrors
value?: ZoomWithStops
} & WithTranslation;
type ZoomPropertyState = {
refs: {[key: number]: string}
};
class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, ZoomPropertyState> {
static defaultProps = {
errors: {},
};
state = {
refs: {} as {[key: number]: string}
};
componentDidMount() {
const newRefs = setStopRefs(this.props, this.state);
if(newRefs) {
this.setState({
refs: newRefs
});
}
}
static getDerivedStateFromProps(props: Readonly<ZoomPropertyInternalProps>, state: ZoomPropertyState) {
const newRefs = setStopRefs(props, state);
if(newRefs) {
return {
refs: newRefs
};
}
return null;
}
// Order the stops altering the refs to reflect their new position.
orderStopsByZoom(stops: ZoomWithStops["stops"]) {
const mappedWithRef = stops
.map((stop, idx) => {
return {
ref: this.state.refs[idx],
data: stop
};
})
// Sort by zoom
.sort((a, b) => sortNumerically(a.data[0]!, b.data[0]!));
// Fetch the new position of the stops
const newRefs: {[key:number]: string} = {};
mappedWithRef
.forEach((stop, idx) =>{
newRefs[idx] = stop.ref;
});
this.setState({
refs: newRefs
});
return mappedWithRef.map((item) => item.data);
}
changeZoomStop(changeIdx: number, stopData: number | undefined, value: number) {
const stops = (this.props.value as ZoomWithStops).stops.slice(0);
stops[changeIdx] = [stopData, value];
const orderedStops = this.orderStopsByZoom(stops);
const changedValue = {
...this.props.value as ZoomWithStops,
stops: orderedStops
};
this.props.onChange!(this.props.fieldName, changedValue);
}
changeBase(newValue: number | undefined) {
const changedValue = {
...this.props.value,
base: newValue
};
if (changedValue.base === undefined) {
delete changedValue["base"];
}
this.props.onChange!(this.props.fieldName, changedValue);
}
changeDataType = (type: string) => {
if (type !== "interpolate" && this.props.onChangeToDataFunction) {
this.props.onChangeToDataFunction(type);
}
};
render() {
const t = this.props.t;
const zoomFields = this.props.value?.stops.map((stop, idx) => {
const zoomLevel = stop[0];
const value = stop[1];
const deleteStopBtn = <DeleteStopButton onClick={this.props.onDeleteStop?.bind(this, idx)} />;
return <tr
key={`${stop[0]}-${stop[1]}`}
>
<td>
<InputNumber
aria-label={t("Zoom")}
value={zoomLevel}
onChange={changedStop => this.changeZoomStop(idx, changedStop, value)}
min={0}
max={22}
/>
</td>
<td>
<InputSpec
aria-label={t("Output value")}
fieldName={this.props.fieldName}
fieldSpec={this.props.fieldSpec as any}
value={value}
onChange={(_, newValue) => this.changeZoomStop(idx, zoomLevel, newValue as number)}
/>
</td>
<td>
{deleteStopBtn}
</td>
</tr>;
});
// return <div className="maputnik-zoom-spec-property">
return <div className="maputnik-data-spec-block">
<fieldset className="maputnik-data-spec-property">
<legend>{labelFromFieldName(this.props.fieldName)}</legend>
<div className="maputnik-data-fieldset-inner">
<Block
label={t("Function")}
>
<div className="maputnik-data-spec-property-input">
<InputSelect
value={"interpolate"}
onChange={(propVal: string) => this.changeDataType(propVal)}
title={t("Select a type of data scale (default is 'categorical').")}
options={this.getDataFunctionTypes(this.props.fieldSpec!)}
/>
</div>
</Block>
<Block
label={t("Base")}
>
<div className="maputnik-data-spec-property-input">
<InputSpec
fieldName={"base"}
fieldSpec={latest.function.base as typeof latest.function.base & { type: "number" }}
value={this.props.value?.base}
onChange={(_, newValue) => this.changeBase(newValue as number | undefined)}
/>
</div>
</Block>
<div className="maputnik-function-stop">
<table className="maputnik-function-stop-table maputnik-function-stop-table--zoom">
<caption>{t("Stops")}</caption>
<thead>
<tr>
<th>{t("Zoom")}</th>
<th rowSpan={2}>{t("Output value")}</th>
</tr>
</thead>
<tbody>
{zoomFields}
</tbody>
</table>
</div>
<div className="maputnik-toolbox">
<InputButton
className="maputnik-add-stop"
onClick={this.props.onAddStop?.bind(this)}
>
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} />
{t("Add stop")}
</InputButton>
<InputButton
className="maputnik-add-stop"
onClick={this.props.onExpressionClick?.bind(this)}
>
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
{t("Convert to expression")}
</InputButton>
</div>
</div>
</fieldset>
</div>;
}
getDataFunctionTypes(fieldSpec: {
"property-type"?: string
"function-type"?: string
}) {
if (fieldSpec["property-type"] === "data-driven") {
return ["interpolate", "categorical", "interval", "exponential", "identity"];
}
else {
return ["interpolate"];
}
}
}
const ZoomProperty = withTranslation()(ZoomPropertyInternal);
export default ZoomProperty;
+24 -17
View File
@@ -14,44 +14,49 @@ type ModalInternalProps = PropsWithChildren & {
} & WithTranslation; } & WithTranslation;
const ModalInternal: React.FC<ModalInternalProps> = ({underlayClickExits = true, ...props}) => { class ModalInternal extends React.Component<ModalInternalProps> {
static defaultProps = {
underlayClickExits: true
};
// See <https://github.com/maplibre/maputnik/issues/416> // See <https://github.com/maplibre/maputnik/issues/416>
const onClose = () => { onClose = () => {
if (document.activeElement) { if (document.activeElement) {
(document.activeElement as HTMLElement).blur(); (document.activeElement as HTMLElement).blur();
} }
setTimeout(() => { setTimeout(() => {
props.onOpenToggle(); this.props.onOpenToggle();
}, 0); }, 0);
}; };
const t = props.t; render() {
if(props.isOpen) { const t = this.props.t;
if(this.props.isOpen) {
return <AriaModal return <AriaModal
titleText={props.title} titleText={this.props.title}
underlayClickExits={underlayClickExits} underlayClickExits={this.props.underlayClickExits}
data-wd-key={props["data-wd-key"]} data-wd-key={this.props["data-wd-key"]}
verticallyCenter={true} verticallyCenter={true}
onExit={onClose} onExit={this.onClose}
dialogClass='maputnik-modal-container' dialogClass='maputnik-modal-container'
> >
<div className={classnames("maputnik-modal", props.className)} <div className={classnames("maputnik-modal", this.props.className)}
data-wd-key={props["data-wd-key"]} data-wd-key={this.props["data-wd-key"]}
> >
<header className="maputnik-modal-header"> <header className="maputnik-modal-header">
<h1 className="maputnik-modal-header-title">{props.title}</h1> <h1 className="maputnik-modal-header-title">{this.props.title}</h1>
<span className="maputnik-space"></span> <span className="maputnik-space"></span>
<button className="maputnik-modal-header-toggle" <button className="maputnik-modal-header-toggle"
title={t("Close modal")} title={t("Close modal")}
onClick={onClose} onClick={this.onClose}
data-wd-key={props["data-wd-key"]+".close-modal"} data-wd-key={this.props["data-wd-key"]+".close-modal"}
> >
<MdClose /> <MdClose />
</button> </button>
</header> </header>
<div className="maputnik-modal-scroller"> <div className="maputnik-modal-scroller">
<div className="maputnik-modal-content">{props.children}</div> <div className="maputnik-modal-content">{this.props.children}</div>
</div> </div>
</div> </div>
</AriaModal>; </AriaModal>;
@@ -59,6 +64,8 @@ const ModalInternal: React.FC<ModalInternalProps> = ({underlayClickExits = true,
else { else {
return false; return false;
} }
}; }
}
export const Modal = withTranslation()(ModalInternal); const Modal = withTranslation()(ModalInternal);
export default Modal;
+105 -101
View File
@@ -1,13 +1,13 @@
import React, { useEffect, useRef, useState } from "react"; import React from "react";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import type {LayerSpecification, SourceSpecification} from "maplibre-gl"; import type {LayerSpecification, SourceSpecification} from "maplibre-gl";
import { InputButton } from "../InputButton"; import InputButton from "../InputButton";
import { Modal } from "./Modal"; import Modal from "./Modal";
import { FieldType } from "../FieldType"; 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 { NON_SOURCE_LAYERS } from "../../libs/non-source-layers"; import { NON_SOURCE_LAYERS } from "../../libs/non-source-layers";
type ModalAddInternalProps = { type ModalAddInternalProps = {
@@ -27,112 +27,115 @@ type ModalAddState = {
error?: string | null error?: string | null
}; };
type ModalAddSources = ModalAddInternalProps["sources"]; class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddState> {
addLayer = () => {
if (this.props.layers.some(l => l.id === this.state.id)) {
this.setState({ error: this.props.t("Layer ID already exists") });
return;
}
function getLayersForSource(allSources: ModalAddSources, source: string) { const changedLayers = this.props.layers.slice(0);
const sourceObj = allSources[source] || {}; const layer: ModalAddState = {
id: this.state.id,
type: this.state.type,
};
if(this.state.type !== "background") {
layer.source = this.state.source;
if(!NON_SOURCE_LAYERS.includes(this.state.type) && this.state["source-layer"]) {
layer["source-layer"] = this.state["source-layer"];
}
}
changedLayers.push(layer as LayerSpecification);
this.setState({ error: null }, () => {
this.props.onLayersChange(changedLayers);
this.props.onOpenToggle();
});
};
constructor(props: ModalAddInternalProps) {
super(props);
const state: ModalAddState = {
type: "fill",
id: "",
error: null,
};
if(Object.keys(props.sources).length > 0) {
state.source = Object.keys(this.props.sources)[0];
const sourceLayers = this.props.sources[state.source].layers || [];
if (sourceLayers.length > 0) {
state["source-layer"] = sourceLayers[0];
}
}
this.state = state;
}
componentDidUpdate(_prevProps: ModalAddInternalProps, prevState: ModalAddState) {
// Check if source is valid for new type
const oldType = prevState.type;
const newType = this.state.type;
const availableSourcesOld = this.getSources(oldType);
const availableSourcesNew = this.getSources(newType);
if(
// Type has changed
oldType !== newType
&& prevState.source !== ""
// Was a valid source previously
&& availableSourcesOld.indexOf(prevState.source!) > -1
// And is not a valid source now
&& availableSourcesNew.indexOf(this.state.source!) < 0
) {
// Clear the source
this.setState({
source: ""
});
}
}
getLayersForSource(source: string) {
const sourceObj = this.props.sources[source] || {};
return sourceObj.layers || []; return sourceObj.layers || [];
} }
function getSources(allSources: ModalAddSources, type: LayerSpecification["type"]) { getSources(type: LayerSpecification["type"]) {
switch(type) { switch(type) {
case "background": case "background":
return []; return [];
case "hillshade": case "hillshade":
case "color-relief": case "color-relief":
return Object.entries(allSources).filter(([_, v]) => v.type === "raster-dem").map(([k, _]) => k); return Object.entries(this.props.sources).filter(([_, v]) => v.type === "raster-dem").map(([k, _]) => k);
case "raster": case "raster":
return Object.entries(allSources).filter(([_, v]) => v.type === "raster").map(([k, _]) => k); return Object.entries(this.props.sources).filter(([_, v]) => v.type === "raster").map(([k, _]) => k);
case "heatmap": case "heatmap":
case "circle": case "circle":
case "fill": case "fill":
case "fill-extrusion": case "fill-extrusion":
case "line": case "line":
case "symbol": case "symbol":
return Object.entries(allSources).filter(([_, v]) => v.type === "vector" || v.type === "geojson").map(([k, _]) => k); return Object.entries(this.props.sources).filter(([_, v]) => v.type === "vector" || v.type === "geojson").map(([k, _]) => k);
default: default:
return []; return [];
} }
} }
const ModalAddInternal: React.FC<ModalAddInternalProps> = (props) => {
const [type, setType] = useState<LayerSpecification["type"]>("fill");
const [id, setId] = useState<string>("");
const [error, setError] = useState<string | null | undefined>(null);
const [source, setSource] = useState<string | undefined>(() => {
const sourceIds = Object.keys(props.sources);
return sourceIds.length > 0 ? sourceIds[0] : undefined;
});
const [sourceLayer, setSourceLayer] = useState<string | undefined>(() => {
const sourceIds = Object.keys(props.sources);
if (sourceIds.length === 0) return undefined;
const sourceLayers = props.sources[sourceIds[0]].layers || [];
return sourceLayers.length > 0 ? sourceLayers[0] : undefined;
});
// Mirrors `prevState.type` from the previous `componentDidUpdate`. render() {
const prevType = useRef<LayerSpecification["type"]>(type); const t = this.props.t;
const sources = this.getSources(this.state.type);
useEffect(() => { const layers = this.getLayersForSource(this.state.source!);
// Check if source is valid for new type
const oldType = prevType.current;
prevType.current = type;
// Only when the type has changed (also skips the initial mount)
if (oldType === type) return;
const availableSourcesOld = getSources(props.sources, oldType);
const availableSourcesNew = getSources(props.sources, type);
if(
source !== ""
// Was a valid source previously
&& availableSourcesOld.indexOf(source!) > -1
// And is not a valid source now
&& availableSourcesNew.indexOf(source!) < 0
) {
// Clear the source
setSource("");
}
}, [type, source, props.sources]);
const addLayer = () => {
if (props.layers.some(l => l.id === id)) {
setError(props.t("Layer ID already exists"));
return;
}
const changedLayers = props.layers.slice(0);
const layer: ModalAddState = {
id: id,
type: type,
};
if(type !== "background") {
layer.source = source;
if(!NON_SOURCE_LAYERS.includes(type) && sourceLayer) {
layer["source-layer"] = sourceLayer;
}
}
changedLayers.push(layer as LayerSpecification);
setError(null);
props.onLayersChange(changedLayers);
props.onOpenToggle();
};
const t = props.t;
const sources = getSources(props.sources, type);
const layers = getLayersForSource(props.sources, source!);
let errorElement; let errorElement;
if (error) { if (this.state.error) {
errorElement = ( errorElement = (
<div className="maputnik-modal-error"> <div className="maputnik-modal-error">
{error} {this.state.error}
<a <a
href="#" href="#"
onClick={() => setError(null)} onClick={() => this.setState({ error: null })}
className="maputnik-modal-error-close" className="maputnik-modal-error-close"
> >
× ×
@@ -142,8 +145,8 @@ const ModalAddInternal: React.FC<ModalAddInternalProps> = (props) => {
} }
return <Modal return <Modal
isOpen={props.isOpen} isOpen={this.props.isOpen}
onOpenToggle={props.onOpenToggle} onOpenToggle={this.props.onOpenToggle}
title={t("Add Layer")} title={t("Add Layer")}
data-wd-key="modal:add-layer" data-wd-key="modal:add-layer"
className="maputnik-add-modal" className="maputnik-add-modal"
@@ -151,42 +154,43 @@ const ModalAddInternal: React.FC<ModalAddInternalProps> = (props) => {
{errorElement} {errorElement}
<div className="maputnik-add-layer"> <div className="maputnik-add-layer">
<FieldId <FieldId
value={id} value={this.state.id}
wdKey="add-layer.layer-id" wdKey="add-layer.layer-id"
onChange={(v: string) => { onChange={(v: string) => {
setId(v); this.setState({ id: v, error: null });
setError(null);
}} }}
/> />
<FieldType <FieldType
value={type} value={this.state.type}
wdKey="add-layer.layer-type" wdKey="add-layer.layer-type"
onChange={(v: LayerSpecification["type"]) => setType(v)} onChange={(v: LayerSpecification["type"]) => this.setState({ type: v })}
/> />
{type !== "background" && {this.state.type !== "background" &&
<FieldSource <FieldSource
sourceIds={sources} sourceIds={sources}
wdKey="add-layer.layer-source-block" wdKey="add-layer.layer-source-block"
value={source} value={this.state.source}
onChange={(v: string) => setSource(v)} onChange={(v: string) => this.setState({ source: v })}
/> />
} }
{!NON_SOURCE_LAYERS.includes(type) && {!NON_SOURCE_LAYERS.includes(this.state.type) &&
<FieldSourceLayer <FieldSourceLayer
sourceLayerIds={layers} sourceLayerIds={layers}
value={sourceLayer} value={this.state["source-layer"]}
onChange={(v: string) => setSourceLayer(v)} onChange={(v: string) => this.setState({ "source-layer": v })}
/> />
} }
<InputButton <InputButton
className="maputnik-add-layer-button" className="maputnik-add-layer-button"
onClick={addLayer} onClick={this.addLayer}
data-wd-key="add-layer" data-wd-key="add-layer"
> >
{t("Add Layer")} {t("Add Layer")}
</InputButton> </InputButton>
</div> </div>
</Modal>; </Modal>;
}; }
}
export const ModalAdd = withTranslation()(ModalAddInternal); const ModalAdd = withTranslation()(ModalAddInternal);
export default ModalAdd;
+16 -13
View File
@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { Trans, type WithTranslation, withTranslation } from "react-i18next"; import { Trans, type WithTranslation, withTranslation } from "react-i18next";
import { Modal } from "./Modal"; import Modal from "./Modal";
type ModalDebugInternalProps = { type ModalDebugInternalProps = {
@@ -22,8 +22,9 @@ type ModalDebugInternalProps = {
} & WithTranslation; } & WithTranslation;
const ModalDebugInternal: React.FC<ModalDebugInternalProps> = (props) => { class ModalDebugInternal extends React.Component<ModalDebugInternalProps> {
const {t, mapView} = props; render() {
const {t, mapView} = this.props;
const osmZoom = Math.round(mapView.zoom)+1; const osmZoom = Math.round(mapView.zoom)+1;
const osmLon = +(mapView.center.lng).toFixed(5); const osmLon = +(mapView.center.lng).toFixed(5);
@@ -31,29 +32,29 @@ const ModalDebugInternal: React.FC<ModalDebugInternalProps> = (props) => {
return <Modal return <Modal
data-wd-key="modal:debug" data-wd-key="modal:debug"
isOpen={props.isOpen} isOpen={this.props.isOpen}
onOpenToggle={props.onOpenToggle} onOpenToggle={this.props.onOpenToggle}
title={t("Debug")} title={t("Debug")}
> >
<section className="maputnik-modal-section maputnik-modal-shortcuts"> <section className="maputnik-modal-section maputnik-modal-shortcuts">
<h1>{t("Options")}</h1> <h1>{t("Options")}</h1>
{props.renderer === "mlgljs" && {this.props.renderer === "mlgljs" &&
<ul> <ul>
{Object.entries(props.maplibreGlDebugOptions!).map(([key, val]) => { {Object.entries(this.props.maplibreGlDebugOptions!).map(([key, val]) => {
return <li key={key}> return <li key={key}>
<label> <label>
<input type="checkbox" checked={val} onChange={(e) => props.onChangeMaplibreGlDebug(key, e.target.checked)} /> {key} <input type="checkbox" checked={val} onChange={(e) => this.props.onChangeMaplibreGlDebug(key, e.target.checked)} /> {key}
</label> </label>
</li>; </li>;
})} })}
</ul> </ul>
} }
{props.renderer === "ol" && {this.props.renderer === "ol" &&
<ul> <ul>
{Object.entries(props.openlayersDebugOptions!).map(([key, val]) => { {Object.entries(this.props.openlayersDebugOptions!).map(([key, val]) => {
return <li key={key}> return <li key={key}>
<label> <label>
<input type="checkbox" checked={val} onChange={(e) => props.onChangeOpenlayersDebug(key, e.target.checked)} /> {key} <input type="checkbox" checked={val} onChange={(e) => this.props.onChangeOpenlayersDebug(key, e.target.checked)} /> {key}
</label> </label>
</li>; </li>;
})} })}
@@ -75,6 +76,8 @@ const ModalDebugInternal: React.FC<ModalDebugInternalProps> = (props) => {
</p> </p>
</section> </section>
</Modal>; </Modal>;
}; }
}
export const ModalDebug = withTranslation()(ModalDebugInternal); const ModalDebug = withTranslation()(ModalDebugInternal);
export default ModalDebug;
+83 -79
View File
@@ -6,11 +6,11 @@ import {format} from "@maplibre/maplibre-gl-style-spec";
import {MdMap, MdSave} from "react-icons/md"; import {MdMap, MdSave} from "react-icons/md";
import {type WithTranslation, withTranslation} from "react-i18next"; import {type WithTranslation, withTranslation} from "react-i18next";
import { FieldString } from "../FieldString"; import FieldString from "../FieldString";
import { InputButton } from "../InputButton"; import InputButton from "../InputButton";
import { Modal } from "./Modal"; import Modal from "./Modal";
import { replaceAccessTokens, stripAccessTokens } from "../../libs/style"; import style from "../../libs/style";
import { spec as fieldSpecAdditional } from "../../libs/field-spec-additional"; import fieldSpecAdditional from "../../libs/field-spec-additional";
import type {OnStyleChangedCallback, StyleSpecificationWithId} from "../../libs/definitions"; import type {OnStyleChangedCallback, StyleSpecificationWithId} from "../../libs/definitions";
@@ -28,31 +28,31 @@ type ModalExportInternalProps = {
} & WithTranslation; } & WithTranslation;
const ModalExportInternal: React.FC<ModalExportInternalProps> = (props) => { class ModalExportInternal extends React.Component<ModalExportInternalProps> {
function tokenizedStyle() { tokenizedStyle() {
return format( return format(
stripAccessTokens( style.stripAccessTokens(
replaceAccessTokens(props.mapStyle) style.replaceAccessTokens(this.props.mapStyle)
) )
); );
} }
function exportName() { exportName() {
if (props.mapStyle.name) { if (this.props.mapStyle.name) {
return Slugify(props.mapStyle.name, { return Slugify(this.props.mapStyle.name, {
replacement: "_", replacement: "_",
remove: /[*\-+~.()'"!:]/g, remove: /[*\-+~.()'"!:]/g,
lower: true lower: true
}); });
} else { } else {
return props.mapStyle.id; return this.props.mapStyle.id;
} }
} }
function createHtml() { createHtml() {
const tokenStyle = tokenizedStyle(); const tokenStyle = this.tokenizedStyle();
const htmlTitle = props.mapStyle.name || props.t("Map"); const htmlTitle = this.props.mapStyle.name || this.props.t("Map");
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html> <html>
<head> <head>
@@ -80,11 +80,49 @@ const ModalExportInternal: React.FC<ModalExportInternalProps> = (props) => {
`; `;
const blob = new Blob([html], {type: "text/html;charset=utf-8"}); const blob = new Blob([html], {type: "text/html;charset=utf-8"});
const name = exportName(); const exportName = this.exportName();
saveAs(blob, name + ".html"); saveAs(blob, exportName + ".html");
} }
async function createFileHandle(): Promise<FileSystemFileHandle | null> { async saveStyle() {
const tokenStyle = this.tokenizedStyle();
// it is not guaranteed that the File System Access API is available on all
// browsers. If the function is not available, a fallback behavior is used.
if (!showSaveFilePickerAvailable) {
const blob = new Blob([tokenStyle], {type: "application/json;charset=utf-8"});
const exportName = this.exportName();
saveAs(blob, exportName + ".json");
return;
}
let fileHandle = this.props.fileHandle;
if (fileHandle == null) {
fileHandle = await this.createFileHandle();
this.props.onSetFileHandle(fileHandle);
if (fileHandle == null) return;
}
const writable = await fileHandle.createWritable();
await writable.write(tokenStyle);
await writable.close();
this.props.onOpenToggle();
}
async saveStyleAs() {
const tokenStyle = this.tokenizedStyle();
const fileHandle = await this.createFileHandle();
this.props.onSetFileHandle(fileHandle);
if (fileHandle == null) return;
const writable = await fileHandle.createWritable();
await writable.write(tokenStyle);
await writable.close();
this.props.onOpenToggle();
}
async createFileHandle(): Promise<FileSystemFileHandle | null> {
const pickerOpts: SaveFilePickerOptions = { const pickerOpts: SaveFilePickerOptions = {
types: [ types: [
{ {
@@ -92,69 +130,33 @@ const ModalExportInternal: React.FC<ModalExportInternalProps> = (props) => {
accept: {"application/json": [".json"]}, accept: {"application/json": [".json"]},
}, },
], ],
suggestedName: exportName(), suggestedName: this.exportName(),
}; };
const fileHandle = await window.showSaveFilePicker(pickerOpts) as FileSystemFileHandle; const fileHandle = await window.showSaveFilePicker(pickerOpts) as FileSystemFileHandle;
props.onSetFileHandle(fileHandle); this.props.onSetFileHandle(fileHandle);
return fileHandle; return fileHandle;
} }
async function saveStyle() { changeMetadataProperty(property: string, value: any) {
const tokenStyle = tokenizedStyle();
// it is not guaranteed that the File System Access API is available on all
// browsers. If the function is not available, a fallback behavior is used.
if (!showSaveFilePickerAvailable) {
const blob = new Blob([tokenStyle], {type: "application/json;charset=utf-8"});
const name = exportName();
saveAs(blob, name + ".json");
return;
}
let fileHandle = props.fileHandle;
if (fileHandle == null) {
fileHandle = await createFileHandle();
props.onSetFileHandle(fileHandle);
if (fileHandle == null) return;
}
const writable = await fileHandle.createWritable();
await writable.write(tokenStyle);
await writable.close();
props.onOpenToggle();
}
async function saveStyleAs() {
const tokenStyle = tokenizedStyle();
const fileHandle = await createFileHandle();
props.onSetFileHandle(fileHandle);
if (fileHandle == null) return;
const writable = await fileHandle.createWritable();
await writable.write(tokenStyle);
await writable.close();
props.onOpenToggle();
}
function changeMetadataProperty(property: string, value: any) {
const changedStyle = { const changedStyle = {
...props.mapStyle, ...this.props.mapStyle,
metadata: { metadata: {
...props.mapStyle.metadata as any, ...this.props.mapStyle.metadata as any,
[property]: value [property]: value
} }
}; };
props.onStyleChanged(changedStyle); this.props.onStyleChanged(changedStyle);
} }
const t = props.t;
render() {
const t = this.props.t;
const fsa = fieldSpecAdditional(t); const fsa = fieldSpecAdditional(t);
return <Modal return <Modal
data-wd-key="modal:export" data-wd-key="modal:export"
isOpen={props.isOpen} isOpen={this.props.isOpen}
onOpenToggle={props.onOpenToggle} onOpenToggle={this.props.onOpenToggle}
title={t("Save Style")} title={t("Save Style")}
className="maputnik-export-modal" className="maputnik-export-modal"
> >
@@ -169,42 +171,42 @@ const ModalExportInternal: React.FC<ModalExportInternalProps> = (props) => {
<FieldString <FieldString
label={fsa.maputnik.maptiler_access_token.label} label={fsa.maputnik.maptiler_access_token.label}
fieldSpec={fsa.maputnik.maptiler_access_token} fieldSpec={fsa.maputnik.maptiler_access_token}
value={(props.mapStyle.metadata || {} as any)["maputnik:openmaptiles_access_token"]} value={(this.props.mapStyle.metadata || {} as any)["maputnik:openmaptiles_access_token"]}
onChange={changeMetadataProperty.bind(null, "maputnik:openmaptiles_access_token")} onChange={this.changeMetadataProperty.bind(this, "maputnik:openmaptiles_access_token")}
/> />
<FieldString <FieldString
label={fsa.maputnik.thunderforest_access_token.label} label={fsa.maputnik.thunderforest_access_token.label}
fieldSpec={fsa.maputnik.thunderforest_access_token} fieldSpec={fsa.maputnik.thunderforest_access_token}
value={(props.mapStyle.metadata || {} as any)["maputnik:thunderforest_access_token"]} value={(this.props.mapStyle.metadata || {} as any)["maputnik:thunderforest_access_token"]}
onChange={changeMetadataProperty.bind(null, "maputnik:thunderforest_access_token")} onChange={this.changeMetadataProperty.bind(this, "maputnik:thunderforest_access_token")}
/> />
<FieldString <FieldString
label={fsa.maputnik.stadia_access_token.label} label={fsa.maputnik.stadia_access_token.label}
fieldSpec={fsa.maputnik.stadia_access_token} fieldSpec={fsa.maputnik.stadia_access_token}
value={(props.mapStyle.metadata || {} as any)["maputnik:stadia_access_token"]} value={(this.props.mapStyle.metadata || {} as any)["maputnik:stadia_access_token"]}
onChange={changeMetadataProperty.bind(null, "maputnik:stadia_access_token")} onChange={this.changeMetadataProperty.bind(this, "maputnik:stadia_access_token")}
/> />
<FieldString <FieldString
label={fsa.maputnik.locationiq_access_token.label} label={fsa.maputnik.locationiq_access_token.label}
fieldSpec={fsa.maputnik.locationiq_access_token} fieldSpec={fsa.maputnik.locationiq_access_token}
value={(props.mapStyle.metadata || {} as any)["maputnik:locationiq_access_token"]} value={(this.props.mapStyle.metadata || {} as any)["maputnik:locationiq_access_token"]}
onChange={changeMetadataProperty.bind(null, "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">
<InputButton onClick={saveStyle}> <InputButton onClick={this.saveStyle.bind(this)}>
<MdSave/> <MdSave/>
{t("Save")} {t("Save")}
</InputButton> </InputButton>
{showSaveFilePickerAvailable && ( {showSaveFilePickerAvailable && (
<InputButton onClick={saveStyleAs}> <InputButton onClick={this.saveStyleAs.bind(this)}>
<MdSave/> <MdSave/>
{t("Save as")} {t("Save as")}
</InputButton> </InputButton>
)} )}
<InputButton onClick={createHtml}> <InputButton onClick={this.createHtml.bind(this)}>
<MdMap/> <MdMap/>
{t("Create HTML")} {t("Create HTML")}
</InputButton> </InputButton>
@@ -212,6 +214,8 @@ const ModalExportInternal: React.FC<ModalExportInternalProps> = (props) => {
</section> </section>
</Modal>; </Modal>;
}; }
}
export const ModalExport = withTranslation()(ModalExportInternal); const ModalExport = withTranslation()(ModalExportInternal);
export default ModalExport;
+6 -5
View File
@@ -3,13 +3,13 @@ import { withTranslation, type WithTranslation } from "react-i18next";
import { MdDelete } from "react-icons/md"; import { 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 { Modal } from "./Modal"; import Modal from "./Modal";
import { FieldString } from "../FieldString"; import FieldString from "../FieldString";
import { InputButton } from "../InputButton"; import InputButton from "../InputButton";
import { PiListPlusBold } from "react-icons/pi"; import { PiListPlusBold } from "react-icons/pi";
import { type StyleSpecificationWithId } from "../../libs/definitions"; import { type StyleSpecificationWithId } from "../../libs/definitions";
import { type SchemaSpecification } from "maplibre-gl"; import { type SchemaSpecification } from "maplibre-gl";
import { Doc } from "../Doc"; import Doc from "../Doc";
type ModalGlobalStateInternalProps = { type ModalGlobalStateInternalProps = {
mapStyle: StyleSpecificationWithId; mapStyle: StyleSpecificationWithId;
@@ -151,4 +151,5 @@ const ModalGlobalStateInternal: React.FC<ModalGlobalStateInternalProps> = (props
); );
}; };
export const ModalGlobalState = withTranslation()(ModalGlobalStateInternal); const ModalGlobalState = withTranslation()(ModalGlobalStateInternal);
export default ModalGlobalState;
+14 -11
View File
@@ -1,8 +1,8 @@
import React from "react"; import React from "react";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import { InputButton } from "../InputButton"; import InputButton from "../InputButton";
import { Modal } from "./Modal"; import Modal from "./Modal";
type ModalLoadingInternalProps = { type ModalLoadingInternalProps = {
@@ -13,24 +13,27 @@ type ModalLoadingInternalProps = {
} & WithTranslation; } & WithTranslation;
const ModalLoadingInternal: React.FC<ModalLoadingInternalProps> = (props) => { class ModalLoadingInternal extends React.Component<ModalLoadingInternalProps> {
const t = props.t; render() {
const t = this.props.t;
return <Modal return <Modal
data-wd-key="modal:loading" data-wd-key="modal:loading"
isOpen={props.isOpen} isOpen={this.props.isOpen}
underlayClickExits={false} underlayClickExits={false}
title={props.title} title={this.props.title}
onOpenToggle={() => props.onCancel()} onOpenToggle={() => this.props.onCancel()}
> >
<p> <p>
{props.message} {this.props.message}
</p> </p>
<p className="maputnik-dialog__buttons"> <p className="maputnik-dialog__buttons">
<InputButton onClick={(e) => props.onCancel(e)}> <InputButton onClick={(e) => this.props.onCancel(e)}>
{t("Cancel")} {t("Cancel")}
</InputButton> </InputButton>
</p> </p>
</Modal>; </Modal>;
}; }
}
export const ModalLoading = withTranslation()(ModalLoadingInternal); const ModalLoading = withTranslation()(ModalLoadingInternal);
export default ModalLoading;
+132 -99
View File
@@ -1,14 +1,14 @@
import React, { type DragEvent, type FormEvent, useRef, useState } from "react"; import React, { type DragEvent, type FormEvent } from "react";
import { MdFileUpload } from "react-icons/md"; import { MdFileUpload } from "react-icons/md";
import { MdAddCircleOutline } from "react-icons/md"; import { MdAddCircleOutline } from "react-icons/md";
import { Trans, type WithTranslation, withTranslation } from "react-i18next"; import { Trans, type WithTranslation, withTranslation } from "react-i18next";
import { ModalLoading } from "./ModalLoading"; import ModalLoading from "./ModalLoading";
import { Modal } from "./Modal"; import Modal from "./Modal";
import { InputButton } from "../InputButton"; import InputButton from "../InputButton";
import { InputUrl } from "../InputUrl"; import InputUrl from "../InputUrl";
import { ensureStyleValidity } from "../../libs/style"; import style from "../../libs/style";
import publicStyles from "../../config/styles.json"; import publicStyles from "../../config/styles.json";
type PublicStyleProps = { type PublicStyleProps = {
@@ -18,27 +18,29 @@ type PublicStyleProps = {
onSelect(...args: unknown[]): unknown onSelect(...args: unknown[]): unknown
}; };
const PublicStyle: React.FC<PublicStyleProps> = (props) => { class PublicStyle extends React.Component<PublicStyleProps> {
render() {
return <div className="maputnik-public-style"> return <div className="maputnik-public-style">
<InputButton <InputButton
className="maputnik-public-style-button" className="maputnik-public-style-button"
aria-label={props.title} aria-label={this.props.title}
onClick={() => props.onSelect(props.url)} onClick={() => this.props.onSelect(this.props.url)}
> >
<div className="maputnik-public-style-header"> <div className="maputnik-public-style-header">
<div>{props.title}</div> <div>{this.props.title}</div>
<span className="maputnik-space" /> <span className="maputnik-space" />
<MdAddCircleOutline /> <MdAddCircleOutline />
</div> </div>
<div <div
className="maputnik-public-style-thumbnail" className="maputnik-public-style-thumbnail"
style={{ style={{
backgroundImage: `url(${props.thumbnailUrl})` backgroundImage: `url(${this.props.thumbnailUrl})`
}} }}
></div> ></div>
</InputButton> </InputButton>
</div>; </div>;
}; }
}
type ModalOpenInternalProps = { type ModalOpenInternalProps = {
isOpen: boolean isOpen: boolean
@@ -47,32 +49,46 @@ type ModalOpenInternalProps = {
fileHandle: FileSystemFileHandle | null fileHandle: FileSystemFileHandle | null
} & WithTranslation; } & WithTranslation;
const ModalOpenInternal: React.FC<ModalOpenInternalProps> = (props) => { type ModalOpenState = {
const fileInputRef = useRef<HTMLInputElement>(null); styleUrl: string
isDragOver: boolean
const [styleUrl, setStyleUrl] = useState<string>(""); error?: string | null
const [isDragOver, setIsDragOver] = useState<boolean>(false); activeRequest?: any
const [error, setError] = useState<string | null | undefined>(undefined); activeRequestUrl?: string | null
const [activeRequest, setActiveRequest] = useState<any>(undefined);
const [activeRequestUrl, setActiveRequestUrl] = useState<string | null | undefined>(undefined);
const clearError = () => {
setError(null);
}; };
const onCancelActiveRequest = (e: Event) => { class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpenState> {
private fileInputRef = React.createRef<HTMLInputElement>();
constructor(props: ModalOpenInternalProps) {
super(props);
this.state = {
styleUrl: "",
isDragOver: false,
};
}
clearError() {
this.setState({
error: null
});
}
onCancelActiveRequest(e: Event) {
// Else the click propagates to the underlying modal // Else the click propagates to the underlying modal
if (e) e.stopPropagation(); if (e) e.stopPropagation();
if (activeRequest) { if (this.state.activeRequest) {
activeRequest.abort(); this.state.activeRequest.abort();
setActiveRequest(null); this.setState({
setActiveRequestUrl(null); activeRequest: null,
activeRequestUrl: null
});
}
} }
};
const onStyleSelect = (styleUrl: string) => { onStyleSelect = (styleUrl: string) => {
clearError(); this.clearError();
let canceled: boolean = false; let canceled: boolean = false;
@@ -88,37 +104,43 @@ const ModalOpenInternal: React.FC<ModalOpenInternalProps> = (props) => {
return; return;
} }
setActiveRequest(null); this.setState({
setActiveRequestUrl(null); activeRequest: null,
activeRequestUrl: null
});
const mapStyle = ensureStyleValidity(body); const mapStyle = style.ensureStyleValidity(body);
console.log("Loaded style ", mapStyle.id); console.log("Loaded style ", mapStyle.id);
props.onStyleOpen(mapStyle); this.props.onStyleOpen(mapStyle);
onOpenToggle(); this.onOpenToggle();
}) })
.catch((err) => { .catch((err) => {
setError(`Failed to load: '${styleUrl}'`); this.setState({
setActiveRequest(null); error: `Failed to load: '${styleUrl}'`,
setActiveRequestUrl(null); activeRequest: null,
activeRequestUrl: null
});
console.error(err); console.error(err);
console.warn("Could not open the style URL", styleUrl); console.warn("Could not open the style URL", styleUrl);
}); });
setActiveRequest({ this.setState({
activeRequest: {
abort: function () { abort: function () {
canceled = true; canceled = true;
} }
},
activeRequestUrl: styleUrl
}); });
setActiveRequestUrl(styleUrl);
}; };
const onSubmitUrl = (e: FormEvent<HTMLFormElement>) => { onSubmitUrl = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
onStyleSelect(styleUrl); this.onStyleSelect(this.state.styleUrl);
}; };
const onOpenFile = async () => { onOpenFile = async () => {
clearError(); this.clearError();
const pickerOpts: OpenFilePickerOptions = { const pickerOpts: OpenFilePickerOptions = {
types: [ types: [
@@ -138,24 +160,26 @@ const ModalOpenInternal: React.FC<ModalOpenInternalProps> = (props) => {
try { try {
mapStyle = JSON.parse(content); mapStyle = JSON.parse(content);
} catch (err) { } catch (err) {
setError((err as Error).toString()); this.setState({
error: (err as Error).toString()
});
return; return;
} }
mapStyle = ensureStyleValidity(mapStyle); mapStyle = style.ensureStyleValidity(mapStyle);
props.onStyleOpen(mapStyle, fileHandle); this.props.onStyleOpen(mapStyle, fileHandle);
onOpenToggle(); this.onOpenToggle();
return file; return file;
}; };
// it is not guaranteed that the File System Access API is available on all // it is not guaranteed that the File System Access API is available on all
// browsers. If the function is not available, a fallback behavior is used. // browsers. If the function is not available, a fallback behavior is used.
const onFileChanged = (files: FileList | null) => { onFileChanged = (files: FileList | null) => {
if (!files) return; if (!files) return;
if (files.length === 0) return; if (files.length === 0) return;
const file = files[0]; const file = files[0];
const reader = new FileReader(); const reader = new FileReader();
clearError(); this.clearError();
reader.readAsText(file, "UTF-8"); reader.readAsText(file, "UTF-8");
reader.onload = e => { reader.onload = e => {
@@ -164,76 +188,83 @@ const ModalOpenInternal: React.FC<ModalOpenInternalProps> = (props) => {
mapStyle = JSON.parse(e.target?.result as string); mapStyle = JSON.parse(e.target?.result as string);
} }
catch (err) { catch (err) {
setError((err as Error).toString()); this.setState({
error: (err as Error).toString()
});
return; return;
} }
mapStyle = ensureStyleValidity(mapStyle); mapStyle = style.ensureStyleValidity(mapStyle);
props.onStyleOpen(mapStyle); this.props.onStyleOpen(mapStyle);
onOpenToggle(); this.onOpenToggle();
}; };
reader.onerror = e => console.log(e.target); reader.onerror = e => console.log(e.target);
}; };
const onOpenToggle = () => { onOpenToggle() {
setStyleUrl(""); this.setState({
setIsDragOver(false); styleUrl: "",
clearError(); isDragOver: false,
props.onOpenToggle(); });
}; this.clearError();
this.props.onOpenToggle();
}
const onBrowseClick = async () => { onBrowseClick = async () => {
if (typeof window.showOpenFilePicker === "function") { if (typeof window.showOpenFilePicker === "function") {
await onOpenFile(); await this.onOpenFile();
return; return;
} }
fileInputRef.current?.click(); this.fileInputRef.current?.click();
}; };
const onFileDragOver = (e: DragEvent<HTMLDivElement>) => { onFileDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (!isDragOver) { if (!this.state.isDragOver) {
setIsDragOver(true); this.setState({ isDragOver: true });
} }
}; };
const onFileDragLeave = (e: DragEvent<HTMLDivElement>) => { onFileDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragOver(false); this.setState({ isDragOver: false });
}; };
const onFileDrop = (e: DragEvent<HTMLDivElement>) => { onFileDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragOver(false); this.setState({ isDragOver: false });
onFileChanged(e.dataTransfer.files); this.onFileChanged(e.dataTransfer.files);
}; };
const onChangeUrl = (url: string) => { onChangeUrl = (url: string) => {
setStyleUrl(url); this.setState({
styleUrl: url,
});
}; };
const t = props.t; render() {
const t = this.props.t;
const styleOptions = publicStyles.map(style => { const styleOptions = publicStyles.map(style => {
return <PublicStyle return <PublicStyle
key={style.id} key={style.id}
url={style.url} url={style.url}
title={style.title} title={style.title}
thumbnailUrl={style.thumbnail} thumbnailUrl={style.thumbnail}
onSelect={onStyleSelect} onSelect={this.onStyleSelect}
/>; />;
}); });
let errorElement; let errorElement;
if (error) { if (this.state.error) {
errorElement = ( errorElement = (
<div className="maputnik-modal-error"> <div className="maputnik-modal-error">
{error} {this.state.error}
<a href="#" onClick={() => clearError()} className="maputnik-modal-error-close">×</a> <a href="#" onClick={() => this.clearError()} className="maputnik-modal-error-close">×</a>
</div> </div>
); );
} }
@@ -242,8 +273,8 @@ const ModalOpenInternal: React.FC<ModalOpenInternalProps> = (props) => {
<div> <div>
<Modal <Modal
data-wd-key="modal:open" data-wd-key="modal:open"
isOpen={props.isOpen} isOpen={this.props.isOpen}
onOpenToggle={() => onOpenToggle()} onOpenToggle={() => this.onOpenToggle()}
title={t("Open Style")} title={t("Open Style")}
> >
{errorElement} {errorElement}
@@ -252,17 +283,17 @@ const ModalOpenInternal: React.FC<ModalOpenInternalProps> = (props) => {
<p>{t("Open a local JSON style from your computer.")}</p> <p>{t("Open a local JSON style from your computer.")}</p>
<div <div
data-wd-key="modal:open.dropzone" data-wd-key="modal:open.dropzone"
className={`maputnik-upload-dropzone${isDragOver ? " maputnik-upload-dropzone--active" : ""}`} className={`maputnik-upload-dropzone${this.state.isDragOver ? " maputnik-upload-dropzone--active" : ""}`}
role="button" role="button"
tabIndex={0} tabIndex={0}
onDragOver={onFileDragOver} onDragOver={this.onFileDragOver}
onDragLeave={onFileDragLeave} onDragLeave={this.onFileDragLeave}
onDrop={onFileDrop} onDrop={this.onFileDrop}
onClick={() => void onBrowseClick()} onClick={() => void this.onBrowseClick()}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
void onBrowseClick(); void this.onBrowseClick();
} }
}} }}
> >
@@ -273,17 +304,17 @@ const ModalOpenInternal: React.FC<ModalOpenInternalProps> = (props) => {
</p> </p>
</div> </div>
<input <input
ref={fileInputRef} ref={this.fileInputRef}
data-wd-key="modal:open.file.input" data-wd-key="modal:open.file.input"
type="file" type="file"
style={{ display: "none" }} style={{ display: "none" }}
onChange={(e) => onFileChanged(e.target.files)} onChange={(e) => this.onFileChanged(e.target.files)}
/> />
</div> </div>
</section> </section>
<section className="maputnik-modal-section"> <section className="maputnik-modal-section">
<form onSubmit={onSubmitUrl}> <form onSubmit={this.onSubmitUrl}>
<h1>{t("Load from URL")}</h1> <h1>{t("Load from URL")}</h1>
<p> <p>
<Trans t={t}> <Trans t={t}>
@@ -296,16 +327,16 @@ const ModalOpenInternal: React.FC<ModalOpenInternalProps> = (props) => {
type="text" type="text"
className="maputnik-input" className="maputnik-input"
default={t("Enter URL...")} default={t("Enter URL...")}
value={styleUrl} value={this.state.styleUrl}
onInput={onChangeUrl} onInput={this.onChangeUrl}
onChange={onChangeUrl} onChange={this.onChangeUrl}
/> />
<div> <div>
<InputButton <InputButton
data-wd-key="modal:open.url.button" data-wd-key="modal:open.url.button"
type="submit" type="submit"
className="maputnik-big-button" className="maputnik-big-button"
disabled={styleUrl.length < 1} disabled={this.state.styleUrl.length < 1}
>Load from URL</InputButton> >Load from URL</InputButton>
</div> </div>
</form> </form>
@@ -323,13 +354,15 @@ const ModalOpenInternal: React.FC<ModalOpenInternalProps> = (props) => {
</Modal> </Modal>
<ModalLoading <ModalLoading
isOpen={!!activeRequest} isOpen={!!this.state.activeRequest}
title={t("Loading style")} title={t("Loading style")}
onCancel={(e: Event) => onCancelActiveRequest(e)} onCancel={(e: Event) => this.onCancelActiveRequest(e)}
message={t("Loading") + ": " + activeRequestUrl} message={t("Loading") + ": " + this.state.activeRequestUrl}
/> />
</div> </div>
); );
}; }
}
export const ModalOpen = withTranslation()(ModalOpenInternal); const ModalOpen = withTranslation()(ModalOpenInternal);
export default ModalOpen;
+65 -69
View File
@@ -3,17 +3,17 @@ import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import type {LightSpecification, ProjectionSpecification, StyleSpecification, TerrainSpecification, TransitionSpecification} from "maplibre-gl"; import type {LightSpecification, ProjectionSpecification, StyleSpecification, TerrainSpecification, TransitionSpecification} from "maplibre-gl";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import { FieldArray } from "../FieldArray"; import FieldArray from "../FieldArray";
import { FieldNumber } from "../FieldNumber"; import FieldNumber from "../FieldNumber";
import { FieldString } from "../FieldString"; import FieldString from "../FieldString";
import { FieldUrl } from "../FieldUrl"; import FieldUrl from "../FieldUrl";
import { FieldSelect } from "../FieldSelect"; import FieldSelect from "../FieldSelect";
import { FieldEnum } from "../FieldEnum"; import FieldEnum from "../FieldEnum";
import { FieldColor } from "../FieldColor"; import FieldColor from "../FieldColor";
import { Modal } from "./Modal"; import Modal from "./Modal";
import { FieldJson } from "../FieldJson"; import FieldJson from "../FieldJson";
import { Block } from "../Block"; import Block from "../Block";
import { spec as fieldSpecAdditional } from "../../libs/field-spec-additional"; import fieldSpecAdditional from "../../libs/field-spec-additional";
import type {OnStyleChangedCallback, StyleSpecificationWithId} from "../../libs/definitions"; import type {OnStyleChangedCallback, StyleSpecificationWithId} from "../../libs/definitions";
type ModalSettingsInternalProps = { type ModalSettingsInternalProps = {
@@ -24,10 +24,10 @@ type ModalSettingsInternalProps = {
onOpenToggle(): void onOpenToggle(): void
} & WithTranslation; } & WithTranslation;
const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => { class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps> {
function changeTransitionProperty(property: keyof TransitionSpecification, value: number | undefined) { changeTransitionProperty(property: keyof TransitionSpecification, value: number | undefined) {
const transition = { const transition = {
...props.mapStyle.transition, ...this.props.mapStyle.transition,
}; };
if (value === undefined) { if (value === undefined) {
@@ -37,15 +37,15 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
transition[property] = value; transition[property] = value;
} }
props.onStyleChanged({ this.props.onStyleChanged({
...props.mapStyle, ...this.props.mapStyle,
transition, transition,
}); });
} }
function changeLightProperty(property: keyof LightSpecification, value: any) { changeLightProperty(property: keyof LightSpecification, value: any) {
const light = { const light = {
...props.mapStyle.light, ...this.props.mapStyle.light,
}; };
if (value === undefined) { if (value === undefined) {
@@ -56,15 +56,15 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
light[property] = value; light[property] = value;
} }
props.onStyleChanged({ this.props.onStyleChanged({
...props.mapStyle, ...this.props.mapStyle,
light, light,
}); });
} }
function changeTerrainProperty(property: keyof TerrainSpecification, value: any) { changeTerrainProperty(property: keyof TerrainSpecification, value: any) {
const terrain = { const terrain = {
...props.mapStyle.terrain, ...this.props.mapStyle.terrain,
} as TerrainSpecification; } as TerrainSpecification;
if (value === undefined) { if (value === undefined) {
@@ -75,15 +75,15 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
terrain[property] = value; terrain[property] = value;
} }
props.onStyleChanged({ this.props.onStyleChanged({
...props.mapStyle, ...this.props.mapStyle,
terrain, terrain,
}); });
} }
function changeProjectionType(value: any) { changeProjectionType(value: any) {
const projection = { const projection = {
...props.mapStyle.projection, ...this.props.mapStyle.projection,
} as ProjectionSpecification; } as ProjectionSpecification;
if (value === undefined) { if (value === undefined) {
@@ -93,15 +93,15 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
projection.type = value; projection.type = value;
} }
props.onStyleChanged({ this.props.onStyleChanged({
...props.mapStyle, ...this.props.mapStyle,
projection, projection,
}); });
} }
function changeStyleProperty(property: keyof StyleSpecification | "owner", value: any) { changeStyleProperty(property: keyof StyleSpecification | "owner", value: any) {
const changedStyle = { const changedStyle = {
...props.mapStyle, ...this.props.mapStyle,
}; };
if (value === undefined) { if (value === undefined) {
@@ -112,22 +112,23 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
// @ts-ignore // @ts-ignore
changedStyle[property] = value; changedStyle[property] = value;
} }
props.onStyleChanged(changedStyle); this.props.onStyleChanged(changedStyle);
} }
const metadata = props.mapStyle.metadata || {} as any; render() {
const {t, onChangeMetadataProperty, mapStyle} = props; const metadata = this.props.mapStyle.metadata || {} as any;
const {t, onChangeMetadataProperty, mapStyle} = this.props;
const fsa = fieldSpecAdditional(t); const fsa = fieldSpecAdditional(t);
const light = props.mapStyle.light || {}; const light = this.props.mapStyle.light || {};
const transition = props.mapStyle.transition || {}; const transition = this.props.mapStyle.transition || {};
const terrain = props.mapStyle.terrain || {} as TerrainSpecification; const terrain = this.props.mapStyle.terrain || {} as TerrainSpecification;
const projection = props.mapStyle.projection || {} as ProjectionSpecification; const projection = this.props.mapStyle.projection || {} as ProjectionSpecification;
return <Modal return <Modal
data-wd-key="modal:settings" data-wd-key="modal:settings"
isOpen={props.isOpen} isOpen={this.props.isOpen}
onOpenToggle={props.onOpenToggle} onOpenToggle={this.props.onOpenToggle}
title={t("Style Settings")} title={t("Style Settings")}
> >
<div className="modal:settings"> <div className="modal:settings">
@@ -135,21 +136,21 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
label={t("Name")} label={t("Name")}
fieldSpec={latest.$root.name} fieldSpec={latest.$root.name}
data-wd-key="modal:settings.name" data-wd-key="modal:settings.name"
value={props.mapStyle.name} value={this.props.mapStyle.name}
onChange={(value) => changeStyleProperty("name", value)} onChange={(value) => this.changeStyleProperty("name", value)}
/> />
<FieldString <FieldString
label={t("Owner")} label={t("Owner")}
fieldSpec={{doc: t("Owner ID of the style. Used by Mapbox or future style APIs.")}} fieldSpec={{doc: t("Owner ID of the style. Used by Mapbox or future style APIs.")}}
data-wd-key="modal:settings.owner" data-wd-key="modal:settings.owner"
value={(props.mapStyle as any).owner} value={(this.props.mapStyle as any).owner}
onChange={(value) => changeStyleProperty("owner", value)} onChange={(value) => this.changeStyleProperty("owner", value)}
/> />
<Block label={t("Sprite URL")} fieldSpec={latest.$root.sprite} data-wd-key="modal:settings.sprite"> <Block label={t("Sprite URL")} fieldSpec={latest.$root.sprite} data-wd-key="modal:settings.sprite">
<FieldJson <FieldJson
lintType="json" lintType="json"
value={props.mapStyle.sprite as any} value={this.props.mapStyle.sprite as any}
onChange={(value) => changeStyleProperty("sprite", value)} onChange={(value) => this.changeStyleProperty("sprite", value)}
/> />
</Block> </Block>
@@ -157,8 +158,8 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
label={t("Glyphs URL")} label={t("Glyphs URL")}
fieldSpec={latest.$root.glyphs} fieldSpec={latest.$root.glyphs}
data-wd-key="modal:settings.glyphs" data-wd-key="modal:settings.glyphs"
value={props.mapStyle.glyphs as string} value={this.props.mapStyle.glyphs as string}
onChange={(value) => changeStyleProperty("glyphs", value)} onChange={(value) => this.changeStyleProperty("glyphs", value)}
/> />
<FieldString <FieldString
@@ -200,34 +201,31 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
type="number" type="number"
value={mapStyle.center || []} value={mapStyle.center || []}
default={[0, 0]} default={[0, 0]}
onChange={(value) => changeStyleProperty("center", value)} onChange={(value) => this.changeStyleProperty("center", value)}
/> />
<FieldNumber <FieldNumber
label={t("Zoom")} label={t("Zoom")}
data-wd-key="modal:settings.zoom"
fieldSpec={latest.$root.zoom} fieldSpec={latest.$root.zoom}
value={mapStyle.zoom} value={mapStyle.zoom}
default={0} default={0}
onChange={(value) => changeStyleProperty("zoom", value)} onChange={(value) => this.changeStyleProperty("zoom", value)}
/> />
<FieldNumber <FieldNumber
label={t("Bearing")} label={t("Bearing")}
data-wd-key="modal:settings.bearing"
fieldSpec={latest.$root.bearing} fieldSpec={latest.$root.bearing}
value={mapStyle.bearing} value={mapStyle.bearing}
default={latest.$root.bearing.default} default={latest.$root.bearing.default}
onChange={(value) => changeStyleProperty("bearing", value)} onChange={(value) => this.changeStyleProperty("bearing", value)}
/> />
<FieldNumber <FieldNumber
label={t("Pitch")} label={t("Pitch")}
data-wd-key="modal:settings.pitch"
fieldSpec={latest.$root.pitch} fieldSpec={latest.$root.pitch}
value={mapStyle.pitch} value={mapStyle.pitch}
default={latest.$root.pitch.default} default={latest.$root.pitch.default}
onChange={(value) => changeStyleProperty("pitch", value)} onChange={(value) => this.changeStyleProperty("pitch", value)}
/> />
<FieldEnum <FieldEnum
@@ -237,7 +235,7 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
value={light.anchor as string} value={light.anchor as string}
options={Object.keys(latest.light.anchor.values)} options={Object.keys(latest.light.anchor.values)}
default={latest.light.anchor.default} default={latest.light.anchor.default}
onChange={(value) => changeLightProperty("anchor", value)} onChange={(value) => this.changeLightProperty("anchor", value)}
/> />
<FieldColor <FieldColor
@@ -245,16 +243,15 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
fieldSpec={latest.light.color} fieldSpec={latest.light.color}
value={light.color as string} value={light.color as string}
default={latest.light.color.default} default={latest.light.color.default}
onChange={(value) => changeLightProperty("color", value)} onChange={(value) => this.changeLightProperty("color", value)}
/> />
<FieldNumber <FieldNumber
label={t("Light intensity")} label={t("Light intensity")}
data-wd-key="modal:settings.light-intensity"
fieldSpec={latest.light.intensity} fieldSpec={latest.light.intensity}
value={light.intensity as number} value={light.intensity as number}
default={latest.light.intensity.default} default={latest.light.intensity.default}
onChange={(value) => changeLightProperty("intensity", value)} onChange={(value) => this.changeLightProperty("intensity", value)}
/> />
<FieldArray <FieldArray
@@ -264,7 +261,7 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
length={latest.light.position.length} length={latest.light.position.length}
value={light.position as number[]} value={light.position as number[]}
default={latest.light.position.default} default={latest.light.position.default}
onChange={(value) => changeLightProperty("position", value)} onChange={(value) => this.changeLightProperty("position", value)}
/> />
<FieldString <FieldString
@@ -272,34 +269,31 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
fieldSpec={latest.terrain.source} fieldSpec={latest.terrain.source}
data-wd-key="modal:settings.maputnik:terrain_source" data-wd-key="modal:settings.maputnik:terrain_source"
value={terrain.source} value={terrain.source}
onChange={(value) => changeTerrainProperty("source", value)} onChange={(value) => this.changeTerrainProperty("source", value)}
/> />
<FieldNumber <FieldNumber
label={t("Terrain exaggeration")} label={t("Terrain exaggeration")}
data-wd-key="modal:settings.terrain-exaggeration"
fieldSpec={latest.terrain.exaggeration} fieldSpec={latest.terrain.exaggeration}
value={terrain.exaggeration} value={terrain.exaggeration}
default={latest.terrain.exaggeration.default} default={latest.terrain.exaggeration.default}
onChange={(value) => changeTerrainProperty("exaggeration", value)} onChange={(value) => this.changeTerrainProperty("exaggeration", value)}
/> />
<FieldNumber <FieldNumber
label={t("Transition delay")} label={t("Transition delay")}
data-wd-key="modal:settings.transition-delay"
fieldSpec={latest.transition.delay} fieldSpec={latest.transition.delay}
value={transition.delay} value={transition.delay}
default={latest.transition.delay.default} default={latest.transition.delay.default}
onChange={(value) => changeTransitionProperty("delay", value)} onChange={(value) => this.changeTransitionProperty("delay", value)}
/> />
<FieldNumber <FieldNumber
label={t("Transition duration")} label={t("Transition duration")}
data-wd-key="modal:settings.transition-duration"
fieldSpec={latest.transition.duration} fieldSpec={latest.transition.duration}
value={transition.duration} value={transition.duration}
default={latest.transition.duration.default} default={latest.transition.duration.default}
onChange={(value) => changeTransitionProperty("duration", value)} onChange={(value) => this.changeTransitionProperty("duration", value)}
/> />
<FieldSelect <FieldSelect
@@ -312,7 +306,7 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
["vertical-perspective", "Vertical Perspective"] ["vertical-perspective", "Vertical Perspective"]
]} ]}
value={projection?.type?.toString() || ""} value={projection?.type?.toString() || ""}
onChange={(value) => changeProjectionType(value)} onChange={(value) => this.changeProjectionType(value)}
/> />
<FieldSelect <FieldSelect
@@ -328,6 +322,8 @@ const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
/> />
</div> </div>
</Modal>; </Modal>;
}; }
}
export const ModalSettings = withTranslation()(ModalSettingsInternal); const ModalSettings = withTranslation()(ModalSettingsInternal);
export default ModalSettings;
+10 -7
View File
@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { Trans, type WithTranslation, withTranslation } from "react-i18next"; import { Trans, type WithTranslation, withTranslation } from "react-i18next";
import { Modal } from "./Modal"; import Modal from "./Modal";
type ModalShortcutsInternalProps = { type ModalShortcutsInternalProps = {
@@ -10,8 +10,9 @@ type ModalShortcutsInternalProps = {
} & WithTranslation; } & WithTranslation;
const ModalShortcutsInternal: React.FC<ModalShortcutsInternalProps> = (props) => { class ModalShortcutsInternal extends React.Component<ModalShortcutsInternalProps> {
const t = props.t; render() {
const t = this.props.t;
const help = [ const help = [
{ {
key: <kbd>?</kbd>, key: <kbd>?</kbd>,
@@ -102,8 +103,8 @@ const ModalShortcutsInternal: React.FC<ModalShortcutsInternalProps> = (props) =>
return <Modal return <Modal
data-wd-key="modal:shortcuts" data-wd-key="modal:shortcuts"
isOpen={props.isOpen} isOpen={this.props.isOpen}
onOpenToggle={props.onOpenToggle} onOpenToggle={this.props.onOpenToggle}
title={t("Shortcuts")} title={t("Shortcuts")}
> >
<section className="maputnik-modal-section maputnik-modal-shortcuts"> <section className="maputnik-modal-section maputnik-modal-shortcuts">
@@ -130,6 +131,8 @@ const ModalShortcutsInternal: React.FC<ModalShortcutsInternalProps> = (props) =>
</ul> </ul>
</section> </section>
</Modal>; </Modal>;
}; }
}
export const ModalShortcuts = withTranslation()(ModalShortcutsInternal); const ModalShortcuts = withTranslation()(ModalShortcutsInternal);
export default ModalShortcuts;
+75 -57
View File
@@ -1,16 +1,16 @@
import React, { useState } 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, VectorSourceSpecification} from "maplibre-gl";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import { Modal } from "./Modal"; import Modal from "./Modal";
import { InputButton } from "../InputButton"; import InputButton from "../InputButton";
import { FieldString } from "../FieldString"; import FieldString from "../FieldString";
import { FieldSelect } from "../FieldSelect"; import FieldSelect from "../FieldSelect";
import { ModalSourcesTypeEditor, type EditorMode } from "./ModalSourcesTypeEditor"; import ModalSourcesTypeEditor, { type EditorMode } from "./ModalSourcesTypeEditor";
import { generateId } 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 { type OnStyleChangedCallback, type StyleSpecificationWithId } from "../../libs/definitions"; import { type OnStyleChangedCallback, type StyleSpecificationWithId } from "../../libs/definitions";
@@ -23,21 +23,23 @@ type PublicSourceProps = {
onSelect(...args: unknown[]): unknown onSelect(...args: unknown[]): unknown
}; };
const PublicSource: React.FC<PublicSourceProps> = (props) => { class PublicSource extends React.Component<PublicSourceProps> {
render() {
return <div className="maputnik-public-source"> return <div className="maputnik-public-source">
<InputButton <InputButton
className="maputnik-public-source-select" className="maputnik-public-source-select"
onClick={() => props.onSelect(props.id)} onClick={() => this.props.onSelect(this.props.id)}
> >
<div className="maputnik-public-source-info"> <div className="maputnik-public-source-info">
<p className="maputnik-public-source-name">{props.title}</p> <p className="maputnik-public-source-name">{this.props.title}</p>
<p className="maputnik-public-source-id">#{props.id}</p> <p className="maputnik-public-source-id">#{this.props.id}</p>
</div> </div>
<span className="maputnik-space" /> <span className="maputnik-space" />
<MdAddCircleOutline /> <MdAddCircleOutline />
</InputButton> </InputButton>
</div>; </div>;
}; }
}
function editorMode(source: SourceSpecification) { function editorMode(source: SourceSpecification) {
if(source.type === "raster") { if(source.type === "raster") {
@@ -77,16 +79,17 @@ type ActiveModalSourcesTypeEditorProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const ActiveModalSourcesTypeEditor: React.FC<ActiveModalSourcesTypeEditorProps> = (props) => { class ActiveModalSourcesTypeEditor extends React.Component<ActiveModalSourcesTypeEditorProps> {
const t = props.t; render() {
const t = this.props.t;
return <div className="maputnik-active-source-type-editor"> return <div className="maputnik-active-source-type-editor">
<div className="maputnik-active-source-type-editor-header"> <div className="maputnik-active-source-type-editor-header">
<span className="maputnik-active-source-type-editor-header-id">#{props.sourceId}</span> <span className="maputnik-active-source-type-editor-header-id">#{this.props.sourceId}</span>
<span className="maputnik-space" /> <span className="maputnik-space" />
<InputButton <InputButton
aria-label={t("Remove '{{sourceId}}' source", {sourceId: props.sourceId})} aria-label={t("Remove '{{sourceId}}' source", {sourceId: this.props.sourceId})}
className="maputnik-active-source-type-editor-header-delete" className="maputnik-active-source-type-editor-header-delete"
onClick={()=> props.onDelete(props.sourceId)} onClick={()=> this.props.onDelete(this.props.sourceId)}
style={{backgroundColor: "transparent"}} style={{backgroundColor: "transparent"}}
> >
<MdDelete /> <MdDelete />
@@ -94,20 +97,37 @@ const ActiveModalSourcesTypeEditor: React.FC<ActiveModalSourcesTypeEditorProps>
</div> </div>
<div className="maputnik-active-source-type-editor-content"> <div className="maputnik-active-source-type-editor-content">
<ModalSourcesTypeEditor <ModalSourcesTypeEditor
onChange={props.onChange} onChange={this.props.onChange}
mode={editorMode(props.source)} mode={editorMode(this.props.source)}
source={props.source} source={this.props.source}
/> />
</div> </div>
</div>; </div>;
}; }
}
type AddSourceProps = { type AddSourceProps = {
onAdd(...args: unknown[]): unknown onAdd(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
function defaultSource(mode: EditorMode, currentSource: SourceSpecification | undefined): SourceSpecification { type AddSourceState = {
const source = currentSource || {}; mode: EditorMode
sourceId: string
source: SourceSpecification
};
class AddSource extends React.Component<AddSourceProps, AddSourceState> {
constructor(props: AddSourceProps) {
super(props);
this.state = {
mode: "tilejson_vector",
sourceId: style.generateId(),
source: this.defaultSource("tilejson_vector"),
};
}
defaultSource(mode: EditorMode): SourceSpecification {
const source = (this.state || {}).source || {};
const {protocol} = window.location; const {protocol} = window.location;
switch(mode) { switch(mode) {
@@ -184,20 +204,17 @@ function defaultSource(mode: EditorMode, currentSource: SourceSpecification | un
} }
} }
const AddSource: React.FC<AddSourceProps> = (props) => { onAdd = () => {
const [mode, setMode] = useState<EditorMode>("tilejson_vector"); const {source, sourceId} = this.state;
const [sourceId, setSourceId] = useState<string>(() => generateId()); this.props.onAdd(sourceId, source);
const [source, setSource] = useState<SourceSpecification>(() => defaultSource("tilejson_vector", undefined));
const onAdd = () => {
props.onAdd(sourceId, source);
}; };
const onChangeSource = (newSource: SourceSpecification) => { onChangeSource = (source: SourceSpecification) => {
setSource(newSource); this.setState({source});
}; };
const t = props.t; render() {
const t = this.props.t;
// Kind of a hack because the type changes, however maputnik has 1..n // Kind of a hack because the type changes, however maputnik has 1..n
// options per type, for example // options per type, for example
// //
@@ -213,8 +230,8 @@ const AddSource: React.FC<AddSourceProps> = (props) => {
<FieldString <FieldString
label={t("Source ID")} label={t("Source ID")}
fieldSpec={{doc: t("Unique ID that identifies the source and is used in the layer to reference the source.")}} fieldSpec={{doc: t("Unique ID that identifies the source and is used in the layer to reference the source.")}}
value={sourceId} value={this.state.sourceId}
onChange={(v: string) => setSourceId(v)} onChange={(v: string) => this.setState({ sourceId: v})}
data-wd-key="modal:sources.add.source_id" data-wd-key="modal:sources.add.source_id"
/> />
<FieldSelect <FieldSelect
@@ -233,27 +250,25 @@ const AddSource: React.FC<AddSourceProps> = (props) => {
["image", t("Image")], ["image", t("Image")],
["video", t("Video")], ["video", t("Video")],
]} ]}
onChange={newMode => { onChange={mode => this.setState({mode: mode as EditorMode, source: this.defaultSource(mode as EditorMode)})}
setMode(newMode as EditorMode); value={this.state.mode as string}
setSource(defaultSource(newMode as EditorMode, source));
}}
value={mode as string}
data-wd-key="modal:sources.add.source_type" data-wd-key="modal:sources.add.source_type"
/> />
<ModalSourcesTypeEditor <ModalSourcesTypeEditor
onChange={onChangeSource} onChange={this.onChangeSource}
mode={mode} mode={this.state.mode}
source={source} source={this.state.source}
/> />
<InputButton <InputButton
className="maputnik-add-source-button" className="maputnik-add-source-button"
onClick={onAdd} onClick={this.onAdd}
data-wd-key="modal:sources.add.add_source" data-wd-key="modal:sources.add.add_source"
> >
{t("Add Source")} {t("Add Source")}
</InputButton> </InputButton>
</div>; </div>;
}; }
}
type ModalSourcesInternalProps = { type ModalSourcesInternalProps = {
mapStyle: StyleSpecificationWithId mapStyle: StyleSpecificationWithId
@@ -262,23 +277,24 @@ type ModalSourcesInternalProps = {
onStyleChanged: OnStyleChangedCallback onStyleChanged: OnStyleChangedCallback
} & WithTranslation; } & WithTranslation;
function stripTitle(source: SourceSpecification & {title?: string}): SourceSpecification { class ModalSourcesInternal extends React.Component<ModalSourcesInternalProps> {
stripTitle(source: SourceSpecification & {title?: string}): SourceSpecification {
const strippedSource = {...source}; const strippedSource = {...source};
delete strippedSource["title"]; delete strippedSource["title"];
return strippedSource; return strippedSource;
} }
const ModalSourcesInternal: React.FC<ModalSourcesInternalProps> = (props) => { render() {
const {t, mapStyle} = props; const {t, mapStyle} = this.props;
const i18nProps = {t, i18n: props.i18n, tReady: props.tReady}; const i18nProps = {t, i18n: this.props.i18n, tReady: this.props.tReady};
const activeSources = Object.keys(mapStyle.sources).map(sourceId => { const activeSources = Object.keys(mapStyle.sources).map(sourceId => {
const source = mapStyle.sources[sourceId]; const source = mapStyle.sources[sourceId];
return <ActiveModalSourcesTypeEditor return <ActiveModalSourcesTypeEditor
key={sourceId} key={sourceId}
sourceId={sourceId} sourceId={sourceId}
source={source} source={source}
onChange={(src: SourceSpecification) => props.onStyleChanged(changeSource(mapStyle, sourceId, src))} onChange={(src: SourceSpecification) => this.props.onStyleChanged(changeSource(mapStyle, sourceId, src))}
onDelete={() => props.onStyleChanged(deleteSource(mapStyle, sourceId))} onDelete={() => this.props.onStyleChanged(deleteSource(mapStyle, sourceId))}
{...i18nProps} {...i18nProps}
/>; />;
}); });
@@ -290,14 +306,14 @@ const ModalSourcesInternal: React.FC<ModalSourcesInternalProps> = (props) => {
id={sourceId} id={sourceId}
type={source.type} type={source.type}
title={source.title} title={source.title}
onSelect={() => props.onStyleChanged(addSource(mapStyle, sourceId, stripTitle(source)))} onSelect={() => this.props.onStyleChanged(addSource(mapStyle, sourceId, this.stripTitle(source)))}
/>; />;
}); });
return <Modal return <Modal
data-wd-key="modal:sources" data-wd-key="modal:sources"
isOpen={props.isOpen} isOpen={this.props.isOpen}
onOpenToggle={props.onOpenToggle} onOpenToggle={this.props.onOpenToggle}
title={t("Sources")} title={t("Sources")}
> >
<section className="maputnik-modal-section"> <section className="maputnik-modal-section">
@@ -319,11 +335,13 @@ const ModalSourcesInternal: React.FC<ModalSourcesInternalProps> = (props) => {
<h1>{t("Add New Source")}</h1> <h1>{t("Add New Source")}</h1>
<p>{t("Add a new source to your style. You can only choose the source type and id at creation time!")}</p> <p>{t("Add a new source to your style. You can only choose the source type and id at creation time!")}</p>
<AddSource <AddSource
onAdd={(sourceId: string, source: SourceSpecification) => props.onStyleChanged(addSource(mapStyle, sourceId, source))} onAdd={(sourceId: string, source: SourceSpecification) => this.props.onStyleChanged(addSource(mapStyle, sourceId, source))}
{...i18nProps} {...i18nProps}
/> />
</section> </section>
</Modal>; </Modal>;
}; }
}
export const ModalSources = withTranslation()(ModalSourcesInternal); const ModalSources = withTranslation()(ModalSourcesInternal);
export default ModalSources;
+115 -97
View File
@@ -3,14 +3,14 @@ import {latest} from "@maplibre/maplibre-gl-style-spec";
import { type WithTranslation, withTranslation } from "react-i18next"; import { type WithTranslation, withTranslation } from "react-i18next";
import { type TFunction } from "i18next"; import { type TFunction } from "i18next";
import { Block } from "../Block"; import Block from "../Block";
import { FieldUrl } from "../FieldUrl"; import FieldUrl from "../FieldUrl";
import { FieldNumber } from "../FieldNumber"; import FieldNumber from "../FieldNumber";
import { FieldSelect } from "../FieldSelect"; import FieldSelect from "../FieldSelect";
import { FieldDynamicArray } from "../FieldDynamicArray"; import FieldDynamicArray from "../FieldDynamicArray";
import { FieldArray } from "../FieldArray"; import FieldArray from "../FieldArray";
import { FieldJson } from "../FieldJson"; import FieldJson from "../FieldJson";
import { FieldCheckbox } from "../FieldCheckbox"; import FieldCheckbox from "../FieldCheckbox";
export type EditorMode = "video" | "image" | "tilejson_vector" | "tile_raster" | "tilejson_raster" | "tilexyz_raster-dem" | "tilejson_raster-dem" | "pmtiles_vector" | "tile_vector" | "geojson_url" | "geojson_json" | null; export type EditorMode = "video" | "image" | "tilejson_vector" | "tile_raster" | "tilejson_raster" | "tilexyz_raster-dem" | "tilejson_raster-dem" | "pmtiles_vector" | "tile_vector" | "geojson_url" | "geojson_json" | null;
@@ -24,21 +24,23 @@ type TileJSONSourceEditorProps = {
} & WithTranslation; } & WithTranslation;
const TileJSONSourceEditor: React.FC<TileJSONSourceEditorProps> = (props) => { class TileJSONSourceEditor extends React.Component<TileJSONSourceEditorProps> {
const t = props.t; render() {
const t = this.props.t;
return <div> return <div>
<FieldUrl <FieldUrl
label={t("TileJSON URL")} label={t("TileJSON URL")}
fieldSpec={latest.source_vector.url} fieldSpec={latest.source_vector.url}
value={props.source.url} value={this.props.source.url}
onChange={url => props.onChange({ onChange={url => this.props.onChange({
...props.source, ...this.props.source,
url: url url: url
})} })}
/> />
{props.children} {this.props.children}
</div>; </div>;
}; }
}
type TileURLSourceEditorProps = { type TileURLSourceEditorProps = {
source: { source: {
@@ -51,28 +53,29 @@ type TileURLSourceEditorProps = {
children?: React.ReactNode children?: React.ReactNode
} & WithTranslation; } & WithTranslation;
const TileURLSourceEditor: React.FC<TileURLSourceEditorProps> = (props) => { class TileURLSourceEditor extends React.Component<TileURLSourceEditorProps> {
function changeTileUrls(tiles: string[]) { changeTileUrls(tiles: string[]) {
props.onChange({ this.props.onChange({
...props.source, ...this.props.source,
tiles, tiles,
}); });
} }
function renderTileUrls() { renderTileUrls() {
const tiles = props.source.tiles || []; const tiles = this.props.source.tiles || [];
return <FieldDynamicArray return <FieldDynamicArray
label={props.t("Tile URL")} label={this.props.t("Tile URL")}
fieldSpec={latest.source_vector.tiles} fieldSpec={latest.source_vector.tiles}
type="url" type="url"
value={tiles} value={tiles}
onChange={changeTileUrls} onChange={this.changeTileUrls.bind(this)}
/>; />;
} }
const t = props.t; render() {
const t = this.props.t;
return <div> return <div>
{renderTileUrls()} {this.renderTileUrls()}
<FieldSelect <FieldSelect
label={t("Scheme Type")} label={t("Scheme Type")}
fieldSpec={latest.source_vector.scheme} fieldSpec={latest.source_vector.scheme}
@@ -80,34 +83,36 @@ const TileURLSourceEditor: React.FC<TileURLSourceEditorProps> = (props) => {
["xyz", "xyz (Slippy map tilenames scheme)"], ["xyz", "xyz (Slippy map tilenames scheme)"],
["tms", "tms (OSGeo spec scheme)"], ["tms", "tms (OSGeo spec scheme)"],
]} ]}
onChange={scheme => props.onChange({ onChange={scheme => this.props.onChange({
...props.source, ...this.props.source,
scheme scheme
})} })}
value={props.source.scheme} value={this.props.source.scheme}
data-wd-key="modal:sources.add.scheme_type" data-wd-key="modal:sources.add.scheme_type"
/> />
<FieldNumber <FieldNumber
label={t("Min Zoom")} label={t("Min Zoom")}
fieldSpec={latest.source_vector.minzoom} fieldSpec={latest.source_vector.minzoom}
value={props.source.minzoom || 0} value={this.props.source.minzoom || 0}
onChange={minzoom => props.onChange({ onChange={minzoom => this.props.onChange({
...props.source, ...this.props.source,
minzoom: minzoom minzoom: minzoom
})} })}
/> />
<FieldNumber <FieldNumber
label={t("Max Zoom")} label={t("Max Zoom")}
fieldSpec={latest.source_vector.maxzoom} fieldSpec={latest.source_vector.maxzoom}
value={props.source.maxzoom || 22} value={this.props.source.maxzoom || 22}
onChange={maxzoom => props.onChange({ onChange={maxzoom => this.props.onChange({
...props.source, ...this.props.source,
maxzoom: maxzoom maxzoom: maxzoom
})} })}
/> />
{props.children} {this.props.children}
</div>; </div>;
};
}
}
const createCornerLabels: (t: TFunction) => { label: string, key: string }[] = (t) => ([ const createCornerLabels: (t: TFunction) => { label: string, key: string }[] = (t) => ([
{ label: t("Coord top left"), key: "top left" }, { label: t("Coord top left"), key: "top left" },
@@ -124,14 +129,15 @@ type ImageSourceEditorProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const ImageSourceEditor: React.FC<ImageSourceEditorProps> = (props) => { class ImageSourceEditor extends React.Component<ImageSourceEditorProps> {
const t = props.t; render() {
const t = this.props.t;
const changeCoord = (idx: number, val: [number, number]) => { const changeCoord = (idx: number, val: [number, number]) => {
const coordinates = props.source.coordinates.slice(0); const coordinates = this.props.source.coordinates.slice(0);
coordinates[idx] = val; coordinates[idx] = val;
props.onChange({ this.props.onChange({
...props.source, ...this.props.source,
coordinates, coordinates,
}); });
}; };
@@ -140,9 +146,9 @@ const ImageSourceEditor: React.FC<ImageSourceEditorProps> = (props) => {
<FieldUrl <FieldUrl
label={t("Image URL")} label={t("Image URL")}
fieldSpec={latest.source_image.url} fieldSpec={latest.source_image.url}
value={props.source.url} value={this.props.source.url}
onChange={url => props.onChange({ onChange={url => this.props.onChange({
...props.source, ...this.props.source,
url, url,
})} })}
/> />
@@ -153,14 +159,15 @@ const ImageSourceEditor: React.FC<ImageSourceEditorProps> = (props) => {
key={key} key={key}
length={2} length={2}
type="number" type="number"
value={props.source.coordinates[idx]} value={this.props.source.coordinates[idx]}
default={[0, 0]} default={[0, 0]}
onChange={(val: [number, number]) => changeCoord(idx, val)} onChange={(val: [number, number]) => changeCoord(idx, val)}
/> />
); );
})} })}
</div>; </div>;
}; }
}
type VideoSourceEditorProps = { type VideoSourceEditorProps = {
source: { source: {
@@ -170,21 +177,22 @@ type VideoSourceEditorProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const VideoSourceEditor: React.FC<VideoSourceEditorProps> = (props) => { class VideoSourceEditor extends React.Component<VideoSourceEditorProps> {
const t = props.t; render() {
const t = this.props.t;
const changeCoord = (idx: number, val: [number, number]) => { const changeCoord = (idx: number, val: [number, number]) => {
const coordinates = props.source.coordinates.slice(0); const coordinates = this.props.source.coordinates.slice(0);
coordinates[idx] = val; coordinates[idx] = val;
props.onChange({ this.props.onChange({
...props.source, ...this.props.source,
coordinates, coordinates,
}); });
}; };
const changeUrls = (urls: string[]) => { const changeUrls = (urls: string[]) => {
props.onChange({ this.props.onChange({
...props.source, ...this.props.source,
urls, urls,
}); });
}; };
@@ -194,7 +202,7 @@ const VideoSourceEditor: React.FC<VideoSourceEditorProps> = (props) => {
label={t("Video URL")} label={t("Video URL")}
fieldSpec={latest.source_video.urls} fieldSpec={latest.source_video.urls}
type="string" type="string"
value={props.source.urls} value={this.props.source.urls}
default={[]} default={[]}
onChange={changeUrls} onChange={changeUrls}
/> />
@@ -205,14 +213,15 @@ const VideoSourceEditor: React.FC<VideoSourceEditorProps> = (props) => {
key={key} key={key}
length={2} length={2}
type="number" type="number"
value={props.source.coordinates[idx]} value={this.props.source.coordinates[idx]}
default={[0, 0]} default={[0, 0]}
onChange={(val: [number, number]) => changeCoord(idx, val)} onChange={(val: [number, number]) => changeCoord(idx, val)}
/> />
); );
})} })}
</div>; </div>;
}; }
}
type GeoJSONSourceUrlEditorProps = { type GeoJSONSourceUrlEditorProps = {
source: { source: {
@@ -221,18 +230,20 @@ type GeoJSONSourceUrlEditorProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const GeoJSONSourceUrlEditor: React.FC<GeoJSONSourceUrlEditorProps> = (props) => { class GeoJSONSourceUrlEditor extends React.Component<GeoJSONSourceUrlEditorProps> {
const t = props.t; render() {
const t = this.props.t;
return <FieldUrl return <FieldUrl
label={t("GeoJSON URL")} label={t("GeoJSON URL")}
fieldSpec={latest.source_geojson.data} fieldSpec={latest.source_geojson.data}
value={props.source.data} value={this.props.source.data}
onChange={data => props.onChange({ onChange={data => this.props.onChange({
...props.source, ...this.props.source,
data: data data: data
})} })}
/>; />;
}; }
}
type GeoJSONSourceFieldJsonEditorProps = { type GeoJSONSourceFieldJsonEditorProps = {
source: { source: {
@@ -242,16 +253,17 @@ type GeoJSONSourceFieldJsonEditorProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const GeoJSONSourceFieldJsonEditor: React.FC<GeoJSONSourceFieldJsonEditorProps> = (props) => { class GeoJSONSourceFieldJsonEditor extends React.Component<GeoJSONSourceFieldJsonEditorProps> {
const t = props.t; render() {
const t = this.props.t;
return <div> return <div>
<Block label={t("GeoJSON")} fieldSpec={latest.source_geojson.data}> <Block label={t("GeoJSON")} fieldSpec={latest.source_geojson.data}>
<FieldJson <FieldJson
value={props.source.data} value={this.props.source.data}
lintType="json" lintType="json"
onChange={data => { onChange={data => {
props.onChange({ this.props.onChange({
...props.source, ...this.props.source,
data, data,
}); });
}} }}
@@ -259,16 +271,17 @@ const GeoJSONSourceFieldJsonEditor: React.FC<GeoJSONSourceFieldJsonEditorProps>
</Block> </Block>
<FieldCheckbox <FieldCheckbox
label={t("Cluster")} label={t("Cluster")}
value={props.source.cluster} value={this.props.source.cluster}
onChange={cluster => { onChange={cluster => {
props.onChange({ this.props.onChange({
...props.source, ...this.props.source,
cluster: cluster, cluster: cluster,
}); });
}} }}
/> />
</div>; </div>;
}; }
}
type PMTilesSourceEditorProps = { type PMTilesSourceEditorProps = {
source: { source: {
@@ -278,22 +291,24 @@ type PMTilesSourceEditorProps = {
children?: React.ReactNode children?: React.ReactNode
} & WithTranslation; } & WithTranslation;
const PMTilesSourceEditor: React.FC<PMTilesSourceEditorProps> = (props) => { class PMTilesSourceEditor extends React.Component<PMTilesSourceEditorProps> {
const t = props.t; render() {
const t = this.props.t;
return <div> return <div>
<FieldUrl <FieldUrl
label={t("PMTiles URL")} label={t("PMTiles URL")}
fieldSpec={latest.source_vector.url} fieldSpec={latest.source_vector.url}
value={props.source.url} value={this.props.source.url}
data-wd-key="modal:sources.add.source_url" data-wd-key="modal:sources.add.source_url"
onChange={(url: string) => props.onChange({ onChange={(url: string) => this.props.onChange({
...props.source, ...this.props.source,
url: url.startsWith("pmtiles://") ? url : `pmtiles://${url}` url: url.startsWith("pmtiles://") ? url : `pmtiles://${url}`
})} })}
/> />
{props.children} {this.props.children}
</div>; </div>;
}; }
}
type ModalSourcesTypeEditorInternalProps = { type ModalSourcesTypeEditorInternalProps = {
mode: EditorMode mode: EditorMode
@@ -301,16 +316,17 @@ type ModalSourcesTypeEditorInternalProps = {
onChange(...args: unknown[]): unknown onChange(...args: unknown[]): unknown
} & WithTranslation; } & WithTranslation;
const ModalSourcesTypeEditorInternal: React.FC<ModalSourcesTypeEditorInternalProps> = (props) => { class ModalSourcesTypeEditorInternal extends React.Component<ModalSourcesTypeEditorInternalProps> {
const t = props.t; render() {
const t = this.props.t;
const commonProps = { const commonProps = {
source: props.source, source: this.props.source,
onChange: props.onChange, onChange: this.props.onChange,
t: props.t, t: this.props.t,
i18n: props.i18n, i18n: this.props.i18n,
tReady: props.tReady, tReady: this.props.tReady,
}; };
switch(props.mode) { switch(this.props.mode) {
case "geojson_url": return <GeoJSONSourceUrlEditor {...commonProps} />; case "geojson_url": return <GeoJSONSourceUrlEditor {...commonProps} />;
case "geojson_json": return <GeoJSONSourceFieldJsonEditor {...commonProps} />; case "geojson_json": return <GeoJSONSourceFieldJsonEditor {...commonProps} />;
case "tilejson_vector": return <TileJSONSourceEditor {...commonProps} />; case "tilejson_vector": return <TileJSONSourceEditor {...commonProps} />;
@@ -320,11 +336,11 @@ const ModalSourcesTypeEditorInternal: React.FC<ModalSourcesTypeEditorInternalPro
<FieldNumber <FieldNumber
label={t("Tile Size")} label={t("Tile Size")}
fieldSpec={latest.source_raster.tileSize} fieldSpec={latest.source_raster.tileSize}
onChange={tileSize => props.onChange({ onChange={tileSize => this.props.onChange({
...props.source, ...this.props.source,
tileSize: tileSize tileSize: tileSize
})} })}
value={props.source.tileSize || latest.source_raster.tileSize.default} value={this.props.source.tileSize || latest.source_raster.tileSize.default}
data-wd-key="modal:sources.add.tile_size" data-wd-key="modal:sources.add.tile_size"
/> />
</TileURLSourceEditor>; </TileURLSourceEditor>;
@@ -333,22 +349,22 @@ const ModalSourcesTypeEditorInternal: React.FC<ModalSourcesTypeEditorInternalPro
<FieldNumber <FieldNumber
label={t("Tile Size")} label={t("Tile Size")}
fieldSpec={latest.source_raster_dem.tileSize} fieldSpec={latest.source_raster_dem.tileSize}
onChange={tileSize => props.onChange({ onChange={tileSize => this.props.onChange({
...props.source, ...this.props.source,
tileSize: tileSize tileSize: tileSize
})} })}
value={props.source.tileSize || latest.source_raster_dem.tileSize.default} value={this.props.source.tileSize || latest.source_raster_dem.tileSize.default}
data-wd-key="modal:sources.add.tile_size" data-wd-key="modal:sources.add.tile_size"
/> />
<FieldSelect <FieldSelect
label={t("Encoding")} label={t("Encoding")}
fieldSpec={latest.source_raster_dem.encoding} fieldSpec={latest.source_raster_dem.encoding}
options={Object.keys(latest.source_raster_dem.encoding.values)} options={Object.keys(latest.source_raster_dem.encoding.values)}
onChange={encoding => props.onChange({ onChange={encoding => this.props.onChange({
...props.source, ...this.props.source,
encoding: encoding encoding: encoding
})} })}
value={props.source.encoding || latest.source_raster_dem.encoding.default} value={this.props.source.encoding || latest.source_raster_dem.encoding.default}
/> />
</TileURLSourceEditor>; </TileURLSourceEditor>;
case "pmtiles_vector": return <PMTilesSourceEditor {...commonProps} />; case "pmtiles_vector": return <PMTilesSourceEditor {...commonProps} />;
@@ -356,6 +372,8 @@ const ModalSourcesTypeEditorInternal: React.FC<ModalSourcesTypeEditorInternalPro
case "video": return <VideoSourceEditor {...commonProps} />; case "video": return <VideoSourceEditor {...commonProps} />;
default: return null; default: return null;
} }
}; }
}
export const ModalSourcesTypeEditor = withTranslation()(ModalSourcesTypeEditorInternal); const ModalSourcesTypeEditor = withTranslation()(ModalSourcesTypeEditorInternal);
export default ModalSourcesTypeEditor;
+1 -1
View File
@@ -42,4 +42,4 @@ i18n
} }
}); });
export { i18n }; export default i18n;
+1 -1
View File
@@ -4,7 +4,7 @@ import { createRoot } from "react-dom/client";
import "./favicon.ico"; import "./favicon.ico";
import "./styles/index.scss"; import "./styles/index.scss";
import "./i18n"; import "./i18n";
import { App } from "./components/App"; import App from "./components/App";
const root = createRoot(document.querySelector("#app")); const root = createRoot(document.querySelector("#app"));
root.render( root.render(
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import type { StyleSpecification } from "maplibre-gl";
import { undoMessages, redoMessages } from "./diffmessage";
const before = { version: 8, sources: {}, layers: [] } as StyleSpecification;
const after = {
version: 8,
sources: {},
layers: [{ id: "bg", type: "background" }],
} as StyleSpecification;
describe("diff messages", () => {
it("prefixes undo messages with 'Undo'", () => {
const messages = undoMessages(before, after);
expect(messages.length).toBeGreaterThan(0);
expect(messages.every((m) => m.startsWith("Undo "))).toBe(true);
});
it("prefixes redo messages with 'Redo'", () => {
const messages = redoMessages(before, after);
expect(messages.length).toBeGreaterThan(0);
expect(messages.every((m) => m.startsWith("Redo "))).toBe(true);
});
});
+1 -1
View File
@@ -3,7 +3,7 @@
*/ */
let REF = 0; let REF = 0;
export function generateUniqueId(prefix="") { export default function generateUniqueId(prefix="") {
REF++; REF++;
return prefix+REF; return prefix+REF;
} }

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