@carto/ps-react-ui 4.25.0 → 4.27.0

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 (91) hide show
  1. package/dist/actions-DON6fSCh.js +1289 -0
  2. package/dist/actions-DON6fSCh.js.map +1 -0
  3. package/dist/category-BoT5FHHp.js +950 -0
  4. package/dist/category-BoT5FHHp.js.map +1 -0
  5. package/dist/item-color-C1cu6EJd.js +20 -0
  6. package/dist/item-color-C1cu6EJd.js.map +1 -0
  7. package/dist/scale-0elCv3tk.js +29 -0
  8. package/dist/scale-0elCv3tk.js.map +1 -0
  9. package/dist/types/widgets-v2/actions/index.d.ts +1 -1
  10. package/dist/types/widgets-v2/actions/relative-data/index.d.ts +1 -0
  11. package/dist/types/widgets-v2/actions/relative-data/labels.d.ts +3 -0
  12. package/dist/types/widgets-v2/actions/relative-data/relative-data.d.ts +16 -2
  13. package/dist/types/widgets-v2/actions/relative-data/scale.d.ts +26 -0
  14. package/dist/types/widgets-v2/actions/relative-data/style.d.ts +12 -0
  15. package/dist/types/widgets-v2/bar/types.d.ts +6 -0
  16. package/dist/types/widgets-v2/category/category.d.ts +2 -1
  17. package/dist/types/widgets-v2/histogram/bins.d.ts +7 -0
  18. package/dist/types/widgets-v2/histogram/download.d.ts +2 -2
  19. package/dist/types/widgets-v2/histogram/index.d.ts +2 -1
  20. package/dist/types/widgets-v2/histogram/options.d.ts +1 -1
  21. package/dist/types/widgets-v2/histogram/transforms.d.ts +5 -4
  22. package/dist/types/widgets-v2/histogram/types.d.ts +22 -5
  23. package/dist/types/widgets-v2/index.d.ts +2 -2
  24. package/dist/types/widgets-v2/pie/types.d.ts +6 -0
  25. package/dist/types/widgets-v2/scatterplot/download.d.ts +2 -2
  26. package/dist/types/widgets-v2/scatterplot/index.d.ts +2 -1
  27. package/dist/types/widgets-v2/scatterplot/points.d.ts +7 -0
  28. package/dist/types/widgets-v2/scatterplot/transforms.d.ts +4 -3
  29. package/dist/types/widgets-v2/scatterplot/types.d.ts +21 -2
  30. package/dist/types/widgets-v2/timeseries/types.d.ts +6 -0
  31. package/dist/types/widgets-v2/utils/item-color.d.ts +26 -0
  32. package/dist/widgets-v2/actions.js +1 -1
  33. package/dist/widgets-v2/bar.js +91 -81
  34. package/dist/widgets-v2/bar.js.map +1 -1
  35. package/dist/widgets-v2/category.js +1 -1
  36. package/dist/widgets-v2/histogram.js +133 -100
  37. package/dist/widgets-v2/histogram.js.map +1 -1
  38. package/dist/widgets-v2/pie.js +98 -78
  39. package/dist/widgets-v2/pie.js.map +1 -1
  40. package/dist/widgets-v2/scatterplot.js +151 -110
  41. package/dist/widgets-v2/scatterplot.js.map +1 -1
  42. package/dist/widgets-v2/timeseries.js +96 -86
  43. package/dist/widgets-v2/timeseries.js.map +1 -1
  44. package/dist/widgets-v2.js +2 -2
  45. package/dist/widgets-v2.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/widgets-v2/actions/fullscreen/fullscreen-store.test.tsx +77 -13
  48. package/src/widgets-v2/actions/fullscreen/fullscreen.tsx +48 -5
  49. package/src/widgets-v2/actions/index.ts +1 -0
  50. package/src/widgets-v2/actions/relative-data/index.ts +1 -0
  51. package/src/widgets-v2/actions/relative-data/labels.ts +6 -0
  52. package/src/widgets-v2/actions/relative-data/relative-data.test.tsx +78 -1
  53. package/src/widgets-v2/actions/relative-data/relative-data.tsx +137 -20
  54. package/src/widgets-v2/actions/relative-data/scale.ts +48 -0
  55. package/src/widgets-v2/actions/relative-data/style.ts +10 -0
  56. package/src/widgets-v2/bar/options.test.ts +122 -0
  57. package/src/widgets-v2/bar/options.ts +31 -16
  58. package/src/widgets-v2/bar/types.ts +6 -0
  59. package/src/widgets-v2/category/category.relative-data.test.tsx +51 -63
  60. package/src/widgets-v2/category/category.tsx +25 -11
  61. package/src/widgets-v2/histogram/bins.ts +22 -0
  62. package/src/widgets-v2/histogram/download.test.ts +10 -0
  63. package/src/widgets-v2/histogram/download.ts +7 -3
  64. package/src/widgets-v2/histogram/index.ts +3 -0
  65. package/src/widgets-v2/histogram/options.test.ts +62 -0
  66. package/src/widgets-v2/histogram/options.ts +44 -19
  67. package/src/widgets-v2/histogram/transforms.test.ts +21 -7
  68. package/src/widgets-v2/histogram/transforms.ts +26 -12
  69. package/src/widgets-v2/histogram/types.ts +25 -5
  70. package/src/widgets-v2/index.ts +7 -1
  71. package/src/widgets-v2/pie/options.test.ts +91 -0
  72. package/src/widgets-v2/pie/options.ts +50 -20
  73. package/src/widgets-v2/pie/types.ts +6 -0
  74. package/src/widgets-v2/scatterplot/download.test.ts +9 -0
  75. package/src/widgets-v2/scatterplot/download.ts +5 -3
  76. package/src/widgets-v2/scatterplot/index.ts +3 -0
  77. package/src/widgets-v2/scatterplot/options.test.ts +90 -0
  78. package/src/widgets-v2/scatterplot/options.ts +53 -15
  79. package/src/widgets-v2/scatterplot/points.ts +35 -0
  80. package/src/widgets-v2/scatterplot/transforms.test.ts +19 -0
  81. package/src/widgets-v2/scatterplot/transforms.ts +23 -18
  82. package/src/widgets-v2/scatterplot/types.ts +25 -2
  83. package/src/widgets-v2/timeseries/options.test.ts +65 -0
  84. package/src/widgets-v2/timeseries/options.ts +38 -19
  85. package/src/widgets-v2/timeseries/types.ts +6 -0
  86. package/src/widgets-v2/utils/item-color.test.ts +74 -0
  87. package/src/widgets-v2/utils/item-color.ts +55 -0
  88. package/dist/actions-B1ss030_.js +0 -1167
  89. package/dist/actions-B1ss030_.js.map +0 -1
  90. package/dist/category-DuKx_CJX.js +0 -933
  91. package/dist/category-DuKx_CJX.js.map +0 -1
