From 5312d6159864e364a5166bc19407e78a436bb9e1 Mon Sep 17 00:00:00 2001 From: Harel M Date: Sun, 14 Sep 2025 12:48:29 +0300 Subject: [PATCH] Add globe support in Maputnik UI (#1379) ## Launch Checklist Add a small drop down to select mercator or globe. This isn't a fully covered field as one can set an expression there, but I believe this is good enough for most cases. Before: image After: image - [x] Briefly describe the changes in this PR. - [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: Birk Skyum Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + cypress/e2e/modals.cy.ts | 23 ++++++++ src/components/modals/ModalSettings.tsx | 76 ++++++++++++++++++------- 3 files changed, 78 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e9593f1..b9de7516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Add support for hillshade's color arrays and relief-color elevation expression - Change layers icons to make them a bit more distinct - Remove `@mdi` packages in favor of `react-icons` +- Add ability to control the projection of the map - either globe or mercator - Add markdown support for doc related to the style-spec fields - Added global state modal to allow editing the global state - _...Add new stuff here..._ diff --git a/cypress/e2e/modals.cy.ts b/cypress/e2e/modals.cy.ts index a80e85a2..d7f822e9 100644 --- a/cypress/e2e/modals.cy.ts +++ b/cypress/e2e/modals.cy.ts @@ -236,6 +236,29 @@ describe("modals", () => { ).shouldInclude({ "maputnik:locationiq_access_token": apiKey }); }); + it("style projection mercator", () => { + when.select("modal:settings.projection", "mercator"); + then( + get.styleFromLocalStorage().then((style) => style.projection) + ).shouldInclude({ type: "mercator" }); + }); + + it("style projection globe", () => { + when.select("modal:settings.projection", "globe"); + then( + get.styleFromLocalStorage().then((style) => style.projection) + ).shouldInclude({ type: "globe" }); + }); + + + it("style projection vertical-perspective", () => { + when.select("modal:settings.projection", "vertical-perspective"); + then( + get.styleFromLocalStorage().then((style) => style.projection) + ).shouldInclude({ type: "vertical-perspective" }); + + }); + it("style renderer", () => { cy.on("uncaught:exception", () => false); // this is due to the fact that this is an invalid style for openlayers when.select("modal:settings.maputnik:renderer", "ol"); diff --git a/src/components/modals/ModalSettings.tsx b/src/components/modals/ModalSettings.tsx index 43717332..5fa01cfd 100644 --- a/src/components/modals/ModalSettings.tsx +++ b/src/components/modals/ModalSettings.tsx @@ -1,6 +1,6 @@ import React from "react"; import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json"; -import type {LightSpecification, StyleSpecification, TerrainSpecification, TransitionSpecification} from "maplibre-gl"; +import type {LightSpecification, ProjectionSpecification, StyleSpecification, TerrainSpecification, TransitionSpecification} from "maplibre-gl"; import { type WithTranslation, withTranslation } from "react-i18next"; import FieldArray from "../FieldArray"; @@ -79,6 +79,24 @@ class ModalSettingsInternal extends React.Component }); } + changeProjectionType(value: any) { + const projection = { + ...this.props.mapStyle.projection, + } as ProjectionSpecification; + + if (value === undefined) { + delete projection.type; + } + else { + projection.type = value; + } + + this.props.onStyleChanged({ + ...this.props.mapStyle, + projection, + }); + } + changeStyleProperty(property: keyof StyleSpecification | "owner", value: any) { const changedStyle = { ...this.props.mapStyle, @@ -103,6 +121,7 @@ class ModalSettingsInternal extends React.Component 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; return fieldSpec={latest.$root.name} data-wd-key="modal:settings.name" value={this.props.mapStyle.name} - onChange={this.changeStyleProperty.bind(this, "name")} + onChange={(value) => this.changeStyleProperty("name", value)} /> this.changeStyleProperty("owner", value)} /> this.changeStyleProperty("sprite", value)} /> fieldSpec={latest.$root.glyphs} data-wd-key="modal:settings.glyphs" value={this.props.mapStyle.glyphs as string} - onChange={this.changeStyleProperty.bind(this, "glyphs")} + onChange={(value) => this.changeStyleProperty("glyphs", value)} /> fieldSpec={fsa.maputnik.maptiler_access_token} data-wd-key="modal:settings.maputnik:openmaptiles_access_token" value={metadata["maputnik:openmaptiles_access_token"]} - onChange={onChangeMetadataProperty.bind(this, "maputnik:openmaptiles_access_token")} + onChange={(value) => onChangeMetadataProperty("maputnik:openmaptiles_access_token", value)} /> fieldSpec={fsa.maputnik.thunderforest_access_token} data-wd-key="modal:settings.maputnik:thunderforest_access_token" value={metadata["maputnik:thunderforest_access_token"]} - onChange={onChangeMetadataProperty.bind(this, "maputnik:thunderforest_access_token")} + onChange={(value) => onChangeMetadataProperty("maputnik:thunderforest_access_token", value)} /> fieldSpec={fsa.maputnik.stadia_access_token} data-wd-key="modal:settings.maputnik:stadia_access_token" value={metadata["maputnik:stadia_access_token"]} - onChange={onChangeMetadataProperty.bind(this, "maputnik:stadia_access_token")} + onChange={(value) => onChangeMetadataProperty("maputnik:stadia_access_token", value)} /> fieldSpec={fsa.maputnik.locationiq_access_token} data-wd-key="modal:settings.maputnik:locationiq_access_token" value={metadata["maputnik:locationiq_access_token"]} - onChange={onChangeMetadataProperty.bind(this, "maputnik:locationiq_access_token")} + onChange={(value) => onChangeMetadataProperty("maputnik:locationiq_access_token", value)} /> type="number" value={mapStyle.center || []} default={[0, 0]} - onChange={this.changeStyleProperty.bind(this, "center")} + onChange={(value) => this.changeStyleProperty("center", value)} /> fieldSpec={latest.$root.zoom} value={mapStyle.zoom} default={0} - onChange={this.changeStyleProperty.bind(this, "zoom")} + onChange={(value) => this.changeStyleProperty("zoom", value)} /> fieldSpec={latest.$root.bearing} value={mapStyle.bearing} default={latest.$root.bearing.default} - onChange={this.changeStyleProperty.bind(this, "bearing")} + onChange={(value) => this.changeStyleProperty("bearing", value)} /> fieldSpec={latest.$root.pitch} value={mapStyle.pitch} default={latest.$root.pitch.default} - onChange={this.changeStyleProperty.bind(this, "pitch")} + onChange={(value) => this.changeStyleProperty("pitch", value)} /> value={light.anchor as string} options={Object.keys(latest.light.anchor.values)} default={latest.light.anchor.default} - onChange={this.changeLightProperty.bind(this, "anchor")} + onChange={(value) => this.changeLightProperty("anchor", value)} /> fieldSpec={latest.light.color} value={light.color as string} default={latest.light.color.default} - onChange={this.changeLightProperty.bind(this, "color")} + onChange={(value) => this.changeLightProperty("color", value)} /> fieldSpec={latest.light.intensity} value={light.intensity as number} default={latest.light.intensity.default} - onChange={this.changeLightProperty.bind(this, "intensity")} + onChange={(value) => this.changeLightProperty("intensity", value)} /> length={latest.light.position.length} value={light.position as number[]} default={latest.light.position.default} - onChange={this.changeLightProperty.bind(this, "position")} + onChange={(value) => this.changeLightProperty("position", value)} /> fieldSpec={latest.terrain.source} data-wd-key="modal:settings.maputnik:terrain_source" value={terrain.source} - onChange={this.changeTerrainProperty.bind(this, "source")} + onChange={(value) => this.changeTerrainProperty("source", value)} /> fieldSpec={latest.terrain.exaggeration} value={terrain.exaggeration} default={latest.terrain.exaggeration.default} - onChange={this.changeTerrainProperty.bind(this, "exaggeration")} + onChange={(value) => this.changeTerrainProperty("exaggeration", value)} /> fieldSpec={latest.transition.delay} value={transition.delay} default={latest.transition.delay.default} - onChange={this.changeTransitionProperty.bind(this, "delay")} + onChange={(value) => this.changeTransitionProperty("delay", value)} /> fieldSpec={latest.transition.duration} value={transition.duration} default={latest.transition.duration.default} - onChange={this.changeTransitionProperty.bind(this, "duration")} + onChange={(value) => this.changeTransitionProperty("duration", value)} + /> + + this.changeProjectionType(value)} /> ["ol", t("Open Layers (experimental)")], ]} value={metadata["maputnik:renderer"] || "mlgljs"} - onChange={onChangeMetadataProperty.bind(this, "maputnik:renderer")} + onChange={(value) => onChangeMetadataProperty("maputnik:renderer", value)} /> ;