@opendata-ai/openchart-engine 7.2.4 → 7.3.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.
Files changed (37) hide show
  1. package/dist/index.d.ts +84 -36
  2. package/dist/index.js +4010 -2871
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +139 -63
  6. package/src/__tests__/axes.test.ts +67 -1
  7. package/src/__tests__/compile-chart.test.ts +6 -0
  8. package/src/__tests__/compile-layer.test.ts +49 -0
  9. package/src/__tests__/dimensions.test.ts +7 -2
  10. package/src/__tests__/helpers/invariants.ts +267 -0
  11. package/src/__tests__/known-bugs.test.ts +113 -0
  12. package/src/__tests__/layout-fuzz.test.ts +342 -0
  13. package/src/__tests__/layout-invariants.test.ts +533 -0
  14. package/src/__tests__/legend.test.ts +37 -0
  15. package/src/annotations/__tests__/compute.test.ts +8 -15
  16. package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
  17. package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
  18. package/src/annotations/__tests__/placement.test.ts +316 -0
  19. package/src/annotations/collisions.ts +19 -7
  20. package/src/annotations/compute.ts +257 -16
  21. package/src/annotations/geometry.ts +73 -15
  22. package/src/annotations/placement.ts +588 -0
  23. package/src/annotations/resolve-text.ts +58 -10
  24. package/src/charts/__tests__/post-process.test.ts +3 -3
  25. package/src/charts/post-process.ts +21 -7
  26. package/src/compile/layer.ts +31 -9
  27. package/src/compile.ts +85 -148
  28. package/src/index.ts +9 -0
  29. package/src/layout/axes.ts +93 -1
  30. package/src/layout/dimensions.ts +80 -98
  31. package/src/layout/plan.ts +524 -0
  32. package/src/layout/shared.ts +44 -0
  33. package/src/legend/compute.ts +315 -78
  34. package/src/legend/wrap.ts +11 -7
  35. package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
  36. package/src/tables/__tests__/heatmap.test.ts +24 -20
  37. package/src/tables/heatmap.ts +25 -9
package/src/compile.ts CHANGED
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Pipeline for charts:
5
5
  * validate spec -> normalize -> resolve theme -> dark mode adapt ->
6
- * compute legend -> compute dimensions (with legend space) ->
7
- * compute scales -> compute axes -> compute gridlines ->
6
+ * filter data -> resolveLayoutPlan (measure real labels) ->
7
+ * compute dimensions (with plan) -> placeLegend ->
8
+ * compute scales -> compute axes (with pinned ticks) -> compute gridlines ->
8
9
  * get chart renderer -> compute marks -> compute a11y -> return ChartLayout
9
10
  *
10
11
  * Table compiler handles full data pipeline (sort, search, pagination, visual enhancements).
