@svgrid/grid 3.0.1 → 3.0.3

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 (77) hide show
  1. package/dist/GridFooter.svelte +14 -5
  2. package/dist/GridFooter.svelte.d.ts +8 -0
  3. package/dist/GridMenus.svelte +18 -10
  4. package/dist/GridMenus.svelte.d.ts +2 -1
  5. package/dist/SvGrid.controller.svelte.d.ts +17 -0
  6. package/dist/SvGrid.controller.svelte.js +128 -14
  7. package/dist/SvGrid.css +79 -10
  8. package/dist/SvGrid.svelte +191 -186
  9. package/dist/SvGrid.types.d.ts +42 -1
  10. package/dist/SvGridChart.svelte +571 -41
  11. package/dist/SvGridChart.svelte.d.ts +63 -1
  12. package/dist/SvGridChartPanel.svelte +169 -21
  13. package/dist/SvGridChartView.svelte +9 -0
  14. package/dist/SvGridChartView.svelte.d.ts +7 -0
  15. package/dist/SvGroupCell.svelte +28 -3
  16. package/dist/SvGroupCell.svelte.d.ts +8 -0
  17. package/dist/SvRowGroupPanel.svelte +20 -4
  18. package/dist/SvRowGroupPanel.svelte.d.ts +8 -0
  19. package/dist/ai.d.ts +11 -2
  20. package/dist/ai.js +51 -7
  21. package/dist/cdn/GridMenus-CHOMnW-c.js +602 -0
  22. package/dist/cdn/{GridMenus-DFB6iwF8.js → GridMenus-DhdPF700.js} +152 -150
  23. package/dist/cdn/SvGridChart-1PMSp2LZ.js +1481 -0
  24. package/dist/cdn/SvGridChart-D02avhyr.js +1480 -0
  25. package/dist/cdn/SvGridChartPanel-DJnViOv4.js +834 -0
  26. package/dist/cdn/SvGridChartPanel-vPTNerFZ.js +810 -0
  27. package/dist/cdn/{SvGridChartView-BhUEJ5ki.js → SvGridChartView-C--HhdUo.js} +5 -3
  28. package/dist/cdn/{SvGridChartView-00LPUHL1.js → SvGridChartView-KyyFG-wY.js} +5 -3
  29. package/dist/cdn/cell-formatting-C2Hf5gqd.js +86 -0
  30. package/dist/cdn/chart-DLg3_zTQ.js +1652 -0
  31. package/dist/cdn/{export-format-Cv1Dll6k.js → export-format-CUDLy2yn.js} +84 -168
  32. package/dist/cdn/{src-C8QjSaq-.js → src-Bugf5XlI.js} +3608 -3414
  33. package/dist/cdn/{src-D0hO2eYN.js → src-D6XvA1Ij.js} +8617 -8423
  34. package/dist/cdn/svgrid.js +16 -15
  35. package/dist/cdn/svgrid.svelte-external.js +16 -15
  36. package/dist/cell-formatting.d.ts +7 -0
  37. package/dist/cell-formatting.js +7 -1
  38. package/dist/chart-export.d.ts +23 -2
  39. package/dist/chart-export.js +80 -6
  40. package/dist/chart.d.ts +410 -18
  41. package/dist/chart.js +824 -24
  42. package/dist/grid-icons.d.ts +49 -0
  43. package/dist/grid-icons.js +86 -0
  44. package/dist/index.d.ts +3 -2
  45. package/dist/index.js +3 -2
  46. package/package.json +11 -11
  47. package/src/GridFooter.svelte +14 -5
  48. package/src/GridMenus.svelte +18 -10
  49. package/src/SvGrid.controller.svelte.ts +146 -13
  50. package/src/SvGrid.css +79 -10
  51. package/src/SvGrid.svelte +191 -186
  52. package/src/SvGrid.types.ts +42 -1
  53. package/src/SvGridChart.svelte +571 -41
  54. package/src/SvGridChart.test.ts +243 -1
  55. package/src/SvGridChartPanel.svelte +169 -21
  56. package/src/SvGridChartView.svelte +9 -0
  57. package/src/SvGroupCell.svelte +28 -3
  58. package/src/SvRowGroupPanel.svelte +20 -4
  59. package/src/ai.test.ts +60 -1
  60. package/src/ai.ts +59 -6
  61. package/src/cell-formatting.ts +7 -1
  62. package/src/chart-export.test.ts +112 -0
  63. package/src/chart-export.ts +76 -8
  64. package/src/chart.test.ts +830 -1
  65. package/src/chart.ts +1053 -32
  66. package/src/grid-icons.test.ts +46 -0
  67. package/src/grid-icons.ts +184 -0
  68. package/src/icon-overrides.grid.test.ts +188 -0
  69. package/src/icon-seam.test.ts +100 -0
  70. package/src/index.ts +19 -0
  71. package/src/svgrid.charting.test.ts +120 -0
  72. package/dist/cdn/GridMenus-CYzOvmNQ.js +0 -600
  73. package/dist/cdn/SvGridChart-BEJmNNx9.js +0 -1203
  74. package/dist/cdn/SvGridChart-BwVos976.js +0 -1202
  75. package/dist/cdn/SvGridChartPanel-BOknOkrP.js +0 -561
  76. package/dist/cdn/SvGridChartPanel-D2PjII4O.js +0 -585
  77. package/dist/cdn/chart-i4XcXyHJ.js +0 -1214
package/src/chart.test.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { buildChart, rowsToChartSpec, niceScale } from './chart'
2
+ import { buildChart, rowsToChartSpec, niceScale, sliceChartWindow, specToTreemap, specToCalendar, specToSankey, rowsToScatterSpec, rowsToGaugeSpec, boxStats, rowsToBoxSpec, chartScales } from './chart'
3
+ import type { ChartSpec } from './chart'
3
4
 
