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 ( - - { + const { defs, stroke } = useGraphicPattern(sym, "line"); + if (defs) { + allDefs.push(defs); + } + const displayWidth = sym['stroke-width'] || 1; + return ( + + ); + }); + return ( + + {allDefs} + {paths} ); }, Polygon: ({ symbolizer }) => { + // Handle both single symbolizer and array of symbolizers + const symbolizers = Array.isArray(symbolizer) ? symbolizer : [symbolizer]; + // Collect all defs and paths + const allDefs = []; + const paths = symbolizers.map((sym, idx) => { + const { defs, fill } = useGraphicPattern(sym, 'polygon'); + if (defs) { + allDefs.push(defs); + } + const strokeDasharray = normalizeDashArray( + sym["stroke-dasharray"] + ); + return ( + + ); + }); + return ( - + {allDefs} + {paths} ); }, @@ -161,26 +229,63 @@ function createSymbolizerForPoint(pointSymbolizer) { function WMSJsonLegendIcon({ rule }) { - const ruleSymbolizers = rule?.symbolizers; + const ruleSymbolizers = rule?.symbolizers || []; + const polygonSymbolizers = []; + const lineSymbolizers = []; + const pointSymbolizers = []; const icons = []; + ruleSymbolizers.forEach((symbolizer) => { let symbolyzierKinds = Object.keys(symbolizer); const availableSymbolyzers = ['Point', 'Line', 'Polygon']; symbolyzierKinds.forEach(kind => { if (!availableSymbolyzers.includes(kind)) return; else if (kind === 'Point') { - let graphicSymbolyzer = symbolizer[kind]?.graphics?.find(gr => Object.keys(gr).includes('mark')); - const graphicType = graphicSymbolyzer ? 'Mark' : 'Icon'; - if (graphicType === 'Mark') { - symbolizer[kind] = createSymbolizerForPoint(symbolizer[kind]); - } - symbolizer[kind].wellKnownName = graphicType === 'Mark' ? graphicSymbolyzer.mark.charAt(0).toUpperCase() + graphicSymbolyzer.mark.slice(1) : ''; - icons.push({Icon: icon[graphicType], symbolizer: symbolizer[kind]}); + pointSymbolizers.push(symbolizer[kind]); + return; + } else if (kind === 'Line') { + lineSymbolizers.push(symbolizer[kind]); + return; + } else if (kind === 'Polygon') { + polygonSymbolizers.push(symbolizer[kind]); return; } icons.push({Icon: icon[kind], symbolizer: symbolizer[kind]}); }); }); + + // Handle Line symbolizers (single or multiple) + if (lineSymbolizers.length > 0) { + icons.push({ + Icon: icon.Line, + symbolizer: lineSymbolizers.length === 1 ? lineSymbolizers[0] : lineSymbolizers + }); + } + + // Handle Polygon symbolizers (single or multiple) + if (polygonSymbolizers.length > 0) { + icons.push({ + Icon: icon.Polygon, + symbolizer: polygonSymbolizers.length === 1 ? polygonSymbolizers[0] : polygonSymbolizers + }); + } + + // Handle Point symbolizers (individual icons, not stacked) + pointSymbolizers.forEach((pointSym) => { + let graphicSymbolyzer = pointSym?.graphics?.find(gr => Object.keys(gr).includes('mark')); + const graphicType = graphicSymbolyzer ? 'Mark' : 'Icon'; + const processedSymbolizer = graphicType === 'Mark' + ? createSymbolizerForPoint(pointSym) + : pointSym; + if (graphicType === 'Mark' && graphicSymbolyzer) { + processedSymbolizer.wellKnownName = graphicSymbolyzer.mark.charAt(0).toUpperCase() + graphicSymbolyzer.mark.slice(1); + } + icons.push({ + Icon: icon[graphicType], + symbolizer: processedSymbolizer + }); + }); + return icons.length ? <> {icons.map(({ Icon, symbolizer }, idx) =>
)} : null; } diff --git a/web/client/components/styleeditor/__tests__/WMSJsonLegendIcon-test.jsx b/web/client/components/styleeditor/__tests__/WMSJsonLegendIcon-test.jsx index 90db2c7efe..cca91a49b8 100644 --- a/web/client/components/styleeditor/__tests__/WMSJsonLegendIcon-test.jsx +++ b/web/client/components/styleeditor/__tests__/WMSJsonLegendIcon-test.jsx @@ -69,4 +69,103 @@ describe('WMSJsonLegendIcon', () => { const svgElements = document.querySelectorAll('svg'); expect(svgElements.length).toBe(1); }); + it('should render polygon icon with graphic-fill pattern', () => { + const symbolizers = [{ + "Polygon": { + "fill": "#4DFF4D", + "fill-opacity": "0.7", + "graphic-fill": { + "size": 10, + "opacity": 1, + "rotation": 45, + "graphics": [{ + "mark": "shape://horline", + "stroke": "#000000", + "stroke-width": 2, + "stroke-opacity": 1 + }] + }, + "vendor-options": { + "graphic-margin": "2 2" + } + } + }]; + ReactDOM.render(, document.getElementById('container')); + const svg = document.querySelector('svg'); + const patterns = svg.querySelectorAll('pattern'); + const paths = svg.querySelectorAll('path'); + expect(patterns.length).toBeGreaterThan(0); + expect(paths.length).toBeGreaterThan(0); + expect(paths[0].getAttribute('fill')).toMatch(/^url\(#pattern-/); + }); + it('should render line icon with graphic-stroke pattern', () => { + const symbolizers = [{ + "Line": { + "stroke": "#AA3333", + "stroke-width": 2, + "stroke-opacity": 1, + "graphic-stroke": { + "size": 8, + "opacity": 1, + "graphics": [{ + "mark": "shape://vertline", + "stroke": "#AA3333", + "stroke-width": 2, + "stroke-opacity": 1 + }] + }, + "vendor-options": { + "graphic-margin": "1 1" + } + } + }]; + ReactDOM.render(, document.getElementById('container')); + const svg = document.querySelector('svg'); + const patterns = svg.querySelectorAll('pattern'); + const paths = svg.querySelectorAll('path'); + expect(patterns.length).toBeGreaterThan(0); + expect(paths.length).toBeGreaterThan(0); + expect(paths[0].getAttribute('stroke')).toMatch(/^url\(#pattern-/); + }); + it('should render multiple polygon symbolizers with patterns', () => { + const symbolizers = [{ + "Polygon": { + "fill": "#FF0000", + "fill-opacity": "0.8", + "graphic-fill": { + "size": 6, + "opacity": 1, + "graphics": [{ + "mark": "circle", + "fill": "#FFFFFF", + "fill-opacity": 1, + "stroke": "#FF0000", + "stroke-width": 1, + "stroke-opacity": 1 + }] + } + } + }, { + "Polygon": { + "fill": "#0000FF", + "fill-opacity": "0.5", + "graphic-fill": { + "size": 6, + "opacity": 1, + "graphics": [{ + "mark": "shape://slash", + "stroke": "#0000FF", + "stroke-width": 1, + "stroke-opacity": 1 + }] + } + } + }]; + ReactDOM.render(, document.getElementById('container')); + const svg = document.querySelector('svg'); + const patterns = svg.querySelectorAll('pattern'); + const paths = svg.querySelectorAll('path'); + expect(patterns.length).toBeGreaterThan(1); + expect(paths.length).toBeGreaterThan(1); + }); }); diff --git a/web/client/components/styleeditor/hooks/__tests__/useGraphicPattern-test.js b/web/client/components/styleeditor/hooks/__tests__/useGraphicPattern-test.js new file mode 100644 index 0000000000..d56a94fa02 --- /dev/null +++ b/web/client/components/styleeditor/hooks/__tests__/useGraphicPattern-test.js @@ -0,0 +1,135 @@ +/* + * 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 { act } from 'react-dom/test-utils'; +import useGraphicPattern from '../useGraphicPattern'; + +describe('useGraphicPattern', () => { + let hookResult; + + const TestComponent = ({ symbolizer, type }) => { + hookResult = useGraphicPattern(symbolizer, type); + return ; + }; + + beforeEach((done) => { + document.body.innerHTML = '
'; + hookResult = null; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById('container')); + document.body.innerHTML = ''; + hookResult = null; + setTimeout(done); + }); + + it('should return plain stroke and no defs for line without graphic-stroke', () => { + const symbolizer = { + stroke: '#AA3333', + 'stroke-width': 2 + }; + + act(() => { + ReactDOM.render( + , + document.getElementById('container') + ); + }); + + expect(hookResult).toBeTruthy(); + expect(hookResult.defs).toBe(null); + expect(hookResult.stroke).toBe('#AA3333'); + }); + + it('should return defs and pattern url stroke for line with graphic-stroke', () => { + const symbolizer = { + stroke: '#AA3333', + 'stroke-width': 2, + 'graphic-stroke': { + size: 8, + opacity: 1, + graphics: [{ + mark: 'shape://vertline', + stroke: '#AA3333', + 'stroke-width': 2, + 'stroke-opacity': 1 + }] + }, + 'vendor-options': { + 'graphic-margin': '1 1' + } + }; + + act(() => { + ReactDOM.render( + , + document.getElementById('container') + ); + }); + + expect(hookResult).toBeTruthy(); + expect(hookResult.defs).toExist(); + expect(hookResult.stroke).toMatch(/^url\(#pattern-/); + }); + + it('should return plain fill and no defs for polygon without graphic-fill', () => { + const symbolizer = { + fill: '#AA3333', + 'fill-width': 2 + }; + + act(() => { + ReactDOM.render( + , + document.getElementById('container') + ); + }); + + expect(hookResult).toBeTruthy(); + expect(hookResult.defs).toBe(null); + expect(hookResult.fill).toBe('#AA3333'); + }); + + it('should return defs and pattern url fill for polygon with graphic-fill', () => { + const symbolizer = { + fill: '#AA3333', + 'fill-width': 2, + 'graphic-fill': { + size: 8, + opacity: 1, + graphics: [{ + mark: 'shape://vertline', + fill: '#AA3333', + 'fill-width': 2, + 'fill-opacity': 1 + }] + }, + 'vendor-options': { + 'graphic-margin': '1 1' + } + }; + + act(() => { + ReactDOM.render( + , + document.getElementById('container') + ); + }); + + expect(hookResult).toBeTruthy(); + expect(hookResult.defs).toExist(); + expect(hookResult.fill).toMatch(/^url\(#pattern-/); + }); +}); + + diff --git a/web/client/components/styleeditor/hooks/useGraphicPattern.js b/web/client/components/styleeditor/hooks/useGraphicPattern.js new file mode 100644 index 0000000000..e7ac5b109a --- /dev/null +++ b/web/client/components/styleeditor/hooks/useGraphicPattern.js @@ -0,0 +1,35 @@ +/* + * 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, { useMemo } from "react"; +import { v4 as uuidv4 } from 'uuid'; +import GraphicPattern from "../GraphicPattern/GraphicPattern"; + +function useGraphicPattern(symbolizer, type) { + return useMemo(() => { + if (type === 'line' && !symbolizer?.["graphic-stroke"]) { + return { + defs: null, + stroke: symbolizer?.stroke + }; + } + if (type === 'polygon' && !symbolizer?.["graphic-fill"]) { + return { + defs: null, + fill: symbolizer?.fill + }; + } + const patternId = "pattern-" + uuidv4(); + return { + defs: , + ...(type === 'line' ? { stroke: `url(#${patternId})` } : {}), + ...(type === 'polygon' ? { fill: `url(#${patternId})` } : {}) + }; + }, [symbolizer, type]); +} + +export default useGraphicPattern;