diff --git a/.changeset/legend-invisible-fill.md b/.changeset/legend-invisible-fill.md new file mode 100644 index 0000000..aa916fe --- /dev/null +++ b/.changeset/legend-invisible-fill.md @@ -0,0 +1,5 @@ +--- +"@xtramaps/layer-control-maplibre-react": patch +--- + +LayerControl: fully transparent fill layers (`fill-opacity: 0`, e.g. invisible click targets for polygons) no longer hide the line layers of a merge-group in its legend icon, which previously left the icon empty. diff --git a/packages/react/layer-control-maplibre/src/Layer.tsx b/packages/react/layer-control-maplibre/src/Layer.tsx index bf1aa0a..033c61b 100644 --- a/packages/react/layer-control-maplibre/src/Layer.tsx +++ b/packages/react/layer-control-maplibre/src/Layer.tsx @@ -29,9 +29,10 @@ function Layer({ isSelected, onSelect, }: LayerProps) { - const hasFill = icons.some( - (icon) => style?.layers?.[icon.index as number]?.type === "fill", - ); + const hasFill = icons.some((icon) => { + const layer = style?.layers?.[icon.index as number]; + return layer?.type === "fill" && layer.paint?.["fill-opacity"] !== 0; + }); const hasRaster = icons.some( (icon) => style?.layers?.[icon.index as number]?.type === "raster", ); diff --git a/packages/react/layer-control-maplibre/src/LegendIcons.stories.tsx b/packages/react/layer-control-maplibre/src/LegendIcons.stories.tsx new file mode 100644 index 0000000..8a9eb8d --- /dev/null +++ b/packages/react/layer-control-maplibre/src/LegendIcons.stories.tsx @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import MapLibre from "@xtramaps/web-map-maplibre-react"; +import "@xtramaps/web-map-maplibre-react/dist/index.css"; +import { expect, within } from "storybook/test"; +import LayerControl from "./LayerControl"; + +const STYLE_URL = "https://demo.ldproxy.net/daraa/styles/topographic?f=mbs"; + +// A common pattern for boundaries: a visible outline plus a fully transparent fill that only +// serves as a click target for the polygon's interior. Each merge-group gets its own outline +// layer, since a style layer shared between entries would be toggled by both. +const extraLayers = [ + { + id: "militarysrf_outline_a", + type: "line", + source: "daraa", + "source-layer": "MilitarySrf", + paint: { "line-color": "#DD3344", "line-width": 3 }, + }, + { + id: "militarysrf_outline_b", + type: "line", + source: "daraa", + "source-layer": "MilitarySrf", + paint: { "line-color": "#DD3344", "line-width": 3 }, + }, + { + id: "militarysrf_invisible", + type: "fill", + source: "daraa", + "source-layer": "MilitarySrf", + paint: { "fill-color": "#ffffff", "fill-opacity": 0 }, + }, +]; + +const entries = [ + { + id: "Outline with invisible fill", + type: "merge-group" as const, + entries: ["militarysrf_outline_a", "militarysrf_invisible"], + }, + { + id: "Outline with visible fill", + type: "merge-group" as const, + entries: ["militarysrf_outline_b", "militarysrf"], + }, +]; + +const meta = { + title: "layer-control-maplibre/LegendIcons", + loaders: [ + async () => { + const style = await (await fetch(STYLE_URL)).json(); + style.layers.push(...extraLayers); + const styleUrl = URL.createObjectURL( + new Blob([JSON.stringify(style)], { type: "application/json" }), + ); + return { styleUrl }; + }, + ], + render: (_args, { loaded }) => ( + + + + ), +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** The legend icon is rendered inside the entry's ; line icons are SVGs, fills s. */ +async function findIcon( + canvas: ReturnType, + text: string, +): Promise { + const label = (await canvas.findByText(text, {}, { timeout: 15000 })).closest( + "label", + ); + await expect(label).not.toBeNull(); + return label as HTMLLabelElement; +} + +export const InvisibleFill: Story = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step( + "a merge-group with an invisible fill shows its line in the legend icon", + async () => { + const label = await findIcon(canvas, "Outline with invisible fill"); + await expect(label.querySelector("svg path")).not.toBeNull(); + }, + ); + + await step( + "a merge-group with a visible fill still shows only the fill", + async () => { + const label = await findIcon(canvas, "Outline with visible fill"); + await expect(label.querySelector("svg path")).toBeNull(); + }, + ); + }, +};