@opendata-ai/openchart-engine 7.2.4 → 7.4.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 +88 -39
- package/dist/index.js +4134 -2930
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +143 -78
- package/src/__tests__/axes.test.ts +129 -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 +70 -2
- package/src/__tests__/helpers/invariants.ts +265 -0
- package/src/__tests__/known-bugs.test.ts +113 -0
- package/src/__tests__/layout-fuzz.test.ts +358 -0
- package/src/__tests__/layout-invariants.test.ts +535 -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 +131 -45
- package/src/charts/bar/compute.ts +44 -4
- package/src/charts/bar/index.ts +9 -2
- package/src/charts/bar/labels.ts +77 -26
- package/src/charts/column/labels.ts +29 -16
- package/src/charts/post-process.ts +21 -7
- package/src/charts/registry.ts +4 -2
- package/src/compile/layer.ts +31 -9
- package/src/compile.ts +88 -149
- package/src/index.ts +9 -0
- package/src/layout/axes/rotation.ts +36 -0
- package/src/layout/axes.ts +130 -8
- package/src/layout/dimensions.ts +80 -98
- package/src/layout/plan.ts +550 -0
- package/src/layout/scales.ts +22 -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) {
|
|
@@ -756,7 +683,9 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
756
683
|
renderSpec.markDef,
|
|
757
684
|
);
|
|
758
685
|
const renderer = getChartRenderer(rendererKey);
|
|
759
|
-
const marks: Mark[] = renderer
|
|
686
|
+
const marks: Mark[] = renderer
|
|
687
|
+
? renderer(renderSpec, scales, chartArea, strategy, theme, options.width)
|
|
688
|
+
: [];
|
|
760
689
|
|
|
761
690
|
// Compute the right-side endpoint labels column for multi-series line/area
|
|
762
691
|
// charts. Reads `mark.dataPoints` so it must run AFTER marks are computed.
|
|
@@ -764,31 +693,41 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
764
693
|
const endpointLabels = computeEndpointLabels(chartSpec, marks, theme, chartArea, strategy);
|
|
765
694
|
|
|
766
695
|
// Compute annotations from spec, passing legend + mark + brand bounds as obstacles
|
|
767
|
-
const obstacles:
|
|
696
|
+
const obstacles: PlacementObstacle[] = [];
|
|
768
697
|
if (finalLegend.bounds.width > 0) {
|
|
769
|
-
obstacles.push(finalLegend.bounds);
|
|
698
|
+
obstacles.push({ ...finalLegend.bounds, kind: 'legend' });
|
|
770
699
|
}
|
|
771
700
|
obstacles.push(...computeMarkObstacles(marks, scales));
|
|
772
701
|
|
|
773
702
|
// Add visible data label bounds as obstacles so annotations avoid overlapping them
|
|
774
703
|
for (const mark of marks) {
|
|
775
704
|
if ('label' in mark && mark.label?.visible) {
|
|
776
|
-
obstacles.push(computeLabelBounds(mark.label));
|
|
705
|
+
obstacles.push({ ...computeLabelBounds(mark.label), kind: 'data-label' });
|
|
777
706
|
}
|
|
778
707
|
}
|
|
779
708
|
|
|
780
709
|
// Add brand watermark as an obstacle so annotations avoid overlapping it.
|
|
781
710
|
const watermarkRect = computeWatermarkObstacle(dims, watermark, axes, theme);
|
|
782
|
-
if (watermarkRect) obstacles.push(watermarkRect);
|
|
783
|
-
|
|
784
|
-
|
|
711
|
+
if (watermarkRect) obstacles.push({ ...watermarkRect, kind: 'watermark' });
|
|
712
|
+
|
|
713
|
+
// Add endpoint label bounds as obstacles
|
|
714
|
+
if (endpointLabels.entries.length > 0 && endpointLabels.bounds.width > 0) {
|
|
715
|
+
obstacles.push({ ...endpointLabels.bounds, kind: 'endpoint-label' });
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const annotationMeasure: AnnotationMeasureTextFn = options.measureText
|
|
719
|
+
? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width
|
|
720
|
+
: heuristicMeasure;
|
|
721
|
+
|
|
722
|
+
const annotations: ResolvedAnnotation[] = computeAnnotations(chartSpec, {
|
|
785
723
|
scales,
|
|
786
724
|
chartArea,
|
|
787
725
|
strategy,
|
|
788
|
-
theme.isDark,
|
|
726
|
+
isDark: theme.isDark,
|
|
789
727
|
obstacles,
|
|
790
|
-
{ width: dims.total.width, height: dims.total.height },
|
|
791
|
-
|
|
728
|
+
svg: { width: dims.total.width, height: dims.total.height },
|
|
729
|
+
measure: annotationMeasure,
|
|
730
|
+
});
|
|
792
731
|
|
|
793
732
|
// Compute tooltip descriptors from marks and encoding
|
|
794
733
|
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,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-rotation decision for band-scale x-axis tick labels.
|
|
3
|
+
*
|
|
4
|
+
* Vertical band x-axes rotate their tick labels to -45° when horizontal
|
|
5
|
+
* labels are wider than the available band. The layout planner and
|
|
6
|
+
* computeAxes must agree on this decision: the planner reserves the bottom
|
|
7
|
+
* margin for the rotated label footprint, and computeAxes emits the same
|
|
8
|
+
* angle. Sharing this rule keeps the reserved space matched to the rendered
|
|
9
|
+
* labels (a mismatch spilled rotated ticks into the source line).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Band fraction above which horizontal labels are considered too wide to fit. */
|
|
13
|
+
const BAND_FIT_FRACTION = 0.85;
|
|
14
|
+
|
|
15
|
+
/** Angle used when band labels don't fit horizontally. */
|
|
16
|
+
const AUTO_ROTATE_ANGLE = -45;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Decide the effective x tick angle for a band scale.
|
|
20
|
+
*
|
|
21
|
+
* @param explicitAngle - User-specified labelAngle (wins if defined).
|
|
22
|
+
* @param maxLabelWidth - Widest tick label width in pixels.
|
|
23
|
+
* @param bandwidth - Band width in pixels.
|
|
24
|
+
* @param labelCount - Number of category labels (rotation needs >= 2).
|
|
25
|
+
* @returns The explicit angle, `-45` if auto-rotation triggers, or `undefined`.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveBandTickAngle(
|
|
28
|
+
explicitAngle: number | undefined,
|
|
29
|
+
maxLabelWidth: number,
|
|
30
|
+
bandwidth: number,
|
|
31
|
+
labelCount: number,
|
|
32
|
+
): number | undefined {
|
|
33
|
+
if (explicitAngle !== undefined) return explicitAngle;
|
|
34
|
+
if (labelCount < 2) return undefined;
|
|
35
|
+
return maxLabelWidth > bandwidth * BAND_FIT_FRACTION ? AUTO_ROTATE_ANGLE : undefined;
|
|
36
|
+
}
|
package/src/layout/axes.ts
CHANGED
|
@@ -19,7 +19,15 @@ 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';
|
|
30
|
+
import { resolveBandTickAngle } from './axes/rotation';
|
|
23
31
|
import { measureLabel, thinTicksUntilFit, ticksOverlap } from './axes/thinning';
|
|
24
32
|
import {
|
|
25
33
|
buildContinuousTicks,
|
|
@@ -123,6 +131,40 @@ const CONTINUOUS_TICK_FLOOR = 4;
|
|
|
123
131
|
*/
|
|
124
132
|
const OVERSHOOT_TOLERANCE = 1.5;
|
|
125
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Quantitative axes must always render at least this many tick labels. A lone
|
|
136
|
+
* tick reads as a broken axis (a real iPhone blog chart degenerated to a
|
|
137
|
+
* single "80" when D3's `scale.ticks(2)` collapsed a tight domain like
|
|
138
|
+
* [73, 88] to one nice value). This floor holds at every width.
|
|
139
|
+
*/
|
|
140
|
+
const MIN_QUANTITATIVE_TICKS = 2;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Ensure a continuous scale yields at least MIN_QUANTITATIVE_TICKS ticks.
|
|
144
|
+
*
|
|
145
|
+
* D3's `scale.ticks(n)` picks nice step sizes, so a tight domain can collapse
|
|
146
|
+
* to a single value at low counts (`[73, 88].ticks(2)` → `[80]`). When that
|
|
147
|
+
* happens, request progressively higher counts until at least two ticks
|
|
148
|
+
* appear. Prefers the smallest count that clears the floor, which keeps the
|
|
149
|
+
* ticks spread toward the domain endpoints rather than clustered.
|
|
150
|
+
*/
|
|
151
|
+
function ensureMinContinuousTicks(
|
|
152
|
+
scale: ResolvedScales['x' | 'y'],
|
|
153
|
+
ticks: AxisTick[],
|
|
154
|
+
requestedCount: number,
|
|
155
|
+
): AxisTick[] {
|
|
156
|
+
if (ticks.length >= MIN_QUANTITATIVE_TICKS) return ticks;
|
|
157
|
+
if (!scale || !scaleSupportsTickCount(scale)) return ticks;
|
|
158
|
+
|
|
159
|
+
let best = ticks;
|
|
160
|
+
for (let n = Math.max(requestedCount, MIN_QUANTITATIVE_TICKS) + 1; n <= 10; n++) {
|
|
161
|
+
const candidate = buildContinuousTicks(scale, n);
|
|
162
|
+
if (candidate.length >= MIN_QUANTITATIVE_TICKS) return candidate;
|
|
163
|
+
if (candidate.length > best.length) best = candidate;
|
|
164
|
+
}
|
|
165
|
+
return best;
|
|
166
|
+
}
|
|
167
|
+
|
|
126
168
|
/**
|
|
127
169
|
* Fit continuous ticks by re-requesting progressively fewer ticks from the
|
|
128
170
|
* scale. D3's `scale.ticks(n)` always returns evenly-spaced round values, so
|
|
@@ -152,10 +194,15 @@ function fitContinuousTicks(
|
|
|
152
194
|
): AxisTick[] {
|
|
153
195
|
if (!scale || !scaleSupportsTickCount(scale)) return initialTicks;
|
|
154
196
|
|
|
197
|
+
// Floor the initial set: a tight domain can make D3 return a single nice
|
|
198
|
+
// value, which would otherwise pass straight through (no overlap, no
|
|
199
|
+
// overshoot) and render as a lone tick.
|
|
200
|
+
const flooredTicks = ensureMinContinuousTicks(scale, initialTicks, initialCount);
|
|
201
|
+
|
|
155
202
|
const tolerance = initialCount * OVERSHOOT_TOLERANCE;
|
|
156
|
-
const overshoots =
|
|
157
|
-
const overlaps = ticksOverlap(
|
|
158
|
-
if (!overshoots && !overlaps) return
|
|
203
|
+
const overshoots = flooredTicks.length > tolerance;
|
|
204
|
+
const overlaps = ticksOverlap(flooredTicks, fontSize, fontWeight, measureText, orientation);
|
|
205
|
+
if (!overshoots && !overlaps) return flooredTicks;
|
|
159
206
|
|
|
160
207
|
// Enforce the floor only when the axis is long enough to fit that many
|
|
161
208
|
// labels without overlap. Very short axes can fall below.
|
|
@@ -186,7 +233,8 @@ function fitContinuousTicks(
|
|
|
186
233
|
// No candidate fit cleanly. Thin whatever most recently met the floor; if
|
|
187
234
|
// nothing did, synthesize a floor-count set directly from the scale so we
|
|
188
235
|
// never hand the overshot initialTicks to the middle-pruning thinner.
|
|
189
|
-
const
|
|
236
|
+
const rawFallback = bestWithinFloor ?? buildContinuousTicks(scale, floor);
|
|
237
|
+
const fallback = ensureMinContinuousTicks(scale, rawFallback, floor);
|
|
190
238
|
return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
|
|
191
239
|
}
|
|
192
240
|
|
|
@@ -273,6 +321,15 @@ export interface AxesDataContext {
|
|
|
273
321
|
* marks default to `'gutter'`.
|
|
274
322
|
*/
|
|
275
323
|
markType?: import('@opendata-ai/openchart-core').MarkType;
|
|
324
|
+
/** Total container width for axis title offset threshold checks. */
|
|
325
|
+
totalWidth?: number;
|
|
326
|
+
/**
|
|
327
|
+
* Pinned y-axis tick values from the layout plan. When provided, the y-axis
|
|
328
|
+
* uses these values (via resolveExplicitTicks) instead of generating fresh
|
|
329
|
+
* ticks, so the labels match exactly what the gutter was measured for.
|
|
330
|
+
* User-specified `axis.values` still takes priority.
|
|
331
|
+
*/
|
|
332
|
+
precomputedYTicks?: unknown[];
|
|
276
333
|
}
|
|
277
334
|
|
|
278
335
|
/**
|
|
@@ -369,9 +426,7 @@ export function computeAxes(
|
|
|
369
426
|
const w = measureLabel(t.label, fontSize, fontWeight, measureText);
|
|
370
427
|
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
371
428
|
}
|
|
372
|
-
|
|
373
|
-
tickAngle = -45;
|
|
374
|
-
}
|
|
429
|
+
tickAngle = resolveBandTickAngle(undefined, maxLabelWidth, bandwidth, allTicks.length);
|
|
375
430
|
}
|
|
376
431
|
|
|
377
432
|
// Thin tick labels to prevent overlap (skip for explicit tick values).
|
|
@@ -440,6 +495,15 @@ export function computeAxes(
|
|
|
440
495
|
let allTicks: AxisTick[];
|
|
441
496
|
if (axisConfig?.values) {
|
|
442
497
|
allTicks = resolveExplicitTicks(axisConfig.values, scales.y);
|
|
498
|
+
} else if (
|
|
499
|
+
dataContext?.precomputedYTicks &&
|
|
500
|
+
dataContext.precomputedYTicks.length > 0 &&
|
|
501
|
+
isContinuousY
|
|
502
|
+
) {
|
|
503
|
+
// Pinned ticks from the layout plan -- use the same resolution path as
|
|
504
|
+
// explicit user values so the labels match exactly what the gutter was
|
|
505
|
+
// measured for.
|
|
506
|
+
allTicks = resolveExplicitTicks(dataContext.precomputedYTicks, scales.y);
|
|
443
507
|
} else if (!isContinuousY) {
|
|
444
508
|
const yFieldName = dataContext?.encoding.y?.field;
|
|
445
509
|
const yLabelField = axisConfig?.labelField;
|
|
@@ -458,7 +522,8 @@ export function computeAxes(
|
|
|
458
522
|
// Thin tick labels to prevent overlap (skip for band scales and explicit tick values).
|
|
459
523
|
// When tickCount is set, we still thin if D3 overshot the requested count
|
|
460
524
|
// (common with log scales where ticks(4) can return 26 values).
|
|
461
|
-
const shouldThinY =
|
|
525
|
+
const shouldThinY =
|
|
526
|
+
scales.y.type !== 'band' && !axisConfig?.values && !dataContext?.precomputedYTicks?.length;
|
|
462
527
|
let ticks: AxisTick[];
|
|
463
528
|
if (!shouldThinY) {
|
|
464
529
|
ticks = allTicks;
|
|
@@ -525,5 +590,62 @@ export function computeAxes(
|
|
|
525
590
|
};
|
|
526
591
|
}
|
|
527
592
|
|
|
593
|
+
const totalWidth = dataContext?.totalWidth ?? chartArea.x + chartArea.width;
|
|
594
|
+
|
|
595
|
+
if (result.x) {
|
|
596
|
+
const isRotated = !!result.x.tickAngle && Math.abs(result.x.tickAngle) > 10;
|
|
597
|
+
|
|
598
|
+
// Single source of truth for the x-axis vertical extent (rotated or flat).
|
|
599
|
+
// computeXAxisExtentFromLabels applies the correct rotated-extent math
|
|
600
|
+
// (textWidth*|sin θ| + lineHeight*|cos θ|), so the reserved bottom margin
|
|
601
|
+
// matches what the labels actually occupy.
|
|
602
|
+
result.x.extent = computeXAxisExtentFromLabels({
|
|
603
|
+
labels: result.x.ticks.map((t) => t.label),
|
|
604
|
+
tickAngle: result.x.tickAngle,
|
|
605
|
+
hasTitle: !!result.x.label,
|
|
606
|
+
tickFontSize: result.x.tickLabelStyle.fontSize,
|
|
607
|
+
tickFontWeight: result.x.tickLabelStyle.fontWeight,
|
|
608
|
+
xAxisHeight: theme.spacing.xAxisHeight,
|
|
609
|
+
measure: (t, fs, fw) => measureLabel(t, fs, fw, measureText),
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
if (result.x.label) {
|
|
613
|
+
// Rotated: place the title just below the tick band (extent already
|
|
614
|
+
// includes the rotated label height and the title band). Flat: fixed 35px.
|
|
615
|
+
const rotatedBand = isRotated ? result.x.extent - X_AXIS_TITLE_BAND_ROTATED + 14 : 35;
|
|
616
|
+
result.x.titlePosition = {
|
|
617
|
+
x: chartArea.x + chartArea.width / 2,
|
|
618
|
+
y: chartArea.y + chartArea.height + rotatedBand,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (result.y?.label && result.y.labelStyle) {
|
|
624
|
+
const isRight = result.y.orient === 'right';
|
|
625
|
+
if (isRight) {
|
|
626
|
+
const titleOff = getAxisTitleOffset(totalWidth);
|
|
627
|
+
result.y.titlePosition = {
|
|
628
|
+
x: chartArea.x + chartArea.width + titleOff,
|
|
629
|
+
y: chartArea.y + chartArea.height / 2,
|
|
630
|
+
angle: 90,
|
|
631
|
+
};
|
|
632
|
+
} else {
|
|
633
|
+
const maxTickLabelWidth = result.y.ticks.reduce((max, t) => {
|
|
634
|
+
const w = estimateTextWidth(
|
|
635
|
+
t.label,
|
|
636
|
+
result.y!.tickLabelStyle.fontSize,
|
|
637
|
+
result.y!.tickLabelStyle.fontWeight ?? 400,
|
|
638
|
+
);
|
|
639
|
+
return Math.max(max, w);
|
|
640
|
+
}, 0);
|
|
641
|
+
const titleOff = axisTitleOffset(maxTickLabelWidth, result.y.labelStyle.fontSize, totalWidth);
|
|
642
|
+
result.y.titlePosition = {
|
|
643
|
+
x: chartArea.x - titleOff,
|
|
644
|
+
y: chartArea.y + chartArea.height / 2,
|
|
645
|
+
angle: -90,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
528
650
|
return result;
|
|
529
651
|
}
|