@@ -23,7 +24,6 @@ import type {
23
24
  EncodingChannel,
24
25
  LayerSpec,
25
26
  Mark,
26
- Rect,
27
27
  ResolvedAnnotation,
28
28
  ResolvedTheme,
29
29
  TableLayout,
@@ -34,7 +34,6 @@ import type {
34
34
  import {
35
35
  adaptTheme,
36
36
  computeLabelBounds,
37
- estimateTextWidth,
38
37
  generateAltText,
39
38
  generateDataTable,
40
39
  getBreakpoint,
@@ -42,8 +41,9 @@ import {
42
41
  getLayoutStrategy,
43
42
  resolveTheme,
44
43
  } from '@opendata-ai/openchart-core';
45
- import { format as d3Format } from 'd3-format';
46
44
  import { computeAnnotations } from './annotations/compute';
45
+ import { type AnnotationMeasureTextFn, heuristicMeasure } from './annotations/geometry';
46
+ import type { PlacementObstacle } from './annotations/placement';
47
47
  import { computeEndpointLabels } from './endpoint-labels/compute';
48
48
  // Side-effect import: registers all built-in chart renderers with the
49
49
  // registry on module load. Tests that clear the registry can import
@@ -74,9 +74,9 @@ import type { GraphCompilation } from './graphs/types';
74
74
  import { computeAxes } from './layout/axes';
75
75
  import { computeDimensions } from './layout/dimensions';
76
76
  import { computeGridlines } from './layout/gridlines';
77
+ import { createMeasureFn, resolveLayoutPlan } from './layout/plan';
77
78
  import { computeScales } from './layout/scales';
78
- import { computeLegend } from './legend/compute';
79
- import { legendGap } from './legend/wrap';
79
+ import { placeLegend } from './legend/compute';
80
80
  import { compileSankey as compileSankeyImpl } from './sankey/compile-sankey';
81
81
  import { compileTableLayout } from './tables/compile-table';
82
82
  import { compileTileMap as compileTileMapImpl } from './tilemap/compile-tilemap';
@@ -533,70 +533,11 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
533
533
  chartSpec = applySparklineDefaults(chartSpec, theme);
534
534
  }
535
535
 
536
- // INVARIANT 1 — double legend pass: preliminaryArea → computeDimensions → legendArea → final
537
- // legend. Breaks a dims/legend dependency cycle. Do not collapse into one call.
538
- // Compute legend first (needs to reserve space)
539
- const preliminaryArea: Rect = {
540
- x: 0,
541
- y: 0,
542
- width: options.width,
543
- height: options.height,
544
- };
545
- const legendLayout = computeLegend(chartSpec, strategy, theme, preliminaryArea, watermark);
546
-
547
- // Compute dimensions (accounts for chrome + legend + responsive strategy)
548
- const dims = computeDimensions(chartSpec, options, legendLayout, theme, strategy, watermark);
549
- const chartArea = dims.chartArea;
550
-
551
- // Recompute legend bounds relative to actual chart area.
552
- // chartArea was shrunk to exclude legend space, so expand it back to include
553
- // the reserved margin. This way computeLegend positions the legend outside
554
- // the data area (in the margin) instead of overlapping data marks.
555
- //
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.
570
- const legendArea: Rect = { ...chartArea };
571
- if ('entries' in legendLayout && legendLayout.entries.length > 0) {
572
- const legendInnerWidth = options.width - theme.spacing.padding - chartArea.x;
573
- const gap = legendGap(options.width);
574
- switch (legendLayout.position) {
575
- case 'top':
576
- legendArea.x = chartArea.x;
577
- legendArea.width = legendInnerWidth;
578
- legendArea.y -= legendLayout.bounds.height + gap;
579
- legendArea.height += legendLayout.bounds.height + gap;
580
- break;
581
- case 'bottom':
582
- legendArea.x = chartArea.x;
583
- legendArea.width = legendInnerWidth;
584
- // Bottom legend sits below the x-axis tick row, not over it. Expand
585
- // legendArea by xAxisHeight + legendHeight + gap so the bottom-anchored
586
- // legend lands beneath the axis. Mirrors dimensions.ts which reserved
587
- // the same xAxisHeight in margins.bottom.
588
- legendArea.height += legendLayout.bounds.height + gap + dims.xAxisHeight;
589
- break;
590
- case 'right':
591
- case 'bottom-right':
592
- legendArea.width += legendLayout.bounds.width + 8;
593
- break;
594
- }
595
- }
596
- const finalLegend = computeLegend(chartSpec, strategy, theme, legendArea, watermark);
597
-
598
- // Apply data filtering after legend (so legend retains all series), but before
599
- // scale computation (so hidden/clipped data doesn't affect domains or marks).
536
+ // ---------------------------------------------------------------------------
537
+ // Data filtering: move BEFORE layout plan so the plan measures real labels
538
+ // from the filtered data set. Legend retains all series via chartSpec
539
+ // (unfiltered); scales and marks use renderSpec (filtered).
540
+ // ---------------------------------------------------------------------------
600
541
  let renderData = chartSpec.data;
601
542
 
602
543
  // Filter hidden series: removed from rendering but kept in legend (dimmed in the adapter)
@@ -619,7 +560,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
619
560
  // Lock the color scale domain to the unfiltered series list so palette
620
561
  // assignments stay stable across legend toggles. Without this, hiding a
621
562
  // series shrinks the ordinal scale's domain and shifts every remaining
622
- // series down one palette index — the visible lines would mismatch the
563
+ // series down one palette index -- the visible lines would mismatch the
623
564
  // legend swatches that still represent the original assignment.
624
565
  // Skipped when the user already supplied an explicit domain.
625
566
  const colorEnc = chartSpec.encoding.color;
@@ -644,73 +585,53 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
644
585
  };
645
586
  }
