@cdc/map 4.25.7 → 4.25.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/.claude/settings.local.json +30 -0
  2. package/CLAUDE.local.md +0 -0
  3. package/dist/cdcmap.js +54785 -53159
  4. package/examples/private/c.json +290 -0
  5. package/examples/private/canvas-city-hover.json +787 -0
  6. package/examples/private/d.json +345 -0
  7. package/examples/private/filter-map.json +909 -0
  8. package/examples/private/g.json +1 -0
  9. package/examples/private/h.json +105911 -0
  10. package/examples/private/measles-data.json +378 -0
  11. package/examples/private/measles.json +211 -0
  12. package/examples/private/north-dakota.json +1132 -0
  13. package/examples/private/rsv-data.json +532 -0
  14. package/examples/private/state-with-pattern.json +883 -0
  15. package/examples/private/test.json +222 -640
  16. package/index.html +1 -1
  17. package/package.json +26 -5
  18. package/src/CdcMap.tsx +28 -8
  19. package/src/CdcMapComponent.tsx +230 -306
  20. package/src/_stories/CdcMap.Filters.stories.tsx +2 -2
  21. package/src/_stories/CdcMap.Legend.Gradient.stories.tsx +3 -3
  22. package/src/_stories/CdcMap.Legend.stories.tsx +7 -4
  23. package/src/_stories/CdcMap.Patterns.stories.tsx +2 -2
  24. package/src/_stories/CdcMap.Table.stories.tsx +2 -2
  25. package/src/_stories/CdcMap.stories.tsx +18 -11
  26. package/src/_stories/GoogleMap.stories.tsx +2 -2
  27. package/src/_stories/UsaMap.NoData.stories.tsx +2 -2
  28. package/src/_stories/_mock/equal-number.json +1109 -0
  29. package/src/_stories/_mock/multi-state.json +21389 -0
  30. package/src/_stories/_mock/us-bubble-cities.json +306 -0
  31. package/src/components/BubbleList.tsx +16 -12
  32. package/src/components/CityList.tsx +88 -110
  33. package/src/components/DataTable.tsx +44 -12
  34. package/src/components/EditorPanel/components/EditorPanel.tsx +201 -203
  35. package/src/components/EditorPanel/components/HexShapeSettings.tsx +3 -2
  36. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +7 -5
  37. package/src/components/Geo.tsx +2 -0
  38. package/src/components/Legend/components/Legend.tsx +117 -93
  39. package/src/components/Legend/components/LegendGroup/Legend.Group.tsx +10 -7
  40. package/src/components/MapContainer.tsx +52 -0
  41. package/src/components/MapControls.tsx +44 -0
  42. package/src/components/Modal.tsx +2 -8
  43. package/src/components/NavigationMenu.tsx +13 -1
  44. package/src/components/UsaMap/components/SingleState/SingleState.CountyOutput.tsx +24 -7
  45. package/src/components/UsaMap/components/SingleState/SingleState.StateOutput.tsx +21 -15
  46. package/src/components/UsaMap/components/TerritoriesSection.tsx +2 -2
  47. package/src/components/UsaMap/components/UsaMap.County.tsx +112 -33
  48. package/src/components/UsaMap/components/UsaMap.Region.tsx +23 -5
  49. package/src/components/UsaMap/components/UsaMap.SingleState.tsx +38 -26
  50. package/src/components/UsaMap/components/UsaMap.State.tsx +28 -10
  51. package/src/components/UsaMap/helpers/map.ts +16 -8
  52. package/src/components/WorldMap/WorldMap.tsx +116 -11
  53. package/src/components/ZoomControls.tsx +6 -9
  54. package/src/context/LegendMemoContext.tsx +30 -0
  55. package/src/context.ts +1 -39
  56. package/src/data/initial-state.js +143 -128
  57. package/src/data/supported-geos.js +202 -4
  58. package/src/helpers/addUIDs.ts +8 -8
  59. package/src/helpers/applyColorToLegend.ts +122 -45
  60. package/src/helpers/applyLegendToRow.ts +15 -13
  61. package/src/helpers/componentHelpers.ts +8 -0
  62. package/src/helpers/constants.ts +12 -0
  63. package/src/helpers/dataTableHelpers.ts +6 -0
  64. package/src/helpers/displayGeoName.ts +12 -7
  65. package/src/helpers/formatLegendLocation.ts +1 -3
  66. package/src/helpers/generateRuntimeLegend.ts +192 -340
  67. package/src/helpers/generateRuntimeLegendHash.ts +4 -2
  68. package/src/helpers/getColumnNames.ts +1 -1
  69. package/src/helpers/getPatternForRow.ts +36 -0
  70. package/src/helpers/getStatesPicked.ts +14 -0
  71. package/src/helpers/handleMapAriaLabels.ts +2 -2
  72. package/src/helpers/index.ts +11 -3
  73. package/src/helpers/isLegendItemDisabled.ts +16 -0
  74. package/src/helpers/mapObserverHelpers.ts +40 -0
  75. package/src/helpers/resetLegendToggles.ts +3 -2
  76. package/src/helpers/toggleLegendActive.ts +6 -11
  77. package/src/helpers/urlDataHelpers.ts +70 -0
  78. package/src/hooks/useGeoClickHandler.ts +35 -1
  79. package/src/hooks/useLegendMemo.ts +17 -0
  80. package/src/hooks/useMapLayers.tsx +5 -4
  81. package/src/hooks/useStateZoom.tsx +137 -88
  82. package/src/hooks/useTooltip.ts +1 -2
  83. package/src/index.jsx +6 -3
  84. package/src/scss/main.scss +23 -12
  85. package/src/store/map.actions.ts +2 -2
  86. package/src/store/map.reducer.ts +21 -10
  87. package/src/test/CdcMap.test.jsx +11 -0
  88. package/src/types/MapConfig.ts +25 -17
  89. package/src/types/MapContext.ts +2 -8
  90. package/src/types/runtimeLegend.ts +12 -10
  91. package/vite.config.js +2 -7
  92. package/vitest.config.ts +16 -0
  93. package/src/_stories/_mock/floating-point.json +0 -427
  94. package/src/coreStyles_map.scss +0 -3
  95. package/src/helpers/colorDistributions.ts +0 -12
  96. package/src/helpers/generateColorsArray.ts +0 -14
  97. package/src/helpers/getStatePicked.ts +0 -8
  98. package/src/helpers/tests/generateColorsArray.test.ts +0 -18
  99. package/src/helpers/tests/generateRuntimeLegendHash.test.ts +0 -11
