@opendata-ai/openchart-engine 7.0.0 → 7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "7.0.0",
3
+ "version": "7.0.3",
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.0.0",
51
+ "@opendata-ai/openchart-core": "7.0.3",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -491,6 +491,7 @@ function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): ChartSpec
491
491
  darkMode: layerSpec.darkMode ?? leaves[0].darkMode,
492
492
  watermark: layerSpec.watermark ?? leaves[0].watermark,
493
493
  hiddenSeries: layerSpec.hiddenSeries ?? leaves[0].hiddenSeries,
494
+ endpointLabels: layerSpec.endpointLabels ?? leaves[0].endpointLabels,
494
495
  };
495
496
 
496
497
  return primary;
package/src/compile.ts CHANGED
@@ -552,15 +552,25 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
552
552
  // chartArea was shrunk to exclude legend space, so expand it back to include
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
+ //
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.
555
561
  const legendArea: Rect = { ...chartArea };
556
562
  if ('entries' in legendLayout && legendLayout.entries.length > 0) {
557
563
  const gap = legendGap(options.width);
558
564
  switch (legendLayout.position) {
559
565
  case 'top':
566
+ legendArea.x = theme.spacing.padding;
567
+ legendArea.width = options.width - theme.spacing.padding * 2;
560
568
  legendArea.y -= legendLayout.bounds.height + gap;
561
569
  legendArea.height += legendLayout.bounds.height + gap;
562
570
  break;
563
571
  case 'bottom':
572
+ legendArea.x = theme.spacing.padding;
573
+ legendArea.width = options.width - theme.spacing.padding * 2;
564
574
  // Bottom legend sits below the x-axis tick row, not over it. Expand
565
575
  // legendArea by xAxisHeight + legendHeight + gap so the bottom-anchored
566
576
  // legend lands beneath the axis. Mirrors dimensions.ts which reserved
@@ -468,6 +468,7 @@ export function flattenLayers(
468
468
  parentEncoding?: Encoding,
469
469
  parentTransforms?: import('@opendata-ai/openchart-core').Transform[],
470
470
  parentWatermark?: boolean,
471
+ parentEndpointLabels?: boolean | import('@opendata-ai/openchart-core').EndpointLabelsConfig,
471
472
  ): ChartSpec[] {
472
473
  const resolvedData = spec.data ?? parentData;
473
474
  const resolvedEncoding: Encoding | undefined =
@@ -477,6 +478,7 @@ export function flattenLayers(
477
478
  const resolvedTransforms = [...(parentTransforms ?? []), ...(spec.transform ?? [])];
478
479
  // Layer-level watermark propagates to children (child can still override)
479
480
  const resolvedWatermark = spec.watermark ?? parentWatermark;
481
+ const resolvedEndpointLabels = spec.endpointLabels ?? parentEndpointLabels;
480
482
 
481
483
  const leaves: ChartSpec[] = [];
482
484
 
@@ -490,6 +492,7 @@ export function flattenLayers(
490
492
  resolvedEncoding,
491
493
  resolvedTransforms,
492
494
  resolvedWatermark,
495
+ resolvedEndpointLabels,
493
496
  ),
494
497
  );
495
498
  } else {
@@ -509,6 +512,9 @@ export function flattenLayers(
509
512
  ...(child.watermark === undefined && resolvedWatermark !== undefined
510
513
  ? { watermark: resolvedWatermark }
511
514
  : {}),
515
+ ...(child.endpointLabels === undefined && resolvedEndpointLabels !== undefined
516
+ ? { endpointLabels: resolvedEndpointLabels }
517
+ : {}),
512
518
  } as ChartSpec);
513
519
  }
514
520
  }
@@ -153,12 +153,13 @@ function getSparklinePad(spec: NormalizedChartSpec): {
153
153
  point === 'last' || point === true || point === 'endpoints' || point === 'transparent';
154
154
  const dotLeft =
155
155
  point === 'first' || point === true || point === 'endpoints' || point === 'transparent';
156
+ const hasDots = dotRight || dotLeft;
156
157
 
157
158
  return {
158
159
  left: dotLeft ? Math.max(strokePad, dotPad) : strokePad,
159
160
  right: dotRight ? Math.max(strokePad, dotPad) : strokePad,
160
- top: strokePad,
161
- bottom: strokePad,
161
+ top: hasDots ? Math.max(strokePad, dotPad) : strokePad,
162
+ bottom: hasDots ? Math.max(strokePad, dotPad) : strokePad,
162
163
  };
163
164
  }
164
165
 
@@ -361,8 +362,13 @@ export function computeDimensions(
361
362
  // is kept; the rollback path subtracts it back when stripped.
362
363
  const wantsMetrics = !!spec.metrics && spec.metrics.length > 0 && chromeMode !== 'hidden';
363
364
  const tentativeMetricsHeight = wantsMetrics ? metricBarHeight() : 0;
365
+ // topAxisGap sits between the legend (or chrome, if no legend) and the
366
+ // chart area. It accounts for the general axis margin plus any inline
367
+ // tick-label overhang. Placing it after the legend (below) keeps the
368
+ // subtitle-to-legend gap tight while reserving physical space for ticks
369
+ // that protrude above the chart area.
364
370
  const margins: Margins = {
365
- top: topPad + chrome.topHeight + tentativeMetricsHeight + topAxisGap,
371
+ top: topPad + chrome.topHeight + tentativeMetricsHeight,
366
372
  right: hPad + (isRadial ? hPad : axisMargin),
367
373
  bottom: padding + chrome.bottomHeight + xAxisHeight,
368
374
  left: hPad + (isRadial ? hPad : axisMargin),
@@ -609,6 +615,10 @@ export function computeDimensions(
609
615
  // above.
610
616
  }
611
617
 
618
+ // Add topAxisGap after legend so it sits between the legend (or chrome
619
+ // when there's no legend) and the chart area.
620
+ margins.top += topAxisGap;
621
+
612
622
  // Chart area is what's left after margins
613
623
  let chartArea: Rect = {
614
624
  x: margins.left,
@@ -643,7 +653,7 @@ export function computeDimensions(
643
653
  // until resolveMetrics decides otherwise).
644
654
  const fallbackTopAxisGap =
645
655
  isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
646
- const newTop = topPad + fallbackChrome.topHeight + fallbackTopAxisGap + tentativeMetricsHeight;
656
+ const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight;
647
657
  const topDelta = margins.top - newTop;
648
658
  const newBottom = padding + fallbackChrome.bottomHeight + xAxisHeight;
649
659
  const bottomDelta = margins.bottom - newBottom;
@@ -656,7 +666,8 @@ export function computeDimensions(
656
666
  legendLayout.entries.length > 0 &&
657
667
  legendLayout.position === 'top'
658
668
  ? legendLayout.bounds.height + gap
659
- : 0);
669
+ : 0) +
670
+ fallbackTopAxisGap;
660
671
  margins.bottom = newBottom;
661
672
 
662
673
  chartArea = {
@@ -709,8 +720,8 @@ export function computeDimensions(
709
720
  // topPad
710
721
  // chrome.topHeight (title / subtitle / eyebrow)
711
722
  // tentativeMetricsHeight (KPI bar — placed here)
712
- // topAxisGap (axisMargin + inlineTickOverhang)
713
723
  // [optional top legend band]
724
+ // topAxisGap (axisMargin + inlineTickOverhang)
714
725
  // chartArea
715
726
  // The metric bar belongs with chrome, above the legend, so its y is
716
727
  // computed off chrome.topHeight only — not the full legend-inclusive