diff --git a/package.json b/package.json index 25202e5bf1..a20f663ece 100644 --- a/package.json +++ b/package.json @@ -244,12 +244,12 @@ "react-copy-to-clipboard": "5.0.0", "react-data-grid": "5.0.4", "react-data-grid-addons": "5.0.4", - "react-draft-wysiwyg": "npm:@geosolutions/react-draft-wysiwyg@1.14.8", "react-dnd": "2.6.0", "react-dnd-html5-backend": "2.6.0", "react-dnd-test-backend": "2.6.0", "react-dock": "0.2.4", "react-dom": "16.10.1", + "react-draft-wysiwyg": "npm:@geosolutions/react-draft-wysiwyg@1.14.8", "react-draggable": "2.2.6", "react-dropzone": "3.13.1", "react-error-boundary": "1.2.5", @@ -292,6 +292,7 @@ "rxjs": "5.1.1", "screenfull": "4.0.0", "shpjs": "3.4.2", + "simple-statistics": "7.8.3", "stickybits": "3.6.6", "stream": "0.0.2", "tinycolor2": "1.4.1", diff --git a/web/client/api/GeoJSONClassification.js b/web/client/api/GeoJSONClassification.js new file mode 100644 index 0000000000..1259499969 --- /dev/null +++ b/web/client/api/GeoJSONClassification.js @@ -0,0 +1,153 @@ +/* + * Copyright 2023, 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 uniq from 'lodash/uniq'; +import isNil from 'lodash/isNil'; +import chroma from 'chroma-js'; +import { defaultClassificationColors } from './SLDService'; + +const getSimpleStatistics = () => import('simple-statistics').then(mod => mod); + +const getColorClasses = ({ ramp, intervals, reverse }) => { + const scale = defaultClassificationColors[ramp] || ramp; + const colorClasses = chroma.scale(scale).colors(intervals); + return reverse ? [...colorClasses].reverse() : colorClasses; +}; +/** + * Classify an array of features with quantile method + * @param {object} features array of GeoJSON features + * @param {object} params parameters to compute the classification + * @param {string} params.attribute the name of the attribute to use for classification + * @param {number} params.intervals number of expected classes + * @param {string} params.ramp the identifier of the color ramp + * @param {boolean} params.reverse reverse the ramp color classification + * @returns {promise} return classification object + */ +const quantile = (features, params) => getSimpleStatistics().then(({ quantileSorted }) => { + const values = features.map(feature => feature?.properties?.[params.attribute]).filter(value => !isNil(value)).sort((a, b) => a - b); + const intervals = params.intervals; + const classes = [...[...new Array(intervals).keys()].map((n) => n / intervals), 1].map((p) => quantileSorted(values, p)); + const colors = getColorClasses({ ...params, intervals }); + return { + data: { + classification: classes.reduce((acc, min, idx) => { + const max = classes[idx + 1]; + if (max !== undefined) { + const color = colors[idx]; + return [ ...acc, { color, min, max }]; + } + return acc; + }, []) + } + }; +}); +/** + * Classify an array of features with jenks method + * @param {object} features array of GeoJSON features + * @param {object} params parameters to compute the classification + * @param {string} params.attribute the name of the attribute to use for classification + * @param {number} params.intervals number of expected classes + * @param {string} params.ramp the identifier of the color ramp + * @param {boolean} params.reverse reverse the ramp color classification + * @returns {promise} return classification object + */ +const jenks = (features, params) => getSimpleStatistics().then(({ jenks: jenksMethod }) => { + const values = features.map(feature => feature?.properties?.[params.attribute]).filter(value => !isNil(value)).sort((a, b) => a - b); + const paramIntervals = params.intervals; + const intervals = paramIntervals > values.length ? values.length : paramIntervals; + const classes = jenksMethod(values, intervals); + const colors = getColorClasses({ ...params, intervals }); + return { + data: { + classification: classes.reduce((acc, min, idx) => { + const max = classes[idx + 1]; + if (max !== undefined) { + const color = colors[idx]; + return [ ...acc, { color, min, max }]; + } + return acc; + }, []) + } + }; +}); +/** + * Classify an array of features with equal interval method + * @param {object} features array of GeoJSON features + * @param {object} params parameters to compute the classification + * @param {string} params.attribute the name of the attribute to use for classification + * @param {number} params.intervals number of expected classes + * @param {string} params.ramp the identifier of the color ramp + * @param {boolean} params.reverse reverse the ramp color classification + * @returns {promise} return classification object + */ +const equalInterval = (features, params) => getSimpleStatistics().then(({ equalIntervalBreaks }) => { + const values = features.map(feature => feature?.properties?.[params.attribute]).filter(value => !isNil(value)).sort((a, b) => a - b); + const classes = equalIntervalBreaks(values, params.intervals); + const colors = getColorClasses(params); + return { + data: { + classification: classes.reduce((acc, min, idx) => { + const max = classes[idx + 1]; + if (max !== undefined) { + const color = colors[idx]; + return [ ...acc, { color, min, max }]; + } + return acc; + }, []) + } + }; +}); +/** + * Classify an array of features with unique interval method + * @param {object} features array of GeoJSON features + * @param {object} params parameters to compute the classification + * @param {string} params.attribute the name of the attribute to use for classification + * @param {string} params.ramp the identifier of the color ramp + * @param {boolean} params.reverse reverse the ramp color classification + * @returns {promise} return classification object + */ +const uniqueInterval = (features, params) => { + const classes = uniq(features.map(feature => feature?.properties?.[params.attribute])).sort((a, b) => a > b ? 1 : -1); + const colors = getColorClasses({ ...params, intervals: classes.length }); + return Promise.resolve({ + data: { + classification: classes.map((value, idx) => { + return { + color: colors[idx], + unique: value + }; + }) + } + }); +}; + +const methods = { + quantile, + jenks, + equalInterval, + uniqueInterval +}; +/** + * Classify a GeoJSON feature collection + * @param {object} geojson a GeoJSON feature collection + * @param {object} params parameters to compute the classification + * @param {string} params.method classification methods, one of: `quantile`, `jenks`, `equalInterval` or `uniqueInterval` + * @param {string} params.attribute the name of the attribute to use for classification + * @param {number} params.intervals number of expected classes + * @param {string} params.ramp the identifier of the color ramp + * @param {boolean} params.reverse reverse the ramp color classification + * @returns {promise} return classification object + */ +export const classifyGeoJSON = (geojson, params) => { + const features = geojson.type === 'FeatureCollection' + ? geojson.features + : []; + return methods[params.method](features, params); +}; + +export const availableMethods = Object.keys(methods); diff --git a/web/client/api/SLDService.js b/web/client/api/SLDService.js index a6e2bc0f8c..5deda63236 100644 --- a/web/client/api/SLDService.js +++ b/web/client/api/SLDService.js @@ -50,23 +50,21 @@ const getCustomClassification = (classification) => { return {}; }; -const standardColors = [{ - name: 'red', - colors: ['#000', '#f00'] -}, { - name: 'green', - colors: ['#000', '#008000', '#0f0'] -}, { - name: 'blue', - colors: ['#000', '#00f'] -}, { - name: 'gray', - colors: ['#333', '#eee'] -}, { - name: 'jet', - colors: ['#00f', '#ff0', '#f00'] -}, -...supportedColorBrewer]; +export const defaultClassificationColors = { + red: ['#000', '#f00'], + green: ['#000', '#008000', '#0f0'], + blue: ['#000', '#00f'], + gray: ['#333', '#eee'], + jet: ['#00f', '#ff0', '#f00'] +}; + +const standardColors = [ + ...Object.keys(defaultClassificationColors).map(name => ({ + name, + colors: defaultClassificationColors[name] + })), + ...supportedColorBrewer +]; const getColor = (layer, name, intervals, customRamp) => { const chosenColors = layer @@ -431,9 +429,10 @@ const API = { return colors.map((color) => !isString(color.colors) && color.colors.length >= samples ? color - : assign({}, color, { - colors: chroma.scale(color.colors).colors(samples) - })); + : { + ...color, + colors: chroma.scale(color.colors.length === 1 ? [color.colors[0], color.colors[0]] : color.colors).colors(samples) + }); }, /** * Checks if the given layer has a thematic style applied on it (SLD param not empty) diff --git a/web/client/api/StyleEditor.js b/web/client/api/StyleEditor.js index 92ce5a64db..52d42f243d 100644 --- a/web/client/api/StyleEditor.js +++ b/web/client/api/StyleEditor.js @@ -116,6 +116,9 @@ const getRasterClassificationError = (params, errorMsg) => { * @returns {object} return classification */ const updateRulesWithColors = (data, params) => { + if (data.classification) { + return { classification: data.classification }; + } const _rules = get(data, 'Rules.Rule'); const _rulesRaster = _rules && get(_rules, 'RasterSymbolizer.ColorMap.ColorMapEntry'); const intervalsForUnique = params.type === "classificationRaster" @@ -201,6 +204,33 @@ const API = { export function updateStyleService({ baseUrl, styleService }) { return API.geoserver.updateStyleService({ baseUrl, styleService }); } +/** + * Default classification promise that uses the SLD service + * @param {object} config configuration properties + * @param {object} config.layer WMS layer options + * @param {object} config.params parameters for a SLD service classification { intervals, method, attribute, intervalsForUnique } + * @param {object} config.params.intervals number of intervals of the classification + * @param {object} config.params.method classification method + * @param {object} config.params.attribute feature attribute to classify + * @param {object} config.params.intervalsForUnique maximum of number of interval for `uniqueInterval` method + * @param {object} config.styleService the style service information { baseUrl, isStatic } + * @param {string} config.styleService.baseUrl base url of a GeoServer supporting sldservice rest endpoint + * @param {string} config.styleService.isStatic if false it tries to request the layer info based on WMS layer object, if true uses the baseUrl + * @returns {promise} return classification from an SLD service + */ +const defaultClassificationRequest = ({ + layer, + params, + styleService +}) => { + const paramSLDService = { + intervals: params.intervals, + method: params.method, + attribute: params.attribute, + intervalsForUnique: params.intervalsForUnique + }; + return axios.get(SLDService.getStyleMetadataService(layer, paramSLDService, styleService)); +}; /** * Update rules of a style for a vector layer using external SLD services * @memberof API.StyleEditor @@ -210,6 +240,7 @@ export function updateStyleService({ baseUrl, styleService }) { * @param {array} rules rules of a style object * @param {object} layer layer configuration object * @param {object} styleService style service configuration object + * @param {function} classificationRequest a function that allow to override the classification promise, it should return a valid classification object * @returns {promise} return new rules with updated property and classification */ export function classificationVector({ @@ -217,7 +248,8 @@ export function classificationVector({ properties, rules, layer, - styleService + styleService, + classificationRequest = defaultClassificationRequest }) { let paramsKeys = [ @@ -272,13 +304,11 @@ export function classificationVector({ }; if (needsRequest) { - const paramSLDService = { - intervals: params.intervals, - method: params.method, - attribute: params.attribute, - intervalsForUnique: params.intervalsForUnique - }; - return axios.get(SLDService.getStyleMetadataService(layer, paramSLDService, styleService)) + return classificationRequest({ + layer, + params, + styleService + }) .then(({ data }) => { return updateRules(ruleId, rules, (rule) => ({ ...rule, diff --git a/web/client/api/__tests__/GeoJSONClassification-test.js b/web/client/api/__tests__/GeoJSONClassification-test.js new file mode 100644 index 0000000000..068d5e577a --- /dev/null +++ b/web/client/api/__tests__/GeoJSONClassification-test.js @@ -0,0 +1,82 @@ +/* + * Copyright 2023, 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 expect from 'expect'; +import shuffle from 'lodash/shuffle'; +import { classifyGeoJSON } from '../GeoJSONClassification'; + +describe('GeoJSONClassification APIs', () => { + const geojson = { + type: 'FeatureCollection', + features: shuffle([...new Array(50).keys()]).map(value => ({ type: 'Feature', properties: { value, category: `category-${value % 2}` }, geometry: null })) + }; + it('classify GeoJSON with quantile method', (done) => { + classifyGeoJSON(geojson, { attribute: 'value', method: 'quantile', ramp: 'viridis', intervals: 5 }) + .then(({ data }) => { + expect(data.classification).toEqual([ + { color: '#440154', min: 0, max: 9.5 }, + { color: '#3f4a8a', min: 9.5, max: 19.5 }, + { color: '#26838f', min: 19.5, max: 29.5 }, + { color: '#6cce5a', min: 29.5, max: 39.5 }, + { color: '#fee825', min: 39.5, max: 49 } + ]); + done(); + }) + .catch(done); + }); + it('classify GeoJSON with jenks method', (done) => { + classifyGeoJSON(geojson, { attribute: 'value', method: 'jenks', ramp: 'viridis', intervals: 5 }) + .then(({ data }) => { + expect(data.classification).toEqual([ + { color: '#440154', min: 0, max: 10 }, + { color: '#3f4a8a', min: 10, max: 20 }, + { color: '#26838f', min: 20, max: 30 }, + { color: '#6cce5a', min: 30, max: 40 }, + { color: '#fee825', min: 40, max: 49 } + ]); + done(); + }) + .catch(done); + }); + it('classify GeoJSON with equalInterval method', (done) => { + classifyGeoJSON(geojson, { attribute: 'value', method: 'equalInterval', ramp: 'viridis', intervals: 5 }) + .then(({ data }) => { + expect(data.classification).toEqual([ + { color: '#440154', min: 0, max: 9.8 }, + { color: '#3f4a8a', min: 9.8, max: 19.6 }, + { color: '#26838f', min: 19.6, max: 29.400000000000002 }, + { color: '#6cce5a', min: 29.400000000000002, max: 39.2 }, + { color: '#fee825', min: 39.2, max: 49 } + ]); + done(); + }) + .catch(done); + }); + it('classify GeoJSON with uniqueInterval method', (done) => { + classifyGeoJSON(geojson, { attribute: 'category', method: 'uniqueInterval', ramp: 'viridis' }) + .then(({ data }) => { + expect(data.classification).toEqual([ + { color: '#440154', unique: 'category-0' }, + { color: '#fee825', unique: 'category-1' } + ]); + done(); + }) + .catch(done); + }); + it('classify GeoJSON with uniqueInterval method and reverse equal to true', (done) => { + classifyGeoJSON(geojson, { attribute: 'category', method: 'uniqueInterval', ramp: 'viridis', reverse: true }) + .then(({ data }) => { + expect(data.classification).toEqual([ + { color: '#fee825', unique: 'category-0' }, + { color: '#440154', unique: 'category-1' } + ]); + done(); + }) + .catch(done); + }); +}); diff --git a/web/client/components/styleeditor/ClassificationSymbolizer.jsx b/web/client/components/styleeditor/ClassificationSymbolizer.jsx index 488fccc516..696613f7b7 100644 --- a/web/client/components/styleeditor/ClassificationSymbolizer.jsx +++ b/web/client/components/styleeditor/ClassificationSymbolizer.jsx @@ -27,6 +27,8 @@ function ClassificationSymbolizer({ symbolizerBlock = {}, bands, config, + supportedSymbolizerMenuOptions, + fonts, ...props }) { @@ -92,6 +94,7 @@ function ClassificationSymbolizer({ onSelect={onReplace} ruleBlock={ruleBlock} symbolizerBlock={symbolizerBlock} + supportedOptions={supportedSymbolizerMenuOptions} />}> onUpdate({ diff --git a/web/client/components/styleeditor/RulesEditor.jsx b/web/client/components/styleeditor/RulesEditor.jsx index dbd863dbaa..7ac7be907d 100644 --- a/web/client/components/styleeditor/RulesEditor.jsx +++ b/web/client/components/styleeditor/RulesEditor.jsx @@ -97,6 +97,7 @@ const RulesEditor = forwardRef(({ simple, svgSymbolsPath, lineDashOptions, + supportedSymbolizerMenuOptions, enableFieldExpression } = config; @@ -347,10 +348,12 @@ const RulesEditor = forwardRef(({ glyph={ruleGlyph} classificationType={classificationType} config={classification || {}} + supportedSymbolizerMenuOptions={supportedSymbolizerMenuOptions} params={ruleParams} methods={methods} getColors={getColors} bands={bands} + fonts={fonts} attributes={attributes && attributes.map((attribute) => ({ ...attribute, ...( rule.method === "customInterval" @@ -374,6 +377,7 @@ const RulesEditor = forwardRef(({ tools={ (!simple && !supportedOptions || supportedOptions.includes(option.value)); const { defaultProperties, params = {} } = symbolizerKind ? symbolizerBlock[symbolizerKind] diff --git a/web/client/plugins/styleeditor/VectorStyleEditor.jsx b/web/client/plugins/styleeditor/VectorStyleEditor.jsx index 79779de3f6..d914a200c2 100644 --- a/web/client/plugins/styleeditor/VectorStyleEditor.jsx +++ b/web/client/plugins/styleeditor/VectorStyleEditor.jsx @@ -26,6 +26,12 @@ import { } from '../../utils/VectorStyleUtils'; import { getCapabilities } from '../../api/ThreeDTiles'; import { describeFeatureType } from '../../api/WFS'; +import { classificationVector } from '../../api/StyleEditor'; +import SLDService from '../../api/SLDService'; +import { classifyGeoJSON, availableMethods } from '../../api/GeoJSONClassification'; +import { getLayerJSONFeature } from '../../observables/wfs'; + +const { getColors } = SLDService; const editors = { visual: VisualStyleEditor, @@ -130,10 +136,12 @@ function VectorStyleEditor({ } const isMounted = useRef(); + const geojson = useRef(); useEffect(() => { isMounted.current = true; return () => { isMounted.current = false; + geojson.current = undefined; }; }, []); @@ -186,9 +194,26 @@ function VectorStyleEditor({ const { format, metadata, body } = style.current || {}; const { editorType, styleJSON } = metadata || {}; + function getLayerFeatureCollection() { + if (geojson.current) { + return Promise.resolve(geojson.current); + } + if (layer.type === 'vector') { + return Promise.resolve({ type: 'FeatureCollection', features: layer.features }); + } + if (layer.type === 'wfs') { + return getLayerJSONFeature(layer).toPromise().then(({ features }) => { + geojson.current = { type: 'FeatureCollection', features }; + return geojson.current; + }); + } + return Promise.resolve({ type: 'FeatureCollection', features: [] }); + } + return ( { + return classificationVector({ + ...options, + classificationRequest: ({ params }) => getLayerFeatureCollection() + .then((collection) => classifyGeoJSON(collection, params)) + }); + } + }} config={{ - simple: true, + simple: !['wfs', 'vector'].includes(layer?.type), + supportedSymbolizerMenuOptions: ['Simple', 'Classification'], fonts, enableFieldExpression: ['vector', 'wfs'].includes(layer.type) }}