Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions src/api/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,13 @@ export interface IExternalBaseLayer {
*
*/
options?: any;
/**
* An optional URL to a thumbnail image to display in the base layer switcher UI
*
* @type {string}
* @since 0.15
*/
thumbnailImageUrl?: string;
}


Expand Down
73 changes: 70 additions & 3 deletions src/components/base-layer-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,24 @@ export interface IBaseLayerSwitcherProps {

/**
* The BaseLayerSwitcher component provides a user interface for switching the active external
* base layer of the current map
* @param props
* base layer of the current map.
*
* When any base layer has a `thumbnailImageUrl` set, the switcher renders as a thumbnail grid
* instead of a radio-button list.
*
* @example
* ```tsx
* <BaseLayerSwitcher
* locale="en"
* externalBaseLayers={[
* { name: "OpenStreetMap", kind: "OSM", thumbnailImageUrl: "https://example.com/osm.png" },
* { name: "Satellite", kind: "BingMaps", thumbnailImageUrl: "https://example.com/sat.png" }
* ]}
* onBaseLayerChanged={(name) => console.log(name)}
* />
* ```
*
* @since 0.15
*/
export const BaseLayerSwitcher = (props: IBaseLayerSwitcherProps) => {
const { locale, externalBaseLayers } = props;
Expand All @@ -31,6 +47,57 @@ export const BaseLayerSwitcher = (props: IBaseLayerSwitcherProps) => {
React.useEffect(() => {
setSelected(visLayers.length == 1 ? visLayers[0].name : STR_EMPTY);
}, [visLayers]);

const visualLayers = externalBaseLayers.filter(ebl => isVisualBaseLayer(ebl));
const hasThumbnails = visualLayers.some(layer => !!layer.thumbnailImageUrl);

if (hasThumbnails) {
const onThumbnailClick = (layerName: string) => {
setSelected(layerName);
props.onBaseLayerChanged?.(layerName);
};
const onThumbnailKeyDown = (e: React.KeyboardEvent, layerName: string) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onThumbnailClick(layerName);
}
};
const noneLabel = tr("NONE", locale);
return <div className="base-layer-switcher-thumbnail-container">
<div
role="button"
tabIndex={0}
aria-label={noneLabel}
aria-pressed={strIsNullOrEmpty(selected)}
className={`base-layer-switcher-thumbnail-item${strIsNullOrEmpty(selected) ? " base-layer-switcher-thumbnail-item-selected" : ""}`}
onClick={() => onThumbnailClick(STR_EMPTY)}
onKeyDown={(e) => onThumbnailKeyDown(e, STR_EMPTY)}
>
<div className="base-layer-switcher-thumbnail-none-placeholder">{noneLabel}</div>
<div className="base-layer-switcher-thumbnail-label">{noneLabel}</div>
</div>
{visualLayers.map(layer => {
const isSelected = layer.name === selected;
return <div
key={`base-layer-thumb-${layer.name}`}
role="button"
tabIndex={0}
aria-label={layer.name}
aria-pressed={isSelected}
className={`base-layer-switcher-thumbnail-item${isSelected ? " base-layer-switcher-thumbnail-item-selected" : ""}`}
onClick={() => onThumbnailClick(layer.name)}
onKeyDown={(e) => onThumbnailKeyDown(e, layer.name)}
>
{layer.thumbnailImageUrl
? <img className="base-layer-switcher-thumbnail-image" src={layer.thumbnailImageUrl} alt="" />
: <div className="base-layer-switcher-thumbnail-none-placeholder" aria-hidden="true">{layer.name}</div>
}
<div className="base-layer-switcher-thumbnail-label" aria-hidden="true">{layer.name}</div>
</div>;
})}
</div>;
}

return <div>
<div className="base-layer-switcher-item-container">
<label className="bp3-control bp3-radio">
Expand All @@ -39,7 +106,7 @@ export const BaseLayerSwitcher = (props: IBaseLayerSwitcherProps) => {
{tr("NONE", locale)}
</label>
</div>
{externalBaseLayers.filter(ebl => isVisualBaseLayer(ebl)).map(layer => {
{visualLayers.map(layer => {
return <div className="base-layer-switcher-item-container" key={`base-layer-${layer.name}`}>
<label className="bp3-control bp3-radio">
<input className="base-layer-switcher-option" type="radio" value={layer.name} checked={layer.name === selected} onChange={onBaseLayerChanged} />
Expand Down
62 changes: 62 additions & 0 deletions src/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,68 @@
background-color: white;
}

/* Base Layer Switcher - Thumbnail grid mode */

.base-layer-switcher-thumbnail-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 4px;
max-width: 320px;
}

.base-layer-switcher-thumbnail-item {
display: flex;
flex-direction: column;
align-items: center;
width: 74px;
cursor: pointer;
border: 2px solid transparent;
border-radius: 4px;
padding: 2px;
box-sizing: border-box;
}

.base-layer-switcher-thumbnail-item:hover {
border-color: #888;
}

.base-layer-switcher-thumbnail-item-selected {
border-color: #106ba3;
}

.base-layer-switcher-thumbnail-image {
width: 70px;
height: 50px;
object-fit: cover;
border-radius: 2px;
display: block;
}

.base-layer-switcher-thumbnail-none-placeholder {
width: 70px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
background-color: #e8e8e8;
border-radius: 2px;
font-size: 11px;
color: #555;
text-align: center;
box-sizing: border-box;
padding: 2px;
}

.base-layer-switcher-thumbnail-label {
font-size: 11px;
text-align: center;
margin-top: 2px;
word-break: break-word;
max-width: 70px;
line-height: 1.2;
}

.iframe-iehack-zindex {
position: absolute;
border: none;
Expand Down
69 changes: 67 additions & 2 deletions test/components/base-layer-switcher.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "@testing-library/react";
import { render, fireEvent } from "@testing-library/react";
import { IExternalBaseLayer } from "../../src/api/common";
import { BaseLayerSwitcher } from "../../src/components/base-layer-switcher";
import { STR_EMPTY } from "../../src/utils/string";
Expand Down Expand Up @@ -53,4 +53,69 @@ describe("components/base-layer-switcher", () => {
expect((radio[2] as HTMLInputElement).value).toBe("Stamen - Toner");
expect((radio[2] as HTMLInputElement).checked).toBe(false);
});
});
it("Renders thumbnail grid when at least one layer has thumbnailImageUrl", () => {
const onBaseLayerChanged = vi.fn();
const layers: IExternalBaseLayer[] = [
{ name: "OpenStreetMap", kind: "OSM", visible: true, thumbnailImageUrl: "https://example.com/osm.png" },
{ name: "Stamen - Toner", kind: "Stamen", thumbnailImageUrl: "https://example.com/stamen.png" }
];
const { container } = render(<BaseLayerSwitcher externalBaseLayers={layers} onBaseLayerChanged={onBaseLayerChanged} locale="en" />);
// Should render thumbnail container, not radio buttons
const thumbnailContainer = container.querySelectorAll(".base-layer-switcher-thumbnail-container");
expect(thumbnailContainer).toHaveLength(1);
const radio = container.querySelectorAll(".base-layer-switcher-option");
expect(radio).toHaveLength(0);
// NONE + 2 layers = 3 thumbnail items
const items = container.querySelectorAll(".base-layer-switcher-thumbnail-item");
expect(items).toHaveLength(3);
// The visible layer (OpenStreetMap) should be selected
const selectedItems = container.querySelectorAll(".base-layer-switcher-thumbnail-item-selected");
expect(selectedItems).toHaveLength(1);
// Should render thumbnail images
const imgs = container.querySelectorAll(".base-layer-switcher-thumbnail-image");
expect(imgs).toHaveLength(2);
expect((imgs[0] as HTMLImageElement).src).toContain("osm.png");
expect((imgs[1] as HTMLImageElement).src).toContain("stamen.png");
});
it("Renders thumbnail grid with NONE selected when multiple layers are visible", () => {
const onBaseLayerChanged = vi.fn();
const layers: IExternalBaseLayer[] = [
{ name: "OpenStreetMap", kind: "OSM", visible: true, thumbnailImageUrl: "https://example.com/osm.png" },
{ name: "Satellite", kind: "BingMaps", visible: true, thumbnailImageUrl: "https://example.com/sat.png" }
];
const { container } = render(<BaseLayerSwitcher externalBaseLayers={layers} onBaseLayerChanged={onBaseLayerChanged} locale="en" />);
const items = container.querySelectorAll(".base-layer-switcher-thumbnail-item");
expect(items).toHaveLength(3);
// NONE item should be selected (first item)
expect(items[0].classList.contains("base-layer-switcher-thumbnail-item-selected")).toBe(true);
expect(items[1].classList.contains("base-layer-switcher-thumbnail-item-selected")).toBe(false);
expect(items[2].classList.contains("base-layer-switcher-thumbnail-item-selected")).toBe(false);
});
it("Calls onBaseLayerChanged when a thumbnail item is clicked", () => {
const onBaseLayerChanged = vi.fn();
const layers: IExternalBaseLayer[] = [
{ name: "OpenStreetMap", kind: "OSM", thumbnailImageUrl: "https://example.com/osm.png" },
{ name: "Satellite", kind: "BingMaps", thumbnailImageUrl: "https://example.com/sat.png" }
];
const { container } = render(<BaseLayerSwitcher externalBaseLayers={layers} onBaseLayerChanged={onBaseLayerChanged} locale="en" />);
const items = container.querySelectorAll(".base-layer-switcher-thumbnail-item");
// Click the second item (OpenStreetMap)
fireEvent.click(items[1]);
expect(onBaseLayerChanged).toHaveBeenCalledWith("OpenStreetMap");
});
it("Renders placeholder for layers without thumbnailImageUrl in thumbnail mode", () => {
const onBaseLayerChanged = vi.fn();
const layers: IExternalBaseLayer[] = [
{ name: "OpenStreetMap", kind: "OSM", thumbnailImageUrl: "https://example.com/osm.png" },
{ name: "Stamen - Toner", kind: "Stamen" } // no thumbnail
];
const { container } = render(<BaseLayerSwitcher externalBaseLayers={layers} onBaseLayerChanged={onBaseLayerChanged} locale="en" />);
const thumbnailContainer = container.querySelectorAll(".base-layer-switcher-thumbnail-container");
expect(thumbnailContainer).toHaveLength(1);
const imgs = container.querySelectorAll(".base-layer-switcher-thumbnail-image");
expect(imgs).toHaveLength(1); // only the layer with a URL gets an img
const placeholders = container.querySelectorAll(".base-layer-switcher-thumbnail-none-placeholder");
// NONE item placeholder + "Stamen - Toner" placeholder
expect(placeholders).toHaveLength(2);
});
});
7 changes: 6 additions & 1 deletion viewer/generic.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@
{ name: "OpenStreetMap", kind: "OSM" },
{ name: "Stamen - Toner", kind: "Stamen", visible: true, options: { layer: "toner" } },
{ name: "Stamen - Watercolor", kind: "Stamen", options: { layer: "watercolor" } }
],*/
],
// To show thumbnails in the base layer switcher, add a thumbnailImageUrl property:
// externalBaseLayers: [
// { name: "OpenStreetMap", kind: "OSM", thumbnailImageUrl: "https://example.com/osm-thumbnail.png" },
// { name: "Stamen - Toner", kind: "Stamen", visible: true, options: { layer: "toner" }, thumbnailImageUrl: "https://example.com/toner-thumbnail.png" }
// ],*/
mapguide: {
agentUri: "../mapagent/mapagent.fcgi"
}
Expand Down
Loading