mirror of
https://github.com/maputnik/editor.git
synced 2026-07-26 15:57:27 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f88e1b04fa | |||
| f14eeae38b | |||
| 9c1499b805 |
@@ -96,7 +96,6 @@ jobs:
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- run: npm run test-unit-ci
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
|
||||
@@ -36,7 +36,7 @@ Then run the end-to-end tests (Playwright starts the dev server automatically):
|
||||
npm run test
|
||||
```
|
||||
|
||||
Run the unit and component tests with Vitest:
|
||||
Run the unit tests with Vitest:
|
||||
|
||||
```
|
||||
npm run test-unit
|
||||
@@ -45,3 +45,106 @@ npm run test-unit
|
||||
## Pull Requests
|
||||
|
||||
- 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).
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
+367
-9
@@ -25,7 +25,18 @@ describe("layer editor", () => {
|
||||
return id;
|
||||
}
|
||||
|
||||
test.skip("expand/collapse", () => {});
|
||||
test("expand/collapse", async () => {
|
||||
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 () => {
|
||||
const bgId = await createBackground();
|
||||
@@ -76,6 +87,14 @@ describe("layer editor", () => {
|
||||
layers: [{ id: "background:" + bgId, type: "background", minzoom: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
test("the range slider adjusts min-zoom", async () => {
|
||||
await when.focus("min-zoom.input-range");
|
||||
await when.typeKeys("{rightarrow}");
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
layers: [{ id: "background:" + bgId, type: "background", minzoom: 2 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("max-zoom", () => {
|
||||
@@ -145,6 +164,13 @@ describe("layer editor", () => {
|
||||
layers: [{ id: "background:" + bgId, type: "background" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("typing a hex value updates the paint color", async () => {
|
||||
await when.setColorValue("background-color", "#ff0000");
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
layers: [{ id: "background:" + bgId, type: "background", paint: { "background-color": "#ff0000" } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("opacity", () => {
|
||||
@@ -166,8 +192,287 @@ describe("layer editor", () => {
|
||||
});
|
||||
|
||||
describe("filter", () => {
|
||||
test.skip("expand/collapse", () => {});
|
||||
test.skip("compound filter", () => {});
|
||||
let id: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
id = await when.modal.fillLayers({ type: "fill", layer: "example" });
|
||||
await when.addFilter();
|
||||
});
|
||||
|
||||
test("should add a filter item", async () => {
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
layers: [{ id, type: "fill", source: "example", filter: ["all", ["==", "name", ""]] }],
|
||||
});
|
||||
});
|
||||
|
||||
test("should change the filter operator", async () => {
|
||||
await when.selectFilterOperator("!=");
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
layers: [{ id, filter: ["all", ["!=", "name", ""]] }],
|
||||
});
|
||||
});
|
||||
|
||||
test("should extend the compound filter with a second item", async () => {
|
||||
await when.addFilter();
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
layers: [{ id, 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", () => {
|
||||
let id: string;
|
||||
|
||||
describe("zoom function", () => {
|
||||
beforeEach(async () => {
|
||||
id = await when.modal.fillLayers({ type: "circle", layer: "example" });
|
||||
await when.makeZoomFunction("circle-radius");
|
||||
});
|
||||
|
||||
test("should convert the property to a zoom function", async () => {
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
layers: [
|
||||
{ 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 then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
layers: [{ id, paint: { "circle-radius": { stops: [[6, 5], [10, 5], [11, 5]] } } }],
|
||||
});
|
||||
});
|
||||
|
||||
test("should delete the first stop", async () => {
|
||||
// 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 then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
layers: [{ id, paint: { "circle-radius": { stops: [[10, 5], [11, 5]] } } }],
|
||||
});
|
||||
});
|
||||
|
||||
test("should set the base", async () => {
|
||||
await when.setFunctionBase("circle-radius", "2");
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
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.makeDataFunction("circle-blur");
|
||||
});
|
||||
|
||||
test("should convert the property to a data function", async () => {
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
layers: [
|
||||
{
|
||||
id,
|
||||
type: "circle",
|
||||
source: "example",
|
||||
paint: {
|
||||
"circle-blur": {
|
||||
property: "",
|
||||
type: "exponential",
|
||||
stops: [[{ zoom: 6, value: 0 }, 1], [{ zoom: 10, value: 0 }, 1]],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("should add a stop", async () => {
|
||||
await when.addFunctionStop("circle-blur");
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
layers: [
|
||||
{
|
||||
id,
|
||||
paint: {
|
||||
"circle-blur": {
|
||||
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 then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
layers: [
|
||||
{
|
||||
id,
|
||||
paint: {
|
||||
"circle-blur": {
|
||||
stops: [[{ zoom: 10, value: 0 }, 1], [{ zoom: 11, value: 0 }, 1]],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -183,10 +488,43 @@ describe("layer editor", () => {
|
||||
});
|
||||
|
||||
describe("paint", () => {
|
||||
test.skip("expand/collapse", () => {});
|
||||
test.skip("color", () => {});
|
||||
test.skip("pattern", () => {});
|
||||
test.skip("opacity", () => {});
|
||||
let id: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
id = await when.modal.fillLayers({ type: "fill", layer: "example" });
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -206,8 +544,28 @@ describe("layer editor", () => {
|
||||
await then(get.element(".cm-lint-marker-error")).shouldExist();
|
||||
});
|
||||
|
||||
test.skip("expand/collapse", () => {});
|
||||
test.skip("modify", () => {});
|
||||
test("expand/collapse", async () => {
|
||||
const bgId = await createBackground();
|
||||
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 () => {
|
||||
const bgId = await createBackground();
|
||||
|
||||
+16
-3
@@ -97,7 +97,15 @@ describe("layers list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.skip("modify", () => {});
|
||||
test("modify", async () => {
|
||||
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", () => {
|
||||
@@ -108,8 +116,13 @@ describe("layers list", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Change source
|
||||
test.skip("change source", () => {});
|
||||
test("change source", async () => {
|
||||
const id = await when.modal.fillLayers({ type: "fill", layer: "example" });
|
||||
await when.changeLayerSource("raster");
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
layers: [{ id, type: "fill", source: "raster" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("line", () => {
|
||||
|
||||
+167
-1
@@ -38,6 +38,7 @@ export class MaputnikDriver {
|
||||
"example-style-with-fonts.json",
|
||||
"example-style-with-zoom-7-and-center-0-51.json",
|
||||
"example-style-with-zoom-5-and-center-50-50.json",
|
||||
"access-token-style.json",
|
||||
];
|
||||
for (const fixture of styleFixtures) {
|
||||
await this.helper.given.interceptAndMockResponse({
|
||||
@@ -71,6 +72,7 @@ export class MaputnikDriver {
|
||||
| "rectangles"
|
||||
| "font"
|
||||
| "zoom_7_center_0_51"
|
||||
| "access_tokens"
|
||||
| "",
|
||||
zoom?: number
|
||||
) => {
|
||||
@@ -82,6 +84,7 @@ export class MaputnikDriver {
|
||||
rectangles: "rectangles-style.json",
|
||||
font: "example-style-with-fonts.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);
|
||||
@@ -107,7 +110,13 @@ export class MaputnikDriver {
|
||||
},
|
||||
|
||||
chooseExampleFile: async () => {
|
||||
await this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone", "modal:open.file.input");
|
||||
await this.helper.when.openFileByFixture("example-style.json", "modal:open.dropzone");
|
||||
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);
|
||||
},
|
||||
|
||||
@@ -128,6 +137,22 @@ export class MaputnikDriver {
|
||||
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) => {
|
||||
await this.helper.get.element(".cm-line").first().click();
|
||||
// Move to the very start of the document so the inserted text breaks the
|
||||
@@ -158,6 +183,147 @@ export class MaputnikDriver {
|
||||
await this.helper.when.typeText(value);
|
||||
},
|
||||
|
||||
makeZoomFunction: async (fieldName: string) => {
|
||||
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||
await container.scrollIntoViewIfNeeded();
|
||||
await container.locator(".maputnik-make-zoom-function").last().click({ force: true });
|
||||
},
|
||||
|
||||
makeDataFunction: async (fieldName: string) => {
|
||||
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||
await container.scrollIntoViewIfNeeded();
|
||||
await container.locator(".maputnik-make-data-function").click({ force: true });
|
||||
},
|
||||
|
||||
addFunctionStop: async (fieldName: string) => {
|
||||
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||
await container.locator(".maputnik-add-stop").first().click({ force: true });
|
||||
},
|
||||
|
||||
deleteFunctionStop: async (fieldName: string) => {
|
||||
const container = this.helper.get.elementByTestId("spec-field-container:" + fieldName);
|
||||
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 () => {
|
||||
const button = this.helper.get.elementByTestId("layer-filter-button");
|
||||
await button.scrollIntoViewIfNeeded();
|
||||
await button.click({ force: true });
|
||||
},
|
||||
|
||||
selectFilterOperator: async (value: string) => {
|
||||
await this.helper.get.element(".maputnik-filter-editor-operator select").first().selectOption(value);
|
||||
},
|
||||
|
||||
/** Chooses how the filter items combine: all / none / any. */
|
||||
selectFilterCombiningOperator: async (value: string) => {
|
||||
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) => {
|
||||
const input = this.helper.get.elementByTestId("spec-field:" + fieldName).locator(".maputnik-color");
|
||||
await input.fill(value);
|
||||
},
|
||||
|
||||
/** Sets a plain string spec field (e.g. a pattern), which has no dedicated input test id. */
|
||||
setStringValue: async (fieldName: string, value: string) => {
|
||||
const input = this.helper.get.elementByTestId("spec-field:" + fieldName).locator("input.maputnik-string");
|
||||
await input.fill(value);
|
||||
await input.blur();
|
||||
},
|
||||
|
||||
/**
|
||||
* Appends text to the end of the JSON editor line holding `lineText`.
|
||||
* CodeMirror types over its own auto-inserted closing quotes/brackets, so a
|
||||
* 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"),
|
||||
|
||||
/** Fill localStorage until we get a QuotaExceededError. */
|
||||
|
||||
@@ -34,5 +34,45 @@ export class ModalDriver {
|
||||
close: async (key: string) => {
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+198
-26
@@ -43,6 +43,16 @@ describe("modals", () => {
|
||||
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", () => {
|
||||
@@ -64,8 +74,15 @@ describe("modals", () => {
|
||||
await then(get.elementByTestId("modal:export")).shouldNotExist();
|
||||
});
|
||||
|
||||
// TODO: Work out how to download a file and check the contents
|
||||
test.skip("download", () => {});
|
||||
test("download HTML and save the style", async () => {
|
||||
// Generate the standalone HTML export (triggers a file download).
|
||||
await when.modal.exportCreateHtml();
|
||||
await then(get.elementByTestId("modal:export")).shouldExist();
|
||||
|
||||
// Saving the style closes the export modal.
|
||||
await when.modal.exportSaveStyle();
|
||||
await then(get.elementByTestId("modal:export")).shouldNotExist();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sources", () => {
|
||||
@@ -74,8 +91,27 @@ describe("modals", () => {
|
||||
await when.click("nav:sources");
|
||||
});
|
||||
|
||||
test.skip("active sources", () => {});
|
||||
test.skip("public source", () => {});
|
||||
test("active sources are listed and can be deleted", async () => {
|
||||
await when.setStyle("both");
|
||||
await when.click("nav:sources");
|
||||
const before = Object.keys(get.fixture("geojson-raster-style.json").sources).length;
|
||||
await when.modal.deleteFirstActiveSource();
|
||||
await then(
|
||||
get.styleFromLocalStorage().then((style) => Object.keys(style.sources).length)
|
||||
).shouldEqual(before - 1);
|
||||
});
|
||||
|
||||
test("public source", async () => {
|
||||
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 () => {
|
||||
const sourceId = "n1z2v3r";
|
||||
@@ -84,8 +120,8 @@ describe("modals", () => {
|
||||
await when.select("modal:sources.add.scheme_type", "tms");
|
||||
await when.click("modal:sources.add.add_source");
|
||||
await when.wait(200);
|
||||
await then(get.styleFromLocalStorage().then((style) => style.sources[sourceId])).shouldInclude({
|
||||
scheme: "tms",
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
sources: { [sourceId]: { scheme: "tms" } },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,8 +154,110 @@ describe("modals", () => {
|
||||
await when.setValue("modal:sources.add.tile_size", "128");
|
||||
await when.click("modal:sources.add.add_source");
|
||||
await when.wait(200);
|
||||
await then(get.styleFromLocalStorage().then((style) => style.sources[sourceId])).shouldInclude({
|
||||
tileSize: 128,
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
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]] },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -204,8 +342,8 @@ describe("modals", () => {
|
||||
const apiKey = "testing123";
|
||||
await when.setValue("modal:settings.maputnik:openmaptiles_access_token", apiKey);
|
||||
await when.click("modal:settings.name");
|
||||
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
|
||||
"maputnik:openmaptiles_access_token": apiKey,
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
metadata: { "maputnik:openmaptiles_access_token": apiKey },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -213,8 +351,8 @@ describe("modals", () => {
|
||||
const apiKey = "testing123";
|
||||
await when.setValue("modal:settings.maputnik:thunderforest_access_token", apiKey);
|
||||
await when.click("modal:settings.name");
|
||||
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
|
||||
"maputnik:thunderforest_access_token": apiKey,
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
metadata: { "maputnik:thunderforest_access_token": apiKey },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,8 +360,8 @@ describe("modals", () => {
|
||||
const apiKey = "testing123";
|
||||
await when.setValue("modal:settings.maputnik:stadia_access_token", apiKey);
|
||||
await when.click("modal:settings.name");
|
||||
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
|
||||
"maputnik:stadia_access_token": apiKey,
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
metadata: { "maputnik:stadia_access_token": apiKey },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -231,29 +369,67 @@ describe("modals", () => {
|
||||
const apiKey = "testing123";
|
||||
await when.setValue("modal:settings.maputnik:locationiq_access_token", apiKey);
|
||||
await when.click("modal:settings.name");
|
||||
await then(get.styleFromLocalStorage().then((style) => style.metadata)).shouldInclude({
|
||||
"maputnik:locationiq_access_token": apiKey,
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
metadata: { "maputnik:locationiq_access_token": apiKey },
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await when.select("modal:settings.projection", "mercator");
|
||||
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({
|
||||
type: "mercator",
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
projection: { type: "mercator" },
|
||||
});
|
||||
});
|
||||
|
||||
test("style projection globe", async () => {
|
||||
await when.select("modal:settings.projection", "globe");
|
||||
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({
|
||||
type: "globe",
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
projection: { type: "globe" },
|
||||
});
|
||||
});
|
||||
|
||||
test("style projection vertical-perspective", async () => {
|
||||
await when.select("modal:settings.projection", "vertical-perspective");
|
||||
await then(get.styleFromLocalStorage().then((style) => style.projection)).shouldInclude({
|
||||
type: "vertical-perspective",
|
||||
await then(get.styleFromLocalStorage()).shouldDeepNestedInclude({
|
||||
projection: { type: "vertical-perspective" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -311,10 +487,6 @@ describe("modals", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sources placeholder", () => {
|
||||
test.skip("toggle", () => {});
|
||||
});
|
||||
|
||||
describe("global state", () => {
|
||||
beforeEach(async () => {
|
||||
await when.click("nav:global-state");
|
||||
|
||||
+61
-22
@@ -62,11 +62,12 @@ function isLocator(target: unknown): target is Locator {
|
||||
);
|
||||
}
|
||||
|
||||
/** Asserts that every top-level key in `expected` deep-equals its counterpart in `actual`. */
|
||||
function assertDeepNestedInclude(actual: any, expected: Record<string, unknown>): void {
|
||||
for (const key of Object.keys(expected)) {
|
||||
expect(actual?.[key], `property "${key}"`).toEqual(expected[key]);
|
||||
}
|
||||
/**
|
||||
* Asserts that `actual` recursively contains everything in `expected`: nested
|
||||
* objects are matched as subsets, while arrays and primitives must match exactly.
|
||||
*/
|
||||
function assertDeepNestedInclude(actual: any, expected: Record<string, unknown> | unknown[]): void {
|
||||
expect(actual).toMatchObject(expected);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,7 +131,7 @@ export class Assertable<T> {
|
||||
}
|
||||
});
|
||||
|
||||
shouldDeepNestedInclude = (value: Record<string, unknown>) =>
|
||||
shouldDeepNestedInclude = (value: Record<string, unknown> | unknown[]) =>
|
||||
this.assertValue((actual) => assertDeepNestedInclude(actual, value));
|
||||
}
|
||||
|
||||
@@ -144,6 +145,8 @@ async function typeSequence(page: Page, text: string): Promise<void> {
|
||||
del: "Delete",
|
||||
tab: "Tab",
|
||||
home: "Home",
|
||||
end: "End",
|
||||
rightarrow: "ArrowRight",
|
||||
};
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
@@ -202,10 +205,31 @@ export class PlaywrightHelper {
|
||||
return new Query<T>(getter);
|
||||
}
|
||||
|
||||
/** Stubs the File System Access "save" picker so file saves complete headlessly. */
|
||||
public stubSaveFilePicker(): Promise<void> {
|
||||
return this.page.evaluate(() => {
|
||||
(window as any).showSaveFilePicker = async () => ({
|
||||
createWritable: async () => ({ write: async () => {}, close: async () => {} }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Entry point for fluent assertions over a Locator or a value/Query. */
|
||||
public then = <T>(target: T): Assertable<T> => new Assertable(target);
|
||||
|
||||
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") => {
|
||||
this.recordedRequests.set(alias, []);
|
||||
await this.page.route(pattern, (route) => {
|
||||
@@ -333,23 +357,38 @@ export class PlaywrightHelper {
|
||||
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 hasPicker = await this.page.evaluate(() => "showOpenFilePicker" in window);
|
||||
if (hasPicker) {
|
||||
await this.page.evaluate((fileContent) => {
|
||||
(window as any).showOpenFilePicker = async () => [
|
||||
{ getFile: async () => ({ text: async () => fileContent }) },
|
||||
];
|
||||
}, content);
|
||||
await this.testId(buttonTestId).click();
|
||||
} else {
|
||||
await this.testId(inputTestId).setInputFiles({
|
||||
name: fixture,
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(content),
|
||||
});
|
||||
}
|
||||
await this.page.evaluate((fileContent) => {
|
||||
(window as any).showOpenFilePicker = async () => [
|
||||
{ getFile: async () => ({ text: async () => fileContent }) },
|
||||
];
|
||||
}, content);
|
||||
await this.testId(buttonTestId).click();
|
||||
},
|
||||
|
||||
/**
|
||||
* 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,
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(content),
|
||||
});
|
||||
},
|
||||
|
||||
dropFileByFixture: async (fixture: string, dropzoneTestId: string) => {
|
||||
|
||||
+3
-1
@@ -32,9 +32,11 @@ export default defineConfig({
|
||||
"@stylistic": stylisticTs
|
||||
},
|
||||
rules: {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true }
|
||||
{ allowConstantExport: true, extraHOCs: ["withTranslation"] }
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
|
||||
Generated
+29
-175
@@ -90,8 +90,6 @@
|
||||
"@types/string-hash": "^1.1.3",
|
||||
"@types/wicg-file-system-access": "^2023.10.7",
|
||||
"@vitejs/plugin-react": "5.2",
|
||||
"@vitest/browser": "^4.1.10",
|
||||
"@vitest/browser-playwright": "^4.1.10",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"cors": "^2.8.6",
|
||||
"eslint": "^10.6.0",
|
||||
@@ -113,8 +111,7 @@
|
||||
"uuid": "^14.0.1",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-istanbul": "^9.0.1",
|
||||
"vitest": "^4.1.10",
|
||||
"vitest-browser-react": "^2.2.0"
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -438,13 +435,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@blazediff/core": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz",
|
||||
"integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cacheable/memory": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.9.tgz",
|
||||
@@ -2631,13 +2621,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||
@@ -4066,53 +4049,6 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz",
|
||||
"integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blazediff/core": "1.9.1",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"magic-string": "^0.30.21",
|
||||
"pngjs": "^7.0.0",
|
||||
"sirv": "^3.0.2",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/browser-playwright": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz",
|
||||
"integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/browser": "4.1.10",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright": "*",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"playwright": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
|
||||
@@ -8387,9 +8323,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-processinfo": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-3.0.1.tgz",
|
||||
"integrity": "sha512-s3mX05h5wGZeScG6XnOanygPh4SJu5ujMc9YbvpnLGXWy1cRiGbp0NdVcjHxgoZt3WfQppfBsa0y+gWdYJ2pGQ==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-3.0.0.tgz",
|
||||
"integrity": "sha512-P7nLXRRlo7Sqinty6lNa7+4o9jBUYGpqtejqCOZKfgXlRoxY/QArflcB86YO500Ahj4pDJEG34JjMRbQgePLnQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -8397,7 +8333,8 @@
|
||||
"cross-spawn": "^7.0.3",
|
||||
"istanbul-lib-coverage": "^3.2.0",
|
||||
"p-map": "^3.0.0",
|
||||
"rimraf": "^6.1.3"
|
||||
"rimraf": "^6.1.3",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
@@ -8416,6 +8353,16 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-processinfo/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
@@ -9756,16 +9703,6 @@
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -10807,16 +10744,6 @@
|
||||
"fflate": "^0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
|
||||
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -11064,13 +10991,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
@@ -11938,14 +11864,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
@@ -11957,13 +11883,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -12023,21 +11949,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sirv": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@polka/url": "^1.0.0-next.24",
|
||||
"mrmime": "^2.0.0",
|
||||
"totalist": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/slugify": {
|
||||
"version": "1.6.9",
|
||||
"resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz",
|
||||
@@ -12909,16 +12820,6 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
@@ -13669,31 +13570,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest-browser-react": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vitest-browser-react/-/vitest-browser-react-2.2.0.tgz",
|
||||
"integrity": "sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0",
|
||||
"vitest": "^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
@@ -13881,28 +13757,6 @@
|
||||
"typedarray-to-buffer": "^3.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-utils": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
|
||||
|
||||
+1
-4
@@ -124,8 +124,6 @@
|
||||
"@types/string-hash": "^1.1.3",
|
||||
"@types/wicg-file-system-access": "^2023.10.7",
|
||||
"@vitejs/plugin-react": "5.2",
|
||||
"@vitest/browser": "^4.1.10",
|
||||
"@vitest/browser-playwright": "^4.1.10",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"cors": "^2.8.6",
|
||||
"eslint": "^10.6.0",
|
||||
@@ -147,7 +145,6 @@
|
||||
"uuid": "^14.0.1",
|
||||
"vite": "^7.3.2",
|
||||
"vite-plugin-istanbul": "^9.0.1",
|
||||
"vitest": "^4.1.10",
|
||||
"vitest-browser-react": "^2.2.0"
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
+641
-598
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import ScrollContainer from "./ScrollContainer";
|
||||
import { ScrollContainer } from "./ScrollContainer";
|
||||
import { type WithTranslation, withTranslation } from "react-i18next";
|
||||
import { IconContext } from "react-icons";
|
||||
|
||||
@@ -13,42 +13,38 @@ type AppLayoutInternalProps = {
|
||||
modals?: React.ReactNode
|
||||
} & WithTranslation;
|
||||
|
||||
class AppLayoutInternal extends React.Component<AppLayoutInternalProps> {
|
||||
const AppLayoutInternal: React.FC<AppLayoutInternalProps> = (props) => {
|
||||
document.body.dir = props.i18n.dir();
|
||||
|
||||
render() {
|
||||
document.body.dir = this.props.i18n.dir();
|
||||
|
||||
return <IconContext.Provider value={{size: "14px"}}>
|
||||
<div className="maputnik-layout">
|
||||
{this.props.toolbar}
|
||||
<div className="maputnik-layout-main">
|
||||
{this.props.codeEditor && <div className="maputnik-layout-code-editor">
|
||||
<ScrollContainer>
|
||||
{this.props.codeEditor}
|
||||
</ScrollContainer>
|
||||
</div>
|
||||
}
|
||||
{!this.props.codeEditor && <>
|
||||
<div className="maputnik-layout-list">
|
||||
{this.props.layerList}
|
||||
</div>
|
||||
<div className="maputnik-layout-drawer">
|
||||
<ScrollContainer>
|
||||
{this.props.layerEditor}
|
||||
</ScrollContainer>
|
||||
</div>
|
||||
</>}
|
||||
{this.props.map}
|
||||
</div>
|
||||
{this.props.bottom && <div className="maputnik-layout-bottom">
|
||||
{this.props.bottom}
|
||||
return <IconContext.Provider value={{size: "14px"}}>
|
||||
<div className="maputnik-layout">
|
||||
{props.toolbar}
|
||||
<div className="maputnik-layout-main">
|
||||
{props.codeEditor && <div className="maputnik-layout-code-editor">
|
||||
<ScrollContainer>
|
||||
{props.codeEditor}
|
||||
</ScrollContainer>
|
||||
</div>
|
||||
}
|
||||
{this.props.modals}
|
||||
{!props.codeEditor && <>
|
||||
<div className="maputnik-layout-list">
|
||||
{props.layerList}
|
||||
</div>
|
||||
<div className="maputnik-layout-drawer">
|
||||
<ScrollContainer>
|
||||
{props.layerEditor}
|
||||
</ScrollContainer>
|
||||
</div>
|
||||
</>}
|
||||
{props.map}
|
||||
</div>
|
||||
</IconContext.Provider>;
|
||||
}
|
||||
}
|
||||
{props.bottom && <div className="maputnik-layout-bottom">
|
||||
{props.bottom}
|
||||
</div>
|
||||
}
|
||||
{props.modals}
|
||||
</div>
|
||||
</IconContext.Provider>;
|
||||
};
|
||||
|
||||
const AppLayout = withTranslation()(AppLayoutInternal);
|
||||
export default AppLayout;
|
||||
export const AppLayout = withTranslation()(AppLayoutInternal);
|
||||
|
||||
@@ -13,53 +13,49 @@ type AppMessagePanelInternalProps = {
|
||||
selectedLayerIndex?: number
|
||||
} & WithTranslation;
|
||||
|
||||
class AppMessagePanelInternal extends React.Component<AppMessagePanelInternalProps> {
|
||||
static defaultProps = {
|
||||
onLayerSelect: () => { },
|
||||
};
|
||||
const AppMessagePanelInternal: React.FC<AppMessagePanelInternalProps> = ({
|
||||
onLayerSelect = () => { },
|
||||
...props
|
||||
}) => {
|
||||
const { t, selectedLayerIndex } = props;
|
||||
const errors = props.errors?.map((error, idx) => {
|
||||
let content;
|
||||
if (error.parsed && error.parsed.type === "layer") {
|
||||
const { parsed } = error;
|
||||
const layerId = props.mapStyle?.layers[parsed.data.index].id;
|
||||
content = (
|
||||
<>
|
||||
{t("Layer")} <span>{formatLayerId(layerId)}</span>: {parsed.data.message}
|
||||
{selectedLayerIndex !== parsed.data.index &&
|
||||
<>
|
||||
—
|
||||
<button
|
||||
className="maputnik-message-panel__switch-button"
|
||||
onClick={() => onLayerSelect!(parsed.data.index)}
|
||||
>
|
||||
{t("switch to layer")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
else {
|
||||
content = error.message;
|
||||
}
|
||||
return <p key={"error-" + idx} className="maputnik-message-panel-error">
|
||||
{content}
|
||||
</p>;
|
||||
});
|
||||
|
||||
render() {
|
||||
const { t, selectedLayerIndex } = this.props;
|
||||
const errors = this.props.errors?.map((error, idx) => {
|
||||
let content;
|
||||
if (error.parsed && error.parsed.type === "layer") {
|
||||
const { parsed } = error;
|
||||
const layerId = this.props.mapStyle?.layers[parsed.data.index].id;
|
||||
content = (
|
||||
<>
|
||||
{t("Layer")} <span>{formatLayerId(layerId)}</span>: {parsed.data.message}
|
||||
{selectedLayerIndex !== parsed.data.index &&
|
||||
<>
|
||||
—
|
||||
<button
|
||||
className="maputnik-message-panel__switch-button"
|
||||
onClick={() => this.props.onLayerSelect!(parsed.data.index)}
|
||||
>
|
||||
{t("switch to layer")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
else {
|
||||
content = error.message;
|
||||
}
|
||||
return <p key={"error-" + idx} className="maputnik-message-panel-error">
|
||||
{content}
|
||||
</p>;
|
||||
});
|
||||
const infos = props.infos?.map((m, i) => {
|
||||
return <p key={"info-" + i}>{m}</p>;
|
||||
});
|
||||
|
||||
const infos = this.props.infos?.map((m, i) => {
|
||||
return <p key={"info-" + i}>{m}</p>;
|
||||
});
|
||||
return <div className="maputnik-message-panel">
|
||||
{errors}
|
||||
{infos}
|
||||
</div>;
|
||||
};
|
||||
|
||||
return <div className="maputnik-message-panel">
|
||||
{errors}
|
||||
{infos}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
const AppMessagePanel = withTranslation()(AppMessagePanelInternal);
|
||||
export default AppMessagePanel;
|
||||
export const AppMessagePanel = withTranslation()(AppMessagePanelInternal);
|
||||
|
||||
+192
-213
@@ -31,11 +31,9 @@ type IconTextProps = {
|
||||
};
|
||||
|
||||
|
||||
class IconText extends React.Component<IconTextProps> {
|
||||
render() {
|
||||
return <span className="maputnik-icon-text">{this.props.children}</span>;
|
||||
}
|
||||
}
|
||||
const IconText: React.FC<IconTextProps> = (props) => {
|
||||
return <span className="maputnik-icon-text">{props.children}</span>;
|
||||
};
|
||||
|
||||
type ToolbarLinkProps = {
|
||||
className?: string
|
||||
@@ -43,35 +41,31 @@ type ToolbarLinkProps = {
|
||||
href?: string
|
||||
};
|
||||
|
||||
class ToolbarLink extends React.Component<ToolbarLinkProps> {
|
||||
render() {
|
||||
return <a
|
||||
className={classnames("maputnik-toolbar-link", this.props.className)}
|
||||
href={this.props.href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
data-wd-key="toolbar:link"
|
||||
>
|
||||
{this.props.children}
|
||||
</a>;
|
||||
}
|
||||
}
|
||||
const ToolbarLink: React.FC<ToolbarLinkProps> = (props) => {
|
||||
return <a
|
||||
className={classnames("maputnik-toolbar-link", props.className)}
|
||||
href={props.href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
data-wd-key="toolbar:link"
|
||||
>
|
||||
{props.children}
|
||||
</a>;
|
||||
};
|
||||
|
||||
type ToolbarSelectProps = {
|
||||
children?: React.ReactNode
|
||||
wdKey?: string
|
||||
};
|
||||
|
||||
class ToolbarSelect extends React.Component<ToolbarSelectProps> {
|
||||
render() {
|
||||
return <div
|
||||
className='maputnik-toolbar-select'
|
||||
data-wd-key={this.props.wdKey}
|
||||
>
|
||||
{this.props.children}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
const ToolbarSelect: React.FC<ToolbarSelectProps> = (props) => {
|
||||
return <div
|
||||
className='maputnik-toolbar-select'
|
||||
data-wd-key={props.wdKey}
|
||||
>
|
||||
{props.children}
|
||||
</div>;
|
||||
};
|
||||
|
||||
type ToolbarActionProps = {
|
||||
children?: React.ReactNode
|
||||
@@ -79,17 +73,15 @@ type ToolbarActionProps = {
|
||||
wdKey?: string
|
||||
};
|
||||
|
||||
class ToolbarAction extends React.Component<ToolbarActionProps> {
|
||||
render() {
|
||||
return <button
|
||||
className='maputnik-toolbar-action'
|
||||
data-wd-key={this.props.wdKey}
|
||||
onClick={this.props.onClick}
|
||||
>
|
||||
{this.props.children}
|
||||
</button>;
|
||||
}
|
||||
}
|
||||
const ToolbarAction: React.FC<ToolbarActionProps> = (props) => {
|
||||
return <button
|
||||
className='maputnik-toolbar-action'
|
||||
data-wd-key={props.wdKey}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.children}
|
||||
</button>;
|
||||
};
|
||||
|
||||
export type MapState = "map" | "inspect" | "filter-achromatopsia" | "filter-deuteranopia" | "filter-protanopia" | "filter-tritanopia";
|
||||
|
||||
@@ -108,26 +100,16 @@ type AppToolbarInternalProps = {
|
||||
renderer?: string
|
||||
} & WithTranslation;
|
||||
|
||||
class AppToolbarInternal extends React.Component<AppToolbarInternalProps> {
|
||||
state = {
|
||||
isOpen: {
|
||||
settings: false,
|
||||
sources: false,
|
||||
open: false,
|
||||
add: false,
|
||||
export: false,
|
||||
}
|
||||
};
|
||||
|
||||
handleSelection(val: MapState) {
|
||||
this.props.onSetMapState(val);
|
||||
const AppToolbarInternal: React.FC<AppToolbarInternalProps> = (props) => {
|
||||
function handleSelection(val: MapState) {
|
||||
props.onSetMapState(val);
|
||||
}
|
||||
|
||||
handleLanguageChange(val: string) {
|
||||
this.props.i18n.changeLanguage(val);
|
||||
function handleLanguageChange(val: string) {
|
||||
props.i18n.changeLanguage(val);
|
||||
}
|
||||
|
||||
onSkip = (target: string) => {
|
||||
const onSkip = (target: string) => {
|
||||
if (target === "map") {
|
||||
(document.querySelector(".maplibregl-canvas") as HTMLCanvasElement).focus();
|
||||
}
|
||||
@@ -137,174 +119,171 @@ class AppToolbarInternal extends React.Component<AppToolbarInternalProps> {
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
const views = [
|
||||
{
|
||||
id: "map",
|
||||
group: "general",
|
||||
title: t("Map"),
|
||||
},
|
||||
{
|
||||
id: "inspect",
|
||||
group: "general",
|
||||
title: t("Inspect"),
|
||||
disabled: this.props.renderer === "ol",
|
||||
},
|
||||
{
|
||||
id: "filter-deuteranopia",
|
||||
group: "color-accessibility",
|
||||
title: t("Deuteranopia filter"),
|
||||
disabled: !colorAccessibilityFiltersEnabled,
|
||||
},
|
||||
{
|
||||
id: "filter-protanopia",
|
||||
group: "color-accessibility",
|
||||
title: t("Protanopia filter"),
|
||||
disabled: !colorAccessibilityFiltersEnabled,
|
||||
},
|
||||
{
|
||||
id: "filter-tritanopia",
|
||||
group: "color-accessibility",
|
||||
title: t("Tritanopia filter"),
|
||||
disabled: !colorAccessibilityFiltersEnabled,
|
||||
},
|
||||
{
|
||||
id: "filter-achromatopsia",
|
||||
group: "color-accessibility",
|
||||
title: t("Achromatopsia filter"),
|
||||
disabled: !colorAccessibilityFiltersEnabled,
|
||||
},
|
||||
];
|
||||
const t = props.t;
|
||||
const views = [
|
||||
{
|
||||
id: "map",
|
||||
group: "general",
|
||||
title: t("Map"),
|
||||
},
|
||||
{
|
||||
id: "inspect",
|
||||
group: "general",
|
||||
title: t("Inspect"),
|
||||
disabled: props.renderer === "ol",
|
||||
},
|
||||
{
|
||||
id: "filter-deuteranopia",
|
||||
group: "color-accessibility",
|
||||
title: t("Deuteranopia filter"),
|
||||
disabled: !colorAccessibilityFiltersEnabled,
|
||||
},
|
||||
{
|
||||
id: "filter-protanopia",
|
||||
group: "color-accessibility",
|
||||
title: t("Protanopia filter"),
|
||||
disabled: !colorAccessibilityFiltersEnabled,
|
||||
},
|
||||
{
|
||||
id: "filter-tritanopia",
|
||||
group: "color-accessibility",
|
||||
title: t("Tritanopia filter"),
|
||||
disabled: !colorAccessibilityFiltersEnabled,
|
||||
},
|
||||
{
|
||||
id: "filter-achromatopsia",
|
||||
group: "color-accessibility",
|
||||
title: t("Achromatopsia filter"),
|
||||
disabled: !colorAccessibilityFiltersEnabled,
|
||||
},
|
||||
];
|
||||
|
||||
const currentView = views.find((view) => {
|
||||
return view.id === this.props.mapState;
|
||||
});
|
||||
const currentView = views.find((view) => {
|
||||
return view.id === props.mapState;
|
||||
});
|
||||
|
||||
return <nav className='maputnik-toolbar'>
|
||||
<div className="maputnik-toolbar__inner">
|
||||
<div
|
||||
className="maputnik-toolbar-logo-container"
|
||||
return <nav className='maputnik-toolbar'>
|
||||
<div className="maputnik-toolbar__inner">
|
||||
<div
|
||||
className="maputnik-toolbar-logo-container"
|
||||
>
|
||||
{/* Keyboard accessible quick links */}
|
||||
<button
|
||||
data-wd-key="root:skip:layer-list"
|
||||
className="maputnik-toolbar-skip"
|
||||
onClick={_e => onSkip("layer-list")}
|
||||
>
|
||||
{/* Keyboard accessible quick links */}
|
||||
<button
|
||||
data-wd-key="root:skip:layer-list"
|
||||
className="maputnik-toolbar-skip"
|
||||
onClick={_e => this.onSkip("layer-list")}
|
||||
>
|
||||
{t("Layers list")}
|
||||
</button>
|
||||
<button
|
||||
data-wd-key="root:skip:layer-editor"
|
||||
className="maputnik-toolbar-skip"
|
||||
onClick={_e => this.onSkip("layer-editor")}
|
||||
>
|
||||
{t("Layer editor")}
|
||||
</button>
|
||||
<button
|
||||
data-wd-key="root:skip:map-view"
|
||||
className="maputnik-toolbar-skip"
|
||||
onClick={_e => this.onSkip("map")}
|
||||
>
|
||||
{t("Map view")}
|
||||
</button>
|
||||
<a
|
||||
className="maputnik-toolbar-logo"
|
||||
target="blank"
|
||||
rel="noreferrer noopener"
|
||||
href="https://github.com/maplibre/maputnik"
|
||||
>
|
||||
<img src={maputnikLogo} alt={t("Maputnik on GitHub")} />
|
||||
<h1>
|
||||
<span className="maputnik-toolbar-name">{pkgJson.name}</span>
|
||||
<span className="maputnik-toolbar-version">v{pkgJson.version}</span>
|
||||
</h1>
|
||||
</a>
|
||||
</div>
|
||||
<div className="maputnik-toolbar__actions" role="navigation" aria-label="Toolbar">
|
||||
<ToolbarAction wdKey="nav:open" onClick={() => this.props.onToggleModal("open")}>
|
||||
<MdOpenInBrowser />
|
||||
<IconText>{t("Open")}</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction wdKey="nav:export" onClick={() => this.props.onToggleModal("export")}>
|
||||
<MdSave />
|
||||
<IconText>{t("Save")}</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction wdKey="nav:code-editor" onClick={() => this.props.onToggleModal("codeEditor")}>
|
||||
<MdCode />
|
||||
<IconText>{t("Code Editor")}</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction wdKey="nav:sources" onClick={() => this.props.onToggleModal("sources")}>
|
||||
<MdLayers />
|
||||
<IconText>{t("Data Sources")}</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction wdKey="nav:settings" onClick={() => this.props.onToggleModal("settings")}>
|
||||
<MdSettings />
|
||||
<IconText>{t("Style Settings")}</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction wdKey="nav:global-state" onClick={() => this.props.onToggleModal("globalState")}>
|
||||
<MdPublic />
|
||||
<IconText>{t("Global State")}</IconText>
|
||||
</ToolbarAction>
|
||||
{t("Layers list")}
|
||||
</button>
|
||||
<button
|
||||
data-wd-key="root:skip:layer-editor"
|
||||
className="maputnik-toolbar-skip"
|
||||
onClick={_e => onSkip("layer-editor")}
|
||||
>
|
||||
{t("Layer editor")}
|
||||
</button>
|
||||
<button
|
||||
data-wd-key="root:skip:map-view"
|
||||
className="maputnik-toolbar-skip"
|
||||
onClick={_e => onSkip("map")}
|
||||
>
|
||||
{t("Map view")}
|
||||
</button>
|
||||
<a
|
||||
className="maputnik-toolbar-logo"
|
||||
target="blank"
|
||||
rel="noreferrer noopener"
|
||||
href="https://github.com/maplibre/maputnik"
|
||||
>
|
||||
<img src={maputnikLogo} alt={t("Maputnik on GitHub")} />
|
||||
<h1>
|
||||
<span className="maputnik-toolbar-name">{pkgJson.name}</span>
|
||||
<span className="maputnik-toolbar-version">v{pkgJson.version}</span>
|
||||
</h1>
|
||||
</a>
|
||||
</div>
|
||||
<div className="maputnik-toolbar__actions" role="navigation" aria-label="Toolbar">
|
||||
<ToolbarAction wdKey="nav:open" onClick={() => props.onToggleModal("open")}>
|
||||
<MdOpenInBrowser />
|
||||
<IconText>{t("Open")}</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction wdKey="nav:export" onClick={() => props.onToggleModal("export")}>
|
||||
<MdSave />
|
||||
<IconText>{t("Save")}</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction wdKey="nav:code-editor" onClick={() => props.onToggleModal("codeEditor")}>
|
||||
<MdCode />
|
||||
<IconText>{t("Code Editor")}</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction wdKey="nav:sources" onClick={() => props.onToggleModal("sources")}>
|
||||
<MdLayers />
|
||||
<IconText>{t("Data Sources")}</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction wdKey="nav:settings" onClick={() => props.onToggleModal("settings")}>
|
||||
<MdSettings />
|
||||
<IconText>{t("Style Settings")}</IconText>
|
||||
</ToolbarAction>
|
||||
<ToolbarAction wdKey="nav:global-state" onClick={() => props.onToggleModal("globalState")}>
|
||||
<MdPublic />
|
||||
<IconText>{t("Global State")}</IconText>
|
||||
</ToolbarAction>
|
||||
|
||||
<ToolbarSelect wdKey="nav:inspect">
|
||||
<MdFindInPage />
|
||||
<IconText>{t("View")}
|
||||
<select
|
||||
className="maputnik-select"
|
||||
data-wd-key="maputnik-select"
|
||||
onChange={(e) => this.handleSelection(e.target.value as MapState)}
|
||||
value={currentView?.id}
|
||||
>
|
||||
{views.filter(v => v.group === "general").map((item) => {
|
||||
<ToolbarSelect wdKey="nav:inspect">
|
||||
<MdFindInPage />
|
||||
<IconText>{t("View")}
|
||||
<select
|
||||
className="maputnik-select"
|
||||
data-wd-key="maputnik-select"
|
||||
onChange={(e) => handleSelection(e.target.value as MapState)}
|
||||
value={currentView?.id}
|
||||
>
|
||||
{views.filter(v => v.group === "general").map((item) => {
|
||||
return (
|
||||
<option key={item.id} value={item.id} disabled={item.disabled} data-wd-key={item.id}>
|
||||
{item.title}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
<optgroup label={t("Color accessibility")}>
|
||||
{views.filter(v => v.group === "color-accessibility").map((item) => {
|
||||
return (
|
||||
<option key={item.id} value={item.id} disabled={item.disabled} data-wd-key={item.id}>
|
||||
<option key={item.id} value={item.id} disabled={item.disabled}>
|
||||
{item.title}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
<optgroup label={t("Color accessibility")}>
|
||||
{views.filter(v => v.group === "color-accessibility").map((item) => {
|
||||
return (
|
||||
<option key={item.id} value={item.id} disabled={item.disabled}>
|
||||
{item.title}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</optgroup>
|
||||
</select>
|
||||
</IconText>
|
||||
</ToolbarSelect>
|
||||
</optgroup>
|
||||
</select>
|
||||
</IconText>
|
||||
</ToolbarSelect>
|
||||
|
||||
<ToolbarSelect wdKey="nav:language">
|
||||
<MdLanguage />
|
||||
<IconText>Language
|
||||
<select
|
||||
className="maputnik-select"
|
||||
data-wd-key="maputnik-lang-select"
|
||||
onChange={(e) => this.handleLanguageChange(e.target.value)}
|
||||
value={this.props.i18n.language}
|
||||
>
|
||||
{Object.entries(supportedLanguages).map(([code, name]) => {
|
||||
return (
|
||||
<option key={code} value={code}>
|
||||
{name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</IconText>
|
||||
</ToolbarSelect>
|
||||
<ToolbarSelect wdKey="nav:language">
|
||||
<MdLanguage />
|
||||
<IconText>Language
|
||||
<select
|
||||
className="maputnik-select"
|
||||
data-wd-key="maputnik-lang-select"
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
value={props.i18n.language}
|
||||
>
|
||||
{Object.entries(supportedLanguages).map(([code, name]) => {
|
||||
return (
|
||||
<option key={code} value={code}>
|
||||
{name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</IconText>
|
||||
</ToolbarSelect>
|
||||
|
||||
<ToolbarLink href={"https://github.com/maplibre/maputnik/wiki"}>
|
||||
<MdHelpOutline />
|
||||
<IconText>{t("Help")}</IconText>
|
||||
</ToolbarLink>
|
||||
</div>
|
||||
<ToolbarLink href={"https://github.com/maplibre/maputnik/wiki"}>
|
||||
<MdHelpOutline />
|
||||
<IconText>{t("Help")}</IconText>
|
||||
</ToolbarLink>
|
||||
</div>
|
||||
</nav>;
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</nav>;
|
||||
};
|
||||
|
||||
const AppToolbar = withTranslation()(AppToolbarInternal);
|
||||
export default AppToolbar;
|
||||
export const AppToolbar = withTranslation()(AppToolbarInternal);
|
||||
|
||||
+48
-69
@@ -1,7 +1,7 @@
|
||||
import React, {type CSSProperties, type PropsWithChildren, type SyntheticEvent} from "react";
|
||||
import React, {type CSSProperties, type PropsWithChildren, type SyntheticEvent, useRef, useState} from "react";
|
||||
import classnames from "classnames";
|
||||
import FieldDocLabel from "./FieldDocLabel";
|
||||
import Doc from "./Doc";
|
||||
import { FieldDocLabel } from "./FieldDocLabel";
|
||||
import { Doc } from "./Doc";
|
||||
|
||||
export type BlockProps = PropsWithChildren & {
|
||||
"data-wd-key"?: string
|
||||
@@ -14,32 +14,13 @@ export type BlockProps = PropsWithChildren & {
|
||||
error?: {message: string}
|
||||
};
|
||||
|
||||
type BlockState = {
|
||||
showDoc: boolean
|
||||
};
|
||||
|
||||
/** Wrap a component with a label */
|
||||
export default class Block extends React.Component<BlockProps, BlockState> {
|
||||
_blockEl: HTMLDivElement | null = null;
|
||||
export const Block: React.FC<BlockProps> = (props) => {
|
||||
const [showDoc, setShowDoc] = useState(false);
|
||||
const blockEl = useRef<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
|
||||
});
|
||||
const onToggleDoc = (val: boolean) => {
|
||||
setShowDoc(val);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -48,9 +29,9 @@ export default class Block extends React.Component<BlockProps, BlockState> {
|
||||
* causing the picker to reopen. This causes a scenario where the picker can
|
||||
* never be closed once open.
|
||||
*/
|
||||
onLabelClick = (event: SyntheticEvent<any, any>) => {
|
||||
const onLabelClick = (event: SyntheticEvent<any, any>) => {
|
||||
const el = event.nativeEvent.target;
|
||||
const contains = this._blockEl?.contains(el);
|
||||
const contains = blockEl.current?.contains(el);
|
||||
|
||||
if (event.nativeEvent.target.nodeName !== "INPUT" && !contains) {
|
||||
event.stopPropagation();
|
||||
@@ -60,45 +41,43 @@ export default class Block extends React.Component<BlockProps, BlockState> {
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return <label style={this.props.style}
|
||||
data-wd-key={this.props["data-wd-key"]}
|
||||
className={classnames({
|
||||
"maputnik-input-block": true,
|
||||
"maputnik-input-block--wide": this.props.wideMode,
|
||||
"maputnik-action-block": this.props.action,
|
||||
"maputnik-input-block--error": this.props.error
|
||||
})}
|
||||
onClick={this.onLabelClick}
|
||||
>
|
||||
{this.props.fieldSpec &&
|
||||
<div className="maputnik-input-block-label">
|
||||
<FieldDocLabel
|
||||
label={this.props.label}
|
||||
onToggleDoc={this.onToggleDoc}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{!this.props.fieldSpec &&
|
||||
<div className="maputnik-input-block-label">
|
||||
{this.props.label}
|
||||
</div>
|
||||
}
|
||||
<div className="maputnik-input-block-action">
|
||||
{this.props.action}
|
||||
return <label style={props.style}
|
||||
data-wd-key={props["data-wd-key"]}
|
||||
className={classnames({
|
||||
"maputnik-input-block": true,
|
||||
"maputnik-input-block--wide": props.wideMode,
|
||||
"maputnik-action-block": props.action,
|
||||
"maputnik-input-block--error": props.error
|
||||
})}
|
||||
onClick={onLabelClick}
|
||||
>
|
||||
{props.fieldSpec &&
|
||||
<div className="maputnik-input-block-label">
|
||||
<FieldDocLabel
|
||||
label={props.label}
|
||||
onToggleDoc={onToggleDoc}
|
||||
fieldSpec={props.fieldSpec}
|
||||
/>
|
||||
</div>
|
||||
<div className="maputnik-input-block-content" ref={el => {this._blockEl = el;}}>
|
||||
{this.props.children}
|
||||
}
|
||||
{!props.fieldSpec &&
|
||||
<div className="maputnik-input-block-label">
|
||||
{props.label}
|
||||
</div>
|
||||
{this.props.fieldSpec &&
|
||||
<div
|
||||
className="maputnik-doc-inline"
|
||||
style={{display: this.state.showDoc ? "" : "none"}}
|
||||
>
|
||||
<Doc fieldSpec={this.props.fieldSpec} />
|
||||
</div>
|
||||
}
|
||||
</label>;
|
||||
}
|
||||
}
|
||||
}
|
||||
<div className="maputnik-input-block-action">
|
||||
{props.action}
|
||||
</div>
|
||||
<div className="maputnik-input-block-content" ref={blockEl}>
|
||||
{props.children}
|
||||
</div>
|
||||
{props.fieldSpec &&
|
||||
<div
|
||||
className="maputnik-doc-inline"
|
||||
style={{display: showDoc ? "" : "none"}}
|
||||
>
|
||||
<Doc fieldSpec={props.fieldSpec} />
|
||||
</div>
|
||||
}
|
||||
</label>;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import InputJson from "./InputJson";
|
||||
import { InputJson } from "./InputJson";
|
||||
import React from "react";
|
||||
import { withTranslation, type WithTranslation } from "react-i18next";
|
||||
import { type StyleSpecification } from "maplibre-gl";
|
||||
@@ -24,6 +24,4 @@ const CodeEditorInternal: React.FC<CodeEditorProps> = (props) => {
|
||||
</>;
|
||||
};
|
||||
|
||||
const CodeEditor = withTranslation()(CodeEditorInternal);
|
||||
|
||||
export default CodeEditor;
|
||||
export const CodeEditor = withTranslation()(CodeEditorInternal);
|
||||
|
||||
+15
-21
@@ -9,25 +9,19 @@ type CollapseProps = {
|
||||
};
|
||||
|
||||
|
||||
export default class Collapse extends React.Component<CollapseProps> {
|
||||
static defaultProps = {
|
||||
isActive: true
|
||||
};
|
||||
|
||||
render() {
|
||||
if (reducedMotionEnabled()) {
|
||||
return (
|
||||
<div style={{display: this.props.isActive ? "block" : "none"}}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
else {
|
||||
return (
|
||||
<ReactCollapse isOpened={this.props.isActive}>
|
||||
{this.props.children}
|
||||
</ReactCollapse>
|
||||
);
|
||||
}
|
||||
export const Collapse: React.FC<CollapseProps> = ({isActive = true, children}) => {
|
||||
if (reducedMotionEnabled()) {
|
||||
return (
|
||||
<div style={{display: isActive ? "block" : "none"}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
else {
|
||||
return (
|
||||
<ReactCollapse isOpened={isActive}>
|
||||
{children}
|
||||
</ReactCollapse>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,13 +6,11 @@ type CollapserProps = {
|
||||
style?: object
|
||||
};
|
||||
|
||||
export default class Collapser extends React.Component<CollapserProps> {
|
||||
render() {
|
||||
const iconStyle = {
|
||||
width: 20,
|
||||
height: 20,
|
||||
...this.props.style,
|
||||
};
|
||||
return this.props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} />;
|
||||
}
|
||||
}
|
||||
export const Collapser: React.FC<CollapserProps> = (props) => {
|
||||
const iconStyle = {
|
||||
width: 20,
|
||||
height: 20,
|
||||
...props.style,
|
||||
};
|
||||
return props.isCollapsed ? <MdArrowDropUp style={iconStyle}/> : <MdArrowDropDown style={iconStyle} />;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
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);
|
||||
@@ -0,0 +1,24 @@
|
||||
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);
|
||||
+76
-80
@@ -23,88 +23,84 @@ type DocProps = {
|
||||
}
|
||||
};
|
||||
|
||||
export default class Doc extends React.Component<DocProps> {
|
||||
render () {
|
||||
const {fieldSpec} = this.props;
|
||||
export const Doc: React.FC<DocProps> = ({fieldSpec}) => {
|
||||
const {doc, values, docUrl, docUrlLinkText} = fieldSpec;
|
||||
const sdkSupport = fieldSpec["sdk-support"];
|
||||
|
||||
const {doc, values, docUrl, docUrlLinkText} = fieldSpec;
|
||||
const sdkSupport = fieldSpec["sdk-support"];
|
||||
const renderValues = (
|
||||
!!values &&
|
||||
// HACK: Currently we merge additional values into the style spec, so this is required
|
||||
// See <https://github.com/maplibre/maputnik/blob/main/src/components/PropertyGroup.jsx#L16>
|
||||
!Array.isArray(values)
|
||||
);
|
||||
|
||||
const renderValues = (
|
||||
!!values &&
|
||||
// HACK: Currently we merge additional values into the style spec, so this is required
|
||||
// See <https://github.com/maplibre/maputnik/blob/main/src/components/PropertyGroup.jsx#L16>
|
||||
!Array.isArray(values)
|
||||
);
|
||||
const sdkSupportToJsx = (value: string) => {
|
||||
const supportValue = value.toLowerCase();
|
||||
if (supportValue.startsWith("https://")) {
|
||||
return <a href={supportValue} target="_blank" rel="noreferrer">{"#" + supportValue.split("/").pop()}</a>;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const sdkSupportToJsx = (value: string) => {
|
||||
const supportValue = value.toLowerCase();
|
||||
if (supportValue.startsWith("https://")) {
|
||||
return <a href={supportValue} target="_blank" rel="noreferrer">{"#" + supportValue.split("/").pop()}</a>;
|
||||
return (
|
||||
<>
|
||||
{doc &&
|
||||
<div className="SpecDoc">
|
||||
<div className="SpecDoc__doc" data-wd-key='spec-field-doc'>
|
||||
<Markdown components={{
|
||||
a: ({node: _node, href, children, ...props}) => <a href={href} target="_blank" {...props}>{children}</a>,
|
||||
}}>{doc}</Markdown>
|
||||
</div>
|
||||
{renderValues &&
|
||||
<ul className="SpecDoc__values">
|
||||
{Object.entries(values).map(([key, value]) => {
|
||||
return (
|
||||
<li key={key}>
|
||||
<code>{JSON.stringify(key)}</code>
|
||||
<div>{value.doc}</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{doc &&
|
||||
<div className="SpecDoc">
|
||||
<div className="SpecDoc__doc" data-wd-key='spec-field-doc'>
|
||||
<Markdown components={{
|
||||
a: ({node: _node, href, children, ...props}) => <a href={href} target="_blank" {...props}>{children}</a>,
|
||||
}}>{doc}</Markdown>
|
||||
</div>
|
||||
{renderValues &&
|
||||
<ul className="SpecDoc__values">
|
||||
{Object.entries(values).map(([key, value]) => {
|
||||
return (
|
||||
<li key={key}>
|
||||
<code>{JSON.stringify(key)}</code>
|
||||
<div>{value.doc}</div>
|
||||
</li>
|
||||
);
|
||||
{sdkSupport &&
|
||||
<div className="SpecDoc__sdk-support">
|
||||
<table className="SpecDoc__sdk-support__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
{Object.values(headers).map(header => {
|
||||
return <th key={header}>{header}</th>;
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
{sdkSupport &&
|
||||
<div className="SpecDoc__sdk-support">
|
||||
<table className="SpecDoc__sdk-support__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
{Object.values(headers).map(header => {
|
||||
return <th key={header}>{header}</th>;
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(sdkSupport).map(([key, supportObj]) => {
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>{key}</td>
|
||||
{Object.keys(headers).map((k) => {
|
||||
if (Object.prototype.hasOwnProperty.call(supportObj, k)) {
|
||||
return <td key={k}>{sdkSupportToJsx(supportObj[k as keyof typeof headers])}</td>;
|
||||
}
|
||||
else {
|
||||
return <td key={k}>no</td>;
|
||||
}
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
{docUrl && docUrlLinkText &&
|
||||
<div className="SpecDoc__learn-more">
|
||||
<a href={docUrl} target="_blank" rel="noreferrer">{docUrlLinkText}</a>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(sdkSupport).map(([key, supportObj]) => {
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>{key}</td>
|
||||
{Object.keys(headers).map((k) => {
|
||||
if (Object.prototype.hasOwnProperty.call(supportObj, k)) {
|
||||
return <td key={k}>{sdkSupportToJsx(supportObj[k as keyof typeof headers])}</td>;
|
||||
}
|
||||
else {
|
||||
return <td key={k}>no</td>;
|
||||
}
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
{docUrl && docUrlLinkText &&
|
||||
<div className="SpecDoc__learn-more">
|
||||
<a href={docUrl} target="_blank" rel="noreferrer">{docUrlLinkText}</a>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
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);
|
||||
@@ -1,5 +1,5 @@
|
||||
import InputArray, { type InputArrayProps } from "./InputArray";
|
||||
import Fieldset from "./Fieldset";
|
||||
import { InputArray, type InputArrayProps } from "./InputArray";
|
||||
import { Fieldset } from "./Fieldset";
|
||||
|
||||
type FieldArrayProps = InputArrayProps & {
|
||||
name?: string
|
||||
@@ -8,12 +8,10 @@ type FieldArrayProps = InputArrayProps & {
|
||||
}
|
||||
};
|
||||
|
||||
const FieldArray: React.FC<FieldArrayProps> = (props) => {
|
||||
export const FieldArray: React.FC<FieldArrayProps> = (props) => {
|
||||
return (
|
||||
<Fieldset label={props.label} fieldSpec={props.fieldSpec}>
|
||||
<InputArray {...props} />
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldArray;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Block from "./Block";
|
||||
import InputAutocomplete, { type InputAutocompleteProps } from "./InputAutocomplete";
|
||||
import { Block } from "./Block";
|
||||
import { InputAutocomplete, type InputAutocompleteProps } from "./InputAutocomplete";
|
||||
|
||||
|
||||
type FieldAutocompleteProps = InputAutocompleteProps & {
|
||||
@@ -7,12 +7,10 @@ type FieldAutocompleteProps = InputAutocompleteProps & {
|
||||
};
|
||||
|
||||
|
||||
const FieldAutocomplete: React.FC<FieldAutocompleteProps> = (props) => {
|
||||
export const FieldAutocomplete: React.FC<FieldAutocompleteProps> = (props) => {
|
||||
return (
|
||||
<Block label={props.label}>
|
||||
<InputAutocomplete {...props} />
|
||||
</Block>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldAutocomplete;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Block from "./Block";
|
||||
import InputCheckbox, {type InputCheckboxProps} from "./InputCheckbox";
|
||||
import { Block } from "./Block";
|
||||
import { InputCheckbox, type InputCheckboxProps } from "./InputCheckbox";
|
||||
|
||||
|
||||
type FieldCheckboxProps = InputCheckboxProps & {
|
||||
@@ -7,12 +7,10 @@ type FieldCheckboxProps = InputCheckboxProps & {
|
||||
};
|
||||
|
||||
|
||||
const FieldCheckbox: React.FC<FieldCheckboxProps> = (props) => {
|
||||
export const FieldCheckbox: React.FC<FieldCheckboxProps> = (props) => {
|
||||
return (
|
||||
<Block label={props.label}>
|
||||
<InputCheckbox {...props} />
|
||||
</Block>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldCheckbox;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Block from "./Block";
|
||||
import InputColor, {type InputColorProps} from "./InputColor";
|
||||
import { Block } from "./Block";
|
||||
import { InputColor, type InputColorProps } from "./InputColor";
|
||||
|
||||
|
||||
type FieldColorProps = InputColorProps & {
|
||||
@@ -10,12 +10,10 @@ type FieldColorProps = InputColorProps & {
|
||||
};
|
||||
|
||||
|
||||
const FieldColor: React.FC<FieldColorProps> = (props) => {
|
||||
export const FieldColor: React.FC<FieldColorProps> = (props) => {
|
||||
return (
|
||||
<Block label={props.label} fieldSpec={props.fieldSpec}>
|
||||
<InputColor {...props} />
|
||||
</Block>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldColor;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
import Block from "./Block";
|
||||
import InputString from "./InputString";
|
||||
import { Block } from "./Block";
|
||||
import { InputString } from "./InputString";
|
||||
import { type WithTranslation, withTranslation } from "react-i18next";
|
||||
|
||||
type FieldCommentInternalProps = {
|
||||
@@ -36,5 +36,4 @@ const FieldCommentInternal: React.FC<FieldCommentInternalProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const FieldComment = withTranslation()(FieldCommentInternal);
|
||||
export default FieldComment;
|
||||
export const FieldComment = withTranslation()(FieldCommentInternal);
|
||||
|
||||
@@ -10,7 +10,7 @@ type FieldDocLabelProps = {
|
||||
};
|
||||
|
||||
|
||||
const FieldDocLabel: React.FC<FieldDocLabelProps> = (props) => {
|
||||
export const FieldDocLabel: React.FC<FieldDocLabelProps> = (props) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const onToggleDoc = (state: boolean) => {
|
||||
@@ -49,5 +49,3 @@ const FieldDocLabel: React.FC<FieldDocLabelProps> = (props) => {
|
||||
}
|
||||
return <div />;
|
||||
};
|
||||
|
||||
export default FieldDocLabel;
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import InputDynamicArray, {type InputDynamicArrayProps} from "./InputDynamicArray";
|
||||
import Fieldset from "./Fieldset";
|
||||
import { InputDynamicArray, type InputDynamicArrayProps } from "./InputDynamicArray";
|
||||
import { Fieldset } from "./Fieldset";
|
||||
|
||||
type FieldDynamicArrayProps = InputDynamicArrayProps & {
|
||||
name?: string
|
||||
};
|
||||
|
||||
const FieldDynamicArray: React.FC<FieldDynamicArrayProps> = (props) => {
|
||||
export const FieldDynamicArray: React.FC<FieldDynamicArrayProps> = (props) => {
|
||||
return (
|
||||
<Fieldset label={props.label}>
|
||||
<InputDynamicArray {...props} />
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldDynamicArray;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import InputEnum, {type InputEnumProps} from "./InputEnum";
|
||||
import Fieldset from "./Fieldset";
|
||||
import { InputEnum, type InputEnumProps } from "./InputEnum";
|
||||
import { Fieldset } from "./Fieldset";
|
||||
|
||||
|
||||
type FieldEnumProps = InputEnumProps & {
|
||||
@@ -10,12 +10,10 @@ type FieldEnumProps = InputEnumProps & {
|
||||
};
|
||||
|
||||
|
||||
const FieldEnum: React.FC<FieldEnumProps> = (props) => {
|
||||
export const FieldEnum: React.FC<FieldEnumProps> = (props) => {
|
||||
return (
|
||||
<Fieldset label={props.label} fieldSpec={props.fieldSpec}>
|
||||
<InputEnum {...props} />
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldEnum;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
import SpecProperty from "./_SpecProperty";
|
||||
import DataProperty, { type Stop } from "./_DataProperty";
|
||||
import ZoomProperty from "./_ZoomProperty";
|
||||
import ExpressionProperty from "./_ExpressionProperty";
|
||||
import { SpecProperty } from "./SpecProperty";
|
||||
import { DataProperty, type Stop } from "./DataProperty";
|
||||
import { ZoomProperty } from "./ZoomProperty";
|
||||
import { ExpressionProperty } from "./ExpressionProperty";
|
||||
import {function as styleFunction} from "@maplibre/maplibre-gl-style-spec";
|
||||
import {findDefaultFromSpec} from "../libs/spec-helper";
|
||||
import { type MappedLayerErrors } from "../libs/definitions";
|
||||
@@ -128,7 +128,7 @@ type FieldFunctionProps = {
|
||||
/** Supports displaying spec field for zoom function objects
|
||||
* https://www.mapbox.com/mapbox-gl-style-spec/#types-function-zoom-property
|
||||
*/
|
||||
const FieldFunction: React.FC<FieldFunctionProps> = (props) => {
|
||||
export const FieldFunction: React.FC<FieldFunctionProps> = (props) => {
|
||||
const [dataType, setDataType] = React.useState(
|
||||
getDataType(props.value, props.fieldSpec)
|
||||
);
|
||||
@@ -402,5 +402,3 @@ const FieldFunction: React.FC<FieldFunctionProps> = (props) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldFunction;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
|
||||
import Block from "./Block";
|
||||
import InputString from "./InputString";
|
||||
import { Block } from "./Block";
|
||||
import { InputString } from "./InputString";
|
||||
|
||||
type FieldIdProps = {
|
||||
value: string
|
||||
@@ -10,7 +10,7 @@ type FieldIdProps = {
|
||||
error?: {message: string}
|
||||
};
|
||||
|
||||
const FieldId: React.FC<FieldIdProps> = (props) => {
|
||||
export const FieldId: React.FC<FieldIdProps> = (props) => {
|
||||
return (
|
||||
<Block label="ID" fieldSpec={latest.layer.id}
|
||||
data-wd-key={props.wdKey}
|
||||
@@ -24,5 +24,3 @@ const FieldId: React.FC<FieldIdProps> = (props) => {
|
||||
</Block>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldId;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import InputJson, {type InputJsonProps} from "./InputJson";
|
||||
import { InputJson, type InputJsonProps } from "./InputJson";
|
||||
|
||||
|
||||
type FieldJsonProps = InputJsonProps & {};
|
||||
|
||||
|
||||
const FieldJson: React.FC<FieldJsonProps> = (props) => {
|
||||
export const FieldJson: React.FC<FieldJsonProps> = (props) => {
|
||||
return <InputJson {...props} />;
|
||||
};
|
||||
|
||||
export default FieldJson;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
|
||||
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
|
||||
import Block from "./Block";
|
||||
import InputNumber from "./InputNumber";
|
||||
import { Block } from "./Block";
|
||||
import { InputNumber } from "./InputNumber";
|
||||
import { type WithTranslation, withTranslation } from "react-i18next";
|
||||
|
||||
type FieldMaxZoomInternalProps = {
|
||||
@@ -31,5 +31,4 @@ const FieldMaxZoomInternal: React.FC<FieldMaxZoomInternalProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const FieldMaxZoom = withTranslation()(FieldMaxZoomInternal);
|
||||
export default FieldMaxZoom;
|
||||
export const FieldMaxZoom = withTranslation()(FieldMaxZoomInternal);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
|
||||
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
|
||||
import Block from "./Block";
|
||||
import InputNumber from "./InputNumber";
|
||||
import { Block } from "./Block";
|
||||
import { InputNumber } from "./InputNumber";
|
||||
import { type WithTranslation, withTranslation } from "react-i18next";
|
||||
|
||||
type FieldMinZoomInternalProps = {
|
||||
@@ -31,5 +31,4 @@ const FieldMinZoomInternal: React.FC<FieldMinZoomInternalProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const FieldMinZoom = withTranslation()(FieldMinZoomInternal);
|
||||
export default FieldMinZoom;
|
||||
export const FieldMinZoom = withTranslation()(FieldMinZoomInternal);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import InputMultiInput, {type InputMultiInputProps} from "./InputMultiInput";
|
||||
import Fieldset from "./Fieldset";
|
||||
import { InputMultiInput, type InputMultiInputProps } from "./InputMultiInput";
|
||||
import { Fieldset } from "./Fieldset";
|
||||
|
||||
|
||||
type FieldMultiInputProps = InputMultiInputProps & {
|
||||
@@ -7,12 +7,10 @@ type FieldMultiInputProps = InputMultiInputProps & {
|
||||
};
|
||||
|
||||
|
||||
const FieldMultiInput: React.FC<FieldMultiInputProps> = (props) => {
|
||||
export const FieldMultiInput: React.FC<FieldMultiInputProps> = (props) => {
|
||||
return (
|
||||
<Fieldset label={props.label}>
|
||||
<InputMultiInput {...props} />
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldMultiInput;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import InputNumber, {type InputNumberProps} from "./InputNumber";
|
||||
import Block from "./Block";
|
||||
import { InputNumber, type InputNumberProps } from "./InputNumber";
|
||||
import { Block } from "./Block";
|
||||
|
||||
|
||||
type FieldNumberProps = InputNumberProps & {
|
||||
@@ -10,12 +10,10 @@ type FieldNumberProps = InputNumberProps & {
|
||||
};
|
||||
|
||||
|
||||
const FieldNumber: React.FC<FieldNumberProps> = (props) => {
|
||||
export const FieldNumber: React.FC<FieldNumberProps> = (props) => {
|
||||
return (
|
||||
<Block label={props.label} fieldSpec={props.fieldSpec}>
|
||||
<InputNumber {...props} />
|
||||
</Block>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldNumber;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Block from "./Block";
|
||||
import InputSelect, {type InputSelectProps} from "./InputSelect";
|
||||
import { Block } from "./Block";
|
||||
import { InputSelect, type InputSelectProps } from "./InputSelect";
|
||||
|
||||
|
||||
type FieldSelectProps = InputSelectProps & {
|
||||
@@ -10,12 +10,10 @@ type FieldSelectProps = InputSelectProps & {
|
||||
};
|
||||
|
||||
|
||||
const FieldSelect: React.FC<FieldSelectProps> = (props) => {
|
||||
export const FieldSelect: React.FC<FieldSelectProps> = (props) => {
|
||||
return (
|
||||
<Block label={props.label} fieldSpec={props.fieldSpec}>
|
||||
<InputSelect {...props} />
|
||||
</Block>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldSelect;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
|
||||
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
|
||||
import Block from "./Block";
|
||||
import InputAutocomplete from "./InputAutocomplete";
|
||||
import { Block } from "./Block";
|
||||
import { InputAutocomplete } from "./InputAutocomplete";
|
||||
import { type WithTranslation, withTranslation } from "react-i18next";
|
||||
|
||||
type FieldSourceInternalProps = {
|
||||
@@ -38,5 +38,4 @@ const FieldSourceInternal: React.FC<FieldSourceInternalProps> = ({
|
||||
};
|
||||
|
||||
|
||||
const FieldSource = withTranslation()(FieldSourceInternal);
|
||||
export default FieldSource;
|
||||
export const FieldSource = withTranslation()(FieldSourceInternal);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
|
||||
import {latest} from "@maplibre/maplibre-gl-style-spec";
|
||||
import Block from "./Block";
|
||||
import InputAutocomplete from "./InputAutocomplete";
|
||||
import { Block } from "./Block";
|
||||
import { InputAutocomplete } from "./InputAutocomplete";
|
||||
import { type WithTranslation, withTranslation } from "react-i18next";
|
||||
|
||||
type FieldSourceLayerInternalProps = {
|
||||
@@ -35,5 +35,4 @@ const FieldSourceLayerInternal: React.FC<FieldSourceLayerInternalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const FieldSourceLayer = withTranslation()(FieldSourceLayerInternal);
|
||||
export default FieldSourceLayer;
|
||||
export const FieldSourceLayer = withTranslation()(FieldSourceLayerInternal);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Block, { type BlockProps } from "./Block";
|
||||
import InputSpec, { type FieldSpecType, type InputSpecProps } from "./InputSpec";
|
||||
import Fieldset, { type FieldsetProps } from "./Fieldset";
|
||||
import { Block, type BlockProps } from "./Block";
|
||||
import { InputSpec, type FieldSpecType, type InputSpecProps } from "./InputSpec";
|
||||
import { Fieldset, type FieldsetProps } from "./Fieldset";
|
||||
|
||||
function getElementFromType(fieldSpec: { type?: FieldSpecType, values?: unknown[] }): typeof Fieldset | typeof Block {
|
||||
switch(fieldSpec.type) {
|
||||
@@ -36,7 +36,7 @@ function getElementFromType(fieldSpec: { type?: FieldSpecType, values?: unknown[
|
||||
|
||||
export type FieldSpecProps = InputSpecProps & BlockProps & FieldsetProps;
|
||||
|
||||
const FieldSpec: React.FC<FieldSpecProps> = (props) => {
|
||||
export const FieldSpec: React.FC<FieldSpecProps> = (props) => {
|
||||
const TypeBlock = getElementFromType(props.fieldSpec!);
|
||||
|
||||
return (
|
||||
@@ -45,5 +45,3 @@ const FieldSpec: React.FC<FieldSpecProps> = (props) => {
|
||||
</TypeBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldSpec;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Block from "./Block";
|
||||
import InputString, {type InputStringProps} from "./InputString";
|
||||
import { Block } from "./Block";
|
||||
import { InputString, type InputStringProps } from "./InputString";
|
||||
|
||||
type FieldStringProps = InputStringProps & {
|
||||
name?: string
|
||||
@@ -9,12 +9,10 @@ type FieldStringProps = InputStringProps & {
|
||||
}
|
||||
};
|
||||
|
||||
const FieldString: React.FC<FieldStringProps> = (props) => {
|
||||
export const FieldString: React.FC<FieldStringProps> = (props) => {
|
||||
return (
|
||||
<Block label={props.label} fieldSpec={props.fieldSpec}>
|
||||
<InputString {...props} />
|
||||
</Block>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldString;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
import {v8} from "@maplibre/maplibre-gl-style-spec";
|
||||
import Block from "./Block";
|
||||
import InputSelect from "./InputSelect";
|
||||
import InputString from "./InputString";
|
||||
import { Block } from "./Block";
|
||||
import { InputSelect } from "./InputSelect";
|
||||
import { InputString } from "./InputString";
|
||||
import { type WithTranslation, withTranslation } from "react-i18next";
|
||||
import { startCase } from "lodash";
|
||||
|
||||
@@ -43,5 +43,4 @@ const FieldTypeInternal: React.FC<FieldTypeInternalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const FieldType = withTranslation()(FieldTypeInternal);
|
||||
export default FieldType;
|
||||
export const FieldType = withTranslation()(FieldTypeInternal);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import InputUrl, {type FieldUrlProps as InputUrlProps} from "./InputUrl";
|
||||
import Block from "./Block";
|
||||
import { InputUrl, type FieldUrlProps as InputUrlProps } from "./InputUrl";
|
||||
import { Block } from "./Block";
|
||||
|
||||
|
||||
type FieldUrlProps = InputUrlProps & {
|
||||
@@ -10,12 +10,10 @@ type FieldUrlProps = InputUrlProps & {
|
||||
};
|
||||
|
||||
|
||||
const FieldUrl: React.FC<FieldUrlProps> = (props) => {
|
||||
export const FieldUrl: React.FC<FieldUrlProps> = (props) => {
|
||||
return (
|
||||
<Block label={props.label} fieldSpec={props.fieldSpec}>
|
||||
<InputUrl {...props} />
|
||||
</Block>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldUrl;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { type PropsWithChildren, type ReactElement } from "react";
|
||||
import classnames from "classnames";
|
||||
import FieldDocLabel from "./FieldDocLabel";
|
||||
import Doc from "./Doc";
|
||||
import generateUniqueId from "../libs/document-uid";
|
||||
import { FieldDocLabel } from "./FieldDocLabel";
|
||||
import { Doc } from "./Doc";
|
||||
import { generateUniqueId } from "../libs/document-uid";
|
||||
|
||||
export type FieldsetProps = PropsWithChildren & {
|
||||
label?: string,
|
||||
@@ -12,7 +12,7 @@ export type FieldsetProps = PropsWithChildren & {
|
||||
};
|
||||
|
||||
|
||||
const Fieldset: React.FC<FieldsetProps> = (props) => {
|
||||
export const Fieldset: React.FC<FieldsetProps> = (props) => {
|
||||
const [showDoc, setShowDoc] = React.useState(false);
|
||||
const labelId = React.useRef(generateUniqueId("fieldset_label_"));
|
||||
|
||||
@@ -49,5 +49,3 @@ const Fieldset: React.FC<FieldsetProps> = (props) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Fieldset;
|
||||
|
||||
+150
-181
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { TbMathFunction } from "react-icons/tb";
|
||||
import { PiListPlusBold } from "react-icons/pi";
|
||||
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 {combiningFilterOps} from "../libs/filterops";
|
||||
import InputSelect from "./InputSelect";
|
||||
import Block from "./Block";
|
||||
import SingleFilterEditor from "./SingleFilterEditor";
|
||||
import FilterEditorBlock from "./FilterEditorBlock";
|
||||
import InputButton from "./InputButton";
|
||||
import Doc from "./Doc";
|
||||
import ExpressionProperty from "./_ExpressionProperty";
|
||||
import { InputSelect } from "./InputSelect";
|
||||
import { Block } from "./Block";
|
||||
import { SingleFilterEditor } from "./SingleFilterEditor";
|
||||
import { FilterEditorBlock } from "./FilterEditorBlock";
|
||||
import { InputButton } from "./InputButton";
|
||||
import { Doc } from "./Doc";
|
||||
import { ExpressionProperty } from "./ExpressionProperty";
|
||||
import { type WithTranslation, withTranslation } from "react-i18next";
|
||||
import type { MappedLayerErrors, StyleSpecificationWithId } from "../libs/definitions";
|
||||
|
||||
@@ -100,221 +100,190 @@ type FilterEditorInternalProps = {
|
||||
onChange(value: LegacyFilterSpecification | ExpressionSpecification): void
|
||||
} & WithTranslation;
|
||||
|
||||
type FilterEditorState = {
|
||||
showDoc: boolean
|
||||
displaySimpleFilter: boolean
|
||||
valueIsSimpleFilter?: boolean
|
||||
};
|
||||
const FilterEditorInternal: React.FC<FilterEditorInternalProps> = ({ filter = ["all"], ...rest }) => {
|
||||
const props = { filter, ...rest } as FilterEditorInternalProps;
|
||||
|
||||
class FilterEditorInternal extends React.Component<FilterEditorInternalProps, FilterEditorState> {
|
||||
static defaultProps = {
|
||||
filter: ["all"],
|
||||
};
|
||||
// Nothing ever toggles this: the Block below renders its own documentation
|
||||
// toggle, so this component's inline doc panel stays hidden (as it did before).
|
||||
const [showDoc] = useState(false);
|
||||
const [displaySimpleFilter, setDisplaySimpleFilter] = useState(() =>
|
||||
checkIfSimpleFilter(combiningFilter(props))
|
||||
);
|
||||
|
||||
constructor (props: FilterEditorInternalProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
showDoc: false,
|
||||
displaySimpleFilter: checkIfSimpleFilter(combiningFilter(props)),
|
||||
};
|
||||
// Replaces getDerivedStateFromProps. "Upgrade but never downgrade": once the
|
||||
// filter stops being expressible in the simple editor, switch to the
|
||||
// expression editor and stay there.
|
||||
const isSimpleFilter = checkIfSimpleFilter(combiningFilter(props));
|
||||
if (!isSimpleFilter && displaySimpleFilter) {
|
||||
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
|
||||
onFilterPartChanged(filterIdx: number, newPart: any[]) {
|
||||
const newFilter = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
|
||||
function onFilterPartChanged(filterIdx: number, newPart: any[]) {
|
||||
const newFilter = combiningFilter(props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
|
||||
newFilter[filterIdx] = newPart;
|
||||
this.props.onChange(newFilter);
|
||||
props.onChange(newFilter);
|
||||
}
|
||||
|
||||
deleteFilterItem(filterIdx: number) {
|
||||
const newFilter = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
|
||||
function deleteFilterItem(filterIdx: number) {
|
||||
const newFilter = combiningFilter(props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
|
||||
newFilter.splice(filterIdx + 1, 1);
|
||||
this.props.onChange(newFilter);
|
||||
props.onChange(newFilter);
|
||||
}
|
||||
|
||||
addFilterItem = () => {
|
||||
const newFilterItem = combiningFilter(this.props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
|
||||
const addFilterItem = () => {
|
||||
const newFilterItem = combiningFilter(props).slice(0) as LegacyFilterSpecification | ExpressionSpecification;
|
||||
(newFilterItem as any[]).push(["==", "name", ""]);
|
||||
this.props.onChange(newFilterItem);
|
||||
props.onChange(newFilterItem);
|
||||
};
|
||||
|
||||
onToggleDoc = (val: boolean) => {
|
||||
this.setState({
|
||||
showDoc: val
|
||||
});
|
||||
const makeFilter = () => {
|
||||
setDisplaySimpleFilter(true);
|
||||
};
|
||||
|
||||
makeFilter = () => {
|
||||
this.setState({
|
||||
displaySimpleFilter: true,
|
||||
});
|
||||
const makeExpression = () => {
|
||||
const currentFilter = combiningFilter(props);
|
||||
props.onChange(migrateFilter(currentFilter));
|
||||
setDisplaySimpleFilter(false);
|
||||
};
|
||||
|
||||
makeExpression = () => {
|
||||
const filter = combiningFilter(this.props);
|
||||
this.props.onChange(migrateFilter(filter));
|
||||
this.setState({
|
||||
displaySimpleFilter: false,
|
||||
});
|
||||
|
||||
const {errors, t} = props;
|
||||
const fieldSpec={
|
||||
doc: latest.layer.filter.doc + " Combine multiple filters together by using a compound filter."
|
||||
};
|
||||
const defaultFilter = ["all"] as LegacyFilterSpecification | ExpressionSpecification;
|
||||
|
||||
static getDerivedStateFromProps(props: Readonly<FilterEditorInternalProps>, state: FilterEditorState) {
|
||||
const displaySimpleFilter = checkIfSimpleFilter(combiningFilter(props));
|
||||
const isNestedCombiningFilter = displaySimpleFilter && hasNestedCombiningFilter(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,
|
||||
};
|
||||
}
|
||||
if (isNestedCombiningFilter) {
|
||||
return <div className="maputnik-filter-editor-unsupported">
|
||||
<p>
|
||||
{t("Nested filters are not supported.")}
|
||||
</p>
|
||||
<InputButton
|
||||
onClick={makeExpression}
|
||||
title={t("Convert to expression")}
|
||||
>
|
||||
<TbMathFunction />
|
||||
{t("Upgrade to expression")}
|
||||
</InputButton>
|
||||
</div>;
|
||||
}
|
||||
else if (displaySimpleFilter) {
|
||||
const filter = combiningFilter(props);
|
||||
const combiningOp = filter[0];
|
||||
const filters = filter.slice(1) as (LegacyFilterSpecification | ExpressionSpecification)[];
|
||||
|
||||
render() {
|
||||
const {errors, t} = this.props;
|
||||
const {displaySimpleFilter} = this.state;
|
||||
const fieldSpec={
|
||||
doc: latest.layer.filter.doc + " Combine multiple filters together by using a compound filter."
|
||||
};
|
||||
const defaultFilter = ["all"] as LegacyFilterSpecification | ExpressionSpecification;
|
||||
|
||||
const isNestedCombiningFilter = displaySimpleFilter && hasNestedCombiningFilter(combiningFilter(this.props));
|
||||
|
||||
if (isNestedCombiningFilter) {
|
||||
return <div className="maputnik-filter-editor-unsupported">
|
||||
<p>
|
||||
{t("Nested filters are not supported.")}
|
||||
</p>
|
||||
const actions = (
|
||||
<div>
|
||||
<InputButton
|
||||
onClick={this.makeExpression}
|
||||
onClick={makeExpression}
|
||||
title={t("Convert to expression")}
|
||||
className="maputnik-make-zoom-function"
|
||||
data-wd-key="filter-convert-to-expression"
|
||||
>
|
||||
<TbMathFunction />
|
||||
{t("Upgrade to expression")}
|
||||
</InputButton>
|
||||
</div>;
|
||||
}
|
||||
else if (displaySimpleFilter) {
|
||||
const filter = combiningFilter(this.props);
|
||||
const combiningOp = filter[0];
|
||||
const filters = filter.slice(1) as (LegacyFilterSpecification | ExpressionSpecification)[];
|
||||
</div>
|
||||
);
|
||||
|
||||
const actions = (
|
||||
<div>
|
||||
<InputButton
|
||||
onClick={this.makeExpression}
|
||||
title={t("Convert to expression")}
|
||||
className="maputnik-make-zoom-function"
|
||||
>
|
||||
<TbMathFunction />
|
||||
</InputButton>
|
||||
const editorBlocks = filters.map((f, idx) => {
|
||||
const error = errors![`filter[${idx+1}]`];
|
||||
|
||||
return (
|
||||
<div key={`block-${idx}`}>
|
||||
<FilterEditorBlock key={idx} onDelete={deleteFilterItem.bind(null, idx)}>
|
||||
<SingleFilterEditor
|
||||
properties={props.properties}
|
||||
filter={f}
|
||||
onChange={onFilterPartChanged.bind(null, idx + 1)}
|
||||
/>
|
||||
</FilterEditorBlock>
|
||||
{error &&
|
||||
<div key="error" className="maputnik-inline-error">{error.message}</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
||||
const editorBlocks = filters.map((f, idx) => {
|
||||
const error = errors![`filter[${idx+1}]`];
|
||||
|
||||
return (
|
||||
<div key={`block-${idx}`}>
|
||||
<FilterEditorBlock key={idx} onDelete={this.deleteFilterItem.bind(this, idx)}>
|
||||
<SingleFilterEditor
|
||||
properties={this.props.properties}
|
||||
filter={f}
|
||||
onChange={this.onFilterPartChanged.bind(this, idx + 1)}
|
||||
/>
|
||||
</FilterEditorBlock>
|
||||
{error &&
|
||||
<div key="error" className="maputnik-inline-error">{error.message}</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Block
|
||||
key="top"
|
||||
fieldSpec={fieldSpec}
|
||||
label={t("Filter")}
|
||||
action={actions}
|
||||
>
|
||||
<InputSelect
|
||||
value={combiningOp}
|
||||
onChange={(v: [string, any]) => this.onFilterPartChanged(0, v)}
|
||||
options={[
|
||||
["all", t("every filter matches")],
|
||||
["none", t("no filter matches")],
|
||||
["any", t("any filter matches")]
|
||||
]}
|
||||
/>
|
||||
</Block>
|
||||
{editorBlocks}
|
||||
<div
|
||||
key="buttons"
|
||||
className="maputnik-filter-editor-add-wrapper"
|
||||
>
|
||||
<InputButton
|
||||
data-wd-key="layer-filter-button"
|
||||
className="maputnik-add-filter"
|
||||
onClick={this.addFilterItem}
|
||||
>
|
||||
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} />
|
||||
{t("Add filter")}
|
||||
</InputButton>
|
||||
</div>
|
||||
<div
|
||||
key="doc"
|
||||
className="maputnik-doc-inline"
|
||||
style={{display: this.state.showDoc ? "" : "none"}}
|
||||
>
|
||||
<Doc fieldSpec={fieldSpec} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
else {
|
||||
const {filter} = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExpressionProperty
|
||||
onDelete={() => {
|
||||
this.setState({displaySimpleFilter: true});
|
||||
this.props.onChange(defaultFilter);
|
||||
}}
|
||||
fieldName="filter"
|
||||
value={filter}
|
||||
errors={errors}
|
||||
onChange={this.props.onChange}
|
||||
return (
|
||||
<>
|
||||
<Block
|
||||
key="top"
|
||||
fieldSpec={fieldSpec}
|
||||
label={t("Filter")}
|
||||
action={actions}
|
||||
data-wd-key="filter-combining-operator"
|
||||
>
|
||||
<InputSelect
|
||||
value={combiningOp}
|
||||
onChange={(v: [string, any]) => onFilterPartChanged(0, v)}
|
||||
options={[
|
||||
["all", t("every filter matches")],
|
||||
["none", t("no filter matches")],
|
||||
["any", t("any filter matches")]
|
||||
]}
|
||||
/>
|
||||
{this.state.valueIsSimpleFilter &&
|
||||
</Block>
|
||||
{editorBlocks}
|
||||
<div
|
||||
key="buttons"
|
||||
className="maputnik-filter-editor-add-wrapper"
|
||||
>
|
||||
<InputButton
|
||||
data-wd-key="layer-filter-button"
|
||||
className="maputnik-add-filter"
|
||||
onClick={addFilterItem}
|
||||
>
|
||||
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} />
|
||||
{t("Add filter")}
|
||||
</InputButton>
|
||||
</div>
|
||||
<div
|
||||
key="doc"
|
||||
className="maputnik-doc-inline"
|
||||
style={{display: showDoc ? "" : "none"}}
|
||||
>
|
||||
<Doc fieldSpec={fieldSpec} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
else {
|
||||
const {filter} = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExpressionProperty
|
||||
onDelete={() => {
|
||||
setDisplaySimpleFilter(true);
|
||||
props.onChange(defaultFilter);
|
||||
}}
|
||||
fieldName="filter"
|
||||
value={filter}
|
||||
errors={errors}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
{valueIsSimpleFilter &&
|
||||
<div className="maputnik-expr-infobox">
|
||||
{t("You've entered an old style filter.")}
|
||||
{" "}
|
||||
<button
|
||||
onClick={this.makeFilter}
|
||||
onClick={makeFilter}
|
||||
className="maputnik-expr-infobox__button"
|
||||
>
|
||||
{t("Switch to filter editor.")}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const FilterEditor = withTranslation()(FilterEditorInternal);
|
||||
export default FilterEditor;
|
||||
export const FilterEditor = withTranslation()(FilterEditorInternal);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { type PropsWithChildren } from "react";
|
||||
import InputButton from "./InputButton";
|
||||
import { InputButton } from "./InputButton";
|
||||
import {MdDelete} from "react-icons/md";
|
||||
import { type WithTranslation, withTranslation } from "react-i18next";
|
||||
|
||||
@@ -7,25 +7,22 @@ type FilterEditorBlockInternalProps = PropsWithChildren & {
|
||||
onDelete(...args: unknown[]): unknown
|
||||
} & WithTranslation;
|
||||
|
||||
class FilterEditorBlockInternal extends React.Component<FilterEditorBlockInternalProps> {
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
return <div className="maputnik-filter-editor-block">
|
||||
<div className="maputnik-filter-editor-block-content">
|
||||
{this.props.children}
|
||||
</div>
|
||||
<div className="maputnik-filter-editor-block-action">
|
||||
<InputButton
|
||||
className="maputnik-icon-button"
|
||||
onClick={this.props.onDelete}
|
||||
title={t("Delete filter block")}
|
||||
>
|
||||
<MdDelete />
|
||||
</InputButton>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
const FilterEditorBlockInternal: React.FC<FilterEditorBlockInternalProps> = (props) => {
|
||||
const t = props.t;
|
||||
return <div className="maputnik-filter-editor-block">
|
||||
<div className="maputnik-filter-editor-block-content">
|
||||
{props.children}
|
||||
</div>
|
||||
<div className="maputnik-filter-editor-block-action">
|
||||
<InputButton
|
||||
className="maputnik-icon-button"
|
||||
onClick={props.onDelete}
|
||||
title={t("Delete filter block")}
|
||||
>
|
||||
<MdDelete />
|
||||
</InputButton>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const FilterEditorBlock = withTranslation()(FilterEditorBlockInternal);
|
||||
export default FilterEditorBlock;
|
||||
export const FilterEditorBlock = withTranslation()(FilterEditorBlockInternal);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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);
|
||||
@@ -13,7 +13,7 @@ type IconLayerProps = {
|
||||
className?: string
|
||||
};
|
||||
|
||||
const IconLayer: React.FC<IconLayerProps> = (props) => {
|
||||
export const IconLayer: React.FC<IconLayerProps> = (props) => {
|
||||
const iconProps = { style: props.style };
|
||||
switch(props.type) {
|
||||
case "fill-extrusion": return <IoMdCube {...iconProps} />;
|
||||
@@ -29,5 +29,3 @@ const IconLayer: React.FC<IconLayerProps> = (props) => {
|
||||
default: return <MdPriorityHigh {...iconProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
export default IconLayer;
|
||||
|
||||
+71
-105
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import InputString from "./InputString";
|
||||
import InputNumber from "./InputNumber";
|
||||
import React, { useState } from "react";
|
||||
import { InputString } from "./InputString";
|
||||
import { InputNumber } from "./InputNumber";
|
||||
|
||||
export type InputArrayProps = {
|
||||
value: (string | number | undefined)[]
|
||||
@@ -12,106 +12,72 @@ export type InputArrayProps = {
|
||||
label?: string
|
||||
};
|
||||
|
||||
type InputArrayState = {
|
||||
value: (string | number | undefined)[]
|
||||
initialPropsValue: unknown[]
|
||||
export const InputArray: React.FC<InputArrayProps> = ({
|
||||
value: propsValue = [],
|
||||
default: propsDefault = [],
|
||||
...rest
|
||||
}) => {
|
||||
const props = { value: propsValue, default: propsDefault, ...rest };
|
||||
|
||||
// The original seeded this from props and then never let props overwrite it
|
||||
// again (its getDerivedStateFromProps assigned the existing state back in
|
||||
// both branches), so the value is owned by this component after mount.
|
||||
const [value, setValue] = useState<(string | number | undefined)[]>(() => propsValue.slice(0));
|
||||
|
||||
function isComplete(val: unknown[]) {
|
||||
return Array(props.length).fill(null).every((_, i) => {
|
||||
const v = val[i];
|
||||
return !(v === undefined || v === "");
|
||||
});
|
||||
}
|
||||
|
||||
function changeValue(idx: number, newValue: string | number | undefined) {
|
||||
const nextValue = value.slice(0);
|
||||
nextValue[idx] = newValue;
|
||||
|
||||
setValue(nextValue);
|
||||
|
||||
if (isComplete(nextValue) && props.onChange) {
|
||||
props.onChange(nextValue);
|
||||
}
|
||||
else if (props.onChange) {
|
||||
// Unset until complete
|
||||
props.onChange(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const containsValues = (
|
||||
value.length > 0 &&
|
||||
!value.every(val => {
|
||||
return (val === "" || val === undefined);
|
||||
})
|
||||
);
|
||||
|
||||
const inputs = Array(props.length).fill(null).map((_, i) => {
|
||||
if(props.type === "number") {
|
||||
return <InputNumber
|
||||
key={i}
|
||||
default={containsValues || !props.default ? undefined : props.default[i] as number}
|
||||
value={value[i] as number}
|
||||
required={containsValues ? true : false}
|
||||
onChange={(v) => changeValue(i, v)}
|
||||
aria-label={props["aria-label"] || props.label}
|
||||
/>;
|
||||
} else {
|
||||
return <InputString
|
||||
key={i}
|
||||
default={containsValues || !props.default ? undefined : props.default[i] as string}
|
||||
value={value[i] as string}
|
||||
required={containsValues ? true : false}
|
||||
onChange={(v) => changeValue(i, v)}
|
||||
aria-label={props["aria-label"] || props.label}
|
||||
/>;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="maputnik-array">
|
||||
{inputs}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default class InputArray extends React.Component<InputArrayProps, InputArrayState> {
|
||||
static defaultProps = {
|
||||
value: [],
|
||||
default: [],
|
||||
};
|
||||
|
||||
constructor (props: InputArrayProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
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 === "");
|
||||
});
|
||||
}
|
||||
|
||||
changeValue(idx: number, newValue: string | number | undefined) {
|
||||
const value = this.state.value.slice(0);
|
||||
value[idx] = newValue;
|
||||
|
||||
this.setState({
|
||||
value,
|
||||
}, () => {
|
||||
if (this.isComplete(value) && this.props.onChange) {
|
||||
this.props.onChange(value);
|
||||
}
|
||||
else if (this.props.onChange){
|
||||
// Unset until complete
|
||||
this.props.onChange(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {value} = this.state;
|
||||
|
||||
const containsValues = (
|
||||
value.length > 0 &&
|
||||
!value.every(val => {
|
||||
return (val === "" || val === undefined);
|
||||
})
|
||||
);
|
||||
|
||||
const inputs = Array(this.props.length).fill(null).map((_, i) => {
|
||||
if(this.props.type === "number") {
|
||||
return <InputNumber
|
||||
key={i}
|
||||
default={containsValues || !this.props.default ? undefined : this.props.default[i] as number}
|
||||
value={value[i] as number}
|
||||
required={containsValues ? true : false}
|
||||
onChange={(v) => this.changeValue(i, v)}
|
||||
aria-label={this.props["aria-label"] || this.props.label}
|
||||
/>;
|
||||
} else {
|
||||
return <InputString
|
||||
key={i}
|
||||
default={containsValues || !this.props.default ? undefined : this.props.default[i] as string}
|
||||
value={value[i] as string}
|
||||
required={containsValues ? true : false}
|
||||
onChange={this.changeValue.bind(this, i)}
|
||||
aria-label={this.props["aria-label"] || this.props.label}
|
||||
/>;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="maputnik-array">
|
||||
{inputs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { render } from "vitest-browser-react";
|
||||
import { page } from "vitest/browser";
|
||||
import InputAutocomplete from "./InputAutocomplete";
|
||||
|
||||
const fruits = ["apple", "banana", "cherry"];
|
||||
|
||||
test("filters options when typing", async () => {
|
||||
render(<InputAutocomplete aria-label="Fruit" options={fruits.map((f) => [f, f])} />);
|
||||
|
||||
const input = page.getByLabelText("Fruit");
|
||||
await input.click();
|
||||
|
||||
const menuItems = page.getByRole("option");
|
||||
await expect.element(menuItems.first()).toBeVisible();
|
||||
expect(menuItems.all()).toHaveLength(3);
|
||||
|
||||
await input.fill("ch");
|
||||
await expect.element(page.getByText("cherry")).toBeVisible();
|
||||
expect(page.getByRole("option").all()).toHaveLength(1);
|
||||
|
||||
await page.getByText("cherry").click();
|
||||
await expect.element(input).toHaveValue("cherry");
|
||||
});
|
||||
@@ -11,7 +11,7 @@ export type InputAutocompleteProps = {
|
||||
"aria-label"?: string
|
||||
};
|
||||
|
||||
export default function InputAutocomplete({
|
||||
export function InputAutocomplete({
|
||||
value,
|
||||
options = [],
|
||||
onChange = () => {},
|
||||
|
||||
@@ -14,20 +14,18 @@ type InputButtonProps = {
|
||||
title?: string
|
||||
};
|
||||
|
||||
export default class InputButton extends React.Component<InputButtonProps> {
|
||||
render() {
|
||||
return <button
|
||||
id={this.props.id}
|
||||
title={this.props.title}
|
||||
type={this.props.type}
|
||||
onClick={this.props.onClick}
|
||||
disabled={this.props.disabled}
|
||||
aria-label={this.props["aria-label"]}
|
||||
className={classnames("maputnik-button", this.props.className)}
|
||||
data-wd-key={this.props["data-wd-key"]}
|
||||
style={this.props.style}
|
||||
>
|
||||
{this.props.children}
|
||||
</button>;
|
||||
}
|
||||
}
|
||||
export const InputButton: React.FC<InputButtonProps> = (props) => {
|
||||
return <button
|
||||
id={props.id}
|
||||
title={props.title}
|
||||
type={props.type}
|
||||
onClick={props.onClick}
|
||||
disabled={props.disabled}
|
||||
aria-label={props["aria-label"]}
|
||||
className={classnames("maputnik-button", props.className)}
|
||||
data-wd-key={props["data-wd-key"]}
|
||||
style={props.style}
|
||||
>
|
||||
{props.children}
|
||||
</button>;
|
||||
};
|
||||
|
||||
@@ -6,32 +6,26 @@ export type InputCheckboxProps = {
|
||||
onChange(...args: unknown[]): unknown
|
||||
};
|
||||
|
||||
export default class InputCheckbox extends React.Component<InputCheckboxProps> {
|
||||
static defaultProps = {
|
||||
value: false,
|
||||
export const InputCheckbox: React.FC<InputCheckboxProps> = ({value = false, ...props}) => {
|
||||
const onChange = () => {
|
||||
props.onChange(!value);
|
||||
};
|
||||
|
||||
onChange = () => {
|
||||
this.props.onChange(!this.props.value);
|
||||
};
|
||||
|
||||
render() {
|
||||
return <div className="maputnik-checkbox-wrapper">
|
||||
<input
|
||||
className="maputnik-checkbox"
|
||||
type="checkbox"
|
||||
style={this.props.style}
|
||||
onChange={this.onChange}
|
||||
onClick={this.onChange}
|
||||
checked={this.props.value}
|
||||
/>
|
||||
<div className="maputnik-checkbox-box">
|
||||
<svg style={{
|
||||
display: this.props.value ? "inline" : "none"
|
||||
}} className="maputnik-checkbox-icon" viewBox='0 0 32 32'>
|
||||
<path d='M1 14 L5 10 L13 18 L27 4 L31 8 L13 26 z' />
|
||||
</svg>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
return <div className="maputnik-checkbox-wrapper">
|
||||
<input
|
||||
className="maputnik-checkbox"
|
||||
type="checkbox"
|
||||
style={props.style}
|
||||
onChange={onChange}
|
||||
onClick={onChange}
|
||||
checked={value}
|
||||
/>
|
||||
<div className="maputnik-checkbox-box">
|
||||
<svg style={{
|
||||
display: value ? "inline" : "none"
|
||||
}} className="maputnik-checkbox-icon" viewBox='0 0 32 32'>
|
||||
<path d='M1 14 L5 10 L13 18 L27 4 L31 8 L13 26 z' />
|
||||
</svg>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Color from "color";
|
||||
import ChromePicker from "react-color/lib/components/chrome/Chrome";
|
||||
import {type ColorResult} from "react-color";
|
||||
@@ -20,26 +20,27 @@ export type InputColorProps = {
|
||||
};
|
||||
|
||||
/*** Number fields with support for min, max and units and documentation*/
|
||||
export default class InputColor extends React.Component<InputColorProps> {
|
||||
state = {
|
||||
pickerOpened: false
|
||||
};
|
||||
colorInput: HTMLInputElement | null = null;
|
||||
export const InputColor: React.FC<InputColorProps> = (props) => {
|
||||
const [pickerOpened, setPickerOpened] = useState(false);
|
||||
const colorInput = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
constructor (props: InputColorProps) {
|
||||
super(props);
|
||||
this.onChangeNoCheck = lodash.throttle(this.onChangeNoCheck, 1000/30);
|
||||
}
|
||||
// Keep the latest `onChange` available to the throttled callback, which is
|
||||
// created only once so that throttling actually takes effect.
|
||||
const onChangeProp = useRef(props.onChange);
|
||||
useEffect(() => {
|
||||
onChangeProp.current = props.onChange;
|
||||
});
|
||||
|
||||
onChangeNoCheck(v: string) {
|
||||
this.props.onChange(v);
|
||||
}
|
||||
const onChangeNoCheck = useMemo(
|
||||
() => lodash.throttle((v: string) => onChangeProp.current(v), 1000/30),
|
||||
[]
|
||||
);
|
||||
|
||||
//TODO: I much rather would do this with absolute positioning
|
||||
//but I am too stupid to get it to work together with fixed position
|
||||
//and scrollbars so I have to fallback to JavaScript
|
||||
calcPickerOffset = () => {
|
||||
const elem = this.colorInput;
|
||||
const calcPickerOffset = () => {
|
||||
const elem = colorInput.current;
|
||||
if(elem) {
|
||||
const pos = elem.getBoundingClientRect();
|
||||
return {
|
||||
@@ -54,82 +55,80 @@ export default class InputColor extends React.Component<InputColorProps> {
|
||||
}
|
||||
};
|
||||
|
||||
togglePicker = () => {
|
||||
this.setState({ pickerOpened: !this.state.pickerOpened });
|
||||
const togglePicker = () => {
|
||||
setPickerOpened(opened => !opened);
|
||||
};
|
||||
|
||||
get color() {
|
||||
const getColor = () => {
|
||||
// Catch invalid color.
|
||||
try {
|
||||
return Color(this.props.value).rgb();
|
||||
return Color(props.value).rgb();
|
||||
}
|
||||
catch(err) {
|
||||
console.warn("Error parsing color: ", err);
|
||||
return Color("rgb(255,255,255)");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onChange (v: string) {
|
||||
this.props.onChange(v === "" ? undefined : v);
|
||||
}
|
||||
const onChange = (v: string) => {
|
||||
props.onChange(v === "" ? undefined : v);
|
||||
};
|
||||
|
||||
render() {
|
||||
const offset = this.calcPickerOffset();
|
||||
const currentColor = this.color.object();
|
||||
const currentChromeColor = {
|
||||
r: currentColor.r,
|
||||
g: currentColor.g,
|
||||
b: currentColor.b,
|
||||
// Rename alpha -> a for ChromePicker
|
||||
a: currentColor.alpha!
|
||||
};
|
||||
const offset = calcPickerOffset();
|
||||
const currentColor = getColor().object();
|
||||
const currentChromeColor = {
|
||||
r: currentColor.r,
|
||||
g: currentColor.g,
|
||||
b: currentColor.b,
|
||||
// Rename alpha -> a for ChromePicker
|
||||
a: currentColor.alpha!
|
||||
};
|
||||
|
||||
const picker = <div
|
||||
const picker = <div
|
||||
className="maputnik-color-picker-offset"
|
||||
style={{
|
||||
position: "fixed",
|
||||
zIndex: 1,
|
||||
left: offset.left,
|
||||
top: offset.top,
|
||||
}}>
|
||||
<ChromePicker
|
||||
color={currentChromeColor}
|
||||
onChange={c => onChangeNoCheck(formatColor(c))}
|
||||
/>
|
||||
<div
|
||||
className="maputnik-color-picker-offset"
|
||||
onClick={togglePicker}
|
||||
style={{
|
||||
zIndex: -1,
|
||||
position: "fixed",
|
||||
zIndex: 1,
|
||||
left: offset.left,
|
||||
top: offset.top,
|
||||
}}>
|
||||
<ChromePicker
|
||||
color={currentChromeColor}
|
||||
onChange={c => this.onChangeNoCheck(formatColor(c))}
|
||||
/>
|
||||
<div
|
||||
className="maputnik-color-picker-offset"
|
||||
onClick={this.togglePicker}
|
||||
style={{
|
||||
zIndex: -1,
|
||||
position: "fixed",
|
||||
top: "0px",
|
||||
right: "0px",
|
||||
bottom: "0px",
|
||||
left: "0px",
|
||||
}}
|
||||
/>
|
||||
</div>;
|
||||
top: "0px",
|
||||
right: "0px",
|
||||
bottom: "0px",
|
||||
left: "0px",
|
||||
}}
|
||||
/>
|
||||
</div>;
|
||||
|
||||
const swatchStyle = {
|
||||
backgroundColor: this.props.value
|
||||
};
|
||||
const swatchStyle = {
|
||||
backgroundColor: props.value
|
||||
};
|
||||
|
||||
return <div className="maputnik-color-wrapper">
|
||||
{this.state.pickerOpened && picker}
|
||||
<div className="maputnik-color-swatch" style={swatchStyle}></div>
|
||||
<input
|
||||
aria-label={this.props["aria-label"]}
|
||||
spellCheck="false"
|
||||
autoComplete="off"
|
||||
className="maputnik-color"
|
||||
ref={(input) => {this.colorInput = input;}}
|
||||
onClick={this.togglePicker}
|
||||
style={this.props.style}
|
||||
name={this.props.name}
|
||||
placeholder={this.props.default}
|
||||
value={this.props.value ? this.props.value : ""}
|
||||
onChange={(e) => this.onChange(e.target.value)}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
return <div className="maputnik-color-wrapper">
|
||||
{pickerOpened && picker}
|
||||
<div className="maputnik-color-swatch" style={swatchStyle}></div>
|
||||
<input
|
||||
aria-label={props["aria-label"]}
|
||||
spellCheck="false"
|
||||
autoComplete="off"
|
||||
className="maputnik-color"
|
||||
ref={colorInput}
|
||||
onClick={togglePicker}
|
||||
style={props.style}
|
||||
name={props.name}
|
||||
placeholder={props.default}
|
||||
value={props.value ? props.value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>;
|
||||
};
|
||||
|
||||
@@ -3,13 +3,13 @@ import capitalize from "lodash.capitalize";
|
||||
import {MdDelete} from "react-icons/md";
|
||||
import { type WithTranslation, withTranslation } from "react-i18next";
|
||||
|
||||
import InputString from "./InputString";
|
||||
import InputNumber from "./InputNumber";
|
||||
import InputButton from "./InputButton";
|
||||
import FieldDocLabel from "./FieldDocLabel";
|
||||
import InputEnum from "./InputEnum";
|
||||
import InputUrl from "./InputUrl";
|
||||
import InputColor from "./InputColor";
|
||||
import { InputString } from "./InputString";
|
||||
import { InputNumber } from "./InputNumber";
|
||||
import { InputButton } from "./InputButton";
|
||||
import { FieldDocLabel } from "./FieldDocLabel";
|
||||
import { InputEnum } from "./InputEnum";
|
||||
import { InputUrl } from "./InputUrl";
|
||||
import { InputColor } from "./InputColor";
|
||||
|
||||
|
||||
export type InputDynamicArrayProps = {
|
||||
@@ -27,138 +27,130 @@ export type InputDynamicArrayProps = {
|
||||
|
||||
type InputDynamicArrayInternalProps = InputDynamicArrayProps & WithTranslation;
|
||||
|
||||
class InputDynamicArrayInternal extends React.Component<InputDynamicArrayInternalProps> {
|
||||
changeValue(idx: number, newValue: string | number | undefined) {
|
||||
const values = this.values.slice(0);
|
||||
values[idx] = newValue;
|
||||
if (this.props.onChange) this.props.onChange(values);
|
||||
}
|
||||
const InputDynamicArrayInternal: React.FC<InputDynamicArrayInternalProps> = (props) => {
|
||||
const values = props.value || props.default || [];
|
||||
|
||||
get values() {
|
||||
return this.props.value || this.props.default || [];
|
||||
}
|
||||
|
||||
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];
|
||||
values.push(defaultValue);
|
||||
} else if (this.props.type === "color") {
|
||||
values.push("#000000");
|
||||
} else {
|
||||
values.push("");
|
||||
}
|
||||
|
||||
if (this.props.onChange) this.props.onChange(values);
|
||||
const changeValue = (idx: number, newValue: string | number | undefined) => {
|
||||
const newValues = values.slice(0);
|
||||
newValues[idx] = newValue;
|
||||
if (props.onChange) props.onChange(newValues);
|
||||
};
|
||||
|
||||
deleteValue(valueIdx: number) {
|
||||
const values = this.values.slice(0);
|
||||
values.splice(valueIdx, 1);
|
||||
const addValue = () => {
|
||||
const newValues = values.slice(0);
|
||||
if (props.type === "number") {
|
||||
newValues.push(0);
|
||||
}
|
||||
else if (props.type === "url") {
|
||||
newValues.push("");
|
||||
}
|
||||
else if (props.type === "enum") {
|
||||
const {fieldSpec} = props;
|
||||
const defaultValue = Object.keys(fieldSpec!.values)[0];
|
||||
newValues.push(defaultValue);
|
||||
} else if (props.type === "color") {
|
||||
newValues.push("#000000");
|
||||
} else {
|
||||
newValues.push("");
|
||||
}
|
||||
|
||||
if (this.props.onChange) this.props.onChange(values.length > 0 ? values : undefined);
|
||||
}
|
||||
if (props.onChange) props.onChange(newValues);
|
||||
};
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
const i18nProps = { t, i18n: this.props.i18n, tReady: this.props.tReady };
|
||||
const inputs = this.values.map((v, i) => {
|
||||
const deleteValueBtn= <DeleteValueInputButton
|
||||
onClick={this.deleteValue.bind(this, i)}
|
||||
{...i18nProps}
|
||||
const deleteValue = (valueIdx: number) => {
|
||||
const newValues = values.slice(0);
|
||||
newValues.splice(valueIdx, 1);
|
||||
|
||||
if (props.onChange) props.onChange(newValues.length > 0 ? newValues : undefined);
|
||||
};
|
||||
|
||||
const t = props.t;
|
||||
const i18nProps = { t, i18n: props.i18n, tReady: props.tReady };
|
||||
const inputs = values.map((v, i) => {
|
||||
const deleteValueBtn= <DeleteValueInputButton
|
||||
onClick={deleteValue.bind(null, i)}
|
||||
{...i18nProps}
|
||||
/>;
|
||||
let input;
|
||||
if(props.type === "url") {
|
||||
input = <InputUrl
|
||||
value={v as string}
|
||||
onChange={changeValue.bind(null, i)}
|
||||
aria-label={props["aria-label"] || props.label}
|
||||
/>;
|
||||
let input;
|
||||
if(this.props.type === "url") {
|
||||
input = <InputUrl
|
||||
value={v as string}
|
||||
onChange={this.changeValue.bind(this, i)}
|
||||
aria-label={this.props["aria-label"] || this.props.label}
|
||||
/>;
|
||||
}
|
||||
else if (this.props.type === "number") {
|
||||
input = <InputNumber
|
||||
value={v as number}
|
||||
onChange={this.changeValue.bind(this, i)}
|
||||
aria-label={this.props["aria-label"] || this.props.label}
|
||||
/>;
|
||||
}
|
||||
else if (this.props.type === "enum") {
|
||||
const options = Object.keys(this.props.fieldSpec?.values).map(v => [v, capitalize(v)]);
|
||||
input = <InputEnum
|
||||
options={options}
|
||||
value={v as string}
|
||||
onChange={this.changeValue.bind(this, i)}
|
||||
aria-label={this.props["aria-label"] || this.props.label}
|
||||
/>;
|
||||
}
|
||||
else if (this.props.type === "color") {
|
||||
input = <InputColor
|
||||
value={v as string}
|
||||
onChange={this.changeValue.bind(this, i)}
|
||||
aria-label={this.props["aria-label"] || this.props.label}
|
||||
/>;
|
||||
}
|
||||
else {
|
||||
input = <InputString
|
||||
value={v as string}
|
||||
onChange={this.changeValue.bind(this, i)}
|
||||
aria-label={this.props["aria-label"] || this.props.label}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
else if (props.type === "number") {
|
||||
input = <InputNumber
|
||||
value={v as number}
|
||||
onChange={changeValue.bind(null, i)}
|
||||
aria-label={props["aria-label"] || props.label}
|
||||
/>;
|
||||
}
|
||||
else if (props.type === "enum") {
|
||||
const options = Object.keys(props.fieldSpec?.values).map(v => [v, capitalize(v)]);
|
||||
input = <InputEnum
|
||||
options={options}
|
||||
value={v as string}
|
||||
onChange={changeValue.bind(null, i)}
|
||||
aria-label={props["aria-label"] || props.label}
|
||||
/>;
|
||||
}
|
||||
else if (props.type === "color") {
|
||||
input = <InputColor
|
||||
value={v as string}
|
||||
onChange={changeValue.bind(null, i)}
|
||||
aria-label={props["aria-label"] || props.label}
|
||||
/>;
|
||||
}
|
||||
else {
|
||||
input = <InputString
|
||||
value={v as string}
|
||||
onChange={changeValue.bind(null, i)}
|
||||
aria-label={props["aria-label"] || props.label}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <div
|
||||
style={this.props.style}
|
||||
key={i}
|
||||
className="maputnik-array-block"
|
||||
>
|
||||
<div className="maputnik-array-block-action">
|
||||
{deleteValueBtn}
|
||||
</div>
|
||||
<div className="maputnik-array-block-content">
|
||||
{input}
|
||||
</div>
|
||||
</div>;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="maputnik-array">
|
||||
{inputs}
|
||||
<InputButton
|
||||
className="maputnik-array-add-value"
|
||||
onClick={this.addValue}
|
||||
>
|
||||
{t("Add value")}
|
||||
</InputButton>
|
||||
return <div
|
||||
style={props.style}
|
||||
key={i}
|
||||
className="maputnik-array-block"
|
||||
>
|
||||
<div className="maputnik-array-block-action">
|
||||
{deleteValueBtn}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
<div className="maputnik-array-block-content">
|
||||
{input}
|
||||
</div>
|
||||
</div>;
|
||||
});
|
||||
|
||||
const InputDynamicArray = withTranslation()(InputDynamicArrayInternal);
|
||||
export default InputDynamicArray;
|
||||
return (
|
||||
<div className="maputnik-array">
|
||||
{inputs}
|
||||
<InputButton
|
||||
className="maputnik-array-add-value"
|
||||
onClick={addValue}
|
||||
>
|
||||
{t("Add value")}
|
||||
</InputButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const InputDynamicArray = withTranslation()(InputDynamicArrayInternal);
|
||||
type DeleteValueInputButtonProps = {
|
||||
onClick?(...args: unknown[]): unknown
|
||||
} & WithTranslation;
|
||||
|
||||
class DeleteValueInputButton extends React.Component<DeleteValueInputButtonProps> {
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
return <InputButton
|
||||
className="maputnik-delete-stop"
|
||||
onClick={this.props.onClick}
|
||||
title={t("Remove array item")}
|
||||
>
|
||||
<FieldDocLabel
|
||||
label={<MdDelete />}
|
||||
/>
|
||||
</InputButton>;
|
||||
}
|
||||
}
|
||||
const DeleteValueInputButton: React.FC<DeleteValueInputButtonProps> = (props) => {
|
||||
const t = props.t;
|
||||
return <InputButton
|
||||
className="maputnik-delete-stop"
|
||||
onClick={props.onClick}
|
||||
title={t("Remove array item")}
|
||||
>
|
||||
<FieldDocLabel
|
||||
label={<MdDelete />}
|
||||
/>
|
||||
</InputButton>;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import InputSelect from "./InputSelect";
|
||||
import InputMultiInput from "./InputMultiInput";
|
||||
import { InputSelect } from "./InputSelect";
|
||||
import { InputMultiInput } from "./InputMultiInput";
|
||||
|
||||
|
||||
function optionsLabelLength(options: any[]) {
|
||||
@@ -25,25 +25,23 @@ export type InputEnumProps = {
|
||||
};
|
||||
|
||||
|
||||
export default class InputEnum extends React.Component<InputEnumProps> {
|
||||
render() {
|
||||
const {options, value, onChange, name, label} = this.props;
|
||||
export const InputEnum: React.FC<InputEnumProps> = (props) => {
|
||||
const {options, value, onChange, name, label} = props;
|
||||
|
||||
if(options.length <= 3 && optionsLabelLength(options) <= 20) {
|
||||
return <InputMultiInput
|
||||
name={name}
|
||||
options={options}
|
||||
value={(value || this.props.default)!}
|
||||
onChange={onChange}
|
||||
aria-label={this.props["aria-label"] || label}
|
||||
/>;
|
||||
} else {
|
||||
return <InputSelect
|
||||
options={options}
|
||||
value={(value || this.props.default)!}
|
||||
onChange={onChange}
|
||||
aria-label={this.props["aria-label"] || label}
|
||||
/>;
|
||||
}
|
||||
if(options.length <= 3 && optionsLabelLength(options) <= 20) {
|
||||
return <InputMultiInput
|
||||
name={name}
|
||||
options={options}
|
||||
value={(value || props.default)!}
|
||||
onChange={onChange}
|
||||
aria-label={props["aria-label"] || label}
|
||||
/>;
|
||||
} else {
|
||||
return <InputSelect
|
||||
options={options}
|
||||
value={(value || props.default)!}
|
||||
onChange={onChange}
|
||||
aria-label={props["aria-label"] || label}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import InputAutocomplete from "./InputAutocomplete";
|
||||
import { InputAutocomplete } from "./InputAutocomplete";
|
||||
|
||||
export type InputFontProps = {
|
||||
name: string
|
||||
@@ -11,13 +11,9 @@ export type InputFontProps = {
|
||||
"aria-label"?: string
|
||||
};
|
||||
|
||||
export default class InputFont extends React.Component<InputFontProps> {
|
||||
static defaultProps = {
|
||||
fonts: []
|
||||
};
|
||||
|
||||
get values() {
|
||||
const out = this.props.value || this.props.default || [];
|
||||
export const InputFont: React.FC<InputFontProps> = ({fonts = [], ...props}) => {
|
||||
const getValues = () => {
|
||||
const out = props.value || props.default || [];
|
||||
|
||||
// Always put a "" in the last field to you can keep adding entries
|
||||
if (out[out.length-1] !== ""){
|
||||
@@ -26,36 +22,36 @@ export default class InputFont extends React.Component<InputFontProps> {
|
||||
else {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
changeFont(idx: number, newValue: string) {
|
||||
const changedValues = this.values.slice(0);
|
||||
const values = getValues();
|
||||
|
||||
const changeFont = (idx: number, newValue: string) => {
|
||||
const changedValues = values.slice(0);
|
||||
changedValues[idx] = newValue;
|
||||
const filteredValues = changedValues
|
||||
.filter(v => v !== undefined)
|
||||
.filter(v => v !== "");
|
||||
|
||||
this.props.onChange(filteredValues);
|
||||
}
|
||||
props.onChange(filteredValues);
|
||||
};
|
||||
|
||||
render() {
|
||||
const inputs = this.values.map((value, i) => {
|
||||
return <li
|
||||
key={i}
|
||||
>
|
||||
<InputAutocomplete
|
||||
aria-label={this.props["aria-label"] || this.props.name}
|
||||
value={value}
|
||||
options={this.props.fonts?.map(f => [f, f])}
|
||||
onChange={this.changeFont.bind(this, i)}
|
||||
/>
|
||||
</li>;
|
||||
});
|
||||
const inputs = values.map((value, i) => {
|
||||
return <li
|
||||
key={i}
|
||||
>
|
||||
<InputAutocomplete
|
||||
aria-label={props["aria-label"] || props.name}
|
||||
value={value}
|
||||
options={fonts.map(f => [f, f])}
|
||||
onChange={changeFont.bind(null, i)}
|
||||
/>
|
||||
</li>;
|
||||
});
|
||||
|
||||
return (
|
||||
<ul className="maputnik-font">
|
||||
{inputs}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<ul className="maputnik-font">
|
||||
{inputs}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import classnames from "classnames";
|
||||
import { type WithTranslation, withTranslation } from "react-i18next";
|
||||
|
||||
@@ -25,88 +25,49 @@ export type InputJsonProps = {
|
||||
};
|
||||
type InputJsonInternalProps = InputJsonProps & WithTranslation;
|
||||
|
||||
type InputJsonState = {
|
||||
isEditing: boolean
|
||||
prevValue: string
|
||||
};
|
||||
function getPrettyJson(data: any) {
|
||||
return stringifyPretty(data, {indent: 2, maxLength: 40});
|
||||
}
|
||||
|
||||
class InputJsonInternal extends React.Component<InputJsonInternalProps, InputJsonState> {
|
||||
static defaultProps = {
|
||||
onFocus: () => {},
|
||||
onBlur: () => {},
|
||||
withScroll: false
|
||||
};
|
||||
_view: EditorView | undefined;
|
||||
_el: HTMLDivElement | null = null;
|
||||
_cancelNextChange: boolean = false;
|
||||
const InputJsonInternal: React.FC<InputJsonInternalProps> = ({
|
||||
value,
|
||||
className,
|
||||
onChange,
|
||||
onFocus = () => {},
|
||||
onBlur = () => {},
|
||||
lintType,
|
||||
spec,
|
||||
withScroll = false,
|
||||
}) => {
|
||||
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);
|
||||
|
||||
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});
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
this._view = createEditor({
|
||||
parent: this._el!,
|
||||
value: this.getPrettyJson(this.props.value),
|
||||
lintType: this.props.lintType || "layer",
|
||||
onChange: (value:string) => this.onChange(value),
|
||||
onFocus: () => this.onFocus(),
|
||||
onBlur: () => this.onBlur(),
|
||||
spec: this.props.spec
|
||||
});
|
||||
}
|
||||
|
||||
onFocus = () => {
|
||||
if (this.props.onFocus) this.props.onFocus();
|
||||
this.setState({
|
||||
isEditing: true,
|
||||
});
|
||||
const handleFocus = () => {
|
||||
if (onFocus) onFocus();
|
||||
isEditing.current = true;
|
||||
};
|
||||
|
||||
onBlur = () => {
|
||||
if (this.props.onBlur) this.props.onBlur();
|
||||
this.setState({
|
||||
isEditing: false,
|
||||
});
|
||||
const handleBlur = () => {
|
||||
if (onBlur) onBlur();
|
||||
isEditing.current = false;
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: InputJsonProps) {
|
||||
if (!this.state.isEditing && prevProps.value !== this.props.value) {
|
||||
this._cancelNextChange = true;
|
||||
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(),
|
||||
});
|
||||
const handleChange = () => {
|
||||
if (cancelNextChange.current) {
|
||||
cancelNextChange.current = false;
|
||||
prevValue.current = view.current!.state.doc.toString();
|
||||
return;
|
||||
}
|
||||
const newCode = this._view!.state.doc.toString();
|
||||
const newCode = view.current!.state.doc.toString();
|
||||
|
||||
if (this.state.prevValue !== newCode) {
|
||||
if (prevValue.current !== newCode) {
|
||||
let parsedLayer, err;
|
||||
try {
|
||||
parsedLayer = JSON.parse(newCode);
|
||||
@@ -116,24 +77,63 @@ class InputJsonInternal extends React.Component<InputJsonInternalProps, InputJso
|
||||
}
|
||||
|
||||
if (!err) {
|
||||
if (this.props.onChange) this.props.onChange(parsedLayer);
|
||||
if (onChange) onChange(parsedLayer);
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
prevValue: newCode,
|
||||
});
|
||||
prevValue.current = newCode;
|
||||
};
|
||||
|
||||
render() {
|
||||
return <div className="json-editor" data-wd-key="json-editor" aria-hidden="true" style={{cursor: "text"}}>
|
||||
<div
|
||||
className={classnames("codemirror-container", this.props.className)}
|
||||
ref={(el) => {this._el = el;}}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
// The editor is created once on mount, so its callbacks have to go through a
|
||||
// ref to always see the latest props.
|
||||
const handlers = useRef({handleChange, handleFocus, handleBlur});
|
||||
useEffect(() => {
|
||||
handlers.current = {handleChange, handleFocus, handleBlur};
|
||||
});
|
||||
|
||||
const InputJson = withTranslation()(InputJsonInternal);
|
||||
export default InputJson;
|
||||
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"}}>
|
||||
<div
|
||||
className={classnames("codemirror-container", className)}
|
||||
ref={el}
|
||||
/>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export const InputJson = withTranslation()(InputJsonInternal);
|
||||
|
||||
@@ -9,31 +9,29 @@ export type InputMultiInputProps = {
|
||||
"aria-label"?: string
|
||||
};
|
||||
|
||||
export default class InputMultiInput extends React.Component<InputMultiInputProps> {
|
||||
render() {
|
||||
let options = this.props.options;
|
||||
if(options.length > 0 && !Array.isArray(options[0])) {
|
||||
options = options.map(v => [v, v]);
|
||||
}
|
||||
|
||||
const selectedValue = this.props.value || options[0][0];
|
||||
const radios = options.map(([val, label])=> {
|
||||
return <label
|
||||
key={val}
|
||||
className={classnames("maputnik-button", "maputnik-radio-as-button", {"maputnik-button-selected": val === selectedValue})}
|
||||
>
|
||||
<input type="radio"
|
||||
name={this.props.name}
|
||||
onChange={_e => this.props.onChange(val)}
|
||||
value={val}
|
||||
checked={val === selectedValue}
|
||||
/>
|
||||
{label}
|
||||
</label>;
|
||||
});
|
||||
|
||||
return <fieldset className="maputnik-multibutton" aria-label={this.props["aria-label"]}>
|
||||
{radios}
|
||||
</fieldset>;
|
||||
export const InputMultiInput: React.FC<InputMultiInputProps> = (props) => {
|
||||
let options = props.options;
|
||||
if(options.length > 0 && !Array.isArray(options[0])) {
|
||||
options = options.map(v => [v, v]);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedValue = props.value || options[0][0];
|
||||
const radios = options.map(([val, label])=> {
|
||||
return <label
|
||||
key={val}
|
||||
className={classnames("maputnik-button", "maputnik-radio-as-button", {"maputnik-button-selected": val === selectedValue})}
|
||||
>
|
||||
<input type="radio"
|
||||
name={props.name}
|
||||
onChange={_e => props.onChange(val)}
|
||||
value={val}
|
||||
checked={val === selectedValue}
|
||||
/>
|
||||
{label}
|
||||
</label>;
|
||||
});
|
||||
|
||||
return <fieldset className="maputnik-multibutton" aria-label={props["aria-label"]}>
|
||||
{radios}
|
||||
</fieldset>;
|
||||
};
|
||||
|
||||
+141
-169
@@ -1,5 +1,4 @@
|
||||
import React, { type BaseSyntheticEvent } from "react";
|
||||
import generateUniqueId from "../libs/document-uid";
|
||||
import React, { type BaseSyntheticEvent, useRef, useState } from "react";
|
||||
|
||||
export type InputNumberProps = {
|
||||
value?: number
|
||||
@@ -14,233 +13,206 @@ export type InputNumberProps = {
|
||||
"aria-label"?: string
|
||||
};
|
||||
|
||||
type InputNumberState = {
|
||||
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
|
||||
};
|
||||
export const InputNumber: React.FC<InputNumberProps> = (props) => {
|
||||
const { rangeStep = 1 } = props;
|
||||
|
||||
export default class InputNumber extends React.Component<InputNumberProps, InputNumberState> {
|
||||
static defaultProps = {
|
||||
rangeStep: 1
|
||||
};
|
||||
_keyboardEvent: boolean = false;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editingRange, setEditingRange] = useState(false);
|
||||
const [value, setValue] = useState<number | undefined>(props.value);
|
||||
/** The value currently being edited. It can be an invalid value. */
|
||||
const [dirtyValue, setDirtyValue] = useState<number | string | undefined>(props.value);
|
||||
|
||||
constructor(props: InputNumberProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
uuid: +generateUniqueId(),
|
||||
editing: false,
|
||||
value: props.value,
|
||||
dirtyValue: props.value,
|
||||
};
|
||||
const keyboardEvent = useRef(false);
|
||||
|
||||
// Replaces getDerivedStateFromProps: while not editing, track the prop.
|
||||
if (!editing && props.value !== value) {
|
||||
setValue(props.value);
|
||||
setDirtyValue(props.value);
|
||||
}
|
||||
|
||||
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) {
|
||||
function isValid(v: number | string | undefined) {
|
||||
if (v === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const value = +v;
|
||||
if(isNaN(value)) {
|
||||
const val = +v;
|
||||
if(isNaN(val)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if(!isNaN(this.props.min!) && value < this.props.min!) {
|
||||
if(!isNaN(props.min!) && val < props.min!) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if(!isNaN(this.props.max!) && value > this.props.max!) {
|
||||
if(!isNaN(props.max!) && val > props.max!) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
resetValue = () => {
|
||||
this.setState({editing: false});
|
||||
function changeValue(newValue: number | string | undefined) {
|
||||
const val = (newValue === "" || newValue === undefined) ?
|
||||
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
|
||||
if(!this.state.value) {
|
||||
if(!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If set value is invalid fall back to the last valid value from props or at last resort the default value
|
||||
if (!this.isValid(this.state.value)) {
|
||||
if(this.isValid(this.props.value)) {
|
||||
this.changeValue(this.props.value);
|
||||
this.setState({dirtyValue: this.props.value});
|
||||
if (!isValid(value)) {
|
||||
if(isValid(props.value)) {
|
||||
changeValue(props.value);
|
||||
setDirtyValue(props.value);
|
||||
} else {
|
||||
this.changeValue(undefined);
|
||||
this.setState({dirtyValue: undefined});
|
||||
changeValue(undefined);
|
||||
setDirtyValue(undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onChangeRange = (e: BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => {
|
||||
let value = parseFloat(e.target.value);
|
||||
const step = this.props.rangeStep;
|
||||
let dirtyValue = value;
|
||||
const onChangeRange = (e: BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => {
|
||||
let newValue = parseFloat(e.target.value);
|
||||
const step = rangeStep;
|
||||
let newDirtyValue = newValue;
|
||||
|
||||
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.
|
||||
const snap = value % step;
|
||||
const snap = newValue % step;
|
||||
|
||||
// Round up/down to step
|
||||
if (this._keyboardEvent) {
|
||||
if (keyboardEvent.current) {
|
||||
// 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
|
||||
// that came from a keyboard event we always want to increase by a
|
||||
// single step value.
|
||||
if (value < +this.state.dirtyValue!) {
|
||||
value = this.state.value! - step;
|
||||
if (newValue < +dirtyValue!) {
|
||||
newValue = value! - step;
|
||||
}
|
||||
else {
|
||||
value = this.state.value! + step;
|
||||
newValue = value! + step;
|
||||
}
|
||||
dirtyValue = value;
|
||||
newDirtyValue = newValue;
|
||||
}
|
||||
else {
|
||||
if (snap < step/2) {
|
||||
value = value - snap;
|
||||
newValue = newValue - snap;
|
||||
}
|
||||
else {
|
||||
value = value + (step - snap);
|
||||
newValue = newValue + (step - snap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._keyboardEvent = false;
|
||||
keyboardEvent.current = false;
|
||||
|
||||
// Clamp between min/max
|
||||
value = Math.max(this.props.min!, Math.min(this.props.max!, value));
|
||||
newValue = Math.max(props.min!, Math.min(props.max!, newValue));
|
||||
|
||||
this.setState({value, dirtyValue});
|
||||
if (this.props.onChange) this.props.onChange(value);
|
||||
setValue(newValue);
|
||||
setDirtyValue(newDirtyValue);
|
||||
if (props.onChange) props.onChange(newValue);
|
||||
};
|
||||
|
||||
render() {
|
||||
if(
|
||||
Object.prototype.hasOwnProperty.call(this.props, "min") &&
|
||||
Object.prototype.hasOwnProperty.call(this.props, "max") &&
|
||||
this.props.min !== undefined && this.props.max !== undefined &&
|
||||
this.props.allowRange
|
||||
) {
|
||||
const value = this.state.editing ? this.state.dirtyValue : this.state.value;
|
||||
const defaultValue = this.props.default === undefined ? "" : this.props.default;
|
||||
let inputValue;
|
||||
if (this.state.editingRange) {
|
||||
inputValue = this.state.value;
|
||||
}
|
||||
else {
|
||||
inputValue = value;
|
||||
}
|
||||
|
||||
return <div className="maputnik-number-container">
|
||||
<input
|
||||
className="maputnik-number-range"
|
||||
key="range"
|
||||
type="range"
|
||||
max={this.props.max}
|
||||
min={this.props.min}
|
||||
step="any"
|
||||
spellCheck="false"
|
||||
value={value === undefined ? defaultValue : value}
|
||||
onChange={this.onChangeRange}
|
||||
onKeyDown={() => {
|
||||
this._keyboardEvent = true;
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
this.setState({editing: true, editingRange: true});
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
// Safari doesn't get onBlur event
|
||||
this.setState({editing: false, editingRange: false});
|
||||
}}
|
||||
onBlur={() => {
|
||||
this.setState({
|
||||
editing: false,
|
||||
editingRange: false,
|
||||
dirtyValue: this.state.value,
|
||||
});
|
||||
}}
|
||||
data-wd-key={this.props["data-wd-key"] + "-range"}
|
||||
/>
|
||||
<input
|
||||
key="text"
|
||||
type="text"
|
||||
spellCheck="false"
|
||||
className="maputnik-number"
|
||||
placeholder={this.props.default?.toString()}
|
||||
value={inputValue === undefined ? "" : inputValue}
|
||||
onFocus={_e => {
|
||||
this.setState({editing: true});
|
||||
}}
|
||||
onChange={e => {
|
||||
this.changeValue(e.target.value);
|
||||
}}
|
||||
onBlur={_e => {
|
||||
this.setState({editing: false});
|
||||
this.resetValue();
|
||||
}}
|
||||
data-wd-key={this.props["data-wd-key"] + "-text"}
|
||||
|
||||
/>
|
||||
</div>;
|
||||
if(
|
||||
Object.prototype.hasOwnProperty.call(props, "min") &&
|
||||
Object.prototype.hasOwnProperty.call(props, "max") &&
|
||||
props.min !== undefined && props.max !== undefined &&
|
||||
props.allowRange
|
||||
) {
|
||||
const currentValue = editing ? dirtyValue : value;
|
||||
const defaultValue = props.default === undefined ? "" : props.default;
|
||||
let inputValue;
|
||||
if (editingRange) {
|
||||
inputValue = value;
|
||||
}
|
||||
else {
|
||||
const value = this.state.editing ? this.state.dirtyValue : this.state.value;
|
||||
inputValue = currentValue;
|
||||
}
|
||||
|
||||
return <input
|
||||
aria-label={this.props["aria-label"]}
|
||||
return <div className="maputnik-number-container">
|
||||
<input
|
||||
className="maputnik-number-range"
|
||||
key="range"
|
||||
type="range"
|
||||
max={props.max}
|
||||
min={props.min}
|
||||
step="any"
|
||||
spellCheck="false"
|
||||
value={currentValue === undefined ? defaultValue : currentValue}
|
||||
onChange={onChangeRange}
|
||||
onKeyDown={() => {
|
||||
keyboardEvent.current = true;
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
setEditing(true);
|
||||
setEditingRange(true);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
// Safari doesn't get onBlur event
|
||||
setEditing(false);
|
||||
setEditingRange(false);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setEditing(false);
|
||||
setEditingRange(false);
|
||||
setDirtyValue(value);
|
||||
}}
|
||||
data-wd-key={props["data-wd-key"] + "-range"}
|
||||
/>
|
||||
<input
|
||||
key="text"
|
||||
type="text"
|
||||
spellCheck="false"
|
||||
className="maputnik-number"
|
||||
placeholder={this.props.default?.toString()}
|
||||
value={value === undefined ? "" : value}
|
||||
onChange={e => this.changeValue(e.target.value)}
|
||||
onFocus={() => {
|
||||
this.setState({editing: true});
|
||||
placeholder={props.default?.toString()}
|
||||
value={inputValue === undefined ? "" : inputValue}
|
||||
onFocus={_e => {
|
||||
setEditing(true);
|
||||
}}
|
||||
onBlur={this.resetValue}
|
||||
required={this.props.required}
|
||||
data-wd-key={this.props["data-wd-key"]}
|
||||
/>;
|
||||
}
|
||||
onChange={e => {
|
||||
changeValue(e.target.value);
|
||||
}}
|
||||
onBlur={_e => {
|
||||
setEditing(false);
|
||||
resetValue();
|
||||
}}
|
||||
data-wd-key={props["data-wd-key"] + "-text"}
|
||||
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
else {
|
||||
const currentValue = editing ? dirtyValue : value;
|
||||
|
||||
return <input
|
||||
aria-label={props["aria-label"]}
|
||||
spellCheck="false"
|
||||
className="maputnik-number"
|
||||
placeholder={props.default?.toString()}
|
||||
value={currentValue === undefined ? "" : currentValue}
|
||||
onChange={e => changeValue(e.target.value)}
|
||||
onFocus={() => {
|
||||
setEditing(true);
|
||||
}}
|
||||
onBlur={resetValue}
|
||||
required={props.required}
|
||||
data-wd-key={props["data-wd-key"]}
|
||||
/>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,23 +10,21 @@ export type InputSelectProps = {
|
||||
"aria-label"?: string
|
||||
};
|
||||
|
||||
export default class InputSelect extends React.Component<InputSelectProps> {
|
||||
render() {
|
||||
let options = this.props.options;
|
||||
if(options.length > 0 && !Array.isArray(options[0])) {
|
||||
options = options.map((v) => [v, v]) as [string, any][];
|
||||
}
|
||||
|
||||
return <select
|
||||
className="maputnik-select"
|
||||
data-wd-key={this.props["data-wd-key"]}
|
||||
style={this.props.style}
|
||||
title={this.props.title}
|
||||
value={this.props.value}
|
||||
onChange={e => this.props.onChange(e.target.value)}
|
||||
aria-label={this.props["aria-label"]}
|
||||
>
|
||||
{ options.map(([val, label]) => <option key={val} value={val}>{label}</option>) }
|
||||
</select>;
|
||||
export const InputSelect: React.FC<InputSelectProps> = (props) => {
|
||||
let options = props.options;
|
||||
if(options.length > 0 && !Array.isArray(options[0])) {
|
||||
options = options.map((v) => [v, v]) as [string, any][];
|
||||
}
|
||||
}
|
||||
|
||||
return <select
|
||||
className="maputnik-select"
|
||||
data-wd-key={props["data-wd-key"]}
|
||||
style={props.style}
|
||||
title={props.title}
|
||||
value={props.value}
|
||||
onChange={e => props.onChange(e.target.value)}
|
||||
aria-label={props["aria-label"]}
|
||||
>
|
||||
{ options.map(([val, label]) => <option key={val} value={val}>{label}</option>) }
|
||||
</select>;
|
||||
};
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { type ReactElement } from "react";
|
||||
|
||||
import InputColor, { type InputColorProps } from "./InputColor";
|
||||
import InputNumber, { type InputNumberProps } from "./InputNumber";
|
||||
import InputCheckbox, { type InputCheckboxProps } from "./InputCheckbox";
|
||||
import InputString, { type InputStringProps } from "./InputString";
|
||||
import InputArray, { type InputArrayProps } from "./InputArray";
|
||||
import InputDynamicArray, { type InputDynamicArrayProps } from "./InputDynamicArray";
|
||||
import InputFont, { type InputFontProps } from "./InputFont";
|
||||
import InputAutocomplete, { type InputAutocompleteProps } from "./InputAutocomplete";
|
||||
import InputEnum, { type InputEnumProps } from "./InputEnum";
|
||||
import { InputColor, type InputColorProps } from "./InputColor";
|
||||
import { InputNumber, type InputNumberProps } from "./InputNumber";
|
||||
import { InputCheckbox, type InputCheckboxProps } from "./InputCheckbox";
|
||||
import { InputString, type InputStringProps } from "./InputString";
|
||||
import { InputArray, type InputArrayProps } from "./InputArray";
|
||||
import { InputDynamicArray, type InputDynamicArrayProps } from "./InputDynamicArray";
|
||||
import { InputFont, type InputFontProps } from "./InputFont";
|
||||
import { InputAutocomplete, type InputAutocompleteProps } from "./InputAutocomplete";
|
||||
import { InputEnum, type InputEnumProps } from "./InputEnum";
|
||||
import capitalize from "lodash.capitalize";
|
||||
|
||||
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
|
||||
* choose the correct field component based on the @{fieldSpec}
|
||||
* to display @{value}. */
|
||||
export default class InputSpec extends React.Component<InputSpecProps> {
|
||||
export const InputSpec: React.FC<InputSpecProps> = (props) => {
|
||||
|
||||
childNodes() {
|
||||
const childNodes = () => {
|
||||
const commonProps = {
|
||||
fieldSpec: this.props.fieldSpec,
|
||||
label: this.props.label,
|
||||
action: this.props.action,
|
||||
style: this.props.style,
|
||||
value: this.props.value,
|
||||
default: this.props.fieldSpec?.default,
|
||||
name: this.props.fieldName,
|
||||
"data-wd-key": "spec-field-input:" + this.props.fieldName,
|
||||
onChange: (newValue: number | undefined | (string | number | undefined)[]) => this.props.onChange!(this.props.fieldName, newValue),
|
||||
"aria-label": this.props["aria-label"],
|
||||
fieldSpec: props.fieldSpec,
|
||||
label: props.label,
|
||||
action: props.action,
|
||||
style: props.style,
|
||||
value: props.value,
|
||||
default: props.fieldSpec?.default,
|
||||
name: props.fieldName,
|
||||
"data-wd-key": "spec-field-input:" + props.fieldName,
|
||||
onChange: (newValue: number | undefined | (string | number | undefined)[]) => props.onChange!(props.fieldName, newValue),
|
||||
"aria-label": props["aria-label"],
|
||||
};
|
||||
switch(this.props.fieldSpec?.type) {
|
||||
switch(props.fieldSpec?.type) {
|
||||
case "number": return (
|
||||
<InputNumber
|
||||
{...commonProps as InputNumberProps}
|
||||
min={this.props.fieldSpec.minimum}
|
||||
max={this.props.fieldSpec.maximum}
|
||||
min={props.fieldSpec.minimum}
|
||||
max={props.fieldSpec.maximum}
|
||||
/>
|
||||
);
|
||||
case "enum": {
|
||||
const options = Object.keys(this.props.fieldSpec.values || []).map(v => [v, capitalize(v)]);
|
||||
const options = Object.keys(props.fieldSpec.values || []).map(v => [v, capitalize(v)]);
|
||||
|
||||
return <InputEnum
|
||||
{...commonProps as Omit<InputEnumProps, "options">}
|
||||
@@ -72,8 +72,8 @@ export default class InputSpec extends React.Component<InputSpecProps> {
|
||||
case "resolvedImage":
|
||||
case "formatted":
|
||||
case "string":
|
||||
if (iconProperties.indexOf(this.props.fieldName!) >= 0) {
|
||||
const options = this.props.fieldSpec.values || [];
|
||||
if (iconProperties.indexOf(props.fieldName!) >= 0) {
|
||||
const options = props.fieldSpec.values || [];
|
||||
return <InputAutocomplete
|
||||
{...commonProps as Omit<InputAutocompleteProps, "options">}
|
||||
options={options.map(f => [f, f])}
|
||||
@@ -94,61 +94,59 @@ export default class InputSpec extends React.Component<InputSpecProps> {
|
||||
/>
|
||||
);
|
||||
case "array":
|
||||
if(this.props.fieldName === "text-font") {
|
||||
if(props.fieldName === "text-font") {
|
||||
return <InputFont
|
||||
{...commonProps as InputFontProps}
|
||||
fonts={this.props.fieldSpec.values}
|
||||
fonts={props.fieldSpec.values}
|
||||
/>;
|
||||
} else {
|
||||
if (this.props.fieldSpec.length) {
|
||||
if (props.fieldSpec.length) {
|
||||
return <InputArray
|
||||
{...commonProps as InputArrayProps}
|
||||
type={this.props.fieldSpec.value}
|
||||
length={this.props.fieldSpec.length}
|
||||
type={props.fieldSpec.value}
|
||||
length={props.fieldSpec.length}
|
||||
/>;
|
||||
} else {
|
||||
return <InputDynamicArray
|
||||
{...commonProps as InputDynamicArrayProps}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
type={this.props.fieldSpec.value as InputDynamicArrayProps["type"]}
|
||||
fieldSpec={props.fieldSpec}
|
||||
type={props.fieldSpec.value as InputDynamicArrayProps["type"]}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
case "numberArray": return (
|
||||
<InputDynamicArray
|
||||
{...commonProps as InputDynamicArrayProps}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
fieldSpec={props.fieldSpec}
|
||||
type="number"
|
||||
value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]}
|
||||
value={(Array.isArray(props.value) ? props.value : [props.value]) as (string | number | undefined)[]}
|
||||
/>
|
||||
);
|
||||
case "colorArray": return (
|
||||
<InputDynamicArray
|
||||
{...commonProps as InputDynamicArrayProps}
|
||||
fieldSpec={this.props.fieldSpec}
|
||||
fieldSpec={props.fieldSpec}
|
||||
type="color"
|
||||
value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]}
|
||||
value={(Array.isArray(props.value) ? props.value : [props.value]) as (string | number | undefined)[]}
|
||||
/>
|
||||
);
|
||||
case "padding": return (
|
||||
<InputArray
|
||||
{...commonProps as InputArrayProps}
|
||||
type="number"
|
||||
value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]}
|
||||
value={(Array.isArray(props.value) ? props.value : [props.value]) as (string | number | undefined)[]}
|
||||
length={4}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
console.warn(`No proper field input for ${this.props.fieldName} type: ${this.props.fieldSpec?.type}`);
|
||||
console.warn(`No proper field input for ${props.fieldName} type: ${props.fieldSpec?.type}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div data-wd-key={"spec-field:"+this.props.fieldName}>
|
||||
{this.childNodes()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div data-wd-key={"spec-field:"+props.fieldName}>
|
||||
{childNodes()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
export type InputStringProps = {
|
||||
"data-wd-key"?: string
|
||||
@@ -15,85 +15,66 @@ export type InputStringProps = {
|
||||
title?: string
|
||||
};
|
||||
|
||||
type InputStringState = {
|
||||
editing: boolean
|
||||
value?: string
|
||||
export const InputString: React.FC<InputStringProps> = (props) => {
|
||||
const { onInput = () => {} } = props;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState<string | undefined>(props.value);
|
||||
|
||||
// Replaces getDerivedStateFromProps: while the field is not being edited its
|
||||
// value tracks the prop, so an external change to the style is picked up.
|
||||
if (!editing && value !== props.value) {
|
||||
setValue(props.value);
|
||||
}
|
||||
|
||||
let tag;
|
||||
let classes;
|
||||
|
||||
if(props.multi) {
|
||||
tag = "textarea";
|
||||
classes = [
|
||||
"maputnik-string",
|
||||
"maputnik-string--multi"
|
||||
];
|
||||
}
|
||||
else {
|
||||
tag = "input";
|
||||
classes = [
|
||||
"maputnik-string"
|
||||
];
|
||||
}
|
||||
|
||||
if(props.disabled) {
|
||||
classes.push("maputnik-string--disabled");
|
||||
}
|
||||
|
||||
return React.createElement(tag, {
|
||||
"aria-label": props["aria-label"],
|
||||
"data-wd-key": props["data-wd-key"],
|
||||
spellCheck: Object.prototype.hasOwnProperty.call(props, "spellCheck") ? props.spellCheck : !(tag === "input"),
|
||||
disabled: props.disabled,
|
||||
className: classes.join(" "),
|
||||
style: props.style,
|
||||
value: value === undefined ? "" : value,
|
||||
placeholder: props.default,
|
||||
title: props.title,
|
||||
onChange: (e: React.BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => {
|
||||
setEditing(true);
|
||||
setValue(e.target.value);
|
||||
onInput(e.target.value);
|
||||
},
|
||||
onBlur: () => {
|
||||
// Note: editing is only cleared when the value actually changed; this
|
||||
// mirrors the original and keeps a no-op blur from resyncing the value.
|
||||
if(value !== props.value) {
|
||||
setEditing(false);
|
||||
if (props.onChange) props.onChange(value);
|
||||
}
|
||||
},
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
if (e.keyCode === 13 && props.onChange) {
|
||||
props.onChange(value);
|
||||
}
|
||||
},
|
||||
required: props.required,
|
||||
});
|
||||
};
|
||||
|
||||
export default class InputString extends React.Component<InputStringProps, InputStringState> {
|
||||
static defaultProps = {
|
||||
onInput: () => {},
|
||||
};
|
||||
|
||||
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 classes;
|
||||
|
||||
if(this.props.multi) {
|
||||
tag = "textarea";
|
||||
classes = [
|
||||
"maputnik-string",
|
||||
"maputnik-string--multi"
|
||||
];
|
||||
}
|
||||
else {
|
||||
tag = "input";
|
||||
classes = [
|
||||
"maputnik-string"
|
||||
];
|
||||
}
|
||||
|
||||
if(this.props.disabled) {
|
||||
classes.push("maputnik-string--disabled");
|
||||
}
|
||||
|
||||
return React.createElement(tag, {
|
||||
"aria-label": this.props["aria-label"],
|
||||
"data-wd-key": this.props["data-wd-key"],
|
||||
spellCheck: Object.prototype.hasOwnProperty.call(this.props, "spellCheck") ? this.props.spellCheck : !(tag === "input"),
|
||||
disabled: this.props.disabled,
|
||||
className: classes.join(" "),
|
||||
style: this.props.style,
|
||||
value: this.state.value === undefined ? "" : this.state.value,
|
||||
placeholder: this.props.default,
|
||||
title: this.props.title,
|
||||
onChange: (e: React.BaseSyntheticEvent<Event, HTMLInputElement, HTMLInputElement>) => {
|
||||
this.setState({
|
||||
editing: true,
|
||||
value: e.target.value
|
||||
}, () => {
|
||||
if (this.props.onInput) this.props.onInput(this.state.value);
|
||||
});
|
||||
},
|
||||
onBlur: () => {
|
||||
if(this.state.value!==this.props.value) {
|
||||
this.setState({editing: false});
|
||||
if (this.props.onChange) this.props.onChange(this.state.value);
|
||||
}
|
||||
},
|
||||
onKeyDown: (e) => {
|
||||
if (e.keyCode === 13 && this.props.onChange) {
|
||||
this.props.onChange(this.state.value);
|
||||
}
|
||||
},
|
||||
required: this.props.required,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+28
-48
@@ -1,6 +1,6 @@
|
||||
import React, { type JSX } from "react";
|
||||
import InputString from "./InputString";
|
||||
import SmallError from "./SmallError";
|
||||
import React, { type JSX, useState } from "react";
|
||||
import { InputString } from "./InputString";
|
||||
import { SmallError } from "./SmallError";
|
||||
import { Trans, type WithTranslation, withTranslation } from "react-i18next";
|
||||
import { type TFunction } from "i18next";
|
||||
import { ErrorType, validate } from "../libs/urlopen";
|
||||
@@ -46,50 +46,30 @@ export type FieldUrlProps = {
|
||||
|
||||
type InputUrlInternalProps = FieldUrlProps & WithTranslation;
|
||||
|
||||
type InputUrlState = {
|
||||
error?: ErrorType
|
||||
const InputUrlInternal: React.FC<InputUrlInternalProps> = ({onInput = () => {}, ...props}) => {
|
||||
const [error, setError] = useState<ErrorType | undefined>(() => validate(props.value));
|
||||
|
||||
const handleInput = (url: string) => {
|
||||
setError(validate(url));
|
||||
onInput(url);
|
||||
};
|
||||
|
||||
const handleChange = (url: string) => {
|
||||
setError(validate(url));
|
||||
props.onChange(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<InputString
|
||||
{...props}
|
||||
onInput={handleInput}
|
||||
onChange={handleChange}
|
||||
aria-label={props["aria-label"]}
|
||||
/>
|
||||
{errorTypeToJsx(error, props.t)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
class InputUrlInternal extends React.Component<InputUrlInternalProps, InputUrlState> {
|
||||
static defaultProps = {
|
||||
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 (
|
||||
<div>
|
||||
<InputString
|
||||
{...this.props}
|
||||
onInput={this.onInput}
|
||||
onChange={this.onChange}
|
||||
aria-label={this.props["aria-label"]}
|
||||
/>
|
||||
{errorTypeToJsx(this.state.error, this.props.t)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const InputUrl = withTranslation()(InputUrlInternal);
|
||||
export default InputUrl;
|
||||
export const InputUrl = withTranslation()(InputUrlInternal);
|
||||
|
||||
+195
-206
@@ -1,4 +1,4 @@
|
||||
import React, { type JSX } from "react";
|
||||
import React, { type JSX, useState } from "react";
|
||||
import { Wrapper, Button, Menu, MenuItem } from "react-aria-menubutton";
|
||||
import { Accordion } from "react-accessible-accordion";
|
||||
import { MdMoreVert } from "react-icons/md";
|
||||
@@ -6,18 +6,20 @@ import { IconContext } from "react-icons";
|
||||
import { type BackgroundLayerSpecification, type LayerSpecification, type SourceSpecification } from "maplibre-gl";
|
||||
import { v8 } from "@maplibre/maplibre-gl-style-spec";
|
||||
|
||||
import FieldJson from "./FieldJson";
|
||||
import FilterEditor from "./FilterEditor";
|
||||
import PropertyGroup from "./PropertyGroup";
|
||||
import LayerEditorGroup from "./LayerEditorGroup";
|
||||
import FieldType from "./FieldType";
|
||||
import FieldId from "./FieldId";
|
||||
import FieldMinZoom from "./FieldMinZoom";
|
||||
import FieldMaxZoom from "./FieldMaxZoom";
|
||||
import FieldComment from "./FieldComment";
|
||||
import FieldSource from "./FieldSource";
|
||||
import FieldSourceLayer from "./FieldSourceLayer";
|
||||
import { changeType, changeProperty } from "../libs/layer";
|
||||
import { FieldJson } from "./FieldJson";
|
||||
import { FilterEditor } from "./FilterEditor";
|
||||
import { PropertyGroup } from "./PropertyGroup";
|
||||
import { LayerEditorGroup } from "./LayerEditorGroup";
|
||||
import { FieldType } from "./FieldType";
|
||||
import { FieldId } from "./FieldId";
|
||||
import { FieldMinZoom } from "./FieldMinZoom";
|
||||
import { FieldMaxZoom } from "./FieldMaxZoom";
|
||||
import { FieldComment } from "./FieldComment";
|
||||
import { FieldSource } from "./FieldSource";
|
||||
import { FieldSourceLayer } from "./FieldSourceLayer";
|
||||
// Aliased: the component defines its own changeProperty, which would otherwise
|
||||
// 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 { type WithTranslation, withTranslation } from "react-i18next";
|
||||
import { type TFunction } from "i18next";
|
||||
@@ -131,67 +133,56 @@ type LayerEditorInternalProps = {
|
||||
errors?: MappedError[]
|
||||
} & WithTranslation;
|
||||
|
||||
type LayerEditorState = {
|
||||
editorGroups: { [keys: string]: boolean }
|
||||
};
|
||||
|
||||
/** Layer editor supporting multiple types of layers. */
|
||||
class LayerEditorInternal extends React.Component<LayerEditorInternalProps, LayerEditorState> {
|
||||
static defaultProps = {
|
||||
onLayerChanged: () => { },
|
||||
onLayerIdChange: () => { },
|
||||
onLayerDestroyed: () => { },
|
||||
};
|
||||
const LayerEditorInternal: React.FC<LayerEditorInternalProps> = ({
|
||||
onLayerChanged = () => { },
|
||||
onLayerIdChange = () => { },
|
||||
...rest
|
||||
}) => {
|
||||
const props = { onLayerChanged, onLayerIdChange, ...rest } as LayerEditorInternalProps;
|
||||
|
||||
constructor(props: LayerEditorInternalProps) {
|
||||
super(props);
|
||||
|
||||
const editorGroups: { [keys: string]: boolean } = {};
|
||||
for (const group of layoutGroups(this.props.layer.type, props.t)) {
|
||||
editorGroups[group.title] = true;
|
||||
const [editorGroups, setEditorGroups] = useState<{ [keys: string]: boolean }>(() => {
|
||||
const groups: { [keys: string]: boolean } = {};
|
||||
for (const group of layoutGroups(props.layer.type, props.t)) {
|
||||
groups[group.title] = true;
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
this.state = { editorGroups };
|
||||
// Replaces getDerivedStateFromProps: groups that appear after mount (because
|
||||
// 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)
|
||||
.filter(group => !(group.title in editorGroups));
|
||||
if (newGroups.length > 0) {
|
||||
const additionalGroups = { ...editorGroups };
|
||||
for (const group of newGroups) {
|
||||
additionalGroups[group.title] = true;
|
||||
}
|
||||
setEditorGroups(additionalGroups);
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props: Readonly<LayerEditorInternalProps>, state: LayerEditorState) {
|
||||
const additionalGroups = { ...state.editorGroups };
|
||||
|
||||
for (const group of getLayoutForType(props.layer.type, props.t)) {
|
||||
if (!(group.title in additionalGroups)) {
|
||||
additionalGroups[group.title] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
editorGroups: additionalGroups
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
changeProperty(group: keyof LayerSpecification | null, property: string, newValue: any) {
|
||||
this.props.onLayerChanged(
|
||||
this.props.layerIndex,
|
||||
changeProperty(this.props.layer, group, property, newValue)
|
||||
function changeProperty(group: keyof LayerSpecification | null, property: string, newValue: any) {
|
||||
props.onLayerChanged(
|
||||
props.layerIndex,
|
||||
changeLayerProperty(props.layer, group, property, newValue)
|
||||
);
|
||||
}
|
||||
|
||||
onGroupToggle(groupTitle: string, active: boolean) {
|
||||
function onGroupToggle(groupTitle: string, active: boolean) {
|
||||
const changedActiveGroups = {
|
||||
...this.state.editorGroups,
|
||||
...editorGroups,
|
||||
[groupTitle]: active,
|
||||
};
|
||||
this.setState({
|
||||
editorGroups: changedActiveGroups
|
||||
});
|
||||
setEditorGroups(changedActiveGroups);
|
||||
}
|
||||
|
||||
renderGroupType(type: string, fields?: string[]): JSX.Element {
|
||||
function renderGroupType(type: string, fields?: string[]): JSX.Element {
|
||||
let comment = "";
|
||||
if (this.props.layer.metadata) {
|
||||
comment = (this.props.layer.metadata as any)["maputnik:comment"];
|
||||
if (props.layer.metadata) {
|
||||
comment = (props.layer.metadata as any)["maputnik:comment"];
|
||||
}
|
||||
const { errors, layerIndex } = this.props;
|
||||
const { errors, layerIndex } = props;
|
||||
|
||||
const errorData: MappedLayerErrors = {};
|
||||
errors!.forEach(error => {
|
||||
@@ -207,84 +198,85 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
|
||||
});
|
||||
|
||||
let sourceLayerIds;
|
||||
const layer = this.props.layer as Exclude<LayerSpecification, BackgroundLayerSpecification>;
|
||||
if (Object.prototype.hasOwnProperty.call(this.props.sources, layer.source)) {
|
||||
sourceLayerIds = this.props.sources[layer.source].layers;
|
||||
const layer = props.layer as Exclude<LayerSpecification, BackgroundLayerSpecification>;
|
||||
if (Object.prototype.hasOwnProperty.call(props.sources, layer.source)) {
|
||||
sourceLayerIds = props.sources[layer.source].layers;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "layer": return <div>
|
||||
<FieldId
|
||||
value={this.props.layer.id}
|
||||
value={props.layer.id}
|
||||
wdKey="layer-editor.layer-id"
|
||||
error={errorData.id}
|
||||
onChange={newId => this.props.onLayerIdChange(this.props.layerIndex, this.props.layer.id, newId)}
|
||||
onChange={newId => props.onLayerIdChange(props.layerIndex, props.layer.id, newId)}
|
||||
/>
|
||||
<FieldType
|
||||
disabled={true}
|
||||
error={errorData.type}
|
||||
value={this.props.layer.type}
|
||||
onChange={newType => this.props.onLayerChanged(
|
||||
this.props.layerIndex,
|
||||
changeType(this.props.layer, newType)
|
||||
value={props.layer.type}
|
||||
onChange={newType => props.onLayerChanged(
|
||||
props.layerIndex,
|
||||
changeType(props.layer, newType)
|
||||
)}
|
||||
/>
|
||||
{this.props.layer.type !== "background" && <FieldSource
|
||||
{props.layer.type !== "background" && <FieldSource
|
||||
wdKey="layer-editor.layer-source"
|
||||
error={errorData.source}
|
||||
sourceIds={Object.keys(this.props.sources!)}
|
||||
value={this.props.layer.source}
|
||||
onChange={v => this.changeProperty(null, "source", v)}
|
||||
sourceIds={Object.keys(props.sources!)}
|
||||
value={props.layer.source}
|
||||
onChange={v => changeProperty(null, "source", v)}
|
||||
/>
|
||||
}
|
||||
{!NON_SOURCE_LAYERS.includes(this.props.layer.type) &&
|
||||
{!NON_SOURCE_LAYERS.includes(props.layer.type) &&
|
||||
<FieldSourceLayer
|
||||
error={errorData["source-layer"]}
|
||||
sourceLayerIds={sourceLayerIds}
|
||||
value={(this.props.layer as any)["source-layer"]}
|
||||
onChange={v => this.changeProperty(null, "source-layer", v)}
|
||||
value={(props.layer as any)["source-layer"]}
|
||||
onChange={v => changeProperty(null, "source-layer", v)}
|
||||
/>
|
||||
}
|
||||
<FieldMinZoom
|
||||
error={errorData.minzoom}
|
||||
value={this.props.layer.minzoom}
|
||||
onChange={v => this.changeProperty(null, "minzoom", v)}
|
||||
value={props.layer.minzoom}
|
||||
onChange={v => changeProperty(null, "minzoom", v)}
|
||||
/>
|
||||
<FieldMaxZoom
|
||||
error={errorData.maxzoom}
|
||||
value={this.props.layer.maxzoom}
|
||||
onChange={v => this.changeProperty(null, "maxzoom", v)}
|
||||
value={props.layer.maxzoom}
|
||||
onChange={v => changeProperty(null, "maxzoom", v)}
|
||||
/>
|
||||
<FieldComment
|
||||
error={errorData.comment}
|
||||
value={comment}
|
||||
onChange={v => this.changeProperty("metadata", "maputnik:comment", v == "" ? undefined : v)}
|
||||
onChange={v => changeProperty("metadata", "maputnik:comment", v == "" ? undefined : v)}
|
||||
/>
|
||||
</div>;
|
||||
case "filter": return <div>
|
||||
<div className="maputnik-filter-editor-wrapper">
|
||||
<FilterEditor
|
||||
errors={errorData}
|
||||
filter={(this.props.layer as any).filter}
|
||||
properties={this.props.vectorLayers[(this.props.layer as any)["source-layer"]]}
|
||||
onChange={f => this.changeProperty(null, "filter", f)}
|
||||
filter={(props.layer as any).filter}
|
||||
properties={props.vectorLayers[(props.layer as any)["source-layer"]]}
|
||||
onChange={f => changeProperty(null, "filter", f)}
|
||||
/>
|
||||
</div>
|
||||
</div>;
|
||||
case "properties":
|
||||
return <PropertyGroup
|
||||
errors={errorData}
|
||||
layer={this.props.layer}
|
||||
layer={props.layer}
|
||||
groupFields={fields!}
|
||||
spec={this.props.spec}
|
||||
onChange={this.changeProperty.bind(this)}
|
||||
spec={props.spec}
|
||||
onChange={changeProperty.bind(null)}
|
||||
/>;
|
||||
case "jsoneditor":
|
||||
return <FieldJson
|
||||
lintType="layer"
|
||||
value={this.props.layer}
|
||||
value={props.layer}
|
||||
onChange={(layer: LayerSpecification) => {
|
||||
this.props.onLayerChanged(
|
||||
this.props.layerIndex,
|
||||
props.onLayerChanged(
|
||||
props.layerIndex,
|
||||
layer
|
||||
);
|
||||
}}
|
||||
@@ -293,131 +285,128 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
|
||||
}
|
||||
}
|
||||
|
||||
moveLayer(offset: number) {
|
||||
this.props.onMoveLayer({
|
||||
oldIndex: this.props.layerIndex,
|
||||
newIndex: this.props.layerIndex + offset
|
||||
function moveLayer(offset: number) {
|
||||
props.onMoveLayer({
|
||||
oldIndex: props.layerIndex,
|
||||
newIndex: props.layerIndex + offset
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
const t = props.t;
|
||||
|
||||
const groupIds: string[] = [];
|
||||
const layerType = this.props.layer.type;
|
||||
const groups = layoutGroups(layerType, t).filter(group => {
|
||||
return !(layerType === "background" && group.type === "source");
|
||||
}).map(group => {
|
||||
const groupId = group.id;
|
||||
groupIds.push(groupId);
|
||||
return <LayerEditorGroup
|
||||
data-wd-key={group.title}
|
||||
id={groupId}
|
||||
key={groupId}
|
||||
title={group.title}
|
||||
isActive={this.state.editorGroups[group.title]}
|
||||
onActiveToggle={this.onGroupToggle.bind(this, group.title)}
|
||||
>
|
||||
{this.renderGroupType(group.type, group.fields)}
|
||||
</LayerEditorGroup>;
|
||||
});
|
||||
const groupIds: string[] = [];
|
||||
const layerType = props.layer.type;
|
||||
const groups = layoutGroups(layerType, t).filter(group => {
|
||||
return !(layerType === "background" && group.type === "source");
|
||||
}).map(group => {
|
||||
const groupId = group.id;
|
||||
groupIds.push(groupId);
|
||||
return <LayerEditorGroup
|
||||
data-wd-key={group.title}
|
||||
id={groupId}
|
||||
key={groupId}
|
||||
title={group.title}
|
||||
isActive={editorGroups[group.title]}
|
||||
onActiveToggle={onGroupToggle.bind(null, group.title)}
|
||||
>
|
||||
{renderGroupType(group.type, group.fields)}
|
||||
</LayerEditorGroup>;
|
||||
});
|
||||
|
||||
const layout = this.props.layer.layout || {};
|
||||
const layout = props.layer.layout || {};
|
||||
|
||||
const items: {
|
||||
[key: string]: {
|
||||
text: string,
|
||||
handler: () => void,
|
||||
disabled?: boolean,
|
||||
wdKey?: string
|
||||
}
|
||||
} = {
|
||||
delete: {
|
||||
text: t("Delete"),
|
||||
handler: () => this.props.onLayerDestroy(this.props.layerIndex),
|
||||
wdKey: "menu-delete-layer"
|
||||
},
|
||||
duplicate: {
|
||||
text: t("Duplicate"),
|
||||
handler: () => this.props.onLayerCopy(this.props.layerIndex),
|
||||
wdKey: "menu-duplicate-layer"
|
||||
},
|
||||
hide: {
|
||||
text: (layout.visibility === "none") ? t("Show") : t("Hide"),
|
||||
handler: () => this.props.onLayerVisibilityToggle(this.props.layerIndex),
|
||||
wdKey: "menu-hide-layer"
|
||||
},
|
||||
moveLayerUp: {
|
||||
text: t("Move layer up"),
|
||||
disabled: this.props.isFirstLayer,
|
||||
handler: () => this.moveLayer(-1),
|
||||
wdKey: "menu-move-layer-up"
|
||||
},
|
||||
moveLayerDown: {
|
||||
text: t("Move layer down"),
|
||||
disabled: this.props.isLastLayer,
|
||||
handler: () => this.moveLayer(+1),
|
||||
wdKey: "menu-move-layer-down"
|
||||
}
|
||||
};
|
||||
|
||||
function handleSelection(id: string, event: React.SyntheticEvent) {
|
||||
event.stopPropagation();
|
||||
items[id].handler();
|
||||
const items: {
|
||||
[key: string]: {
|
||||
text: string,
|
||||
handler: () => void,
|
||||
disabled?: boolean,
|
||||
wdKey?: string
|
||||
}
|
||||
} = {
|
||||
delete: {
|
||||
text: t("Delete"),
|
||||
handler: () => props.onLayerDestroy(props.layerIndex),
|
||||
wdKey: "menu-delete-layer"
|
||||
},
|
||||
duplicate: {
|
||||
text: t("Duplicate"),
|
||||
handler: () => props.onLayerCopy(props.layerIndex),
|
||||
wdKey: "menu-duplicate-layer"
|
||||
},
|
||||
hide: {
|
||||
text: (layout.visibility === "none") ? t("Show") : t("Hide"),
|
||||
handler: () => props.onLayerVisibilityToggle(props.layerIndex),
|
||||
wdKey: "menu-hide-layer"
|
||||
},
|
||||
moveLayerUp: {
|
||||
text: t("Move layer up"),
|
||||
disabled: props.isFirstLayer,
|
||||
handler: () => moveLayer(-1),
|
||||
wdKey: "menu-move-layer-up"
|
||||
},
|
||||
moveLayerDown: {
|
||||
text: t("Move layer down"),
|
||||
disabled: props.isLastLayer,
|
||||
handler: () => moveLayer(+1),
|
||||
wdKey: "menu-move-layer-down"
|
||||
}
|
||||
};
|
||||
|
||||
return <IconContext.Provider value={{ size: "14px", color: "#8e8e8e" }}>
|
||||
<section className="maputnik-layer-editor"
|
||||
role="main"
|
||||
aria-label={t("Layer editor")}
|
||||
data-wd-key="layer-editor"
|
||||
>
|
||||
<header data-wd-key="layer-editor.header">
|
||||
<div className="layer-header">
|
||||
<h2 className="layer-header__title">
|
||||
{t("Layer")}: {formatLayerId(this.props.layer.id)}
|
||||
</h2>
|
||||
<div className="layer-header__info">
|
||||
<Wrapper
|
||||
className='more-menu'
|
||||
onSelection={(id, event) => handleSelection(id as string, event)}
|
||||
closeOnSelection={false}
|
||||
>
|
||||
<Button
|
||||
id="skip-target-layer-editor"
|
||||
data-wd-key="skip-target-layer-editor"
|
||||
className='more-menu__button'
|
||||
title={"Layer options"}>
|
||||
<MdMoreVert className="more-menu__button__svg" />
|
||||
</Button>
|
||||
<Menu>
|
||||
<ul className="more-menu__menu">
|
||||
{Object.keys(items).map((id) => {
|
||||
const item = items[id];
|
||||
return <li key={id}>
|
||||
<MenuItem value={id} className='more-menu__menu__item' data-wd-key={item.wdKey}>
|
||||
{item.text}
|
||||
</MenuItem>
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
</Menu>
|
||||
</Wrapper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
<Accordion
|
||||
allowMultipleExpanded={true}
|
||||
allowZeroExpanded={true}
|
||||
preExpanded={groupIds}
|
||||
>
|
||||
{groups}
|
||||
</Accordion>
|
||||
</section>
|
||||
</IconContext.Provider>;
|
||||
function handleSelection(id: string, event: React.SyntheticEvent) {
|
||||
event.stopPropagation();
|
||||
items[id].handler();
|
||||
}
|
||||
}
|
||||
|
||||
const LayerEditor = withTranslation()(LayerEditorInternal);
|
||||
export default LayerEditor;
|
||||
return <IconContext.Provider value={{ size: "14px", color: "#8e8e8e" }}>
|
||||
<section className="maputnik-layer-editor"
|
||||
role="main"
|
||||
aria-label={t("Layer editor")}
|
||||
data-wd-key="layer-editor"
|
||||
>
|
||||
<header data-wd-key="layer-editor.header">
|
||||
<div className="layer-header">
|
||||
<h2 className="layer-header__title">
|
||||
{t("Layer")}: {formatLayerId(props.layer.id)}
|
||||
</h2>
|
||||
<div className="layer-header__info">
|
||||
<Wrapper
|
||||
className='more-menu'
|
||||
onSelection={(id, event) => handleSelection(id as string, event)}
|
||||
closeOnSelection={false}
|
||||
>
|
||||
<Button
|
||||
id="skip-target-layer-editor"
|
||||
data-wd-key="skip-target-layer-editor"
|
||||
className='more-menu__button'
|
||||
title={"Layer options"}>
|
||||
<MdMoreVert className="more-menu__button__svg" />
|
||||
</Button>
|
||||
<Menu>
|
||||
<ul className="more-menu__menu">
|
||||
{Object.keys(items).map((id) => {
|
||||
const item = items[id];
|
||||
return <li key={id}>
|
||||
<MenuItem value={id} className='more-menu__menu__item' data-wd-key={item.wdKey}>
|
||||
{item.text}
|
||||
</MenuItem>
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
</Menu>
|
||||
</Wrapper>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
<Accordion
|
||||
allowMultipleExpanded={true}
|
||||
allowZeroExpanded={true}
|
||||
preExpanded={groupIds}
|
||||
>
|
||||
{groups}
|
||||
</Accordion>
|
||||
</section>
|
||||
</IconContext.Provider>;
|
||||
};
|
||||
|
||||
export const LayerEditor = withTranslation()(LayerEditorInternal);
|
||||
|
||||
@@ -18,22 +18,20 @@ type LayerEditorGroupProps = {
|
||||
};
|
||||
|
||||
|
||||
export default class LayerEditorGroup extends React.Component<LayerEditorGroupProps> {
|
||||
render() {
|
||||
return <AccordionItem uuid={this.props.id}>
|
||||
<AccordionItemHeading className="maputnik-layer-editor-group"
|
||||
data-wd-key={"layer-editor-group:"+this.props["data-wd-key"]}
|
||||
onClick={_e => this.props.onActiveToggle(!this.props.isActive)}
|
||||
>
|
||||
<AccordionItemButton className="maputnik-layer-editor-group__button">
|
||||
<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>
|
||||
<MdArrowDropDown size={"2em"} className="maputnik-layer-editor-group__button__icon maputnik-layer-editor-group__button__icon--down"></MdArrowDropDown>
|
||||
</AccordionItemButton>
|
||||
</AccordionItemHeading>
|
||||
<AccordionItemPanel>
|
||||
{this.props.children}
|
||||
</AccordionItemPanel>
|
||||
</AccordionItem>;
|
||||
}
|
||||
}
|
||||
export const LayerEditorGroup: React.FC<LayerEditorGroupProps> = (props) => {
|
||||
return <AccordionItem uuid={props.id}>
|
||||
<AccordionItemHeading className="maputnik-layer-editor-group"
|
||||
data-wd-key={"layer-editor-group:"+props["data-wd-key"]}
|
||||
onClick={_e => props.onActiveToggle(!props.isActive)}
|
||||
>
|
||||
<AccordionItemButton className="maputnik-layer-editor-group__button">
|
||||
<span style={{flexGrow: 1, alignContent: "center"}}>{props.title}</span>
|
||||
<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>
|
||||
</AccordionItemButton>
|
||||
</AccordionItemHeading>
|
||||
<AccordionItemPanel>
|
||||
{props.children}
|
||||
</AccordionItemPanel>
|
||||
</AccordionItem>;
|
||||
};
|
||||
|
||||
+228
-241
@@ -1,4 +1,4 @@
|
||||
import React, {type JSX} from "react";
|
||||
import React, {type JSX, useEffect, useRef, useState} from "react";
|
||||
import classnames from "classnames";
|
||||
import lodash from "lodash";
|
||||
import {
|
||||
@@ -14,12 +14,12 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
|
||||
import LayerListGroup from "./LayerListGroup";
|
||||
import LayerListItem from "./LayerListItem";
|
||||
import ModalAdd from "./modals/ModalAdd";
|
||||
import { LayerListGroup } from "./LayerListGroup";
|
||||
import { LayerListItem } from "./LayerListItem";
|
||||
import { ModalAdd } from "./modals/ModalAdd";
|
||||
|
||||
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 { type WithTranslation, withTranslation } from "react-i18next";
|
||||
import { type MappedError, type OnMoveLayerCallback } from "../libs/definitions";
|
||||
@@ -37,62 +37,98 @@ type LayerListContainerProps = {
|
||||
};
|
||||
type LayerListContainerInternalProps = LayerListContainerProps & WithTranslation;
|
||||
|
||||
type LayerListContainerState = {
|
||||
collapsedGroups: {[ket: string]: boolean}
|
||||
areAllGroupsExpanded: boolean
|
||||
keys: {[key: string]: number}
|
||||
isOpen: {[key: string]: boolean}
|
||||
};
|
||||
const noopLayerSelect = () => {};
|
||||
|
||||
// Replaces the previous `shouldComponentUpdate`. Note the inversion: this
|
||||
// returns true when the props are EQUAL (i.e. no re-render is needed), whereas
|
||||
// `shouldComponentUpdate` returned true when a re-render WAS needed.
|
||||
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
|
||||
class LayerListContainerInternal extends React.Component<LayerListContainerInternalProps, LayerListContainerState> {
|
||||
static defaultProps = {
|
||||
onLayerSelect: () => {},
|
||||
};
|
||||
selectedItemRef: React.RefObject<any>;
|
||||
scrollContainerRef: React.RefObject<HTMLElement | null>;
|
||||
function LayerListContainerInternal({
|
||||
layers: propsLayers,
|
||||
selectedLayerIndex,
|
||||
onLayersChange,
|
||||
onLayerSelect = noopLayerSelect,
|
||||
onLayerDestroy,
|
||||
onLayerCopy,
|
||||
onLayerVisibilityToggle,
|
||||
sources,
|
||||
errors,
|
||||
t,
|
||||
}: LayerListContainerInternalProps) {
|
||||
const selectedItemRef = useRef<any>(null);
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
const hasMountedRef = useRef(false);
|
||||
|
||||
constructor(props: LayerListContainerInternalProps) {
|
||||
super(props);
|
||||
this.selectedItemRef = React.createRef();
|
||||
this.scrollContainerRef = React.createRef();
|
||||
this.state = {
|
||||
collapsedGroups: {},
|
||||
areAllGroupsExpanded: false,
|
||||
keys: {
|
||||
add: +generateUniqueId(),
|
||||
},
|
||||
isOpen: {
|
||||
add: false,
|
||||
}
|
||||
};
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<{[key: string]: boolean}>({});
|
||||
const [areAllGroupsExpanded, setAreAllGroupsExpanded] = useState(false);
|
||||
const [keys, setKeys] = useState<{[key: string]: number}>(() => ({
|
||||
add: +generateUniqueId(),
|
||||
}));
|
||||
const [isOpen, setIsOpen] = useState<{[key: string]: boolean}>({
|
||||
add: false,
|
||||
});
|
||||
|
||||
function toggleModal(modalName: string) {
|
||||
setKeys(prevKeys => ({
|
||||
...prevKeys,
|
||||
[modalName]: +generateUniqueId(),
|
||||
}));
|
||||
setIsOpen(prevIsOpen => ({
|
||||
...prevIsOpen,
|
||||
[modalName]: !prevIsOpen[modalName]
|
||||
}));
|
||||
}
|
||||
|
||||
toggleModal(modalName: string) {
|
||||
this.setState({
|
||||
keys: {
|
||||
...this.state.keys,
|
||||
[modalName]: +generateUniqueId(),
|
||||
},
|
||||
isOpen: {
|
||||
...this.state.isOpen,
|
||||
[modalName]: !this.state.isOpen[modalName]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleLayers = () => {
|
||||
const toggleLayers = () => {
|
||||
let idx = 0;
|
||||
|
||||
const newGroups: {[key:string]: boolean} = {};
|
||||
|
||||
this.groupedLayers().forEach(layers => {
|
||||
groupedLayers().forEach(layers => {
|
||||
const groupPrefix = layerPrefix(layers[0].id);
|
||||
const lookupKey = [groupPrefix, idx].join("-");
|
||||
|
||||
|
||||
if (layers.length > 1) {
|
||||
newGroups[lookupKey] = this.state.areAllGroupsExpanded;
|
||||
newGroups[lookupKey] = areAllGroupsExpanded;
|
||||
}
|
||||
|
||||
layers.forEach((_layer) => {
|
||||
@@ -100,19 +136,17 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
|
||||
});
|
||||
});
|
||||
|
||||
this.setState({
|
||||
collapsedGroups: newGroups,
|
||||
areAllGroupsExpanded: !this.state.areAllGroupsExpanded
|
||||
});
|
||||
setCollapsedGroups(newGroups);
|
||||
setAreAllGroupsExpanded(!areAllGroupsExpanded);
|
||||
};
|
||||
|
||||
groupedLayers(): (LayerSpecification & {key: string})[][] {
|
||||
function groupedLayers(): (LayerSpecification & {key: string})[][] {
|
||||
const groups = [];
|
||||
const layerIdCount = new Map();
|
||||
|
||||
for (let i = 0; i < this.props.layers.length; i++) {
|
||||
const origLayer = this.props.layers[i];
|
||||
const previousLayer = this.props.layers[i-1];
|
||||
for (let i = 0; i < propsLayers.length; i++) {
|
||||
const origLayer = propsLayers[i];
|
||||
const previousLayer = propsLayers[i-1];
|
||||
layerIdCount.set(origLayer.id,
|
||||
layerIdCount.has(origLayer.id) ? layerIdCount.get(origLayer.id) + 1 : 0
|
||||
);
|
||||
@@ -130,213 +164,168 @@ class LayerListContainerInternal extends React.Component<LayerListContainerInter
|
||||
return groups;
|
||||
}
|
||||
|
||||
toggleLayerGroup(groupPrefix: string, idx: number) {
|
||||
function toggleLayerGroup(groupPrefix: string, idx: number) {
|
||||
const lookupKey = [groupPrefix, idx].join("-");
|
||||
const newGroups = { ...this.state.collapsedGroups };
|
||||
if(lookupKey in this.state.collapsedGroups) {
|
||||
newGroups[lookupKey] = !this.state.collapsedGroups[lookupKey];
|
||||
} else {
|
||||
newGroups[lookupKey] = false;
|
||||
}
|
||||
this.setState({
|
||||
collapsedGroups: newGroups
|
||||
setCollapsedGroups(prevCollapsedGroups => {
|
||||
const newGroups = { ...prevCollapsedGroups };
|
||||
if(lookupKey in prevCollapsedGroups) {
|
||||
newGroups[lookupKey] = !prevCollapsedGroups[lookupKey];
|
||||
} else {
|
||||
newGroups[lookupKey] = false;
|
||||
}
|
||||
return newGroups;
|
||||
});
|
||||
}
|
||||
|
||||
isCollapsed(groupPrefix: string, idx: number) {
|
||||
const collapsed = this.state.collapsedGroups[[groupPrefix, idx].join("-")];
|
||||
function isCollapsed(groupPrefix: string, idx: number) {
|
||||
const collapsed = collapsedGroups[[groupPrefix, idx].join("-")];
|
||||
return collapsed === undefined ? true : collapsed;
|
||||
}
|
||||
|
||||
shouldComponentUpdate (nextProps: LayerListContainerProps, nextState: LayerListContainerState) {
|
||||
// Always update on state change
|
||||
if (this.state !== nextState) {
|
||||
return true;
|
||||
useEffect(() => {
|
||||
// `componentDidUpdate` did not run on mount, so skip the first run here too.
|
||||
if (!hasMountedRef.current) {
|
||||
hasMountedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 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,
|
||||
const selectedItemNode = selectedItemRef.current;
|
||||
if (selectedItemNode && selectedItemNode.node) {
|
||||
const target = selectedItemNode.node;
|
||||
const options = {
|
||||
root: scrollContainerRef.current,
|
||||
threshold: 1.0
|
||||
};
|
||||
|
||||
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) {
|
||||
const target = selectedItemNode.node;
|
||||
const options = {
|
||||
root: this.scrollContainerRef.current,
|
||||
threshold: 1.0
|
||||
};
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
observer.unobserve(target);
|
||||
if (entries.length > 0 && entries[0].intersectionRatio < 1) {
|
||||
target.scrollIntoView();
|
||||
}
|
||||
}, options);
|
||||
|
||||
observer.observe(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
const listItems: JSX.Element[] = [];
|
||||
let idx = 0;
|
||||
const layersByGroup = this.groupedLayers();
|
||||
layersByGroup.forEach(layers => {
|
||||
const groupPrefix = layerPrefix(layers[0].id);
|
||||
if(layers.length > 1) {
|
||||
const grp = <LayerListGroup
|
||||
data-wd-key={[groupPrefix, idx].join("-")}
|
||||
aria-controls={layers.map(l => l.key).join(" ")}
|
||||
key={`group-${groupPrefix}-${idx}`}
|
||||
title={groupPrefix}
|
||||
isActive={!this.isCollapsed(groupPrefix, idx) || idx === this.props.selectedLayerIndex}
|
||||
onActiveToggle={this.toggleLayerGroup.bind(this, groupPrefix, idx)}
|
||||
/>;
|
||||
listItems.push(grp);
|
||||
}
|
||||
|
||||
layers.forEach((layer, idxInGroup) => {
|
||||
const groupIdx = findClosestCommonPrefix(this.props.layers, idx);
|
||||
|
||||
const layerError = this.props.errors.find(error => {
|
||||
return (
|
||||
error.parsed &&
|
||||
error.parsed.type === "layer" &&
|
||||
error.parsed.data.index == idx
|
||||
);
|
||||
});
|
||||
|
||||
const additionalProps: {ref?: React.RefObject<any>} = {};
|
||||
if (idx === this.props.selectedLayerIndex) {
|
||||
additionalProps.ref = this.selectedItemRef;
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
observer.unobserve(target);
|
||||
if (entries.length > 0 && entries[0].intersectionRatio < 1) {
|
||||
target.scrollIntoView();
|
||||
}
|
||||
}, options);
|
||||
|
||||
const listItem = <LayerListItem
|
||||
className={classnames({
|
||||
"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--error": !!layerError
|
||||
})}
|
||||
key={layer.key}
|
||||
id={layer.key}
|
||||
layerId={layer.id}
|
||||
layerIndex={idx}
|
||||
layerType={layer.type}
|
||||
visibility={(layer.layout || {}).visibility}
|
||||
isSelected={idx === this.props.selectedLayerIndex}
|
||||
onLayerSelect={this.props.onLayerSelect}
|
||||
onLayerDestroy={this.props.onLayerDestroy?.bind(this)}
|
||||
onLayerCopy={this.props.onLayerCopy.bind(this)}
|
||||
onLayerVisibilityToggle={this.props.onLayerVisibilityToggle.bind(this)}
|
||||
{...additionalProps}
|
||||
/>;
|
||||
listItems.push(listItem);
|
||||
idx += 1;
|
||||
observer.observe(target);
|
||||
}
|
||||
}, [selectedLayerIndex]);
|
||||
|
||||
const listItems: JSX.Element[] = [];
|
||||
let idx = 0;
|
||||
const layersByGroup = groupedLayers();
|
||||
layersByGroup.forEach(layers => {
|
||||
const groupPrefix = layerPrefix(layers[0].id);
|
||||
if(layers.length > 1) {
|
||||
const currentIdx = idx;
|
||||
const grp = <LayerListGroup
|
||||
data-wd-key={[groupPrefix, idx].join("-")}
|
||||
aria-controls={layers.map(l => l.key).join(" ")}
|
||||
key={`group-${groupPrefix}-${idx}`}
|
||||
title={groupPrefix}
|
||||
isActive={!isCollapsed(groupPrefix, idx) || idx === selectedLayerIndex}
|
||||
onActiveToggle={() => toggleLayerGroup(groupPrefix, currentIdx)}
|
||||
/>;
|
||||
listItems.push(grp);
|
||||
}
|
||||
|
||||
layers.forEach((layer, idxInGroup) => {
|
||||
const groupIdx = findClosestCommonPrefix(propsLayers, idx);
|
||||
|
||||
const layerError = errors.find(error => {
|
||||
return (
|
||||
error.parsed &&
|
||||
error.parsed.type === "layer" &&
|
||||
error.parsed.data.index == idx
|
||||
);
|
||||
});
|
||||
|
||||
const additionalProps: {ref?: React.RefObject<any>} = {};
|
||||
if (idx === selectedLayerIndex) {
|
||||
additionalProps.ref = selectedItemRef;
|
||||
}
|
||||
|
||||
const listItem = <LayerListItem
|
||||
className={classnames({
|
||||
"maputnik-layer-list-item-collapsed": layers.length > 1 && isCollapsed(groupPrefix, groupIdx) && idx !== selectedLayerIndex,
|
||||
"maputnik-layer-list-item-group-last": idxInGroup == layers.length - 1 && layers.length > 1,
|
||||
"maputnik-layer-list-item--error": !!layerError
|
||||
})}
|
||||
key={layer.key}
|
||||
id={layer.key}
|
||||
layerId={layer.id}
|
||||
layerIndex={idx}
|
||||
layerType={layer.type}
|
||||
visibility={(layer.layout || {}).visibility}
|
||||
isSelected={idx === selectedLayerIndex}
|
||||
onLayerSelect={onLayerSelect}
|
||||
onLayerDestroy={onLayerDestroy}
|
||||
onLayerCopy={onLayerCopy}
|
||||
onLayerVisibilityToggle={onLayerVisibilityToggle}
|
||||
{...additionalProps}
|
||||
/>;
|
||||
listItems.push(listItem);
|
||||
idx += 1;
|
||||
});
|
||||
});
|
||||
|
||||
const t = this.props.t;
|
||||
|
||||
return <section
|
||||
className="maputnik-layer-list"
|
||||
data-wd-key="layer-list"
|
||||
role="complementary"
|
||||
aria-label={t("Layers list")}
|
||||
ref={this.scrollContainerRef}
|
||||
>
|
||||
<ModalAdd
|
||||
key={this.state.keys.add}
|
||||
layers={this.props.layers}
|
||||
sources={this.props.sources}
|
||||
isOpen={this.state.isOpen.add}
|
||||
onOpenToggle={this.toggleModal.bind(this, "add")}
|
||||
onLayersChange={this.props.onLayersChange}
|
||||
/>
|
||||
<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-space" />
|
||||
<div className="maputnik-default-property">
|
||||
<div className="maputnik-multibutton">
|
||||
<button
|
||||
id="skip-target-layer-list"
|
||||
data-wd-key="skip-target-layer-list"
|
||||
onClick={this.toggleLayers}
|
||||
className="maputnik-button">
|
||||
{this.state.areAllGroupsExpanded === true ?
|
||||
t("Collapse")
|
||||
:
|
||||
t("Expand")
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
return <section
|
||||
className="maputnik-layer-list"
|
||||
data-wd-key="layer-list"
|
||||
role="complementary"
|
||||
aria-label={t("Layers list")}
|
||||
ref={scrollContainerRef}
|
||||
>
|
||||
<ModalAdd
|
||||
key={keys.add}
|
||||
layers={propsLayers}
|
||||
sources={sources}
|
||||
isOpen={isOpen.add}
|
||||
onOpenToggle={() => toggleModal("add")}
|
||||
onLayersChange={onLayersChange}
|
||||
/>
|
||||
<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-space" />
|
||||
<div className="maputnik-default-property">
|
||||
<div className="maputnik-multibutton">
|
||||
<button
|
||||
id="skip-target-layer-list"
|
||||
data-wd-key="skip-target-layer-list"
|
||||
onClick={toggleLayers}
|
||||
className="maputnik-button">
|
||||
{areAllGroupsExpanded === true ?
|
||||
t("Collapse")
|
||||
:
|
||||
t("Expand")
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div className="maputnik-default-property">
|
||||
<div className="maputnik-multibutton">
|
||||
<button
|
||||
onClick={this.toggleModal.bind(this, "add")}
|
||||
data-wd-key="layer-list:add-layer"
|
||||
className="maputnik-button maputnik-button-selected">
|
||||
{t("Add Layer")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
role="navigation"
|
||||
aria-label={t("Layers list")}
|
||||
>
|
||||
<ul className="maputnik-layer-list-container">
|
||||
{listItems}
|
||||
</ul>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
<div className="maputnik-default-property">
|
||||
<div className="maputnik-multibutton">
|
||||
<button
|
||||
onClick={() => toggleModal("add")}
|
||||
data-wd-key="layer-list:add-layer"
|
||||
className="maputnik-button maputnik-button-selected">
|
||||
{t("Add Layer")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
role="navigation"
|
||||
aria-label={t("Layers list")}
|
||||
>
|
||||
<ul className="maputnik-layer-list-container">
|
||||
{listItems}
|
||||
</ul>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
const LayerListContainer = withTranslation()(LayerListContainerInternal);
|
||||
const LayerListContainer = withTranslation()(React.memo(LayerListContainerInternal, arePropsEqual));
|
||||
|
||||
type LayerListProps = LayerListContainerProps & {
|
||||
onMoveLayer: OnMoveLayerCallback
|
||||
};
|
||||
|
||||
const LayerList: React.FC<LayerListProps> = (props) => {
|
||||
export const LayerList: React.FC<LayerListProps> = (props) => {
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
@@ -361,5 +350,3 @@ const LayerList: React.FC<LayerListProps> = (props) => {
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayerList;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import Collapser from "./Collapser";
|
||||
import { Collapser } from "./Collapser";
|
||||
|
||||
type LayerListGroupProps = {
|
||||
title: string
|
||||
@@ -9,26 +9,24 @@ type LayerListGroupProps = {
|
||||
"aria-controls"?: string
|
||||
};
|
||||
|
||||
export default class LayerListGroup extends React.Component<LayerListGroupProps> {
|
||||
render() {
|
||||
return <li className="maputnik-layer-list-group">
|
||||
<div className="maputnik-layer-list-group-header"
|
||||
data-wd-key={"layer-list-group:"+this.props["data-wd-key"]}
|
||||
onClick={_e => this.props.onActiveToggle(!this.props.isActive)}
|
||||
export const LayerListGroup: React.FC<LayerListGroupProps> = (props) => {
|
||||
return <li className="maputnik-layer-list-group">
|
||||
<div className="maputnik-layer-list-group-header"
|
||||
data-wd-key={"layer-list-group:"+props["data-wd-key"]}
|
||||
onClick={_e => props.onActiveToggle(!props.isActive)}
|
||||
>
|
||||
<button
|
||||
className="maputnik-layer-list-group-title"
|
||||
aria-controls={props["aria-controls"]}
|
||||
aria-expanded={props.isActive}
|
||||
>
|
||||
<button
|
||||
className="maputnik-layer-list-group-title"
|
||||
aria-controls={this.props["aria-controls"]}
|
||||
aria-expanded={this.props.isActive}
|
||||
>
|
||||
{this.props.title}
|
||||
</button>
|
||||
<span className="maputnik-space" />
|
||||
<Collapser
|
||||
style={{ height: 14, width: 14 }}
|
||||
isCollapsed={this.props.isActive}
|
||||
/>
|
||||
</div>
|
||||
</li>;
|
||||
}
|
||||
}
|
||||
{props.title}
|
||||
</button>
|
||||
<span className="maputnik-space" />
|
||||
<Collapser
|
||||
style={{ height: 14, width: 14 }}
|
||||
isCollapsed={props.isActive}
|
||||
/>
|
||||
</div>
|
||||
</li>;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { IconContext } from "react-icons";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import IconLayer from "./IconLayer";
|
||||
import { IconLayer } from "./IconLayer";
|
||||
import type { VisibilitySpecification } from "maplibre-gl";
|
||||
|
||||
|
||||
@@ -38,9 +38,9 @@ type IconActionProps = {
|
||||
classBlockModifier?: string
|
||||
};
|
||||
|
||||
class IconAction extends React.Component<IconActionProps> {
|
||||
renderIcon() {
|
||||
switch (this.props.action) {
|
||||
const IconAction: React.FC<IconActionProps> = (props) => {
|
||||
function renderIcon() {
|
||||
switch (props.action) {
|
||||
case "duplicate": return <MdContentCopy />;
|
||||
case "show": return <MdVisibility />;
|
||||
case "hide": return <MdVisibilityOff />;
|
||||
@@ -48,30 +48,28 @@ class IconAction extends React.Component<IconActionProps> {
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { classBlockName, classBlockModifier } = this.props;
|
||||
const { classBlockName, classBlockModifier } = props;
|
||||
|
||||
let classAdditions = "";
|
||||
if (classBlockName) {
|
||||
classAdditions = `maputnik-layer-list-icon-action__${classBlockName}`;
|
||||
let classAdditions = "";
|
||||
if (classBlockName) {
|
||||
classAdditions = `maputnik-layer-list-icon-action__${classBlockName}`;
|
||||
|
||||
if (classBlockModifier) {
|
||||
classAdditions += ` maputnik-layer-list-icon-action__${classBlockName}--${classBlockModifier}`;
|
||||
}
|
||||
if (classBlockModifier) {
|
||||
classAdditions += ` maputnik-layer-list-icon-action__${classBlockName}--${classBlockModifier}`;
|
||||
}
|
||||
|
||||
return <button
|
||||
tabIndex={-1}
|
||||
title={this.props.action}
|
||||
className={`maputnik-layer-list-icon-action ${classAdditions}`}
|
||||
data-wd-key={this.props.wdKey}
|
||||
onClick={this.props.onClick}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{this.renderIcon()}
|
||||
</button>;
|
||||
}
|
||||
}
|
||||
|
||||
return <button
|
||||
tabIndex={-1}
|
||||
title={props.action}
|
||||
className={`maputnik-layer-list-icon-action ${classAdditions}`}
|
||||
data-wd-key={props.wdKey}
|
||||
onClick={props.onClick}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{renderIcon()}
|
||||
</button>;
|
||||
};
|
||||
|
||||
type LayerListItemProps = {
|
||||
id?: string
|
||||
@@ -87,7 +85,7 @@ type LayerListItemProps = {
|
||||
onLayerVisibilityToggle?(...args: unknown[]): unknown
|
||||
};
|
||||
|
||||
const LayerListItem = React.forwardRef<HTMLLIElement, LayerListItemProps>((props, ref) => {
|
||||
export const LayerListItem = React.forwardRef<HTMLLIElement, LayerListItemProps>((props, ref) => {
|
||||
const {
|
||||
isSelected = false,
|
||||
visibility = "visible",
|
||||
@@ -162,5 +160,3 @@ const LayerListItem = React.forwardRef<HTMLLIElement, LayerListItemProps>((props
|
||||
</li>
|
||||
</IconContext.Provider>;
|
||||
});
|
||||
|
||||
export default LayerListItem;
|
||||
|
||||
+238
-209
@@ -1,12 +1,12 @@
|
||||
import React from "react";
|
||||
import React, {useCallback, useEffect, useReducer, useRef} from "react";
|
||||
import {createRoot} from "react-dom/client";
|
||||
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 colors from "@maplibre/maplibre-gl-inspect/lib/colors";
|
||||
import MapMaplibreGlLayerPopup from "./MapMaplibreGlLayerPopup";
|
||||
import MapMaplibreGlFeaturePropertyPopup, { type InspectFeature } from "./MapMaplibreGlFeaturePropertyPopup";
|
||||
import { FeatureLayerPopup as MapMaplibreGlLayerPopup } from "./MapMaplibreGlLayerPopup";
|
||||
import { FeaturePropertyPopup as MapMaplibreGlFeaturePropertyPopup, type InspectFeature } from "./MapMaplibreGlFeaturePropertyPopup";
|
||||
import Color from "color";
|
||||
import ZoomControl from "../libs/zoomcontrol";
|
||||
import { ZoomControl } from "../libs/zoomcontrol";
|
||||
import { type HighlightedLayer, colorHighlightedLayer } from "../libs/highlight";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import "../maplibregl.css";
|
||||
@@ -71,199 +71,65 @@ type MapMaplibreGlInternalProps = {
|
||||
onChange(value: {center: LngLat, zoom: number, _from: "map" | "app"}): unknown
|
||||
} & WithTranslation;
|
||||
|
||||
type MapMaplibreGlState = {
|
||||
map: Map | null;
|
||||
inspect: MaplibreInspect | null;
|
||||
geocoder: MaplibreGeocoder | null;
|
||||
zoomControl: ZoomControl | null;
|
||||
zoom?: number;
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
/**
|
||||
* Replacement for the previous `shouldComponentUpdate`. `React.memo` expects the
|
||||
* inverse: `true` means "props are equal, skip the render", whereas
|
||||
* `shouldComponentUpdate` returned `true` when it *should* re-render.
|
||||
* As before, if the props cannot be serialized we treat them as equal and skip
|
||||
* the render ("no biggie, carry on").
|
||||
*/
|
||||
function arePropsEqual(prevProps: MapMaplibreGlInternalProps, nextProps: MapMaplibreGlInternalProps) {
|
||||
let should = false;
|
||||
try {
|
||||
should = JSON.stringify(prevProps) !== JSON.stringify(nextProps);
|
||||
} catch(_e) {
|
||||
// no biggie, carry on
|
||||
}
|
||||
return !should;
|
||||
}
|
||||
|
||||
const MapMaplibreGlInternal = ({
|
||||
onDataChange = () => {},
|
||||
onLayerSelect = () => {},
|
||||
onChange = () => {},
|
||||
options = {},
|
||||
mapStyle,
|
||||
mapView,
|
||||
inspectModeEnabled,
|
||||
highlightedLayer,
|
||||
replaceAccessTokens,
|
||||
t,
|
||||
}: MapMaplibreGlInternalProps) => {
|
||||
const container = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
shouldComponentUpdate(nextProps: MapMaplibreGlInternalProps, nextState: MapMaplibreGlState) {
|
||||
let should = false;
|
||||
try {
|
||||
should = JSON.stringify(this.props) !== JSON.stringify(nextProps) || JSON.stringify(this.state) !== JSON.stringify(nextState);
|
||||
} catch(_e) {
|
||||
// no biggie, carry on
|
||||
}
|
||||
return should;
|
||||
}
|
||||
// These used to live in `this.state`, but were never able to trigger a
|
||||
// re-render: `shouldComponentUpdate` stringified the state, which throws for
|
||||
// the (circular) maplibre `Map`, so the comparison was swallowed and the
|
||||
// component only ever re-rendered on prop changes. They are imperative
|
||||
// handles, so they are refs here and mutating them does not re-render.
|
||||
const map = useRef<Map | null>(null);
|
||||
const inspect = useRef<MaplibreInspect | null>(null);
|
||||
const geocoder = useRef<MaplibreGeocoder | null>(null);
|
||||
const zoomControl = useRef<ZoomControl | null>(null);
|
||||
const zoom = useRef<number | undefined>(undefined);
|
||||
|
||||
componentDidUpdate() {
|
||||
const map = this.state.map;
|
||||
// `componentDidUpdate` did not run on mount, so neither may the effect below.
|
||||
const hasMounted = useRef(false);
|
||||
|
||||
const styleWithTokens = this.props.replaceAccessTokens(this.props.mapStyle);
|
||||
if (map) {
|
||||
// Maplibre GL now does diffing natively so we don't need to calculate
|
||||
// the necessary operations ourselves!
|
||||
// We also need to update the style for inspect to work properly
|
||||
map.setStyle(styleWithTokens, {diff: true});
|
||||
map.showTileBoundaries = this.props.options?.showTileBoundaries!;
|
||||
map.showCollisionBoxes = this.props.options?.showCollisionBoxes!;
|
||||
map.showOverdrawInspector = this.props.options?.showOverdrawInspector!;
|
||||
const [, forceUpdate] = useReducer((tick: number) => tick + 1, 0);
|
||||
|
||||
// set the map view when the prop was updated from outside
|
||||
if (this.props.mapView._from === "app") {
|
||||
map.jumpTo(this.props.mapView);
|
||||
}
|
||||
}
|
||||
// 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};
|
||||
|
||||
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);
|
||||
}
|
||||
const onLayerSelectById = useCallback((id: string) => {
|
||||
const index = latestProps.current.mapStyle.layers.findIndex(layer => layer.id === id);
|
||||
latestProps.current.onLayerSelect(index);
|
||||
}, []);
|
||||
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const mapOpts = {
|
||||
...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 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 initGeocoder = useCallback((mapInstance: Map) => {
|
||||
const geocoderConfig = {
|
||||
forwardGeocode: async (config: MaplibreGeocoderApiConfig) => {
|
||||
const features = [];
|
||||
@@ -300,27 +166,190 @@ class MapMaplibreGlInternal extends React.Component<MapMaplibreGlInternalProps,
|
||||
};
|
||||
},
|
||||
} as unknown as MaplibreGeocoderApi;
|
||||
const geocoder = new MaplibreGeocoder(geocoderConfig, {
|
||||
placeholder: this.props.t("Search"),
|
||||
const geocoderInstance = new MaplibreGeocoder(geocoderConfig, {
|
||||
placeholder: latestProps.current.t("Search"),
|
||||
maplibregl: MapLibreGl,
|
||||
});
|
||||
map.addControl(geocoder, "top-left");
|
||||
return geocoder;
|
||||
}
|
||||
mapInstance.addControl(geocoderInstance, "top-left");
|
||||
return geocoderInstance;
|
||||
}, []);
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
this.state.geocoder?.setPlaceholder(t("Search"));
|
||||
this.state.zoomControl?.setLabel(t("Zoom:"));
|
||||
return <div
|
||||
className="maputnik-map__map"
|
||||
role="region"
|
||||
aria-label={t("Map view")}
|
||||
ref={x => {this.container = x;}}
|
||||
data-wd-key="maplibre:map"
|
||||
></div>;
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const MapMaplibreGl = withTranslation()(MapMaplibreGlInternal);
|
||||
export default MapMaplibreGl;
|
||||
// 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);
|
||||
if (map.current) {
|
||||
// Maplibre GL now does diffing natively so we don't need to calculate
|
||||
// the necessary operations ourselves!
|
||||
// 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
|
||||
className="maputnik-map__map"
|
||||
role="region"
|
||||
aria-label={t("Map view")}
|
||||
ref={container}
|
||||
data-wd-key="maplibre:map"
|
||||
></div>;
|
||||
};
|
||||
|
||||
export const MapMaplibreGl = withTranslation()(React.memo(MapMaplibreGlInternal, arePropsEqual));
|
||||
|
||||
@@ -63,18 +63,13 @@ type FeaturePropertyPopupProps = {
|
||||
features: InspectFeature[]
|
||||
};
|
||||
|
||||
class FeaturePropertyPopup extends React.Component<FeaturePropertyPopupProps> {
|
||||
render() {
|
||||
const features = removeDuplicatedFeatures(this.props.features);
|
||||
return <div className="maputnik-feature-property-popup" dir="ltr" data-wd-key="feature-property-popup">
|
||||
<table className="maputnik-popup-table">
|
||||
<tbody>
|
||||
{features.map(renderFeature)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default FeaturePropertyPopup;
|
||||
export const FeaturePropertyPopup: React.FC<FeaturePropertyPopupProps> = (props) => {
|
||||
const features = removeDuplicatedFeatures(props.features);
|
||||
return <div className="maputnik-feature-property-popup" dir="ltr" data-wd-key="feature-property-popup">
|
||||
<table className="maputnik-popup-table">
|
||||
<tbody>
|
||||
{features.map(renderFeature)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import IconLayer from "./IconLayer";
|
||||
import { IconLayer } from "./IconLayer";
|
||||
import type {InspectFeature} from "./MapMaplibreGlFeaturePropertyPopup";
|
||||
|
||||
function groupFeaturesBySourceLayer(features: InspectFeature[]) {
|
||||
@@ -32,8 +32,8 @@ type FeatureLayerPopupProps = {
|
||||
zoom?: number
|
||||
};
|
||||
|
||||
class FeatureLayerPopup extends React.Component<FeatureLayerPopupProps> {
|
||||
_getFeatureColor(feature: InspectFeature, _zoom?: number) {
|
||||
export const FeatureLayerPopup: React.FC<FeatureLayerPopupProps> = (props) => {
|
||||
function _getFeatureColor(feature: InspectFeature, _zoom?: number) {
|
||||
// Guard because openlayers won't have this
|
||||
if (!feature.layer.paint) {
|
||||
return;
|
||||
@@ -65,50 +65,45 @@ class FeatureLayerPopup extends React.Component<FeatureLayerPopupProps> {
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const sources = groupFeaturesBySourceLayer(this.props.features);
|
||||
const sources = groupFeaturesBySourceLayer(props.features);
|
||||
|
||||
const items = Object.keys(sources).map(vectorLayerId => {
|
||||
const layers = sources[vectorLayerId].map((feature: InspectFeature, idx: number) => {
|
||||
const featureColor = this._getFeatureColor(feature, this.props.zoom);
|
||||
const items = Object.keys(sources).map(vectorLayerId => {
|
||||
const layers = sources[vectorLayerId].map((feature: InspectFeature, idx: number) => {
|
||||
const featureColor = _getFeatureColor(feature, props.zoom);
|
||||
|
||||
return <div
|
||||
key={idx}
|
||||
className="maputnik-popup-layer"
|
||||
return <div
|
||||
key={idx}
|
||||
className="maputnik-popup-layer"
|
||||
>
|
||||
<div
|
||||
className="maputnik-popup-layer__swatch"
|
||||
style={{background: featureColor}}
|
||||
></div>
|
||||
<label
|
||||
className="maputnik-popup-layer__label"
|
||||
onClick={() => {
|
||||
props.onLayerSelect(feature.layer.id);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="maputnik-popup-layer__swatch"
|
||||
style={{background: featureColor}}
|
||||
></div>
|
||||
<label
|
||||
className="maputnik-popup-layer__label"
|
||||
onClick={() => {
|
||||
this.props.onLayerSelect(feature.layer.id);
|
||||
}}
|
||||
>
|
||||
{feature.layer.type &&
|
||||
<IconLayer type={feature.layer.type} style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
paddingRight: 3
|
||||
}}/>
|
||||
}
|
||||
{feature.layer.id}
|
||||
{feature.counter && <span> × {feature.counter}</span>}
|
||||
</label>
|
||||
</div>;
|
||||
});
|
||||
return <div key={vectorLayerId}>
|
||||
<div className="maputnik-popup-layer-id">{vectorLayerId}</div>
|
||||
{layers}
|
||||
{feature.layer.type &&
|
||||
<IconLayer type={feature.layer.type} style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
paddingRight: 3
|
||||
}}/>
|
||||
}
|
||||
{feature.layer.id}
|
||||
{feature.counter && <span> × {feature.counter}</span>}
|
||||
</label>
|
||||
</div>;
|
||||
});
|
||||
|
||||
return <div className="maputnik-feature-layer-popup" data-wd-key="feature-layer-popup" dir="ltr">
|
||||
{items}
|
||||
return <div key={vectorLayerId}>
|
||||
<div className="maputnik-popup-layer-id">{vectorLayerId}</div>
|
||||
{layers}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export default FeatureLayerPopup;
|
||||
return <div className="maputnik-feature-layer-popup" data-wd-key="feature-layer-popup" dir="ltr">
|
||||
{items}
|
||||
</div>;
|
||||
};
|
||||
|
||||
+140
-130
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from "react";
|
||||
import {throttle} from "lodash";
|
||||
import { type WithTranslation, withTranslation } from "react-i18next";
|
||||
|
||||
import MapMaplibreGlLayerPopup from "./MapMaplibreGlLayerPopup";
|
||||
import { FeatureLayerPopup as MapMaplibreGlLayerPopup } from "./MapMaplibreGlLayerPopup";
|
||||
|
||||
import "ol/ol.css";
|
||||
//@ts-ignore
|
||||
@@ -35,56 +35,52 @@ type MapOpenLayersInternalProps = {
|
||||
onChange(...args: unknown[]): unknown
|
||||
} & WithTranslation;
|
||||
|
||||
type MapOpenLayersState = {
|
||||
zoom: string
|
||||
rotation: string
|
||||
cursor: string[]
|
||||
center: string[]
|
||||
selectedFeatures?: any[]
|
||||
};
|
||||
const MapOpenLayersInternal = ({
|
||||
onLayerSelect = () => {},
|
||||
mapStyle,
|
||||
style,
|
||||
debugToolbox,
|
||||
replaceAccessTokens,
|
||||
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);
|
||||
|
||||
class MapOpenLayersInternal extends React.Component<MapOpenLayersInternalProps, MapOpenLayersState> {
|
||||
static defaultProps = {
|
||||
onMapLoaded: () => {},
|
||||
onDataChange: () => {},
|
||||
onLayerSelect: () => {},
|
||||
};
|
||||
updateStyle: any;
|
||||
map: any;
|
||||
container: HTMLDivElement | null = null;
|
||||
overlay: Overlay | undefined;
|
||||
popupContainer: HTMLElement | null = null;
|
||||
// Imperative handles: they were plain instance fields, so they are refs and
|
||||
// mutating them must not re-render.
|
||||
const map = useRef<Map | null>(null);
|
||||
const container = useRef<HTMLDivElement | null>(null);
|
||||
const overlay = useRef<Overlay | undefined>(undefined);
|
||||
const popupContainer = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
constructor(props: MapOpenLayersInternalProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
zoom: "0",
|
||||
rotation: "0",
|
||||
cursor: [] as string[],
|
||||
center: [],
|
||||
};
|
||||
this.updateStyle = throttle(this._updateStyle.bind(this), 200);
|
||||
}
|
||||
// componentDidUpdate did not run on mount, and componentDidMount already
|
||||
// applies the initial style, so the style effect below must skip the mount.
|
||||
const hasMounted = useRef(false);
|
||||
|
||||
_updateStyle(newMapStyle: StyleSpecification) {
|
||||
if(!this.map) return;
|
||||
// The openlayers event handlers are registered on mount but read `this.props`
|
||||
// at call time in the class version.
|
||||
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>
|
||||
this.map.getLayers().clear();
|
||||
apply(this.map, newMapStyle);
|
||||
}
|
||||
map.current.getLayers().clear();
|
||||
apply(map.current, newMapStyle);
|
||||
}, 200), []);
|
||||
|
||||
componentDidUpdate(prevProps: MapOpenLayersInternalProps) {
|
||||
if (this.props.mapStyle !== prevProps.mapStyle) {
|
||||
this.updateStyle(
|
||||
this.props.replaceAccessTokens(this.props.mapStyle)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.overlay = new Overlay({
|
||||
element: this.popupContainer!,
|
||||
// componentDidMount
|
||||
useEffect(() => {
|
||||
overlay.current = new Overlay({
|
||||
element: popupContainer.current!,
|
||||
autoPan: {
|
||||
animation: {
|
||||
duration: 250
|
||||
@@ -92,117 +88,131 @@ class MapOpenLayersInternal extends React.Component<MapOpenLayersInternalProps,
|
||||
},
|
||||
});
|
||||
|
||||
const map = new Map({
|
||||
target: this.container!,
|
||||
overlays: [this.overlay],
|
||||
const mapInstance = new Map({
|
||||
target: container.current!,
|
||||
overlays: [overlay.current],
|
||||
view: new View({
|
||||
zoom: 1,
|
||||
center: [180, -90],
|
||||
})
|
||||
});
|
||||
|
||||
map.on("pointermove", (evt) => {
|
||||
mapInstance.on("pointermove", (evt) => {
|
||||
const coords = toLonLat(evt.coordinate);
|
||||
this.setState({
|
||||
cursor: [
|
||||
coords[0].toFixed(2),
|
||||
coords[1].toFixed(2)
|
||||
]
|
||||
});
|
||||
setCursor([
|
||||
coords[0].toFixed(2),
|
||||
coords[1].toFixed(2)
|
||||
]);
|
||||
});
|
||||
|
||||
const onMoveEnd = () => {
|
||||
const zoom = map.getView().getZoom();
|
||||
const center = toLonLat(map.getView().getCenter()!);
|
||||
const currentZoom = mapInstance.getView().getZoom();
|
||||
const currentCenter = toLonLat(mapInstance.getView().getCenter()!);
|
||||
|
||||
this.props.onChange({
|
||||
zoom,
|
||||
latestProps.current.onChange({
|
||||
zoom: currentZoom,
|
||||
center: {
|
||||
lng: center[0],
|
||||
lat: center[1],
|
||||
lng: currentCenter[0],
|
||||
lat: currentCenter[1],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onMoveEnd();
|
||||
map.on("moveend", onMoveEnd);
|
||||
mapInstance.on("moveend", onMoveEnd);
|
||||
|
||||
map.on("postrender", (_e) => {
|
||||
const center = toLonLat(map.getView().getCenter()!);
|
||||
this.setState({
|
||||
center: [
|
||||
center[0].toFixed(2),
|
||||
center[1].toFixed(2),
|
||||
],
|
||||
rotation: map.getView().getRotation().toFixed(2),
|
||||
zoom: map.getView().getZoom()!.toFixed(2)
|
||||
});
|
||||
mapInstance.on("postrender", (_e) => {
|
||||
const currentCenter = toLonLat(mapInstance.getView().getCenter()!);
|
||||
setCenter([
|
||||
currentCenter[0].toFixed(2),
|
||||
currentCenter[1].toFixed(2),
|
||||
]);
|
||||
setRotation(mapInstance.getView().getRotation().toFixed(2));
|
||||
setZoom(mapInstance.getView().getZoom()!.toFixed(2));
|
||||
});
|
||||
|
||||
|
||||
|
||||
this.map = map;
|
||||
this.updateStyle(
|
||||
this.props.replaceAccessTokens(this.props.mapStyle)
|
||||
map.current = mapInstance;
|
||||
updateStyle(
|
||||
latestProps.current.replaceAccessTokens(latestProps.current.mapStyle) as StyleSpecification
|
||||
);
|
||||
}
|
||||
// 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]);
|
||||
|
||||
closeOverlay = (e: any) => {
|
||||
// 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) => {
|
||||
e.target.blur();
|
||||
this.overlay!.setPosition(undefined);
|
||||
};
|
||||
overlay.current!.setPosition(undefined);
|
||||
}, []);
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
return <div className="maputnik-ol-container">
|
||||
<div
|
||||
ref={x => {this.popupContainer = x;}}
|
||||
style={{background: "black"}}
|
||||
className="maputnik-popup"
|
||||
return <div className="maputnik-ol-container">
|
||||
<div
|
||||
ref={popupContainer}
|
||||
style={{background: "black"}}
|
||||
className="maputnik-popup"
|
||||
>
|
||||
<button
|
||||
className="maplibregl-popup-close-button"
|
||||
onClick={closeOverlay}
|
||||
aria-label={t("Close popup")}
|
||||
>
|
||||
<button
|
||||
className="maplibregl-popup-close-button"
|
||||
onClick={this.closeOverlay}
|
||||
aria-label={t("Close popup")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<MapMaplibreGlLayerPopup
|
||||
features={this.state.selectedFeatures || []}
|
||||
onLayerSelect={this.props.onLayerSelect}
|
||||
/>
|
||||
</div>
|
||||
<div className="maputnik-ol-zoom">
|
||||
{t("Zoom:")} {this.state.zoom}
|
||||
</div>
|
||||
{this.props.debugToolbox &&
|
||||
<div className="maputnik-ol-debug">
|
||||
<div>
|
||||
<label>{t("cursor:")} </label>
|
||||
<span>{renderCoords(this.state.cursor)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>{t("center:")} </label>
|
||||
<span>{renderCoords(this.state.center)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>{t("rotation:")} </label>
|
||||
<span>{this.state.rotation}</span>
|
||||
</div>
|
||||
×
|
||||
</button>
|
||||
<MapMaplibreGlLayerPopup
|
||||
features={selectedFeatures || []}
|
||||
onLayerSelect={onLayerSelect}
|
||||
/>
|
||||
</div>
|
||||
<div className="maputnik-ol-zoom">
|
||||
{t("Zoom:")} {zoom}
|
||||
</div>
|
||||
{debugToolbox &&
|
||||
<div className="maputnik-ol-debug">
|
||||
<div>
|
||||
<label>{t("cursor:")} </label>
|
||||
<span>{renderCoords(cursor)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>{t("center:")} </label>
|
||||
<span>{renderCoords(center)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<label>{t("rotation:")} </label>
|
||||
<span>{rotation}</span>
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
className="maputnik-ol"
|
||||
ref={x => {this.container = x;}}
|
||||
role="region"
|
||||
aria-label={t("Map view")}
|
||||
style={{
|
||||
...this.props.style,
|
||||
}}>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
}
|
||||
<div
|
||||
className="maputnik-ol"
|
||||
ref={container}
|
||||
role="region"
|
||||
aria-label={t("Map view")}
|
||||
style={{
|
||||
...style,
|
||||
}}>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const MapOpenLayers = withTranslation()(MapOpenLayersInternal);
|
||||
export default MapOpenLayers;
|
||||
export const MapOpenLayers = withTranslation()(MapOpenLayersInternal);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
import FieldFunction from "./FieldFunction";
|
||||
import { FieldFunction } from "./FieldFunction";
|
||||
import type {LayerSpecification} from "maplibre-gl";
|
||||
import { type MappedLayerErrors } from "../libs/definitions";
|
||||
|
||||
@@ -40,37 +40,35 @@ type PropertyGroupProps = {
|
||||
errors?: MappedLayerErrors
|
||||
};
|
||||
|
||||
export default class PropertyGroup extends React.Component<PropertyGroupProps> {
|
||||
onPropertyChange = (property: string, newValue: any) => {
|
||||
const group = getGroupName(this.props.spec, this.props.layer.type, property);
|
||||
this.props.onChange(group ,property, newValue);
|
||||
export const PropertyGroup: React.FC<PropertyGroupProps> = (props) => {
|
||||
const onPropertyChange = (property: string, newValue: any) => {
|
||||
const group = getGroupName(props.spec, props.layer.type, property);
|
||||
props.onChange(group ,property, newValue);
|
||||
};
|
||||
|
||||
render() {
|
||||
const {errors} = this.props;
|
||||
const fields = this.props.groupFields.map(fieldName => {
|
||||
const fieldSpec = getFieldSpec(this.props.spec, this.props.layer.type, fieldName);
|
||||
const {errors} = props;
|
||||
const fields = props.groupFields.map(fieldName => {
|
||||
const fieldSpec = getFieldSpec(props.spec, props.layer.type, fieldName);
|
||||
|
||||
const paint = this.props.layer.paint || {};
|
||||
const layout = this.props.layer.layout || {};
|
||||
const fieldValue = fieldName in paint
|
||||
? paint[fieldName as keyof typeof paint]
|
||||
: layout[fieldName as keyof typeof layout];
|
||||
const fieldType = fieldName in paint ? "paint" : "layout";
|
||||
const paint = props.layer.paint || {};
|
||||
const layout = props.layer.layout || {};
|
||||
const fieldValue = fieldName in paint
|
||||
? paint[fieldName as keyof typeof paint]
|
||||
: layout[fieldName as keyof typeof layout];
|
||||
const fieldType = fieldName in paint ? "paint" : "layout";
|
||||
|
||||
return <FieldFunction
|
||||
errors={errors}
|
||||
onChange={this.onPropertyChange}
|
||||
key={fieldName}
|
||||
fieldName={fieldName}
|
||||
value={fieldValue}
|
||||
fieldType={fieldType}
|
||||
fieldSpec={fieldSpec}
|
||||
/>;
|
||||
});
|
||||
return <FieldFunction
|
||||
errors={errors}
|
||||
onChange={onPropertyChange}
|
||||
key={fieldName}
|
||||
fieldName={fieldName}
|
||||
value={fieldValue}
|
||||
fieldType={fieldType}
|
||||
fieldSpec={fieldSpec}
|
||||
/>;
|
||||
});
|
||||
|
||||
return <div className="maputnik-property-group">
|
||||
{fields}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
return <div className="maputnik-property-group">
|
||||
{fields}
|
||||
</div>;
|
||||
};
|
||||
|
||||
@@ -4,10 +4,8 @@ type ScrollContainerProps = {
|
||||
children?: React.ReactNode
|
||||
};
|
||||
|
||||
export default class ScrollContainer extends React.Component<ScrollContainerProps> {
|
||||
render() {
|
||||
return <div className="maputnik-scroll-container">
|
||||
{this.props.children}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
export const ScrollContainer: React.FC<ScrollContainerProps> = (props) => {
|
||||
return <div className="maputnik-scroll-container">
|
||||
{props.children}
|
||||
</div>;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
import {otherFilterOps} from "../libs/filterops";
|
||||
import InputString from "./InputString";
|
||||
import InputAutocomplete from "./InputAutocomplete";
|
||||
import InputSelect from "./InputSelect";
|
||||
import { InputString } from "./InputString";
|
||||
import { InputAutocomplete } from "./InputAutocomplete";
|
||||
import { InputSelect } from "./InputSelect";
|
||||
|
||||
function tryParseInt(v: string | number) {
|
||||
if (v === "") return v;
|
||||
@@ -40,53 +40,50 @@ type SingleFilterEditorProps = {
|
||||
properties?: {[key: string]: string}
|
||||
};
|
||||
|
||||
export default class SingleFilterEditor extends React.Component<SingleFilterEditorProps> {
|
||||
static defaultProps = {
|
||||
properties: {},
|
||||
};
|
||||
|
||||
onFilterPartChanged(filterOp: string, propertyName: string, filterArgs: string[]) {
|
||||
export const SingleFilterEditor: React.FC<SingleFilterEditorProps> = ({
|
||||
properties = {},
|
||||
...props
|
||||
}) => {
|
||||
function onFilterPartChanged(filterOp: string, propertyName: string, filterArgs: string[]) {
|
||||
let newFilter = [filterOp, propertyName, ...filterArgs.map(parseFilter)];
|
||||
if(filterOp === "has" || filterOp === "!has") {
|
||||
newFilter = [filterOp, propertyName];
|
||||
} else if(filterArgs.length === 0) {
|
||||
newFilter = [filterOp, propertyName, ""];
|
||||
}
|
||||
this.props.onChange(newFilter);
|
||||
props.onChange(newFilter);
|
||||
}
|
||||
|
||||
render() {
|
||||
const f = this.props.filter;
|
||||
const filterOp = f[0];
|
||||
const propertyName = f[1];
|
||||
const filterArgs = f.slice(2);
|
||||
const f = props.filter;
|
||||
const filterOp = f[0];
|
||||
const propertyName = f[1];
|
||||
const filterArgs = f.slice(2);
|
||||
|
||||
return <div className="maputnik-filter-editor-single">
|
||||
<div className="maputnik-filter-editor-property">
|
||||
<InputAutocomplete
|
||||
aria-label="key"
|
||||
value={propertyName}
|
||||
options={Object.keys(this.props.properties!).map(propName => [propName, propName])}
|
||||
onChange={(newPropertyName: string) => this.onFilterPartChanged(filterOp, newPropertyName, filterArgs)}
|
||||
/>
|
||||
</div>
|
||||
<div className="maputnik-filter-editor-operator">
|
||||
<InputSelect
|
||||
aria-label="function"
|
||||
value={filterOp}
|
||||
onChange={(newFilterOp: string) => this.onFilterPartChanged(newFilterOp, propertyName, filterArgs)}
|
||||
options={otherFilterOps}
|
||||
/>
|
||||
</div>
|
||||
{filterArgs.length > 0 &&
|
||||
<div className="maputnik-filter-editor-args">
|
||||
<InputString
|
||||
aria-label="value"
|
||||
value={filterArgs.join(",")}
|
||||
onChange={(v: string) => this.onFilterPartChanged(filterOp, propertyName, v.split(","))}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
return <div className="maputnik-filter-editor-single">
|
||||
<div className="maputnik-filter-editor-property">
|
||||
<InputAutocomplete
|
||||
aria-label="key"
|
||||
value={propertyName}
|
||||
options={Object.keys(properties).map(propName => [propName, propName])}
|
||||
onChange={(newPropertyName: string) => onFilterPartChanged(filterOp, newPropertyName, filterArgs)}
|
||||
/>
|
||||
</div>
|
||||
<div className="maputnik-filter-editor-operator">
|
||||
<InputSelect
|
||||
aria-label="function"
|
||||
value={filterOp}
|
||||
onChange={(newFilterOp: string) => onFilterPartChanged(newFilterOp, propertyName, filterArgs)}
|
||||
options={otherFilterOps}
|
||||
/>
|
||||
</div>
|
||||
{filterArgs.length > 0 &&
|
||||
<div className="maputnik-filter-editor-args">
|
||||
<InputString
|
||||
aria-label="value"
|
||||
value={filterArgs.join(",")}
|
||||
onChange={(v: string) => onFilterPartChanged(filterOp, propertyName, v.split(","))}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>;
|
||||
};
|
||||
|
||||
@@ -7,16 +7,13 @@ type SmallErrorInternalProps = {
|
||||
children?: React.ReactNode
|
||||
} & WithTranslation;
|
||||
|
||||
class SmallErrorInternal extends React.Component<SmallErrorInternalProps> {
|
||||
render () {
|
||||
const t = this.props.t;
|
||||
return (
|
||||
<div className="SmallError">
|
||||
{t("Error:")} {this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
const SmallErrorInternal: React.FC<SmallErrorInternalProps> = (props) => {
|
||||
const t = props.t;
|
||||
return (
|
||||
<div className="SmallError">
|
||||
{t("Error:")} {props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SmallError = withTranslation()(SmallErrorInternal);
|
||||
export default SmallError;
|
||||
export const SmallError = withTranslation()(SmallErrorInternal);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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}
|
||||
/>;
|
||||
};
|
||||
@@ -0,0 +1,246 @@
|
||||
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);
|
||||
@@ -1,384 +0,0 @@
|
||||
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;
|
||||
@@ -1,27 +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;
|
||||
|
||||
|
||||
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;
|
||||
@@ -1,94 +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;
|
||||
|
||||
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;
|
||||
@@ -1,71 +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;
|
||||
|
||||
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;
|
||||
@@ -1,48 +0,0 @@
|
||||
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}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
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;
|
||||
@@ -14,58 +14,51 @@ type ModalInternalProps = PropsWithChildren & {
|
||||
} & WithTranslation;
|
||||
|
||||
|
||||
class ModalInternal extends React.Component<ModalInternalProps> {
|
||||
static defaultProps = {
|
||||
underlayClickExits: true
|
||||
};
|
||||
|
||||
const ModalInternal: React.FC<ModalInternalProps> = ({underlayClickExits = true, ...props}) => {
|
||||
// See <https://github.com/maplibre/maputnik/issues/416>
|
||||
onClose = () => {
|
||||
const onClose = () => {
|
||||
if (document.activeElement) {
|
||||
(document.activeElement as HTMLElement).blur();
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.props.onOpenToggle();
|
||||
props.onOpenToggle();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
if(this.props.isOpen) {
|
||||
return <AriaModal
|
||||
titleText={this.props.title}
|
||||
underlayClickExits={this.props.underlayClickExits}
|
||||
data-wd-key={this.props["data-wd-key"]}
|
||||
verticallyCenter={true}
|
||||
onExit={this.onClose}
|
||||
dialogClass='maputnik-modal-container'
|
||||
const t = props.t;
|
||||
if(props.isOpen) {
|
||||
return <AriaModal
|
||||
titleText={props.title}
|
||||
underlayClickExits={underlayClickExits}
|
||||
data-wd-key={props["data-wd-key"]}
|
||||
verticallyCenter={true}
|
||||
onExit={onClose}
|
||||
dialogClass='maputnik-modal-container'
|
||||
>
|
||||
<div className={classnames("maputnik-modal", props.className)}
|
||||
data-wd-key={props["data-wd-key"]}
|
||||
>
|
||||
<div className={classnames("maputnik-modal", this.props.className)}
|
||||
data-wd-key={this.props["data-wd-key"]}
|
||||
>
|
||||
<header className="maputnik-modal-header">
|
||||
<h1 className="maputnik-modal-header-title">{this.props.title}</h1>
|
||||
<span className="maputnik-space"></span>
|
||||
<button className="maputnik-modal-header-toggle"
|
||||
title={t("Close modal")}
|
||||
onClick={this.onClose}
|
||||
data-wd-key={this.props["data-wd-key"]+".close-modal"}
|
||||
>
|
||||
<MdClose />
|
||||
</button>
|
||||
</header>
|
||||
<div className="maputnik-modal-scroller">
|
||||
<div className="maputnik-modal-content">{this.props.children}</div>
|
||||
</div>
|
||||
<header className="maputnik-modal-header">
|
||||
<h1 className="maputnik-modal-header-title">{props.title}</h1>
|
||||
<span className="maputnik-space"></span>
|
||||
<button className="maputnik-modal-header-toggle"
|
||||
title={t("Close modal")}
|
||||
onClick={onClose}
|
||||
data-wd-key={props["data-wd-key"]+".close-modal"}
|
||||
>
|
||||
<MdClose />
|
||||
</button>
|
||||
</header>
|
||||
<div className="maputnik-modal-scroller">
|
||||
<div className="maputnik-modal-content">{props.children}</div>
|
||||
</div>
|
||||
</AriaModal>;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
</div>
|
||||
</AriaModal>;
|
||||
}
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const Modal = withTranslation()(ModalInternal);
|
||||
export default Modal;
|
||||
export const Modal = withTranslation()(ModalInternal);
|
||||
|
||||
+148
-152
@@ -1,13 +1,13 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { type WithTranslation, withTranslation } from "react-i18next";
|
||||
import type {LayerSpecification, SourceSpecification} from "maplibre-gl";
|
||||
|
||||
import InputButton from "../InputButton";
|
||||
import Modal from "./Modal";
|
||||
import FieldType from "../FieldType";
|
||||
import FieldId from "../FieldId";
|
||||
import FieldSource from "../FieldSource";
|
||||
import FieldSourceLayer from "../FieldSourceLayer";
|
||||
import { InputButton } from "../InputButton";
|
||||
import { Modal } from "./Modal";
|
||||
import { FieldType } from "../FieldType";
|
||||
import { FieldId } from "../FieldId";
|
||||
import { FieldSource } from "../FieldSource";
|
||||
import { FieldSourceLayer } from "../FieldSourceLayer";
|
||||
import { NON_SOURCE_LAYERS } from "../../libs/non-source-layers";
|
||||
|
||||
type ModalAddInternalProps = {
|
||||
@@ -27,170 +27,166 @@ type ModalAddState = {
|
||||
error?: string | null
|
||||
};
|
||||
|
||||
class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddState> {
|
||||
addLayer = () => {
|
||||
if (this.props.layers.some(l => l.id === this.state.id)) {
|
||||
this.setState({ error: this.props.t("Layer ID already exists") });
|
||||
type ModalAddSources = ModalAddInternalProps["sources"];
|
||||
|
||||
function getLayersForSource(allSources: ModalAddSources, source: string) {
|
||||
const sourceObj = allSources[source] || {};
|
||||
return sourceObj.layers || [];
|
||||
}
|
||||
|
||||
function getSources(allSources: ModalAddSources, type: LayerSpecification["type"]) {
|
||||
|
||||
switch(type) {
|
||||
case "background":
|
||||
return [];
|
||||
case "hillshade":
|
||||
case "color-relief":
|
||||
return Object.entries(allSources).filter(([_, v]) => v.type === "raster-dem").map(([k, _]) => k);
|
||||
case "raster":
|
||||
return Object.entries(allSources).filter(([_, v]) => v.type === "raster").map(([k, _]) => k);
|
||||
case "heatmap":
|
||||
case "circle":
|
||||
case "fill":
|
||||
case "fill-extrusion":
|
||||
case "line":
|
||||
case "symbol":
|
||||
return Object.entries(allSources).filter(([_, v]) => v.type === "vector" || v.type === "geojson").map(([k, _]) => k);
|
||||
default:
|
||||
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`.
|
||||
const prevType = useRef<LayerSpecification["type"]>(type);
|
||||
|
||||
useEffect(() => {
|
||||
// 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 = this.props.layers.slice(0);
|
||||
const changedLayers = props.layers.slice(0);
|
||||
const layer: ModalAddState = {
|
||||
id: this.state.id,
|
||||
type: this.state.type,
|
||||
id: id,
|
||||
type: 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"];
|
||||
if(type !== "background") {
|
||||
layer.source = source;
|
||||
if(!NON_SOURCE_LAYERS.includes(type) && sourceLayer) {
|
||||
layer["source-layer"] = sourceLayer;
|
||||
}
|
||||
}
|
||||
|
||||
changedLayers.push(layer as LayerSpecification);
|
||||
this.setState({ error: null }, () => {
|
||||
this.props.onLayersChange(changedLayers);
|
||||
this.props.onOpenToggle();
|
||||
});
|
||||
setError(null);
|
||||
props.onLayersChange(changedLayers);
|
||||
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;
|
||||
const t = props.t;
|
||||
const sources = getSources(props.sources, type);
|
||||
const layers = getLayersForSource(props.sources, source!);
|
||||
let errorElement;
|
||||
if (error) {
|
||||
errorElement = (
|
||||
<div className="maputnik-modal-error">
|
||||
{error}
|
||||
<a
|
||||
href="#"
|
||||
onClick={() => setError(null)}
|
||||
className="maputnik-modal-error-close"
|
||||
>
|
||||
×
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 || [];
|
||||
}
|
||||
|
||||
getSources(type: LayerSpecification["type"]) {
|
||||
|
||||
switch(type) {
|
||||
case "background":
|
||||
return [];
|
||||
case "hillshade":
|
||||
case "color-relief":
|
||||
return Object.entries(this.props.sources).filter(([_, v]) => v.type === "raster-dem").map(([k, _]) => k);
|
||||
case "raster":
|
||||
return Object.entries(this.props.sources).filter(([_, v]) => v.type === "raster").map(([k, _]) => k);
|
||||
case "heatmap":
|
||||
case "circle":
|
||||
case "fill":
|
||||
case "fill-extrusion":
|
||||
case "line":
|
||||
case "symbol":
|
||||
return Object.entries(this.props.sources).filter(([_, v]) => v.type === "vector" || v.type === "geojson").map(([k, _]) => k);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
const sources = this.getSources(this.state.type);
|
||||
const layers = this.getLayersForSource(this.state.source!);
|
||||
let errorElement;
|
||||
if (this.state.error) {
|
||||
errorElement = (
|
||||
<div className="maputnik-modal-error">
|
||||
{this.state.error}
|
||||
<a
|
||||
href="#"
|
||||
onClick={() => this.setState({ error: null })}
|
||||
className="maputnik-modal-error-close"
|
||||
>
|
||||
×
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Modal
|
||||
isOpen={this.props.isOpen}
|
||||
onOpenToggle={this.props.onOpenToggle}
|
||||
title={t("Add Layer")}
|
||||
data-wd-key="modal:add-layer"
|
||||
className="maputnik-add-modal"
|
||||
>
|
||||
{errorElement}
|
||||
<div className="maputnik-add-layer">
|
||||
<FieldId
|
||||
value={this.state.id}
|
||||
wdKey="add-layer.layer-id"
|
||||
onChange={(v: string) => {
|
||||
this.setState({ id: v, error: null });
|
||||
}}
|
||||
/>
|
||||
<FieldType
|
||||
value={this.state.type}
|
||||
wdKey="add-layer.layer-type"
|
||||
onChange={(v: LayerSpecification["type"]) => this.setState({ type: v })}
|
||||
/>
|
||||
{this.state.type !== "background" &&
|
||||
return <Modal
|
||||
isOpen={props.isOpen}
|
||||
onOpenToggle={props.onOpenToggle}
|
||||
title={t("Add Layer")}
|
||||
data-wd-key="modal:add-layer"
|
||||
className="maputnik-add-modal"
|
||||
>
|
||||
{errorElement}
|
||||
<div className="maputnik-add-layer">
|
||||
<FieldId
|
||||
value={id}
|
||||
wdKey="add-layer.layer-id"
|
||||
onChange={(v: string) => {
|
||||
setId(v);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
<FieldType
|
||||
value={type}
|
||||
wdKey="add-layer.layer-type"
|
||||
onChange={(v: LayerSpecification["type"]) => setType(v)}
|
||||
/>
|
||||
{type !== "background" &&
|
||||
<FieldSource
|
||||
sourceIds={sources}
|
||||
wdKey="add-layer.layer-source-block"
|
||||
value={this.state.source}
|
||||
onChange={(v: string) => this.setState({ source: v })}
|
||||
value={source}
|
||||
onChange={(v: string) => setSource(v)}
|
||||
/>
|
||||
}
|
||||
{!NON_SOURCE_LAYERS.includes(this.state.type) &&
|
||||
}
|
||||
{!NON_SOURCE_LAYERS.includes(type) &&
|
||||
<FieldSourceLayer
|
||||
sourceLayerIds={layers}
|
||||
value={this.state["source-layer"]}
|
||||
onChange={(v: string) => this.setState({ "source-layer": v })}
|
||||
value={sourceLayer}
|
||||
onChange={(v: string) => setSourceLayer(v)}
|
||||
/>
|
||||
}
|
||||
<InputButton
|
||||
className="maputnik-add-layer-button"
|
||||
onClick={this.addLayer}
|
||||
data-wd-key="add-layer"
|
||||
>
|
||||
{t("Add Layer")}
|
||||
</InputButton>
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
}
|
||||
}
|
||||
<InputButton
|
||||
className="maputnik-add-layer-button"
|
||||
onClick={addLayer}
|
||||
data-wd-key="add-layer"
|
||||
>
|
||||
{t("Add Layer")}
|
||||
</InputButton>
|
||||
</div>
|
||||
</Modal>;
|
||||
};
|
||||
|
||||
const ModalAdd = withTranslation()(ModalAddInternal);
|
||||
export default ModalAdd;
|
||||
export const ModalAdd = withTranslation()(ModalAddInternal);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { Trans, type WithTranslation, withTranslation } from "react-i18next";
|
||||
|
||||
import Modal from "./Modal";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
|
||||
type ModalDebugInternalProps = {
|
||||
@@ -22,62 +22,59 @@ type ModalDebugInternalProps = {
|
||||
} & WithTranslation;
|
||||
|
||||
|
||||
class ModalDebugInternal extends React.Component<ModalDebugInternalProps> {
|
||||
render() {
|
||||
const {t, mapView} = this.props;
|
||||
const ModalDebugInternal: React.FC<ModalDebugInternalProps> = (props) => {
|
||||
const {t, mapView} = props;
|
||||
|
||||
const osmZoom = Math.round(mapView.zoom)+1;
|
||||
const osmLon = +(mapView.center.lng).toFixed(5);
|
||||
const osmLat = +(mapView.center.lat).toFixed(5);
|
||||
const osmZoom = Math.round(mapView.zoom)+1;
|
||||
const osmLon = +(mapView.center.lng).toFixed(5);
|
||||
const osmLat = +(mapView.center.lat).toFixed(5);
|
||||
|
||||
return <Modal
|
||||
data-wd-key="modal:debug"
|
||||
isOpen={this.props.isOpen}
|
||||
onOpenToggle={this.props.onOpenToggle}
|
||||
title={t("Debug")}
|
||||
>
|
||||
<section className="maputnik-modal-section maputnik-modal-shortcuts">
|
||||
<h1>{t("Options")}</h1>
|
||||
{this.props.renderer === "mlgljs" &&
|
||||
<ul>
|
||||
{Object.entries(this.props.maplibreGlDebugOptions!).map(([key, val]) => {
|
||||
return <li key={key}>
|
||||
<label>
|
||||
<input type="checkbox" checked={val} onChange={(e) => this.props.onChangeMaplibreGlDebug(key, e.target.checked)} /> {key}
|
||||
</label>
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
{this.props.renderer === "ol" &&
|
||||
<ul>
|
||||
{Object.entries(this.props.openlayersDebugOptions!).map(([key, val]) => {
|
||||
return <li key={key}>
|
||||
<label>
|
||||
<input type="checkbox" checked={val} onChange={(e) => this.props.onChangeOpenlayersDebug(key, e.target.checked)} /> {key}
|
||||
</label>
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
<section className="maputnik-modal-section">
|
||||
<h1>{t("Links")}</h1>
|
||||
<p>
|
||||
<Trans t={t}>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`https://www.openstreetmap.org/#map=${osmZoom}/${osmLat}/${osmLon}`}
|
||||
>
|
||||
Open in OSM
|
||||
</a>. Opens the current view on openstreetmap.org
|
||||
</Trans>
|
||||
</p>
|
||||
</section>
|
||||
</Modal>;
|
||||
}
|
||||
}
|
||||
return <Modal
|
||||
data-wd-key="modal:debug"
|
||||
isOpen={props.isOpen}
|
||||
onOpenToggle={props.onOpenToggle}
|
||||
title={t("Debug")}
|
||||
>
|
||||
<section className="maputnik-modal-section maputnik-modal-shortcuts">
|
||||
<h1>{t("Options")}</h1>
|
||||
{props.renderer === "mlgljs" &&
|
||||
<ul>
|
||||
{Object.entries(props.maplibreGlDebugOptions!).map(([key, val]) => {
|
||||
return <li key={key}>
|
||||
<label>
|
||||
<input type="checkbox" checked={val} onChange={(e) => props.onChangeMaplibreGlDebug(key, e.target.checked)} /> {key}
|
||||
</label>
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
{props.renderer === "ol" &&
|
||||
<ul>
|
||||
{Object.entries(props.openlayersDebugOptions!).map(([key, val]) => {
|
||||
return <li key={key}>
|
||||
<label>
|
||||
<input type="checkbox" checked={val} onChange={(e) => props.onChangeOpenlayersDebug(key, e.target.checked)} /> {key}
|
||||
</label>
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
<section className="maputnik-modal-section">
|
||||
<h1>{t("Links")}</h1>
|
||||
<p>
|
||||
<Trans t={t}>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`https://www.openstreetmap.org/#map=${osmZoom}/${osmLat}/${osmLon}`}
|
||||
>
|
||||
Open in OSM
|
||||
</a>. Opens the current view on openstreetmap.org
|
||||
</Trans>
|
||||
</p>
|
||||
</section>
|
||||
</Modal>;
|
||||
};
|
||||
|
||||
const ModalDebug = withTranslation()(ModalDebugInternal);
|
||||
export default ModalDebug;
|
||||
export const ModalDebug = withTranslation()(ModalDebugInternal);
|
||||
|
||||
@@ -6,11 +6,11 @@ import {format} from "@maplibre/maplibre-gl-style-spec";
|
||||
import {MdMap, MdSave} from "react-icons/md";
|
||||
import {type WithTranslation, withTranslation} from "react-i18next";
|
||||
|
||||
import FieldString from "../FieldString";
|
||||
import InputButton from "../InputButton";
|
||||
import Modal from "./Modal";
|
||||
import style from "../../libs/style";
|
||||
import fieldSpecAdditional from "../../libs/field-spec-additional";
|
||||
import { FieldString } from "../FieldString";
|
||||
import { InputButton } from "../InputButton";
|
||||
import { Modal } from "./Modal";
|
||||
import { replaceAccessTokens, stripAccessTokens } from "../../libs/style";
|
||||
import { spec as fieldSpecAdditional } from "../../libs/field-spec-additional";
|
||||
import type {OnStyleChangedCallback, StyleSpecificationWithId} from "../../libs/definitions";
|
||||
|
||||
|
||||
@@ -28,31 +28,31 @@ type ModalExportInternalProps = {
|
||||
} & WithTranslation;
|
||||
|
||||
|
||||
class ModalExportInternal extends React.Component<ModalExportInternalProps> {
|
||||
const ModalExportInternal: React.FC<ModalExportInternalProps> = (props) => {
|
||||
|
||||
tokenizedStyle() {
|
||||
function tokenizedStyle() {
|
||||
return format(
|
||||
style.stripAccessTokens(
|
||||
style.replaceAccessTokens(this.props.mapStyle)
|
||||
stripAccessTokens(
|
||||
replaceAccessTokens(props.mapStyle)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
exportName() {
|
||||
if (this.props.mapStyle.name) {
|
||||
return Slugify(this.props.mapStyle.name, {
|
||||
function exportName() {
|
||||
if (props.mapStyle.name) {
|
||||
return Slugify(props.mapStyle.name, {
|
||||
replacement: "_",
|
||||
remove: /[*\-+~.()'"!:]/g,
|
||||
lower: true
|
||||
});
|
||||
} else {
|
||||
return this.props.mapStyle.id;
|
||||
return props.mapStyle.id;
|
||||
}
|
||||
}
|
||||
|
||||
createHtml() {
|
||||
const tokenStyle = this.tokenizedStyle();
|
||||
const htmlTitle = this.props.mapStyle.name || this.props.t("Map");
|
||||
function createHtml() {
|
||||
const tokenStyle = tokenizedStyle();
|
||||
const htmlTitle = props.mapStyle.name || props.t("Map");
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -80,49 +80,11 @@ class ModalExportInternal extends React.Component<ModalExportInternalProps> {
|
||||
`;
|
||||
|
||||
const blob = new Blob([html], {type: "text/html;charset=utf-8"});
|
||||
const exportName = this.exportName();
|
||||
saveAs(blob, exportName + ".html");
|
||||
const name = exportName();
|
||||
saveAs(blob, name + ".html");
|
||||
}
|
||||
|
||||
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> {
|
||||
async function createFileHandle(): Promise<FileSystemFileHandle | null> {
|
||||
const pickerOpts: SaveFilePickerOptions = {
|
||||
types: [
|
||||
{
|
||||
@@ -130,92 +92,126 @@ class ModalExportInternal extends React.Component<ModalExportInternalProps> {
|
||||
accept: {"application/json": [".json"]},
|
||||
},
|
||||
],
|
||||
suggestedName: this.exportName(),
|
||||
suggestedName: exportName(),
|
||||
};
|
||||
|
||||
const fileHandle = await window.showSaveFilePicker(pickerOpts) as FileSystemFileHandle;
|
||||
this.props.onSetFileHandle(fileHandle);
|
||||
props.onSetFileHandle(fileHandle);
|
||||
return fileHandle;
|
||||
}
|
||||
|
||||
changeMetadataProperty(property: string, value: any) {
|
||||
async function saveStyle() {
|
||||
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 = {
|
||||
...this.props.mapStyle,
|
||||
...props.mapStyle,
|
||||
metadata: {
|
||||
...this.props.mapStyle.metadata as any,
|
||||
...props.mapStyle.metadata as any,
|
||||
[property]: value
|
||||
}
|
||||
};
|
||||
this.props.onStyleChanged(changedStyle);
|
||||
props.onStyleChanged(changedStyle);
|
||||
}
|
||||
|
||||
const t = props.t;
|
||||
const fsa = fieldSpecAdditional(t);
|
||||
return <Modal
|
||||
data-wd-key="modal:export"
|
||||
isOpen={props.isOpen}
|
||||
onOpenToggle={props.onOpenToggle}
|
||||
title={t("Save Style")}
|
||||
className="maputnik-export-modal"
|
||||
>
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
const fsa = fieldSpecAdditional(t);
|
||||
return <Modal
|
||||
data-wd-key="modal:export"
|
||||
isOpen={this.props.isOpen}
|
||||
onOpenToggle={this.props.onOpenToggle}
|
||||
title={t("Save Style")}
|
||||
className="maputnik-export-modal"
|
||||
>
|
||||
<section className="maputnik-modal-section">
|
||||
<h1>{t("Save Style")}</h1>
|
||||
<p>
|
||||
{t("Save the JSON style to your computer.")}
|
||||
</p>
|
||||
|
||||
<section className="maputnik-modal-section">
|
||||
<h1>{t("Save Style")}</h1>
|
||||
<p>
|
||||
{t("Save the JSON style to your computer.")}
|
||||
</p>
|
||||
<div>
|
||||
<FieldString
|
||||
label={fsa.maputnik.maptiler_access_token.label}
|
||||
fieldSpec={fsa.maputnik.maptiler_access_token}
|
||||
value={(props.mapStyle.metadata || {} as any)["maputnik:openmaptiles_access_token"]}
|
||||
onChange={changeMetadataProperty.bind(null, "maputnik:openmaptiles_access_token")}
|
||||
/>
|
||||
<FieldString
|
||||
label={fsa.maputnik.thunderforest_access_token.label}
|
||||
fieldSpec={fsa.maputnik.thunderforest_access_token}
|
||||
value={(props.mapStyle.metadata || {} as any)["maputnik:thunderforest_access_token"]}
|
||||
onChange={changeMetadataProperty.bind(null, "maputnik:thunderforest_access_token")}
|
||||
/>
|
||||
<FieldString
|
||||
label={fsa.maputnik.stadia_access_token.label}
|
||||
fieldSpec={fsa.maputnik.stadia_access_token}
|
||||
value={(props.mapStyle.metadata || {} as any)["maputnik:stadia_access_token"]}
|
||||
onChange={changeMetadataProperty.bind(null, "maputnik:stadia_access_token")}
|
||||
/>
|
||||
<FieldString
|
||||
label={fsa.maputnik.locationiq_access_token.label}
|
||||
fieldSpec={fsa.maputnik.locationiq_access_token}
|
||||
value={(props.mapStyle.metadata || {} as any)["maputnik:locationiq_access_token"]}
|
||||
onChange={changeMetadataProperty.bind(null, "maputnik:locationiq_access_token")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldString
|
||||
label={fsa.maputnik.maptiler_access_token.label}
|
||||
fieldSpec={fsa.maputnik.maptiler_access_token}
|
||||
value={(this.props.mapStyle.metadata || {} as any)["maputnik:openmaptiles_access_token"]}
|
||||
onChange={this.changeMetadataProperty.bind(this, "maputnik:openmaptiles_access_token")}
|
||||
/>
|
||||
<FieldString
|
||||
label={fsa.maputnik.thunderforest_access_token.label}
|
||||
fieldSpec={fsa.maputnik.thunderforest_access_token}
|
||||
value={(this.props.mapStyle.metadata || {} as any)["maputnik:thunderforest_access_token"]}
|
||||
onChange={this.changeMetadataProperty.bind(this, "maputnik:thunderforest_access_token")}
|
||||
/>
|
||||
<FieldString
|
||||
label={fsa.maputnik.stadia_access_token.label}
|
||||
fieldSpec={fsa.maputnik.stadia_access_token}
|
||||
value={(this.props.mapStyle.metadata || {} as any)["maputnik:stadia_access_token"]}
|
||||
onChange={this.changeMetadataProperty.bind(this, "maputnik:stadia_access_token")}
|
||||
/>
|
||||
<FieldString
|
||||
label={fsa.maputnik.locationiq_access_token.label}
|
||||
fieldSpec={fsa.maputnik.locationiq_access_token}
|
||||
value={(this.props.mapStyle.metadata || {} as any)["maputnik:locationiq_access_token"]}
|
||||
onChange={this.changeMetadataProperty.bind(this, "maputnik:locationiq_access_token")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="maputnik-modal-export-buttons">
|
||||
<InputButton onClick={this.saveStyle.bind(this)}>
|
||||
<div className="maputnik-modal-export-buttons">
|
||||
<InputButton onClick={saveStyle}>
|
||||
<MdSave/>
|
||||
{t("Save")}
|
||||
</InputButton>
|
||||
{showSaveFilePickerAvailable && (
|
||||
<InputButton onClick={saveStyleAs}>
|
||||
<MdSave/>
|
||||
{t("Save")}
|
||||
{t("Save as")}
|
||||
</InputButton>
|
||||
{showSaveFilePickerAvailable && (
|
||||
<InputButton onClick={this.saveStyleAs.bind(this)}>
|
||||
<MdSave/>
|
||||
{t("Save as")}
|
||||
</InputButton>
|
||||
)}
|
||||
)}
|
||||
|
||||
<InputButton onClick={this.createHtml.bind(this)}>
|
||||
<MdMap/>
|
||||
{t("Create HTML")}
|
||||
</InputButton>
|
||||
</div>
|
||||
</section>
|
||||
<InputButton onClick={createHtml}>
|
||||
<MdMap/>
|
||||
{t("Create HTML")}
|
||||
</InputButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</Modal>;
|
||||
}
|
||||
}
|
||||
</Modal>;
|
||||
};
|
||||
|
||||
const ModalExport = withTranslation()(ModalExportInternal);
|
||||
export default ModalExport;
|
||||
export const ModalExport = withTranslation()(ModalExportInternal);
|
||||
|
||||
@@ -3,13 +3,13 @@ import { withTranslation, type WithTranslation } from "react-i18next";
|
||||
import { MdDelete } from "react-icons/md";
|
||||
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
|
||||
|
||||
import Modal from "./Modal";
|
||||
import FieldString from "../FieldString";
|
||||
import InputButton from "../InputButton";
|
||||
import { Modal } from "./Modal";
|
||||
import { FieldString } from "../FieldString";
|
||||
import { InputButton } from "../InputButton";
|
||||
import { PiListPlusBold } from "react-icons/pi";
|
||||
import { type StyleSpecificationWithId } from "../../libs/definitions";
|
||||
import { type SchemaSpecification } from "maplibre-gl";
|
||||
import Doc from "../Doc";
|
||||
import { Doc } from "../Doc";
|
||||
|
||||
type ModalGlobalStateInternalProps = {
|
||||
mapStyle: StyleSpecificationWithId;
|
||||
@@ -151,5 +151,4 @@ const ModalGlobalStateInternal: React.FC<ModalGlobalStateInternalProps> = (props
|
||||
);
|
||||
};
|
||||
|
||||
const ModalGlobalState = withTranslation()(ModalGlobalStateInternal);
|
||||
export default ModalGlobalState;
|
||||
export const ModalGlobalState = withTranslation()(ModalGlobalStateInternal);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
import { type WithTranslation, withTranslation } from "react-i18next";
|
||||
|
||||
import InputButton from "../InputButton";
|
||||
import Modal from "./Modal";
|
||||
import { InputButton } from "../InputButton";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
|
||||
type ModalLoadingInternalProps = {
|
||||
@@ -13,27 +13,24 @@ type ModalLoadingInternalProps = {
|
||||
} & WithTranslation;
|
||||
|
||||
|
||||
class ModalLoadingInternal extends React.Component<ModalLoadingInternalProps> {
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
return <Modal
|
||||
data-wd-key="modal:loading"
|
||||
isOpen={this.props.isOpen}
|
||||
underlayClickExits={false}
|
||||
title={this.props.title}
|
||||
onOpenToggle={() => this.props.onCancel()}
|
||||
>
|
||||
<p>
|
||||
{this.props.message}
|
||||
</p>
|
||||
<p className="maputnik-dialog__buttons">
|
||||
<InputButton onClick={(e) => this.props.onCancel(e)}>
|
||||
{t("Cancel")}
|
||||
</InputButton>
|
||||
</p>
|
||||
</Modal>;
|
||||
}
|
||||
}
|
||||
const ModalLoadingInternal: React.FC<ModalLoadingInternalProps> = (props) => {
|
||||
const t = props.t;
|
||||
return <Modal
|
||||
data-wd-key="modal:loading"
|
||||
isOpen={props.isOpen}
|
||||
underlayClickExits={false}
|
||||
title={props.title}
|
||||
onOpenToggle={() => props.onCancel()}
|
||||
>
|
||||
<p>
|
||||
{props.message}
|
||||
</p>
|
||||
<p className="maputnik-dialog__buttons">
|
||||
<InputButton onClick={(e) => props.onCancel(e)}>
|
||||
{t("Cancel")}
|
||||
</InputButton>
|
||||
</p>
|
||||
</Modal>;
|
||||
};
|
||||
|
||||
const ModalLoading = withTranslation()(ModalLoadingInternal);
|
||||
export default ModalLoading;
|
||||
export const ModalLoading = withTranslation()(ModalLoadingInternal);
|
||||
|
||||
+202
-235
@@ -1,14 +1,14 @@
|
||||
import React, { type DragEvent, type FormEvent } from "react";
|
||||
import React, { type DragEvent, type FormEvent, useRef, useState } from "react";
|
||||
import { MdFileUpload } from "react-icons/md";
|
||||
import { MdAddCircleOutline } from "react-icons/md";
|
||||
import { Trans, type WithTranslation, withTranslation } from "react-i18next";
|
||||
|
||||
import ModalLoading from "./ModalLoading";
|
||||
import Modal from "./Modal";
|
||||
import InputButton from "../InputButton";
|
||||
import InputUrl from "../InputUrl";
|
||||
import { ModalLoading } from "./ModalLoading";
|
||||
import { Modal } from "./Modal";
|
||||
import { InputButton } from "../InputButton";
|
||||
import { InputUrl } from "../InputUrl";
|
||||
|
||||
import style from "../../libs/style";
|
||||
import { ensureStyleValidity } from "../../libs/style";
|
||||
import publicStyles from "../../config/styles.json";
|
||||
|
||||
type PublicStyleProps = {
|
||||
@@ -18,29 +18,27 @@ type PublicStyleProps = {
|
||||
onSelect(...args: unknown[]): unknown
|
||||
};
|
||||
|
||||
class PublicStyle extends React.Component<PublicStyleProps> {
|
||||
render() {
|
||||
return <div className="maputnik-public-style">
|
||||
<InputButton
|
||||
className="maputnik-public-style-button"
|
||||
aria-label={this.props.title}
|
||||
onClick={() => this.props.onSelect(this.props.url)}
|
||||
>
|
||||
<div className="maputnik-public-style-header">
|
||||
<div>{this.props.title}</div>
|
||||
<span className="maputnik-space" />
|
||||
<MdAddCircleOutline />
|
||||
</div>
|
||||
<div
|
||||
className="maputnik-public-style-thumbnail"
|
||||
style={{
|
||||
backgroundImage: `url(${this.props.thumbnailUrl})`
|
||||
}}
|
||||
></div>
|
||||
</InputButton>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
const PublicStyle: React.FC<PublicStyleProps> = (props) => {
|
||||
return <div className="maputnik-public-style">
|
||||
<InputButton
|
||||
className="maputnik-public-style-button"
|
||||
aria-label={props.title}
|
||||
onClick={() => props.onSelect(props.url)}
|
||||
>
|
||||
<div className="maputnik-public-style-header">
|
||||
<div>{props.title}</div>
|
||||
<span className="maputnik-space" />
|
||||
<MdAddCircleOutline />
|
||||
</div>
|
||||
<div
|
||||
className="maputnik-public-style-thumbnail"
|
||||
style={{
|
||||
backgroundImage: `url(${props.thumbnailUrl})`
|
||||
}}
|
||||
></div>
|
||||
</InputButton>
|
||||
</div>;
|
||||
};
|
||||
|
||||
type ModalOpenInternalProps = {
|
||||
isOpen: boolean
|
||||
@@ -49,46 +47,32 @@ type ModalOpenInternalProps = {
|
||||
fileHandle: FileSystemFileHandle | null
|
||||
} & WithTranslation;
|
||||
|
||||
type ModalOpenState = {
|
||||
styleUrl: string
|
||||
isDragOver: boolean
|
||||
error?: string | null
|
||||
activeRequest?: any
|
||||
activeRequestUrl?: string | null
|
||||
};
|
||||
const ModalOpenInternal: React.FC<ModalOpenInternalProps> = (props) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpenState> {
|
||||
private fileInputRef = React.createRef<HTMLInputElement>();
|
||||
const [styleUrl, setStyleUrl] = useState<string>("");
|
||||
const [isDragOver, setIsDragOver] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null | undefined>(undefined);
|
||||
const [activeRequest, setActiveRequest] = useState<any>(undefined);
|
||||
const [activeRequestUrl, setActiveRequestUrl] = useState<string | null | undefined>(undefined);
|
||||
|
||||
constructor(props: ModalOpenInternalProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
styleUrl: "",
|
||||
isDragOver: false,
|
||||
};
|
||||
}
|
||||
const clearError = () => {
|
||||
setError(null);
|
||||
};
|
||||
|
||||
clearError() {
|
||||
this.setState({
|
||||
error: null
|
||||
});
|
||||
}
|
||||
|
||||
onCancelActiveRequest(e: Event) {
|
||||
const onCancelActiveRequest = (e: Event) => {
|
||||
// Else the click propagates to the underlying modal
|
||||
if (e) e.stopPropagation();
|
||||
|
||||
if (this.state.activeRequest) {
|
||||
this.state.activeRequest.abort();
|
||||
this.setState({
|
||||
activeRequest: null,
|
||||
activeRequestUrl: null
|
||||
});
|
||||
if (activeRequest) {
|
||||
activeRequest.abort();
|
||||
setActiveRequest(null);
|
||||
setActiveRequestUrl(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onStyleSelect = (styleUrl: string) => {
|
||||
this.clearError();
|
||||
const onStyleSelect = (styleUrl: string) => {
|
||||
clearError();
|
||||
|
||||
let canceled: boolean = false;
|
||||
|
||||
@@ -104,43 +88,37 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
activeRequest: null,
|
||||
activeRequestUrl: null
|
||||
});
|
||||
setActiveRequest(null);
|
||||
setActiveRequestUrl(null);
|
||||
|
||||
const mapStyle = style.ensureStyleValidity(body);
|
||||
const mapStyle = ensureStyleValidity(body);
|
||||
console.log("Loaded style ", mapStyle.id);
|
||||
this.props.onStyleOpen(mapStyle);
|
||||
this.onOpenToggle();
|
||||
props.onStyleOpen(mapStyle);
|
||||
onOpenToggle();
|
||||
})
|
||||
.catch((err) => {
|
||||
this.setState({
|
||||
error: `Failed to load: '${styleUrl}'`,
|
||||
activeRequest: null,
|
||||
activeRequestUrl: null
|
||||
});
|
||||
setError(`Failed to load: '${styleUrl}'`);
|
||||
setActiveRequest(null);
|
||||
setActiveRequestUrl(null);
|
||||
console.error(err);
|
||||
console.warn("Could not open the style URL", styleUrl);
|
||||
});
|
||||
|
||||
this.setState({
|
||||
activeRequest: {
|
||||
abort: function () {
|
||||
canceled = true;
|
||||
}
|
||||
},
|
||||
activeRequestUrl: styleUrl
|
||||
setActiveRequest({
|
||||
abort: function () {
|
||||
canceled = true;
|
||||
}
|
||||
});
|
||||
setActiveRequestUrl(styleUrl);
|
||||
};
|
||||
|
||||
onSubmitUrl = (e: FormEvent<HTMLFormElement>) => {
|
||||
const onSubmitUrl = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
this.onStyleSelect(this.state.styleUrl);
|
||||
onStyleSelect(styleUrl);
|
||||
};
|
||||
|
||||
onOpenFile = async () => {
|
||||
this.clearError();
|
||||
const onOpenFile = async () => {
|
||||
clearError();
|
||||
|
||||
const pickerOpts: OpenFilePickerOptions = {
|
||||
types: [
|
||||
@@ -160,26 +138,24 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
||||
try {
|
||||
mapStyle = JSON.parse(content);
|
||||
} catch (err) {
|
||||
this.setState({
|
||||
error: (err as Error).toString()
|
||||
});
|
||||
setError((err as Error).toString());
|
||||
return;
|
||||
}
|
||||
mapStyle = style.ensureStyleValidity(mapStyle);
|
||||
mapStyle = ensureStyleValidity(mapStyle);
|
||||
|
||||
this.props.onStyleOpen(mapStyle, fileHandle);
|
||||
this.onOpenToggle();
|
||||
props.onStyleOpen(mapStyle, fileHandle);
|
||||
onOpenToggle();
|
||||
return file;
|
||||
};
|
||||
|
||||
// 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.
|
||||
onFileChanged = (files: FileList | null) => {
|
||||
const onFileChanged = (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
if (files.length === 0) return;
|
||||
const file = files[0];
|
||||
const reader = new FileReader();
|
||||
this.clearError();
|
||||
clearError();
|
||||
|
||||
reader.readAsText(file, "UTF-8");
|
||||
reader.onload = e => {
|
||||
@@ -188,181 +164,172 @@ class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpe
|
||||
mapStyle = JSON.parse(e.target?.result as string);
|
||||
}
|
||||
catch (err) {
|
||||
this.setState({
|
||||
error: (err as Error).toString()
|
||||
});
|
||||
setError((err as Error).toString());
|
||||
return;
|
||||
}
|
||||
mapStyle = style.ensureStyleValidity(mapStyle);
|
||||
this.props.onStyleOpen(mapStyle);
|
||||
this.onOpenToggle();
|
||||
mapStyle = ensureStyleValidity(mapStyle);
|
||||
props.onStyleOpen(mapStyle);
|
||||
onOpenToggle();
|
||||
};
|
||||
reader.onerror = e => console.log(e.target);
|
||||
};
|
||||
|
||||
onOpenToggle() {
|
||||
this.setState({
|
||||
styleUrl: "",
|
||||
isDragOver: false,
|
||||
});
|
||||
this.clearError();
|
||||
this.props.onOpenToggle();
|
||||
}
|
||||
const onOpenToggle = () => {
|
||||
setStyleUrl("");
|
||||
setIsDragOver(false);
|
||||
clearError();
|
||||
props.onOpenToggle();
|
||||
};
|
||||
|
||||
onBrowseClick = async () => {
|
||||
const onBrowseClick = async () => {
|
||||
if (typeof window.showOpenFilePicker === "function") {
|
||||
await this.onOpenFile();
|
||||
await onOpenFile();
|
||||
return;
|
||||
}
|
||||
|
||||
this.fileInputRef.current?.click();
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
onFileDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
const onFileDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!this.state.isDragOver) {
|
||||
this.setState({ isDragOver: true });
|
||||
if (!isDragOver) {
|
||||
setIsDragOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
onFileDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
const onFileDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.setState({ isDragOver: false });
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
onFileDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
const onFileDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.setState({ isDragOver: false });
|
||||
this.onFileChanged(e.dataTransfer.files);
|
||||
setIsDragOver(false);
|
||||
onFileChanged(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
onChangeUrl = (url: string) => {
|
||||
this.setState({
|
||||
styleUrl: url,
|
||||
});
|
||||
const onChangeUrl = (url: string) => {
|
||||
setStyleUrl(url);
|
||||
};
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
const styleOptions = publicStyles.map(style => {
|
||||
return <PublicStyle
|
||||
key={style.id}
|
||||
url={style.url}
|
||||
title={style.title}
|
||||
thumbnailUrl={style.thumbnail}
|
||||
onSelect={this.onStyleSelect}
|
||||
/>;
|
||||
});
|
||||
const t = props.t;
|
||||
const styleOptions = publicStyles.map(style => {
|
||||
return <PublicStyle
|
||||
key={style.id}
|
||||
url={style.url}
|
||||
title={style.title}
|
||||
thumbnailUrl={style.thumbnail}
|
||||
onSelect={onStyleSelect}
|
||||
/>;
|
||||
});
|
||||
|
||||
let errorElement;
|
||||
if (this.state.error) {
|
||||
errorElement = (
|
||||
<div className="maputnik-modal-error">
|
||||
{this.state.error}
|
||||
<a href="#" onClick={() => this.clearError()} className="maputnik-modal-error-close">×</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Modal
|
||||
data-wd-key="modal:open"
|
||||
isOpen={this.props.isOpen}
|
||||
onOpenToggle={() => this.onOpenToggle()}
|
||||
title={t("Open Style")}
|
||||
>
|
||||
{errorElement}
|
||||
<section className="maputnik-modal-section">
|
||||
<h1>{t("Open local Style")}</h1>
|
||||
<p>{t("Open a local JSON style from your computer.")}</p>
|
||||
<div
|
||||
data-wd-key="modal:open.dropzone"
|
||||
className={`maputnik-upload-dropzone${this.state.isDragOver ? " maputnik-upload-dropzone--active" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onDragOver={this.onFileDragOver}
|
||||
onDragLeave={this.onFileDragLeave}
|
||||
onDrop={this.onFileDrop}
|
||||
onClick={() => void this.onBrowseClick()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
void this.onBrowseClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="maputnik-upload-dropzone-content">
|
||||
<MdFileUpload className="maputnik-upload-dropzone-icon" aria-hidden="true" />
|
||||
<p className="maputnik-upload-dropzone-text">
|
||||
{t("Drag and drop a style JSON file here or click to browse")}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref={this.fileInputRef}
|
||||
data-wd-key="modal:open.file.input"
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => this.onFileChanged(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="maputnik-modal-section">
|
||||
<form onSubmit={this.onSubmitUrl}>
|
||||
<h1>{t("Load from URL")}</h1>
|
||||
<p>
|
||||
<Trans t={t}>
|
||||
Load from a URL. Note that the URL must have <a href="https://enable-cors.org" target="_blank" rel="noopener noreferrer">CORS enabled</a>.
|
||||
</Trans>
|
||||
</p>
|
||||
<InputUrl
|
||||
aria-label={t("Style URL")}
|
||||
data-wd-key="modal:open.url.input"
|
||||
type="text"
|
||||
className="maputnik-input"
|
||||
default={t("Enter URL...")}
|
||||
value={this.state.styleUrl}
|
||||
onInput={this.onChangeUrl}
|
||||
onChange={this.onChangeUrl}
|
||||
/>
|
||||
<div>
|
||||
<InputButton
|
||||
data-wd-key="modal:open.url.button"
|
||||
type="submit"
|
||||
className="maputnik-big-button"
|
||||
disabled={this.state.styleUrl.length < 1}
|
||||
>Load from URL</InputButton>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="maputnik-modal-section maputnik-modal-section--shrink">
|
||||
<h1>{t("Gallery Styles")}</h1>
|
||||
<p>
|
||||
{t("Open one of the publicly available styles to start from.")}
|
||||
</p>
|
||||
<div className="maputnik-style-gallery-container">
|
||||
{styleOptions}
|
||||
</div>
|
||||
</section>
|
||||
</Modal>
|
||||
|
||||
<ModalLoading
|
||||
isOpen={!!this.state.activeRequest}
|
||||
title={t("Loading style")}
|
||||
onCancel={(e: Event) => this.onCancelActiveRequest(e)}
|
||||
message={t("Loading") + ": " + this.state.activeRequestUrl}
|
||||
/>
|
||||
let errorElement;
|
||||
if (error) {
|
||||
errorElement = (
|
||||
<div className="maputnik-modal-error">
|
||||
{error}
|
||||
<a href="#" onClick={() => clearError()} className="maputnik-modal-error-close">×</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ModalOpen = withTranslation()(ModalOpenInternal);
|
||||
export default ModalOpen;
|
||||
return (
|
||||
<div>
|
||||
<Modal
|
||||
data-wd-key="modal:open"
|
||||
isOpen={props.isOpen}
|
||||
onOpenToggle={() => onOpenToggle()}
|
||||
title={t("Open Style")}
|
||||
>
|
||||
{errorElement}
|
||||
<section className="maputnik-modal-section">
|
||||
<h1>{t("Open local Style")}</h1>
|
||||
<p>{t("Open a local JSON style from your computer.")}</p>
|
||||
<div
|
||||
data-wd-key="modal:open.dropzone"
|
||||
className={`maputnik-upload-dropzone${isDragOver ? " maputnik-upload-dropzone--active" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onDragOver={onFileDragOver}
|
||||
onDragLeave={onFileDragLeave}
|
||||
onDrop={onFileDrop}
|
||||
onClick={() => void onBrowseClick()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
void onBrowseClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="maputnik-upload-dropzone-content">
|
||||
<MdFileUpload className="maputnik-upload-dropzone-icon" aria-hidden="true" />
|
||||
<p className="maputnik-upload-dropzone-text">
|
||||
{t("Drag and drop a style JSON file here or click to browse")}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
data-wd-key="modal:open.file.input"
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => onFileChanged(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="maputnik-modal-section">
|
||||
<form onSubmit={onSubmitUrl}>
|
||||
<h1>{t("Load from URL")}</h1>
|
||||
<p>
|
||||
<Trans t={t}>
|
||||
Load from a URL. Note that the URL must have <a href="https://enable-cors.org" target="_blank" rel="noopener noreferrer">CORS enabled</a>.
|
||||
</Trans>
|
||||
</p>
|
||||
<InputUrl
|
||||
aria-label={t("Style URL")}
|
||||
data-wd-key="modal:open.url.input"
|
||||
type="text"
|
||||
className="maputnik-input"
|
||||
default={t("Enter URL...")}
|
||||
value={styleUrl}
|
||||
onInput={onChangeUrl}
|
||||
onChange={onChangeUrl}
|
||||
/>
|
||||
<div>
|
||||
<InputButton
|
||||
data-wd-key="modal:open.url.button"
|
||||
type="submit"
|
||||
className="maputnik-big-button"
|
||||
disabled={styleUrl.length < 1}
|
||||
>Load from URL</InputButton>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="maputnik-modal-section maputnik-modal-section--shrink">
|
||||
<h1>{t("Gallery Styles")}</h1>
|
||||
<p>
|
||||
{t("Open one of the publicly available styles to start from.")}
|
||||
</p>
|
||||
<div className="maputnik-style-gallery-container">
|
||||
{styleOptions}
|
||||
</div>
|
||||
</section>
|
||||
</Modal>
|
||||
|
||||
<ModalLoading
|
||||
isOpen={!!activeRequest}
|
||||
title={t("Loading style")}
|
||||
onCancel={(e: Event) => onCancelActiveRequest(e)}
|
||||
message={t("Loading") + ": " + activeRequestUrl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModalOpen = withTranslation()(ModalOpenInternal);
|
||||
|
||||
@@ -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 WithTranslation, withTranslation } from "react-i18next";
|
||||
|
||||
import FieldArray from "../FieldArray";
|
||||
import FieldNumber from "../FieldNumber";
|
||||
import FieldString from "../FieldString";
|
||||
import FieldUrl from "../FieldUrl";
|
||||
import FieldSelect from "../FieldSelect";
|
||||
import FieldEnum from "../FieldEnum";
|
||||
import FieldColor from "../FieldColor";
|
||||
import Modal from "./Modal";
|
||||
import FieldJson from "../FieldJson";
|
||||
import Block from "../Block";
|
||||
import fieldSpecAdditional from "../../libs/field-spec-additional";
|
||||
import { FieldArray } from "../FieldArray";
|
||||
import { FieldNumber } from "../FieldNumber";
|
||||
import { FieldString } from "../FieldString";
|
||||
import { FieldUrl } from "../FieldUrl";
|
||||
import { FieldSelect } from "../FieldSelect";
|
||||
import { FieldEnum } from "../FieldEnum";
|
||||
import { FieldColor } from "../FieldColor";
|
||||
import { Modal } from "./Modal";
|
||||
import { FieldJson } from "../FieldJson";
|
||||
import { Block } from "../Block";
|
||||
import { spec as fieldSpecAdditional } from "../../libs/field-spec-additional";
|
||||
import type {OnStyleChangedCallback, StyleSpecificationWithId} from "../../libs/definitions";
|
||||
|
||||
type ModalSettingsInternalProps = {
|
||||
@@ -24,10 +24,10 @@ type ModalSettingsInternalProps = {
|
||||
onOpenToggle(): void
|
||||
} & WithTranslation;
|
||||
|
||||
class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps> {
|
||||
changeTransitionProperty(property: keyof TransitionSpecification, value: number | undefined) {
|
||||
const ModalSettingsInternal: React.FC<ModalSettingsInternalProps> = (props) => {
|
||||
function changeTransitionProperty(property: keyof TransitionSpecification, value: number | undefined) {
|
||||
const transition = {
|
||||
...this.props.mapStyle.transition,
|
||||
...props.mapStyle.transition,
|
||||
};
|
||||
|
||||
if (value === undefined) {
|
||||
@@ -37,15 +37,15 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
transition[property] = value;
|
||||
}
|
||||
|
||||
this.props.onStyleChanged({
|
||||
...this.props.mapStyle,
|
||||
props.onStyleChanged({
|
||||
...props.mapStyle,
|
||||
transition,
|
||||
});
|
||||
}
|
||||
|
||||
changeLightProperty(property: keyof LightSpecification, value: any) {
|
||||
function changeLightProperty(property: keyof LightSpecification, value: any) {
|
||||
const light = {
|
||||
...this.props.mapStyle.light,
|
||||
...props.mapStyle.light,
|
||||
};
|
||||
|
||||
if (value === undefined) {
|
||||
@@ -56,15 +56,15 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
light[property] = value;
|
||||
}
|
||||
|
||||
this.props.onStyleChanged({
|
||||
...this.props.mapStyle,
|
||||
props.onStyleChanged({
|
||||
...props.mapStyle,
|
||||
light,
|
||||
});
|
||||
}
|
||||
|
||||
changeTerrainProperty(property: keyof TerrainSpecification, value: any) {
|
||||
function changeTerrainProperty(property: keyof TerrainSpecification, value: any) {
|
||||
const terrain = {
|
||||
...this.props.mapStyle.terrain,
|
||||
...props.mapStyle.terrain,
|
||||
} as TerrainSpecification;
|
||||
|
||||
if (value === undefined) {
|
||||
@@ -75,15 +75,15 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
terrain[property] = value;
|
||||
}
|
||||
|
||||
this.props.onStyleChanged({
|
||||
...this.props.mapStyle,
|
||||
props.onStyleChanged({
|
||||
...props.mapStyle,
|
||||
terrain,
|
||||
});
|
||||
}
|
||||
|
||||
changeProjectionType(value: any) {
|
||||
function changeProjectionType(value: any) {
|
||||
const projection = {
|
||||
...this.props.mapStyle.projection,
|
||||
...props.mapStyle.projection,
|
||||
} as ProjectionSpecification;
|
||||
|
||||
if (value === undefined) {
|
||||
@@ -93,15 +93,15 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
projection.type = value;
|
||||
}
|
||||
|
||||
this.props.onStyleChanged({
|
||||
...this.props.mapStyle,
|
||||
props.onStyleChanged({
|
||||
...props.mapStyle,
|
||||
projection,
|
||||
});
|
||||
}
|
||||
|
||||
changeStyleProperty(property: keyof StyleSpecification | "owner", value: any) {
|
||||
function changeStyleProperty(property: keyof StyleSpecification | "owner", value: any) {
|
||||
const changedStyle = {
|
||||
...this.props.mapStyle,
|
||||
...props.mapStyle,
|
||||
};
|
||||
|
||||
if (value === undefined) {
|
||||
@@ -112,218 +112,222 @@ class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps>
|
||||
// @ts-ignore
|
||||
changedStyle[property] = value;
|
||||
}
|
||||
this.props.onStyleChanged(changedStyle);
|
||||
props.onStyleChanged(changedStyle);
|
||||
}
|
||||
|
||||
render() {
|
||||
const metadata = this.props.mapStyle.metadata || {} as any;
|
||||
const {t, onChangeMetadataProperty, mapStyle} = this.props;
|
||||
const fsa = fieldSpecAdditional(t);
|
||||
const metadata = props.mapStyle.metadata || {} as any;
|
||||
const {t, onChangeMetadataProperty, mapStyle} = props;
|
||||
const fsa = fieldSpecAdditional(t);
|
||||
|
||||
const light = this.props.mapStyle.light || {};
|
||||
const transition = this.props.mapStyle.transition || {};
|
||||
const terrain = this.props.mapStyle.terrain || {} as TerrainSpecification;
|
||||
const projection = this.props.mapStyle.projection || {} as ProjectionSpecification;
|
||||
const light = props.mapStyle.light || {};
|
||||
const transition = props.mapStyle.transition || {};
|
||||
const terrain = props.mapStyle.terrain || {} as TerrainSpecification;
|
||||
const projection = props.mapStyle.projection || {} as ProjectionSpecification;
|
||||
|
||||
return <Modal
|
||||
data-wd-key="modal:settings"
|
||||
isOpen={this.props.isOpen}
|
||||
onOpenToggle={this.props.onOpenToggle}
|
||||
title={t("Style Settings")}
|
||||
>
|
||||
<div className="modal:settings">
|
||||
<FieldString
|
||||
label={t("Name")}
|
||||
fieldSpec={latest.$root.name}
|
||||
data-wd-key="modal:settings.name"
|
||||
value={this.props.mapStyle.name}
|
||||
onChange={(value) => this.changeStyleProperty("name", value)}
|
||||
return <Modal
|
||||
data-wd-key="modal:settings"
|
||||
isOpen={props.isOpen}
|
||||
onOpenToggle={props.onOpenToggle}
|
||||
title={t("Style Settings")}
|
||||
>
|
||||
<div className="modal:settings">
|
||||
<FieldString
|
||||
label={t("Name")}
|
||||
fieldSpec={latest.$root.name}
|
||||
data-wd-key="modal:settings.name"
|
||||
value={props.mapStyle.name}
|
||||
onChange={(value) => changeStyleProperty("name", value)}
|
||||
/>
|
||||
<FieldString
|
||||
label={t("Owner")}
|
||||
fieldSpec={{doc: t("Owner ID of the style. Used by Mapbox or future style APIs.")}}
|
||||
data-wd-key="modal:settings.owner"
|
||||
value={(props.mapStyle as any).owner}
|
||||
onChange={(value) => changeStyleProperty("owner", value)}
|
||||
/>
|
||||
<Block label={t("Sprite URL")} fieldSpec={latest.$root.sprite} data-wd-key="modal:settings.sprite">
|
||||
<FieldJson
|
||||
lintType="json"
|
||||
value={props.mapStyle.sprite as any}
|
||||
onChange={(value) => changeStyleProperty("sprite", value)}
|
||||
/>
|
||||
<FieldString
|
||||
label={t("Owner")}
|
||||
fieldSpec={{doc: t("Owner ID of the style. Used by Mapbox or future style APIs.")}}
|
||||
data-wd-key="modal:settings.owner"
|
||||
value={(this.props.mapStyle as any).owner}
|
||||
onChange={(value) => this.changeStyleProperty("owner", value)}
|
||||
/>
|
||||
<Block label={t("Sprite URL")} fieldSpec={latest.$root.sprite} data-wd-key="modal:settings.sprite">
|
||||
<FieldJson
|
||||
lintType="json"
|
||||
value={this.props.mapStyle.sprite as any}
|
||||
onChange={(value) => this.changeStyleProperty("sprite", value)}
|
||||
/>
|
||||
</Block>
|
||||
</Block>
|
||||
|
||||
<FieldUrl
|
||||
label={t("Glyphs URL")}
|
||||
fieldSpec={latest.$root.glyphs}
|
||||
data-wd-key="modal:settings.glyphs"
|
||||
value={this.props.mapStyle.glyphs as string}
|
||||
onChange={(value) => this.changeStyleProperty("glyphs", value)}
|
||||
/>
|
||||
<FieldUrl
|
||||
label={t("Glyphs URL")}
|
||||
fieldSpec={latest.$root.glyphs}
|
||||
data-wd-key="modal:settings.glyphs"
|
||||
value={props.mapStyle.glyphs as string}
|
||||
onChange={(value) => changeStyleProperty("glyphs", value)}
|
||||
/>
|
||||
|
||||
<FieldString
|
||||
label={fsa.maputnik.maptiler_access_token.label}
|
||||
fieldSpec={fsa.maputnik.maptiler_access_token}
|
||||
data-wd-key="modal:settings.maputnik:openmaptiles_access_token"
|
||||
value={metadata["maputnik:openmaptiles_access_token"]}
|
||||
onChange={(value) => onChangeMetadataProperty("maputnik:openmaptiles_access_token", value)}
|
||||
/>
|
||||
<FieldString
|
||||
label={fsa.maputnik.maptiler_access_token.label}
|
||||
fieldSpec={fsa.maputnik.maptiler_access_token}
|
||||
data-wd-key="modal:settings.maputnik:openmaptiles_access_token"
|
||||
value={metadata["maputnik:openmaptiles_access_token"]}
|
||||
onChange={(value) => onChangeMetadataProperty("maputnik:openmaptiles_access_token", value)}
|
||||
/>
|
||||
|
||||
<FieldString
|
||||
label={fsa.maputnik.thunderforest_access_token.label}
|
||||
fieldSpec={fsa.maputnik.thunderforest_access_token}
|
||||
data-wd-key="modal:settings.maputnik:thunderforest_access_token"
|
||||
value={metadata["maputnik:thunderforest_access_token"]}
|
||||
onChange={(value) => onChangeMetadataProperty("maputnik:thunderforest_access_token", value)}
|
||||
/>
|
||||
<FieldString
|
||||
label={fsa.maputnik.thunderforest_access_token.label}
|
||||
fieldSpec={fsa.maputnik.thunderforest_access_token}
|
||||
data-wd-key="modal:settings.maputnik:thunderforest_access_token"
|
||||
value={metadata["maputnik:thunderforest_access_token"]}
|
||||
onChange={(value) => onChangeMetadataProperty("maputnik:thunderforest_access_token", value)}
|
||||
/>
|
||||
|
||||
<FieldString
|
||||
label={fsa.maputnik.stadia_access_token.label}
|
||||
fieldSpec={fsa.maputnik.stadia_access_token}
|
||||
data-wd-key="modal:settings.maputnik:stadia_access_token"
|
||||
value={metadata["maputnik:stadia_access_token"]}
|
||||
onChange={(value) => onChangeMetadataProperty("maputnik:stadia_access_token", value)}
|
||||
/>
|
||||
<FieldString
|
||||
label={fsa.maputnik.stadia_access_token.label}
|
||||
fieldSpec={fsa.maputnik.stadia_access_token}
|
||||
data-wd-key="modal:settings.maputnik:stadia_access_token"
|
||||
value={metadata["maputnik:stadia_access_token"]}
|
||||
onChange={(value) => onChangeMetadataProperty("maputnik:stadia_access_token", value)}
|
||||
/>
|
||||
|
||||
<FieldString
|
||||
label={fsa.maputnik.locationiq_access_token.label}
|
||||
fieldSpec={fsa.maputnik.locationiq_access_token}
|
||||
data-wd-key="modal:settings.maputnik:locationiq_access_token"
|
||||
value={metadata["maputnik:locationiq_access_token"]}
|
||||
onChange={(value) => onChangeMetadataProperty("maputnik:locationiq_access_token", value)}
|
||||
/>
|
||||
<FieldString
|
||||
label={fsa.maputnik.locationiq_access_token.label}
|
||||
fieldSpec={fsa.maputnik.locationiq_access_token}
|
||||
data-wd-key="modal:settings.maputnik:locationiq_access_token"
|
||||
value={metadata["maputnik:locationiq_access_token"]}
|
||||
onChange={(value) => onChangeMetadataProperty("maputnik:locationiq_access_token", value)}
|
||||
/>
|
||||
|
||||
<FieldArray
|
||||
label={t("Center")}
|
||||
fieldSpec={latest.$root.center}
|
||||
length={2}
|
||||
type="number"
|
||||
value={mapStyle.center || []}
|
||||
default={[0, 0]}
|
||||
onChange={(value) => this.changeStyleProperty("center", value)}
|
||||
/>
|
||||
<FieldArray
|
||||
label={t("Center")}
|
||||
fieldSpec={latest.$root.center}
|
||||
length={2}
|
||||
type="number"
|
||||
value={mapStyle.center || []}
|
||||
default={[0, 0]}
|
||||
onChange={(value) => changeStyleProperty("center", value)}
|
||||
/>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Zoom")}
|
||||
fieldSpec={latest.$root.zoom}
|
||||
value={mapStyle.zoom}
|
||||
default={0}
|
||||
onChange={(value) => this.changeStyleProperty("zoom", value)}
|
||||
/>
|
||||
<FieldNumber
|
||||
label={t("Zoom")}
|
||||
data-wd-key="modal:settings.zoom"
|
||||
fieldSpec={latest.$root.zoom}
|
||||
value={mapStyle.zoom}
|
||||
default={0}
|
||||
onChange={(value) => changeStyleProperty("zoom", value)}
|
||||
/>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Bearing")}
|
||||
fieldSpec={latest.$root.bearing}
|
||||
value={mapStyle.bearing}
|
||||
default={latest.$root.bearing.default}
|
||||
onChange={(value) => this.changeStyleProperty("bearing", value)}
|
||||
/>
|
||||
<FieldNumber
|
||||
label={t("Bearing")}
|
||||
data-wd-key="modal:settings.bearing"
|
||||
fieldSpec={latest.$root.bearing}
|
||||
value={mapStyle.bearing}
|
||||
default={latest.$root.bearing.default}
|
||||
onChange={(value) => changeStyleProperty("bearing", value)}
|
||||
/>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Pitch")}
|
||||
fieldSpec={latest.$root.pitch}
|
||||
value={mapStyle.pitch}
|
||||
default={latest.$root.pitch.default}
|
||||
onChange={(value) => this.changeStyleProperty("pitch", value)}
|
||||
/>
|
||||
<FieldNumber
|
||||
label={t("Pitch")}
|
||||
data-wd-key="modal:settings.pitch"
|
||||
fieldSpec={latest.$root.pitch}
|
||||
value={mapStyle.pitch}
|
||||
default={latest.$root.pitch.default}
|
||||
onChange={(value) => changeStyleProperty("pitch", value)}
|
||||
/>
|
||||
|
||||
<FieldEnum
|
||||
label={t("Light anchor")}
|
||||
fieldSpec={latest.light.anchor}
|
||||
name="light-anchor"
|
||||
value={light.anchor as string}
|
||||
options={Object.keys(latest.light.anchor.values)}
|
||||
default={latest.light.anchor.default}
|
||||
onChange={(value) => this.changeLightProperty("anchor", value)}
|
||||
/>
|
||||
<FieldEnum
|
||||
label={t("Light anchor")}
|
||||
fieldSpec={latest.light.anchor}
|
||||
name="light-anchor"
|
||||
value={light.anchor as string}
|
||||
options={Object.keys(latest.light.anchor.values)}
|
||||
default={latest.light.anchor.default}
|
||||
onChange={(value) => changeLightProperty("anchor", value)}
|
||||
/>
|
||||
|
||||
<FieldColor
|
||||
label={t("Light color")}
|
||||
fieldSpec={latest.light.color}
|
||||
value={light.color as string}
|
||||
default={latest.light.color.default}
|
||||
onChange={(value) => this.changeLightProperty("color", value)}
|
||||
/>
|
||||
<FieldColor
|
||||
label={t("Light color")}
|
||||
fieldSpec={latest.light.color}
|
||||
value={light.color as string}
|
||||
default={latest.light.color.default}
|
||||
onChange={(value) => changeLightProperty("color", value)}
|
||||
/>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Light intensity")}
|
||||
fieldSpec={latest.light.intensity}
|
||||
value={light.intensity as number}
|
||||
default={latest.light.intensity.default}
|
||||
onChange={(value) => this.changeLightProperty("intensity", value)}
|
||||
/>
|
||||
<FieldNumber
|
||||
label={t("Light intensity")}
|
||||
data-wd-key="modal:settings.light-intensity"
|
||||
fieldSpec={latest.light.intensity}
|
||||
value={light.intensity as number}
|
||||
default={latest.light.intensity.default}
|
||||
onChange={(value) => changeLightProperty("intensity", value)}
|
||||
/>
|
||||
|
||||
<FieldArray
|
||||
label={t("Light position")}
|
||||
fieldSpec={latest.light.position}
|
||||
type="number"
|
||||
length={latest.light.position.length}
|
||||
value={light.position as number[]}
|
||||
default={latest.light.position.default}
|
||||
onChange={(value) => this.changeLightProperty("position", value)}
|
||||
/>
|
||||
<FieldArray
|
||||
label={t("Light position")}
|
||||
fieldSpec={latest.light.position}
|
||||
type="number"
|
||||
length={latest.light.position.length}
|
||||
value={light.position as number[]}
|
||||
default={latest.light.position.default}
|
||||
onChange={(value) => changeLightProperty("position", value)}
|
||||
/>
|
||||
|
||||
<FieldString
|
||||
label={t("Terrain source")}
|
||||
fieldSpec={latest.terrain.source}
|
||||
data-wd-key="modal:settings.maputnik:terrain_source"
|
||||
value={terrain.source}
|
||||
onChange={(value) => this.changeTerrainProperty("source", value)}
|
||||
/>
|
||||
<FieldString
|
||||
label={t("Terrain source")}
|
||||
fieldSpec={latest.terrain.source}
|
||||
data-wd-key="modal:settings.maputnik:terrain_source"
|
||||
value={terrain.source}
|
||||
onChange={(value) => changeTerrainProperty("source", value)}
|
||||
/>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Terrain exaggeration")}
|
||||
fieldSpec={latest.terrain.exaggeration}
|
||||
value={terrain.exaggeration}
|
||||
default={latest.terrain.exaggeration.default}
|
||||
onChange={(value) => this.changeTerrainProperty("exaggeration", value)}
|
||||
/>
|
||||
<FieldNumber
|
||||
label={t("Terrain exaggeration")}
|
||||
data-wd-key="modal:settings.terrain-exaggeration"
|
||||
fieldSpec={latest.terrain.exaggeration}
|
||||
value={terrain.exaggeration}
|
||||
default={latest.terrain.exaggeration.default}
|
||||
onChange={(value) => changeTerrainProperty("exaggeration", value)}
|
||||
/>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Transition delay")}
|
||||
fieldSpec={latest.transition.delay}
|
||||
value={transition.delay}
|
||||
default={latest.transition.delay.default}
|
||||
onChange={(value) => this.changeTransitionProperty("delay", value)}
|
||||
/>
|
||||
<FieldNumber
|
||||
label={t("Transition delay")}
|
||||
data-wd-key="modal:settings.transition-delay"
|
||||
fieldSpec={latest.transition.delay}
|
||||
value={transition.delay}
|
||||
default={latest.transition.delay.default}
|
||||
onChange={(value) => changeTransitionProperty("delay", value)}
|
||||
/>
|
||||
|
||||
<FieldNumber
|
||||
label={t("Transition duration")}
|
||||
fieldSpec={latest.transition.duration}
|
||||
value={transition.duration}
|
||||
default={latest.transition.duration.default}
|
||||
onChange={(value) => this.changeTransitionProperty("duration", value)}
|
||||
/>
|
||||
<FieldNumber
|
||||
label={t("Transition duration")}
|
||||
data-wd-key="modal:settings.transition-duration"
|
||||
fieldSpec={latest.transition.duration}
|
||||
value={transition.duration}
|
||||
default={latest.transition.duration.default}
|
||||
onChange={(value) => changeTransitionProperty("duration", value)}
|
||||
/>
|
||||
|
||||
<FieldSelect
|
||||
label={t("Projection")}
|
||||
data-wd-key="modal:settings.projection"
|
||||
options={[
|
||||
["", "Undefined"],
|
||||
["mercator", "Mercator"],
|
||||
["globe", "Globe"],
|
||||
["vertical-perspective", "Vertical Perspective"]
|
||||
]}
|
||||
value={projection?.type?.toString() || ""}
|
||||
onChange={(value) => this.changeProjectionType(value)}
|
||||
/>
|
||||
<FieldSelect
|
||||
label={t("Projection")}
|
||||
data-wd-key="modal:settings.projection"
|
||||
options={[
|
||||
["", "Undefined"],
|
||||
["mercator", "Mercator"],
|
||||
["globe", "Globe"],
|
||||
["vertical-perspective", "Vertical Perspective"]
|
||||
]}
|
||||
value={projection?.type?.toString() || ""}
|
||||
onChange={(value) => changeProjectionType(value)}
|
||||
/>
|
||||
|
||||
<FieldSelect
|
||||
label={fsa.maputnik.style_renderer.label}
|
||||
fieldSpec={fsa.maputnik.style_renderer}
|
||||
data-wd-key="modal:settings.maputnik:renderer"
|
||||
options={[
|
||||
["mlgljs", "MapLibreGL JS"],
|
||||
["ol", t("Open Layers (experimental)")],
|
||||
]}
|
||||
value={metadata["maputnik:renderer"] || "mlgljs"}
|
||||
onChange={(value) => onChangeMetadataProperty("maputnik:renderer", value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
}
|
||||
<FieldSelect
|
||||
label={fsa.maputnik.style_renderer.label}
|
||||
fieldSpec={fsa.maputnik.style_renderer}
|
||||
data-wd-key="modal:settings.maputnik:renderer"
|
||||
options={[
|
||||
["mlgljs", "MapLibreGL JS"],
|
||||
["ol", t("Open Layers (experimental)")],
|
||||
]}
|
||||
value={metadata["maputnik:renderer"] || "mlgljs"}
|
||||
onChange={(value) => onChangeMetadataProperty("maputnik:renderer", value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>;
|
||||
};
|
||||
|
||||
const ModalSettings = withTranslation()(ModalSettingsInternal);
|
||||
export default ModalSettings;
|
||||
export const ModalSettings = withTranslation()(ModalSettingsInternal);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { Trans, type WithTranslation, withTranslation } from "react-i18next";
|
||||
|
||||
import Modal from "./Modal";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
|
||||
type ModalShortcutsInternalProps = {
|
||||
@@ -10,129 +10,126 @@ type ModalShortcutsInternalProps = {
|
||||
} & WithTranslation;
|
||||
|
||||
|
||||
class ModalShortcutsInternal extends React.Component<ModalShortcutsInternalProps> {
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
const help = [
|
||||
{
|
||||
key: <kbd>?</kbd>,
|
||||
text: t("Shortcuts menu")
|
||||
},
|
||||
{
|
||||
key: <kbd>o</kbd>,
|
||||
text: t("Open modal")
|
||||
},
|
||||
{
|
||||
key: <kbd>e</kbd>,
|
||||
text: t("Export modal")
|
||||
},
|
||||
{
|
||||
key: <kbd>d</kbd>,
|
||||
text: t("Data Sources modal")
|
||||
},
|
||||
{
|
||||
key: <kbd>s</kbd>,
|
||||
text: t("Style Settings modal")
|
||||
},
|
||||
{
|
||||
key: <kbd>i</kbd>,
|
||||
text: t("Toggle inspect")
|
||||
},
|
||||
{
|
||||
key: <kbd>m</kbd>,
|
||||
text: t("Focus map")
|
||||
},
|
||||
{
|
||||
key: <kbd>!</kbd>,
|
||||
text: t("Debug modal")
|
||||
},
|
||||
];
|
||||
const ModalShortcutsInternal: React.FC<ModalShortcutsInternalProps> = (props) => {
|
||||
const t = props.t;
|
||||
const help = [
|
||||
{
|
||||
key: <kbd>?</kbd>,
|
||||
text: t("Shortcuts menu")
|
||||
},
|
||||
{
|
||||
key: <kbd>o</kbd>,
|
||||
text: t("Open modal")
|
||||
},
|
||||
{
|
||||
key: <kbd>e</kbd>,
|
||||
text: t("Export modal")
|
||||
},
|
||||
{
|
||||
key: <kbd>d</kbd>,
|
||||
text: t("Data Sources modal")
|
||||
},
|
||||
{
|
||||
key: <kbd>s</kbd>,
|
||||
text: t("Style Settings modal")
|
||||
},
|
||||
{
|
||||
key: <kbd>i</kbd>,
|
||||
text: t("Toggle inspect")
|
||||
},
|
||||
{
|
||||
key: <kbd>m</kbd>,
|
||||
text: t("Focus map")
|
||||
},
|
||||
{
|
||||
key: <kbd>!</kbd>,
|
||||
text: t("Debug modal")
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const mapShortcuts = [
|
||||
{
|
||||
key: <kbd>+</kbd>,
|
||||
text: t("Increase the zoom level by 1.",)
|
||||
},
|
||||
{
|
||||
key: <><kbd>Shift</kbd> + <kbd>+</kbd></>,
|
||||
text: t("Increase the zoom level by 2.",)
|
||||
},
|
||||
{
|
||||
key: <kbd>-</kbd>,
|
||||
text: t("Decrease the zoom level by 1.",)
|
||||
},
|
||||
{
|
||||
key: <><kbd>Shift</kbd> + <kbd>-</kbd></>,
|
||||
text: t("Decrease the zoom level by 2.",)
|
||||
},
|
||||
{
|
||||
key: <kbd>Up</kbd>,
|
||||
text: t("Pan up by 100 pixels.",)
|
||||
},
|
||||
{
|
||||
key: <kbd>Down</kbd>,
|
||||
text: t("Pan down by 100 pixels.",)
|
||||
},
|
||||
{
|
||||
key: <kbd>Left</kbd>,
|
||||
text: t("Pan left by 100 pixels.",)
|
||||
},
|
||||
{
|
||||
key: <kbd>Right</kbd>,
|
||||
text: t("Pan right by 100 pixels.",)
|
||||
},
|
||||
{
|
||||
key: <><kbd>Shift</kbd> + <kbd>Right</kbd></>,
|
||||
text: t("Increase the rotation by 15 degrees.",)
|
||||
},
|
||||
{
|
||||
key: <><kbd>Shift</kbd> + <kbd>Left</kbd></>,
|
||||
text: t("Decrease the rotation by 15 degrees.")
|
||||
},
|
||||
{
|
||||
key: <><kbd>Shift</kbd> + <kbd>Up</kbd></>,
|
||||
text: t("Increase the pitch by 10 degrees.")
|
||||
},
|
||||
{
|
||||
key: <><kbd>Shift</kbd> + <kbd>Down</kbd></>,
|
||||
text: t("Decrease the pitch by 10 degrees.")
|
||||
},
|
||||
];
|
||||
const mapShortcuts = [
|
||||
{
|
||||
key: <kbd>+</kbd>,
|
||||
text: t("Increase the zoom level by 1.",)
|
||||
},
|
||||
{
|
||||
key: <><kbd>Shift</kbd> + <kbd>+</kbd></>,
|
||||
text: t("Increase the zoom level by 2.",)
|
||||
},
|
||||
{
|
||||
key: <kbd>-</kbd>,
|
||||
text: t("Decrease the zoom level by 1.",)
|
||||
},
|
||||
{
|
||||
key: <><kbd>Shift</kbd> + <kbd>-</kbd></>,
|
||||
text: t("Decrease the zoom level by 2.",)
|
||||
},
|
||||
{
|
||||
key: <kbd>Up</kbd>,
|
||||
text: t("Pan up by 100 pixels.",)
|
||||
},
|
||||
{
|
||||
key: <kbd>Down</kbd>,
|
||||
text: t("Pan down by 100 pixels.",)
|
||||
},
|
||||
{
|
||||
key: <kbd>Left</kbd>,
|
||||
text: t("Pan left by 100 pixels.",)
|
||||
},
|
||||
{
|
||||
key: <kbd>Right</kbd>,
|
||||
text: t("Pan right by 100 pixels.",)
|
||||
},
|
||||
{
|
||||
key: <><kbd>Shift</kbd> + <kbd>Right</kbd></>,
|
||||
text: t("Increase the rotation by 15 degrees.",)
|
||||
},
|
||||
{
|
||||
key: <><kbd>Shift</kbd> + <kbd>Left</kbd></>,
|
||||
text: t("Decrease the rotation by 15 degrees.")
|
||||
},
|
||||
{
|
||||
key: <><kbd>Shift</kbd> + <kbd>Up</kbd></>,
|
||||
text: t("Increase the pitch by 10 degrees.")
|
||||
},
|
||||
{
|
||||
key: <><kbd>Shift</kbd> + <kbd>Down</kbd></>,
|
||||
text: t("Decrease the pitch by 10 degrees.")
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
return <Modal
|
||||
data-wd-key="modal:shortcuts"
|
||||
isOpen={this.props.isOpen}
|
||||
onOpenToggle={this.props.onOpenToggle}
|
||||
title={t("Shortcuts")}
|
||||
>
|
||||
<section className="maputnik-modal-section maputnik-modal-shortcuts">
|
||||
<p>
|
||||
<Trans t={t}>
|
||||
Press <code>ESC</code> to lose focus of any active elements, then press one of:
|
||||
</Trans>
|
||||
</p>
|
||||
<dl>
|
||||
{help.map((item, idx) => {
|
||||
return <div key={idx} className="maputnik-modal-shortcuts__shortcut">
|
||||
<dt key={"dt"+idx}>{item.key}</dt>
|
||||
<dd key={"dd"+idx}>{item.text}</dd>
|
||||
</div>;
|
||||
})}
|
||||
</dl>
|
||||
<p>{t("If the Map is in focused you can use the following shortcuts")}</p>
|
||||
<ul>
|
||||
{mapShortcuts.map((item, idx) => {
|
||||
return <li key={idx}>
|
||||
<span>{item.key}</span> {item.text}
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</Modal>;
|
||||
}
|
||||
}
|
||||
return <Modal
|
||||
data-wd-key="modal:shortcuts"
|
||||
isOpen={props.isOpen}
|
||||
onOpenToggle={props.onOpenToggle}
|
||||
title={t("Shortcuts")}
|
||||
>
|
||||
<section className="maputnik-modal-section maputnik-modal-shortcuts">
|
||||
<p>
|
||||
<Trans t={t}>
|
||||
Press <code>ESC</code> to lose focus of any active elements, then press one of:
|
||||
</Trans>
|
||||
</p>
|
||||
<dl>
|
||||
{help.map((item, idx) => {
|
||||
return <div key={idx} className="maputnik-modal-shortcuts__shortcut">
|
||||
<dt key={"dt"+idx}>{item.key}</dt>
|
||||
<dd key={"dd"+idx}>{item.text}</dd>
|
||||
</div>;
|
||||
})}
|
||||
</dl>
|
||||
<p>{t("If the Map is in focused you can use the following shortcuts")}</p>
|
||||
<ul>
|
||||
{mapShortcuts.map((item, idx) => {
|
||||
return <li key={idx}>
|
||||
<span>{item.key}</span> {item.text}
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
</Modal>;
|
||||
};
|
||||
|
||||
const ModalShortcuts = withTranslation()(ModalShortcutsInternal);
|
||||
export default ModalShortcuts;
|
||||
export const ModalShortcuts = withTranslation()(ModalShortcutsInternal);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import {MdAddCircleOutline, MdDelete} from "react-icons/md";
|
||||
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
|
||||
import type {GeoJSONSourceSpecification, RasterDEMSourceSpecification, RasterSourceSpecification, SourceSpecification, VectorSourceSpecification} from "maplibre-gl";
|
||||
import { type WithTranslation, withTranslation } from "react-i18next";
|
||||
|
||||
import Modal from "./Modal";
|
||||
import InputButton from "../InputButton";
|
||||
import FieldString from "../FieldString";
|
||||
import FieldSelect from "../FieldSelect";
|
||||
import ModalSourcesTypeEditor, { type EditorMode } from "./ModalSourcesTypeEditor";
|
||||
import { Modal } from "./Modal";
|
||||
import { InputButton } from "../InputButton";
|
||||
import { FieldString } from "../FieldString";
|
||||
import { FieldSelect } from "../FieldSelect";
|
||||
import { ModalSourcesTypeEditor, type EditorMode } from "./ModalSourcesTypeEditor";
|
||||
|
||||
import style from "../../libs/style";
|
||||
import { generateId } from "../../libs/style";
|
||||
import { deleteSource, addSource, changeSource } from "../../libs/source";
|
||||
import publicSources from "../../config/tilesets.json";
|
||||
import { type OnStyleChangedCallback, type StyleSpecificationWithId } from "../../libs/definitions";
|
||||
@@ -23,23 +23,21 @@ type PublicSourceProps = {
|
||||
onSelect(...args: unknown[]): unknown
|
||||
};
|
||||
|
||||
class PublicSource extends React.Component<PublicSourceProps> {
|
||||
render() {
|
||||
return <div className="maputnik-public-source">
|
||||
<InputButton
|
||||
className="maputnik-public-source-select"
|
||||
onClick={() => this.props.onSelect(this.props.id)}
|
||||
>
|
||||
<div className="maputnik-public-source-info">
|
||||
<p className="maputnik-public-source-name">{this.props.title}</p>
|
||||
<p className="maputnik-public-source-id">#{this.props.id}</p>
|
||||
</div>
|
||||
<span className="maputnik-space" />
|
||||
<MdAddCircleOutline />
|
||||
</InputButton>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
const PublicSource: React.FC<PublicSourceProps> = (props) => {
|
||||
return <div className="maputnik-public-source">
|
||||
<InputButton
|
||||
className="maputnik-public-source-select"
|
||||
onClick={() => props.onSelect(props.id)}
|
||||
>
|
||||
<div className="maputnik-public-source-info">
|
||||
<p className="maputnik-public-source-name">{props.title}</p>
|
||||
<p className="maputnik-public-source-id">#{props.id}</p>
|
||||
</div>
|
||||
<span className="maputnik-space" />
|
||||
<MdAddCircleOutline />
|
||||
</InputButton>
|
||||
</div>;
|
||||
};
|
||||
|
||||
function editorMode(source: SourceSpecification) {
|
||||
if(source.type === "raster") {
|
||||
@@ -79,197 +77,184 @@ type ActiveModalSourcesTypeEditorProps = {
|
||||
onChange(...args: unknown[]): unknown
|
||||
} & WithTranslation;
|
||||
|
||||
class ActiveModalSourcesTypeEditor extends React.Component<ActiveModalSourcesTypeEditorProps> {
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
return <div className="maputnik-active-source-type-editor">
|
||||
<div className="maputnik-active-source-type-editor-header">
|
||||
<span className="maputnik-active-source-type-editor-header-id">#{this.props.sourceId}</span>
|
||||
<span className="maputnik-space" />
|
||||
<InputButton
|
||||
aria-label={t("Remove '{{sourceId}}' source", {sourceId: this.props.sourceId})}
|
||||
className="maputnik-active-source-type-editor-header-delete"
|
||||
onClick={()=> this.props.onDelete(this.props.sourceId)}
|
||||
style={{backgroundColor: "transparent"}}
|
||||
>
|
||||
<MdDelete />
|
||||
</InputButton>
|
||||
</div>
|
||||
<div className="maputnik-active-source-type-editor-content">
|
||||
<ModalSourcesTypeEditor
|
||||
onChange={this.props.onChange}
|
||||
mode={editorMode(this.props.source)}
|
||||
source={this.props.source}
|
||||
/>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
const ActiveModalSourcesTypeEditor: React.FC<ActiveModalSourcesTypeEditorProps> = (props) => {
|
||||
const t = props.t;
|
||||
return <div className="maputnik-active-source-type-editor">
|
||||
<div className="maputnik-active-source-type-editor-header">
|
||||
<span className="maputnik-active-source-type-editor-header-id">#{props.sourceId}</span>
|
||||
<span className="maputnik-space" />
|
||||
<InputButton
|
||||
aria-label={t("Remove '{{sourceId}}' source", {sourceId: props.sourceId})}
|
||||
className="maputnik-active-source-type-editor-header-delete"
|
||||
onClick={()=> props.onDelete(props.sourceId)}
|
||||
style={{backgroundColor: "transparent"}}
|
||||
>
|
||||
<MdDelete />
|
||||
</InputButton>
|
||||
</div>
|
||||
<div className="maputnik-active-source-type-editor-content">
|
||||
<ModalSourcesTypeEditor
|
||||
onChange={props.onChange}
|
||||
mode={editorMode(props.source)}
|
||||
source={props.source}
|
||||
/>
|
||||
</div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
type AddSourceProps = {
|
||||
onAdd(...args: unknown[]): unknown
|
||||
} & WithTranslation;
|
||||
|
||||
type AddSourceState = {
|
||||
mode: EditorMode
|
||||
sourceId: string
|
||||
source: SourceSpecification
|
||||
};
|
||||
function defaultSource(mode: EditorMode, currentSource: SourceSpecification | undefined): SourceSpecification {
|
||||
const source = currentSource || {};
|
||||
const {protocol} = window.location;
|
||||
|
||||
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"),
|
||||
switch(mode) {
|
||||
case "pmtiles_vector": return {
|
||||
type: "vector",
|
||||
url: `${protocol}//localhost:3000/file.pmtiles`
|
||||
};
|
||||
}
|
||||
|
||||
defaultSource(mode: EditorMode): SourceSpecification {
|
||||
const source = (this.state || {}).source || {};
|
||||
const {protocol} = window.location;
|
||||
|
||||
switch(mode) {
|
||||
case "pmtiles_vector": return {
|
||||
type: "vector",
|
||||
url: `${protocol}//localhost:3000/file.pmtiles`
|
||||
};
|
||||
case "geojson_url": return {
|
||||
type: "geojson",
|
||||
data: `${protocol}//localhost:3000/geojson.json`
|
||||
};
|
||||
case "geojson_json": return {
|
||||
type: "geojson",
|
||||
cluster: (source as GeoJSONSourceSpecification).cluster || false,
|
||||
data: ""
|
||||
};
|
||||
case "tilejson_vector": return {
|
||||
type: "vector",
|
||||
url: (source as VectorSourceSpecification).url || `${protocol}//localhost:3000/tilejson.json`
|
||||
};
|
||||
case "tile_vector": return {
|
||||
type: "vector",
|
||||
tiles: (source as VectorSourceSpecification).tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.pbf`],
|
||||
minzoom: (source as VectorSourceSpecification).minzoom || 0,
|
||||
maxzoom: (source as VectorSourceSpecification).maxzoom || 14,
|
||||
scheme: (source as VectorSourceSpecification).scheme || "xyz"
|
||||
};
|
||||
case "tilejson_raster": return {
|
||||
type: "raster",
|
||||
url: (source as RasterSourceSpecification).url || `${protocol}//localhost:3000/tilejson.json`
|
||||
};
|
||||
case "tile_raster": return {
|
||||
type: "raster",
|
||||
tiles: (source as RasterSourceSpecification).tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.png`],
|
||||
minzoom: (source as RasterSourceSpecification).minzoom || 0,
|
||||
maxzoom: (source as RasterSourceSpecification).maxzoom || 14,
|
||||
scheme: (source as RasterSourceSpecification).scheme || "xyz",
|
||||
tileSize: (source as RasterSourceSpecification).tileSize || 512,
|
||||
};
|
||||
case "tilejson_raster-dem": return {
|
||||
type: "raster-dem",
|
||||
url: (source as RasterDEMSourceSpecification).url || `${protocol}//localhost:3000/tilejson.json`
|
||||
};
|
||||
case "tilexyz_raster-dem": return {
|
||||
type: "raster-dem",
|
||||
tiles: (source as RasterDEMSourceSpecification).tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.png`],
|
||||
minzoom: (source as RasterDEMSourceSpecification).minzoom || 0,
|
||||
maxzoom: (source as RasterDEMSourceSpecification).maxzoom || 14,
|
||||
tileSize: (source as RasterDEMSourceSpecification).tileSize || 512
|
||||
};
|
||||
case "image": return {
|
||||
type: "image",
|
||||
url: `${protocol}//localhost:3000/image.png`,
|
||||
coordinates: [
|
||||
[0,0],
|
||||
[0,0],
|
||||
[0,0],
|
||||
[0,0],
|
||||
],
|
||||
};
|
||||
case "video": return {
|
||||
type: "video",
|
||||
urls: [
|
||||
`${protocol}//localhost:3000/movie.mp4`
|
||||
],
|
||||
coordinates: [
|
||||
[0,0],
|
||||
[0,0],
|
||||
[0,0],
|
||||
[0,0],
|
||||
],
|
||||
};
|
||||
default: return {} as any;
|
||||
}
|
||||
}
|
||||
|
||||
onAdd = () => {
|
||||
const {source, sourceId} = this.state;
|
||||
this.props.onAdd(sourceId, source);
|
||||
};
|
||||
|
||||
onChangeSource = (source: SourceSpecification) => {
|
||||
this.setState({source});
|
||||
};
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
// Kind of a hack because the type changes, however maputnik has 1..n
|
||||
// options per type, for example
|
||||
//
|
||||
// - 'geojson' - 'GeoJSON (URL)' and 'GeoJSON (JSON)'
|
||||
// - 'raster' - 'Raster (TileJSON URL)' and 'Raster (XYZ URL)'
|
||||
//
|
||||
// So we just ignore the values entirely as they are self explanatory
|
||||
const sourceTypeFieldSpec = {
|
||||
doc: latest.source_vector.type.doc
|
||||
case "geojson_url": return {
|
||||
type: "geojson",
|
||||
data: `${protocol}//localhost:3000/geojson.json`
|
||||
};
|
||||
|
||||
return <div className="maputnik-add-source">
|
||||
<FieldString
|
||||
label={t("Source ID")}
|
||||
fieldSpec={{doc: t("Unique ID that identifies the source and is used in the layer to reference the source.")}}
|
||||
value={this.state.sourceId}
|
||||
onChange={(v: string) => this.setState({ sourceId: v})}
|
||||
data-wd-key="modal:sources.add.source_id"
|
||||
/>
|
||||
<FieldSelect
|
||||
label={t("Source Type")}
|
||||
fieldSpec={sourceTypeFieldSpec}
|
||||
options={[
|
||||
["geojson_json", t("GeoJSON (JSON)")],
|
||||
["geojson_url", t("GeoJSON (URL)")],
|
||||
["tilejson_vector", t("Vector (TileJSON URL)")],
|
||||
["tile_vector", t("Vector (Tile URLs)")],
|
||||
["tilejson_raster", t("Raster (TileJSON URL)")],
|
||||
["tile_raster", t("Raster (Tile URLs)")],
|
||||
["tilejson_raster-dem", t("Raster DEM (TileJSON URL)")],
|
||||
["tilexyz_raster-dem", t("Raster DEM (XYZ URLs)")],
|
||||
["pmtiles_vector", t("Vector (PMTiles)")],
|
||||
["image", t("Image")],
|
||||
["video", t("Video")],
|
||||
]}
|
||||
onChange={mode => this.setState({mode: mode as EditorMode, source: this.defaultSource(mode as EditorMode)})}
|
||||
value={this.state.mode as string}
|
||||
data-wd-key="modal:sources.add.source_type"
|
||||
/>
|
||||
<ModalSourcesTypeEditor
|
||||
onChange={this.onChangeSource}
|
||||
mode={this.state.mode}
|
||||
source={this.state.source}
|
||||
/>
|
||||
<InputButton
|
||||
className="maputnik-add-source-button"
|
||||
onClick={this.onAdd}
|
||||
data-wd-key="modal:sources.add.add_source"
|
||||
>
|
||||
{t("Add Source")}
|
||||
</InputButton>
|
||||
</div>;
|
||||
case "geojson_json": return {
|
||||
type: "geojson",
|
||||
cluster: (source as GeoJSONSourceSpecification).cluster || false,
|
||||
data: ""
|
||||
};
|
||||
case "tilejson_vector": return {
|
||||
type: "vector",
|
||||
url: (source as VectorSourceSpecification).url || `${protocol}//localhost:3000/tilejson.json`
|
||||
};
|
||||
case "tile_vector": return {
|
||||
type: "vector",
|
||||
tiles: (source as VectorSourceSpecification).tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.pbf`],
|
||||
minzoom: (source as VectorSourceSpecification).minzoom || 0,
|
||||
maxzoom: (source as VectorSourceSpecification).maxzoom || 14,
|
||||
scheme: (source as VectorSourceSpecification).scheme || "xyz"
|
||||
};
|
||||
case "tilejson_raster": return {
|
||||
type: "raster",
|
||||
url: (source as RasterSourceSpecification).url || `${protocol}//localhost:3000/tilejson.json`
|
||||
};
|
||||
case "tile_raster": return {
|
||||
type: "raster",
|
||||
tiles: (source as RasterSourceSpecification).tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.png`],
|
||||
minzoom: (source as RasterSourceSpecification).minzoom || 0,
|
||||
maxzoom: (source as RasterSourceSpecification).maxzoom || 14,
|
||||
scheme: (source as RasterSourceSpecification).scheme || "xyz",
|
||||
tileSize: (source as RasterSourceSpecification).tileSize || 512,
|
||||
};
|
||||
case "tilejson_raster-dem": return {
|
||||
type: "raster-dem",
|
||||
url: (source as RasterDEMSourceSpecification).url || `${protocol}//localhost:3000/tilejson.json`
|
||||
};
|
||||
case "tilexyz_raster-dem": return {
|
||||
type: "raster-dem",
|
||||
tiles: (source as RasterDEMSourceSpecification).tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.png`],
|
||||
minzoom: (source as RasterDEMSourceSpecification).minzoom || 0,
|
||||
maxzoom: (source as RasterDEMSourceSpecification).maxzoom || 14,
|
||||
tileSize: (source as RasterDEMSourceSpecification).tileSize || 512
|
||||
};
|
||||
case "image": return {
|
||||
type: "image",
|
||||
url: `${protocol}//localhost:3000/image.png`,
|
||||
coordinates: [
|
||||
[0,0],
|
||||
[0,0],
|
||||
[0,0],
|
||||
[0,0],
|
||||
],
|
||||
};
|
||||
case "video": return {
|
||||
type: "video",
|
||||
urls: [
|
||||
`${protocol}//localhost:3000/movie.mp4`
|
||||
],
|
||||
coordinates: [
|
||||
[0,0],
|
||||
[0,0],
|
||||
[0,0],
|
||||
[0,0],
|
||||
],
|
||||
};
|
||||
default: return {} as any;
|
||||
}
|
||||
}
|
||||
|
||||
const AddSource: React.FC<AddSourceProps> = (props) => {
|
||||
const [mode, setMode] = useState<EditorMode>("tilejson_vector");
|
||||
const [sourceId, setSourceId] = useState<string>(() => generateId());
|
||||
const [source, setSource] = useState<SourceSpecification>(() => defaultSource("tilejson_vector", undefined));
|
||||
|
||||
const onAdd = () => {
|
||||
props.onAdd(sourceId, source);
|
||||
};
|
||||
|
||||
const onChangeSource = (newSource: SourceSpecification) => {
|
||||
setSource(newSource);
|
||||
};
|
||||
|
||||
const t = props.t;
|
||||
// Kind of a hack because the type changes, however maputnik has 1..n
|
||||
// options per type, for example
|
||||
//
|
||||
// - 'geojson' - 'GeoJSON (URL)' and 'GeoJSON (JSON)'
|
||||
// - 'raster' - 'Raster (TileJSON URL)' and 'Raster (XYZ URL)'
|
||||
//
|
||||
// So we just ignore the values entirely as they are self explanatory
|
||||
const sourceTypeFieldSpec = {
|
||||
doc: latest.source_vector.type.doc
|
||||
};
|
||||
|
||||
return <div className="maputnik-add-source">
|
||||
<FieldString
|
||||
label={t("Source ID")}
|
||||
fieldSpec={{doc: t("Unique ID that identifies the source and is used in the layer to reference the source.")}}
|
||||
value={sourceId}
|
||||
onChange={(v: string) => setSourceId(v)}
|
||||
data-wd-key="modal:sources.add.source_id"
|
||||
/>
|
||||
<FieldSelect
|
||||
label={t("Source Type")}
|
||||
fieldSpec={sourceTypeFieldSpec}
|
||||
options={[
|
||||
["geojson_json", t("GeoJSON (JSON)")],
|
||||
["geojson_url", t("GeoJSON (URL)")],
|
||||
["tilejson_vector", t("Vector (TileJSON URL)")],
|
||||
["tile_vector", t("Vector (Tile URLs)")],
|
||||
["tilejson_raster", t("Raster (TileJSON URL)")],
|
||||
["tile_raster", t("Raster (Tile URLs)")],
|
||||
["tilejson_raster-dem", t("Raster DEM (TileJSON URL)")],
|
||||
["tilexyz_raster-dem", t("Raster DEM (XYZ URLs)")],
|
||||
["pmtiles_vector", t("Vector (PMTiles)")],
|
||||
["image", t("Image")],
|
||||
["video", t("Video")],
|
||||
]}
|
||||
onChange={newMode => {
|
||||
setMode(newMode as EditorMode);
|
||||
setSource(defaultSource(newMode as EditorMode, source));
|
||||
}}
|
||||
value={mode as string}
|
||||
data-wd-key="modal:sources.add.source_type"
|
||||
/>
|
||||
<ModalSourcesTypeEditor
|
||||
onChange={onChangeSource}
|
||||
mode={mode}
|
||||
source={source}
|
||||
/>
|
||||
<InputButton
|
||||
className="maputnik-add-source-button"
|
||||
onClick={onAdd}
|
||||
data-wd-key="modal:sources.add.add_source"
|
||||
>
|
||||
{t("Add Source")}
|
||||
</InputButton>
|
||||
</div>;
|
||||
};
|
||||
|
||||
type ModalSourcesInternalProps = {
|
||||
mapStyle: StyleSpecificationWithId
|
||||
isOpen: boolean
|
||||
@@ -277,71 +262,68 @@ type ModalSourcesInternalProps = {
|
||||
onStyleChanged: OnStyleChangedCallback
|
||||
} & WithTranslation;
|
||||
|
||||
class ModalSourcesInternal extends React.Component<ModalSourcesInternalProps> {
|
||||
stripTitle(source: SourceSpecification & {title?: string}): SourceSpecification {
|
||||
const strippedSource = {...source};
|
||||
delete strippedSource["title"];
|
||||
return strippedSource;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {t, mapStyle} = this.props;
|
||||
const i18nProps = {t, i18n: this.props.i18n, tReady: this.props.tReady};
|
||||
const activeSources = Object.keys(mapStyle.sources).map(sourceId => {
|
||||
const source = mapStyle.sources[sourceId];
|
||||
return <ActiveModalSourcesTypeEditor
|
||||
key={sourceId}
|
||||
sourceId={sourceId}
|
||||
source={source}
|
||||
onChange={(src: SourceSpecification) => this.props.onStyleChanged(changeSource(mapStyle, sourceId, src))}
|
||||
onDelete={() => this.props.onStyleChanged(deleteSource(mapStyle, sourceId))}
|
||||
{...i18nProps}
|
||||
/>;
|
||||
});
|
||||
|
||||
const tilesetOptions = Object.keys(publicSources).filter((sourceId: string) => !(sourceId in mapStyle.sources)).map((sourceId: string) => {
|
||||
const source = publicSources[sourceId as keyof typeof publicSources] as SourceSpecification & {title: string};
|
||||
return <PublicSource
|
||||
key={sourceId}
|
||||
id={sourceId}
|
||||
type={source.type}
|
||||
title={source.title}
|
||||
onSelect={() => this.props.onStyleChanged(addSource(mapStyle, sourceId, this.stripTitle(source)))}
|
||||
/>;
|
||||
});
|
||||
|
||||
return <Modal
|
||||
data-wd-key="modal:sources"
|
||||
isOpen={this.props.isOpen}
|
||||
onOpenToggle={this.props.onOpenToggle}
|
||||
title={t("Sources")}
|
||||
>
|
||||
<section className="maputnik-modal-section">
|
||||
<h1>{t("Active Sources")}</h1>
|
||||
{activeSources}
|
||||
</section>
|
||||
|
||||
<section className="maputnik-modal-section">
|
||||
<h1>{t("Choose Public Source")}</h1>
|
||||
<p>
|
||||
{t("Add one of the publicly available sources to your style.")}
|
||||
</p>
|
||||
<div className="maputnik-public-sources" style={{maxWidth: 500}}>
|
||||
{tilesetOptions}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="maputnik-modal-section">
|
||||
<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>
|
||||
<AddSource
|
||||
onAdd={(sourceId: string, source: SourceSpecification) => this.props.onStyleChanged(addSource(mapStyle, sourceId, source))}
|
||||
{...i18nProps}
|
||||
/>
|
||||
</section>
|
||||
</Modal>;
|
||||
}
|
||||
function stripTitle(source: SourceSpecification & {title?: string}): SourceSpecification {
|
||||
const strippedSource = {...source};
|
||||
delete strippedSource["title"];
|
||||
return strippedSource;
|
||||
}
|
||||
|
||||
const ModalSources = withTranslation()(ModalSourcesInternal);
|
||||
export default ModalSources;
|
||||
const ModalSourcesInternal: React.FC<ModalSourcesInternalProps> = (props) => {
|
||||
const {t, mapStyle} = props;
|
||||
const i18nProps = {t, i18n: props.i18n, tReady: props.tReady};
|
||||
const activeSources = Object.keys(mapStyle.sources).map(sourceId => {
|
||||
const source = mapStyle.sources[sourceId];
|
||||
return <ActiveModalSourcesTypeEditor
|
||||
key={sourceId}
|
||||
sourceId={sourceId}
|
||||
source={source}
|
||||
onChange={(src: SourceSpecification) => props.onStyleChanged(changeSource(mapStyle, sourceId, src))}
|
||||
onDelete={() => props.onStyleChanged(deleteSource(mapStyle, sourceId))}
|
||||
{...i18nProps}
|
||||
/>;
|
||||
});
|
||||
|
||||
const tilesetOptions = Object.keys(publicSources).filter((sourceId: string) => !(sourceId in mapStyle.sources)).map((sourceId: string) => {
|
||||
const source = publicSources[sourceId as keyof typeof publicSources] as SourceSpecification & {title: string};
|
||||
return <PublicSource
|
||||
key={sourceId}
|
||||
id={sourceId}
|
||||
type={source.type}
|
||||
title={source.title}
|
||||
onSelect={() => props.onStyleChanged(addSource(mapStyle, sourceId, stripTitle(source)))}
|
||||
/>;
|
||||
});
|
||||
|
||||
return <Modal
|
||||
data-wd-key="modal:sources"
|
||||
isOpen={props.isOpen}
|
||||
onOpenToggle={props.onOpenToggle}
|
||||
title={t("Sources")}
|
||||
>
|
||||
<section className="maputnik-modal-section">
|
||||
<h1>{t("Active Sources")}</h1>
|
||||
{activeSources}
|
||||
</section>
|
||||
|
||||
<section className="maputnik-modal-section">
|
||||
<h1>{t("Choose Public Source")}</h1>
|
||||
<p>
|
||||
{t("Add one of the publicly available sources to your style.")}
|
||||
</p>
|
||||
<div className="maputnik-public-sources" style={{maxWidth: 500}}>
|
||||
{tilesetOptions}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="maputnik-modal-section">
|
||||
<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>
|
||||
<AddSource
|
||||
onAdd={(sourceId: string, source: SourceSpecification) => props.onStyleChanged(addSource(mapStyle, sourceId, source))}
|
||||
{...i18nProps}
|
||||
/>
|
||||
</section>
|
||||
</Modal>;
|
||||
};
|
||||
|
||||
export const ModalSources = withTranslation()(ModalSourcesInternal);
|
||||
|
||||
@@ -3,14 +3,14 @@ import {latest} from "@maplibre/maplibre-gl-style-spec";
|
||||
import { type WithTranslation, withTranslation } from "react-i18next";
|
||||
import { type TFunction } from "i18next";
|
||||
|
||||
import Block from "../Block";
|
||||
import FieldUrl from "../FieldUrl";
|
||||
import FieldNumber from "../FieldNumber";
|
||||
import FieldSelect from "../FieldSelect";
|
||||
import FieldDynamicArray from "../FieldDynamicArray";
|
||||
import FieldArray from "../FieldArray";
|
||||
import FieldJson from "../FieldJson";
|
||||
import FieldCheckbox from "../FieldCheckbox";
|
||||
import { Block } from "../Block";
|
||||
import { FieldUrl } from "../FieldUrl";
|
||||
import { FieldNumber } from "../FieldNumber";
|
||||
import { FieldSelect } from "../FieldSelect";
|
||||
import { FieldDynamicArray } from "../FieldDynamicArray";
|
||||
import { FieldArray } from "../FieldArray";
|
||||
import { FieldJson } from "../FieldJson";
|
||||
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;
|
||||
@@ -24,23 +24,21 @@ type TileJSONSourceEditorProps = {
|
||||
} & WithTranslation;
|
||||
|
||||
|
||||
class TileJSONSourceEditor extends React.Component<TileJSONSourceEditorProps> {
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
return <div>
|
||||
<FieldUrl
|
||||
label={t("TileJSON URL")}
|
||||
fieldSpec={latest.source_vector.url}
|
||||
value={this.props.source.url}
|
||||
onChange={url => this.props.onChange({
|
||||
...this.props.source,
|
||||
url: url
|
||||
})}
|
||||
/>
|
||||
{this.props.children}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
const TileJSONSourceEditor: React.FC<TileJSONSourceEditorProps> = (props) => {
|
||||
const t = props.t;
|
||||
return <div>
|
||||
<FieldUrl
|
||||
label={t("TileJSON URL")}
|
||||
fieldSpec={latest.source_vector.url}
|
||||
value={props.source.url}
|
||||
onChange={url => props.onChange({
|
||||
...props.source,
|
||||
url: url
|
||||
})}
|
||||
/>
|
||||
{props.children}
|
||||
</div>;
|
||||
};
|
||||
|
||||
type TileURLSourceEditorProps = {
|
||||
source: {
|
||||
@@ -53,66 +51,63 @@ type TileURLSourceEditorProps = {
|
||||
children?: React.ReactNode
|
||||
} & WithTranslation;
|
||||
|
||||
class TileURLSourceEditor extends React.Component<TileURLSourceEditorProps> {
|
||||
changeTileUrls(tiles: string[]) {
|
||||
this.props.onChange({
|
||||
...this.props.source,
|
||||
const TileURLSourceEditor: React.FC<TileURLSourceEditorProps> = (props) => {
|
||||
function changeTileUrls(tiles: string[]) {
|
||||
props.onChange({
|
||||
...props.source,
|
||||
tiles,
|
||||
});
|
||||
}
|
||||
|
||||
renderTileUrls() {
|
||||
const tiles = this.props.source.tiles || [];
|
||||
function renderTileUrls() {
|
||||
const tiles = props.source.tiles || [];
|
||||
return <FieldDynamicArray
|
||||
label={this.props.t("Tile URL")}
|
||||
label={props.t("Tile URL")}
|
||||
fieldSpec={latest.source_vector.tiles}
|
||||
type="url"
|
||||
value={tiles}
|
||||
onChange={this.changeTileUrls.bind(this)}
|
||||
onChange={changeTileUrls}
|
||||
/>;
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
return <div>
|
||||
{this.renderTileUrls()}
|
||||
<FieldSelect
|
||||
label={t("Scheme Type")}
|
||||
fieldSpec={latest.source_vector.scheme}
|
||||
options={[
|
||||
["xyz", "xyz (Slippy map tilenames scheme)"],
|
||||
["tms", "tms (OSGeo spec scheme)"],
|
||||
]}
|
||||
onChange={scheme => this.props.onChange({
|
||||
...this.props.source,
|
||||
scheme
|
||||
})}
|
||||
value={this.props.source.scheme}
|
||||
data-wd-key="modal:sources.add.scheme_type"
|
||||
/>
|
||||
<FieldNumber
|
||||
label={t("Min Zoom")}
|
||||
fieldSpec={latest.source_vector.minzoom}
|
||||
value={this.props.source.minzoom || 0}
|
||||
onChange={minzoom => this.props.onChange({
|
||||
...this.props.source,
|
||||
minzoom: minzoom
|
||||
})}
|
||||
/>
|
||||
<FieldNumber
|
||||
label={t("Max Zoom")}
|
||||
fieldSpec={latest.source_vector.maxzoom}
|
||||
value={this.props.source.maxzoom || 22}
|
||||
onChange={maxzoom => this.props.onChange({
|
||||
...this.props.source,
|
||||
maxzoom: maxzoom
|
||||
})}
|
||||
/>
|
||||
{this.props.children}
|
||||
</div>;
|
||||
|
||||
}
|
||||
}
|
||||
const t = props.t;
|
||||
return <div>
|
||||
{renderTileUrls()}
|
||||
<FieldSelect
|
||||
label={t("Scheme Type")}
|
||||
fieldSpec={latest.source_vector.scheme}
|
||||
options={[
|
||||
["xyz", "xyz (Slippy map tilenames scheme)"],
|
||||
["tms", "tms (OSGeo spec scheme)"],
|
||||
]}
|
||||
onChange={scheme => props.onChange({
|
||||
...props.source,
|
||||
scheme
|
||||
})}
|
||||
value={props.source.scheme}
|
||||
data-wd-key="modal:sources.add.scheme_type"
|
||||
/>
|
||||
<FieldNumber
|
||||
label={t("Min Zoom")}
|
||||
fieldSpec={latest.source_vector.minzoom}
|
||||
value={props.source.minzoom || 0}
|
||||
onChange={minzoom => props.onChange({
|
||||
...props.source,
|
||||
minzoom: minzoom
|
||||
})}
|
||||
/>
|
||||
<FieldNumber
|
||||
label={t("Max Zoom")}
|
||||
fieldSpec={latest.source_vector.maxzoom}
|
||||
value={props.source.maxzoom || 22}
|
||||
onChange={maxzoom => props.onChange({
|
||||
...props.source,
|
||||
maxzoom: maxzoom
|
||||
})}
|
||||
/>
|
||||
{props.children}
|
||||
</div>;
|
||||
};
|
||||
|
||||
const createCornerLabels: (t: TFunction) => { label: string, key: string }[] = (t) => ([
|
||||
{ label: t("Coord top left"), key: "top left" },
|
||||
@@ -129,45 +124,43 @@ type ImageSourceEditorProps = {
|
||||
onChange(...args: unknown[]): unknown
|
||||
} & WithTranslation;
|
||||
|
||||
class ImageSourceEditor extends React.Component<ImageSourceEditorProps> {
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
const changeCoord = (idx: number, val: [number, number]) => {
|
||||
const coordinates = this.props.source.coordinates.slice(0);
|
||||
coordinates[idx] = val;
|
||||
const ImageSourceEditor: React.FC<ImageSourceEditorProps> = (props) => {
|
||||
const t = props.t;
|
||||
const changeCoord = (idx: number, val: [number, number]) => {
|
||||
const coordinates = props.source.coordinates.slice(0);
|
||||
coordinates[idx] = val;
|
||||
|
||||
this.props.onChange({
|
||||
...this.props.source,
|
||||
coordinates,
|
||||
});
|
||||
};
|
||||
props.onChange({
|
||||
...props.source,
|
||||
coordinates,
|
||||
});
|
||||
};
|
||||
|
||||
return <div>
|
||||
<FieldUrl
|
||||
label={t("Image URL")}
|
||||
fieldSpec={latest.source_image.url}
|
||||
value={this.props.source.url}
|
||||
onChange={url => this.props.onChange({
|
||||
...this.props.source,
|
||||
url,
|
||||
})}
|
||||
/>
|
||||
{createCornerLabels(t).map(({label, key}, idx) => {
|
||||
return (
|
||||
<FieldArray
|
||||
label={label}
|
||||
key={key}
|
||||
length={2}
|
||||
type="number"
|
||||
value={this.props.source.coordinates[idx]}
|
||||
default={[0, 0]}
|
||||
onChange={(val: [number, number]) => changeCoord(idx, val)}
|
||||
/>
|
||||
);
|
||||
return <div>
|
||||
<FieldUrl
|
||||
label={t("Image URL")}
|
||||
fieldSpec={latest.source_image.url}
|
||||
value={props.source.url}
|
||||
onChange={url => props.onChange({
|
||||
...props.source,
|
||||
url,
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
/>
|
||||
{createCornerLabels(t).map(({label, key}, idx) => {
|
||||
return (
|
||||
<FieldArray
|
||||
label={label}
|
||||
key={key}
|
||||
length={2}
|
||||
type="number"
|
||||
value={props.source.coordinates[idx]}
|
||||
default={[0, 0]}
|
||||
onChange={(val: [number, number]) => changeCoord(idx, val)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>;
|
||||
};
|
||||
|
||||
type VideoSourceEditorProps = {
|
||||
source: {
|
||||
@@ -177,51 +170,49 @@ type VideoSourceEditorProps = {
|
||||
onChange(...args: unknown[]): unknown
|
||||
} & WithTranslation;
|
||||
|
||||
class VideoSourceEditor extends React.Component<VideoSourceEditorProps> {
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
const changeCoord = (idx: number, val: [number, number]) => {
|
||||
const coordinates = this.props.source.coordinates.slice(0);
|
||||
coordinates[idx] = val;
|
||||
const VideoSourceEditor: React.FC<VideoSourceEditorProps> = (props) => {
|
||||
const t = props.t;
|
||||
const changeCoord = (idx: number, val: [number, number]) => {
|
||||
const coordinates = props.source.coordinates.slice(0);
|
||||
coordinates[idx] = val;
|
||||
|
||||
this.props.onChange({
|
||||
...this.props.source,
|
||||
coordinates,
|
||||
});
|
||||
};
|
||||
props.onChange({
|
||||
...props.source,
|
||||
coordinates,
|
||||
});
|
||||
};
|
||||
|
||||
const changeUrls = (urls: string[]) => {
|
||||
this.props.onChange({
|
||||
...this.props.source,
|
||||
urls,
|
||||
});
|
||||
};
|
||||
const changeUrls = (urls: string[]) => {
|
||||
props.onChange({
|
||||
...props.source,
|
||||
urls,
|
||||
});
|
||||
};
|
||||
|
||||
return <div>
|
||||
<FieldDynamicArray
|
||||
label={t("Video URL")}
|
||||
fieldSpec={latest.source_video.urls}
|
||||
type="string"
|
||||
value={this.props.source.urls}
|
||||
default={[]}
|
||||
onChange={changeUrls}
|
||||
/>
|
||||
{createCornerLabels(t).map(({label, key}, idx) => {
|
||||
return (
|
||||
<FieldArray
|
||||
label={label}
|
||||
key={key}
|
||||
length={2}
|
||||
type="number"
|
||||
value={this.props.source.coordinates[idx]}
|
||||
default={[0, 0]}
|
||||
onChange={(val: [number, number]) => changeCoord(idx, val)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
return <div>
|
||||
<FieldDynamicArray
|
||||
label={t("Video URL")}
|
||||
fieldSpec={latest.source_video.urls}
|
||||
type="string"
|
||||
value={props.source.urls}
|
||||
default={[]}
|
||||
onChange={changeUrls}
|
||||
/>
|
||||
{createCornerLabels(t).map(({label, key}, idx) => {
|
||||
return (
|
||||
<FieldArray
|
||||
label={label}
|
||||
key={key}
|
||||
length={2}
|
||||
type="number"
|
||||
value={props.source.coordinates[idx]}
|
||||
default={[0, 0]}
|
||||
onChange={(val: [number, number]) => changeCoord(idx, val)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>;
|
||||
};
|
||||
|
||||
type GeoJSONSourceUrlEditorProps = {
|
||||
source: {
|
||||
@@ -230,20 +221,18 @@ type GeoJSONSourceUrlEditorProps = {
|
||||
onChange(...args: unknown[]): unknown
|
||||
} & WithTranslation;
|
||||
|
||||
class GeoJSONSourceUrlEditor extends React.Component<GeoJSONSourceUrlEditorProps> {
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
return <FieldUrl
|
||||
label={t("GeoJSON URL")}
|
||||
fieldSpec={latest.source_geojson.data}
|
||||
value={this.props.source.data}
|
||||
onChange={data => this.props.onChange({
|
||||
...this.props.source,
|
||||
data: data
|
||||
})}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
const GeoJSONSourceUrlEditor: React.FC<GeoJSONSourceUrlEditorProps> = (props) => {
|
||||
const t = props.t;
|
||||
return <FieldUrl
|
||||
label={t("GeoJSON URL")}
|
||||
fieldSpec={latest.source_geojson.data}
|
||||
value={props.source.data}
|
||||
onChange={data => props.onChange({
|
||||
...props.source,
|
||||
data: data
|
||||
})}
|
||||
/>;
|
||||
};
|
||||
|
||||
type GeoJSONSourceFieldJsonEditorProps = {
|
||||
source: {
|
||||
@@ -253,35 +242,33 @@ type GeoJSONSourceFieldJsonEditorProps = {
|
||||
onChange(...args: unknown[]): unknown
|
||||
} & WithTranslation;
|
||||
|
||||
class GeoJSONSourceFieldJsonEditor extends React.Component<GeoJSONSourceFieldJsonEditorProps> {
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
return <div>
|
||||
<Block label={t("GeoJSON")} fieldSpec={latest.source_geojson.data}>
|
||||
<FieldJson
|
||||
value={this.props.source.data}
|
||||
lintType="json"
|
||||
onChange={data => {
|
||||
this.props.onChange({
|
||||
...this.props.source,
|
||||
data,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Block>
|
||||
<FieldCheckbox
|
||||
label={t("Cluster")}
|
||||
value={this.props.source.cluster}
|
||||
onChange={cluster => {
|
||||
this.props.onChange({
|
||||
...this.props.source,
|
||||
cluster: cluster,
|
||||
const GeoJSONSourceFieldJsonEditor: React.FC<GeoJSONSourceFieldJsonEditorProps> = (props) => {
|
||||
const t = props.t;
|
||||
return <div>
|
||||
<Block label={t("GeoJSON")} fieldSpec={latest.source_geojson.data}>
|
||||
<FieldJson
|
||||
value={props.source.data}
|
||||
lintType="json"
|
||||
onChange={data => {
|
||||
props.onChange({
|
||||
...props.source,
|
||||
data,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
</Block>
|
||||
<FieldCheckbox
|
||||
label={t("Cluster")}
|
||||
value={props.source.cluster}
|
||||
onChange={cluster => {
|
||||
props.onChange({
|
||||
...props.source,
|
||||
cluster: cluster,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>;
|
||||
};
|
||||
|
||||
type PMTilesSourceEditorProps = {
|
||||
source: {
|
||||
@@ -291,24 +278,22 @@ type PMTilesSourceEditorProps = {
|
||||
children?: React.ReactNode
|
||||
} & WithTranslation;
|
||||
|
||||
class PMTilesSourceEditor extends React.Component<PMTilesSourceEditorProps> {
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
return <div>
|
||||
<FieldUrl
|
||||
label={t("PMTiles URL")}
|
||||
fieldSpec={latest.source_vector.url}
|
||||
value={this.props.source.url}
|
||||
data-wd-key="modal:sources.add.source_url"
|
||||
onChange={(url: string) => this.props.onChange({
|
||||
...this.props.source,
|
||||
url: url.startsWith("pmtiles://") ? url : `pmtiles://${url}`
|
||||
})}
|
||||
/>
|
||||
{this.props.children}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
const PMTilesSourceEditor: React.FC<PMTilesSourceEditorProps> = (props) => {
|
||||
const t = props.t;
|
||||
return <div>
|
||||
<FieldUrl
|
||||
label={t("PMTiles URL")}
|
||||
fieldSpec={latest.source_vector.url}
|
||||
value={props.source.url}
|
||||
data-wd-key="modal:sources.add.source_url"
|
||||
onChange={(url: string) => props.onChange({
|
||||
...props.source,
|
||||
url: url.startsWith("pmtiles://") ? url : `pmtiles://${url}`
|
||||
})}
|
||||
/>
|
||||
{props.children}
|
||||
</div>;
|
||||
};
|
||||
|
||||
type ModalSourcesTypeEditorInternalProps = {
|
||||
mode: EditorMode
|
||||
@@ -316,64 +301,61 @@ type ModalSourcesTypeEditorInternalProps = {
|
||||
onChange(...args: unknown[]): unknown
|
||||
} & WithTranslation;
|
||||
|
||||
class ModalSourcesTypeEditorInternal extends React.Component<ModalSourcesTypeEditorInternalProps> {
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
const commonProps = {
|
||||
source: this.props.source,
|
||||
onChange: this.props.onChange,
|
||||
t: this.props.t,
|
||||
i18n: this.props.i18n,
|
||||
tReady: this.props.tReady,
|
||||
};
|
||||
switch(this.props.mode) {
|
||||
case "geojson_url": return <GeoJSONSourceUrlEditor {...commonProps} />;
|
||||
case "geojson_json": return <GeoJSONSourceFieldJsonEditor {...commonProps} />;
|
||||
case "tilejson_vector": return <TileJSONSourceEditor {...commonProps} />;
|
||||
case "tile_vector": return <TileURLSourceEditor {...commonProps} />;
|
||||
case "tilejson_raster": return <TileJSONSourceEditor {...commonProps} />;
|
||||
case "tile_raster": return <TileURLSourceEditor {...commonProps}>
|
||||
<FieldNumber
|
||||
label={t("Tile Size")}
|
||||
fieldSpec={latest.source_raster.tileSize}
|
||||
onChange={tileSize => this.props.onChange({
|
||||
...this.props.source,
|
||||
tileSize: tileSize
|
||||
})}
|
||||
value={this.props.source.tileSize || latest.source_raster.tileSize.default}
|
||||
data-wd-key="modal:sources.add.tile_size"
|
||||
/>
|
||||
</TileURLSourceEditor>;
|
||||
case "tilejson_raster-dem": return <TileJSONSourceEditor {...commonProps} />;
|
||||
case "tilexyz_raster-dem": return <TileURLSourceEditor {...commonProps}>
|
||||
<FieldNumber
|
||||
label={t("Tile Size")}
|
||||
fieldSpec={latest.source_raster_dem.tileSize}
|
||||
onChange={tileSize => this.props.onChange({
|
||||
...this.props.source,
|
||||
tileSize: tileSize
|
||||
})}
|
||||
value={this.props.source.tileSize || latest.source_raster_dem.tileSize.default}
|
||||
data-wd-key="modal:sources.add.tile_size"
|
||||
/>
|
||||
<FieldSelect
|
||||
label={t("Encoding")}
|
||||
fieldSpec={latest.source_raster_dem.encoding}
|
||||
options={Object.keys(latest.source_raster_dem.encoding.values)}
|
||||
onChange={encoding => this.props.onChange({
|
||||
...this.props.source,
|
||||
encoding: encoding
|
||||
})}
|
||||
value={this.props.source.encoding || latest.source_raster_dem.encoding.default}
|
||||
/>
|
||||
</TileURLSourceEditor>;
|
||||
case "pmtiles_vector": return <PMTilesSourceEditor {...commonProps} />;
|
||||
case "image": return <ImageSourceEditor {...commonProps} />;
|
||||
case "video": return <VideoSourceEditor {...commonProps} />;
|
||||
default: return null;
|
||||
}
|
||||
const ModalSourcesTypeEditorInternal: React.FC<ModalSourcesTypeEditorInternalProps> = (props) => {
|
||||
const t = props.t;
|
||||
const commonProps = {
|
||||
source: props.source,
|
||||
onChange: props.onChange,
|
||||
t: props.t,
|
||||
i18n: props.i18n,
|
||||
tReady: props.tReady,
|
||||
};
|
||||
switch(props.mode) {
|
||||
case "geojson_url": return <GeoJSONSourceUrlEditor {...commonProps} />;
|
||||
case "geojson_json": return <GeoJSONSourceFieldJsonEditor {...commonProps} />;
|
||||
case "tilejson_vector": return <TileJSONSourceEditor {...commonProps} />;
|
||||
case "tile_vector": return <TileURLSourceEditor {...commonProps} />;
|
||||
case "tilejson_raster": return <TileJSONSourceEditor {...commonProps} />;
|
||||
case "tile_raster": return <TileURLSourceEditor {...commonProps}>
|
||||
<FieldNumber
|
||||
label={t("Tile Size")}
|
||||
fieldSpec={latest.source_raster.tileSize}
|
||||
onChange={tileSize => props.onChange({
|
||||
...props.source,
|
||||
tileSize: tileSize
|
||||
})}
|
||||
value={props.source.tileSize || latest.source_raster.tileSize.default}
|
||||
data-wd-key="modal:sources.add.tile_size"
|
||||
/>
|
||||
</TileURLSourceEditor>;
|
||||
case "tilejson_raster-dem": return <TileJSONSourceEditor {...commonProps} />;
|
||||
case "tilexyz_raster-dem": return <TileURLSourceEditor {...commonProps}>
|
||||
<FieldNumber
|
||||
label={t("Tile Size")}
|
||||
fieldSpec={latest.source_raster_dem.tileSize}
|
||||
onChange={tileSize => props.onChange({
|
||||
...props.source,
|
||||
tileSize: tileSize
|
||||
})}
|
||||
value={props.source.tileSize || latest.source_raster_dem.tileSize.default}
|
||||
data-wd-key="modal:sources.add.tile_size"
|
||||
/>
|
||||
<FieldSelect
|
||||
label={t("Encoding")}
|
||||
fieldSpec={latest.source_raster_dem.encoding}
|
||||
options={Object.keys(latest.source_raster_dem.encoding.values)}
|
||||
onChange={encoding => props.onChange({
|
||||
...props.source,
|
||||
encoding: encoding
|
||||
})}
|
||||
value={props.source.encoding || latest.source_raster_dem.encoding.default}
|
||||
/>
|
||||
</TileURLSourceEditor>;
|
||||
case "pmtiles_vector": return <PMTilesSourceEditor {...commonProps} />;
|
||||
case "image": return <ImageSourceEditor {...commonProps} />;
|
||||
case "video": return <VideoSourceEditor {...commonProps} />;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ModalSourcesTypeEditor = withTranslation()(ModalSourcesTypeEditorInternal);
|
||||
export default ModalSourcesTypeEditor;
|
||||
export const ModalSourcesTypeEditor = withTranslation()(ModalSourcesTypeEditorInternal);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user