Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 90 additions & 105 deletions packages/plugins/src/plugins/arcgis-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
HostedLayer,
VectorTileLayer,
} from "@esri/maplibre-arcgis";
import type { FeatureCollection } from "geojson";
import type maplibregl from "maplibre-gl";
import type { GeoLibreAppAPI } from "../types";

Expand Down Expand Up @@ -79,12 +80,22 @@ export async function addArcGISLayer(
app: GeoLibreAppAPI,
options: ArcGISLayerOptions,
): Promise<string> {
const input = getArcGISInput(options);

// A feature layer is just attributed vector data, so load it as a regular
// GeoJSON layer rather than an opaque external-native layer. That unlocks the
// host's full vector styling surface for it — labels (with uppercase/offset/
// rotation formatting), the attribute table, identify, symbology, and export —
// instead of only the fill/stroke paint an external-native layer exposes.
if (options.layerType === "feature") {
return addArcGISFeatureLayerAsGeoJson(app, options, input);
}

const map = app.getMap?.();
if (!map) {
throw new Error("The map is not ready.");
}

const input = getArcGISInput(options);
const arcgis = await import("@esri/maplibre-arcgis");
const hostedLayer = await createArcGISHostedLayer(arcgis, options, input);
const id = createArcGISLayerId();
Expand Down Expand Up @@ -133,10 +144,6 @@ async function createArcGISHostedLayer(
token: options.token?.trim() || undefined,
};

if (options.layerType === "feature") {
return createFallbackFeatureLayer(input, options);
}

return options.sourceType === "url"
? (arcgis.VectorTileLayer as typeof VectorTileLayer).fromUrl(
input,
Expand Down Expand Up @@ -204,55 +211,102 @@ function addArcGISRuntimeLayerToMap(
}
}

async function createFallbackFeatureLayer(
input: string,
/**
* Load an ArcGIS FeatureServer layer as a host-managed GeoJSON layer.
*
* The features are fetched up front (`/query?f=geojson`) and handed to the
* store's GeoJSON layer path, so the layer is a first-class vector layer with
* its attributes available — enabling labels and their formatting, the
* attribute table, identify, symbology, and export. Vector tile layers keep the
* external-native runtime path; only feature layers come through here.
*
* @param app - The host app API (used to fit the view to the layer extent).
* @param options - The ArcGIS layer options (source type, URL/item, token).
* @param input - The resolved service URL or portal item id from the options.
* @returns The new GeoLibre layer's id.
*/
async function addArcGISFeatureLayerAsGeoJson(
app: GeoLibreAppAPI,
options: ArcGISLayerOptions,
cause: unknown = undefined,
): Promise<ArcGISRuntimeLayer> {
input: string,
): Promise<string> {
const layerUrl =
options.sourceType === "url"
? await resolveFeatureLayerUrl(input, options, cause)
: await resolvePortalFeatureLayerUrl(input, options, cause);
? await resolveFeatureLayerUrl(input, options, undefined)
: await resolvePortalFeatureLayerUrl(input, options, undefined);
const layerInfo = await fetchArcGISJson<ArcGISFeatureLayerInfo>(
layerUrl,
options,
cause,
undefined,
);
if (!layerInfo.geometryType) {
throw new Error("The ArcGIS feature layer metadata is missing geometry type.", {
cause,
});
throw new Error(
"The ArcGIS feature layer metadata is missing geometry type.",
);
}

const sourceId = layerInfo.name || layerNameFromArcGISInput(layerUrl, "arcgis");
const styleLayerType = arcgisGeometryLayerType(layerInfo.geometryType);
const styleLayerId = `${sourceId}-layer`;
const queryUrl = appendArcGISParams(`${trimTrailingSlash(layerUrl)}/query`, {
f: "geojson",
outFields: "*",
returnGeometry: "true",
token: options.token?.trim(),
where: "1=1",
});
const geojson = await fetchArcGISGeoJson(queryUrl);
Comment thread
giswqs marked this conversation as resolved.
Outdated

return createStaticArcGISRuntimeLayer({
bounds: arcgisExtentToBounds(layerInfo.extent),
layers: [
{
id: styleLayerId,
source: sourceId,
type: styleLayerType,
paint: arcgisFallbackPaint(styleLayerType),
} as maplibregl.LayerSpecification,
],
sources: {
[sourceId]: {
type: "geojson",
data: queryUrl,
attribution: layerInfo.copyrightText || "ArcGIS Feature Service",
},
},
});
const name =
options.name?.trim() ||
layerInfo.name ||
layerNameFromArcGISInput(layerUrl, "ArcGIS Layer");
const id = useAppStore
.getState()
.addGeoJsonLayer(name, geojson, input, options.beforeLayerId ?? null);
Comment thread
giswqs marked this conversation as resolved.
Outdated
Comment thread
giswqs marked this conversation as resolved.
Outdated

const bounds = arcgisExtentToBounds(layerInfo.extent);
if (bounds) app.fitBounds?.(bounds);
return id;
}

/**
* Fetch and validate a GeoJSON FeatureCollection from an ArcGIS query URL.
*
* ArcGIS can answer a `f=geojson` request with a JSON error envelope rather than
* GeoJSON, so both the transport status and the payload shape are checked. A
* result truncated at the service's `maxRecordCount` is loaded as-is but warned
* about, so a partial attribute table or export is not mistaken for the full
* dataset.
*
* @param url - The fully-built `/query?f=geojson` request URL.
* @returns The parsed FeatureCollection.
*/
async function fetchArcGISGeoJson(url: string): Promise<FeatureCollection> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`ArcGIS feature query failed with ${response.status}.`);
}
const json = (await response.json()) as FeatureCollection & {
Comment thread
giswqs marked this conversation as resolved.
Outdated
error?: { message?: string };
exceededTransferLimit?: boolean;
};
if (json.error) {
throw new Error(json.error.message || "ArcGIS feature query failed.");
}
if (json.type !== "FeatureCollection" || !Array.isArray(json.features)) {
throw new Error(
"The ArcGIS feature layer did not return GeoJSON features.",
);
}
// ArcGIS caps a single query at the service's maxRecordCount and flags the
// shortfall with `exceededTransferLimit`. The partial data still loads (it is
// the same subset the previous URL-source path rendered), but the truncation
// is surfaced so the caller knows the layer is not the complete dataset.
if (json.exceededTransferLimit) {
console.warn(
`[GeoLibre] ArcGIS feature query was truncated at the service record ` +
`limit; loaded ${json.features.length} features (partial dataset).`,
);
}
Comment on lines +335 to +340

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.

