diff --git a/package-lock.json b/package-lock.json index 09373b8..f1430ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2958,6 +2958,10 @@ "resolved": "packages/geocoding", "link": true }, + "node_modules/@geospatial-sdk/legend": { + "resolved": "packages/legend", + "link": true + }, "node_modules/@geospatial-sdk/openlayers": { "resolved": "packages/openlayers", "link": true @@ -15101,6 +15105,13 @@ "version": "0.0.5-alpha.2", "license": "BSD-3-Clause" }, + "packages/legend": { + "version": "0.0.5-alpha.2", + "license": "BSD-3-Clause", + "devDependencies": { + "@geospatial-sdk/core": "^0.0.5-alpha.2" + } + }, "packages/openlayers": { "name": "@geospatial-sdk/openlayers", "version": "0.0.5-alpha.2", diff --git a/packages/legend/README.md b/packages/legend/README.md new file mode 100644 index 0000000..16d2410 --- /dev/null +++ b/packages/legend/README.md @@ -0,0 +1,35 @@ +# `legend` + +> A library to get legend graphics from the map-context. + +## Installation + +To install the package, use npm: + +```sh +npm install @geospatial-sdk/legend +``` + +## Usage + +```typescript +import { createLegendFromLayer } from "@geospatial-sdk/legend"; + +const layer = { + type: "wms", + url: "https://example.com/wms", + name: "test-layer", +}; + +createLegendFromLayer(layer).then((legendDiv) => { + document.body.appendChild(legendDiv); +}); +``` + +## Documentation + +For more detailed API documentation, see the [documentation website](https://camptocamp.github.io/geospatial-sdk/docs/). + +## Examples + +For examples and demos, see the [examples website](https://camptocamp.github.io/geospatial-sdk/). diff --git a/packages/legend/lib/create-legend/from-layer.test.ts b/packages/legend/lib/create-legend/from-layer.test.ts new file mode 100644 index 0000000..406895e --- /dev/null +++ b/packages/legend/lib/create-legend/from-layer.test.ts @@ -0,0 +1,148 @@ +import { createLegendFromLayer } from "./from-layer"; +import { + MapContextLayer, + MapContextLayerWms, + MapContextLayerWmts, +} from "@geospatial-sdk/core"; +import { WmtsEndpoint } from "@camptocamp/ogc-client"; + +// Mock dependencies +vi.mock("@camptocamp/ogc-client", () => ({ + WmtsEndpoint: vi.fn(), +})); + +describe("createLegendFromLayer", () => { + const baseWmsLayer: MapContextLayerWms = { + type: "wms", + url: "https://example.com/wms", + name: "test-layer", + }; + + const baseWmtsLayer: MapContextLayerWmts = { + type: "wmts", + url: "https://example.com/wmts", + name: "test-wmts-layer", + }; + + beforeEach(() => { + // Clear all mocks before each test + vi.clearAllMocks(); + }); + + it("creates a legend for a valid WMS layer", async () => { + const result = await createLegendFromLayer(baseWmsLayer); + + expect(result).toBeInstanceOf(HTMLElement); + + const legendDiv = result as HTMLElement; + const img = legendDiv.querySelector("img"); + const title = legendDiv.querySelector("h4"); + + expect(title?.textContent).toBe("test-layer"); + expect(img).toBeTruthy(); + expect(img?.src).toContain("REQUEST=GetLegendGraphic"); + expect(img?.alt).toBe("Legend for test-layer"); + }); + + it("creates a legend for a valid WMS layer with custom options", async () => { + const result = await createLegendFromLayer(baseWmsLayer, { + format: "image/jpeg", + widthPxHint: 200, + heightPxHint: 100, + }); + + const img = (result as HTMLElement).querySelector("img"); + + expect(img?.src).toContain("FORMAT=image%2Fjpeg"); + expect(img?.src).toContain("WIDTH=200"); + expect(img?.src).toContain("HEIGHT=100"); + }); + + it("creates a legend for a valid WMTS layer with legend URL", async () => { + const mockLegendUrl = "https://example.com/legend.png"; + const mockIsReady = { + getLayerByName: () => ({ + styles: [{ legendUrl: mockLegendUrl }], + }), + }; + + // Mock WmtsEndpoint + (WmtsEndpoint as any).mockImplementation(() => ({ + isReady: () => Promise.resolve(mockIsReady), + })); + + const result = await createLegendFromLayer(baseWmtsLayer); + + const img = (result as HTMLElement).querySelector("img"); + + expect(img?.src).toBe(mockLegendUrl); + }); + + it("handles WMTS layer without legend URL", async () => { + const mockIsReady = { + getLayerByName: () => ({ + styles: [], + }), + }; + + // Mock WmtsEndpoint + (WmtsEndpoint as any).mockImplementation(() => ({ + isReady: () => Promise.resolve(mockIsReady), + })); + + const result = await createLegendFromLayer(baseWmtsLayer); + + const errorSpan = (result as HTMLElement).querySelector("span"); + + expect(result).toBeInstanceOf(HTMLElement); + expect(errorSpan?.textContent).toBe( + "Legend not available for test-wmts-layer", + ); + }); + + it("returns null for invalid layer type", async () => { + const invalidLayer = { ...baseWmsLayer, type: "invalid" as any }; + + const result = await createLegendFromLayer(invalidLayer); + + expect(result).toBe(null); + }); + + it("returns null for layer without URL", async () => { + const layerWithoutUrl = { ...baseWmsLayer, url: "" }; + + const result = await createLegendFromLayer(layerWithoutUrl); + + expect(result).toBe(null); + }); + + it("returns null for layer without name", async () => { + const layerWithoutName = { ...baseWmsLayer, name: "" }; + + const result = await createLegendFromLayer(layerWithoutName); + + expect(result).toBe(null); + }); + + it("handles image load error", async () => { + const result = await createLegendFromLayer(baseWmsLayer); + const img = (result as HTMLElement).querySelector("img"); + + if (img) { + const errorEvent = new Event("error"); + img.dispatchEvent(errorEvent); + + const errorSpan = (result as HTMLElement).querySelector("span"); + expect(errorSpan?.textContent).toBe( + "Legend not available for test-layer", + ); + } + }); + + it("adds accessibility attributes", async () => { + const result = await createLegendFromLayer(baseWmsLayer); + + expect(result.getAttribute("role")).toBe("region"); + expect(result.getAttribute("aria-label")).toBe("Map Layer Legend"); + }); +}); diff --git a/packages/legend/lib/create-legend/from-layer.ts b/packages/legend/lib/create-legend/from-layer.ts new file mode 100644 index 0000000..5d142d8 --- /dev/null +++ b/packages/legend/lib/create-legend/from-layer.ts @@ -0,0 +1,159 @@ +import { + MapContextLayer, + MapContextLayerWms, + MapContextLayerWmts, + removeSearchParams, +} from "@geospatial-sdk/core"; +import { WmtsEndpoint } from "@camptocamp/ogc-client"; + +/** + * Configuration options for legend generation + */ +interface LegendOptions { + format?: string; + widthPxHint?: number; + heightPxHint?: number; +} + +/** + * Create a legend URL for a WMS layer + * + * @param layer - The MapContextLayer to create a legend URL for + * @param options - Optional configuration for legend generation + * @returns A URL for the WMS legend graphic + */ +function createWmsLegendUrl( + layer: MapContextLayerWms, + options: LegendOptions = {}, +): URL { + const { format = "image/png", widthPxHint, heightPxHint } = options; + + const legendUrl = new URL( + removeSearchParams(layer.url, [ + "SERVICE", + "REQUEST", + "FORMAT", + "LAYER", + "LAYERTITLE", + "WIDTH", + "HEIGHT", + ]), + ); + legendUrl.searchParams.set("SERVICE", "WMS"); + legendUrl.searchParams.set("REQUEST", "GetLegendGraphic"); + legendUrl.searchParams.set("FORMAT", format); + legendUrl.searchParams.set("LAYER", layer.name); + legendUrl.searchParams.set("LAYERTITLE", false.toString()); // Disable layer title for QGIS Server + + if (widthPxHint) { + legendUrl.searchParams.set("WIDTH", widthPxHint.toString()); + } + if (heightPxHint) { + legendUrl.searchParams.set("HEIGHT", heightPxHint.toString()); + } + + return legendUrl; +} + +/** + * Create a legend URL for a WMTS layer + * + * @param layer - The MapContextLayer to create a legend URL for + * @returns A URL for the WMTS legend graphic or null if not available + */ +async function createWmtsLegendUrl( + layer: MapContextLayerWmts, +): Promise { + const endpoint = await new WmtsEndpoint(layer.url).isReady(); + + const layerByName = endpoint.getLayerByName(layer.name); + console.log("layerByName"); + console.log(layerByName); + + if ( + layerByName.styles && + layerByName.styles.length > 0 && + layerByName.styles[0].legendUrl + ) { + return layerByName.styles[0].legendUrl; + } + + return null; +} + +/** + * Creates a legend from a layer. + * + * @param {MapContextLayer} layer - The layer to create the legend from. + * @param {LegendOptions} [options] - The options to create the legend. + * @returns {Promise} A promise that resolves to the legend element or `null` if the legend could not be created. + */ +export async function createLegendFromLayer( + layer: MapContextLayer, + options: LegendOptions = {}, +): Promise { + if ( + (layer.type !== "wms" && layer.type !== "wmts") || + !layer.url || + !layer.name + ) { + console.error("Invalid layer for legend creation"); + return null; + } + + // Create a container for the legend + const legendDiv = document.createElement("div"); + legendDiv.id = "legend"; + legendDiv.setAttribute("role", "region"); + legendDiv.setAttribute("aria-label", "Map Layer Legend"); + legendDiv.classList.add("geosdk--legend-container"); + + const layerDiv = document.createElement("div"); + layerDiv.classList.add("geosdk--legend-layer"); + + const layerTitle = document.createElement("h4"); + layerTitle.textContent = layer.name; + layerTitle.classList.add("geosdk--legend-layer-label"); + layerDiv.appendChild(layerTitle); + + const img = document.createElement("img"); + img.alt = `Legend for ${layer.name}`; + img.classList.add("geosdk--legend-layer-image"); + + // Error handling for failed image loading + img.onerror = (e) => { + console.warn(`Failed to load legend for layer: ${layer.name}`, e); + const errorMessage = document.createElement("span"); + errorMessage.textContent = `Legend not available for ${layer.name}`; + layerDiv.replaceChild(errorMessage, img); + }; + + try { + let legendUrl: string | null = null; + + // Determine legend URL based on layer type + if (layer.type === "wms") { + legendUrl = createWmsLegendUrl(layer, options).toString(); + } else if (layer.type === "wmts") { + legendUrl = await createWmtsLegendUrl(layer); + } + + // If legend URL is available, set the image source + if (legendUrl) { + img.src = legendUrl; + layerDiv.appendChild(img); + } else { + const errorMessage = document.createElement("span"); + errorMessage.textContent = `Legend not available for ${layer.name}`; + layerDiv.appendChild(errorMessage); + } + } catch (error) { + console.error(`Error creating legend for layer ${layer.name}:`, error); + const errorMessage = document.createElement("span"); + errorMessage.textContent = `Error loading legend for ${layer.name}`; + layerDiv.appendChild(errorMessage); + } + + legendDiv.appendChild(layerDiv); + return legendDiv; +} diff --git a/packages/legend/lib/create-legend/index.ts b/packages/legend/lib/create-legend/index.ts new file mode 100644 index 0000000..34e6ada --- /dev/null +++ b/packages/legend/lib/create-legend/index.ts @@ -0,0 +1 @@ +export { createLegendFromLayer } from "./from-layer"; diff --git a/packages/legend/lib/index.ts b/packages/legend/lib/index.ts new file mode 100644 index 0000000..438d3f9 --- /dev/null +++ b/packages/legend/lib/index.ts @@ -0,0 +1 @@ +export * from "./create-legend"; diff --git a/packages/legend/package.json b/packages/legend/package.json new file mode 100644 index 0000000..074e72d --- /dev/null +++ b/packages/legend/package.json @@ -0,0 +1,38 @@ +{ + "name": "@geospatial-sdk/legend", + "version": "0.0.5-alpha.2", + "description": "Get legend graphic from the map-context", + "keywords": [ + "legend" + ], + "author": "ronitjadhav ", + "homepage": "https://github.com/camptocamp/geospatial-sdk#readme", + "license": "BSD-3-Clause", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "type": "module", + "publishConfig": { + "access": "public" + }, + "directories": { + "lib": "lib" + }, + "files": [ + "lib", + "dist" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/camptocamp/geospatial-sdk.git" + }, + "scripts": { + "test": "vitest", + "build": "tsc" + }, + "devDependencies": { + "@geospatial-sdk/core": "^0.0.5-alpha.2" + }, + "bugs": { + "url": "https://github.com/camptocamp/geospatial-sdk/issues" + } +} diff --git a/packages/legend/tsconfig.json b/packages/legend/tsconfig.json new file mode 100644 index 0000000..437ac13 --- /dev/null +++ b/packages/legend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./lib"], + "compilerOptions": { + "outDir": "./dist" + } +} diff --git a/packages/legend/vitest.config.ts b/packages/legend/vitest.config.ts new file mode 100644 index 0000000..76e5c5e --- /dev/null +++ b/packages/legend/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + environment: "jsdom", + globals: true, + }, +});