• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

geosolutions-it / MapStore2 / 19697959845

26 Nov 2025 08:55AM UTC coverage: 76.67% (+0.005%) from 76.665%
19697959845

Pull #11733

github

web-flow
Merge 2cb166e47 into 8f0a40eca
Pull Request #11733: Add support for line traces classification

32302 of 50254 branches covered (64.28%)

18 of 21 new or added lines in 4 files covered. (85.71%)

3 existing lines in 2 files now uncovered.

40173 of 52397 relevant lines covered (76.67%)

37.81 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

55.07
/web/client/components/widgets/builder/wizard/chart/ChartClassification.jsx
1
/*
2
  * Copyright 2023, GeoSolutions Sas.
3
  * All rights reserved.
4
  *
5
  * This source code is licensed under the BSD-style license found in the
6
  * LICENSE file in the root directory of this source tree.
7
  */
8

9
import React, { useState, useEffect, useRef } from 'react';
10
import chroma from 'chroma-js';
11
import uuid from 'uuid/v1';
12
import ColorSelector from '../../../../style/ColorSelector';
13
import DebouncedFormControl from '../../../../misc/DebouncedFormControl';
14
import { FormGroup, ControlLabel, InputGroup, Checkbox, Button as ButtonRB, Glyphicon } from 'react-bootstrap';
15
import Select from "react-select";
16
import Message from "../../../../I18N/Message";
17
import ColorRamp from '../../../../styleeditor/ColorRamp';
18
import { ControlledPopover } from '../../../../styleeditor/Popover';
19
import multiProtocolChart from '../../../enhancers/multiProtocolChart';
20
import ThemeClassesEditor from '../../../../style/ThemaClassesEditor';
21
import { availableMethods } from '../../../../../api/GeoJSONClassification';
22
import { standardClassificationScales } from '../../../../../utils/ClassificationUtils';
23
import DisposablePopover from '../../../../misc/popover/DisposablePopover';
24
import Loader from '../../../../misc/Loader';
25
import withClassifyGeoJSONSync from '../../../../charts/withClassifyGeoJSONSync';
26
import HTML from '../../../../I18N/HTML';
27
import {
28
    generateClassifiedData,
29
    getAggregationAttributeDataKey,
30
    parsePieNoAggregationFunctionData
31
} from '../../../../../utils/WidgetsUtils';
32
import tooltip from '../../../../misc/enhancers/tooltip';
33

34
const Button = tooltip(ButtonRB);
1✔
35

36
const RAMP_PREVIEW_CLASSES = 5;
1✔
37

38
const rampOptions = standardClassificationScales
1✔
39
    .map((entry) => ({
42✔
40
        ...entry,
41
        colors: chroma.scale(entry.colors).colors(RAMP_PREVIEW_CLASSES)
42
    }));
43