4
5
  describe('niceScale', () => {
5
6
  it('rounds a positive domain to nice ticks including 0', () => {
@@ -158,6 +159,18 @@ describe('buildChart', () => {
158
159
  expect(g.bars.map((b) => b.value).sort()).toEqual([1, 3])
159
160
  })
160
161
 
162
+ it('stacked100 labels the axis as a percentage, not as the measure', () => {
163
+ // The axis is a share of the total there, so a currency format would give
164
+ // an axis reading 0 to 100 in currency for what are percentages.
165
+ const g = buildChart({
166
+ type: 'bar', stacked100: true, valueFormat: 'currency',
167
+ categories: ['A'],
168
+ series: [{ label: 'x', values: [1] }, { label: 'y', values: [3] }],
169
+ })
170
+ expect(g.yTicks.map((t) => t.label)).toContain('100%')
171
+ expect(g.yTicks.every((t) => !t.label.includes('$'))).toBe(true)
172
+ })
173
+
161
174
  it('stacked100 area: the top series reaches the 100% line, not the raw total', () => {
162
175
  const g = buildChart({
163
176
  type: 'area',
@@ -309,3 +322,819 @@ describe('rowsToChartSpec', () => {
309
322
  expect(spec.stacked100).toBe(true)
310
323
  })
311
324
  })
325
+
326
+ describe('sliceChartWindow (the zoom / brush window)', () => {
327
+ const banded = (): ChartSpec => ({
328
+ type: 'line',
329
+ categories: ['a', 'b', 'c', 'd', 'e'],
330
+ series: [
331
+ {
332
+ label: 'forecast',
333
+ values: [1, 2, 3, 4, 5],
334
+ upperValues: [2, 3, 4, 5, 6],
335
+ lowerValues: [0, 1, 2, 3, 4],
336
+ rowIds: [['r1'], ['r2'], ['r3'], ['r4'], ['r5']],
337
+ },
338
+ ],
339
+ })
340
+
341
+ it('keeps every category-parallel array the same length', () => {
342
+ const w = sliceChartWindow(banded(), 1, 3)
343
+ const s = w.series[0]!
344
+ expect(w.categories).toEqual(['b', 'c', 'd'])
345
+ expect(s.values).toEqual([2, 3, 4])
346
+ expect(s.upperValues).toEqual([3, 4, 5])
347
+ expect(s.lowerValues).toEqual([1, 2, 3])
348
+ expect(s.rowIds).toEqual([['r2'], ['r3'], ['r4']])
349
+ })
350
+
351
+ it('still draws the confidence band after zooming', () => {
352
+ // The regression this function exists for. The window used to slice
353
+ // `values` but not the envelopes, so their lengths stopped matching and
354
+ // buildChart's equality guard dropped the band without a word.
355
+ const full = buildChart(banded())
356
+ expect(full.lines[0]!.bandPath).not.toBe('')
357
+
358
+ const zoomed = buildChart(sliceChartWindow(banded(), 1, 3))
359
+ expect(zoomed.lines[0]!.bandPath, 'band vanished inside the zoom window').not.toBe('')
360
+ })
361
+
362
+ it('slices ohlc, boxes and errors with the window', () => {
363
+ // Same defect class as the band above, and the reason to assert every
364
+ // per-category array rather than the one that broke last time: a missed
365
+ // array does not throw, it silently draws against the wrong categories.
366
+ const spec: ChartSpec = {
367
+ type: 'candlestick',
368
+ categories: ['a', 'b', 'c', 'd'],
369
+ series: [
370
+ {
371
+ label: 'px',
372
+ values: [1, 2, 3, 4],
373
+ ohlc: [
374
+ { o: 1, h: 2, l: 0, c: 1 },
375
+ { o: 2, h: 3, l: 1, c: 2 },
376
+ { o: 3, h: 4, l: 2, c: 3 },
377
+ { o: 4, h: 5, l: 3, c: 4 },
378
+ ],
379
+ boxes: [
380
+ { min: 0, q1: 1, median: 2, q3: 3, max: 4 },
381
+ { min: 1, q1: 2, median: 3, q3: 4, max: 5 },
382
+ { min: 2, q1: 3, median: 4, q3: 5, max: 6 },
383
+ { min: 3, q1: 4, median: 5, q3: 6, max: 7 },
384
+ ],
385
+ errors: [0.1, 0.2, 0.3, 0.4],
386
+ },
387
+ ],
388
+ }
389
+ const s = sliceChartWindow(spec, 1, 2).series[0]!
390
+ expect(s.values).toEqual([2, 3])
391
+ expect(s.ohlc).toHaveLength(2)
392
+ expect(s.ohlc![0]!.c).toBe(2)
393
+ expect(s.boxes).toHaveLength(2)
394
+ expect(s.boxes![0]!.median).toBe(3)
395
+ expect(s.errors).toEqual([0.2, 0.3])
396
+ })
397
+
398
+ it('draws a zoomed candlestick against the right categories', () => {
399
+ // Before the slice covered `ohlc`, the geometry walked the full-length
400
+ // array against three categories, so candles landed off the plot.
401
+ const spec: ChartSpec = {
402
+ type: 'candlestick',
403
+ categories: ['a', 'b', 'c', 'd', 'e'],
404
+ series: [
405
+ {
406
+ label: 'px',
407
+ values: [1, 2, 3, 4, 5],
408
+ ohlc: [
409
+ { o: 1, h: 2, l: 0, c: 1 },
410
+ { o: 2, h: 3, l: 1, c: 2 },
411
+ { o: 3, h: 4, l: 2, c: 3 },
412
+ { o: 4, h: 5, l: 3, c: 4 },
413
+ { o: 5, h: 6, l: 4, c: 5 },
414
+ ],
415
+ },
416
+ ],
417
+ width: 400,
418
+ height: 300,
419
+ }
420
+ const zoomed = buildChart(sliceChartWindow(spec, 1, 3))
421
+ expect(zoomed.candles).toHaveLength(3)
422
+ expect(zoomed.candles.map((c) => c.label)).toEqual(['b', 'c', 'd'])
423
+ for (const c of zoomed.candles) {
424
+ expect(c.xCenter).toBeGreaterThanOrEqual(zoomed.plot.x)
425
+ expect(c.xCenter).toBeLessThanOrEqual(zoomed.plot.x + zoomed.plot.w)
426
+ }
427
+ })
428
+
429
+ it('carries waterfall totals with the window', () => {
430
+ const w = sliceChartWindow(
431
+ { type: 'waterfall', categories: ['a', 'b', 'c'], series: [{ label: 's', values: [1, 2, 3] }], waterfallTotals: [false, false, true] },
432
+ 1,
433
+ 2,
434
+ )
435
+ expect(w.waterfallTotals).toEqual([false, true])
436
+ })
437
+
438
+ it('clamps a window that runs past either end', () => {
439
+ const w = sliceChartWindow(banded(), -5, 99)
440
+ expect(w.categories).toHaveLength(5)
441
+ expect(w.series[0]!.values).toHaveLength(5)
442
+ })
443
+
444
+ it('leaves a series without envelopes undisturbed', () => {
445
+ const w = sliceChartWindow(
446
+ { type: 'bar', categories: ['a', 'b'], series: [{ label: 's', values: [1, 2] }] },
447
+ 0,
448
+ 1,
449
+ )
450
+ expect(w.series[0]!.upperValues).toBeUndefined()
451
+ expect(w.series[0]!.rowIds).toBeUndefined()
452
+ })
453
+ })
454
+
455
+ describe('buildChart: candlestick / OHLC', () => {
456
+ const bars = [
457
+ { o: 100, h: 110, l: 95, c: 105 },
458
+ { o: 105, h: 108, l: 99, c: 101 },
459
+ { o: 101, h: 120, l: 100, c: 118 },
460
+ ]
461
+ const priced = (extra: Partial<ChartSpec> = {}): ChartSpec => ({
462
+ type: 'candlestick',
463
+ categories: ['2026-03-02', '2026-03-03', '2026-03-04'],
464
+ series: [{ label: 'ACME', values: bars.map((b) => b.c), ohlc: bars }],
465
+ ...extra,
466
+ })
467
+
468
+ it('draws one candle per bar, body spanning open to close', () => {
469
+ const g = buildChart(priced())
470
+ expect(g.candles).toHaveLength(3)
471
+ const first = g.candles[0]!
472
+ // SVG y grows downward, so a rising candle opens BELOW its close on screen.
473
+ expect(first.up).toBe(true)
474
+ expect(first.bodyY).toBe(Math.min(first.yOpen, first.yClose))
475
+ expect(first.bodyH).toBeCloseTo(Math.abs(first.yClose - first.yOpen), 5)
476
+ // Wick brackets the body.
477
+ expect(first.yHigh).toBeLessThanOrEqual(first.bodyY)
478
+ expect(first.yLow).toBeGreaterThanOrEqual(first.bodyY + first.bodyH)
479
+ })
480
+
481
+ it('colours by direction, not by series', () => {
482
+ const g = buildChart(priced({ candleColors: { up: '#00f', down: '#f00' } }))
483
+ expect(g.candles.map((k) => k.color)).toEqual(['#00f', '#f00', '#00f'])
484
+ })
485
+
486
+ it('does not drag the price axis down to zero', () => {
487
+ // Candles are not stackable, so the bar chart's "always include 0" rule
488
+ // must not fire. A 95..120 price series pinned to a zero baseline wastes
489
+ // four fifths of the plot and is the whole reason `kind` is its own value.
490
+ const g = buildChart(priced())
491
+ expect(g.yTicks[0]!.value).toBeGreaterThan(0)
492
+ })
493
+
494
+ it('does not also draw the closes as a line over the candles', () => {
495
+ // `values` carries the closes so tooltips, CSV and overlays keep working.
496
+ // That is only safe while the line loop skips candle series.
497
+ const g = buildChart(priced())
498
+ expect(g.lines).toEqual([])
499
+ })
500
+
501
+ it('still draws a moving-average overlay from those same closes', () => {
502
+ const withMa = priced()
503
+ withMa.series[0]!.overlay = 'sma:2'
504
+ const g = buildChart(withMa)
505
+ expect(g.overlays.length).toBe(1)
506
+ expect(g.lines).toEqual([])
507
+ })
508
+
509
+ it('ohlc renders the same geometry as candlestick', () => {
510
+ const a = buildChart(priced())
511
+ const b = buildChart(priced({ type: 'ohlc' }))
512
+ expect(b.candles).toEqual(a.candles)
513
+ })
514
+
515
+ it('skips a null bar without shifting the rest', () => {
516
+ const withGap = priced()
517
+ withGap.series[0]!.ohlc = [bars[0]!, null, bars[2]!]
518
+ const g = buildChart(withGap)
519
+ expect(g.candles).toHaveLength(2)
520
+ expect(g.candles.map((k) => k.label)).toEqual(['2026-03-02', '2026-03-04'])
521
+ })
522
+
523
+ it('keeps a doji visible', () => {
524
+ const flat = priced()
525
+ flat.series[0]!.ohlc = [{ o: 100, h: 104, l: 96, c: 100 }]
526
+ flat.categories = ['2026-03-02']
527
+ flat.series[0]!.values = [100]
528
+ expect(buildChart(flat).candles[0]!.bodyH).toBeGreaterThanOrEqual(1)
529
+ })
530
+
531
+ it('returns empty geometry when no series carries ohlc', () => {
532
+ const g = buildChart({ type: 'candlestick', categories: ['a'], series: [{ label: 's', values: [1] }] })
533
+ expect(g.candles).toEqual([])
534
+ })
535
+
536
+ it('carries a volume series on the right axis alongside the prices', () => {
537
+ const g = buildChart(
538
+ priced({
539
+ series: [
540
+ { label: 'ACME', values: bars.map((b) => b.c), ohlc: bars },
541
+ { label: 'Volume', values: [12, 30, 21], type: 'bar', axis: 'right' },
542
+ ],
543
+ }),
544
+ )
545
+ expect(g.candles).toHaveLength(3)
546
+ expect(g.bars).toHaveLength(3)
547
+ expect(g.hasRightAxis).toBe(true)
548
+ })
549
+
550
+ it('drops a non-positive price on a log axis instead of emitting NaN', () => {
551
+ const bad = priced({ yScale: 'log' })
552
+ bad.series[0]!.ohlc = [{ o: 0, h: 10, l: -5, c: 8 }, bars[1]!, bars[2]!]
553
+ const g = buildChart(bad)
554
+ expect(g.candles).toHaveLength(2)
555
+ for (const k of g.candles) {
556
+ for (const v of [k.yOpen, k.yClose, k.yHigh, k.yLow]) expect(Number.isFinite(v)).toBe(true)
557
+ }
558
+ })
559
+ })
560
+
561
+ describe("xType: 'ordinal-time'", () => {
562
+ // Fri, Mon, Tue: a real weekend sits between the first two.
563
+ const sessions = ['2026-03-06', '2026-03-09', '2026-03-10']
564
+ const spec = (xType: ChartSpec['xType']): ChartSpec => ({
565
+ type: 'line',
566
+ categories: sessions,
567
+ series: [{ label: 'p', values: [1, 2, 3] }],
568
+ xType,
569
+ })
570
+
571
+ it('spaces sessions evenly across a weekend', () => {
572
+ const g = buildChart(spec('ordinal-time'))
573
+ const xs = g.lines[0]!.points.map((p) => p.x)
574
+ expect(xs[1]! - xs[0]!).toBeCloseTo(xs[2]! - xs[1]!, 5)
575
+ })
576
+
577
+ it("differs from 'time', which leaves the weekend as a gap", () => {
578
+ const g = buildChart(spec('time'))
579
+ const xs = g.lines[0]!.points.map((p) => p.x)
580
+ // Three calendar days versus one, so the first gap is the wider one.
581
+ expect(xs[1]! - xs[0]!).toBeGreaterThan(xs[2]! - xs[1]!)
582
+ })
583
+
584
+ it('labels ticks from the dates and puts them on real points', () => {
585
+ const g = buildChart(spec('ordinal-time'))
586
+ expect(g.xTicks.length).toBeGreaterThan(0)
587
+ const pointXs = g.lines[0]!.points.map((p) => p.x)
588
+ for (const t of g.xTicks) {
589
+ expect(t.label).not.toBe('')
590
+ expect(pointXs).toContain(t.x)
591
+ }
592
+ })
593
+
594
+ it('falls back to the raw labels when nothing parses as a date', () => {
595
+ const g = buildChart({
596
+ type: 'line',
597
+ categories: ['north', 'south'],
598
+ series: [{ label: 'p', values: [1, 2] }],
599
+ xType: 'ordinal-time',
600
+ })
601
+ expect(g.xTicks.map((t) => t.label)).toEqual(['north', 'south'])
602
+ })
603
+ })
604
+
605
+ describe('shape adapters (the types the panel could not reach)', () => {
606
+ const rows = [
607
+ { region: 'EMEA', channel: 'web', date: '2026-03-02', revenue: 100, deals: 4, id: 'r1' },
608
+ { region: 'EMEA', channel: 'shop', date: '2026-03-03', revenue: 50, deals: 2, id: 'r2' },
609
+ { region: 'APAC', channel: 'web', date: '2026-03-04', revenue: 70, deals: 9, id: 'r3' },
610
+ ]
611
+
612
+ it('specToTreemap: one level from a single series', () => {
613
+ const t = specToTreemap(rowsToChartSpec(rows, { type: 'treemap', category: 'region', value: 'revenue' }))
614
+ expect(t.children!.map((c) => c.name).sort()).toEqual(['APAC', 'EMEA'])
615
+ expect(t.children!.find((c) => c.name === 'EMEA')!.value).toBe(150)
616
+ })
617
+
618
+ it('specToTreemap: two levels when a split-by produced several series', () => {
619
+ const t = specToTreemap(
620
+ rowsToChartSpec(rows, { type: 'treemap', category: 'region', value: 'revenue', series: 'channel' }),
621
+ )
622
+ const emea = t.children!.find((c) => c.name === 'EMEA')!
623
+ expect(emea.children!.map((c) => c.name).sort()).toEqual(['shop', 'web'])
624
+ })
625
+
626
+ it('specToTreemap: drops non-positive leaves, which have no area', () => {
627
+ const t = specToTreemap({ type: 'treemap', categories: ['a', 'b'], series: [{ label: 's', values: [5, -3] }] })
628
+ expect(t.children!.map((c) => c.name)).toEqual(['a'])
629
+ })
630
+
631
+ it('specToCalendar: normalises categories to YYYY-MM-DD', () => {
632
+ const c = specToCalendar(rowsToChartSpec(rows, { type: 'calendar', category: 'date', value: 'deals' }))
633
+ expect(c).toEqual([
634
+ { date: '2026-03-02', value: 4 },
635
+ { date: '2026-03-03', value: 2 },
636
+ { date: '2026-03-04', value: 9 },
637
+ ])
638
+ })
639
+
640
+ it('specToCalendar: drops a category that is not a date rather than placing it at epoch 0', () => {
641
+ const c = specToCalendar({ type: 'calendar', categories: ['north', '2026-03-02'], series: [{ label: 's', values: [1, 2] }] })
642
+ expect(c).toEqual([{ date: '2026-03-02', value: 2 }])
643
+ })
644
+
645
+ it('specToSankey: a pivot becomes an edge list', () => {
646
+ const { nodes, links } = specToSankey(
647
+ rowsToChartSpec(rows, { type: 'sankey', category: 'region', value: 'revenue', series: 'channel' }),
648
+ )
649
+ expect(links).toHaveLength(3)
650
+ expect(links.every((l) => l.value > 0)).toBe(true)
651
+ // Sources and targets are namespaced, so a name on both sides is two nodes
652
+ // rather than one node with a loop through it.
653
+ expect(nodes.every((n) => n.id.startsWith('from:') || n.id.startsWith('to:'))).toBe(true)
654
+ expect(new Set(nodes.map((n) => n.id)).size).toBe(nodes.length)
655
+ })
656
+
657
+ it('rowsToScatterSpec: one point per row, grouped into series', () => {
658
+ const s = rowsToScatterSpec(rows, { x: 'revenue', y: 'deals', series: 'channel' })
659
+ expect(s.type).toBe('scatter')
660
+ expect(s.series.map((x) => x.label).sort()).toEqual(['shop', 'web'])
661
+ expect(s.series.find((x) => x.label === 'web')!.points).toHaveLength(2)
662
+ expect(buildChart(s).scatterPoints).toHaveLength(3)
663
+ })
664
+
665
+ it('rowsToScatterSpec: skips rows whose x or y is not numeric', () => {
666
+ const s = rowsToScatterSpec([{ a: 1, b: 2 }, { a: 'x', b: 3 }], { x: 'a', y: 'b' })
667
+ expect(s.series[0]!.points).toHaveLength(1)
668
+ })
669
+
670
+ it('rowsToGaugeSpec: reduces to one number and rounds the dial end up', () => {
671
+ const g = rowsToGaugeSpec(rows, { value: 'revenue' })
672
+ expect(g.gaugeValue).toBe(220)
673
+ // The needle must not sit pinned at the very end of the arc.
674
+ expect(g.gaugeMax!).toBeGreaterThanOrEqual(220)
675
+ expect(buildChart(g).gauge).not.toBeNull()
676
+ })
677
+
678
+ it('rowsToGaugeSpec: honours avg and count', () => {
679
+ expect(rowsToGaugeSpec(rows, { value: 'deals', reduce: 'count' }).gaugeValue).toBe(3)
680
+ expect(rowsToGaugeSpec(rows, { value: 'deals', reduce: 'avg' }).gaugeValue).toBeCloseTo(5, 5)
681
+ })
682
+
683
+ it('every adapter survives an empty row set', () => {
684
+ const empty = rowsToChartSpec([] as Array<{ a: string; b: number }>, { type: 'bar', category: 'a', value: 'b' })
685
+ expect(specToTreemap(empty).children).toEqual([])
686
+ expect(specToCalendar(empty)).toEqual([])
687
+ expect(specToSankey(empty).links).toEqual([])
688
+ expect(rowsToScatterSpec([] as Array<{ a: number; b: number }>, { x: 'a', y: 'b' }).series).toEqual([])
689
+ expect(rowsToGaugeSpec([] as Array<{ a: number }>, { value: 'a' }).gaugeValue).toBe(0)
690
+ })
691
+ })
692
+
693
+ describe('gauge bands', () => {
694
+ // Bands normally share endpoints, so which side of a boundary a value falls
695
+ // on decides its colour. Half-open [from, to), first match wins.
696
+ const bands = [
697
+ { from: 0, to: 0.3, color: 'green' },
698
+ { from: 0.3, to: 0.45, color: 'amber' },
699
+ { from: 0.45, to: 5, color: 'red' },
700
+ ]
701
+ const dial = (gaugeValue: number): ChartSpec => ({
702
+ type: 'gauge',
703
+ categories: [],
704
+ series: [],
705
+ gaugeValue,
706
+ gaugeMin: 0,
707
+ gaugeMax: 5,
708
+ gaugeRanges: bands,
709
+ })
710
+
711
+ it('a value inside a band takes that band', () => {
712
+ expect(buildChart(dial(0.4)).gauge!.valueColor).toBe('amber')
713
+ expect(buildChart(dial(0.1)).gauge!.valueColor).toBe('green')
714
+ expect(buildChart(dial(3)).gauge!.valueColor).toBe('red')
715
+ })
716
+
717
+ it('a value exactly on a boundary belongs to the band it opens', () => {
718
+ // This is the one that was wrong: an inclusive `to` plus last-match-wins
719
+ // gave 0.45 the colour of the band ABOVE it.
720
+ expect(buildChart(dial(0.3)).gauge!.valueColor).toBe('amber')
721
+ expect(buildChart(dial(0.45)).gauge!.valueColor).toBe('red')
722
+ })
723
+
724
+ it('the top of the scale still lands in the last band', () => {
725
+ expect(buildChart(dial(5)).gauge!.valueColor).toBe('red')
726
+ })
727
+
728
+ it('keeps the bands clear of the value arc', () => {
729
+ // Bands are context on their own inner ring; the value arc is the reading.
730
+ // Drawn at the same radius, a band spanning most of the scale swamps it.
731
+ const g = buildChart(dial(0.45)).gauge!
732
+ const bandR = Number(/A([\d.]+),/.exec(g.rangePaths[0]!.path)![1])
733
+ expect(bandR).toBeLessThan(g.r - 8)
734
+ })
735
+ })
736
+
737
+ describe('boxStats (the five-number summary)', () => {
738
+ it('computes quartiles and stops the whiskers at real observations', () => {
739
+ // Whiskers land on the last point INSIDE the 1.5 IQR fence, not on the
740
+ // fence itself, which is what makes them read as data rather than as a
741
+ // calculated boundary.
742
+ const b = boxStats([1, 2, 3, 4, 5, 6, 7, 8, 9])!
743
+ expect(b.median).toBe(5)
744
+ expect(b.q1).toBe(3)
745
+ expect(b.q3).toBe(7)
746
+ expect(b.min).toBe(1)
747
+ expect(b.max).toBe(9)
748
+ expect(b.outliers).toBeUndefined()
749
+ })
750
+
751
+ it('splits points beyond the fence into outliers', () => {
752
+ const b = boxStats([10, 11, 12, 13, 14, 15, 90])!
753
+ expect(b.outliers).toEqual([90])
754
+ // The whisker stops at the largest value still inside the fence.
755
+ expect(b.max).toBe(15)
756
+ })
757
+
758
+ it('interpolates quartiles between order statistics', () => {
759
+ // 4 points: q1 sits between the 1st and 2nd.
760
+ const b = boxStats([1, 2, 3, 4])!
761
+ expect(b.q1).toBeCloseTo(1.75, 5)
762
+ expect(b.median).toBeCloseTo(2.5, 5)
763
+ expect(b.q3).toBeCloseTo(3.25, 5)
764
+ })
765
+
766
+ it('returns null for an empty sample, so the category is a gap', () => {
767
+ expect(boxStats([])).toBeNull()
768
+ expect(boxStats([Number.NaN, Number.POSITIVE_INFINITY])).toBeNull()
769
+ })
770
+
771
+ it('handles a single observation and a zero-spread sample', () => {
772
+ const one = boxStats([7])!
773
+ expect(one).toMatchObject({ min: 7, q1: 7, median: 7, q3: 7, max: 7 })
774
+ const flat = boxStats([3, 3, 3, 3])!
775
+ expect(flat.q1).toBe(3)
776
+ expect(flat.outliers).toBeUndefined()
777
+ })
778
+ })
779
+
780
+ describe('buildChart: box plots', () => {
781
+ const spec = (): ChartSpec => ({
782
+ type: 'boxplot',
783
+ categories: ['a', 'b'],
784
+ series: [
785
+ {
786
+ label: 'latency',
787
+ // `values` holds the medians, the way `ohlc` sets closes.
788
+ values: [20, 30],
789
+ boxes: [
790
+ { min: 10, q1: 15, median: 20, q3: 25, max: 30, outliers: [45] },
791
+ { min: 20, q1: 25, median: 30, q3: 35, max: 40 },
792
+ ],
793
+ },
794
+ ],
795
+ width: 400,
796
+ height: 300,
797
+ })
798
+
799
+ it('lays out a box per category, ordered and inside the plot', () => {
800
+ const geo = buildChart(spec())
801
+ expect(geo.boxes).toHaveLength(2)
802
+ const b = geo.boxes[0]!
803
+ // SVG y grows downward, so the max whisker is the SMALLEST y.
804
+ expect(b.yMax).toBeLessThan(b.yQ3)
805
+ expect(b.yQ3).toBeLessThan(b.yMedian)
806
+ expect(b.yMedian).toBeLessThan(b.yQ1)
807
+ expect(b.yQ1).toBeLessThan(b.yMin)
808
+ expect(b.boxY).toBe(Math.min(b.yQ1, b.yQ3))
809
+ expect(b.boxH).toBeGreaterThan(0)
810
+ expect(b.label).toBe('a')
811
+ })
812
+
813
+ it('positions outliers and carries the raw numbers for the tooltip', () => {
814
+ const geo = buildChart(spec())
815
+ const b = geo.boxes[0]!
816
+ expect(b.outliers).toHaveLength(1)
817
+ expect(b.outliers[0]!.value).toBe(45)
818
+ // The outlier is above the upper whisker, so a smaller y.
819
+ expect(b.outliers[0]!.y).toBeLessThan(b.yMax)
820
+ expect(b).toMatchObject({ min: 10, q1: 15, median: 20, q3: 25, max: 30 })
821
+ })
822
+
823
+ it('scales the axis to the whiskers and outliers, not the medians', () => {
824
+ // `values` are 20 and 30; a domain built from those would clip every box.
825
+ const geo = buildChart(spec())
826
+ const top = geo.yTicks[geo.yTicks.length - 1]!.value
827
+ expect(top).toBeGreaterThanOrEqual(45)
828
+ expect(geo.yTicks[0]!.value).toBeLessThanOrEqual(10)
829
+ })
830
+
831
+ it('never also draws the medians as a line', () => {
832
+ // The candlestick bug: `values` exists for tooltips / CSV / overlays, and
833
+ // was drawn a second time as a dotted line straight over the marks.
834
+ const geo = buildChart(spec())
835
+ expect(geo.lines).toHaveLength(0)
836
+ expect(geo.bars).toHaveLength(0)
837
+ })
838
+
839
+ it('treats a null box as a gap and returns empty geometry for no data', () => {
840
+ const withGap = spec()
841
+ withGap.series[0]!.boxes = [null, { min: 1, q1: 2, median: 3, q3: 4, max: 5 }]
842
+ expect(buildChart(withGap).boxes).toHaveLength(1)
843
+ expect(buildChart({ type: 'boxplot', categories: [], series: [] }).boxes).toEqual([])
844
+ })
845
+
846
+ it('shares the slot between two box series, like grouped bars', () => {
847
+ const two = spec()
848
+ two.series.push({
849
+ label: 'p99',
850
+ values: [40, 50],
851
+ boxes: [
852
+ { min: 30, q1: 35, median: 40, q3: 45, max: 50 },
853
+ { min: 40, q1: 45, median: 50, q3: 55, max: 60 },
854
+ ],
855
+ })
856
+ const geo = buildChart(two)
857
+ expect(geo.boxes).toHaveLength(4)
858
+ const first = geo.boxes.filter((b) => b.label === 'a')
859
+ expect(first).toHaveLength(2)
860
+ expect(first[0]!.x).not.toBe(first[1]!.x)
861
+ })
862
+ })
863
+
864
+ describe('buildChart: error bars', () => {
865
+ const spec = (errors: ChartSpec['series'][number]['errors']): ChartSpec => ({
866
+ type: 'bar',
867
+ categories: ['a', 'b'],
868
+ series: [{ label: 'mean', values: [10, 20], errors }],
869
+ width: 400,
870
+ height: 300,
871
+ })
872
+
873
+ it('draws a symmetric margin around the value', () => {
874
+ const geo = buildChart(spec([2, 3]))
875
+ expect(geo.errorBars).toHaveLength(2)
876
+ expect(geo.errorBars[0]).toMatchObject({ lo: 8, hi: 12 })
877
+ expect(geo.errorBars[1]).toMatchObject({ lo: 17, hi: 23 })
878
+ // Higher value, smaller y.
879
+ expect(geo.errorBars[0]!.yHi).toBeLessThan(geo.errorBars[0]!.yLo)
880
+ })
881
+
882
+ it('accepts an explicit asymmetric pair and normalizes the order', () => {
883
+ const geo = buildChart(spec([{ lo: 9, hi: 14 }, { hi: 18, lo: 25 }]))
884
+ expect(geo.errorBars[0]).toMatchObject({ lo: 9, hi: 14 })
885
+ expect(geo.errorBars[1]).toMatchObject({ lo: 18, hi: 25 })
886
+ })
887
+
888
+ it('extends the axis domain past the mark, so a whisker cannot clip', () => {
889
+ const withBars = buildChart(spec([0, 40]))
890
+ const top = withBars.yTicks[withBars.yTicks.length - 1]!.value
891
+ expect(top).toBeGreaterThanOrEqual(60)
892
+ })
893
+
894
+ it('skips a null entry and still draws the bar it annotates', () => {
895
+ const geo = buildChart(spec([null, 3]))
896
+ expect(geo.errorBars).toHaveLength(1)
897
+ expect(geo.errorBars[0]!.label).toBe('b')
898
+ expect(geo.bars).toHaveLength(2)
899
+ })
900
+
901
+ it('annotates a line series too, without changing its type', () => {
902
+ const geo = buildChart({
903
+ type: 'line',
904
+ categories: ['a', 'b'],
905
+ series: [{ label: 'm', values: [10, 20], errors: [1, 1] }],
906
+ width: 400,
907
+ height: 300,
908
+ })
909
+ expect(geo.lines).toHaveLength(1)
910
+ expect(geo.errorBars).toHaveLength(2)
911
+ })
912
+ })
913
+
914
+ describe('rowsToBoxSpec (the panel path)', () => {
915
+ const rows = [
916
+ { team: 'A', ms: 10 }, { team: 'A', ms: 12 }, { team: 'A', ms: 14 }, { team: 'A', ms: 90 },
917
+ { team: 'B', ms: 20 }, { team: 'B', ms: 22 }, { team: 'B', ms: 24 },
918
+ ]
919
+
920
+ it('groups rows into one box per category', () => {
921
+ const spec = rowsToBoxSpec(rows, { category: 'team', value: 'ms' })
922
+ expect(spec.type).toBe('boxplot')
923
+ expect(spec.categories).toEqual(['A', 'B'])
924
+ const boxes = spec.series[0]!.boxes!
925
+ expect(boxes).toHaveLength(2)
926
+ expect(boxes[0]!.outliers).toEqual([90])
927
+ expect(boxes[1]!.median).toBe(22)
928
+ })
929
+
930
+ it('sets values to the medians so tooltips and CSV keep working', () => {
931
+ const spec = rowsToBoxSpec(rows, { category: 'team', value: 'ms' })
932
+ expect(spec.series[0]!.values).toEqual([13, 22])
933
+ })
934
+
935
+ it('splits into side-by-side series on a second field', () => {
936
+ const split = [
937
+ { team: 'A', env: 'prod', ms: 10 }, { team: 'A', env: 'prod', ms: 12 },
938
+ { team: 'A', env: 'dev', ms: 30 }, { team: 'A', env: 'dev', ms: 32 },
939
+ ]
940
+ const spec = rowsToBoxSpec(split, { category: 'team', value: 'ms', series: 'env' })
941
+ expect(spec.series.map((s) => s.label)).toEqual(['prod', 'dev'])
942
+ expect(spec.series[1]!.boxes![0]!.median).toBe(31)
943
+ expect(buildChart({ ...spec, width: 400, height: 300 }).boxes).toHaveLength(2)
944
+ })
945
+
946
+ it('leaves a category with no numeric sample as a gap, not a zero', () => {
947
+ const spec = rowsToBoxSpec(
948
+ [{ team: 'A', ms: 5 }, { team: 'B', ms: null as unknown as number }],
949
+ { category: 'team', value: 'ms' },
950
+ )
951
+ expect(spec.series[0]!.boxes![1]).toBeNull()
952
+ expect(Number.isNaN(spec.series[0]!.values[1]!)).toBe(true)
953
+ expect(buildChart({ ...spec, width: 400, height: 300 }).boxes).toHaveLength(1)
954
+ })
955
+ })
956
+
957
+ describe('category axis label thinning', () => {
958
+ const line = (n: number, width = 800): ChartSpec => ({
959
+ type: 'line',
960
+ categories: Array.from({ length: n }, (_, i) => `c${i}`),
961
+ series: [{ label: 's', values: Array.from({ length: n }, (_, i) => i % 50) }],
962
+ width,
963
+ height: 400,
964
+ })
965
+
966
+ it('labels every category while they fit', () => {
967
+ // The threshold is set just under the spacing a 40-category chart at 800px
968
+ // already had, so charts that read fine before are untouched.
969
+ expect(buildChart(line(2)).xTicks).toHaveLength(2)
970
+ expect(buildChart(line(12)).xTicks).toHaveLength(12)
971
+ expect(buildChart(line(40)).xTicks).toHaveLength(40)
972
+ })
973
+
974
+ it('thins once labels would overlap, and never grows without bound', () => {
975
+ const many = buildChart(line(5000)).xTicks
976
+ expect(many.length).toBeLessThan(60)
977
+ expect(many.length).toBeGreaterThan(10)
978
+ // 5000 labels were 5000 <text> nodes stacked into an unreadable band, and
979
+ // the single biggest thing the renderer had to put in the DOM.
980
+ expect(buildChart(line(500)).xTicks.length).toBeLessThan(60)
981
+ })
982
+
983
+ it('keeps the first category labelled, so the axis starts where the data does', () => {
984
+ expect(buildChart(line(5000)).xTicks[0]!.label).toBe('c0')
985
+ })
986
+
987
+ it('thins by available width, not by count alone', () => {
988
+ const narrow = buildChart(line(200, 400)).xTicks.length
989
+ const wide = buildChart(line(200, 1600)).xTicks.length
990
+ expect(wide).toBeGreaterThan(narrow)
991
+ })
992
+
993
+ it('leaves ticks in ascending x order after thinning', () => {
994
+ const ticks = buildChart(line(1000)).xTicks
995
+ for (let i = 1; i < ticks.length; i += 1) expect(ticks[i]!.x).toBeGreaterThan(ticks[i - 1]!.x)
996
+ })
997
+ })
998
+
999
+ describe('chartScales (the custom-series seam)', () => {
1000
+ const spec = (over: Partial<ChartSpec> = {}): ChartSpec => ({
1001
+ type: 'bar',
1002
+ categories: ['a', 'b', 'c', 'd'],
1003
+ series: [{ label: 's', values: [10, 20, 30, 40] }],
1004
+ width: 500,
1005
+ height: 300,
1006
+ ...over,
1007
+ })
1008
+
1009
+ it('lands a custom mark exactly where the built-in one is', () => {
1010
+ // The whole point. A caller recomputing the scale from the raw data would
1011
+ // miss the nice-scale rounding and the "always include zero" rule, and its
1012
+ // marks would sit a few pixels off in a way that reads as a rendering bug.
1013
+ const geo = buildChart(spec())
1014
+ const sc = chartScales(geo)!
1015
+ // The category tick is the authoritative x - that is where the axis label
1016
+ // sits - and `xOf` matches it exactly.
1017
+ geo.xTicks.forEach((t, i) => expect(sc.xOf(i)).toBeCloseTo(t.x, 6))
1018
+ geo.bars.forEach((bar, i) => {
1019
+ // A bar's own centre can be a half pixel off its slot: the bar width is
1020
+ // rounded before it is centred. Within a pixel of the tick, which is what
1021
+ // the eye compares it against.
1022
+ expect(Math.abs(sc.xOf(i) - (bar.x + bar.w / 2))).toBeLessThanOrEqual(1)
1023
+ expect(sc.yOf(bar.value)).toBeCloseTo(bar.y, 0)
1024
+ })
1025
+ })
1026
+
1027
+ it('agrees with the axis ticks it was drawn from', () => {
1028
+ const geo = buildChart(spec({ type: 'line' }))
1029
+ const sc = chartScales(geo)!
1030
+ for (const t of geo.yTicks) expect(sc.yOf(t.value)).toBeCloseTo(t.y, 0)
1031
+ })
1032
+
1033
+ it('maps the right axis separately when there is one', () => {
1034
+ const geo = buildChart(spec({
1035
+ series: [
1036
+ { label: 'left', values: [10, 20, 30, 40], axis: 'left' },
1037
+ { label: 'right', values: [0.1, 0.2, 0.3, 0.9], type: 'line', axis: 'right' },
1038
+ ],
1039
+ }))
1040
+ const sc = chartScales(geo)!
1041
+ expect(geo.axes!.y2).not.toBeNull()
1042
+ // Same number, different axis, different pixel - or the second axis is a lie.
1043
+ expect(sc.yOf(0.5, 'right')).not.toBeCloseTo(sc.yOf(0.5, 'left'), 0)
1044
+ for (const t of geo.y2Ticks) expect(sc.yOf(t.value, 'right')).toBeCloseTo(t.y, 0)
1045
+ })
1046
+
1047
+ it('inverts back to the value and the category it came from', () => {
1048
+ const geo = buildChart(spec())
1049
+ const sc = chartScales(geo)!
1050
+ expect(sc.yInvert(sc.yOf(25))).toBeCloseTo(25, 4)
1051
+ for (let i = 0; i < 4; i += 1) expect(sc.xInvert(sc.xOf(i))).toBe(i)
1052
+ // Clamped rather than off the end.
1053
+ expect(sc.xInvert(-500)).toBe(0)
1054
+ expect(sc.xInvert(99_999)).toBe(3)
1055
+ })
1056
+
1057
+ it('round-trips on a log axis too', () => {
1058
+ const geo = buildChart(spec({ yScale: 'log', series: [{ label: 's', values: [1, 10, 100, 1000] }] }))
1059
+ const sc = chartScales(geo)!
1060
+ expect(sc.yInvert(sc.yOf(50))).toBeCloseTo(50, 3)
1061
+ // A value the axis cannot express is NaN, not a silently wrong pixel.
1062
+ expect(Number.isNaN(sc.yOf(0))).toBe(true)
1063
+ })
1064
+
1065
+ it('is null for the types where plot coordinates mean nothing', () => {
1066
+ for (const type of ['pie', 'gauge', 'treemap', 'sankey', 'radar', 'funnel', 'calendar'] as const) {
1067
+ const geo = buildChart({ ...spec(), type })
1068
+ expect(chartScales(geo), `${type} should have no cartesian scale`).toBeNull()
1069
+ expect(geo.axes).toBeNull()
1070
+ }
1071
+ })
1072
+
1073
+ it('reports the axes a candlestick and a box plot were drawn against', () => {
1074
+ // Both keep out of the zero-baseline rule, so their domain is not something
1075
+ // a caller could guess from the data.
1076
+ const px = buildChart({
1077
+ type: 'candlestick',
1078
+ categories: ['a', 'b'],
1079
+ series: [{ label: 'p', values: [190, 192], ohlc: [{ o: 189, h: 195, l: 188, c: 190 }, { o: 190, h: 196, l: 189, c: 192 }] }],
1080
+ width: 400, height: 300,
1081
+ })
1082
+ expect(px.axes!.y.min).toBeGreaterThan(0)
1083
+ const sc = chartScales(px)!
1084
+ expect(sc.yOf(195)).toBeLessThan(sc.yOf(188)) // higher price, smaller y
1085
+ })
1086
+ })
1087
+
1088
+ describe('gauge needle', () => {
1089
+ const dial = (value: number, max = 100) =>
1090
+ buildChart({ type: 'gauge', categories: [], series: [], gaugeValue: value, gaugeMin: 0, gaugeMax: max, width: 360, height: 240 })
1091
+
1092
+ /** The needle's four vertices, as distances from the hub. */
1093
+ const spokes = (g: ReturnType<typeof dial>) => {
1094
+ const { cx, cy } = g.gauge!
1095
+ return g.gauge!.needle.path
1096
+ .match(/-?[\d.]+,-?[\d.]+/g)!
1097
+ .map((p) => p.split(',').map(Number) as [number, number])
1098
+ .map(([x, y]) => Math.hypot(x - cx, y - cy))
1099
+ }
1100
+
1101
+ it('keeps the counterweight inside the hub, so it cannot read as an arrowhead', () => {
1102
+ // The tail used to stick out past the hub. At a horizontal angle that
1103
+ // little solid triangle is visually denser than the 130px taper opposite
1104
+ // it, so a near-full dial read as an arrow pointing at the MINIMUM.
1105
+ const g = dial(99.82)
1106
+ const hubR = g.gauge!.needle.hubR
1107
+ const dists = spokes(g).sort((a, b) => a - b)
1108
+ const [tail, s1, s2, tip] = [dists[0]!, dists[1]!, dists[2]!, dists[3]!]
1109
+ expect(tail).toBeLessThanOrEqual(hubR)
1110
+ // ...and the shoulders OUTSIDE it, or the hub hides the needle's base and
1111
+ // all that is left is a hairline.
1112
+ expect(s1).toBeGreaterThan(hubR)
1113
+ expect(s2).toBeGreaterThan(hubR)
1114
+ // The tip is the far end, by a wide margin.
1115
+ expect(tip).toBeGreaterThan(s2 * 5)
1116
+ })
1117
+
1118
+ it('points at the value: the tip is the vertex nearest the value angle', () => {
1119
+ const { cx, cy, r } = dial(25).gauge!
1120
+ const g = dial(25)
1121
+ const pts = g.gauge!.needle.path
1122
+ .match(/-?[\d.]+,-?[\d.]+/g)!
1123
+ .map((p) => p.split(',').map(Number) as [number, number])
1124
+ const far = pts.reduce((a, b) => (Math.hypot(b[0] - cx, b[1] - cy) > Math.hypot(a[0] - cx, a[1] - cy) ? b : a))
1125
+ // 25 of 100 on a semicircle sweeping left -> right is up and to the left.
1126
+ expect(far[0]).toBeLessThan(cx)
1127
+ expect(far[1]).toBeLessThan(cy)
1128
+ // And it stops inside the track rather than poking through it.
1129
+ expect(Math.hypot(far[0] - cx, far[1] - cy)).toBeLessThan(r)
1130
+ })
1131
+
1132
+ it('swings to the other side for a high value', () => {
1133
+ const { cx } = dial(75).gauge!
1134
+ const pts = dial(75).gauge!.needle.path
1135
+ .match(/-?[\d.]+,-?[\d.]+/g)!
1136
+ .map((p) => p.split(',').map(Number) as [number, number])
1137
+ const far = pts.reduce((a, b) => (Math.abs(b[0] - cx) > Math.abs(a[0] - cx) ? b : a))
1138
+ expect(far[0]).toBeGreaterThan(cx)
1139
+ })
1140
+ })