@@ -12,12 +12,16 @@ import {
12
12
  import { ZOOM_LAYOUT } from '../actions/zoom-toggle'
13
13
  import type { OptionFactory } from '../echart'
14
14
  import { mergeOptions, resolveThemeColor } from '../utils'
15
+ import { createItemColorResolver } from '../utils/item-color'
16
+ import { getScatterplotXY } from './points'
15
17
  import type {
16
18
  ScatterplotEChartsOption,
17
19
  ScatterplotOptionFactoryInput,
18
20
  ScatterplotOptionsInput,
21
+ ScatterplotItem,
19
22
  ScatterplotWidgetData,
20
23
  } from './types'
24
+ import { fixedAxisBounds } from '../actions/relative-data/scale'
21
25
 
22
26
  /**
23
27
  * Builds the **structural** ECharts option for a scatterplot widget — both
@@ -156,7 +160,9 @@ export function createScatterplotOptionFactory(
156
160
  : structural
157
161
  }
158
162
 
159
- const seriesArr = Array.isArray(data) ? (data as ScatterplotWidgetData) : []
163
+ const seriesArr = Array.isArray(data)
164
+ ? (data as ScatterplotWidgetData<ScatterplotItem>)
165
+ : []
160
166
  if (seriesArr.length === 0) {
161
167
  return { ...option, dataset: [], series: [] }
162
168
  }
@@ -184,8 +190,12 @@ export function createScatterplotOptionFactory(
184
190
 
185
191
  const reactiveFormatter = ctx?.formatter
186
192
 
187
- const { niceMinX, niceMaxX, niceMinY, niceMaxY } =
188
- computeScatterBounds(seriesArr)
193
+ const { niceMinX, niceMaxX, niceMinY, niceMaxY } = computeScatterBounds(
194
+ seriesArr,
195
+ // A config transform (RelativeData's `'total'` scale) may pin the
196
+ // y ends; each applies only on the side the data reaches.
197
+ fixedAxisBounds(baseYAxis),
198
+ )
189
199
 
190
200
  // Dim non-selected points via `series.itemStyle.color`. The selection
191
201
  // key is `${seriesIndex}:${dataIndex}`; we read those off the params
@@ -198,15 +208,26 @@ export function createScatterplotOptionFactory(
198
208
  // Keeping the key always-present means a plain setOption merge swaps
199
209
  // the callback in place, no `replaceMerge` and no entry-animation
200
210
  // flash on selection on/off.
201
- const makeDimColor =
202
- (seriesIdx: number) => (params: CallbackDataParams) => {
203
- const base = params.color as string
211
+ //
212
+ // The base colour is the point's own `color` (object points), else the
213
+ // template's `itemStyle.color`, else the series colour / palette.
214
+ const seriesTemplates = Array.isArray(option.series) ? option.series : []
215
+ const makeDimColor = (seriesIdx: number, datums: readonly unknown[]) => {
216
+ const baseColor = createItemColorResolver({
217
+ theme,
218
+ datums,
219
+ template: seriesTemplates[seriesIdx] ?? seriesTemplates[0],
220
+ fallback: (params) => params.color as string,
221
+ })
222
+ return (params: CallbackDataParams) => {
223
+ const base = baseColor(params)
204
224
  if (!selectionSet) return base
205
225
  const key = `${seriesIdx}:${params.dataIndex}`
206
226
  return selectionSet.has(key)
207
227
  ? base
208
228
  : echarts.color.modifyAlpha(base, 0.15)
209
229
  }
230
+ }
210
231
 
211
232
  // Zoom slider layout: when ZoomToggle has installed `dataZoom`,
212
233
  // reserve grid space and lift any x-slider above the legend (if
@@ -232,9 +253,15 @@ export function createScatterplotOptionFactory(
232
253
  return {
233
254
  ...option,
234
255
  // ECharts dataset.source wants a mutable [number, number][] shape; we
235
- // hold readonly tuples internally, so cast at the boundary.
236
- dataset: seriesArr.map((s) => ({ source: s as unknown as number[][] })),
237
- series: seriesArr.map((_, i) => {
256
+ // hold readonly tuples internally, so cast at the boundary. Object
257
+ // points are flattened to `[x, y]` so the positional encode and the
258
+ // tooltip keep reading tuples.
259
+ dataset: seriesArr.map((s) => ({
260
+ source: (s.every(Array.isArray)
261
+ ? s
262
+ : s.map(getScatterplotXY)) as unknown as number[][],
263
+ })),
264
+ series: seriesArr.map((s, i) => {
238
265
  const overrideColor = resolveThemeColor(theme, series?.[i]?.color)
239
266
  return {
240
267
  type: 'scatter' as const,
@@ -243,7 +270,7 @@ export function createScatterplotOptionFactory(
243
270
  encode: { x: 0, y: 1 },
244
271
  symbolSize,
245
272
  emphasis: { focus: 'series' },
246
- itemStyle: { color: makeDimColor(i) },
273
+ itemStyle: { color: makeDimColor(i, s) },
247
274
  ...(overrideColor ? { color: overrideColor } : {}),
248
275
  }
249
276
  }),
@@ -386,7 +413,10 @@ function buildScatterTooltipFormatter(
386
413
  })
387
414
  }
388
415
 
389
- function computeScatterBounds(seriesArr: ScatterplotWidgetData): {
416
+ function computeScatterBounds(
417
+ seriesArr: ScatterplotWidgetData<ScatterplotItem>,
418
+ fixedY: { min?: number; max?: number } = {},
419
+ ): {
390
420
  niceMinX: number
391
421
  niceMaxX: number
392
422
  niceMinY: number
@@ -398,8 +428,8 @@ function computeScatterBounds(seriesArr: ScatterplotWidgetData): {
398
428
  let maxY = -Infinity
399
429
  for (const series of seriesArr) {
400
430
  for (const point of series) {
401
- const x = point?.[0]
402
- const y = point?.[1]
431
+ if (point == null) continue
432
+ const [x, y] = getScatterplotXY(point)
403
433
  if (typeof x === 'number' && Number.isFinite(x)) {
404
434
  if (x < minX) minX = x
405
435
  if (x > maxX) maxX = x
@@ -415,8 +445,16 @@ function computeScatterBounds(seriesArr: ScatterplotWidgetData): {
415
445
  // negative mins, and floor max=0 to 1 so the chart always has range.
416
446
  // Scatter can have free coordinates so we apply this per-axis.
417
447
  const niceMaxX = Number.isFinite(maxX) ? (maxX <= 0 ? 1 : niceNum(maxX)) : 1
418
- const niceMaxY = Number.isFinite(maxY) ? (maxY <= 0 ? 1 : niceNum(maxY)) : 1
448
+ const niceMaxY = Number.isFinite(maxY)
449
+ ? maxY <= 0
450
+ ? 1
451
+ : (fixedY.max ?? niceNum(maxY))
452
+ : 1
419
453
  const niceMinX = Number.isFinite(minX) ? (minX < 0 ? niceNum(minX) : 0) : 0
420
- const niceMinY = Number.isFinite(minY) ? (minY < 0 ? niceNum(minY) : 0) : 0
454
+ const niceMinY = Number.isFinite(minY)
455
+ ? minY < 0
456
+ ? (fixedY.min ?? niceNum(minY))
457
+ : 0
458
+ : 0
421
459
  return { niceMinX, niceMaxX, niceMinY, niceMaxY }
422
460
  }
@@ -0,0 +1,35 @@
1
+ import type { ScatterplotItem } from './types'
2
+
3
+ /** `[x, y]` of a {@link ScatterplotItem}, whichever form it takes. */
4
+ export function getScatterplotXY(
5
+ point: ScatterplotItem,
6
+ ): readonly [number, number] {
7
+ return isTuple(point) ? point : [point.x, point.y]
8
+ }
9
+
10
+ /** Whether `v` is a valid {@link ScatterplotItem} with finite coordinates. */
11
+ export function isScatterplotDatum(v: unknown): v is ScatterplotItem {
12
+ if (Array.isArray(v)) {
13
+ return v.length === 2 && isFiniteNumber(v[0]) && isFiniteNumber(v[1])
14
+ }
15
+ if (v == null || typeof v !== 'object') return false
16
+ const { x, y } = v as { x?: unknown; y?: unknown }
17
+ return isFiniteNumber(x) && isFiniteNumber(y)
18
+ }
19
+
20
+ /** Same point with new coordinates; keeps the object form (and `color`). */
21
+ export function withScatterplotXY(
22
+ point: ScatterplotItem,
23
+ x: number,
24
+ y: number,
25
+ ): ScatterplotItem {
26
+ return isTuple(point) ? [x, y] : { ...point, x, y }
27
+ }
28
+
29
+ function isTuple(point: ScatterplotItem): point is readonly [number, number] {
30
+ return Array.isArray(point)
31
+ }
32
+
33
+ function isFiniteNumber(v: unknown): v is number {
34
+ return typeof v === 'number' && Number.isFinite(v)
35
+ }
@@ -99,6 +99,25 @@ describe('toRelativeScatterplotData', () => {
99
99
  })
100
100
  })
