Compare commits

...

3 Commits

Author SHA1 Message Date
HarelM f88e1b04fa Initial commit. 2026-07-14 13:50:17 +03:00
Harel M f14eeae38b Increase coverage (#1997)
## Launch Checklist

This PR increases coverage by adding unit tests to lib folde, replace
the skipped end to end placeholder with actual tests and adds more end
to end tests.

This was mostly done by AI (Claude opus 4.8) and I reviewed it and
requested changes where needed.

 - [x] Briefly describe the changes in this PR.
 - [x] Link to related issues.
- [x] Include before/after visuals or gifs if this PR includes visual
changes.
 - [x] Write tests for all new functionality.
 - [x] Add an entry to `CHANGELOG.md` under the `## main` section.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-12 15:46:30 +03:00
Harel M 9c1499b805 Replace default export with named exports (#1998)
## Launch Checklist

See title,
Also removed some "_" from some file names.
This is a pure refactoring, no logic changes.


 - [x] Briefly describe the changes in this PR.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-07-12 14:07:28 +03:00
120 changed files with 6770 additions and 6034 deletions
-1
View File
@@ -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
+104 -1
View File
@@ -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).
+60
View File
@@ -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");
});
});
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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. */
+40
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": [
+29 -175
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+31 -35
View File
@@ -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);
+43 -47
View File
@@ -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 &&
<>
&nbsp;&mdash;&nbsp;
<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 &&
<>
&nbsp;&mdash;&nbsp;
<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
View File
@@ -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
View File
@@ -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>;
};
+2 -4
View File
@@ -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
View File
@@ -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>
);
}
};
+8 -10
View File
@@ -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} />;
};
+371
View File
@@ -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);
+24
View File
@@ -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
View File
@@ -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>
}
</>
);
};
+85
View File
@@ -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);
+3 -5
View File
@@ -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;
+3 -5
View File
@@ -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;
+3 -5
View File
@@ -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;
+3 -5
View File
@@ -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;
+3 -4
View File
@@ -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);
+1 -3
View File
@@ -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;
+3 -5
View File
@@ -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;
+3 -5
View File
@@ -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;
+5 -7
View File
@@ -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;
+3 -5
View File
@@ -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;
+2 -4
View File
@@ -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;
+3 -4
View File
@@ -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);
+3 -4
View File
@@ -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);
+3 -5
View File
@@ -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;
+3 -5
View File
@@ -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;
+3 -5
View File
@@ -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;
+3 -4
View File
@@ -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);
+3 -4
View File
@@ -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);
+4 -6
View File
@@ -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;
+3 -5
View File
@@ -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;
+4 -5
View File
@@ -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);
+3 -5
View File
@@ -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;
+4 -6
View File
@@ -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
View File
@@ -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);
+19 -22
View File
@@ -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);
+68
View File
@@ -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);
+1 -3
View File
@@ -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
View File
@@ -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");
});
+1 -1
View File
@@ -11,7 +11,7 @@ export type InputAutocompleteProps = {
"aria-label"?: string
};
export default function InputAutocomplete({
export function InputAutocomplete({
value,
options = [],
onChange = () => {},
+15 -17
View File
@@ -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>;
};
+21 -27
View File
@@ -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>;
};
+76 -77
View File
@@ -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>;
};
+120 -128
View File
@@ -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>;
};
+20 -22
View File
@@ -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}
/>;
}
}
};
+29 -33
View File
@@ -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>
);
};
+89 -89
View File
@@ -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);
+25 -27
View File
@@ -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
View File
@@ -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"]}
/>;
}
};
+17 -19
View File
@@ -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>;
};
+47 -49
View File
@@ -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>
);
};
+63 -82
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+17 -19
View File
@@ -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
View File
@@ -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;
+21 -23
View File
@@ -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>;
};
+23 -27
View File
@@ -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
View File
@@ -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>;
};
+38 -43
View File
@@ -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
View File
@@ -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);
+28 -30
View File
@@ -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>;
};
+5 -7
View File
@@ -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>;
};
+41 -44
View File
@@ -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>;
};
+9 -12
View File
@@ -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);
+44
View File
@@ -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}
/>;
};
+246
View File
@@ -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);
-384
View File
@@ -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;
-27
View File
@@ -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;
-94
View File
@@ -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;
-71
View File
@@ -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;
-48
View File
@@ -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}
/>;
}
}
-268
View File
@@ -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;
+35 -42
View File
@@ -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
View File
@@ -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);
+54 -57
View File
@@ -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);
+121 -125
View File
@@ -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);
+5 -6
View File
@@ -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);
+22 -25
View File
@@ -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
View File
@@ -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);
+224 -220
View File
@@ -3,17 +3,17 @@ import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
import type {LightSpecification, ProjectionSpecification, StyleSpecification, TerrainSpecification, TransitionSpecification} from "maplibre-gl";
import { type 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);
+119 -122
View File
@@ -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);
+252 -270
View File
@@ -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);
+251 -269
View File
@@ -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