Address review feedback

- Honor `basemapVisible` and `basemapOpacity` on the globe (CodeRabbit).
  `CesiumCanvas` selected only `basemapStyleUrl`, so hiding or fading the
  Background row left the globe opaque while the 2D map dimmed. Adds
  `applyBasemapAppearance()` and a live effect that re-styles the drawn layers
  rather than rebuilding them; a hybrid basemap's overlay fades with its
  imagery, matching the 2D map treating the pair as one background.
- Stop `basemapToCesiumImagery()` throwing on a malformed percent-escape
  (CodeRabbit). A URL keeps `%E0%A4` in its pathname but decoding it raises
  `URIError`, and the call sits inside a render, so a hand-edited project could
  take the pane down. Decoding now degrades to `{ kind: "default" }`.
- Draw the basemap once per mount (Claude). `ready` flips right after the mount
  effect's own draw, so the `[ready, basemapImagery]` effect immediately redrew
  the same descriptor — rebuilding providers, restarting the first tile
  requests, and flashing a hybrid basemap's overlay. Guarded on the applied
  descriptor.
- Qualify the `getCesiumIonToken` docstring (CodeRabbit): the globe draws the
  project basemap only when it has a raster form, and falls back to Ion World
  Imagery, or OpenStreetMap without a token, for the ones that do not.
- Align the token summaries in `docs/features.md` and `docs/roadmap.md` with
  that same fallback behaviour (CodeRabbit).

Claude-Session: https://claude.ai/code/session_01E9PXzrYZMVNBKhxFEU5ksY
This commit is contained in:
giswqs committed 2026-09-01 20:54:44 -04:00
1 parent a76cb07100
commit 1e62517ad2
8 files changed
+143 -11

No files matched your search