@@ -14,8 +14,13 @@ import _ from 'lodash'
14
14
  import * as d3 from 'd3'
15
15
 
16
16
  // Cdc
17
- import colorPalettes from '@cdc/core/data/colorPalettes'
17
+ import { mapColorPalettes as colorPalettes } from '@cdc/core/data/colorPalettes'
18
18
  import { supportedCountries } from '../data/supported-geos'
19
+ import { getColorPaletteVersion } from '@cdc/core/helpers/getColorPaletteVersion'
20
+ import { v2ColorDistribution } from '@cdc/core/helpers/palettes/colorDistributions'
21
+
22
+ // Types
23
+ import { MapConfig, DataRow, RuntimeFilters } from '../types/MapConfig'
19
24
 
20
25
  type LegendItem = {
21
26
  special?: boolean
@@ -33,22 +38,12 @@ export type GeneratedLegend = {
33
38
  items: LegendItem[] | []
34
39
  }
35
40
 
36
- // Helper function to convert and round values consistently
37
- const convertAndRoundValue = (value: any, roundToPlace: number): number => {
38
- const num = Number(value)
39
- if (isNaN(num)) return NaN
40
-
41
- // Apply rounding to handle floating-point precision issues
42
- const factor = Math.pow(10, roundToPlace)
43
- return Math.round(num * factor) / factor
44
- }
45
-
46
41
  export const generateRuntimeLegend = (
47
- configObj,
48
- runtimeData: object[],
42
+ configObj: MapConfig,
43
+ runtimeData: DataRow[],
49
44
  hash: string,
50
- setConfig: Function,
51
- runtimeFilters: object[],
45
+ setConfig: (newMapConfig: MapConfig) => void,
46
+ runtimeFilters: RuntimeFilters,
52
47
  legendMemo: React.MutableRefObject<Map<string, number>>,
53
48
  legendSpecialClassLastMemo: React.MutableRefObject<Map<string, number>>
54
49
  ): GeneratedLegend | [] => {
@@ -61,8 +56,8 @@ export const generateRuntimeLegend = (
61
56
  if (!legendSpecialClassLastMemo) Error('No legend special class last memo provided')
62
57
 
63
58
  // Define variables..
64
- const newLegendMemo = new Map<string, number>() // Reset memoization
65
- const newLegendSpecialClassLastMemo = new Map<string, number>() // Reset bin memoization
59
+ const newLegendMemo = new Map() // Reset memoization
60
+ const newLegendSpecialClassLastMemo = new Map() // Reset bin memoization
66
61
  const countryKeys = Object.keys(supportedCountries)
67
62
  const { legend, columns, general } = configObj
68
63
  const primaryColName = columns.primary.name
@@ -89,12 +84,9 @@ export const generateRuntimeLegend = (
89
84
  // Unified will base the legend off ALL the data maps received. Otherwise, it will use
90
85
  let dataSet = legend.unified ? data : Object?.values(runtimeData)
91
86
 
92
- const roundToPlace = Number(columns?.primary?.roundToPlace) || 1
93
- let domainNums = Array.from(
94
- new Set(dataSet?.map(item => convertAndRoundValue(item[configObj.columns.primary.name], roundToPlace)))
95
- )
87
+ let domainNums = Array.from(new Set(dataSet?.map(item => item[configObj.columns.primary.name])))
96
88
  .filter(d => typeof d === 'number' && !isNaN(d))
97
- .sort((a, b) => (a as number) - (b as number))
89
+ .sort((a, b) => a - b)
98
90
 
99
91
  let specialClasses = 0
100
92
  let specialClassesHash = {}
@@ -116,6 +108,12 @@ export const generateRuntimeLegend = (
116
108
  label: specialClass.label
117
109
  })
118
110
 
111
+ result.items[result.items.length - 1].color = applyColorToLegend(
112
+ result.items.length - 1,
113
+ configObj,
114
+ result.items
115
+ )
116
+
119
117
  specialClasses += 1
120
118
  }
121
119
 
@@ -124,7 +122,7 @@ export const generateRuntimeLegend = (
124
122
  // color the configObj if val is in row
125
123
  specialColor = result.items.findIndex(p => p.value === val)
126
124
 
127
- newLegendMemo.set(String(hashObj(row)), specialColor)
125
+ newLegendMemo.set(hashObj(row), specialColor)
128
126
 
129
127
  return false
130
128
  }
@@ -146,14 +144,14 @@ export const generateRuntimeLegend = (
146
144
  if (undefined === value) continue
147
145
 
148
146
  if (false === uniqueValues.has(value)) {
149
- uniqueValues.set(value, [String(hashObj(row))])
147
+ uniqueValues.set(value, [hashObj(row)])
150
148
  count++
151
149
  } else {
152
- uniqueValues.get(value).push(String(hashObj(row)))
150
+ uniqueValues.get(value).push(hashObj(row))
153
151
  }
154
152
  }
155
153
 
156
- let sorted = Array.from(uniqueValues.keys())
154
+ let sorted = [...uniqueValues.keys()]
157
155
 
158
156
  if (legend.additionalCategories) {
159
157
  legend.additionalCategories.forEach(additionalCategory => {
@@ -191,103 +189,54 @@ export const generateRuntimeLegend = (
191
189
  let arr = uniqueValues.get(val)
192
190
 
193
191
  if (arr) {
194
- arr.forEach(hashedRow => newLegendMemo.set(String(hashedRow), lastIdx))
192
+ arr.forEach(hashedRow => newLegendMemo.set(hashedRow, lastIdx))
195
193
  }
196
194
  })
197
195
 
198
- // before returning the legend result
199
- // add property for bin number and set to index location
200
- setBinNumbers(result)
201
-
202
- // Store original legend items with their indices before sorting
203
- const originalCategoricalItems = result.items.map((item, index) => ({
204
- item: { ...item }, // Create a copy to avoid reference issues
205
- originalIndex: index
206
- }))
207
-
208
- // Move all special legend items from "Special Classes" to the end of the legend
209
- sortSpecialClassesLast(result, legend)
210
-
211
- // Update legend memo to reflect new positions after sorting for categorical legends
212
- if (legend.showSpecialClassesLast) {
213
- const updatedLegendMemo = new Map()
196
+ // Add color to new legend item (normal items only, not special classes)
197
+ for (let i = 0; i < result.items.length; i++) {
198
+ if (!result.items[i].special) {
199
+ result.items[i].color = applyColorToLegend(i, configObj, result.items)
200
+ }
201
+ }
214
202
 
215
- // Create a mapping from old index to new index
216
- const indexMapping = new Map()
203
+ // Now apply special class colors last, to overwrite if needed
204
+ for (let i = 0; i < result.items.length; i++) {
205
+ if (result.items[i].special) {
206
+ result.items[i].color = applyColorToLegend(i, configObj, result.items)
207
+ }
208
+ }
217
209
 
218
- // For each item in the new sorted order, find its original position
219
- result.items.forEach((newItem, newIndex) => {
220
- const originalData = originalCategoricalItems.find(({ item }) => {
221
- if (newItem.special) {
222
- return item.special && item.value === newItem.value
223
- } else {
224
- return !item.special && item.value === newItem.value
225
- }
210
+ // Overwrite legendMemo for special class rows to ensure correct color lookup
211
+ result.items.forEach((item, idx) => {
212
+ if (item.special) {
213
+ // Find all rows in the data that match this special class value
214
+ let specialRows = data.filter(row => {
215
+ // If special class has a key, use it, otherwise use primaryColName
216
+ const key = legend.specialClasses.find(sc => String(sc.value) === String(item.value))?.key || primaryColName
217
+ return String(row[key]) === String(item.value)
226
218
  })
219
+ specialRows.forEach(row => {
220
+ newLegendMemo.set(hashObj(row), idx)
221
+ })
222
+ }
223
+ })
227
224
 
228
- if (originalData) {
229
- indexMapping.set(originalData.originalIndex, newIndex)
230
- }
231
- })
232
-
233
- // Update all memo entries using the index mapping
234
- newLegendMemo.forEach((originalIndex, rowHash) => {
235
- const newIndex = indexMapping.get(originalIndex)
236
- if (newIndex !== undefined) {
237
- updatedLegendMemo.set(rowHash, newIndex)
238
- } else {
239
- // Fallback: clamp to valid range
240
- const clampedIndex = Math.min(originalIndex, result.items.length - 1)
241
- updatedLegendMemo.set(rowHash, clampedIndex)
242
- }
243
- })
244
-
245
- legendMemo.current = updatedLegendMemo
246
-
247
- // Apply colors based on original positions before sorting for categorical legends
248
- for (let i = 0; i < result.items.length; i++) {
249
- const currentItem = result.items[i]
225
+ legendMemo.current = newLegendMemo
250
226
 
251
- // Find the original position of this item
252
- const originalData = originalCategoricalItems.find(({ item }) => {
253
- if (currentItem.special) {
254
- return item.special && item.value === currentItem.value
255
- } else {
256
- return !item.special && item.value === currentItem.value
257
- }
258
- })
227
+ // before returning the legend result
228
+ // add property for bin number and set to index location
229
+ setBinNumbers(result)
259
230
 
260
- if (originalData) {
261
- // Use the original index for color calculation to maintain proper color sequence
262
- const contextArray = originalCategoricalItems.slice(0, originalData.originalIndex + 1).map(o => o.item)
263
- const appliedColor = applyColorToLegend(originalData.originalIndex, configObj, contextArray)
264
- result.items[i].color = appliedColor
265
- } else {
266
- // Fallback: apply color normally
267
- const contextArray = result.items.slice(0, i + 1)
268
- const appliedColor = applyColorToLegend(i, configObj, contextArray)
269
- result.items[i].color = appliedColor
270
- }
271
- }
272
- } else {
273
- // Simple case: no special sorting, just apply colors normally
274
- legendMemo.current = newLegendMemo
275
-
276
- for (let i = 0; i < result.items.length; i++) {
277
- // Create a context array that simulates the original incremental state
278
- const contextArray = result.items.slice(0, i + 1)
279
- const appliedColor = applyColorToLegend(i, configObj, contextArray)
280
- result.items[i].color = appliedColor
281
- }
282
- }
231
+ // Move all special legend items from "Special Classes" to the end of the legend
232
+ sortSpecialClassesLast(result, legend)
283
233
 
284
234
  const assignSpecialClassLastIndex = (value, key) => {
285
235
  const newIndex = result.items.findIndex(d => d.bin === value)
286
236
  newLegendSpecialClassLastMemo.set(key, newIndex)
287
237
  }
288
238
 
289
- // Use the current legend memo (which might have been updated after sorting)
290
- legendMemo.current.forEach(assignSpecialClassLastIndex)
239
+ newLegendMemo.forEach(assignSpecialClassLastIndex)
291
240
  legendSpecialClassLastMemo.current = newLegendSpecialClassLastMemo
292
241
 
293
242
  return result
@@ -304,26 +253,30 @@ export const generateRuntimeLegend = (
304
253
  if (true === legend.separateZero && !general.equalNumberOptIn) {
305
254
  let addLegendItem = false
306
255
 
307
- // First, add the zero bucket
308
- result.items.push({
309
- min: 0,
310
- max: 0
311
- })
312
-
313
- // Then process zero values and assign them to the zero bucket (index 0)
314
256
  for (let i = 0; i < dataSet.length; i++) {
315
257
  if (dataSet[i][primaryColName] === 0) {
316
258
  addLegendItem = true
317
259
 
318
260
  let row = dataSet.splice(i, 1)[0]
319
261
 
320
- newLegendMemo.set(String(hashObj(row)), 0) // Assign to the zero bucket at index 0
262
+ newLegendMemo.set(hashObj(row), result.items.length)
321
263
  i--
322
264
  }
323
265
  }
324
266
 
325
267
  if (addLegendItem) {
326
268
  legendNumber -= 1 // This zero takes up one legend item
269
+
270
+ // Add new legend item
271
+ result.items.push({
272
+ min: 0,
273
+ max: 0
274
+ })
275
+
276
+ let lastIdx = result.items.length - 1
277
+
278
+ // Add color to new legend item
279
+ result.items[lastIdx].color = applyColorToLegend(lastIdx, configObj, result.items)
327
280
  }
328
281
  }
329
282
 
@@ -365,7 +318,7 @@ export const generateRuntimeLegend = (
365
318
 
366
319
  // eslint-disable-next-line
367
320
  removedRows.forEach(row => {
368
- newLegendMemo.set(String(hashObj(row)), result.items.length)
321
+ newLegendMemo.set(hashObj(row), result.items.length)
369
322
  })
370
323
 
371
324
  result.items.push({
@@ -373,25 +326,62 @@ export const generateRuntimeLegend = (
373
326
  max
374
327
  })
375
328
 
329
+ result.items[result.items.length - 1].color = applyColorToLegend(
330
+ result.items.length - 1,
331
+ configObj,
332
+ result.items
333
+ )
334
+
376
335
  changingNumber -= 1
377
336
  numberOfRows -= chunkAmt
378
337
  }
379
338
  } else {
380
- // Use the appropriate rounding precision
381
- const roundingPrecision =
382
- general?.equalNumberOptIn && columns?.primary?.roundToPlace !== undefined
383
- ? Number(columns.primary.roundToPlace)
384
- : roundToPlace
339
+ const paletteName = configObj.general?.palette?.name || configObj.color
340
+ const version = getColorPaletteVersion(configObj)
341
+ let colors = colorPalettes?.[`v${version}`]?.[paletteName]
342
+ // Fallback to a default palette if none is selected or found
343
+ if (!colors) {
344
+ const defaultPalette = version === 1 ? 'sequential_blue_green' : 'sequential_blue'
345
+ colors = colorPalettes?.[`v${version}`]?.[defaultPalette]
346
+ }
347
+
348
+ if (!colors) {
349
+ console.warn('No color palette found, using fallback colors')
350
+ colors = ['#d3d3d3', '#a0a0a0', '#707070', '#404040'] // Gray fallback
351
+ }
385
352
 
386
- let colors = colorPalettes[configObj.color]
387
- let colorRange = colors.slice(0, legend.numberOfItems)
353
+ // Check if we should use v2 distribution logic for better contrast
354
+ const isSequentialOrDivergent =
355
+ paletteName && (paletteName.includes('sequential') || paletteName.includes('divergent'))
356
+ const useV2Distribution =
357
+ version === 2 && isSequentialOrDivergent && colors.length === 9 && legend.numberOfItems <= 9
358
+
359
+ let colorRange
360
+ if (useV2Distribution && v2ColorDistribution[legend.numberOfItems]) {
361
+ // Use strategic color distribution for v2 sequential/divergent palettes
362
+ const distributionIndices = v2ColorDistribution[legend.numberOfItems]
363
+ colorRange = distributionIndices.map(index => colors[index])
364
+ } else {
365
+ // Use existing logic for v1 palettes and other cases
366
+ colorRange = colors.slice(0, legend.numberOfItems)
367
+ }
388
368
 
389
369
  const getDomain = () => {
390
- return _.uniq(dataSet.map(item => convertAndRoundValue(item[columns.primary.name], roundingPrecision)))
370
+ // backwards compatibility
371
+ if (columns?.primary?.roundToPlace !== undefined && general?.equalNumberOptIn) {
372
+ return _.uniq(
373
+ dataSet.map(item => Number(item[columns.primary.name]).toFixed(Number(columns?.primary?.roundToPlace)))
374
+ )
375
+ }
376
+ return _.uniq(dataSet.map(item => Math.round(Number(item[columns.primary.name]))))
391
377
  }
392
378
 
393
379
  const getBreaks = scale => {
394
- return scale.quantiles().map(b => convertAndRoundValue(b, roundingPrecision))
380
+ // backwards compatibility
381
+ if (columns?.primary?.roundToPlace !== undefined && general?.equalNumberOptIn) {
382
+ return scale.quantiles().map(b => Number(b)?.toFixed(Number(columns?.primary?.roundToPlace)))
383
+ }
384
+ return scale.quantiles().map(item => Number(Math.round(item)))
395
385
  }
396
386
 
397
387
  let scale = d3
@@ -405,125 +395,85 @@ export const generateRuntimeLegend = (
405
395
  cachedBreaks = breaks
406
396
  }
407
397
 
408
- // Handle separateZero logic: if separating zero and it's not already included, add it
409
- if (legend.separateZero) {
410
- // Add zero bucket first if separating zero
411
- result.items.push({
412
- min: 0,
413
- max: 0
414
- })
415
-
416
- // Assign all zero values to this bucket
417
- dataSet.forEach(row => {
418
- let number = convertAndRoundValue(row[columns.primary.name], roundingPrecision)
419
- if (number === 0) {
420
- newLegendMemo.set(String(hashObj(row)), 0)
421
- }
422
- })
398
+ // if separating zero force it into breaks
399
+ if (cachedBreaks[0] !== 0) {
400
+ cachedBreaks.unshift(0)
423
401
  }
424
402
 
425
- // Create quantile breaks for non-zero values (or all values if not separating zero)
426
- const dataForBreaks = legend.separateZero
427
- ? dataSet.filter(row => convertAndRoundValue(row[columns.primary.name], roundingPrecision) !== 0)
428
- : dataSet
429
-
430
- if (dataForBreaks.length > 0) {
431
- // Recalculate scale and breaks for non-zero data
432
- const getNonZeroDomain = () => {
433
- return _.uniq(
434
- dataForBreaks.map(item => convertAndRoundValue(item[columns.primary.name], roundingPrecision))
435
- ).sort((a: number, b: number) => a - b)
436
- }
403
+ // eslint-disable-next-line array-callback-return
404
+ cachedBreaks.map((item, index) => {
405
+ const setMin = index => {
406
+ let min = cachedBreaks[index]
437
407
 
438
- const nonZeroDomain = getNonZeroDomain()
439
- const numberOfBuckets = legend.separateZero ? legend.numberOfItems - 1 : legend.numberOfItems
408
+ // if first break is a seperated zero, min is zero
409
+ if (index === 0 && legend.separateZero) {
410
+ min = 0
411
+ }
440
412
 
441
- if (nonZeroDomain.length > 0 && numberOfBuckets > 0) {
442
- let nonZeroScale = d3
443
- .scaleQuantile()
444
- .domain(nonZeroDomain)
445
- .range(colorPalettes[configObj.color].slice(0, numberOfBuckets))
413
+ // if we're on the second break, and separating out zero, increment min to 1.
414
+ if (index === 1 && legend.separateZero) {
415
+ min = 1
416
+ }
446
417
 
447
- const quantileBreaks = nonZeroScale.quantiles().map(b => convertAndRoundValue(b, roundingPrecision))
418
+ // For non-first ranges, add small increment to prevent overlap
419
+ if (index > 0 && !legend.separateZero) {
420
+ const decimalPlace = Number(configObj?.columns?.primary?.roundToPlace) || 1
421
+ min = Number(cachedBreaks[index]) + Math.pow(10, -decimalPlace)
422
+ }
448
423
 
449
- // Create buckets based on quantile breaks
450
- const createBuckets = () => {
451
- const buckets = []
452
- const sortedDomain = nonZeroDomain.sort((a: number, b: number) => a - b)
424
+ return min
425
+ }
453
426
 
454
- if (quantileBreaks.length === 0) {
455
- // Single bucket case
456
- buckets.push({
457
- min: sortedDomain[0],
458
- max: sortedDomain[sortedDomain.length - 1]
459
- })
460
- } else {
461
- // First bucket: min value to first break
462
- buckets.push({
463
- min: sortedDomain[0],
464
- max: quantileBreaks[0]
465
- })
427
+ const getDecimalPlace = n => {
428
+ return Math.pow(10, -n)
429
+ }
466
430
 
467
- // Middle buckets: previous break + increment to current break
468
- for (let i = 1; i < quantileBreaks.length; i++) {
469
- const increment = Math.pow(10, -roundingPrecision)
470
- buckets.push({
471
- min: convertAndRoundValue(quantileBreaks[i - 1] + increment, roundingPrecision),
472
- max: quantileBreaks[i]
473
- })
474
- }
431
+ const setMax = index => {
432
+ let max = Number(breaks[index + 1])
475
433
 
476
- // Last bucket: last break + increment to max value
477
- if (quantileBreaks.length > 0) {
478
- const increment = Math.pow(10, -roundingPrecision)
479
- buckets.push({
480
- min: convertAndRoundValue(quantileBreaks[quantileBreaks.length - 1] + increment, roundingPrecision),
481
- max: sortedDomain[sortedDomain.length - 1]
482
- })
483
- }
484
- }
485
-
486
- return buckets
434
+ if (index === 0 && legend.separateZero) {
435
+ max = 0
487
436
  }
488
437
 
489
- const buckets = createBuckets()
438
+ if (index + 1 === breaks.length) {
439
+ max = Number(domainNums[domainNums.length - 1])
440
+ }
490
441
 
491
- // Add buckets to result
492
- buckets.forEach(bucket => {
493
- result.items.push(bucket)
494
- })
442
+ return max
443
+ }
495
444
 
496
- // Assign non-zero values to appropriate buckets
497
- dataForBreaks.forEach(row => {
498
- let number = convertAndRoundValue(row[columns.primary.name], roundingPrecision)
499
- let assigned = false
445
+ let min = setMin(index)
446
+ let max = setMax(index)
500
447
 
501
- for (let itemIndex = legend.separateZero ? 1 : 0; itemIndex < result.items.length; itemIndex++) {
502
- const item = result.items[itemIndex]
448
+ result.items.push({
449
+ min,
450
+ max
451
+ })
452
+ result.items[result.items.length - 1].color = applyColorToLegend(
453
+ result.items.length - 1,
454
+ configObj,
455
+ result.items
456
+ )
503
457
 
504
- if (item.min === undefined || item.max === undefined) continue
458
+ dataSet.forEach(row => {
459
+ let number = row[columns.primary.name]
460
+ let updated = result.items.length - 1
505
461
 
506
- if (number >= item.min && number <= item.max) {
507
- newLegendMemo.set(String(hashObj(row)), itemIndex)
508
- assigned = true
509
- break
510
- }
511
- }
462
+ if (result.items?.[updated]?.min === undefined || result.items?.[updated]?.max === undefined) return
512
463
 
513
- // Fallback assignment if not assigned
514
- if (!assigned) {
515
- console.warn('Non-zero value not assigned to any range:', number)
516
- const fallbackIndex = legend.separateZero ? 1 : 0
517
- newLegendMemo.set(String(hashObj(row)), fallbackIndex)
464
+ // Check if this row hasn't been assigned yet to prevent double assignment
465
+ if (!newLegendMemo.has(hashObj(row))) {
466
+ if (number >= result.items[updated].min && number <= result.items[updated].max) {
467
+ newLegendMemo.set(hashObj(row), updated)
518
468
  }
519
- })
520
- }
521
- }
469
+ }
470
+ })
471
+ })
522
472
 
523
473
  // Final pass: handle any unassigned rows
524
474
  dataSet.forEach(row => {
525
- if (!newLegendMemo.has(String(hashObj(row)))) {
526
- let number = convertAndRoundValue(row[columns.primary.name], roundingPrecision)
475
+ if (!newLegendMemo.has(hashObj(row))) {
476
+ let number = row[columns.primary.name]
527
477
  let assigned = false
528
478
 
529
479
  // Find the correct range for this value - check both boundaries
@@ -534,7 +484,7 @@ export const generateRuntimeLegend = (
534
484
 
535
485
  // Check if value falls within range (inclusive of both min and max)
536
486
  if (number >= item.min && number <= item.max) {
537
- newLegendMemo.set(String(hashObj(row)), itemIndex)
487
+ newLegendMemo.set(hashObj(row), itemIndex)
538
488
  assigned = true
539
489
  break
540
490
  }
@@ -555,7 +505,7 @@ export const generateRuntimeLegend = (
555
505
  }
556
506
  }
557
507
 
558
- newLegendMemo.set(String(hashObj(row)), closestIndex)
508
+ newLegendMemo.set(hashObj(row), closestIndex)
559
509
  }
560
510
  }
561
511
  })
@@ -591,7 +541,7 @@ export const generateRuntimeLegend = (
591
541
 
592
542
  // Add rows in dataSet that belong to this new legend item since we've got the data sorted
593
543
  while (pointer < dataSet.length && dataSet[pointer][primaryColName] <= max) {
594
- newLegendMemo.set(String(hashObj(dataSet[pointer])), result.items.length)
544
+ newLegendMemo.set(hashObj(dataSet[pointer]), result.items.length)
595
545
  pointer += 1
596
546
  }
597
547
 
@@ -601,11 +551,19 @@ export const generateRuntimeLegend = (
601
551
  }
602
552
 
603
553
  result.items.push(range)
554
+
555
+ result.items[result.items.length - 1].color = applyColorToLegend(
556
+ result.items.length - 1,
557
+ configObj,
558
+ result.items
559
+ )
604
560
  }
605
561
  }
606
562
 
607
563
  setBinNumbers(result)
608
564
 
565
+ legendMemo.current = newLegendMemo
566
+
609
567
  if (general.geoType === 'world') {
610
568
  const runtimeDataKeys = Object.keys(runtimeData)
611
569
  const isCountriesWithNoDataState =
@@ -621,129 +579,23 @@ export const generateRuntimeLegend = (
621
579
  }
622
580
 
623
581
  setBinNumbers(result)
624
-
625
- // Only do complex sorting and color mapping if showSpecialClassesLast is enabled
626
- if (legend.showSpecialClassesLast) {
627
- // Store original legend items with their indices before sorting
628
- const originalItems = result.items.map((item, index) => ({
629
- item: { ...item }, // Create a copy to avoid reference issues
630
- originalIndex: index
631
- }))
632
-
633
- sortSpecialClassesLast(result, legend)
634
-
635
- // Update legend memo to reflect new positions after sorting
636
- const updatedLegendMemo = new Map()
637
-
638
- // Create a mapping from old index to new index
639
- const indexMapping = new Map()
640
-
641
- // For each item in the new sorted order, find its original position
642
- result.items.forEach((newItem, newIndex) => {
643
- const originalData = originalItems.find(({ item }) => {
644
- if (newItem.special) {
645
- return item.special && item.value === newItem.value
646
- } else {
647
- return !item.special && item.min === newItem.min && item.max === newItem.max
648
- }
649
- })
650
-
651
- if (originalData) {
652
- indexMapping.set(originalData.originalIndex, newIndex)
653
- }
654
- })
655
-
656
- // Update all memo entries using the index mapping
657
- newLegendMemo.forEach((originalIndex, rowHash) => {
658
- const newIndex = indexMapping.get(originalIndex)
659
- if (newIndex !== undefined) {
660
- updatedLegendMemo.set(rowHash, newIndex)
661
- } else {
662
- // For unmapped entries, check if it was originally a special class
663
- const originalItem = originalItems[originalIndex]?.item
664
- if (originalItem?.special) {
665
- // Find the special class in its new position
666
- const specialIndex = result.items.findIndex(item => item.special && item.value === originalItem.value)
667
- if (specialIndex !== -1) {
668
- updatedLegendMemo.set(rowHash, specialIndex)
669
- } else {
670
- // Fallback: clamp to valid range
671
- const clampedIndex = Math.min(originalIndex, result.items.length - 1)
672
- updatedLegendMemo.set(rowHash, clampedIndex)
673
- }
674
- } else {
675
- // Fallback: clamp to valid range
676
- const clampedIndex = Math.min(originalIndex, result.items.length - 1)
677
- updatedLegendMemo.set(rowHash, clampedIndex)
678
- }
679
- }
680
- })
681
-
682
- legendMemo.current = updatedLegendMemo
683
-
684
- // Apply colors based on original positions to maintain proper color sequence
685
- for (let i = 0; i < result.items.length; i++) {
686
- const currentItem = result.items[i]
687
-
688
- // Find the original position of this item
689
- const originalData = originalItems.find(({ item }) => {
690
- if (currentItem.special) {
691
- return item.special && item.value === currentItem.value
692
- } else {
693
- return !item.special && item.min === currentItem.min && item.max === currentItem.max
694
- }
695
- })
696
-
697
- if (originalData) {
698
- // Use the original index for color calculation to maintain proper color sequence
699
- const contextArray = originalItems.slice(0, originalData.originalIndex + 1).map(o => o.item)
700
- const appliedColor = applyColorToLegend(originalData.originalIndex, configObj, contextArray)
701
- result.items[i].color = appliedColor
702
- } else {
703
- // Fallback: apply color normally
704
- const contextArray = result.items.slice(0, i + 1)
705
- const appliedColor = applyColorToLegend(i, configObj, contextArray)
706
- result.items[i].color = appliedColor
707
- }
708
- }
709
-
710
- // Final step: Ensure special class rows are correctly mapped to their legend items
711
- result.items.forEach((item, idx) => {
712
- if (item.special) {
713
- // Find all rows in the original data that match this special class value
714
- let specialRows = data.filter(row => {
715
- // If special class has a key, use it, otherwise use primaryColName
716
- const key = legend.specialClasses.find(sc => String(sc.value) === String(item.value))?.key || primaryColName
717
- return String(row[key]) === String(item.value)
718
- })
719
- specialRows.forEach(row => {
720
- legendMemo.current.set(String(hashObj(row)), idx)
721
- })
722
- }
723
- })
724
- } else {
725
- legendMemo.current = newLegendMemo
726
-
727
- for (let i = 0; i < result.items.length; i++) {
728
- const contextArray = result.items.slice(0, i + 1)
729
- const appliedColor = applyColorToLegend(i, configObj, contextArray)
730
- result.items[i].color = appliedColor
731
- }
732
- }
582
+ sortSpecialClassesLast(result, legend)
733
583
 
734
584
  const assignSpecialClassLastIndex = (value, key) => {
735
585
  const newIndex = result.items.findIndex(d => d.bin === value)
736
586
  newLegendSpecialClassLastMemo.set(key, newIndex)
737
587
  }
738
-
739
- // Use the current legend memo (which might have been updated after sorting)
740
- legendMemo.current.forEach(assignSpecialClassLastIndex)
588
+ newLegendMemo.forEach(assignSpecialClassLastIndex)
741
589
  legendSpecialClassLastMemo.current = newLegendSpecialClassLastMemo
742
590
 
743
591
  return result
744
592
  } catch (e) {
745
593
  console.error(e)
746
- return []
594
+ return {
595
+ fromHash: null,
596
+ runtimeDataHash: null,
597
+ items: []
598
+ }
747
599
  }
748
600
  }
749
601