@opendata-ai/openchart-engine 7.2.0 → 7.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "7.2.0",
3
+ "version": "7.2.2",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "7.2.0",
51
+ "@opendata-ai/openchart-core": "7.2.2",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -462,7 +462,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
462
462
  "chromeToChart": 8,
463
463
  "padding": 20,
464
464
  "xAxisHeight": 26,
465
- "xAxisLabelPadding": 14,
465
+ "xAxisLabelPadding": 4,
466
466
  },
467
467
  },
468
468
  "tooltipDescriptors": [
@@ -1438,7 +1438,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
1438
1438
  "chromeToChart": 8,
1439
1439
  "padding": 20,
1440
1440
  "xAxisHeight": 26,
1441
- "xAxisLabelPadding": 14,
1441
+ "xAxisLabelPadding": 4,
1442
1442
  },
1443
1443
  },
1444
1444
  "tooltipDescriptors": [],
@@ -2029,7 +2029,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
2029
2029
  "chromeToChart": 8,
2030
2030
  "padding": 20,
2031
2031
  "xAxisHeight": 26,
2032
- "xAxisLabelPadding": 14,
2032
+ "xAxisLabelPadding": 4,
2033
2033
  },
2034
2034
  },
2035
2035
  "tooltipDescriptors": [
@@ -175,6 +175,32 @@ describe('compileChart', () => {
175
175
  expect(layout.legend.position).toBe('top');
176
176
  });
177
177
 
178
+ it('aligns the top legend left edge to the plot area, not the container edge', () => {
179
+ // A y-axis title + numeric ticks reserves a left gutter, pushing the plot
180
+ // area right of the container padding. The top legend should start at the
181
+ // plot's left edge (above the y-axis guide), not flush against the container.
182
+ const layout = compileChart(
183
+ {
184
+ ...lineSpec,
185
+ encoding: {
186
+ ...lineSpec.encoding,
187
+ y: { field: 'value', type: 'quantitative' as const, axis: { title: 'GDP ($B)' } },
188
+ },
189
+ legend: { position: 'top' as const, show: true },
190
+ },
191
+ { width: 360, height: 400 },
192
+ );
193
+ expect(layout.legend.position).toBe('top');
194
+ expect(layout.legend.entries.length).toBeGreaterThan(0);
195
+ // Plot area is inset from the container by the y-axis gutter.
196
+ expect(layout.area.x).toBeGreaterThan(0);
197
+ // Legend left edge now matches the plot left edge (within a pixel).
198
+ expect(layout.legend.bounds.x).toBeCloseTo(layout.area.x, 0);
199
+ // And the legend never paints past the container's right padding edge.
200
+ // Default theme spacing.padding is 20.
201
+ expect(layout.legend.bounds.x + layout.legend.bounds.width).toBeLessThanOrEqual(360 - 20 + 0.5);
202
+ });
203
+
178
204
  it('produces line marks with dataPoints (no PointMarks by default)', () => {
179
205
  const layout = compileChart(lineSpec, { width: 600, height: 400 });
180
206
  expect(layout.marks.length).toBeGreaterThan(0);
@@ -205,6 +231,33 @@ describe('compileChart', () => {
205
231
  expect(pointMarks.length).toBeGreaterThan(0);
206
232
  });
207
233
 
234
+ it('produces PointMarks on a multi-series area when mark.point is true', () => {
235
+ // Multi-series areas derive their lines from area tops, which bypasses the
236
+ // line renderer's point emission. mark.point must still place dots.
237
+ const areaSpec = {
238
+ ...lineSpec,
239
+ mark: { type: 'area' as const, point: true as const },
240
+ };
241
+ const layout = compileChart(areaSpec, { width: 600, height: 400 });
242
+
243
+ const areaMarks = layout.marks.filter((m) => m.type === 'area');
244
+ const pointMarks = layout.marks.filter((m) => m.type === 'point');
245
+ expect(areaMarks.length).toBe(2); // US + UK
246
+ // One point per data row across both series (2 points x 2 series).
247
+ expect(pointMarks.length).toBe(4);
248
+ for (const p of pointMarks) {
249
+ if (p.type === 'point') expect(p.r).toBeGreaterThan(0);
250
+ }
251
+ });
252
+
253
+ it('does not produce PointMarks on a multi-series area by default', () => {
254
+ const areaSpec = { ...lineSpec, mark: 'area' as const };
255
+ const layout = compileChart(areaSpec, { width: 600, height: 400 });
256
+
257
+ const pointMarks = layout.marks.filter((m) => m.type === 'point');
258
+ expect(pointMarks.length).toBe(0);
259
+ });
260
+
208
261
  it('includes accessibility metadata with meaningful content', () => {
209
262
  const layout = compileChart(lineSpec, { width: 600, height: 400 });
210
263
  expect(layout.a11y.altText).toContain('Line chart');
@@ -269,6 +269,40 @@ describe('scale config properties', () => {
269
269
  expect(paddedBandwidth).toBeGreaterThan(defaultBandwidth);
270
270
  });
271
271
 
272
+ it('point scale honors paddingOuter as a padding alias', () => {
273
+ const ordinalBase = {
274
+ ...lineSpec,
275
+ data: [
276
+ { year: 'A', value: 10 },
277
+ { year: 'B', value: 20 },
278
+ { year: 'C', value: 30 },
279
+ ],
280
+ };
281
+ const wide: NormalizedChartSpec = {
282
+ ...ordinalBase,
283
+ encoding: {
284
+ x: { field: 'year', type: 'ordinal', scale: { paddingOuter: 0.5 } },
285
+ y: { field: 'value', type: 'quantitative' },
286
+ },
287
+ };
288
+ const tight: NormalizedChartSpec = {
289
+ ...ordinalBase,
290
+ encoding: {
291
+ x: { field: 'year', type: 'ordinal', scale: { paddingOuter: 0.04 } },
292
+ y: { field: 'value', type: 'quantitative' },
293
+ },
294
+ };
295
+
296
+ const wideScales = computeScales(wide, chartArea, wide.data);
297
+ const tightScales = computeScales(tight, chartArea, tight.data);
298
+ expect(wideScales.x!.type).toBe('point');
299
+
300
+ // Less outer padding pushes the first point closer to the left edge.
301
+ const wideFirst = wideScales.x!.scale('A') as number;
302
+ const tightFirst = tightScales.x!.scale('A') as number;
303
+ expect(tightFirst).toBeLessThan(wideFirst);
304
+ });
305
+
272
306
  it('backward compatible: existing specs still work', () => {
273
307
  // lineSpec and barSpec from before should still produce valid scales
274
308
  const lineScales = computeScales(lineSpec, chartArea, lineSpec.data);
@@ -225,6 +225,35 @@ describe('computeAnnotations', () => {
225
225
  expect(annotations[0].label!.text).toBe('Recession');
226
226
  });
227
227
 
228
+ it('range label defaults to 11px / weight 500', () => {
229
+ const spec = makeSpec([
230
+ { type: 'range', x1: '2020-01-01', x2: '2021-01-01', label: 'Period' },
231
+ ]);
232
+ const scales = computeScales(spec, chartArea, spec.data);
233
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
234
+
235
+ expect(annotations[0].label!.style.fontSize).toBe(11);
236
+ expect(annotations[0].label!.style.fontWeight).toBe(500);
237
+ });
238
+
239
+ it('range label honors fontSize and fontWeight overrides', () => {
240
+ const spec = makeSpec([
241
+ {
242
+ type: 'range',
243
+ x1: '2020-01-01',
244
+ x2: '2021-01-01',
245
+ label: 'Period',
246
+ fontSize: 19,
247
+ fontWeight: 600,
248
+ },
249
+ ]);
250
+ const scales = computeScales(spec, chartArea, spec.data);
251
+ const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
252
+
253
+ expect(annotations[0].label!.style.fontSize).toBe(19);
254
+ expect(annotations[0].label!.style.fontWeight).toBe(600);
255
+ });
256
+
228
257
  it('range has fill and opacity', () => {
229
258
  const spec = makeSpec([
230
259
  {
@@ -463,6 +492,41 @@ describe('computeAnnotations', () => {
463
492
  expect(rect.x + rect.width).toBeCloseTo(x2BandStart + bandwidth, 1);
464
493
  });
465
494
 
495
+ it('extendToEdges:false anchors a point-scale range at data point centers', () => {
496
+ // With extendToEdges:false the band starts/ends exactly at the first/last
497
+ // data point centers instead of extending half a step to the plot edge,
498
+ // so the range is inset from the axis.
499
+ const domainValues = ['2006', '2008', '2010', '2012'];
500
+ const ordinalSpec: NormalizedChartSpec = {
501
+ markType: 'line',
502
+ markDef: { type: 'line' },
503
+ data: domainValues.map((year, i) => ({ year, value: i * 10 })),
504
+ encoding: {
505
+ x: { field: 'year', type: 'ordinal' },
506
+ y: { field: 'value', type: 'quantitative' },
507
+ },
508
+ chrome: {},
509
+ annotations: [{ type: 'range', x1: '2006', x2: '2012', extendToEdges: false }],
510
+ responsive: true,
511
+ theme: {},
512
+ darkMode: 'off',
513
+ labels: { density: 'auto', format: '' },
514
+ };
515
+ const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
516
+ const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
517
+
518
+ expect(annotations).toHaveLength(1);
519
+ const rect = annotations[0].rect!;
520
+
521
+ const xScale = scales.x!.scale as ScalePoint<string>;
522
+ // Left/right edges land at the point centers, not extended past them.
523
+ expect(rect.x).toBeCloseTo(xScale('2006')!, 1);
524
+ expect(rect.x + rect.width).toBeCloseTo(xScale('2012')!, 1);
525
+ // And the band is strictly inside the plot (inset from both edges).
526
+ expect(rect.x).toBeGreaterThan(chartArea.x);
527
+ expect(rect.x + rect.width).toBeLessThan(chartArea.x + chartArea.width);
528
+ });
529
+
466
530
  it('linear-scale range is unaffected by edge extension', () => {
467
531
  // For linear scales, resolvePositionEdge is identical to resolvePosition.
468
532
  // This ensures the fix doesn't introduce any drift on continuous axes.
@@ -12,7 +12,7 @@ import type {
12
12
  import type { ResolvedScales } from '../layout/scales';
13
13
  import { DEFAULT_RANGE_FILL, DEFAULT_RANGE_OPACITY } from './constants';
14
14
  import { applyOffset } from './geometry';
15
- import { resolvePositionEdge } from './position';
15
+ import { resolvePosition, resolvePositionEdge } from './position';
16
16
  import { makeAnnotationLabelStyle } from './resolve-text';
17
17
 
18
18
  export function resolveRangeAnnotation(
@@ -26,10 +26,22 @@ export function resolveRangeAnnotation(
26
26
  let width = chartArea.width;
27
27
  let height = chartArea.height;
28
28
 
29
+ // When extendToEdges is false, anchor at the data point's exact position
30
+ // (band/point center) instead of extending to the band/step edge. This insets
31
+ // the range from the axis so it starts at the first data point rather than
32
+ // flush against the axis guide. No effect on linear/time scales.
33
+ const extend = annotation.extendToEdges !== false;
34
+ const resolveEdge = (
35
+ value: string | number,
36
+ scale: typeof scales.x,
37
+ edge: 'start' | 'end',
38
+ ): number | null =>
39
+ extend ? resolvePositionEdge(value, scale, edge) : resolvePosition(value, scale);
40
+
29
41
  // X-range (vertical band)
30
42
  if (annotation.x1 !== undefined && annotation.x2 !== undefined) {
31
- const x1px = resolvePositionEdge(annotation.x1, scales.x, 'start');
32
- const x2px = resolvePositionEdge(annotation.x2, scales.x, 'end');
43
+ const x1px = resolveEdge(annotation.x1, scales.x, 'start');
44
+ const x2px = resolveEdge(annotation.x2, scales.x, 'end');
33
45
  if (x1px === null || x2px === null) return null;
34
46
 
35
47
  x = Math.min(x1px, x2px);
@@ -38,8 +50,8 @@ export function resolveRangeAnnotation(
38
50
 
39
51
  // Y-range (horizontal band)
40
52
  if (annotation.y1 !== undefined && annotation.y2 !== undefined) {
41
- const y1px = resolvePositionEdge(annotation.y1, scales.y, 'end');
42
- const y2px = resolvePositionEdge(annotation.y2, scales.y, 'start');
53
+ const y1px = resolveEdge(annotation.y1, scales.y, 'end');
54
+ const y2px = resolveEdge(annotation.y2, scales.y, 'start');
43
55
  if (y1px === null || y2px === null) return null;
44
56
 
45
57
  y = Math.min(y1px, y2px);
@@ -62,7 +74,12 @@ export function resolveRangeAnnotation(
62
74
  const baseDy = 14;
63
75
  const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
64
76
 
65
- const style = makeAnnotationLabelStyle(11, 500, undefined, isDark);
77
+ const style = makeAnnotationLabelStyle(
78
+ annotation.fontSize ?? 11,
79
+ annotation.fontWeight ?? 500,
80
+ undefined,
81
+ isDark,
82
+ );
66
83
  if (centered) {
67
84
  style.textAnchor = 'middle';
68
85
  } else if (anchor === 'right') {
@@ -10,13 +10,17 @@
10
10
  * `'normalize'`, or `'center'`.
11
11
  */
12
12
 
13
- import type { AreaMark, LineMark, Mark } from '@opendata-ai/openchart-core';
13
+ import type { AreaMark, LineMark, Mark, PointMark } from '@opendata-ai/openchart-core';
14
14
  import { getRepresentativeColor } from '@opendata-ai/openchart-core';
15
+ import type { NormalizedChartSpec } from '../../compiler/types';
15
16
  import type { ChartRenderer } from '../registry';
16
17
  import { computeAreaMarks } from './area';
17
18
  import { computeLineMarks } from './compute';
18
19
  import { computeLineLabels } from './labels';
19
20
 
21
+ /** Radius for area-chart data points, matching the line renderer. */
22
+ const AREA_POINT_RADIUS = 3;
23
+
20
24
  // ---------------------------------------------------------------------------
21
25
  // Line chart renderer
22
26
  // ---------------------------------------------------------------------------
@@ -84,8 +88,14 @@ export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, t
84
88
  ? linesFromAreas(areas)
85
89
  : computeLineMarks(spec, scales, chartArea, strategy);
86
90
 
87
- // Areas go first (rendered behind lines), then lines on top
88
- return [...areas, ...lines] as Mark[];
91
+ // For multi-series areas the lines are derived from area tops, which skips
92
+ // the point-emission path in computeLineMarks. Emit the data-point dots here
93
+ // so `mark.point` works on area charts the same way it does on lines.
94
+ // Single-series areas already get their points from computeLineMarks above.
95
+ const points = hasColor && spec.markDef.point ? pointsFromAreas(areas, spec.markDef.point) : [];
96
+
97
+ // Areas go first (rendered behind lines), then lines, then points on top
98
+ return [...areas, ...lines, ...points] as Mark[];
89
99
  };
90
100
 
91
101
  // ---------------------------------------------------------------------------
@@ -117,6 +127,46 @@ function linesFromAreas(areas: AreaMark[]): LineMark[] {
117
127
  }));
118
128
  }
119
129
 
130
+ /**
131
+ * Derive PointMark[] sitting on each area's top boundary. Honors the same
132
+ * `mark.point` modes as the line renderer: `true` (filled dots), `'transparent'`
133
+ * (invisible hover targets), and `'endpoints'` (hollow dots at first/last only).
134
+ */
135
+ function pointsFromAreas(
136
+ areas: AreaMark[],
137
+ pointMode: NonNullable<NormalizedChartSpec['markDef']['point']>,
138
+ ): PointMark[] {
139
+ const isTransparent = pointMode === 'transparent';
140
+ const isEndpoints = pointMode === 'endpoints';
141
+ const points: PointMark[] = [];
142
+
143
+ for (const a of areas) {
144
+ const stroke = getRepresentativeColor(a.fill);
145
+ const lastIdx = a.topPoints.length - 1;
146
+
147
+ for (let i = 0; i < a.topPoints.length; i++) {
148
+ const pt = a.topPoints[i];
149
+ const isEndpoint = i === 0 || i === lastIdx;
150
+ const visible = !isTransparent && (!isEndpoints || isEndpoint);
151
+ const hollow = isEndpoints && visible;
152
+ points.push({
153
+ type: 'point',
154
+ cx: pt.x,
155
+ cy: pt.y,
156
+ r: visible ? AREA_POINT_RADIUS : 0,
157
+ fill: hollow ? 'transparent' : stroke,
158
+ stroke: hollow ? stroke : visible ? '#ffffff' : 'transparent',
159
+ strokeWidth: visible ? 1.5 : 0,
160
+ fillOpacity: isTransparent ? 0 : 1,
161
+ data: a.data[i] ?? {},
162
+ aria: { decorative: true },
163
+ });
164
+ }
165
+ }
166
+
167
+ return points;
168
+ }
169
+
120
170
  // ---------------------------------------------------------------------------
121
171
  // Public exports
122
172
  // ---------------------------------------------------------------------------
package/src/compile.ts CHANGED
@@ -553,24 +553,34 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
553
553
  // the reserved margin. This way computeLegend positions the legend outside
554
554
  // the data area (in the margin) instead of overlapping data marks.
555
555
  //
556
- // Top/bottom legends sit above/below the chart and aren't constrained by
557
- // y-axis labels. Use the full container width so wrapping decisions match
558
- // the first pass (which uses options.width). Without this, charts with wide
559
- // y-axis labels (horizontal bars, long category names) artificially narrow
560
- // the legend and trigger premature wrapping.
556
+ // Top/bottom legends align their left edge to the plot area (chartArea.x),
557
+ // so the legend sits above/below the y-axis guide rather than flush against
558
+ // the container edge.
559
+ //
560
+ // Width runs from the new left origin (chartArea.x) to the container's right
561
+ // padding, so the rendered legend never paints past the right edge / over the
562
+ // endpoint-label column. This is narrower than the first pass (which used the
563
+ // full container at preliminaryArea.width = options.width), so on a chart with
564
+ // BOTH a wide y-axis gutter AND a legend that nearly fills its row, the second
565
+ // pass can wrap to one more row than the first pass reserved in margins.top —
566
+ // the extra row protrudes slightly into the chrome gap. That graceful failure
567
+ // (a few px of vertical crowding, still on-canvas) is preferable to the
568
+ // alternative (chips clipped off the right edge of the SVG). The maxRows cap
569
+ // (default 2 for top legends) bounds the worst case to a single extra row.
561
570
  const legendArea: Rect = { ...chartArea };
562
571
  if ('entries' in legendLayout && legendLayout.entries.length > 0) {
572
+ const legendInnerWidth = options.width - theme.spacing.padding - chartArea.x;
563
573
  const gap = legendGap(options.width);
564
574
  switch (legendLayout.position) {
565
575
  case 'top':
566
- legendArea.x = theme.spacing.padding;
567
- legendArea.width = options.width - theme.spacing.padding * 2;
576
+ legendArea.x = chartArea.x;
577
+ legendArea.width = legendInnerWidth;
568
578
  legendArea.y -= legendLayout.bounds.height + gap;
569
579
  legendArea.height += legendLayout.bounds.height + gap;
570
580
  break;
571
581
  case 'bottom':
572
- legendArea.x = theme.spacing.padding;
573
- legendArea.width = options.width - theme.spacing.padding * 2;
582
+ legendArea.x = chartArea.x;
583
+ legendArea.width = legendInnerWidth;
574
584
  // Bottom legend sits below the x-axis tick row, not over it. Expand
575
585
  // legendArea by xAxisHeight + legendHeight + gap so the bottom-anchored
576
586
  // legend lands beneath the axis. Mirrors dimensions.ts which reserved
@@ -23,12 +23,11 @@ import type {
23
23
  ResolvedTheme,
24
24
  } from '@opendata-ai/openchart-core';
25
25
  import {
26
- AXIS_TITLE_GAP,
27
26
  AXIS_TITLE_TRAILING_PAD,
27
+ axisTitleOffset,
28
28
  BREAKPOINT_COMPACT_MAX,
29
29
  computeChrome,
30
30
  estimateTextWidth,
31
- getAxisTitleOffset,
32
31
  HPAD_COMPACT_FRACTION,
33
32
  HPAD_COMPACT_MIN,
34
33
  LABEL_GAP_COMPACT,
@@ -38,7 +37,6 @@ import {
38
37
  MAX_LEFT_LABEL_FRACTION_MEDIUM,
39
38
  MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
40
39
  NARROW_VIEWPORT_MAX,
41
- TICK_LABEL_OFFSET,
42
40
  TOP_PAD_EXTRA_NARROW,
43
41
  } from '@opendata-ai/openchart-core';
44
42
  import { format as d3Format } from 'd3-format';
@@ -634,14 +632,16 @@ export function computeDimensions(
634
632
  theme.fonts.weights.normal,
635
633
  );
636
634
  }
637
- // Mirror the renderer's dynamic offset formula:
638
- // dynamicOffset = TICK_LABEL_OFFSET(6) + maxTickLabelWidth + AXIS_TITLE_GAP(14)
639
- // titleOffset = max(dynamicOffset, getAxisTitleOffset(width))
640
- const dynamicTitleOffset = TICK_LABEL_OFFSET + estTickLabelWidth + AXIS_TITLE_GAP;
641
- const axisTitleOffset = Math.max(dynamicTitleOffset, getAxisTitleOffset(width));
642
- const halfGlyph = Math.ceil(theme.fonts.sizes.body / 2);
635
+ // Mirror the renderer's title placement so the reserved space matches where
636
+ // the title is drawn. axisTitleOffset() returns the distance to the title's
637
+ // center; it already includes the title half-glyph on the tick-label side.
638
+ // We add another halfGlyph here for the title glyph extending the other way,
639
+ // toward the container edge, so the margin reaches the title's outer edge.
640
+ const titleFontSize = theme.fonts.sizes.body;
641
+ const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width);
642
+ const halfGlyph = Math.ceil(titleFontSize / 2);
643
643
  const rotatedLabelMargin =
644
- axisTitleOffset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
644
+ offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
645
645
  margins.left = Math.max(margins.left, hPad + rotatedLabelMargin);
646
646
  }
647
647
 
@@ -500,7 +500,11 @@ function buildPointScale(
500
500
  ? (channel.scale.domain as string[])
501
501
  : applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
502
502
 
503
- const padding = channel.scale?.padding ?? 0.5;
503
+ // Point scales have a single padding knob (outer padding only -- there are no
504
+ // bands, so paddingInner is meaningless). Accept `paddingOuter` as an alias so
505
+ // a spec written for a band scale doesn't silently no-op when the mark is a
506
+ // line; explicit `padding` wins if both are set.
507
+ const padding = channel.scale?.padding ?? channel.scale?.paddingOuter ?? 0.5;
504
508
  const scale = scalePoint().domain(values).range([rangeStart, rangeEnd]).padding(padding);
505
509
 
506
510
  if (channel.scale?.reverse) {