Skip to content

Commit

Permalink
#9092 Styling classification for WFS and Vector layers (#9556)
Browse files Browse the repository at this point in the history
* #9092 Styling classification for WFS and Vector layers
  • Loading branch information
allyoucanmap authored Oct 4, 2023
1 parent be05b0e commit d834240
Show file tree
Hide file tree
Showing 9 changed files with 344 additions and 33 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]",
"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/[email protected]",
"react-draggable": "2.2.6",
"react-dropzone": "3.13.1",
"react-error-boundary": "1.2.5",
Expand Down Expand Up @@ -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",
Expand Down
153 changes: 153 additions & 0 deletions web/client/api/GeoJSONClassification.js
Original file line number Diff line number Diff line change
@@ -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);
39 changes: 19 additions & 20 deletions web/client/api/SLDService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
46 changes: 38 additions & 8 deletions web/client/api/StyleEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -210,14 +240,16 @@ 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({
values,
properties,
rules,
layer,
styleService
styleService,
classificationRequest = defaultClassificationRequest
}) {

let paramsKeys = [
Expand Down Expand Up @@ -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,
Expand Down
82 changes: 82 additions & 0 deletions web/client/api/__tests__/GeoJSONClassification-test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading

0 comments on commit d834240

Please sign in to comment.