646
587
 
647
- // Inline y-axis labels render at chartArea.x with text-anchor=start. Without
648
- // an inset the leftmost data point on a line/area would clip through the
649
- // label glyphs (since the line begins at chartArea.x too). Inset the x-scale
650
- // range start by the widest tick label width so the data starts to the right
651
- // of the labels. Axes/gridlines still span the full chart area, so inline
652
- // labels stay flush left where the design intends.
653
- const yEnc = renderSpec.encoding?.y;
654
- const yAxisCfgInline =
655
- typeof yEnc?.axis === 'object' && yEnc.axis !== null
656
- ? (yEnc.axis as Record<string, unknown>)
657
- : undefined;
658
- const yIsContinuous = yEnc?.type === 'quantitative' || yEnc?.type === 'temporal';
659
- const isLineOrArea = renderSpec.markType === 'line' || renderSpec.markType === 'area';
660
- const yInlineExplicit = yAxisCfgInline?.tickPosition as 'inline' | 'gutter' | undefined;
661
- // Sparkline mode hides the y-axis by default, so the inline-label inset
662
- // would shrink the data range without any labels actually being drawn —
663
- // the line ends up starting ~30px from the left edge for no visible
664
- // reason. Skip the inset unless the user explicitly opted into the
665
- // y-axis (in which case the labels do render and the inset is needed).
666
- const sparklineSuppressesYAxis =
667
- chartSpec.display === 'sparkline' && !chartSpec.userExplicit.yAxis;
668
- const yIsInline =
669
- !sparklineSuppressesYAxis &&
670
- (yInlineExplicit === 'inline' ||
671
- (yInlineExplicit === undefined &&
672
- isLineOrArea &&
673
- yIsContinuous &&
674
- yAxisCfgInline?.orient !== 'right'));
588
+ // ---------------------------------------------------------------------------
589
+ // Layout plan: measure real labels, freeze gutter + chrome + legend content
590
+ // ---------------------------------------------------------------------------
591
+ const measure = createMeasureFn(options.measureText);
592
+ const plan = resolveLayoutPlan(
593
+ chartSpec,
594
+ renderSpec,
595
+ options,
596
+ theme,
597
+ strategy,
598
+ watermark,
599
+ measure,
600
+ );
601
+
602
+ // Compute dimensions (accounts for chrome + legend + responsive strategy).
603
+ // The plan provides measured leftGutter, xAxisExtent, and chrome so
604
+ // dimensions.ts skips its guess blocks.
605
+ const legendStub = placeLegend(
606
+ plan.legendContent,
607
+ { x: 0, y: 0, width: options.width, height: options.height },
608
+ options.width,
609
+ theme,
610
+ plan.xAxisExtent,
611
+ );
612
+ const dims = computeDimensions(chartSpec, options, legendStub, theme, strategy, watermark, plan);
613
+ const chartArea = dims.chartArea;
614
+
615
+ // Place legend in its final position relative to the computed chart area.
616
+ const finalLegend = placeLegend(
617
+ plan.legendContent,
618
+ chartArea,
619
+ options.width,
620
+ theme,
621
+ plan.xAxisExtent,
622
+ );
623
+
624
+ // Inline y-axis labels: inset the x-scale range start by the widest tick
625
+ // label width so data marks start to the right of the labels. The plan
626
+ // already measured the real tick labels.
675
627
  let scaleArea = chartArea;
