Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add legend package to generate legends from WMS and WMTS layers #19

Merged
merged 5 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 42 additions & 0 deletions packages/legend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# `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);
});
```

## API Documentation
Copy link
Member

Choose a reason for hiding this comment

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

The API documentation should be written in the code as jsdoc annotations; there's an automatic website generated from it :) you can try it using npm run docs:dev

Copy link
Member Author

Choose a reason for hiding this comment

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

Should I remove the API documentation section from here? I kept it because it might be visible on the package page on npmjs.com

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, I think you can remove it and put a link to the online documentation. Otherwise we'll have to maintain it manually and it might become misleading/wrong in the future.


### `createLegendFromLayer(layer: Layer): Promise<HTMLDivElement>`

Creates a legend from a layer.

#### Parameters

- `layer: (MapContextLayer)`: The layer to create the legend from.
- `options: (LegendOptions, optional)`: The options to create the legend.

#### Returns

- `Promise<HTMLElement | false>`: A promise that resolves to the legend element or `false` if the legend could not be created.
148 changes: 148 additions & 0 deletions packages/legend/lib/create-legend/from-layer.test.ts
Original file line number Diff line number Diff line change
@@ -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 false for invalid layer type", async () => {
const invalidLayer = { ...baseWmsLayer, type: "invalid" as any };

const result = await createLegendFromLayer(invalidLayer);

expect(result).toBe(false);
});

it("returns false for layer without URL", async () => {
const layerWithoutUrl = { ...baseWmsLayer, url: "" };

const result = await createLegendFromLayer(layerWithoutUrl);

expect(result).toBe(false);
});

it("returns false for layer without name", async () => {
const layerWithoutName = { ...baseWmsLayer, name: "" };

const result = await createLegendFromLayer(layerWithoutName);

expect(result).toBe(false);
});

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");
});
});
160 changes: 160 additions & 0 deletions packages/legend/lib/create-legend/from-layer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
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<string | null> {
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;
}

/**
* Create a legend from a layer
*
*
* @param layer - The MapContextLayer to create a legend from
* @param options - Optional configuration for legend generation
* @returns The legend as a DOM element or false if the legend could not be created
*/
export async function createLegendFromLayer(
layer: MapContextLayer,
options: LegendOptions = {},
): Promise<HTMLElement | false> {
ronitjadhav marked this conversation as resolved.
Show resolved Hide resolved
if (
(layer.type !== "wms" && layer.type !== "wmts") ||
!layer.url ||
!layer.name
) {
console.error("Invalid layer for legend creation");
return false;
}

// 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;
}
1 change: 1 addition & 0 deletions packages/legend/lib/create-legend/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createLegendFromLayer } from "./from-layer";
1 change: 1 addition & 0 deletions packages/legend/lib/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./create-legend";
Loading
Loading