44
const ComputeClassification = withClassifyGeoJSONSync(
1✔
45
    multiProtocolChart(({
46
        data,
47
        traces,
48
        chartType,
49
        classifyGeoJSONSync,
50
        onChangeStyle = () => {},
×
51
        loading
52
    }) => {
53
        const msClassification = traces?.[0]?.style?.msClassification || {};
×
54
        const groupByAttributesKey =  traces?.[0]?.options?.groupByAttributes;
×
55
        const classificationDataKey = traces?.[0]?.options?.classificationAttribute
×
56
            || groupByAttributesKey;
57
        const traceData = data?.[0] &&
×
58
            (traces?.[0]?.type === 'pie'
×
59
                ? parsePieNoAggregationFunctionData(data[0], traces?.[0]?.options)
60
                : data?.[0]);
61
        const classes = msClassification?.classes;
×
62
        const loader = loading ? <div style={{ display: 'flex', justifyContent: 'center' }}><Loader size={30} /></div> : null;
×
63
        if (!classes && !(classifyGeoJSONSync && traceData && classificationDataKey) || loading) {
×
64
            return loader;
×
65
        }
66
        const method = msClassification.method || 'uniqueInterval';
×
67
        const { classes: computedClasses } = !classes ? generateClassifiedData({
×
68
            type: traces?.[0]?.type,
69
            sortBy: traces?.[0]?.sortBy,
70
            data: traceData,
71
            options: traces?.[0]?.options,
72
            msClassification,
73
            classifyGeoJSON: classifyGeoJSONSync,
74
            excludeOthers: true,
75
            applyCustomSortFunctionOnClasses: true
76
        }) : [];
77
        return (
×
78
            <>
79
                <div style={{ display: 'flex', alignItems: 'center' }}>
80
                    <div style={{ flex: 1 }}><Message msgId="widgets.builder.wizard.classAttributes.classColor"/></div>
81
                    {method === 'uniqueInterval' ?
×
82
                        <div style={{ flex: 1 }}><Message msgId="widgets.builder.wizard.classAttributes.classValue"/></div>
83
                        : <>
84
                            <div style={{ flex: 1 }}><Message msgId="widgets.builder.wizard.classAttributes.minValue"/></div>
85
                            <div style={{ flex: 1 }}><Message msgId="widgets.builder.wizard.classAttributes.maxValue"/></div>
86
                        </>}
87
                    <div style={{ flex: 1 }}>
88
                        <Message msgId="widgets.builder.wizard.classAttributes.classLabel"/>{' '}
89
                        <DisposablePopover
90
                            popoverClassName="chart-color-class-popover"
91
                            placement="top"
92
                            title={<Message msgId="widgets.builder.wizard.classAttributes.customLabels" />}
93
                            text={<HTML msgId={method === 'uniqueInterval'
×
94
                                ? `widgets.builder.wizard.classAttributes.${chartType}ChartCustomLabelsExample`
95
                                : `widgets.builder.wizard.classAttributes.${chartType}RangeClassChartCustomLabelsExample`} />}
96
                        />
97
                    </div>
98
                    <div style={{ width: 30 }} />
99
                </div>
100
                <ThemeClassesEditor
101
                    classification={classes || (computedClasses || []).map(({ insideClass, index, label, ...entry }) => ({
×
102
                        ...entry,
103
                        id: uuid()
104
                    }))}
105
                    uniqueValuesClasses={method === 'uniqueInterval'}
106
                    autoCompleteOptions={traces?.[0]?.layer && {
×
107
                        classificationAttribute: classificationDataKey,
108
                        dropUpAutoComplete: true,
109
                        layer: traces[0].layer
110
                    }}
111
                    customLabels
112
                    onUpdateClasses={(newClasses) => onChangeStyle('msClassification.classes', newClasses)}
×
113
                />
114
            </>
115
        );
116
    })
117
);
118

119
function getScrollableParent(node) {
120
    if (node === null || node === document.body) {
24✔
121
        return null;
3✔
122
    }
123
    if (node.scrollHeight > node.clientHeight) {
21!
124
        return node;
×
125
    }
126
    return getScrollableParent(node.parentNode);
21✔
127
}
128

