feat: add project-aware agent workspace
This commit is contained in:
@@ -33,23 +33,24 @@ export const REQUIRED_GEOSERVER_SOURCE_IDS: GeoServerWaterNetworkSourceId[] = SU
|
||||
layer.sourceLayer !== undefined && layer.availability === "required"
|
||||
).map((layer) => layer.id);
|
||||
|
||||
export function createGeoServerLayerHitsUrl(sourceId: DataDependentGeoServerSourceId) {
|
||||
export function createGeoServerLayerHitsUrl(sourceId: DataDependentGeoServerSourceId, workspace = GEOSERVER_WORKSPACE) {
|
||||
const params = new URLSearchParams({
|
||||
service: "WFS",
|
||||
version: "2.0.0",
|
||||
request: "GetFeature",
|
||||
typeNames: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS[sourceId]}`,
|
||||
typeNames: `${workspace}:${SOURCE_LAYERS[sourceId]}`,
|
||||
resultType: "hits"
|
||||
});
|
||||
|
||||
return new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows?${params.toString()}`);
|
||||
return new URL(`${MAP_URL}/${workspace}/ows?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function fetchGeoServerLayerFeatureCount(
|
||||
sourceId: DataDependentGeoServerSourceId,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
workspace = GEOSERVER_WORKSPACE
|
||||
) {
|
||||
const response = await fetch(createGeoServerLayerHitsUrl(sourceId), {
|
||||
const response = await fetch(createGeoServerLayerHitsUrl(sourceId, workspace), {
|
||||
headers: { Accept: "application/xml, text/xml" },
|
||||
signal
|
||||
});
|
||||
@@ -74,12 +75,13 @@ export async function fetchGeoServerLayerFeatureCount(
|
||||
}
|
||||
|
||||
export async function resolveGeoServerLayerAvailability(
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
workspace = GEOSERVER_WORKSPACE
|
||||
): Promise<GeoServerLayerAvailability> {
|
||||
const results = await Promise.allSettled(
|
||||
DATA_DEPENDENT_GEOSERVER_SOURCE_IDS.map(async (sourceId) => ({
|
||||
sourceId,
|
||||
count: await fetchGeoServerLayerFeatureCount(sourceId, signal)
|
||||
count: await fetchGeoServerLayerFeatureCount(sourceId, signal, workspace)
|
||||
}))
|
||||
);
|
||||
const availableOptionalSourceIds: DataDependentGeoServerSourceId[] = [];
|
||||
|
||||
@@ -63,13 +63,13 @@ export function escapeCqlLiteral(value: string) {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
|
||||
export function createMapFeatureWfsUrl(query: MapFeatureQuery) {
|
||||
export function createMapFeatureWfsUrl(query: MapFeatureQuery, workspace = GEOSERVER_WORKSPACE) {
|
||||
const idField = MAP_FEATURE_ID_FIELDS[query.sourceId];
|
||||
const params = new URLSearchParams({
|
||||
service: "WFS",
|
||||
version: "2.0.0",
|
||||
request: "GetFeature",
|
||||
typeNames: `${GEOSERVER_WORKSPACE}:${SOURCE_LAYERS[query.sourceId]}`,
|
||||
typeNames: `${workspace}:${SOURCE_LAYERS[query.sourceId]}`,
|
||||
outputFormat: "application/json",
|
||||
srsName: "EPSG:4326",
|
||||
count: String(query.featureIds ? MAX_MAP_FEATURE_IDS : MAX_PIPE_FEATURES)
|
||||
@@ -79,7 +79,7 @@ export function createMapFeatureWfsUrl(query: MapFeatureQuery) {
|
||||
params.set("cql_filter", `"${idField}" IN (${query.featureIds.map(escapeCqlLiteral).join(",")})`);
|
||||
}
|
||||
|
||||
return new URL(`${MAP_URL}/${GEOSERVER_WORKSPACE}/ows?${params.toString()}`);
|
||||
return new URL(`${MAP_URL}/${workspace}/ows?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function normalizeMapFeatureCollection(value: unknown, maxFeatures: number): FeatureCollection | null {
|
||||
|
||||
@@ -131,3 +131,16 @@ function createImageMap(loadImage: (path: string) => Promise<{ data: { width: nu
|
||||
addImage: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
it("does not access a disposed map after SCADA image loading completes", async () => {
|
||||
const controller = new AbortController();
|
||||
let resolve!: (value: { data: { width: number; height: number } }) => void;
|
||||
const map = createImageMap(() => new Promise((done) => { resolve = done; }));
|
||||
const pending = registerScadaImages(map, controller.signal);
|
||||
controller.abort();
|
||||
resolve({ data: { width: 64, height: 64 } });
|
||||
await expect(pending).resolves.toMatchObject({ status: "cancelled" });
|
||||
expect(map.addImage).not.toHaveBeenCalled();
|
||||
expect(map.loadImage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ const stateOpacity = (state: "hovered" | "selected"): ExpressionSpecification =>
|
||||
type Layer = NonNullable<StyleSpecification["layers"]>[number];
|
||||
|
||||
export type ScadaImageRegistrationResult = {
|
||||
status: "ready" | "degraded" | "unavailable";
|
||||
status: "ready" | "degraded" | "unavailable" | "cancelled";
|
||||
failedImageIds: string[];
|
||||
};
|
||||
|
||||
@@ -226,12 +226,15 @@ export const scadaFallbackLayers = [
|
||||
];
|
||||
|
||||
export async function registerScadaImages(
|
||||
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">
|
||||
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">,
|
||||
signal?: AbortSignal
|
||||
): Promise<ScadaImageRegistrationResult> {
|
||||
if (signal?.aborted) return { status: "cancelled", failedImageIds: [] };
|
||||
let pressure: Awaited<ReturnType<typeof loadImage>>;
|
||||
|
||||
try {
|
||||
pressure = await loadImage(map, SCADA_MAP_ICON_PATHS.pressure);
|
||||
if (signal?.aborted) return { status: "cancelled", failedImageIds: [] };
|
||||
addImageIfMissing(map, SCADA_IMAGE_IDS.pressure, pressure);
|
||||
} catch {
|
||||
return { status: "unavailable", failedImageIds: Object.values(SCADA_IMAGE_IDS) };
|
||||
@@ -245,8 +248,10 @@ export async function registerScadaImages(
|
||||
|
||||
try {
|
||||
const image = await loadImage(map, path);
|
||||
if (signal?.aborted) return { status: "cancelled", failedImageIds };
|
||||
addImageIfMissing(map, imageId, image);
|
||||
} catch {
|
||||
if (signal?.aborted) return { status: "cancelled", failedImageIds };
|
||||
failedImageIds.push(imageId);
|
||||
try {
|
||||
addImageIfMissing(map, imageId, pressure);
|
||||
|
||||
@@ -282,30 +282,33 @@ export function createWaterNetworkSources(): Record<
|
||||
VectorSourceSpecification
|
||||
>;
|
||||
export function createWaterNetworkSources(
|
||||
sourceIds: readonly GeoServerWaterNetworkSourceId[]
|
||||
sourceIds: readonly GeoServerWaterNetworkSourceId[],
|
||||
workspace?: string
|
||||
): Partial<Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>>;
|
||||
export function createWaterNetworkSources(
|
||||
sourceIds: readonly GeoServerWaterNetworkSourceId[] = GEOSERVER_WATER_NETWORK_SOURCE_IDS
|
||||
sourceIds: readonly GeoServerWaterNetworkSourceId[] = GEOSERVER_WATER_NETWORK_SOURCE_IDS,
|
||||
workspace = GEOSERVER_WORKSPACE
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
SUPPLY_LAYER_CATALOG.flatMap((layer) =>
|
||||
layer.available &&
|
||||
layer.sourceLayer !== undefined &&
|
||||
sourceIds.includes(layer.id as GeoServerWaterNetworkSourceId)
|
||||
? [[layer.id, createGeoServerVectorSource(layer.sourceLayer)] as const]
|
||||
? [[layer.id, createGeoServerVectorSource(layer.sourceLayer, workspace)] as const]
|
||||
: []
|
||||
)
|
||||
) as Partial<Record<GeoServerWaterNetworkSourceId, VectorSourceSpecification>>;
|
||||
}
|
||||
|
||||
function createGeoServerVectorSource(
|
||||
sourceLayer: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS]
|
||||
sourceLayer: (typeof SOURCE_LAYERS)[keyof typeof SOURCE_LAYERS],
|
||||
workspace: string
|
||||
): VectorSourceSpecification {
|
||||
return {
|
||||
type: "vector",
|
||||
promoteId: "id",
|
||||
tiles: [
|
||||
`${MAP_URL}/gwc/service/wmts/rest/${GEOSERVER_WORKSPACE}:${sourceLayer}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
|
||||
`${MAP_URL}/gwc/service/wmts/rest/${workspace}:${sourceLayer}/WebMercatorQuad/{z}/{y}/{x}?format=application/vnd.mapbox-vector-tile`
|
||||
],
|
||||
minzoom: 0,
|
||||
maxzoom: 24
|
||||
|
||||
@@ -51,3 +51,17 @@ describe("supply facility map icons", () => {
|
||||
expect(map.addImage).toHaveBeenCalledWith(expect.any(String), image, { pixelRatio: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("does not register images after the owning map is cancelled during loading", async () => {
|
||||
const controller = new AbortController();
|
||||
let resolve!: (value: { data: { width: number; height: number } }) => void;
|
||||
const map = { hasImage: vi.fn(() => false), addImage: vi.fn(),
|
||||
loadImage: vi.fn(() => new Promise<{ data: { width: number; height: number } }>((done) => { resolve = done; })) };
|
||||
const pending = registerSupplyAssetImages(map as unknown as Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">, controller.signal);
|
||||
controller.abort();
|
||||
resolve({ data: { width: 64, height: 64 } });
|
||||
await expect(pending).resolves.toMatchObject({ status: "cancelled" });
|
||||
expect(map.addImage).not.toHaveBeenCalled();
|
||||
expect(map.loadImage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ export const SUPPLY_ASSET_MAP_ICON_PATHS = {
|
||||
} as const;
|
||||
|
||||
export type SupplyAssetImageRegistrationResult = {
|
||||
status: "ready" | "degraded";
|
||||
status: "ready" | "degraded" | "cancelled";
|
||||
failedImageIds: string[];
|
||||
};
|
||||
|
||||
@@ -63,16 +63,19 @@ const ACTIVE_SUPPLY_ASSET_ICON_KEYS = [
|
||||
] as const;
|
||||
|
||||
export async function registerSupplyAssetImages(
|
||||
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">
|
||||
map: Pick<MapLibreMap, "loadImage" | "addImage" | "hasImage">,
|
||||
signal?: AbortSignal
|
||||
): Promise<SupplyAssetImageRegistrationResult> {
|
||||
const failedImageIds: string[] = [];
|
||||
|
||||
for (const key of ACTIVE_SUPPLY_ASSET_ICON_KEYS) {
|
||||
if (signal?.aborted) return { status: "cancelled", failedImageIds };
|
||||
const imageId = SUPPLY_ASSET_IMAGE_IDS[key];
|
||||
if (map.hasImage(imageId)) continue;
|
||||
|
||||
try {
|
||||
const image = await loadImage(map, SUPPLY_ASSET_MAP_ICON_PATHS[key]);
|
||||
if (signal?.aborted) return { status: "cancelled", failedImageIds };
|
||||
addImageIfMissing(map, imageId, image);
|
||||
} catch {
|
||||
failedImageIds.push(imageId);
|
||||
|
||||
@@ -86,6 +86,7 @@ export type WorkbenchMapCommands = {
|
||||
};
|
||||
|
||||
export type WorkbenchMapControllerOptions = {
|
||||
geoserverWorkspace?: string;
|
||||
getMap: () => MapLibreMap | null;
|
||||
isReady: () => boolean;
|
||||
getPadding: () => PaddingOptions;
|
||||
@@ -619,7 +620,7 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
|
||||
}
|
||||
const parsed = parseMapFeatureQuery({ sourceId, featureIds });
|
||||
if (!parsed.ok) throw new Error(parsed.code);
|
||||
const response = await fetch(createMapFeatureWfsUrl(parsed.value), {
|
||||
const response = await fetch(createMapFeatureWfsUrl(parsed.value, this.options.geoserverWorkspace), {
|
||||
headers: { Accept: "application/geo+json, application/json" },
|
||||
signal
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user