@opendata-ai/openchart-engine 7.2.1 → 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.1",
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.1",
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);
@@ -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') {
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