Silent truncation is a meaningful UX regression

console.warn is the only signal when exceededTransferLimit is true, so users who add a service with more features than its maxRecordCount (often 1 000–2 000) silently get a partial layer with no in-product indication. In the old URL-backed GeoJSON source the same truncation happened, but the layer was dynamic (MapLibre re-fetched on demand). Now the truncated copy is permanently frozen in the Zustand store and appears complete in the attribute table and any export.

At minimum, either:

  • Surface a non-blocking toast/notification in the UI at the plugin level (if the app API exposes one), or
  • Throw a typed warning object that the Add Data dialog's error boundary can render distinctly from a hard error, so the layer still loads but the user sees "Loaded N features — service limit reached."

A comment on line 302 claims this is "the same subset the previous URL-source path rendered," but that framing misses the key difference: the subset is now static and stored.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Partially pushing back on the framing here. A MapLibre geojson source loads its data once and does not re-fetch on demand, so the previous URL-source path was equally static and equally capped at maxRecordCount — there is no loss of dynamism, and this change actually adds a truncation signal where there was none. A user-facing toast would be a nice improvement, but the plugin app API exposes no notification/toast hook, so surfacing one would require a broader API addition that is out of scope for this fix. Leaving this thread open for your call on whether to add that API separately; happy to do it in a follow-up if you'd like.

Comment thread
giswqs marked this conversation as resolved.
return json;
Comment thread
giswqs marked this conversation as resolved.
}

async function resolveFeatureLayerUrl(
Expand Down Expand Up @@ -340,42 +394,6 @@ async function fetchArcGISJson<T>(
return json;
}

function createStaticArcGISRuntimeLayer(args: {
bounds?: [number, number, number, number];
layers: maplibregl.LayerSpecification[];
sources: Record<string, maplibregl.SourceSpecification>;
}): ArcGISRuntimeLayer {
return {
get bounds() {
return args.bounds;
},
get layers() {
return args.layers;
},
get sources() {
return args.sources;
},
setSourceId(oldId: string, newId: string) {
args.sources[newId] = args.sources[oldId];
delete args.sources[oldId];
for (const layer of args.layers) {
if ("source" in layer && layer.source === oldId) {
layer.source = newId;
}
}
},
addSourcesAndLayersTo(map: maplibregl.Map) {
for (const [sourceId, source] of Object.entries(args.sources)) {
map.addSource(sourceId, source);
}
for (const layer of args.layers) {
map.addLayer(layer);
}
return this;
},
};
}

async function resolveArcGISLayerBounds(
input: string,
options: ArcGISLayerOptions,
Expand Down Expand Up @@ -485,39 +503,6 @@ function isGeoBounds(value: unknown): value is [number, number, number, number]
);
}

function arcgisGeometryLayerType(
geometryType: string,
): "circle" | "fill" | "line" {
if (geometryType === "esriGeometryPoint") return "circle";
if (geometryType === "esriGeometryMultipoint") return "circle";
if (geometryType === "esriGeometryPolyline") return "line";
return "fill";
}

function arcgisFallbackPaint(
layerType: "circle" | "fill" | "line",
): maplibregl.LayerSpecification["paint"] {
if (layerType === "circle") {
return {
"circle-color": DEFAULT_LAYER_STYLE.fillColor,
"circle-radius": DEFAULT_LAYER_STYLE.circleRadius,
"circle-stroke-color": DEFAULT_LAYER_STYLE.strokeColor,
"circle-stroke-width": DEFAULT_LAYER_STYLE.strokeWidth,
};
}
if (layerType === "line") {
return {
"line-color": DEFAULT_LAYER_STYLE.strokeColor,
"line-width": DEFAULT_LAYER_STYLE.strokeWidth,
};
}
return {
"fill-color": DEFAULT_LAYER_STYLE.fillColor,
"fill-opacity": DEFAULT_LAYER_STYLE.fillOpacity,
"fill-outline-color": DEFAULT_LAYER_STYLE.strokeColor,
};
}

function appendArcGISParams(
url: string,
params: Record<string, string | undefined>,
Expand Down
Loading
Loading