diff --git a/web/client/components/styleeditor/GraphicPattern/GraphicPattern.jsx b/web/client/components/styleeditor/GraphicPattern/GraphicPattern.jsx
new file mode 100644
index 0000000000..0fa319587a
--- /dev/null
+++ b/web/client/components/styleeditor/GraphicPattern/GraphicPattern.jsx
@@ -0,0 +1,354 @@
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+
+const MARKS_TYPES = {
+ line: ({ mark }) => {
+ return (
+
+ );
+ },
+ polygon: ({ mark }) => {
+ return (
+
+ );
+ },
+ circle: ({ mark }) => {
+ return (
+
+ );
+ },
+ polyline: ({ mark }) => {
+ return (
+
+ );
+ }
+};
+
+const GraphicPattern = ({ id, symbolizer, type }) => {
+ const graphic = type === 'line' ? symbolizer["graphic-stroke"] : symbolizer["graphic-fill"];
+ const mark = graphic?.graphics?.[0];
+
+ if (!graphic || !mark) return null;
+
+ const size = Number(graphic.size || 10);
+ const rotation = Number(graphic.rotation || 0);
+ const opacity = Number(graphic.opacity ?? 1);
+
+ const margin =
+ symbolizer["vendor-options"]?.["graphic-margin"]
+ ?.split(" ")
+ .map(Number) || [0, 0];
+
+ const patternWidth = size + margin[0];
+ const patternHeight = size + margin[1];
+
+ const getMarkTypes = () => {
+ switch (mark.mark) {
+ case "shape://horline":
+ return MARKS_TYPES.line({
+ mark: {
+ ...mark,
+ x1: 0,
+ y1: size / 2,
+ x2: size,
+ y2: size / 2,
+ opacity
+ }
+ });
+ case "line":
+ case "shape://vertline":
+ return MARKS_TYPES.line({
+ mark: {
+ ...mark,
+ x1: size / 2,
+ y1: 0,
+ x2: size / 2,
+ y2: size,
+ opacity
+ }
+ });
+ case "shape://slash":
+ return MARKS_TYPES.line({
+ mark: {
+ ...mark,
+ x1: 0,
+ y1: size,
+ x2: size,
+ y2: 0,
+ opacity
+ }
+ });
+ case "shape://backslash":
+ return MARKS_TYPES.line({
+ mark: {
+ ...mark,
+ x1: 0,
+ y1: 0,
+ x2: size,
+ y2: size,
+ opacity
+ }
+ });
+ case "circle":
+ return MARKS_TYPES.circle({
+ mark: {
+ ...mark,
+ cx: size / 2,
+ cy: size / 2,
+ r: size / 4
+ }
+ });
+ case "triangle":
+ return MARKS_TYPES.polygon({ mark: { ...mark, points: `${size / 2},0 0,${size} ${size},${size}` } });
+ case "star":
+ const cx = size / 2;
+ const cy = size / 2;
+ const outerR = size / 2;
+ const innerR = size / 2.5;
+
+ const points = [];
+ for (let i = 0; i < 10; i++) {
+ const angle = (Math.PI / 5) * i - Math.PI / 2;
+ const r = i % 2 === 0 ? outerR : innerR;
+ points.push(
+ `${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`
+ );
+ }
+ return MARKS_TYPES.polygon({ mark: { ...mark, points, fill: mark.fill ?? "#000" } });
+ case "cross":
+ return MARKS_TYPES.polygon({ mark: { ...mark, points: `${size / 2},0 ${size / 2},${size} ${size},${size / 2}` } });
+ case "shape://dot":
+ return MARKS_TYPES.circle({
+ mark: {
+ ...mark,
+ cx: size / 2,
+ cy: size / 2,
+ r: size / 8,
+ opacity
+ }
+ });
+ case "shape://plus":
+ return <>
+ {MARKS_TYPES.line({
+ mark: {
+ ...mark,
+ x1: 0,
+ y1: size / 2,
+ x2: size,
+ y2: size / 2,
+ opacity
+ }
+ })}
+ {MARKS_TYPES.line({
+ mark: {
+ ...mark,
+ x1: size / 2,
+ y1: 0,
+ x2: size / 2,
+ y2: size,
+ opacity
+ }
+ })}
+ >;
+ case "shape://times":
+ return <>
+ {MARKS_TYPES.line({
+ mark: {
+ ...mark,
+ x1: 0,
+ y1: size,
+ x2: size,
+ y2: 0,
+ opacity
+ }
+ })}
+ {MARKS_TYPES.line({
+ mark: {
+ ...mark,
+ x1: 0,
+ y1: size,
+ x2: size,
+ y2: 0,
+ opacity
+ }
+ })}
+ >;
+ case "shape://oarrow":
+ return MARKS_TYPES.polyline({
+ mark: {
+ ...mark,
+ points: `
+ 0,0
+ ${size},${size / 2}
+ 0,${size}
+ `,
+ fill: "none",
+ stroke: mark.stroke,
+ strokeWidth: mark["stroke-width"],
+ strokeOpacity: mark["stroke-opacity"],
+ opacity
+ }
+ });
+
+ case "shape://carrow":
+ return MARKS_TYPES.polygon({
+ mark: {
+ ...mark,
+ points: `0,0 ${size},${size / 2} 0,${size}`,
+ opacity
+ }
+ });
+ case "extshape://triangle":
+ return MARKS_TYPES.polygon({ mark: { ...mark, points: `${size / 2},0 0,${size} ${size},${size}`, opacity, fill: mark.fill ?? "none" } });
+ case "extshape://emicircle":
+ const rx = size / 2;
+ const ry = size / 4;
+ return (
+
+ );
+ case "extshape://triangleemicircle": {
+ const triangleHeight = size / 2;
+ const semicircleRadius = size / 4;
+ return (
+ <>
+ {MARKS_TYPES.polygon({
+ mark: {
+ ...mark,
+ points: `
+ ${size / 2},0
+ 0,${triangleHeight}
+ ${size},${triangleHeight}
+ `,
+ fill: mark.fill ?? "none",
+ opacity
+ }
+ })}
+
+ >
+ );
+ }
+ case "extshape://narrow":
+ return MARKS_TYPES.polygon({
+ mark: {
+ ...mark,
+ points: `
+ ${size / 2},0
+ 0,${size}
+ ${size},${size}
+ `,
+ fill: mark.fill ?? "none",
+ opacity
+ }
+ });
+ case "extshape://sarrow":
+ return MARKS_TYPES.polygon({
+ mark: {
+ ...mark,
+ points: `
+ 0,0
+ ${size},0
+ ${size / 2},${size}
+ `,
+ fill: mark.fill ?? "none",
+ opacity
+ }
+ });
+ case "square":
+ default:
+ return MARKS_TYPES.polygon({
+ mark: {
+ ...mark,
+ points: `0,0 ${size},0 ${size},${size} 0,${size}`,
+ fill: mark.fill ?? "none",
+ opacity
+ }
+ });
+ }
+ };
+
+ return (
+
+
+
+ {getMarkTypes()}
+
+
+
+ );
+};
+
+export default GraphicPattern;
diff --git a/web/client/components/styleeditor/GraphicPattern/__tests__/GraphicPattern-test.jsx b/web/client/components/styleeditor/GraphicPattern/__tests__/GraphicPattern-test.jsx
new file mode 100644
index 0000000000..96cafcc961
--- /dev/null
+++ b/web/client/components/styleeditor/GraphicPattern/__tests__/GraphicPattern-test.jsx
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import GraphicPattern from '../GraphicPattern';
+
+describe('GraphicPattern', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+
+ it('should return null when no graphic is provided', () => {
+ const container = document.getElementById('container');
+ ReactDOM.render(
+ ,
+ container
+ );
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('should render pattern with horizontal line mark for polygon graphic-fill', () => {
+ const symbolizer = {
+ "graphic-fill": {
+ size: 10,
+ opacity: 0.8,
+ rotation: 0,
+ graphics: [{
+ mark: "shape://horline",
+ stroke: "#000000",
+ "stroke-width": 2,
+ "stroke-opacity": 1
+ }]
+ },
+ "vendor-options": {
+ "graphic-margin": "2 2"
+ }
+ };
+
+ const container = document.getElementById('container');
+ ReactDOM.render(
+ ,
+ container
+ );
+
+ const pattern = container.querySelector('pattern#pattern-horline');
+ expect(pattern).toExist();
+ const line = pattern.querySelector('line');
+ expect(line).toExist();
+ expect(line.getAttribute('stroke')).toBe('#000000');
+ expect(line.getAttribute('stroke-width')).toBe('2');
+ });
+
+ it('should render pattern with circle mark for line graphic-stroke', () => {
+ const symbolizer = {
+ "graphic-stroke": {
+ size: 12,
+ opacity: 1,
+ rotation: 45,
+ graphics: [{
+ mark: "circle",
+ fill: "#FF0000",
+ "fill-opacity": 0.5,
+ stroke: "#000000",
+ "stroke-width": 1,
+ "stroke-opacity": 1
+ }]
+ }
+ };
+
+ const container = document.getElementById('container');
+ ReactDOM.render(
+ ,
+ container
+ );
+
+ const pattern = container.querySelector('pattern#pattern-circle');
+ expect(pattern).toExist();
+ const circle = pattern.querySelector('circle');
+ expect(circle).toExist();
+ expect(circle.getAttribute('fill')).toBe('#FF0000');
+ expect(circle.getAttribute('stroke')).toBe('#000000');
+ });
+});
+
+
diff --git a/web/client/components/styleeditor/WMSJsonLegendIcon.jsx b/web/client/components/styleeditor/WMSJsonLegendIcon.jsx
index 4b6aa9af69..8bfef20f56 100644
--- a/web/client/components/styleeditor/WMSJsonLegendIcon.jsx
+++ b/web/client/components/styleeditor/WMSJsonLegendIcon.jsx
@@ -11,37 +11,105 @@ import { Glyphicon } from 'react-bootstrap';
import {
getWellKnownNameImageFromSymbolizer
} from '../../utils/styleparser/StyleParserUtils';
+import { colorToRgbaStr } from '../../utils/ColorUtils';
+import useGraphicPattern from './hooks/useGraphicPattern';
+
+function normalizeDashArray(dasharray) {
+ if (!dasharray) return null;
+ return Array.isArray(dasharray) ? dasharray.join(" ") : dasharray;
+}
+
+function getPolygonFill(symbolizer, graphicFill) {
+ const fillOpacity = Number(symbolizer["fill-opacity"]);
+ const baseFill = graphicFill?.fill || symbolizer.fill;
+
+ // If baseFill is a pattern URL (starts with "url("), return it as-is
+ // Pattern URLs can't be converted to rgba strings
+ if (baseFill && typeof baseFill === 'string' && baseFill.startsWith('url(')) {
+ return baseFill;
+ }
+
+ // Otherwise, convert the color with opacity
+ return colorToRgbaStr(baseFill, fillOpacity, baseFill);
+}
+
+function getLineStroke(symbolizer, graphicStroke) {
+ const strokeOpacity = Number(symbolizer["stroke-opacity"]);
+ const baseStroke = graphicStroke?.stroke || symbolizer.stroke;
+
+ // If baseStroke is a pattern URL (starts with "url("), return it as-is
+ // Pattern URLs can't be converted to rgba strings
+ if (baseStroke && typeof baseStroke === 'string' && baseStroke.startsWith('url(')) {
+ return baseStroke;
+ }
+
+ // Otherwise, convert the color with opacity
+ return colorToRgbaStr(baseStroke, strokeOpacity, baseStroke);
+}
const icon = {
Line: ({ symbolizer }) => {
- const displayWidth = symbolizer['stroke-width'] ? symbolizer['stroke-width'] : symbolizer.width === 0
- ? 1
- : symbolizer.width > 7
- ? 7
- : symbolizer.width;
- return (
-