676
- if (yIsInline && yEnc && yIsContinuous && yEnc.axis !== false) {
677
- const yField = yEnc.field;
678
- const yFmt = yAxisCfgInline?.format as string | undefined;
679
- let maxAbsVal = 0;
680
- for (const row of renderSpec.data) {
681
- const v = Number(row[yField]);
682
- if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
683
- }
684
- let sample: string;
685
- if (yFmt) {
686
- try {
687
- sample = d3Format(yFmt)(maxAbsVal);
688
- } catch {
689
- sample = String(maxAbsVal);
690
- }
691
- } else if (maxAbsVal >= 1_000_000_000) sample = '1.5B';
692
- else if (maxAbsVal >= 1_000_000) sample = '1.5M';
693
- else if (maxAbsVal >= 1_000) sample = '1.5K';
694
- else if (maxAbsVal >= 100) sample = '100';
695
- else if (maxAbsVal >= 10) sample = '10';
696
- else sample = '0.0';
697
- const negPrefix = renderSpec.data.some((r) => Number(r[yField]) < 0) ? '-' : '';
698
- const labelEst = negPrefix + sample;
699
- const labelWidth = estimateTextWidth(
700
- labelEst,
701
- theme.fonts.sizes.axisTick,
702
- theme.fonts.weights.normal,
703
- );
704
- const INLINE_LABEL_BREATHING_ROOM = 8;
705
- const inset = Math.ceil(labelWidth + INLINE_LABEL_BREATHING_ROOM);
706
- if (inset > 0 && inset < chartArea.width) {
707
- scaleArea = {
708
- x: chartArea.x + inset,
709
- y: chartArea.y,
710
- width: chartArea.width - inset,
711
- height: chartArea.height,
712
- };
713
- }
628
+ if (plan.inlineYLabelInset > 0 && plan.inlineYLabelInset < chartArea.width) {
629
+ scaleArea = {
630
+ x: chartArea.x + plan.inlineYLabelInset,
631
+ y: chartArea.y,
632
+ width: chartArea.width - plan.inlineYLabelInset,
633
+ height: chartArea.height,
634
+ };
714
635
  }
715
636
 
716
637
  // Compute scales
@@ -719,7 +640,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
719
640
  // Update color scale to use theme palette (only when user hasn't provided an explicit range)
720
641
  applyColorScaleRange(scales, renderSpec.encoding, theme);
721
642
 
722
- // INVARIANT 3 — post-hoc defaultColor: must run AFTER computeScales since resolution needs
643
+ // INVARIANT 3 -- post-hoc defaultColor: must run AFTER computeScales since resolution needs
723
644
  // theme context. Do not move into computeScales (would require threading theme through).
724
645
  // fill wins for bar/area/arc marks; stroke wins for line marks (the stroke IS the color).
725
646
  scales.defaultColor =
@@ -741,8 +662,14 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
741
662
  skipX,
742
663
  skipY,
743
664
  markType: chartSpec.markType,
665
+ totalWidth: options.width,
666
+ precomputedYTicks: plan.yTickValues.length > 0 ? plan.yTickValues : undefined,
744
667
  });
745
668
 
669
+ // Intentional post-hoc mutation: axes must resolve before we know the x-axis extent.
670
+ const xAxisExtent = axes.x?.extent ?? 0;
671
+ dims.chrome.bottomAnchorY = chartArea.y + chartArea.height + xAxisExtent;
672
+
746
673
  // INVARIANT 2 — computeGridlines mutates `axes` in place. Downstream consumers read
747
674
  // axes.y.gridlines off the same object. Do not introduce a copy-on-write.
