-
Notifications
You must be signed in to change notification settings - Fork 1
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
Changes from 4 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
7d78ffc
Add legend package to generate legends from WMS and WMTS layers
ronitjadhav 7ebe068
Update package-lock.json to sync with package.json
ronitjadhav 8e18524
Updated the README for legend package
ronitjadhav 8df6af9
Added CSS classes to legend elements
ronitjadhav 01b2a89
Resolved PR comments
ronitjadhav File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
||
### `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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { createLegendFromLayer } from "./from-layer"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./create-legend"; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.