@opendata-ai/openchart-engine 7.2.3 → 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.
- package/dist/index.d.ts +84 -36
- package/dist/index.js +4013 -2872
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +139 -63
- package/src/__tests__/axes.test.ts +67 -1
- package/src/__tests__/compile-chart.test.ts +6 -0
- package/src/__tests__/compile-layer.test.ts +49 -0
- package/src/__tests__/dimensions.test.ts +7 -2
- package/src/__tests__/helpers/invariants.ts +267 -0
- package/src/__tests__/known-bugs.test.ts +113 -0
- package/src/__tests__/layout-fuzz.test.ts +342 -0
- package/src/__tests__/layout-invariants.test.ts +533 -0
- package/src/__tests__/legend.test.ts +37 -0
- package/src/annotations/__tests__/compute.test.ts +8 -15
- package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
- package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
- package/src/annotations/__tests__/placement.test.ts +316 -0
- package/src/annotations/collisions.ts +19 -7
- package/src/annotations/compute.ts +257 -16
- package/src/annotations/geometry.ts +73 -15
- package/src/annotations/placement.ts +588 -0
- package/src/annotations/resolve-text.ts +58 -10
- package/src/charts/__tests__/post-process.test.ts +3 -3
- package/src/charts/bar/__tests__/compute.test.ts +67 -0
- package/src/charts/bar/compute.ts +8 -1
- package/src/charts/post-process.ts +21 -7
- package/src/compile/layer.ts +31 -9
- package/src/compile.ts +85 -148
- package/src/index.ts +9 -0
- package/src/layout/__tests__/metrics.test.ts +97 -0
- package/src/layout/axes.ts +93 -1
- package/src/layout/dimensions.ts +80 -98
- package/src/layout/plan.ts +524 -0
- package/src/layout/shared.ts +44 -0
- package/src/legend/compute.ts +315 -78
- package/src/legend/wrap.ts +11 -7
- package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
- package/src/tables/__tests__/heatmap.test.ts +24 -20
- 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
|
-
*
|
|
7
|
-
* compute
|
|
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 {
|
|
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
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
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
|
-
//
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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 (
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
784
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Metric } from '@opendata-ai/openchart-core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
computeMetricBar,
|
|
5
|
+
METRIC_BAR_INTERNALS,
|
|
6
|
+
type MetricFontSizes,
|
|
7
|
+
metricBarHeight,
|
|
8
|
+
} from '../metrics';
|
|
9
|
+
|
|
10
|
+
const { TOP_GAP, BOTTOM_GAP, INTER_ROW_GAP, LABEL_LINE_HEIGHT_RATIO, VALUE_LINE_HEIGHT_RATIO } =
|
|
11
|
+
METRIC_BAR_INTERNALS;
|
|
12
|
+
|
|
13
|
+
const defaultFonts: MetricFontSizes = { label: 10, value: 22 };
|
|
14
|
+
const largeFonts: MetricFontSizes = { label: 16, value: 32 };
|
|
15
|
+
|
|
16
|
+
const metrics: Metric[] = [
|
|
17
|
+
{ label: 'Revenue', value: '$1.2B', delta: '+12%', deltaTone: 'up' },
|
|
18
|
+
{ label: 'Users', value: '4.5M' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const area = { x: 20, width: 600 };
|
|
22
|
+
|
|
23
|
+
describe('metricBarHeight', () => {
|
|
24
|
+
it('returns the expected height at default font sizes', () => {
|
|
25
|
+
const expected =
|
|
26
|
+
TOP_GAP +
|
|
27
|
+
defaultFonts.label * LABEL_LINE_HEIGHT_RATIO +
|
|
28
|
+
INTER_ROW_GAP +
|
|
29
|
+
defaultFonts.value * VALUE_LINE_HEIGHT_RATIO +
|
|
30
|
+
BOTTOM_GAP;
|
|
31
|
+
expect(metricBarHeight()).toBe(expected);
|
|
32
|
+
expect(metricBarHeight(defaultFonts)).toBe(expected);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('grows when font sizes increase', () => {
|
|
36
|
+
const large = metricBarHeight(largeFonts);
|
|
37
|
+
expect(large).toBeGreaterThan(metricBarHeight(defaultFonts));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('matches the formula for custom sizes', () => {
|
|
41
|
+
const expected =
|
|
42
|
+
TOP_GAP +
|
|
43
|
+
largeFonts.label * LABEL_LINE_HEIGHT_RATIO +
|
|
44
|
+
INTER_ROW_GAP +
|
|
45
|
+
largeFonts.value * VALUE_LINE_HEIGHT_RATIO +
|
|
46
|
+
BOTTOM_GAP;
|
|
47
|
+
expect(metricBarHeight(largeFonts)).toBe(expected);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('computeMetricBar', () => {
|
|
52
|
+
it('uses custom font sizes for layout positions', () => {
|
|
53
|
+
const topY = 50;
|
|
54
|
+
const defaultBar = computeMetricBar(metrics, topY, area, 400, undefined, defaultFonts)!;
|
|
55
|
+
const largeBar = computeMetricBar(metrics, topY, area, 400, undefined, largeFonts)!;
|
|
56
|
+
|
|
57
|
+
expect(defaultBar).toBeDefined();
|
|
58
|
+
expect(largeBar).toBeDefined();
|
|
59
|
+
|
|
60
|
+
expect(largeBar.height).toBeGreaterThan(defaultBar.height);
|
|
61
|
+
expect(largeBar.height).toBe(metricBarHeight(largeFonts));
|
|
62
|
+
|
|
63
|
+
expect(largeBar.cells[0].labelY).toBeGreaterThan(defaultBar.cells[0].labelY);
|
|
64
|
+
expect(largeBar.cells[0].valueY).toBeGreaterThan(defaultBar.cells[0].valueY);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('computes labelY and valueY from font sizes', () => {
|
|
68
|
+
const topY = 50;
|
|
69
|
+
const bar = computeMetricBar(metrics, topY, area, 400, undefined, largeFonts)!;
|
|
70
|
+
|
|
71
|
+
expect(bar.cells[0].labelY).toBe(topY + TOP_GAP + largeFonts.label);
|
|
72
|
+
expect(bar.cells[0].valueY).toBe(
|
|
73
|
+
topY +
|
|
74
|
+
TOP_GAP +
|
|
75
|
+
largeFonts.label * LABEL_LINE_HEIGHT_RATIO +
|
|
76
|
+
INTER_ROW_GAP +
|
|
77
|
+
largeFonts.value,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('uses custom font size for overflow detection', () => {
|
|
82
|
+
const tinyCell: Metric[] = [{ label: 'A', value: 'WWWWWWWWWWWWWWWWWWWWWWWWWW' }];
|
|
83
|
+
const narrowArea = { x: 0, width: 500 };
|
|
84
|
+
|
|
85
|
+
const withSmall = computeMetricBar(tinyCell, 0, narrowArea, 400, undefined, {
|
|
86
|
+
label: 10,
|
|
87
|
+
value: 12,
|
|
88
|
+
});
|
|
89
|
+
const withLarge = computeMetricBar(tinyCell, 0, narrowArea, 400, undefined, {
|
|
90
|
+
label: 10,
|
|
91
|
+
value: 40,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(withSmall).toBeDefined();
|
|
95
|
+
expect(withLarge).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
});
|
package/src/layout/axes.ts
CHANGED
|
@@ -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 =
|
|
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
|
}
|