Skip to content
3 changes: 3 additions & 0 deletions apps/geolibre-desktop/src/components/layout/AddDataDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { KIND_I18N_KEY } from "./add-data/constants";
import { ArcGISSource } from "./add-data/sources/ArcGISSource";
import { CadSource } from "./add-data/sources/CadSource";
import { CesiumIonSource } from "./add-data/sources/CesiumIonSource";
import { CzmlSource } from "./add-data/sources/CzmlSource";
import { DeckVizSource } from "./add-data/sources/DeckVizSource";
import { DelimitedTextSource } from "./add-data/sources/DelimitedTextSource";
import { GdbSource } from "./add-data/sources/GdbSource";
Expand Down Expand Up @@ -84,6 +85,8 @@ function renderSource(
return <XyzSource initialUrl={initialUrl} />;
case "cesium-ion":
return <CesiumIonSource />;
case "czml":
return <CzmlSource initialUrl={initialUrl} />;
case "wms":
return <WmsSource initialUrl={initialUrl} initialLayers={initialLayer} />;
case "csw":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export type KindI18nKey =
| "iceberg"
| "deckglViz"
| "video"
| "cesiumIon";
| "cesiumIon"
| "czml";

/**
* Maps each Add Data kind to its `addData.kind.<key>` i18n segment. The dialog
Expand Down Expand Up @@ -61,6 +62,7 @@ export const KIND_I18N_KEY: Record<AddDataKind, KindI18nKey> = {
"deckgl-viz": "deckglViz",
video: "video",
"cesium-ion": "cesiumIon",
czml: "czml",
};

export const DEFAULT_XYZ_URL =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { CZML_QUICK_PICKS, createCzmlLayer, parseCzml } from "@geolibre/core";
import { Button, Input, Label, Select } from "@geolibre/ui";
import { FileUp } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { openLocalDataFileWithFallback } from "../../../../lib/tauri-io";
import { errorMessage, layerNameFromPath } from "../helpers";
import { AddDataSourceForm, useAddDataSource } from "../shared";

/** Mode for supplying CZML data: remote URL endpoint or local file. */
export type CzmlMode = "url" | "file";

/**
* Add a CZML (Cesium Language) layer for dynamic 3D scenes (issue #2290).
*
* CZML describes time-dynamic 3D geospatial scenes, satellite orbits, vehicle
* paths, and sensory models on the Cesium globe with synchronized clock playback.
*
* @param props Component properties.
* @param props.initialUrl Optional prefilled URL from deep links.
*/
export function CzmlSource({ initialUrl }: { initialUrl?: string }) {
const { t } = useTranslation();
const [defaultName] = useState(() => t("addData.czml.defaultName"));
const source = useAddDataSource(defaultName);
const [czmlMode, setCzmlMode] = useState<CzmlMode>("url");
const [czmlUrl, setCzmlUrl] = useState(initialUrl ?? "");
const [selectedFile, setSelectedFile] = useState<{
path: string;
text: string;
} | null>(null);

const handleModeChange = (mode: CzmlMode) => {
setCzmlMode(mode);
setSelectedFile(null);
};

const handleChooseFile = async () => {
source.setError(null);
try {
const result = await openLocalDataFileWithFallback({
filters: [
{
name: "CZML / JSON",
extensions: ["czml", "json"],
},
],
accept: ".czml,.json",
readText: true,
});
if (!result) return;
if (!result.text) throw new Error(t("addData.czml.errorFileMissing"));
setSelectedFile({
path: result.path,
text: result.text,
});
source.setLayerName((current) =>
current.trim() && current !== defaultName
? current
: layerNameFromPath(result.path, defaultName),
);
} catch (err) {
source.setError(errorMessage(err, t("addData.czml.readError")));
}
};

const handleSubmit = source.runSubmit(() => {
const name = source.layerName.trim() || defaultName;

if (czmlMode === "file") {
if (!selectedFile) throw new Error(t("addData.czml.errorChooseFile"));
const doc = parseCzml(selectedFile.text);
if (!doc) throw new Error(t("addData.czml.errorInvalidCzml"));
source.addAndClose(
createCzmlLayer({
name,
data: doc,
sourcePath: selectedFile.path,
}),
);
return;
}

const trimmedUrl = czmlUrl.trim();
if (!trimmedUrl) throw new Error(t("addData.czml.errorUrl"));

source.addAndClose(
createCzmlLayer({
name,
url: trimmedUrl,
}),
);
});

// A quick pick adds straight from a button, so it cannot go through the
// form's `runSubmit`; mirror its error handling here. The sample is cloned so
// every layer owns its packets rather than sharing the exported constant.
const handleSelectQuickPick = (pick: (typeof CZML_QUICK_PICKS)[number]) => {
source.setLayerName(pick.name);
source.setError(null);
try {
source.addAndClose(
createCzmlLayer({
name: pick.name,
data: structuredClone(pick.data),
}),
);
} catch (err) {
source.setError(errorMessage(err, t("addData.shared.addError")));
}
};
Comment thread
giswqs marked this conversation as resolved.

return (
<AddDataSourceForm
layerName={source.layerName}
onLayerNameChange={source.setLayerName}
beforeLayerId={source.beforeLayerId}
onBeforeLayerIdChange={source.setBeforeLayerId}
onSubmit={handleSubmit}
error={source.error}
submitDisabled={source.isSubmitting}
>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="czml-source-mode">{t("addData.common.sourceType")}</Label>
<Select
id="czml-source-mode"
value={czmlMode}
onChange={(event) => handleModeChange(event.target.value as CzmlMode)}
>
<option value="url">{t("addData.czml.sourceModeUrl")}</option>
<option value="file">{t("addData.czml.sourceModeFile")}</option>
</Select>
</div>

{czmlMode === "url" ? (
<div className="space-y-1.5">
<Label htmlFor="czml-url">{t("addData.czml.url")}</Label>
<Input
id="czml-url"
placeholder="https://example.com/orbit.czml"
value={czmlUrl}
onChange={(event) => setCzmlUrl(event.target.value)}
/>
</div>
) : (
<div className="space-y-1.5">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quality (medium confidence): Quick picks here add the layer and close the dialog immediately (source.addAndClose(...)), overwriting whatever the user already typed into "Layer name" via source.setLayerName(pick.name) right beforehand, with no chance to review or edit before it's committed.

Every other source in this directory (e.g. CesiumIonSource.tsx's quick picks) only prefills the form fields on a quick-pick click; the user still has to press "Add layer" to submit. This component is the only one that skips that step, which is a bit surprising given the pattern established elsewhere, and means a custom name typed before clicking a sample is silently discarded.

If the one-click behavior is intentional (as the comment above suggests), consider at least not clobbering a name the user already customized, similar to the handleChooseFile guard just above (current.trim() && current !== defaultName ? current : ...).

<Label>{t("addData.czml.file")}</Label>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={handleChooseFile}>
<FileUp className="me-2 h-4 w-4" />
{t("addData.common.chooseFile")}
</Button>
<span className="text-xs text-muted-foreground truncate">
{selectedFile?.path ?? t("addData.common.noFileSelected")}
</span>
</div>
</div>
)}

<div className="space-y-1.5">
<Label>{t("addData.czml.quickPicks")}</Label>
<div className="flex flex-wrap gap-2">
{CZML_QUICK_PICKS.map((pick) => (
<Button
key={pick.name}
type="button"
variant="outline"
size="sm"
onClick={() => handleSelectQuickPick(pick)}
>
{pick.name}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">{t("addData.czml.hint")}</p>
</div>
</div>
</AddDataSourceForm>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export type AddDataKind =
| "iceberg"
| "deckgl-viz"
| "video"
| "cesium-ion";
| "cesium-ion"
| "czml";

/** A data source loadable either from a remote URL or a local file. */
export type FeedMode = "url" | "file";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ export function AddDataMenu({
// Ion assets load through Cesium only (issue #2290); on the 2D map the
// entry stays visible but disabled so the capability is discoverable.
"cesium-ion": { onSelect: () => onSetAddDataKind("cesium-ion"), disabled: !cesiumPrimary },
// CZML dynamic 3D scenes load through Cesium only (issue #2290).
czml: { onSelect: () => onSetAddDataKind("czml"), disabled: !cesiumPrimary },
// The glTF model opens the same deck.gl scenegraph builder, so it is
// gated the way "deckgl-viz" is.
"gltf-model": { onSelect: onAddGltfModel, disabled: !capabilities.customLayers },
Expand Down
12 changes: 10 additions & 2 deletions apps/geolibre-desktop/src/components/panels/StylePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type VectorStyleStop,
collectDiagramData,
geojsonHasZCoordinates,
isCzmlLayer,
isStyleLibraryTargetLayer,
parseJsonExpression,
pluginOwnsPaint,
Expand Down Expand Up @@ -1776,6 +1777,12 @@ export function StylePanel({
const isDeckVectorLayer = hasExternalDeckLayer(layer);
const isRasterTileLayer = layer.metadata.tileType === "raster";
const isThreeDTilesLayer = layer.type === "3d-tiles";
// A CZML scene reuses the `3d-tiles` type so the globe owns it, but
// `CesiumLayerSync` only toggles its visibility: no `Cesium3DTileStyle` is
// compiled for it and no feature filter reaches its entities, so the tileset
// symbology and quick-filter controls would be silent no-ops (#2290).
const isCzmlScene = isCzmlLayer(layer);
const hasTilesetSymbology = isThreeDTilesLayer && !isCzmlScene;
// An external plugin's MapLibre custom (WebGL) layer draws its own pixels and
// has no MapLibre paint properties, so every paint editor below would be inert
// for it (#1445). The plugin declares that with `paintMode: "plugin"`; the
Expand Down Expand Up @@ -1821,6 +1828,7 @@ export function StylePanel({
// `type` (a deck GeoJSON layer is still `"geojson"`), so testing the type
// first would let it through even though a custom layer accepts no filter.
!hasExternalDeckLayer(layer) &&
!isCzmlScene &&
(layer.type === "geojson" ||
layer.type === "vector-tiles" ||
layer.type === "mbtiles" ||
Expand Down Expand Up @@ -4984,7 +4992,7 @@ export function StylePanel({
still has to appear for a layer restored from one. */}
{hasNetcdfSymbology ? (
<NetcdfSymbologySection layer={layer} />
) : isThreeDTilesLayer ? (
) : hasTilesetSymbology ? (
// A tileset has no MapLibre paint properties, but the globe can
// classify its features from the same symbology every vector
// layer uses — `CesiumLayerSync` compiles the colour expression
Expand Down Expand Up @@ -5019,7 +5027,7 @@ export function StylePanel({
</ScrollArea>
<Separator />
<p className="p-2 text-[10px] text-muted-foreground">
{t("style.selectedLayerType", { type: layer.type })}
{t("style.selectedLayerType", { type: isCzmlScene ? "czml" : layer.type })}
</p>
</aside>
);
Expand Down
19 changes: 19 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,10 @@
"cesiumIon": {
"label": "Add Cesium Ion Asset",
"description": "Add a 3D Tiles tileset or imagery from Cesium Ion by asset id. Renders on the 3D globe with your Cesium Ion token."
},
"czml": {
"label": "Add CZML Dynamic 3D Layer",
"description": "Load a dynamic Cesium Language (CZML) document describing orbits, trajectories, vehicle paths, and time-varying 3D scenes on the globe."
}
},
"shared": {
Expand Down Expand Up @@ -420,6 +424,20 @@
"errorAltitude": "Enter the altitude offset as a number of metres.",
"tokenMissing": "No Cesium Ion token is configured. Add one in Settings → Environment variables (VITE_CESIUM_TOKEN) or the asset will fail to load."
},
"czml": {
"defaultName": "CZML Dynamic Scene",
"url": "CZML URL",
"sourceModeUrl": "URL",
"sourceModeFile": "Local file",
"file": "File",
"quickPicks": "Sample dynamic scenes",
"hint": "CZML streams or files define dynamic 3D positions, animations, and orbits synchronized with the globe clock.",
"errorUrl": "Enter a valid CZML endpoint URL.",
"errorChooseFile": "Select a CZML (.czml or .json) file to load.",
"errorFileMissing": "The selected file could not be read.",
"errorInvalidCzml": "The file does not contain a valid CZML document (expected JSON array or packet object).",
"readError": "Failed to read CZML data."
},
"xyz": {
"defaultName": "XYZ Layer",
"sampleLabel": "USGS imagery",
Expand Down Expand Up @@ -2508,6 +2526,7 @@
"video": "Video Layer",
"deckglViz": "Deck.gl Layer",
"cesiumIon": "Cesium Ion Asset",
"czml": "CZML Dynamic 3D Scene",
"gltfModel": "3D Model (glTF)",
"postgres": "PostgreSQL Layer",
"iceberg": "Apache Iceberg Layer"
Expand Down
6 changes: 6 additions & 0 deletions apps/geolibre-desktop/src/lib/ui-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ export const DATA_SOURCE_CATALOG: readonly DataSourceCatalogEntry[] = [
labelKey: "toolbar.layerType.cesiumIon",
tier: "advanced",
},
{
id: "czml",
section: "threeD",
labelKey: "toolbar.layerType.czml",
tier: "advanced",
},
{
id: "gltf-model",
section: "threeD",
Expand Down
1 change: 1 addition & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ Give it a directory meant for maps, not your home directory.
| `add_ogc_layer` | WMS and WMTS endpoints. |
| `add_3d_tiles_layer` | OGC 3D Tiles tilesets, by URL or Cesium Ion asset id. |
| `add_cesium_ion_layer` | Cesium Ion assets (tileset or imagery) by id; rendered by the 3D globe only. |
| `add_czml_layer` | A CZML (Cesium Language) dynamic 3D scene, by URL or inline packets; rendered by the 3D globe only. |

### Editing

Expand Down
1 change: 1 addition & 0 deletions docs/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ m.on_layer_change(lambda e: print("layers", e["layerIds"]))
| `add_raster(source, name=, bands=, colormap=, rescale=, array_args=, **style)` | Add a COG/GeoTIFF URL or path, or an xarray DataArray/Dataset (xarray needs `geolibre[raster]`). |
| `add_3d_tiles(url=None, name=, ion_asset_id=, altitude_offset=, request_headers=, **style)` | Add a 3D Tiles `tileset.json` URL, or a Cesium Ion tileset by asset id (3D globe only). |
| `add_cesium_ion(asset_id, name=, kind="3d-tiles", altitude_offset=, **style)` | Add a Cesium Ion asset by id: a 3D Tiles tileset or (`kind="imagery"`) an imagery layer. Renders on the 3D globe, with the app's Ion token. |
| `add_czml(url=None, name=, data=, source_path=, **style)` | Add a CZML (Cesium Language) dynamic 3D scene by URL or inline packets: orbits, vehicle tracks, moving models. Renders on the 3D globe, which follows the document's clock. |
| `add_video(urls, coordinates, name=, **style)` | Add a georeferenced video (four `[lng, lat]` corners). |
| `add_basemap(basemap)` | Set the background basemap. |
| `split_map(left_layers=None, right_layers=None, orientation=, position=, control_position=)` | Add a swipe (split-map) comparison slider between two layer sets. |
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/cesium-ion.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isCzmlLayer } from "./czml";
import { DEFAULT_LAYER_STYLE, type GeoLibreLayer } from "./types";

// Cesium Ion assets (issue #2290): 3D Tiles and imagery referenced by Ion
Expand Down Expand Up @@ -57,7 +58,7 @@ export function cesiumIonAssetKind(layer: Pick<GeoLibreLayer, "type">): CesiumIo
* `isCesiumSupportedLayerType`, for the Layers panel to badge on the 2D map.
*/
export function isCesiumOnlyLayer(layer: Pick<GeoLibreLayer, "source" | "metadata">): boolean {
return isCesiumIonLayer(layer);
return isCesiumIonLayer(layer) || isCzmlLayer(layer);
}

function newLayerId(): string {
Expand Down
Loading
Loading