748
675
  if (!isRadial) {
@@ -764,31 +691,41 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
764
691
  const endpointLabels = computeEndpointLabels(chartSpec, marks, theme, chartArea, strategy);
765
692
 
766
693
  // Compute annotations from spec, passing legend + mark + brand bounds as obstacles
767
- const obstacles: Rect[] = [];
694
+ const obstacles: PlacementObstacle[] = [];
768
695
  if (finalLegend.bounds.width > 0) {
769
- obstacles.push(finalLegend.bounds);
696
+ obstacles.push({ ...finalLegend.bounds, kind: 'legend' });
770
697
  }
771
698
  obstacles.push(...computeMarkObstacles(marks, scales));
772
699
 
773
700
  // Add visible data label bounds as obstacles so annotations avoid overlapping them
774
701
  for (const mark of marks) {
775
702
  if ('label' in mark && mark.label?.visible) {
776
- obstacles.push(computeLabelBounds(mark.label));
703
+ obstacles.push({ ...computeLabelBounds(mark.label), kind: 'data-label' });
777
704
  }
778
705
  }
779
706
 
780
707
  // Add brand watermark as an obstacle so annotations avoid overlapping it.
781
708
  const watermarkRect = computeWatermarkObstacle(dims, watermark, axes, theme);
782
- if (watermarkRect) obstacles.push(watermarkRect);
783
- const annotations: ResolvedAnnotation[] = computeAnnotations(
784
- chartSpec,
709
+ if (watermarkRect) obstacles.push({ ...watermarkRect, kind: 'watermark' });
710
+
711
+ // Add endpoint label bounds as obstacles
712
+ if (endpointLabels.entries.length > 0 && endpointLabels.bounds.width > 0) {
713
+ obstacles.push({ ...endpointLabels.bounds, kind: 'endpoint-label' });
714
+ }
715
+
716
+ const annotationMeasure: AnnotationMeasureTextFn = options.measureText
717
+ ? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width
718
+ : heuristicMeasure;
719
+
720
+ const annotations: ResolvedAnnotation[] = computeAnnotations(chartSpec, {
785
721
  scales,
786
722
  chartArea,
787
723
  strategy,
788
- theme.isDark,
724
+ isDark: theme.isDark,
789
725
  obstacles,
790
- { width: dims.total.width, height: dims.total.height },
791
- );
726
+ svg: { width: dims.total.width, height: dims.total.height },
727
+ measure: annotationMeasure,
728
+ });
792
729
 
793
730
  // Compute tooltip descriptors from marks and encoding
794
731
  const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
package/src/index.ts CHANGED
@@ -53,6 +53,15 @@ export type { NormalizedBarListSpec } from './barlist/types';
53
53
 
54
54
  export type { NormalizedTileMapSpec } from './tilemap/types';
55
55
 
56
+ // ---------------------------------------------------------------------------
57
+ // Annotations
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export type { AnnotationContext } from './annotations/compute';
61
+ export type { AnnotationMeasureTextFn } from './annotations/geometry';
62
+ export { heuristicMeasure } from './annotations/geometry';
63
+ export type { ObstacleKind, PlacementObstacle } from './annotations/placement';
64
+
56
65
  // ---------------------------------------------------------------------------
57
66
  // Compiler pipeline (spec validation, normalization, generic compile)
58
67
  // ---------------------------------------------------------------------------
@@ -19,6 +19,13 @@ import type {
19
19
  ResolvedTheme,
20
20
  TextStyle,
21
21
  } from '@opendata-ai/openchart-core';
22
+ import {
23
+ axisTitleOffset,
24
+ computeXAxisExtentFromLabels,
25
+ estimateTextWidth,
26
+ getAxisTitleOffset,
27
+ X_AXIS_TITLE_BAND_ROTATED,
28
+ } from '@opendata-ai/openchart-core';
22
29
  import type { ScaleBand } from 'd3-scale';
23
30
  import { measureLabel, thinTicksUntilFit, ticksOverlap } from './axes/thinning';
24
31
  import {
@@ -273,6 +280,15 @@ export interface AxesDataContext {
273
280
  * marks default to `'gutter'`.
274
281
  */
275
282
  markType?: import('@opendata-ai/openchart-core').MarkType;
283
+ /** Total container width for axis title offset threshold checks. */
284
+ totalWidth?: number;
285
+ /**
286
+ * Pinned y-axis tick values from the layout plan. When provided, the y-axis
287
+ * uses these values (via resolveExplicitTicks) instead of generating fresh
288
+ * ticks, so the labels match exactly what the gutter was measured for.
289
+ * User-specified `axis.values` still takes priority.
290
+ */
291
+ precomputedYTicks?: unknown[];
276
292
  }
277
293
 
278
294
  /**
@@ -440,6 +456,15 @@ export function computeAxes(
440
456
  let allTicks: AxisTick[];
441
457
  if (axisConfig?.values) {
442
458
  allTicks = resolveExplicitTicks(axisConfig.values, scales.y);
459
+ } else if (
460
+ dataContext?.precomputedYTicks &&
461
+ dataContext.precomputedYTicks.length > 0 &&
462
+ isContinuousY
463
+ ) {
464
+ // Pinned ticks from the layout plan -- use the same resolution path as
465
+ // explicit user values so the labels match exactly what the gutter was
466
+ // measured for.
467
+ allTicks = resolveExplicitTicks(dataContext.precomputedYTicks, scales.y);
443
468
  } else if (!isContinuousY) {
444
469
  const yFieldName = dataContext?.encoding.y?.field;
445
470
  const yLabelField = axisConfig?.labelField;
@@ -458,7 +483,8 @@ export function computeAxes(
458
483
  // Thin tick labels to prevent overlap (skip for band scales and explicit tick values).
459
484
  // When tickCount is set, we still thin if D3 overshot the requested count
460
485
  // (common with log scales where ticks(4) can return 26 values).
461
- const shouldThinY = scales.y.type !== 'band' && !axisConfig?.values;
486
+ const shouldThinY =
487
+ scales.y.type !== 'band' && !axisConfig?.values && !dataContext?.precomputedYTicks?.length;
462
488
  let ticks: AxisTick[];
463
489
  if (!shouldThinY) {
464
490
  ticks = allTicks;
@@ -525,5 +551,71 @@ export function computeAxes(
525
551
  };
526
552
  }
527
553
 
554
+ const totalWidth = dataContext?.totalWidth ?? chartArea.x + chartArea.width;
555
+
556
+ if (result.x) {
557
+ let rotatedHeight: number | undefined;
558
+ if (result.x.tickAngle && Math.abs(result.x.tickAngle) > 10) {
559
+ const angleRad = Math.abs(result.x.tickAngle) * (Math.PI / 180);
560
+ let maxLabelWidth = 40;
561
+ for (const tick of result.x.ticks) {
562
+ const w = estimateTextWidth(
563
+ tick.label,
564
+ result.x.tickLabelStyle.fontSize,
565
+ result.x.tickLabelStyle.fontWeight,
566
+ );
567
+ if (w > maxLabelWidth) maxLabelWidth = w;
568
+ }
569
+ rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
570
+ }
571
+
572
+ if (rotatedHeight != null) {
573
+ result.x.extent = result.x.label ? rotatedHeight + X_AXIS_TITLE_BAND_ROTATED : rotatedHeight;
574
+ } else {
575
+ result.x.extent = computeXAxisExtentFromLabels({
576
+ labels: result.x.ticks.map((t) => t.label),
577
+ tickAngle: result.x.tickAngle,
578
+ hasTitle: !!result.x.label,
579
+ tickFontSize: result.x.tickLabelStyle.fontSize,
580
+ tickFontWeight: result.x.tickLabelStyle.fontWeight,
581
+ xAxisHeight: theme.spacing.xAxisHeight,
582
+ });
583
+ }
584
+
585
+ if (result.x.label) {
586
+ result.x.titlePosition = {
587
+ x: chartArea.x + chartArea.width / 2,
588
+ y: chartArea.y + chartArea.height + (rotatedHeight != null ? rotatedHeight + 14 : 35),
589
+ };
590
+ }
591
+ }
592
+
593
+ if (result.y?.label && result.y.labelStyle) {
594
+ const isRight = result.y.orient === 'right';
595
+ if (isRight) {
596
+ const titleOff = getAxisTitleOffset(totalWidth);
597
+ result.y.titlePosition = {
598
+ x: chartArea.x + chartArea.width + titleOff,
599
+ y: chartArea.y + chartArea.height / 2,
600
+ angle: 90,
601
+ };
602
+ } else {
603
+ const maxTickLabelWidth = result.y.ticks.reduce((max, t) => {
604
+ const w = estimateTextWidth(
605
+ t.label,
606
+ result.y!.tickLabelStyle.fontSize,
607
+ result.y!.tickLabelStyle.fontWeight ?? 400,
608
+ );
609
+ return Math.max(max, w);
610
+ }, 0);
611
+ const titleOff = axisTitleOffset(maxTickLabelWidth, result.y.labelStyle.fontSize, totalWidth);
612
+ result.y.titlePosition = {
613
+ x: chartArea.x - titleOff,
614
+ y: chartArea.y + chartArea.height / 2,
615
+ angle: -90,
616
+ };
617
+ }
618
+ }
619
+
528
620
  return result;
529
621
  }