129
const CustomClassification = ({
1✔
130
    traces,
131
    containerNode,
132
    disabled,
133
    placement,
134
    onChangeStyle = () => {}
×
135
}) => {
136
    const [open, setOpen] = useState();
3✔
137
    const node = useRef();
3✔
138
    const msClassification = traces?.[0]?.style?.msClassification || {};
3✔
139
    const chartType = traces?.[0]?.type || '';
3!
140
    useEffect(() => {
3✔
141
        const onScroll = () => setOpen(false);
3✔
142
        const scrollableNode = getScrollableParent(node.current);
3✔
143
        if (scrollableNode) {
3!
144
            scrollableNode.addEventListener('scroll', onScroll);
×
145
        }
146
        return () => {
3✔
147
            if (scrollableNode) {
3!
148
                scrollableNode.removeEventListener('scroll', onScroll);
×
149
            }
150
        };
151
    }, []);
152
    return (
3✔
153
        <ControlledPopover
154
            open={open}
155
            onClick={() => setOpen(!open)}
×
156
            disabled={disabled}
157
            placement={placement}
158
            containerNode={containerNode}
159
            content={
160
                <div className="ms-wizard-chart-custom-classification">
161
                    <div className="ms-wizard-form-separator">
162
                        <Message msgId="widgets.builder.wizard.classAttributes.title" />
163
                        <Button
164
                            className="no-border square-button-md"
165
                            onClick={() => setOpen(false)}
×
166
                        >
167
                            <Glyphicon glyph="1-close"/>
168
                        </Button>
169
                    </div>
170
                    <FormGroup className="form-group-flex">
171
                        <ControlLabel><Message msgId={"widgets.builder.wizard.classAttributes.defaultColor"} /></ControlLabel>
172
                        <InputGroup>
173
                            <ColorSelector
174
                                format="rgb"
175
                                color={msClassification?.defaultColor || '#ffff00'}
6✔
176
                                onChangeColor={(color) => color && onChangeStyle('msClassification.defaultColor', color)}
×
177
                            />
178
                        </InputGroup>
179
                    </FormGroup>
180
                    <FormGroup className="form-group-flex">
181
                        <ControlLabel>
182
                            <Message msgId="widgets.builder.wizard.classAttributes.defaultClassLabel" />{' '}
183
                            <DisposablePopover
184
                                popoverClassName="chart-color-class-popover"
185
                                placement="top"
186
                                title={<Message msgId="widgets.builder.wizard.classAttributes.customLabels" />}
187
                                text={<HTML msgId={
188
                                    msClassification?.method === 'uniqueInterval' || !msClassification?.method
9✔
189
                                        ? `widgets.builder.wizard.classAttributes.${chartType}ChartCustomLabelsExample`
190
                                        : `widgets.builder.wizard.classAttributes.${chartType}RangeDefaultChartCustomLabelsExample`
191
                                } />}
192
                            />
193
                        </ControlLabel>
194
                        <InputGroup>
195
                            <DebouncedFormControl
196
                                value={msClassification?.defaultLabel || ''}
6✔
197
                                style={{ zIndex: 0 }}
198
                                onChange={eventValue => onChangeStyle('msClassification.defaultLabel', eventValue)}
×
199
                            />
200
                        </InputGroup>
201
                    </FormGroup>
202
                    <FormGroup className="form-group-flex">
203
                        <div style={{ width: '100%' }}>
204
                            {open && <ComputeClassification
3!
205
                                traces={traces}
206
                                onChangeStyle={onChangeStyle}
207
                                chartType={chartType}
208
                            />}
209
                        </div>
210
                    </FormGroup>
211
                    <div className="ms-wizard-chart-custom-classification-footer">
212
                        <Button
213
                            disabled={!msClassification?.classes}
214
                            onClick={() => onChangeStyle('msClassification.classes', undefined)}
×
215
                        >
216
                            <Message msgId="widgets.builder.wizard.classAttributes.removeCustomColors" />
217
                        </Button>
218
                    </div>
219
                </div>
220
            }
221
        >
222
            <Button
223
                disabled={disabled}
224
                bsStyle={msClassification?.classes ? 'success' : 'primary'}
3!
225
                tooltipId="widgets.builder.wizard.classAttributes.editCustomColors"
226
            >
227
                {/* using a default button to access the node ref */}
228
                <span className="glyphicon glyphicon-pencil" ref={node}/>
229
            </Button>
230
        </ControlledPopover>
231
    );
232
};
233