+1 -1
View File
@@ -40,7 +40,7 @@ kepler.gl, see the [Comparison](comparison.md).
- **Status bar readouts**: the pointer coordinate in decimal degrees, DMS, DDM, or UTM (click to cycle, or set it in Settings; UTM reuses the projection that draws the Gridlines grid, and falls back to degrees outside its valid latitude band), the ground elevation under the pointer, camera altitude above sea level as Google Earth-style **Eye alt** — scaled to the active celestial body, so it stays right on a Mars or Moon basemap — plus zoom, bearing, pitch, and the view bounding box
- **Pointer elevation** resolved from the map's own 3D terrain whenever a usable sample is available there (instant, offline, nothing leaves the device), and from a public elevation API when terrain has no value to give — after the pointer settles, cached per cell, Earth-only, off by default, and gated behind an explicit consent notice, so declining is what keeps the readout off the network
- **View menu**: viewport history navigation, a reset pitch and bearing control, a distinct north arrow, and View in Google Maps and View in Google Earth actions
- Multi-map grid that splits the workspace into a grid of synchronized map views, so you can compare basemaps, layers, or time steps side by side, with any **secondary** pane switchable to an optional CesiumJS 3D globe via its 2D/3D toggle — the primary map is always MapLibre (camera-synced with the 2D maps, and drawing the project's own basemap; a Cesium Ion token is optional and adds terrain — see [Optional 3D globe credentials](getting-started.md#optional-3d-globe-credentials-cesium-ion))
- Multi-map grid that splits the workspace into a grid of synchronized map views, so you can compare basemaps, layers, or time steps side by side, with any **secondary** pane switchable to an optional CesiumJS 3D globe via its 2D/3D toggle — the primary map is always MapLibre (camera-synced with the 2D maps, and drawing the project's own basemap; a Cesium Ion token is optional and adds terrain plus Ion World Imagery as the fallback for a basemap with no raster form — see [Optional 3D globe credentials](getting-started.md#optional-3d-globe-credentials-cesium-ion))
- Timelapse mode that animates annual cloudless basemaps — EOX Sentinel-2 and NASA GIBS providers (Landsat/WELD and MODIS land cover) — with a provider picker and legend
- Weather menu with live cloud and precipitation radar overlays (RainViewer), a Clouds overlay in the Controls menu, and a Google Earth-style sun position simulation that lights the scene for a given date and time
- Wikipedia knowledge cards: click a place on the map to pull up its Wikipedia summary and info card
+1 -1
View File
@@ -301,7 +301,7 @@
## v2.0: A 3D globe, planetary mapping, symbology interchange, and editable source layers
- [x] Switch any map pane to a **CesiumJS 3D globe** view that stays camera-synced with the 2D maps and mirrors the layer stack, adding a true photorealistic globe alongside the flat workspace (a Cesium Ion token is optional, and adds terrain)
- [x] Switch any map pane to a **CesiumJS 3D globe** view that stays camera-synced with the 2D maps and mirrors the layer stack, adding a true photorealistic globe alongside the flat workspace (a Cesium Ion token is optional, and adds terrain plus Ion World Imagery as the fallback for a basemap with no raster form)
- [x] Planetary mapping with the OpenPlanetaryMap basemap set (Mars and the Moon) plus USGS Astrogeology basemaps for Mercury, Venus, the Galilean moons, Titan, Pluto, and Charon reprojected to Web Mercator, a per-project ellipsoid whose radius drives distance/area/scale, and a planet switcher in the Layers panel, plus an expanded EOX Maps catalog and dark-mode-aware basemap theming
- [x] Import and export vector layer symbology as OGC SLD, QGIS QML, and Mapbox GL style JSON, so styles round-trip between GeoLibre, QGIS, and the Mapbox/MapLibre ecosystem
- [x] Edit vector layers and write the changes back to their source, covering GeoPackage and GeoJSON files as well as PostGIS database tables
+16 -1
View File
@@ -141,6 +141,20 @@ function toImagery(analogue: RasterAnalogue): CesiumBasemapImagery {
};
}
/**
* `decodeURIComponent` that reports failure instead of throwing. A URL keeps a
* malformed escape (`%E0%A4`) in its pathname quite happily, but decoding one
* throws `URIError` — and this runs inside a React render, so an unhandled throw
* would take the globe pane down instead of falling through to the default.
*/
function safeDecode(value: string): string | undefined {
try {
return decodeURIComponent(value);
} catch {
return undefined;
}
}
/**
* The raster analogue for a vector GL style URL, or undefined when the style is
* not one GeoLibre ships (a provider style, a user's own URL, a Mapbox style).
@@ -164,7 +178,8 @@ function vectorStyleAnalogue(styleUrl: string): RasterAnalogue | undefined {
// https://api.protomaps.com/styles/v5/<flavor>/<lang>.json?key=…
const segments = parsed.pathname.split("/").filter(Boolean);
const flavor = segments[0] === "styles" ? segments[2] : undefined;
return flavor ? PROTOMAPS_ANALOGUES[decodeURIComponent(flavor)] : undefined;
const decoded = flavor ? safeDecode(flavor) : undefined;
return decoded ? PROTOMAPS_ANALOGUES[decoded] : undefined;
}
return undefined;
}
+4 -2
View File
@@ -160,8 +160,10 @@ export function getMapboxAccessToken(env?: Record<string, string | undefined>):
* so a bare `CESIUM_TOKEN` entry works there too).
*
* The 3D-globe view itself does **not** need one: it draws the project basemap
* as its base imagery (see {@link basemapToCesiumImagery}), so an absent token
* costs terrain and the Ion imagery fallback, not the view.
* as its base imagery whenever that basemap has a raster form (see
* {@link basemapToCesiumImagery}), and falls back to Ion World Imagery — or, with
* no token, to OpenStreetMap — for the ones that do not. So an absent token
* costs terrain and the quality of that fallback, not the view.
*
* @param env - Environment record (defaults to the runtime environment);
* injectable for testing.
+46 -5
View File
@@ -2,12 +2,13 @@ import {
applyGroupEffects,
basemapToCesiumImagery,
useAppStore,
type CesiumBasemapImagery,
type GeoLibreLayer,
type MapViewState,
} from "@geolibre/core";
import type { ImageryLayer, Viewer } from "cesium";
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { applyBasemapImagery } from "./cesium-basemap";
import { applyBasemapAppearance, applyBasemapImagery } from "./cesium-basemap";
import { applyMapViewToCamera, isSameView, readMapViewFromCamera } from "./cesium-camera";
import { CesiumLayerSync } from "./cesium-layer-sync";
@@ -137,19 +138,50 @@ export const CesiumCanvas = memo(function CesiumCanvas({ viewId, ionToken }: Ces
// dependency-free effect re-run, mirroring paneLayersRef above.
const basemapImageryRef = useRef(basemapImagery);
basemapImageryRef.current = basemapImagery;
// The descriptor currently drawn. `ready` flips right after the mount effect's
// own draw, so the [ready, basemapImagery] effect below would otherwise redraw
// the same basemap immediately — rebuilding the providers, restarting the
// first tile requests, and flashing a hybrid basemap as its overlay is torn
// down and re-added.
const appliedImageryRef = useRef<CesiumBasemapImagery | null>(null);
// The basemap's visibility and opacity (the layer panel's Background row).
// Read through refs as well so the dependency-free mount effect can apply the
// current values without re-running.
const basemapVisible = useAppStore((s) => s.basemapVisible);
const basemapOpacity = useAppStore((s) => s.basemapOpacity);
const basemapVisibleRef = useRef(basemapVisible);
basemapVisibleRef.current = basemapVisible;
const basemapOpacityRef = useRef(basemapOpacity);
basemapOpacityRef.current = basemapOpacity;
/** Push the store's basemap visibility/opacity onto the drawn layers. */
function applyBasemapLook(): void {
applyBasemapAppearance(
baseImageryLayersRef.current,
basemapVisibleRef.current,
basemapOpacityRef.current,
);
}
// Replace the globe's base imagery with the current basemap.
function applyBasemap(): void {
const Cesium = cesiumRef.current;
const viewer = viewerRef.current;
if (!Cesium || !viewer || viewer.isDestroyed()) return;
const imagery = basemapImageryRef.current;
if (appliedImageryRef.current === imagery) return;
appliedImageryRef.current = imagery;
baseImageryLayersRef.current = applyBasemapImagery(
Cesium,
viewer,
baseImageryLayersRef.current,
basemapImageryRef.current,
imagery,
ionTokenRef.current?.trim() || undefined,
);
// Fresh layers start shown and fully opaque, so carry the store's
// visibility/opacity straight onto them.
applyBasemapLook();
}
// Push a store view into the camera and remember it as the expected echo.
@@ -314,8 +346,9 @@ export const CesiumCanvas = memo(function CesiumCanvas({ viewId, ionToken }: Ces
layerSyncRef.current?.destroy();
layerSyncRef.current = null;
// The viewer's destroy() below tears the imagery down with it; just drop
// the handles so a remount starts from an empty stack.
// the handles so a remount starts from an empty stack and redraws.
baseImageryLayersRef.current = [];
appliedImageryRef.current = null;
const viewer = viewerRef.current;
if (viewer && !viewer.isDestroyed()) viewer.destroy();
viewerRef.current = null;
@@ -325,14 +358,22 @@ export const CesiumCanvas = memo(function CesiumCanvas({ viewId, ionToken }: Ces
}, []);
// Re-draw the base imagery when the project basemap changes. `ready` re-runs
// this once the viewer exists; the mount effect's initial draw already covers
// the value captured at ready time.
// this once the viewer exists; applyBasemap's own guard makes that first run a
// no-op, since the mount effect already drew this descriptor.
useEffect(() => {
if (!ready) return;
applyBasemap();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, basemapImagery]);
// Hiding or fading the background is a live appearance change, so it re-styles
// the existing layers rather than rebuilding them.
useEffect(() => {
if (!ready) return;
applyBasemapLook();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, basemapVisible, basemapOpacity]);
// Reconcile the store layers (with this pane's overrides) onto the globe
// whenever they change. `ready` re-runs this once the viewer exists; the
// mount effect's initial sync already covers the value captured at ready time.
+24
View File
@@ -37,6 +37,30 @@ function templateProvider(
});
}
/**
* Apply the project's basemap visibility and opacity to the layers drawing it.
*
* These are separate store fields from the style URL — the layer panel's
* Background row hides the basemap and fades it with a slider — and the 2D map
* honours both (`MapController` sets the style layers' visibility and scales
* their paint opacity). A hybrid basemap's overlay fades with its imagery, which
* matches the 2D map treating the pair as one background.
*
* @param layers - The basemap layers from {@link applyBasemapImagery}.
* @param visible - The store's `basemapVisible`.
* @param opacity - The store's `basemapOpacity`, 0–1.
*/
export function applyBasemapAppearance(
layers: readonly ImageryLayer[],
visible: boolean,
opacity: number,
): void {
for (const layer of layers) {
layer.show = visible;
layer.alpha = opacity;
}
}
/**
* Draw `imagery` at the bottom of the globe's imagery stack, replacing whatever
* basemap is there.
+34 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { CesiumBasemapImagery } from "../packages/core/src/cesium-imagery";
import { applyBasemapImagery } from "../packages/map/src/cesium-basemap";
import { applyBasemapAppearance, applyBasemapImagery } from "../packages/map/src/cesium-basemap";
// Verifies that the project basemap lands at the bottom of the globe's imagery
// stack (below the data layers CesiumLayerSync appends), that a basemap change
@@ -178,3 +178,36 @@ describe("applyBasemapImagery", () => {
assert.equal((added[0] as FakeLayer).source, "osm");
});
});
describe("applyBasemapAppearance", () => {
it("applies the project's basemap visibility and opacity", () => {
const { Cesium, viewer } = makeFakes();
const added = applyBasemapImagery(Cesium, viewer, [], XYZ, undefined);
applyBasemapAppearance(added, false, 0.4);
assert.equal((added[0] as { show?: boolean }).show, false);
assert.equal((added[0] as { alpha?: number }).alpha, 0.4);
});
it("fades a hybrid basemap's overlay with its imagery", () => {
// The 2D map treats the imagery and its labels overlay as one background,
// so both follow the single Background row in the layer panel.
const { Cesium, viewer } = makeFakes();
const added = applyBasemapImagery(
Cesium,
viewer,
[],
{ ...XYZ, overlayTemplate: "https://tiles.example.com/labels/{z}/{x}/{y}.png" },
undefined,
);
applyBasemapAppearance(added, true, 0.25);
assert.equal(added.length, 2);
for (const layer of added) {
assert.equal((layer as { show?: boolean }).show, true);
assert.equal((layer as { alpha?: number }).alpha, 0.25);
}
});
});
+17
View File
@@ -102,6 +102,23 @@ describe("basemapToCesiumImagery", () => {
assert.deepEqual(basemapToCesiumImagery("not a url"), { kind: "default" });
});
it("defers for a malformed percent-escape in the flavor rather than throwing", () => {
// A URL keeps `%E0%A4` in its pathname, but decoding it throws URIError.
// This runs inside a render, so it has to degrade rather than blow up.
assert.deepEqual(
basemapToCesiumImagery("https://api.protomaps.com/styles/v5/%E0%A4/en.json?key=abc"),
{ kind: "default" },
);
});
it("still resolves a percent-encoded flavor that decodes cleanly", () => {
const encoded = basemapToCesiumImagery("https://api.protomaps.com/styles/v5/%64ark/en.json");
assert.deepEqual(
encoded,
basemapToCesiumImagery("https://api.protomaps.com/styles/v5/dark/en.json"),
);
});
it("falls back to the default basemap for a sentinel that no longer resolves", () => {
// Mirrors resolveMapStyle: an unknown geolibre:// sentinel must never reach
// the renderer as if it were a fetchable tile URL.