diff --git a/src/components/edit/earthEngine/PeriodSelect.jsx b/src/components/edit/earthEngine/PeriodSelect.jsx index 72f77fb7c..ef36f3440 100644 --- a/src/components/edit/earthEngine/PeriodSelect.jsx +++ b/src/components/edit/earthEngine/PeriodSelect.jsx @@ -16,7 +16,7 @@ import { SelectField } from '../../core/index.js' import styles from './styles/PeriodSelect.module.css' const isValidDate = (d) => { - return d instanceof Date && !isNaN(d) + return d instanceof Date && !Number.isNaN(d) } const normalizeToDayBefore2359 = (date) => { const d = new Date(date) @@ -80,7 +80,7 @@ const EarthEnginePeriodSelect = ({ let name = e.name if (name.includes(AVAILABLE_UP_TO)) { const regex = new RegExp(`\\s*\\(${AVAILABLE_UP_TO}.*\\)$`) - name = name.replace(regex, '') + name = name.replaceAll(regex, '') } onChange({ ...e, diff --git a/src/components/edit/thematic/RadiusSelect.jsx b/src/components/edit/thematic/RadiusSelect.jsx index 4623bb5a6..155ef8a62 100644 --- a/src/components/edit/thematic/RadiusSelect.jsx +++ b/src/components/edit/thematic/RadiusSelect.jsx @@ -17,8 +17,8 @@ export const isValidRadius = ( radiusLow = THEMATIC_RADIUS_LOW, radiusHigh = THEMATIC_RADIUS_HIGH ) => - !isNaN(radiusLow) && - !isNaN(radiusHigh) && + !Number.isNaN(radiusLow) && + !Number.isNaN(radiusHigh) && radiusLow <= radiusHigh && radiusLow >= THEMATIC_RADIUS_MIN && radiusHigh <= THEMATIC_RADIUS_MAX @@ -33,7 +33,7 @@ const RadiusSelect = ({
diff --git a/src/components/layers/overlays/Layer.jsx b/src/components/layers/overlays/Layer.jsx index b1debc116..9c8800b41 100644 --- a/src/components/layers/overlays/Layer.jsx +++ b/src/components/layers/overlays/Layer.jsx @@ -7,7 +7,9 @@ import styles from './styles/Layer.module.css' const Layer = ({ layer, onClick }) => { const { img, type, name } = layer const label = name || i18n.t(type) - const dataTest = `addlayeritem-${label.toLowerCase().replace(/\s/g, '_')}` + const dataTest = `addlayeritem-${label + .toLowerCase() + .replaceAll(/\s/g, '_')}` return (
getLegendItemForValue({ value, + valueFormat, legendItems: legend.items.filter((item) => !item.noData), - clamp: !legendSet, + clamp: method !== CLASSIFICATION_PREDEFINED, }) if (legendSet && Array.isArray(legend.items) && legend.items.length >= 2) { - minValue = legend.items[0].startValue - maxValue = legend.items[legend.items.length - 1].endValue + const regularItems = legend.items.filter((item) => !item.noData) + minValue = regularItems[0].startValue + maxValue = regularItems.at(-1).endValue } const getRadiusForValue = scaleSqrt() diff --git a/src/util/__tests__/classify.spec.js b/src/util/__tests__/classify.spec.js index 9db2e936f..b22c8ed68 100644 --- a/src/util/__tests__/classify.spec.js +++ b/src/util/__tests__/classify.spec.js @@ -65,13 +65,28 @@ describe('getLegendItemForValue', () => { getLegendItemForValue({ value: -1, legendItems }) ).toBeUndefined() }) + + it('applies valueFormat to value before lookup', () => { + // 9.999 formatted to 2 decimals → 10.00, which falls in the second bin [10, 20) + expect( + getLegendItemForValue({ + value: 9.999, + valueFormat: (v) => Number(v.toFixed(2)), + legendItems, + }) + ).toEqual(legendItems[1]) + }) }) describe('getLegendItems', () => { it('returns equal intervals for CLASSIFICATION_EQUAL_INTERVALS', () => { const values = [0, 100] - const result = getLegendItems(values, CLASSIFICATION_EQUAL_INTERVALS, 4) - expect(result).toEqual([ + const { items } = getLegendItems( + values, + CLASSIFICATION_EQUAL_INTERVALS, + 4 + ) + expect(items).toEqual([ { startValue: 0.0, endValue: 25.0 }, { startValue: 25.0, endValue: 50.0 }, { startValue: 50.0, endValue: 75.0 }, @@ -81,8 +96,8 @@ describe('getLegendItems', () => { it('returns quantiles for CLASSIFICATION_EQUAL_COUNTS', () => { const values = [1, 2, 3, 4, 5, 6] - const result = getLegendItems(values, CLASSIFICATION_EQUAL_COUNTS, 3) - expect(result).toEqual([ + const { items } = getLegendItems(values, CLASSIFICATION_EQUAL_COUNTS, 3) + expect(items).toEqual([ { startValue: 1.0, endValue: 3.0 }, { startValue: 3.0, endValue: 5.0 }, { startValue: 5.0, endValue: 6.0 }, @@ -90,7 +105,16 @@ describe('getLegendItems', () => { }) it('returns undefined if method is unknown', () => { - const result = getLegendItems([0, 100], 'UNKNOWN', 3) - expect(result).toBeUndefined() + const { items } = getLegendItems([0, 100], 'UNKNOWN', 3) + expect(items).toBeUndefined() + }) + + it('returns a valueFormat function for known methods', () => { + const { valueFormat } = getLegendItems( + [0, 100], + CLASSIFICATION_EQUAL_INTERVALS, + 4 + ) + expect(typeof valueFormat).toBe('function') }) }) diff --git a/src/util/__tests__/legend.spec.js b/src/util/__tests__/legend.spec.js index dd6c9e94f..115860109 100644 --- a/src/util/__tests__/legend.spec.js +++ b/src/util/__tests__/legend.spec.js @@ -6,6 +6,7 @@ import { import { defaultClasses, defaultColorScale } from '../colors.js' import { loadDataItemLegendSet, + sortLegendItems, formatLegendItems, getBinsFromLegendItems, getColorScaleFromLegendItems, @@ -15,6 +16,61 @@ import { getRenderingLabel, } from '../legend.js' +describe('sortLegendItems', () => { + it('sorts items by startValue descending', () => { + const items = [ + { startValue: 20, endValue: 30 }, + { startValue: 0, endValue: 10 }, + { startValue: 10, endValue: 20 }, + ] + expect(sortLegendItems(items).map((i) => i.startValue)).toEqual([ + 20, 10, 0, + ]) + }) + + it('sorts items with from/to keys descending', () => { + const items = [ + { from: 5, to: 10 }, + { from: 0, to: 5 }, + { from: 10, to: 15 }, + ] + expect(sortLegendItems(items).map((i) => i.from)).toEqual([10, 5, 0]) + }) + + it('places items without range keys at the end', () => { + const items = [ + { startValue: 10, endValue: 20 }, + { name: 'Other', color: 'grey' }, + { startValue: 0, endValue: 10 }, + ] + const sorted = sortLegendItems(items) + expect(sorted[0].startValue).toBe(10) + expect(sorted[1].startValue).toBe(0) + expect(sorted[2].name).toBe('Other') + }) + + it('does not mutate the original array', () => { + const items = [ + { startValue: 10, endValue: 20 }, + { startValue: 0, endValue: 10 }, + ] + const copy = [...items] + sortLegendItems(items) + expect(items).toEqual(copy) + }) + + it('sorts by endValue descending when startValues are equal', () => { + const items = [ + { startValue: 0, endValue: 10 }, + { startValue: 0, endValue: 20 }, + { startValue: 0, endValue: 15 }, + ] + expect(sortLegendItems(items).map((i) => i.endValue)).toEqual([ + 20, 15, 10, + ]) + }) +}) + describe('legend utils', () => { describe('loadDataItemLegendSet', () => { it('returns null when no dataItem provided', async () => { @@ -96,12 +152,12 @@ describe('legend utils', () => { describe('getAutomaticLegendItems', () => { it('returns items with colors from default color scale', () => { const data = [1, 2, 3, 4, 5] - const items = getAutomaticLegendItems( + const { items } = getAutomaticLegendItems({ data, - CLASSIFICATION_EQUAL_INTERVALS, - defaultClasses, - defaultColorScale - ) + method: CLASSIFICATION_EQUAL_INTERVALS, + classes: defaultClasses, + colorScale: defaultColorScale, + }) expect(items.length).toBeGreaterThan(0) // each item should have a color from the provided colorScale items.forEach((item, idx) => { @@ -110,14 +166,25 @@ describe('legend utils', () => { }) it('returns empty array when no data', () => { - const items = getAutomaticLegendItems( - [], - CLASSIFICATION_EQUAL_INTERVALS, - defaultClasses, - defaultColorScale - ) + const { items } = getAutomaticLegendItems({ + data: [], + method: CLASSIFICATION_EQUAL_INTERVALS, + classes: defaultClasses, + colorScale: defaultColorScale, + }) expect(items).toEqual([]) }) + + it('returns a valueFormat function alongside items', () => { + const { items, valueFormat } = getAutomaticLegendItems({ + data: [0, 50, 100], + method: CLASSIFICATION_EQUAL_INTERVALS, + classes: 3, + colorScale: defaultColorScale, + }) + expect(items.length).toBe(3) + expect(typeof valueFormat).toBe('function') + }) }) describe('getRenderingLabel', () => { diff --git a/src/util/classify.js b/src/util/classify.js index bc11ae5c9..cd98c3f4a 100644 --- a/src/util/classify.js +++ b/src/util/classify.js @@ -10,12 +10,16 @@ import { getRoundToPrecisionFn } from './numbers.js' // Returns legend item where a value belongs export const getLegendItemForValue = ({ value, + valueFormat, legendItems, clamp = false, }) => { if (!hasValue(value) || legendItems.length === 0) { return } + if (valueFormat) { + value = valueFormat(value) + } if (clamp) { if (value < legendItems[0].startValue) { @@ -37,15 +41,15 @@ export const getLegendItemForValue = ({ export const getLegendItems = (values, method, numClasses) => { const minValue = values[0] const maxValue = values[values.length - 1] - let bins + let classification if (method === CLASSIFICATION_EQUAL_INTERVALS) { - bins = getEqualIntervals(minValue, maxValue, numClasses) + classification = getEqualIntervals(minValue, maxValue, numClasses) } else if (method === CLASSIFICATION_EQUAL_COUNTS) { - bins = getQuantiles(values, numClasses) + classification = getQuantiles(values, numClasses) } - return bins + return classification ?? {} } // This function is not in use, but keeping it @@ -65,50 +69,52 @@ export const getLegendItems = (values, method, numClasses) => { // } const getEqualIntervals = (minValue, maxValue, numClasses) => { - const bins = [] - const binSize = (maxValue - minValue) / numClasses - const precision = precisionRound(binSize, maxValue) + const items = [] + const classSize = (maxValue - minValue) / numClasses + const precision = precisionRound(classSize, maxValue) const valueFormat = getRoundToPrecisionFn(precision) for (let i = 0; i < numClasses; i++) { - const startValue = minValue + i * binSize - const endValue = i < numClasses - 1 ? startValue + binSize : maxValue + const startValue = minValue + i * classSize + const endValue = i < numClasses - 1 ? startValue + classSize : maxValue - bins.push({ + items.push({ startValue: valueFormat(startValue), endValue: valueFormat(endValue), }) } - return bins + return { items, valueFormat } } const getQuantiles = (values, numClasses) => { const minValue = values[0] const maxValue = values[values.length - 1] - const bins = [] - const binCount = values.length / numClasses + const items = [] + const valuesCount = values.length / numClasses const precision = precisionRound( (maxValue - minValue) / numClasses, maxValue ) const valueFormat = getRoundToPrecisionFn(precision) - let binLastValPos = binCount === 0 ? 0 : binCount - + let lastValuePosition = valuesCount if (values.length > 0) { - bins[0] = minValue + items[0] = minValue for (let i = 1; i < numClasses; i++) { - bins[i] = values[Math.round(binLastValPos)] - binLastValPos += binCount + items[i] = values[Math.round(lastValuePosition)] + lastValuePosition += valuesCount } } // bin can be undefined if few values - return bins - .filter((bin) => bin !== undefined) - .map((value, index) => ({ - startValue: valueFormat(value), - endValue: valueFormat(bins[index + 1] || maxValue), - })) + return { + items: items + .filter((bin) => bin !== undefined) + .map((value, index) => ({ + startValue: valueFormat(value), + endValue: valueFormat(items[index + 1] || maxValue), + })), + valueFormat, + } } diff --git a/src/util/colors.js b/src/util/colors.js index 360dffd36..5fafda63c 100644 --- a/src/util/colors.js +++ b/src/util/colors.js @@ -74,7 +74,7 @@ export const getUniqueColor = (defaultColors) => { const colors = [...defaultColors] function randomColor() { - const color = '#000000'.replace(/0/g, () => + const color = '#000000'.replaceAll('0', () => (~~(Math.random() * 16)).toString(16) ) diff --git a/src/util/dataDownload.js b/src/util/dataDownload.js index 648d876a3..298edc173 100644 --- a/src/util/dataDownload.js +++ b/src/util/dataDownload.js @@ -25,7 +25,7 @@ export const getFormatOptions = () => [ // Other layers will include layer name after aggregation type export const addPropNames = (layer, data) => { const { aggregationType, name, legend } = layer - const layerName = name.replace(/ /g, '_').toLowerCase() + const layerName = name.replaceAll(' ', '_').toLowerCase() const { items } = legend return hasClasses(aggregationType) @@ -48,7 +48,7 @@ export const addPropNames = (layer, data) => { // Replaces anything that's not a letter, number or space // Multiple spaces is replaced by a single space in the last replace export const standardizeFilename = (name, ext) => - `${name.replace(/[^a-z0-9 ]/gi, '').replace(/ +/g, ' ')}.${ext}` + `${name.replaceAll(/[^a-z0-9 ]/gi, '').replaceAll(/ +/g, ' ')}.${ext}` export const createGeoJsonBlob = (data) => { const geojson = { diff --git a/src/util/date.js b/src/util/date.js index 39f860697..c9f3ec3ab 100644 --- a/src/util/date.js +++ b/src/util/date.js @@ -209,7 +209,7 @@ export const getCurrentYearInCalendar = (calendar) => { } export function replaceAt(str, index, replacement) { - const cleanReplacement = replacement.replace(/\D/g, '') + const cleanReplacement = replacement.replaceAll(/\D/g, '') if (index >= str.length) { return str + cleanReplacement } @@ -255,7 +255,7 @@ export const formatDateInput = ({ finalHyphen = '-' } - const numericDate = date.replace(/\D/g, '') + const numericDate = date.replaceAll(/\D/g, '') const year = numericDate.slice(0, 4) const month = numericDate.slice(4, 6) diff --git a/src/util/earthEngine.js b/src/util/earthEngine.js index c34ad7a0a..74e1ee762 100644 --- a/src/util/earthEngine.js +++ b/src/util/earthEngine.js @@ -119,7 +119,7 @@ export const getPeriodFromFilter = (filter, datasetId) => { // Remove non-digits from periodId (needed for backward compatibility for population layers saved before 2.41) if (!isNightTimeLights && nonDigits.test(periodId)) { - periodId = Number(periodId.replace(nonDigits, '')) // Remove non-digits + periodId = Number(periodId.replaceAll(nonDigits, '')) // Remove non-digits } return { diff --git a/src/util/legend.js b/src/util/legend.js index 52a881f45..d21c1499f 100644 --- a/src/util/legend.js +++ b/src/util/legend.js @@ -43,14 +43,34 @@ const DATA_SET_QUERY = { }, } +const getRange = (item) => { + if ('from' in item) { + return { start: item.from, end: item.to } + } + if ('startValue' in item) { + return { start: item.startValue, end: item.endValue } + } + return null +} + export const sortLegendItems = (items) => - items.sort((a, b) => { - if ('from' in a) { - return b.from - a.from + [...items].sort((a, b) => { + const aRange = getRange(a) + const bRange = getRange(b) + + if (!aRange && !bRange) { + return 0 } - if ('startValue' in a) { - return b.startValue - a.startValue + if (!aRange) { + return 1 } + if (!bRange) { + return -1 + } + + return bRange.start === aRange.start + ? bRange.end - aRange.end + : bRange.start - aRange.start }) export const loadDataItemLegendSet = async (dataItem, engine) => { @@ -129,21 +149,25 @@ export const getPredefinedLegendItems = (legendSet) => { ) } -/* eslint-disable max-params */ -export const getAutomaticLegendItems = ( +export const getAutomaticLegendItems = ({ data, method = CLASSIFICATION_EQUAL_INTERVALS, classes = defaultClasses, - colorScale = defaultColorScale -) => { - const items = data.length ? getLegendItems(data, method, classes) : [] + colorScale = defaultColorScale, +}) => { + if (data.length === 0) { + return { items: [] } + } - return items.map((item, index) => ({ - ...item, - color: colorScale[index], - })) + const classification = getLegendItems(data, method, classes) + return { + items: classification.items.map((item, index) => ({ + ...item, + color: colorScale[index], + })), + valueFormat: classification.valueFormat, + } } -/* eslint-enable max-params */ export const getRenderingLabel = (strategy) => { const map = { diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js index e75b2c672..9ff5091c7 100644 --- a/src/util/styleByDataItem.js +++ b/src/util/styleByDataItem.js @@ -138,6 +138,7 @@ const styleByNumeric = async (config, engine) => { eventPointColor, eventPointRadius, } = config + let valueFormat // If legend set if (method === CLASSIFICATION_PREDEFINED) { @@ -164,17 +165,20 @@ const styleByNumeric = async (config, engine) => { legend.unit = await getLegendUnit(engine, styleDataItem) // Generate legend items based on layer config - legend.items = getAutomaticLegendItems( - sortedValues, + const classification = getAutomaticLegendItems({ + data: sortedValues, method, classes, - colorScale - ) + colorScale, + }) + legend.items = classification.items + valueFormat = classification.valueFormat } legend.items.push({ name: i18n.t('Other'), color: cssColor(eventPointColor) || EVENT_COLOR, + noData: true, }) // Add radius and count to each legend item @@ -187,7 +191,9 @@ const styleByNumeric = async (config, engine) => { const getLegendItem = (value) => getLegendItemForValue({ value, - legendItems: config.legend.items.slice(0, -1), + valueFormat, + legendItems: legend.items.filter((item) => !item.noData), + clamp: method !== CLASSIFICATION_PREDEFINED, }) // Add style data value and color to each feature diff --git a/src/util/time.js b/src/util/time.js index ffb1e8010..353e4641e 100644 --- a/src/util/time.js +++ b/src/util/time.js @@ -4,7 +4,7 @@ const DEFAULT_LOCALE = 'en' // BCP 47 locale format const dateLocale = (locale) => - locale && locale.includes('_') ? locale.replace('_', '-') : locale + locale && locale.includes('_') ? locale.replaceAll('_', '-') : locale /** * Trims the time part from an ISO date-time string, returning only the date (YYYY-MM-DD).