101
101
 
102
+ describe('toRelativeScatterplotData (object points)', () => {
103
+ it('relativizes y of object points, keeping x and colour', () => {
104
+ expect(
105
+ toRelativeScatterplotData([[{ x: 1, y: 5, color: '#f00' }, [2, 15]]]),
106
+ ).toEqual([[{ x: 1, y: 25, color: '#f00' }, [2, 75]]])
107
+ })
108
+ })
109
+
110
+ describe('filterScatterplotByLockedPoints (object points)', () => {
111
+ it('keeps locked object points as they are', () => {
112
+ expect(
113
+ filterScatterplotByLockedPoints(
114
+ [[{ x: 1, y: 2, color: '#f00' }, { x: 3, y: 4 }, [5, 6]]],
115
+ ['0:0', '0:2'],
116
+ ),
117
+ ).toEqual([[{ x: 1, y: 2, color: '#f00' }, [5, 6]]])
118
+ })
119
+ })
120
+
102
121
  describe('filterScatterplotByLockedPoints', () => {
103
122
  const DATA = [
104
123
  [
@@ -1,6 +1,14 @@
1
+ import {
2
+ getScatterplotXY,
3
+ isScatterplotDatum,
4
+ withScatterplotXY,
5
+ } from './points'
6
+ import type { ScatterplotItem } from './types'
7
+
1
8
  /**
2
9
  * Scatterplot-specific `RelativeData` transform. Scatter data is
3
- * `[number, number][]` — each series is a list of `[x, y]` tuples.
10
+ * `ScatterplotItem[][]` — each series is a list of `[x, y]` tuples or
11
+ * `{ x, y, color? }` points (whose shape is kept).
4
12
  * Relative is a values-axis concept, so this rewrites `y` to its
5
13
  * share of the series's total `y` (0–100) and leaves `x` raw — x is
6
14
  * coordinate space, not part of the cohort total.
@@ -11,34 +19,31 @@
11
19
  * y values produce sane signed shares-of-magnitude. A series whose
12
20
  * total y-magnitude is zero (all-zero or empty input) is returned
13
21
  * unchanged so a stalled or empty data set doesn't show misleading 0%
14
- * values. Inputs whose shape isn't `[number, number][]` fall through
22
+ * values. Inputs whose shape isn't `ScatterplotItem[]` fall through
15
23
  * untouched.
16
24
  */
17
25
  export const toRelativeScatterplotData = (input: unknown): unknown => {
18
26
  if (!Array.isArray(input)) return input
19
27
  return input.map((series: unknown): unknown => {
20
- if (!isXyTupleArray(series)) return series
21
- const total = series.reduce((acc, [, y]) => acc + Math.abs(y), 0)
28
+ if (!isPointArray(series)) return series
29
+ const total = series.reduce(
30
+ (acc, p) => acc + Math.abs(getScatterplotXY(p)[1]),
31
+ 0,
32
+ )
22
33
  if (total <= 0) return series
23
- return series.map(([x, y]) => [x, (y / total) * 100] as [number, number])
34
+ return series.map((p) => {
35
+ const [x, y] = getScatterplotXY(p)
36
+ return withScatterplotXY(p, x, (y / total) * 100)
37
+ })
24
38
  })
25
39
  }
26
40
 
27
- function isXyTupleArray(v: unknown): v is [number, number][] {
28
- if (!Array.isArray(v)) return false
29
- return v.every(
30
- (item) =>
31
- Array.isArray(item) &&
32
- item.length === 2 &&
33
- typeof item[0] === 'number' &&
34
- Number.isFinite(item[0]) &&
35
- typeof item[1] === 'number' &&
36
- Number.isFinite(item[1]),
37
- )
41
+ function isPointArray(v: unknown): v is ScatterplotItem[] {
42
+ return Array.isArray(v) && v.every(isScatterplotDatum)
38
43
  }
39
44
 
40
45
  /**
41
- * `LockSelection` filter for Scatterplot. Data is `[number, number][][]` —
46
+ * `LockSelection` filter for Scatterplot. Data is `ScatterplotItem[][]` —
42
47
  * points have no `name`, so selection (and locking) is keyed on a synthetic
43
48
  * `${seriesIndex}:${dataIndex}` string (same format `useChartSelection`'s
44
49
  * `keyFromClick`/`keysFromBrush` produce for this widget). Unlike Histogram,
@@ -53,7 +58,7 @@ export function filterScatterplotByLockedPoints(
53
58
  if (!Array.isArray(input)) return input
54
59
  const locked = new Set<string>(lockedItems.map(String))
55
60
  return input.map((series: unknown, seriesIndex: number): unknown => {
56
- if (!isXyTupleArray(series)) return series
61
+ if (!isPointArray(series)) return series
57
62
  return series.filter((_, dataIndex) =>
58
63
  locked.has(`${seriesIndex}:${dataIndex}`),
59
64
  )
@@ -5,8 +5,31 @@ import type { WidgetSeries } from '../types'
5
5
  /** A single point — `[x, y]` tuple. */
6
6
  export type ScatterplotDatum = readonly [number, number]
7
7
 
8
- /** Scatterplot widget data — array of series, each a list of `[x, y]` tuples. */
9
- export type ScatterplotWidgetData = readonly (readonly ScatterplotDatum[])[]
8
+ /**
9
+ * Object form of a point, to give it its own colour. `color` (CSS colour or
10
+ * theme palette path such as `'primary.main'`) wins over the template's
11
+ * `itemStyle.color` and the series colour; selection dimming still applies.
12
+ */
13
+ export interface ScatterplotPoint {
14
+ x: number
15
+ y: number
16
+ color?: string
17
+ }
18
+
19
+ /** Any accepted point: an `[x, y]` tuple or a {@link ScatterplotPoint}. */
20
+ export type ScatterplotItem = ScatterplotDatum | ScatterplotPoint
21
+
22
+ /**
23
+ * Scatterplot widget data — array of series, each a list of points.
24
+ *
25
+ * Defaults to `[x, y]` tuples so existing code that destructures points
26
+ * keeps type-checking; use `ScatterplotWidgetData<ScatterplotItem>` to also
27
+ * accept `{ x, y, color? }` points. Every scatterplot API (option factory,
28
+ * transforms, download) accepts both forms.
29
+ */
30
+ export type ScatterplotWidgetData<
31
+ P extends ScatterplotItem = ScatterplotDatum,
32
+ > = readonly (readonly P[])[]
10
33
 
11
34
  /** Inputs to the structural-only {@link scatterplotOptions} builder. */
12
35
  export interface ScatterplotOptionsInput {
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
+ import * as echarts from 'echarts'
2
3
  import { createTheme, type Theme } from '@mui/material'
3
4
  import { createTimeseriesOptionFactory, timeseriesOptions } from './options'
4
5
 
@@ -397,3 +398,67 @@ describe('createTimeseriesOptionFactory', () => {
397
398
  expect(out.grid.bottom).toBe(96)
398
399
  })
399
400
  })
401
+
402
+ describe('createTimeseriesOptionFactory — per-item colour', () => {
403
+ interface Out {
404
+ series: {
405
+ showSymbol: boolean
406
+ itemStyle: { color: (p: unknown) => string }
407
+ }[]
408
+ }
409
+ const data = [
410
+ [
411
+ { name: 1, value: 1, color: '#00ff00' },
412
+ { name: 2, value: 2 },
413
+ ],
414
+ ]
415
+ const p = (dataIndex: number, name: number) => ({
416
+ dataIndex,
417
+ color: '#0000ff',
418
+ value: { name },
419
+ })
420
+ const dim = (c: string) => echarts.color.modifyAlpha(c, 0.15)
421
+
422
+ it('datum colour wins over the series colour; template next', () => {
423
+ const merge = createTimeseriesOptionFactory({
424
+ theme,
425
+ series: [{ name: 'S', color: '#0000ff' }],
426
+ })
427
+ const out = merge(
428
+ { series: [{ type: 'line', itemStyle: { color: '#123456' } }] },
429
+ data,
430
+ ) as Out
431
+ const colorFn = out.series[0]!.itemStyle.color
432
+ expect(colorFn(p(0, 1))).toBe('#00ff00')
433
+ expect(colorFn(p(1, 2))).toBe('#123456')
434
+ })
435
+
436
+ it('calls a function template itemStyle.color', () => {
437
+ const factory = createTimeseriesOptionFactory({
438
+ theme,
439
+ optionsOverride: {
440
+ series: [{ type: 'line', itemStyle: { color: () => '#ff0000' } }],
441
+ },
442
+ })
443
+ const out = factory(factory(undefined, undefined), [
444
+ [{ name: 1, value: 1 }],
445
+ ]) as Out
446
+ expect(out.series[0]!.itemStyle.color(p(0, 1))).toBe('#ff0000')
447
+ })
448
+
449
+ it('dims item colours outside the selection', () => {
450
+ const merge = createTimeseriesOptionFactory({ theme, selection: [2] })
451
+ const out = merge({}, data) as Out
452
+ const colorFn = out.series[0]!.itemStyle.color
453
+ expect(colorFn(p(0, 1))).toBe(dim('#00ff00'))
454
+ expect(colorFn(p(1, 2))).toBe('#0000ff')
455
+ })
456
+
457
+ it('shows symbols when a point has its own colour, not otherwise', () => {
458
+ const merge = createTimeseriesOptionFactory({ theme })
459
+ expect((merge({}, data) as Out).series[0]!.showSymbol).toBe(true)
460
+ const plain = merge({}, [[{ name: 1, value: 1 }]]) as Out
461
+ expect(plain.series[0]!.showSymbol).toBe(false)
462
+ expect(plain.series[0]!.itemStyle.color(p(0, 1))).toBe('#0000ff')
463
+ })
464
+ })
@@ -12,6 +12,7 @@ import {
12
12
  import { ZOOM_LAYOUT } from '../actions/zoom-toggle'
13
13
  import type { OptionFactory } from '../echart'
14
14
  import { mergeOptions, resolveThemeColor } from '../utils'
15
+ import { createItemColorResolver } from '../utils/item-color'
15
16
  import { positionDataZoomForLegend } from '../utils/data-zoom-layout'
16
17
  import type {
17
18
  TimeseriesEChartsOption,
@@ -19,6 +20,7 @@ import type {
19
20
  TimeseriesOptionsInput,
20
21
  TimeseriesWidgetData,
21
22
  } from './types'
23
+ import { fixedAxisBounds } from '../actions/relative-data/scale'
22
24
 
23
25
  /**
24
26
  * Builds the **structural** ECharts option for a timeseries widget —
@@ -187,6 +189,7 @@ export function createTimeseriesOptionFactory(
187
189
  typeof option.yAxis === 'object' && !Array.isArray(option.yAxis)
188
190
  ? option.yAxis
189
191
  : {}
192
+ const fixedY = fixedAxisBounds(baseYAxis)
190
193
  const baseGrid =
191
194
  typeof option.grid === 'object' && !Array.isArray(option.grid)
192
195
  ? option.grid
@@ -237,24 +240,39 @@ export function createTimeseriesOptionFactory(
237
240
  // and points would stay dimmed forever after an external clear.
238
241
  // Always emitting lets normal merge swap the callback in place, no
239
242
  // `replaceMerge` and no entry-animation flash on selection on/off.
240
- const dimItemStyle = {
241
- color: (params: CallbackDataParams) => {
242
- const datum = params.value as
243
- | { name?: Date | number | string }
244
- | undefined
245
- const raw = datum?.name
246
- const base = params.color as string
247
- if (!selectionSet || raw == null) return base
248
- return selectionSet.has(normalizeName(raw))
249
- ? base
250
- : echarts.color.modifyAlpha(base, 0.15)
251
- },
243
+ //
244
+ // The base colour is the point's own `color`, else the template's
245
+ // `itemStyle.color`, else the series colour / palette (`params.color`).
246
+ const makeDimItemStyle = (datums: readonly object[], template: unknown) => {
247
+ const baseColor = createItemColorResolver({
248
+ theme,
249
+ datums,
250
+ template,
251
+ fallback: (params) => params.color as string,
252
+ })
253
+ return {
254
+ color: (params: CallbackDataParams) => {
255
+ const datum = params.value as
256
+ { name?: Date | number | string } | undefined
257
+ const raw = datum?.name
258
+ const base = baseColor(params)
259
+ if (!selectionSet || raw == null) return base
260
+ return selectionSet.has(normalizeName(raw))
261
+ ? base
262
+ : echarts.color.modifyAlpha(base, 0.15)
263
+ },
264
+ }
252
265
  }
266
+ // Per-point colours only show on markers, so surface them whenever a
267
+ // point carries its own `color` (same reason as the selection case).
268
+ const hasDatumColor = seriesArr.some((s) =>
269
+ s.some((d) => typeof d?.color === 'string' && d.color !== ''),
270
+ )
253
271
 
254
272
  return {
255
273
  ...option,
256
274
  dataset: seriesArr.map((s) => ({ source: s as readonly object[] })),
257
- series: seriesArr.map((_, i) => {
275
+ series: seriesArr.map((s, i) => {
258
276
  const template =
259
277
  (seriesTemplates[i] as object | undefined) ??
260
278
  (broadcastTemplate as object)
@@ -272,10 +290,10 @@ export function createTimeseriesOptionFactory(
272
290
  // When a selection is active, surface markers so the per-point
273
291
  // color callback has something to dim — a continuous line would
274
292
  // hide the visual selection feedback.
275
- showSymbol: selectionSet != null,
293
+ showSymbol: selectionSet != null || hasDatumColor,
276
294
  ...(area ? { areaStyle: {} } : {}),
277
295
  emphasis: { focus: 'series' },
278
- itemStyle: dimItemStyle,
296
+ itemStyle: makeDimItemStyle(s, template),
279
297
  ...(overrideColor
280
298
  ? { color: overrideColor, lineStyle: { color: overrideColor } }
281
299
  : {}),
@@ -286,12 +304,14 @@ export function createTimeseriesOptionFactory(
286
304
  ...(dataZoomLayout ? { dataZoom: dataZoomLayout } : {}),
287
305
  yAxis: {
288
306
  ...baseYAxis,
307
+ // A config transform (RelativeData's `'total'` scale) may pin the
308
+ // ends; each applies only on the side the data reaches.
289
309
  min: (extent: { min: number }) => {
290
- niceMin = extent.min < 0 ? niceNum(extent.min) : 0
310
+ niceMin = extent.min < 0 ? (fixedY.min ?? niceNum(extent.min)) : 0
291
311
  return niceMin
292
312
  },
293
313
  max: (extent: { min: number; max: number }) => {
294
- niceMax = extent.max <= 0 ? 1 : niceNum(extent.max)
314
+ niceMax = extent.max <= 0 ? 1 : (fixedY.max ?? niceNum(extent.max))
295
315
  return niceMax
296
316
  },
297
317
  axisLabel: {
@@ -327,8 +347,7 @@ function buildTimeseriesTooltipFormatter(
327
347
  ) {
328
348
  return createTooltipFormatter((item) => {
329
349
  const row = item.value as
330
- | { name?: Date | number | string; value?: number }
331
- | undefined
350
+ { name?: Date | number | string; value?: number } | undefined
332
351
  const raw = row?.value
333
352
  const formattedValue =
334
353
  typeof raw === 'number' && formatter ? formatter(raw) : (raw ?? '')
@@ -10,6 +10,12 @@ import type { WidgetSeries } from '../types'
10
10
  export interface TimeseriesDatum {
11
11
  name: Date | number | string
12
12
  value: number
13
+ /**
14
+ * Optional colour for this item (CSS colour or theme palette path such as
15
+ * `'primary.main'`). Wins over the template's `itemStyle.color` and the
16
+ * series colour; selection dimming still applies on top.
17
+ */
18
+ color?: string
13
19
  }
14
20
 
15
21
  /** Timeseries widget data — one array of points per series. */
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { createTheme } from '@mui/material'
3
+ import type { CallbackDataParams } from 'echarts/types/dist/shared'
4
+ import { createItemColorResolver } from './item-color'
5
+
6
+ const theme = createTheme({ palette: { primary: { main: '#1976d2' } } })
7
+ const params = (dataIndex: number) =>
8
+ ({ dataIndex, color: '#fallback' }) as unknown as CallbackDataParams
9
+ const fallback = (p: CallbackDataParams) => p.color as string
10
+
11
+ describe('createItemColorResolver', () => {
12
+ it('uses the datum colour first', () => {
13
+ const resolve = createItemColorResolver({
14
+ theme,
15
+ datums: [{ color: '#00ff00' }],
16
+ template: { itemStyle: { color: '#123456' } },
17
+ fallback,
18
+ })
19
+ expect(resolve(params(0))).toBe('#00ff00')
20
+ })
21
+
22
+ it('uses a string template itemStyle.color when the datum has none', () => {
23
+ const resolve = createItemColorResolver({
24
+ theme,
25
+ datums: [{}, 3],
26
+ template: { itemStyle: { color: '#123456' } },
27
+ fallback,
28
+ })
29
+ expect(resolve(params(0))).toBe('#123456')
30
+ expect(resolve(params(1))).toBe('#123456')
31
+ })
32
+
33
+ it('calls a function template itemStyle.color with the ECharts params', () => {
34
+ const color = vi.fn(() => '#abcdef')
35
+ const resolve = createItemColorResolver({
36
+ theme,
37
+ datums: [{}],
38
+ template: { itemStyle: { color } },
39
+ fallback,
40
+ })
41
+ const p = params(0)
42
+ expect(resolve(p)).toBe('#abcdef')
43
+ expect(color).toHaveBeenCalledWith(p)
44
+ })
45
+
46
+ it('falls back when neither datum nor template give a colour', () => {
47
+ const resolve = createItemColorResolver({
48
+ theme,
49
+ datums: [{ value: 1 }],
50
+ template: { itemStyle: { color: () => undefined } },
51
+ fallback,
52
+ })
53
+ expect(resolve(params(0))).toBe('#fallback')
54
+ expect(resolve(params(5))).toBe('#fallback')
55
+ const noTemplate = createItemColorResolver({
56
+ theme,
57
+ datums: [],
58
+ template: undefined,
59
+ fallback,
60
+ })
61
+ expect(noTemplate(params(0))).toBe('#fallback')
62
+ })
63
+
64
+ it('resolves theme palette paths on datum and template colours', () => {
65
+ const resolve = createItemColorResolver({
66
+ theme,
67
+ datums: [{ color: 'primary.main' }, {}],
68
+ template: { itemStyle: { color: () => 'primary.main' } },
69
+ fallback,
70
+ })
71
+ expect(resolve(params(0))).toBe('#1976d2')
72
+ expect(resolve(params(1))).toBe('#1976d2')
73
+ })
74
+ })
@@ -0,0 +1,55 @@
1
+ import type { Theme } from '@mui/material'
2
+ import type { CallbackDataParams } from 'echarts/types/dist/shared'
3
+ import { resolveThemeColor } from './resolve-theme-color'
4
+
5
+ /** ECharts `itemStyle.color` callback, as emitted by the chart widgets. */
6
+ export type ItemColorCallback = (params: CallbackDataParams) => string
7
+
8
+ /**
9
+ * Builds the callback that resolves the **base** colour of every item of one
10
+ * series, before any selection dimming. Priority:
11
+ *
12
+ * 1. the datum's own `color` (`datums[params.dataIndex].color`);
13
+ * 2. the consumer's `template.itemStyle.color` (from `optionsOverride`):
14
+ * a string is used as is, a function is called with the ECharts params;
15
+ * 3. `fallback` — the widget's default (series colour / palette).
16
+ *
17
+ * The datum is looked up by `params.dataIndex` in the widget's own data
18
+ * rather than read off `params.data`, so it works whatever the dataset row
19
+ * shape is (object rows, `[label, count]` tuples, `[x, y]` tuples).
20
+ *
21
+ * Datum and template colours go through {@link resolveThemeColor}, same as
22
+ * per-series colours, so palette paths such as `'primary.main'` work.
23
+ */
24
+ export function createItemColorResolver({
25
+ theme,
26
+ datums,
27
+ template,
28
+ fallback,
29
+ }: {
30
+ theme: Theme
31
+ datums: readonly unknown[]
32
+ template: unknown
33
+ fallback: ItemColorCallback
34
+ }): ItemColorCallback {
35
+ const templateColor = (template as { itemStyle?: { color?: unknown } })
36
+ ?.itemStyle?.color
37
+ return (params) => {
38
+ const datumColor = readColor(datums[params.dataIndex])
39
+ if (datumColor) return resolveThemeColor(theme, datumColor)!
40
+ const custom =
41
+ typeof templateColor === 'function'
42
+ ? (templateColor as (p: CallbackDataParams) => unknown)(params)
43
+ : templateColor
44
+ if (typeof custom === 'string' && custom !== '') {
45
+ return resolveThemeColor(theme, custom)!
46
+ }
47
+ return fallback(params)
48
+ }
49
+ }
50
+
51
+ function readColor(datum: unknown): string | undefined {
52
+ if (datum == null || typeof datum !== 'object') return undefined
53
+ const color = (datum as { color?: unknown }).color
54
+ return typeof color === 'string' && color !== '' ? color : undefined
55
+ }