@carto/ps-react-ui 4.24.0 → 4.26.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.
- package/dist/{actions-B1ss030_.js → actions-DXfQMrll.js} +56 -46
- package/dist/actions-DXfQMrll.js.map +1 -0
- package/dist/category-DuKx_CJX.js +933 -0
- package/dist/category-DuKx_CJX.js.map +1 -0
- package/dist/item-color-C1cu6EJd.js +20 -0
- package/dist/item-color-C1cu6EJd.js.map +1 -0
- package/dist/types/widgets-v2/bar/types.d.ts +6 -0
- package/dist/types/widgets-v2/category/category-ui.d.ts +10 -2
- package/dist/types/widgets-v2/category/category.d.ts +7 -1
- package/dist/types/widgets-v2/category/components/category-bar-stacked.d.ts +8 -1
- package/dist/types/widgets-v2/category/components/category-bar.d.ts +24 -2
- package/dist/types/widgets-v2/category/components/category-row-multi.d.ts +3 -1
- package/dist/types/widgets-v2/category/components/category-row-single.d.ts +3 -1
- package/dist/types/widgets-v2/category/components/category-row-stacked.d.ts +10 -1
- package/dist/types/widgets-v2/category/style.d.ts +33 -6
- package/dist/types/widgets-v2/histogram/bins.d.ts +7 -0
- package/dist/types/widgets-v2/histogram/download.d.ts +2 -2
- package/dist/types/widgets-v2/histogram/index.d.ts +2 -1
- package/dist/types/widgets-v2/histogram/options.d.ts +1 -1
- package/dist/types/widgets-v2/histogram/transforms.d.ts +5 -4
- package/dist/types/widgets-v2/histogram/types.d.ts +22 -5
- package/dist/types/widgets-v2/index.d.ts +1 -1
- package/dist/types/widgets-v2/pie/types.d.ts +6 -0
- package/dist/types/widgets-v2/scatterplot/download.d.ts +2 -2
- package/dist/types/widgets-v2/scatterplot/index.d.ts +2 -1
- package/dist/types/widgets-v2/scatterplot/points.d.ts +7 -0
- package/dist/types/widgets-v2/scatterplot/transforms.d.ts +4 -3
- package/dist/types/widgets-v2/scatterplot/types.d.ts +21 -2
- package/dist/types/widgets-v2/timeseries/types.d.ts +6 -0
- package/dist/types/widgets-v2/utils/item-color.d.ts +26 -0
- package/dist/widgets-v2/actions.js +1 -1
- package/dist/widgets-v2/bar.js +66 -57
- package/dist/widgets-v2/bar.js.map +1 -1
- package/dist/widgets-v2/category.js +1 -1
- package/dist/widgets-v2/histogram.js +103 -71
- package/dist/widgets-v2/histogram.js.map +1 -1
- package/dist/widgets-v2/pie.js +98 -78
- package/dist/widgets-v2/pie.js.map +1 -1
- package/dist/widgets-v2/scatterplot.js +119 -79
- package/dist/widgets-v2/scatterplot.js.map +1 -1
- package/dist/widgets-v2/timeseries.js +70 -61
- package/dist/widgets-v2/timeseries.js.map +1 -1
- package/dist/widgets-v2.js +2 -2
- package/dist/widgets-v2.js.map +1 -1
- package/package.json +3 -3
- package/src/widgets-v2/actions/fullscreen/fullscreen-store.test.tsx +77 -13
- package/src/widgets-v2/actions/fullscreen/fullscreen.tsx +48 -5
- package/src/widgets-v2/bar/options.test.ts +106 -0
- package/src/widgets-v2/bar/options.ts +25 -14
- package/src/widgets-v2/bar/types.ts +6 -0
- package/src/widgets-v2/category/category-ui.test.tsx +139 -0
- package/src/widgets-v2/category/category-ui.tsx +64 -11
- package/src/widgets-v2/category/category.test.tsx +67 -0
- package/src/widgets-v2/category/category.tsx +32 -15
- package/src/widgets-v2/category/components/category-bar-stacked.test.tsx +18 -0
- package/src/widgets-v2/category/components/category-bar-stacked.tsx +42 -29
- package/src/widgets-v2/category/components/category-bar.test.tsx +66 -0
- package/src/widgets-v2/category/components/category-bar.tsx +65 -9
- package/src/widgets-v2/category/components/category-row-multi.tsx +21 -0
- package/src/widgets-v2/category/components/category-row-single.tsx +4 -0
- package/src/widgets-v2/category/components/category-row-stacked.tsx +24 -1
- package/src/widgets-v2/category/style.ts +58 -14
- package/src/widgets-v2/histogram/bins.ts +22 -0
- package/src/widgets-v2/histogram/download.test.ts +10 -0
- package/src/widgets-v2/histogram/download.ts +7 -3
- package/src/widgets-v2/histogram/index.ts +3 -0
- package/src/widgets-v2/histogram/options.test.ts +62 -0
- package/src/widgets-v2/histogram/options.ts +38 -17
- package/src/widgets-v2/histogram/transforms.test.ts +21 -7
- package/src/widgets-v2/histogram/transforms.ts +26 -12
- package/src/widgets-v2/histogram/types.ts +25 -5
- package/src/widgets-v2/index.ts +6 -1
- package/src/widgets-v2/pie/options.test.ts +91 -0
- package/src/widgets-v2/pie/options.ts +50 -20
- package/src/widgets-v2/pie/types.ts +6 -0
- package/src/widgets-v2/scatterplot/download.test.ts +9 -0
- package/src/widgets-v2/scatterplot/download.ts +5 -3
- package/src/widgets-v2/scatterplot/index.ts +3 -0
- package/src/widgets-v2/scatterplot/options.test.ts +78 -0
- package/src/widgets-v2/scatterplot/options.ts +35 -11
- package/src/widgets-v2/scatterplot/points.ts +35 -0
- package/src/widgets-v2/scatterplot/transforms.test.ts +19 -0
- package/src/widgets-v2/scatterplot/transforms.ts +23 -18
- package/src/widgets-v2/scatterplot/types.ts +25 -2
- package/src/widgets-v2/timeseries/options.test.ts +65 -0
- package/src/widgets-v2/timeseries/options.ts +32 -17
- package/src/widgets-v2/timeseries/types.ts +6 -0
- package/src/widgets-v2/utils/item-color.test.ts +74 -0
- package/src/widgets-v2/utils/item-color.ts +55 -0
- package/dist/actions-B1ss030_.js.map +0 -1
- package/dist/category-Ca5XDx0N.js +0 -839
- package/dist/category-Ca5XDx0N.js.map +0 -1
|
@@ -315,15 +315,17 @@ function DialogStore({
|
|
|
315
315
|
const unsubscribe = subscribeEchartInstance(dialogId, (chart) => {
|
|
316
316
|
// Published from inside the chart's init, before its first
|
|
317
317
|
// setOption, so the dialog's own option merges on top of the copy.
|
|
318
|
+
// The empty chart takes the card's whole option: the legend, dataZoom
|
|
319
|
+
// and brush areas need the card's components (axes, brush) to land on.
|
|
318
320
|
const card = liveChart(sourceId)
|
|
319
|
-
if (chart && card) copyChartState(card, chart)
|
|
321
|
+
if (chart && card) copyChartState(card, chart, card.getOption())
|
|
320
322
|
dialogChart = chart
|
|
321
323
|
})
|
|
322
324
|
return () => {
|
|
323
325
|
unsubscribe()
|
|
324
326
|
const card = liveChart(sourceId)
|
|
325
327
|
if (card && dialogChart && !dialogChart.isDisposed()) {
|
|
326
|
-
copyChartState(dialogChart, card)
|
|
328
|
+
copyChartState(dialogChart, card, viewState(dialogChart))
|
|
327
329
|
}
|
|
328
330
|
}
|
|
329
331
|
}, [dialogId, sourceId])
|
|
@@ -351,10 +353,15 @@ function liveChart(id: string): ECharts | null {
|
|
|
351
353
|
/**
|
|
352
354
|
* Put on `chart` what `peer` is showing: the legend selection, the dataZoom
|
|
353
355
|
* window and any drawn brush areas, which live in the ECharts instance
|
|
354
|
-
* rather than the widget store.
|
|
356
|
+
* rather than the widget store. `option` is what of `peer`'s option to merge
|
|
357
|
+
* into `chart`.
|
|
355
358
|
*/
|
|
356
|
-
function copyChartState(
|
|
357
|
-
|
|
359
|
+
function copyChartState(
|
|
360
|
+
peer: ECharts,
|
|
361
|
+
chart: ECharts,
|
|
362
|
+
option: Parameters<ECharts['setOption']>[0],
|
|
363
|
+
): void {
|
|
364
|
+
chart.setOption(option)
|
|
358
365
|
// The pointer was over `peer`, not `chart`: drop the tooltip and axis
|
|
359
366
|
// pointer that came along, or they'd stay drawn until the next hover.
|
|
360
367
|
chart.dispatchAction({ type: 'hideTip' })
|
|
@@ -374,6 +381,42 @@ function copyChartState(peer: ECharts, chart: ECharts): void {
|
|
|
374
381
|
if (areas?.length) chart.dispatchAction({ type: 'brush', areas })
|
|
375
382
|
}
|
|
376
383
|
|
|
384
|
+
interface ViewStateOption {
|
|
385
|
+
// setOption takes an indexed option.
|
|
386
|
+
[key: string]: unknown
|
|
387
|
+
legend?: { selected?: Record<string, boolean> }[]
|
|
388
|
+
dataZoom?: Record<string, unknown>[]
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const ZOOM_WINDOW_KEYS = ['start', 'end', 'startValue', 'endValue'] as const
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The part of `peer`'s option that is user view state. Only this goes back
|
|
395
|
+
* to the card on close: the dialog's full option also carries its own
|
|
396
|
+
* styling (value labels, bigger axis text), and merging it into the card
|
|
397
|
+
* would leave that styling behind once the dialog closes.
|
|
398
|
+
*/
|
|
399
|
+
function viewState(peer: ECharts): ViewStateOption {
|
|
400
|
+
const { legend, dataZoom } = peer.getOption() as ViewStateOption
|
|
401
|
+
const state: ViewStateOption = {}
|
|
402
|
+
if (legend?.some((l) => l.selected)) {
|
|
403
|
+
state.legend = legend.map((l) =>
|
|
404
|
+
l.selected ? { selected: l.selected } : {},
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
if (dataZoom?.length) {
|
|
408
|
+
state.dataZoom = dataZoom.map((zoom) =>
|
|
409
|
+
Object.fromEntries(
|
|
410
|
+
ZOOM_WINDOW_KEYS.filter((k) => zoom[k] !== undefined).map((k) => [
|
|
411
|
+
k,
|
|
412
|
+
zoom[k],
|
|
413
|
+
]),
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
return state
|
|
418
|
+
}
|
|
419
|
+
|
|
377
420
|
/**
|
|
378
421
|
* Merge a caller's `paperSx` over the default paper.
|
|
379
422
|
*
|
|
@@ -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 { barOptions, createBarOptionFactory } from './options'
|
|
4
5
|
|
|
@@ -359,3 +360,108 @@ describe('createBarOptionFactory', () => {
|
|
|
359
360
|
expect(html).toContain('[ITEM:A]')
|
|
360
361
|
})
|
|
361
362
|
})
|
|
363
|
+
|
|
364
|
+
describe('createBarOptionFactory — per-item colour', () => {
|
|
365
|
+
interface Out {
|
|
366
|
+
series: { itemStyle: { color: (p: unknown) => string } }[]
|
|
367
|
+
}
|
|
368
|
+
const data = [
|
|
369
|
+
[
|
|
370
|
+
{ name: 'A', value: 1, color: '#00ff00' },
|
|
371
|
+
{ name: 'B', value: -2 },
|
|
372
|
+
],
|
|
373
|
+
]
|
|
374
|
+
const dim = (c: string) => echarts.color.modifyAlpha(c, 0.15)
|
|
375
|
+
|
|
376
|
+
it('datum colour wins over the series colour', () => {
|
|
377
|
+
const merge = createBarOptionFactory({
|
|
378
|
+
theme,
|
|
379
|
+
series: [{ name: 'S', color: '#0000ff' }],
|
|
380
|
+
})
|
|
381
|
+
const out = merge({}, data) as Out
|
|
382
|
+
const colorFn = out.series[0]!.itemStyle.color
|
|
383
|
+
expect(colorFn({ dataIndex: 0, color: '#0000ff', name: 'A' })).toBe(
|
|
384
|
+
'#00ff00',
|
|
385
|
+
)
|
|
386
|
+
expect(colorFn({ dataIndex: 1, color: '#0000ff', name: 'B' })).toBe(
|
|
387
|
+
'#0000ff',
|
|
388
|
+
)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('uses a string template itemStyle.color from optionsOverride', () => {
|
|
392
|
+
const factory = createBarOptionFactory({
|
|
393
|
+
theme,
|
|
394
|
+
series: [{ name: 'S', color: '#0000ff' }],
|
|
395
|
+
optionsOverride: {
|
|
396
|
+
series: [{ type: 'bar', itemStyle: { color: '#123456' } }],
|
|
397
|
+
},
|
|
398
|
+
})
|
|
399
|
+
const out = factory(factory(undefined, undefined), data) as Out
|
|
400
|
+
const colorFn = out.series[0]!.itemStyle.color
|
|
401
|
+
expect(colorFn({ dataIndex: 0, color: '#0000ff', name: 'A' })).toBe(
|
|
402
|
+
'#00ff00',
|
|
403
|
+
)
|
|
404
|
+
expect(colorFn({ dataIndex: 1, color: '#0000ff', name: 'B' })).toBe(
|
|
405
|
+
'#123456',
|
|
406
|
+
)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('calls a function template itemStyle.color with the ECharts params', () => {
|
|
410
|
+
const merge = createBarOptionFactory({ theme })
|
|
411
|
+
const out = merge(
|
|
412
|
+
{
|
|
413
|
+
series: [
|
|
414
|
+
{
|
|
415
|
+
type: 'bar',
|
|
416
|
+
itemStyle: {
|
|
417
|
+
color: (p: { dataIndex: number }) =>
|
|
418
|
+
p.dataIndex === 1 ? '#ff0000' : '#00ff00',
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
],
|
|
422
|
+
},
|
|
423
|
+
[
|
|
424
|
+
[
|
|
425
|
+
{ name: 'A', value: 1 },
|
|
426
|
+
{ name: 'B', value: -2 },
|
|
427
|
+
],
|
|
428
|
+
],
|
|
429
|
+
) as Out
|
|
430
|
+
const colorFn = out.series[0]!.itemStyle.color
|
|
431
|
+
expect(colorFn({ dataIndex: 0, color: '#abc', name: 'A' })).toBe('#00ff00')
|
|
432
|
+
expect(colorFn({ dataIndex: 1, color: '#abc', name: 'B' })).toBe('#ff0000')
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('resolves theme palette paths on the datum colour', () => {
|
|
436
|
+
const merge = createBarOptionFactory({ theme })
|
|
437
|
+
const out = merge({}, [[{ name: 'A', value: 1, color: 'primary.main' }]])
|
|
438
|
+
const colorFn = (out as Out).series[0]!.itemStyle.color
|
|
439
|
+
expect(colorFn({ dataIndex: 0, color: '#abc', name: 'A' })).toBe(
|
|
440
|
+
theme.palette.primary.main,
|
|
441
|
+
)
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('dims both datum and template colours outside the selection', () => {
|
|
445
|
+
const merge = createBarOptionFactory({ theme, selection: ['Z'] })
|
|
446
|
+
const out = merge(
|
|
447
|
+
{ series: [{ type: 'bar', itemStyle: { color: '#123456' } }] },
|
|
448
|
+
data,
|
|
449
|
+
) as Out
|
|
450
|
+
const colorFn = out.series[0]!.itemStyle.color
|
|
451
|
+
expect(colorFn({ dataIndex: 0, color: '#abc', name: 'A' })).toBe(
|
|
452
|
+
dim('#00ff00'),
|
|
453
|
+
)
|
|
454
|
+
expect(colorFn({ dataIndex: 1, color: '#abc', name: 'B' })).toBe(
|
|
455
|
+
dim('#123456'),
|
|
456
|
+
)
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('keeps the series / palette colour when no item colour is given', () => {
|
|
460
|
+
const merge = createBarOptionFactory({ theme, selection: ['Z'] })
|
|
461
|
+
const out = merge({}, [[{ name: 'A', value: 1 }]]) as Out
|
|
462
|
+
const colorFn = out.series[0]!.itemStyle.color
|
|
463
|
+
expect(colorFn({ dataIndex: 0, color: '#abcdef', name: 'A' })).toBe(
|
|
464
|
+
dim('#abcdef'),
|
|
465
|
+
)
|
|
466
|
+
})
|
|
467
|
+
})
|
|
@@ -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
|
BarEChartsOption,
|
|
@@ -234,10 +235,12 @@ export function createBarOptionFactory(
|
|
|
234
235
|
: baseBottom
|
|
235
236
|
|
|
236
237
|
// When a selection is active, dim non-selected bars by routing the
|
|
237
|
-
//
|
|
238
|
+
// item's base colour through `modifyAlpha`. Per-row `itemStyle` on
|
|
238
239
|
// dataset object-rows is silently ignored when `series.encode` is in
|
|
239
240
|
// play — the callback approach is the standard ECharts pattern for
|
|
240
|
-
// per-data styling derived from a dataset.
|
|
241
|
+
// per-data styling derived from a dataset. The base colour is the
|
|
242
|
+
// datum's `color`, else the template's `itemStyle.color`, else the
|
|
243
|
+
// series colour / palette (`params.color`).
|
|
241
244
|
//
|
|
242
245
|
// We *always* emit `itemStyle.color` (a passthrough when nothing is
|
|
243
246
|
// selected), not conditionally — dropping the key between renders
|
|
@@ -245,22 +248,30 @@ export function createBarOptionFactory(
|
|
|
245
248
|
// and bars would stay dimmed forever after an external clear. Always
|
|
246
249
|
// emitting lets normal merge swap the callback in place, no
|
|
247
250
|
// `replaceMerge` and no entry-animation flash on selection on/off.
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
251
|
+
const makeDimItemStyle = (datums: readonly object[], template: unknown) => {
|
|
252
|
+
const baseColor = createItemColorResolver({
|
|
253
|
+
theme,
|
|
254
|
+
datums,
|
|
255
|
+
template,
|
|
256
|
+
fallback: (params) => params.color as string,
|
|
257
|
+
})
|
|
258
|
+
return {
|
|
259
|
+
color: (params: CallbackDataParams) => {
|
|
260
|
+
const base = baseColor(params)
|
|
261
|
+
if (!selectionSet) return base
|
|
262
|
+
const datum = params.value as { name?: string | number } | undefined
|
|
263
|
+
const name = datum?.name ?? params.name
|
|
264
|
+
return name != null && selectionSet.has(name)
|
|
265
|
+
? base
|
|
266
|
+
: echarts.color.modifyAlpha(base, 0.15)
|
|
267
|
+
},
|
|
268
|
+
}
|
|
258
269
|
}
|
|
259
270
|
|
|
260
271
|
return {
|
|
261
272
|
...option,
|
|
262
273
|
dataset: seriesArr.map((s) => ({ source: s as readonly object[] })),
|
|
263
|
-
series: seriesArr.map((
|
|
274
|
+
series: seriesArr.map((s, i) => {
|
|
264
275
|
const template =
|
|
265
276
|
(seriesTemplates[i] as object | undefined) ??
|
|
266
277
|
(broadcastTemplate as object)
|
|
@@ -277,7 +288,7 @@ export function createBarOptionFactory(
|
|
|
277
288
|
encode: { x: 'name', y: 'value' },
|
|
278
289
|
barMaxWidth: 100,
|
|
279
290
|
emphasis: { focus: 'series' },
|
|
280
|
-
itemStyle:
|
|
291
|
+
itemStyle: makeDimItemStyle(s, template),
|
|
281
292
|
...(overrideColor ? { color: overrideColor } : {}),
|
|
282
293
|
}
|
|
283
294
|
}),
|
|
@@ -6,6 +6,12 @@ import type { WidgetSeries } from '../types'
|
|
|
6
6
|
export interface BarDatum {
|
|
7
7
|
name: string | number
|
|
8
8
|
value: number
|
|
9
|
+
/**
|
|
10
|
+
* Optional colour for this item (CSS colour or theme palette path such as
|
|
11
|
+
* `'primary.main'`). Wins over the template's `itemStyle.color` and the
|
|
12
|
+
* series colour; selection dimming still applies on top.
|
|
13
|
+
*/
|
|
14
|
+
color?: string
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
/** v2 bar widget data shape: array of series, each a flat list of `{name, value}`. */
|
|
@@ -814,4 +814,143 @@ describe('<CategoryUI>', () => {
|
|
|
814
814
|
fills.forEach((f) => expect(f.style.backgroundColor).toBe(DIM))
|
|
815
815
|
})
|
|
816
816
|
})
|
|
817
|
+
|
|
818
|
+
// ── Signed values ──────────────────────────────────────────────────
|
|
819
|
+
describe('negative values', () => {
|
|
820
|
+
const MIXED = [
|
|
821
|
+
[
|
|
822
|
+
{ name: 'Income', value: 0.8 },
|
|
823
|
+
{ name: 'Transit', value: -0.4 },
|
|
824
|
+
{ name: 'Size', value: 0 },
|
|
825
|
+
],
|
|
826
|
+
]
|
|
827
|
+
const fills = (c: HTMLElement) =>
|
|
828
|
+
Array.from(c.querySelectorAll<HTMLElement>('[data-bar-fill="true"]'))
|
|
829
|
+
const ticks = (c: HTMLElement) =>
|
|
830
|
+
Array.from(c.querySelectorAll<HTMLElement>('[data-zero-tick="true"]'))
|
|
831
|
+
|
|
832
|
+
it('centres the zero with a symmetric domain and keeps the series color', () => {
|
|
833
|
+
const { container } = render(<CategoryUI data={MIXED} />)
|
|
834
|
+
const [income, transit, size] = fills(container)
|
|
835
|
+
expect([income!.style.left, income!.style.width]).toEqual(['50%', '50%'])
|
|
836
|
+
expect([transit!.style.left, transit!.style.width]).toEqual([
|
|
837
|
+
'25%',
|
|
838
|
+
'25%',
|
|
839
|
+
])
|
|
840
|
+
expect(size!.style.width).toBe('0%')
|
|
841
|
+
expect(transit!.style.backgroundColor).toBe(income!.style.backgroundColor)
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
it('marks the zero with one tick per bar, none on "Others"', () => {
|
|
845
|
+
const { container } = render(<CategoryUI data={MIXED} maxItems={2} />)
|
|
846
|
+
expect(ticks(container).map((t) => t.style.left)).toEqual(['50%', '50%'])
|
|
847
|
+
expect(screen.getByText('Others')).toBeTruthy()
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
it('draws one zero line per multi-series row, spanning every bar', () => {
|
|
851
|
+
const { container } = render(
|
|
852
|
+
<CategoryUI
|
|
853
|
+
data={[
|
|
854
|
+
[
|
|
855
|
+
{ name: 'A', value: 1 },
|
|
856
|
+
{ name: 'B', value: 2 },
|
|
857
|
+
],
|
|
858
|
+
[
|
|
859
|
+
{ name: 'A', value: -1 },
|
|
860
|
+
{ name: 'B', value: 0.5 },
|
|
861
|
+
],
|
|
862
|
+
[
|
|
863
|
+
{ name: 'A', value: 0 },
|
|
864
|
+
{ name: 'B', value: -2 },
|
|
865
|
+
],
|
|
866
|
+
]}
|
|
867
|
+
/>,
|
|
868
|
+
)
|
|
869
|
+
const t = ticks(container)
|
|
870
|
+
expect(t).toHaveLength(2)
|
|
871
|
+
expect(t[0]!.style.left).toBe('50%')
|
|
872
|
+
expect(t[0]!.parentElement!.style.gridRow).toBe('2 / span 3')
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
it.each([
|
|
876
|
+
['positive-only', [1, 2]],
|
|
877
|
+
['negative-only', [-1, -2]],
|
|
878
|
+
['all-zero', [0, 0]],
|
|
879
|
+
])('draws no zero tick for %s data', (_, values) => {
|
|
880
|
+
const { container } = render(
|
|
881
|
+
<CategoryUI
|
|
882
|
+
data={[values.map((value, i) => ({ name: `R${i}`, value }))]}
|
|
883
|
+
/>,
|
|
884
|
+
)
|
|
885
|
+
expect(ticks(container)).toHaveLength(0)
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
it('hangs negative-only bars from the right edge', () => {
|
|
889
|
+
const { container } = render(
|
|
890
|
+
<CategoryUI
|
|
891
|
+
data={[
|
|
892
|
+
[
|
|
893
|
+
{ name: 'A', value: -2 },
|
|
894
|
+
{ name: 'B', value: -1 },
|
|
895
|
+
],
|
|
896
|
+
]}
|
|
897
|
+
/>,
|
|
898
|
+
)
|
|
899
|
+
const [a, b] = fills(container)
|
|
900
|
+
expect([a!.style.left, a!.style.width]).toEqual(['0%', '100%'])
|
|
901
|
+
expect([b!.style.left, b!.style.width]).toEqual(['50%', '50%'])
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
it('treats maxOverride as a magnitude for signed data', () => {
|
|
905
|
+
const { container } = render(
|
|
906
|
+
<CategoryUI data={MIXED} maxOverride={1.6} />,
|
|
907
|
+
)
|
|
908
|
+
const [income, transit] = fills(container)
|
|
909
|
+
// domain [-1.6, 1.6]
|
|
910
|
+
expect([income!.style.left, income!.style.width]).toEqual(['50%', '25%'])
|
|
911
|
+
expect(transit!.style.width).toBe('12.5%')
|
|
912
|
+
})
|
|
913
|
+
|
|
914
|
+
it('minOverride pins the zero even when the data has no negatives', () => {
|
|
915
|
+
const { container } = render(
|
|
916
|
+
<CategoryUI data={[[{ name: 'A', value: 1 }]]} minOverride={-1} />,
|
|
917
|
+
)
|
|
918
|
+
expect(fills(container)[0]!.style.left).toBe('50%')
|
|
919
|
+
expect(ticks(container)).toHaveLength(1)
|
|
920
|
+
})
|
|
921
|
+
|
|
922
|
+
it('prefixes U+2212 itself and hands the formatter |v|', () => {
|
|
923
|
+
const formatter = vi.fn((n: number) => `$${n}`)
|
|
924
|
+
render(
|
|
925
|
+
<CategoryUI
|
|
926
|
+
data={[
|
|
927
|
+
[
|
|
928
|
+
{ name: 'A', value: -0.5 },
|
|
929
|
+
{ name: 'B', value: -0 },
|
|
930
|
+
{ name: 'C', value: 0 },
|
|
931
|
+
],
|
|
932
|
+
]}
|
|
933
|
+
formatter={formatter}
|
|
934
|
+
/>,
|
|
935
|
+
)
|
|
936
|
+
expect(screen.getByText('\u2212$0.5')).toBeTruthy()
|
|
937
|
+
expect(screen.getAllByText('$0')).toHaveLength(2)
|
|
938
|
+
formatter.mock.calls.forEach(([n]) => expect(n).toBeGreaterThanOrEqual(0))
|
|
939
|
+
})
|
|
940
|
+
|
|
941
|
+
it('stacked mode diverges around the shared zero and shows the signed total', () => {
|
|
942
|
+
const { container } = render(
|
|
943
|
+
<CategoryUI
|
|
944
|
+
stacked
|
|
945
|
+
data={[[{ name: 'A', value: 3 }], [{ name: 'A', value: -1 }]]}
|
|
946
|
+
/>,
|
|
947
|
+
)
|
|
948
|
+
const [pos, neg] = fills(container)
|
|
949
|
+
// zero at 50%, longer side (3) fills its half
|
|
950
|
+
expect([pos!.style.left, pos!.style.width]).toEqual(['50%', '50%'])
|
|
951
|
+
expect(parseFloat(neg!.style.left)).toBeCloseTo(50 - 50 / 3)
|
|
952
|
+
expect(screen.getByText('2')).toBeTruthy()
|
|
953
|
+
expect(screen.getByText('Series 2: \u22121')).toBeTruthy()
|
|
954
|
+
})
|
|
955
|
+
})
|
|
817
956
|
})
|
|
@@ -78,8 +78,16 @@ export interface CategoryUIProps {
|
|
|
78
78
|
* overflow row stays a static summary.
|
|
79
79
|
*/
|
|
80
80
|
onShowAll?: () => void
|
|
81
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Manual override for the top of the bar scale. With signed data it is a
|
|
83
|
+
* magnitude: the scale stays symmetric around the centred zero.
|
|
84
|
+
*/
|
|
82
85
|
maxOverride?: number
|
|
86
|
+
/**
|
|
87
|
+
* Manual override for the bottom of the bar scale, the negative mirror of
|
|
88
|
+
* `maxOverride`. Ignored unless negative.
|
|
89
|
+
*/
|
|
90
|
+
minOverride?: number
|
|
83
91
|
/**
|
|
84
92
|
* Visual density of the bar primitive. `'small'` (default) keeps the
|
|
85
93
|
* historical 4px-tall pill; `'medium'` switches to a 12px-tall track
|
|
@@ -147,6 +155,42 @@ function generateGroupedData(data: CategoryWidgetData): GroupedRow[] {
|
|
|
147
155
|
return order.map((k) => grouped.get(k)!)
|
|
148
156
|
}
|
|
149
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Bar domain `[lo, hi]`, always containing zero. Positive-only data keeps
|
|
160
|
+
* the zero at the left edge and negative-only at the right; mixed data is
|
|
161
|
+
* made symmetric so the zero sits in the middle of the track.
|
|
162
|
+
*/
|
|
163
|
+
function signedDomain(
|
|
164
|
+
rows: readonly GroupedRow[],
|
|
165
|
+
minOverride?: number,
|
|
166
|
+
maxOverride?: number,
|
|
167
|
+
): [lo: number, hi: number] {
|
|
168
|
+
let lo = 0
|
|
169
|
+
let hi = 0
|
|
170
|
+
for (const row of rows) {
|
|
171
|
+
for (const v of row.values) {
|
|
172
|
+
if (v < lo) lo = v
|
|
173
|
+
if (v > hi) hi = v
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (typeof minOverride === 'number' && minOverride < 0) lo = minOverride
|
|
177
|
+
if (typeof maxOverride === 'number' && maxOverride > 0) hi = maxOverride
|
|
178
|
+
if (lo < 0 && hi > 0) {
|
|
179
|
+
const m = Math.max(-lo, hi)
|
|
180
|
+
return [-m, m]
|
|
181
|
+
}
|
|
182
|
+
return [lo, hi]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The widget owns the sign: `formatter` only ever sees `|v|`, so a formatter
|
|
187
|
+
* that prefixes a unit reads `−$5`, not `$-5`, and one that emits its own
|
|
188
|
+
* `-` cannot double it. U+2212 is as wide as a tabular digit.
|
|
189
|
+
*/
|
|
190
|
+
function formatSigned(fmt: (n: number) => string, v: number): string {
|
|
191
|
+
return (v < 0 ? '\u2212' : '') + fmt(Math.abs(v))
|
|
192
|
+
}
|
|
193
|
+
|
|
150
194
|
/**
|
|
151
195
|
* Pure presentational component for the Category widget. Renders
|
|
152
196
|
* single-series rows (`data.length === 1`) as a compact grid or
|
|
@@ -173,13 +217,15 @@ export function CategoryUI({
|
|
|
173
217
|
labels,
|
|
174
218
|
onShowAll,
|
|
175
219
|
maxOverride,
|
|
220
|
+
minOverride,
|
|
176
221
|
size = 'small',
|
|
177
222
|
stacked = false,
|
|
178
223
|
hiddenSeries: hiddenSeriesProp,
|
|
179
224
|
onHiddenSeriesChange,
|
|
180
225
|
}: CategoryUIProps) {
|
|
181
226
|
const theme = useTheme()
|
|
182
|
-
const
|
|
227
|
+
const baseFmt = formatter ?? ((n: number) => String(n))
|
|
228
|
+
const fmt = (n: number) => formatSigned(baseFmt, n)
|
|
183
229
|
|
|
184
230
|
const hasSelection = !!selection && selection.length > 0
|
|
185
231
|
const selectionSet = useMemo(
|
|
@@ -231,14 +277,14 @@ export function CategoryUI({
|
|
|
231
277
|
|
|
232
278
|
const grouped = useMemo(() => generateGroupedData(data), [data])
|
|
233
279
|
|
|
234
|
-
const maxValue = useMemo(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
280
|
+
const [minValue, maxValue] = useMemo(
|
|
281
|
+
() => signedDomain(grouped, minOverride, maxOverride),
|
|
282
|
+
[grouped, minOverride, maxOverride],
|
|
283
|
+
)
|
|
284
|
+
// Where the zero sits on the track, handed to stacked rows so their zero
|
|
285
|
+
// matches the other rows'. Positive-only data keeps it at the left edge.
|
|
286
|
+
const zeroPct =
|
|
287
|
+
maxValue > minValue ? (-minValue / (maxValue - minValue)) * 100 : 0
|
|
242
288
|
|
|
243
289
|
// Three modes:
|
|
244
290
|
// * positive finite N (default `DEFAULT_MAX_ITEMS = 20` when prop
|
|
@@ -363,7 +409,11 @@ export function CategoryUI({
|
|
|
363
409
|
<Box sx={styles.root}>
|
|
364
410
|
<Box
|
|
365
411
|
ref={listRefCallback}
|
|
366
|
-
sx={
|
|
412
|
+
sx={{
|
|
413
|
+
...styles.list,
|
|
414
|
+
...(isMulti && !stacked && styles.listMulti),
|
|
415
|
+
...(scrollMode && styles.listScroll),
|
|
416
|
+
}}
|
|
367
417
|
style={
|
|
368
418
|
scrollMaxHeight !== undefined
|
|
369
419
|
? { maxHeight: scrollMaxHeight, overflowY: 'auto' }
|
|
@@ -413,6 +463,7 @@ export function CategoryUI({
|
|
|
413
463
|
selected={selected}
|
|
414
464
|
onToggle={toggle}
|
|
415
465
|
size={size}
|
|
466
|
+
zeroPct={zeroPct}
|
|
416
467
|
/>
|
|
417
468
|
)
|
|
418
469
|
}
|
|
@@ -424,6 +475,7 @@ export function CategoryUI({
|
|
|
424
475
|
values={visibleIdx.map((i) => row.values[i]!)}
|
|
425
476
|
colors={visibleIdx.map((i) => fill(i))}
|
|
426
477
|
maxValue={maxValue}
|
|
478
|
+
minValue={minValue}
|
|
427
479
|
formatter={fmt}
|
|
428
480
|
selected={selected}
|
|
429
481
|
onToggle={toggle}
|
|
@@ -438,6 +490,7 @@ export function CategoryUI({
|
|
|
438
490
|
displayName={displayName}
|
|
439
491
|
value={row.values[0] ?? 0}
|
|
440
492
|
maxValue={maxValue}
|
|
493
|
+
minValue={minValue}
|
|
441
494
|
color={fill(0)}
|
|
442
495
|
formatter={fmt}
|
|
443
496
|
selected={selected}
|
|
@@ -238,6 +238,73 @@ describe('<Category> bridge', () => {
|
|
|
238
238
|
expect(fills[0]!.style.width).toBe('25%')
|
|
239
239
|
})
|
|
240
240
|
|
|
241
|
+
it('keeps the zero centred when a transform hides every negative row', () => {
|
|
242
|
+
render(
|
|
243
|
+
<Provider
|
|
244
|
+
id='cat-bridge-auto-neg'
|
|
245
|
+
data={[
|
|
246
|
+
[
|
|
247
|
+
{ name: 'A', value: 1 },
|
|
248
|
+
{ name: 'B', value: -1 },
|
|
249
|
+
],
|
|
250
|
+
]}
|
|
251
|
+
>
|
|
252
|
+
<Category />
|
|
253
|
+
</Provider>,
|
|
254
|
+
)
|
|
255
|
+
act(() => {
|
|
256
|
+
getWidgetStore('cat-bridge-auto-neg').setState({
|
|
257
|
+
data: [[{ name: 'A', value: 1 }]],
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
const fills = getBarFills()
|
|
261
|
+
expect(fills).toHaveLength(1)
|
|
262
|
+
expect(fills[0]!.style.left).toBe('50%')
|
|
263
|
+
expect(fills[0]!.style.width).toBe('50%')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it.each([
|
|
267
|
+
[
|
|
268
|
+
'positive-only',
|
|
269
|
+
[50, 25],
|
|
270
|
+
[
|
|
271
|
+
['0%', '50%'],
|
|
272
|
+
['0%', '25%'],
|
|
273
|
+
],
|
|
274
|
+
],
|
|
275
|
+
[
|
|
276
|
+
'negative-only',
|
|
277
|
+
[-50, -25],
|
|
278
|
+
[
|
|
279
|
+
['50%', '50%'],
|
|
280
|
+
['75%', '25%'],
|
|
281
|
+
],
|
|
282
|
+
],
|
|
283
|
+
[
|
|
284
|
+
'mixed',
|
|
285
|
+
[50, -25],
|
|
286
|
+
[
|
|
287
|
+
['50%', '25%'],
|
|
288
|
+
['37.5%', '12.5%'],
|
|
289
|
+
],
|
|
290
|
+
],
|
|
291
|
+
])(
|
|
292
|
+
'applies ±100 overrides only on the side %s data reaches',
|
|
293
|
+
(_, values, expected) => {
|
|
294
|
+
render(
|
|
295
|
+
<Provider
|
|
296
|
+
id={`cat-bridge-side-${values.join('')}`}
|
|
297
|
+
data={[values.map((value, i) => ({ name: `R${i}`, value }))]}
|
|
298
|
+
>
|
|
299
|
+
<Category maxOverride={100} minOverride={-100} />
|
|
300
|
+
</Provider>,
|
|
301
|
+
)
|
|
302
|
+
expect(getBarFills().map((f) => [f.style.left, f.style.width])).toEqual(
|
|
303
|
+
expected,
|
|
304
|
+
)
|
|
305
|
+
},
|
|
306
|
+
)
|
|
307
|
+
|
|
241
308
|
it('consumer-supplied maxOverride wins over the rawData auto-fill', () => {
|
|
242
309
|
render(
|
|
243
310
|
<Provider id='cat-bridge-auto-3' data={PEAK_DATA}>
|