234
const isCustomClassificationAvailable = (trace) => {
1✔
235
    const groupByAttributesKey =  trace?.options?.groupByAttributes;
3✔
236
    const aggregationAttributeDataKey = getAggregationAttributeDataKey(trace?.options);
3✔
237
    const classificationDataKey = trace?.options?.classificationAttribute
3✔
238
        || groupByAttributesKey;
239
    return groupByAttributesKey && groupByAttributesKey && aggregationAttributeDataKey
3!
240
        && classificationDataKey;
241
};
242
/**
243
 * ChartClassification. A component that renders fields to change the trace style classification
244
 * @prop {object} data trace data
245
 * @prop {function} onChange callback on every input change
246
 * @prop {function} onChangeStyle callback on every style input change
247
 * @prop {array} options list of available attributes
248
 * @prop {array} traces list of traces supported by the chart
249
 * @prop {array} sortByOptions list of available sort by options
250
 */
251
const ChartClassification = ({
1✔
252
    data = {},
×
253
    onChangeStyle,
254
    onChange,
255
    options = [],
3✔
256
    traces,
257
    sortByOptions = [
3✔
258
        { value: 'groupBy', label: <Message msgId={`widgets.groupByAttributes.${data.type || "default"}`} /> },
3!
259
        { value: 'aggregation', label: <Message msgId={`widgets.aggregationAttribute.${data.type || "default"}`} /> }
3!
260
    ]
261
}) => {
262
    const {
263
        msClassification
264
    } = data.style || {};
3✔
265
    const classes = msClassification?.classes;
3✔
266
    const classesAvailable = !!classes;
3✔
267
    const classificationAttribute = data?.options?.classificationAttribute || data?.options?.groupByAttributes;
3✔
268
    const selectedAttribute = options.find((option) => option.value === classificationAttribute);
3✔
269
    const { filter, ...trace } = data; // remove filter to compute complete classification
3✔
270
    const disableClassificationAttribute = traces && traces.length > 1;
3!
271
    const hideClassificationAttribute = data.type === 'line';
3✔
272
    return (
3✔
273
        <>
274
            {!disableClassificationAttribute && <FormGroup className="form-group-flex">
6✔
275
                <ControlLabel>
276
                    <Message msgId="widgets.builder.wizard.classAttributes.classificationAttribute" />
277
                </ControlLabel>
278
                <InputGroup>
279
                    <Select
280
                        disabled={classesAvailable}
281
                        value={classificationAttribute}
282
                        options={options}
283
                        clearable={false}
284
                        onChange={(option) => {
285
                            onChangeStyle('msClassification', {
×
286
                                intervals: 5,
287
                                ramp: 'viridis',
288
                                reverse: false,
289
                                ...msClassification,
290
                                method: option?.type === 'string'
×
291
                                    ? 'uniqueInterval'
292
                                    : selectedAttribute.type === 'string'
×
293
                                        ? 'jenks'
294
                                        : msClassification?.method || 'jenks'
×
295
                            });
296
                            onChange('options.classificationAttribute', option?.value);
×
297
                        }}
298
                    />
299
                </InputGroup>
300
            </FormGroup>}
301
            <FormGroup className="form-group-flex">
302
                <ControlLabel><Message msgId={'styleeditor.method'} /></ControlLabel>
303
                <InputGroup>
304
                    <Select
305
                        disabled={classesAvailable || selectedAttribute?.type === 'string'}
6✔
306
                        value={msClassification?.method || 'uniqueInterval'}
5✔
307
                        clearable={false}
308
                        options={availableMethods.map((value) => ({
12✔
309
                            value,
310
                            label: <Message msgId={`styleeditor.${value}`} />
311
                        }))}
312
                        onChange={(option) => onChangeStyle('msClassification.method', option?.value)}
×
313
                    />
314
                </InputGroup>
315
            </FormGroup>
316
            {!hideClassificationAttribute && (
5✔
317
                <FormGroup className="form-group-flex">
318
                    <ControlLabel><Message msgId="widgets.advanced.sortBy" /></ControlLabel>
319
                    <InputGroup>
320
                        <Select
321
                            disabled={classesAvailable}
322
                            value={data?.sortBy || (data.type === 'pie' ? 'aggregation' : 'groupBy')}
6✔
323
                            clearable={false}
324
                            options={sortByOptions}
NEW
325
                            onChange={(option) => onChange('sortBy', option?.value)}
×
326
                        />
327
                    </InputGroup>
328
                </FormGroup>
329
            )}
330
            <FormGroup className="form-group-flex">
331
                <ControlLabel><Message msgId={'styleeditor.colorRamp'} /></ControlLabel>
332
                <InputGroup>
333
                    <ColorRamp
334
                        disabled={classesAvailable}
335
                        items={classesAvailable ? [{
3!
336
                            name: 'custom',
337
                            label: 'global.colors.custom',
338
                            colors: classes.map(({ color }) => color)
×
339
                        }] : rampOptions}
340
                        rampFunction={({ colors }) => colors}
126✔
341
                        samples={RAMP_PREVIEW_CLASSES}
342
                        value={{ name: classesAvailable ? 'custom' : msClassification?.ramp }}
3!
343
                        onChange={ramp => onChangeStyle('msClassification.ramp', ramp.name)}
×
344
                    />
345
                    <InputGroup.Button>
346
                        <CustomClassification
347
                            traces={[trace]}
348
                            placement="right"
349
                            onChangeStyle={onChangeStyle}
350
                            onChange={onChange}
351
                            disabled={!isCustomClassificationAvailable(data)}
352
                        />
353
                    </InputGroup.Button>
354
                </InputGroup>
355
            </FormGroup>
356
            <FormGroup className="form-group-flex">
357
                <ControlLabel><Message msgId={'styleeditor.intervals'} /></ControlLabel>
358
                <InputGroup style={{ maxWidth: 80 }}>
359
                    <DebouncedFormControl
360
                        type="number"
361
                        disabled={msClassification?.method === 'uniqueInterval' || classesAvailable}
6✔
362
                        value={msClassification?.intervals}
363
                        min={2}
364
                        max={25}
365
                        fallbackValue={5}
366
                        style={{ zIndex: 0 }}
367
                        onChange={eventValue => onChangeStyle('msClassification.intervals', eventValue)}
×
368
                    />
369
                </InputGroup>
370
            </FormGroup>
371
            <FormGroup className="form-group-flex">
372
                <Checkbox
373
                    disabled={classesAvailable}
374
                    checked={!!msClassification?.reverse}
375
                    onChange={(event) => { onChangeStyle('msClassification.reverse', event?.target?.checked); }}
×
376
                >
377
                    <Message msgId="widgets.advanced.reverseRampColor" />
378
                </Checkbox>
379
            </FormGroup>
380
            {!hideClassificationAttribute && (
5✔
381
                <>
382
                    <FormGroup className="form-group-flex">
383
                        <ControlLabel><Message msgId={'styleeditor.outlineColor'} /></ControlLabel>
384
                        <InputGroup>
385
                            <ColorSelector
386
                                format="rgb"
387
                                color={data?.style?.marker?.line?.color}
388
                                line
NEW
389
                                onChangeColor={(color) => color && onChangeStyle('marker.line.color', color)}
×
390
                            />
391
                        </InputGroup>
392
                    </FormGroup>
393
                    <FormGroup className="form-group-flex">
394
                        <ControlLabel><Message msgId={'styleeditor.outlineWidth'} /></ControlLabel>
395
                        <InputGroup style={{ maxWidth: 80 }}>
396
                            <DebouncedFormControl
397
                                type="number"
398
                                value={data?.style?.marker?.line?.width}
399
                                min={0}
400
                                fallbackValue={0}
401
                                style={{ zIndex: 0 }}
NEW
402
                                onChange={eventValue => onChangeStyle('marker.line.width', eventValue)}
×
403
                            />
404
                        </InputGroup>
405
                    </FormGroup>
406
                </>
407
            )}
408
        </>
409
    );
410
};
411

412
export default ChartClassification;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc