@opendata-ai/openchart-core 7.2.1 → 7.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-core",
3
- "version": "7.2.1",
3
+ "version": "7.2.2",
4
4
  "description": "Types, theme, colors, accessibility, and utilities for openchart",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
package/src/index.ts CHANGED
@@ -82,6 +82,7 @@ export {
82
82
  AXIS_TITLE_OFFSET_COMPACT,
83
83
  AXIS_TITLE_OFFSET_DEFAULT,
84
84
  AXIS_TITLE_TRAILING_PAD,
85
+ axisTitleOffset,
85
86
  BREAKPOINT_COMPACT_MAX,
86
87
  BREAKPOINT_MEDIUM_MAX,
87
88
  getAxisTitleOffset,
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ AXIS_TITLE_GAP,
4
+ AXIS_TITLE_OFFSET_DEFAULT,
5
+ axisTitleOffset,
6
+ TICK_LABEL_OFFSET,
7
+ } from '../metrics';
8
+
9
+ // The rotated y-axis title is anchored at its center and its glyph box extends
10
+ // half the title font size toward the tick labels. axisTitleOffset() returns the
11
+ // distance from the chart edge to that center; visible clearance between the
12
+ // widest tick label and the title's near edge is therefore:
13
+ // offset - TICK_LABEL_OFFSET - tickLabelWidth - titleFontSize / 2
14
+ const visibleClearance = (offset: number, tickLabelWidth: number, titleFontSize: number): number =>
15
+ offset - TICK_LABEL_OFFSET - tickLabelWidth - titleFontSize / 2;
16
+
17
+ describe('axisTitleOffset', () => {
18
+ const WIDE = 1200; // wide viewport so the dynamic value wins over the floor
19
+
20
+ it('leaves the title clear of the tick labels at the default font size', () => {
21
+ const offset = axisTitleOffset(40, 13, WIDE);
22
+ // ~AXIS_TITLE_GAP of clearance, never overlapping (negative).
23
+ expect(visibleClearance(offset, 40, 13)).toBeGreaterThanOrEqual(AXIS_TITLE_GAP - 1);
24
+ });
25
+
26
+ it('keeps clearance constant as the title font size grows', () => {
27
+ // The original bug: a fixed gap let large title fonts overlap the tick
28
+ // labels because the title's half-glyph ate into the gap. Clearance must
29
+ // stay ~AXIS_TITLE_GAP regardless of how big the title font is.
30
+ const small = visibleClearance(axisTitleOffset(40, 13, WIDE), 40, 13);
31
+ const large = visibleClearance(axisTitleOffset(40, 30, WIDE), 40, 30);
32
+
33
+ expect(large).toBeGreaterThanOrEqual(AXIS_TITLE_GAP - 1);
34
+ expect(Math.abs(large - small)).toBeLessThanOrEqual(1); // rounding only
35
+ });
36
+
37
+ it('pushes the title further left as tick labels get wider', () => {
38
+ // Both label widths sit above the viewport floor, so the offset tracks the
39
+ // label width 1:1 (a 60px wider label moves the title 60px further left).
40
+ const narrow = axisTitleOffset(50, 13, WIDE);
41
+ const wide = axisTitleOffset(110, 13, WIDE);
42
+ expect(narrow).toBeGreaterThan(AXIS_TITLE_OFFSET_DEFAULT); // floor not in play
43
+ expect(wide - narrow).toBeCloseTo(60, 0);
44
+ });
45
+
46
+ it('falls back to the viewport-minimum offset for short labels on wide containers', () => {
47
+ // Tiny label + small font: the dynamic value is below the floor, so the
48
+ // floor wins (prevents the title from hugging the chart edge).
49
+ const offset = axisTitleOffset(2, 11, WIDE);
50
+ expect(offset).toBe(AXIS_TITLE_OFFSET_DEFAULT);
51
+ });
52
+ });
@@ -26,6 +26,7 @@ export {
26
26
  AXIS_TITLE_OFFSET_COMPACT,
27
27
  AXIS_TITLE_OFFSET_DEFAULT,
28
28
  AXIS_TITLE_TRAILING_PAD,
29
+ axisTitleOffset,
29
30
  getAxisTitleOffset,
30
31
  HPAD_COMPACT_FRACTION,
31
32
  HPAD_COMPACT_MIN,
@@ -64,15 +64,41 @@ export const AXIS_TITLE_TRAILING_PAD = 4;
64
64
  // ---------------------------------------------------------------------------
65
65
 
66
66
  /**
67
- * Breathing room between the widest tick label's far edge and the rotated
68
- * y-axis title center. The title glyph extends ~halfGlyph (ceil(bodyFontSize/2))
69
- * from its center toward the tick labels, so visible clearance is
70
- * AXIS_TITLE_GAP - halfGlyph (~7px at default body size 13).
67
+ * Visible breathing room between the widest tick label's far edge and the
68
+ * rotated y-axis title's near edge.
69
+ *
70
+ * The title is rotated -90deg and anchored at its center, so its glyph box
71
+ * extends half the title font size toward the tick labels. To keep the gap
72
+ * constant regardless of title font size, callers add that half-glyph on top
73
+ * of this value (see axisTitleOffset() below). At the old fixed gap of 14 the
74
+ * clearance silently shrank as the font grew (14 - 11 = 3px at body size 21),
75
+ * which let large-font titles collide with the tick labels.
71
76
  *
72
77
  * Used in both the engine (dimensions.ts margin reservation) and the renderer
73
- * (axes.ts title placement). Both must agree on this value.
78
+ * (axes.ts title placement) via axisTitleOffset(). Both must agree on this value.
74
79
  */
75
- export const AXIS_TITLE_GAP = 14;
80
+ export const AXIS_TITLE_GAP = 7;
81
+
82
+ /**
83
+ * Computes the distance from the chart edge to the rotated y-axis title's center.
84
+ *
85
+ * The title center must sit far enough left that, after accounting for the
86
+ * widest tick label and the title's own half-glyph height, AXIS_TITLE_GAP of
87
+ * visible clearance remains. Falls back to the viewport-minimum offset when the
88
+ * dynamic value would be smaller (short tick labels on wide containers).
89
+ *
90
+ * Shared by the engine (margin reservation) and the renderer (title placement)
91
+ * so the reserved space always matches where the title is drawn.
92
+ */
93
+ export function axisTitleOffset(
94
+ maxTickLabelWidth: number,
95
+ titleFontSize: number,
96
+ width: number,
97
+ ): number {
98
+ const halfTitleGlyph = Math.ceil(titleFontSize / 2);
99
+ const dynamic = TICK_LABEL_OFFSET + maxTickLabelWidth + AXIS_TITLE_GAP + halfTitleGlyph;
100
+ return Math.max(dynamic, getAxisTitleOffset(width));
101
+ }
76
102
 
77
103
  // ---------------------------------------------------------------------------
78
104
  // Narrow viewport threshold (between compact and medium)
@@ -57,7 +57,10 @@ export const DEFAULT_THEME: Theme = {
57
57
  chartToFooter: 8,
58
58
  axisMargin: 6,
59
59
  xAxisHeight: 26,
60
- xAxisLabelPadding: 14,
60
+ // Gap (px) between the x-axis line and the TOP of the tick labels. The
61
+ // renderer anchors x-tick labels at their top edge (hanging baseline), so
62
+ // this is a literal top gap that holds regardless of font size.
63
+ xAxisLabelPadding: 4,
61
64
  },
62
65
  borderRadius: 2,
63
66
  chrome: {
package/src/types/spec.ts CHANGED
@@ -727,10 +727,24 @@ export interface RangeAnnotation extends AnnotationBase {
727
727
  y1?: string | number;
728
728
  /** End of the range on the y-axis. */
729
729
  y2?: string | number;
730
+ /**
731
+ * For band/point (ordinal) x or y scales, whether the range extends from the
732
+ * data point's center out to the band/step edge. Default `true`: a range on a
733
+ * bar chart spans full columns, and a range on a line chart extends half a step
734
+ * past the first/last point so the band reaches the plot edge. Set `false` to
735
+ * anchor the range exactly at the data point centers instead — useful when you
736
+ * want the band inset from the axis (e.g. starting at the first data point
737
+ * rather than flush against the y-axis guide). No effect on linear/time scales.
738
+ */
739
+ extendToEdges?: boolean;
730
740
  /** Pixel offset for the range label. */
731
741
  labelOffset?: AnnotationOffset;
732
742
  /** Anchor direction for the range label. */
733
743
  labelAnchor?: AnnotationAnchor;
744
+ /** Font size override for the range label (px). Default: 11. */
745
+ fontSize?: number;
746
+ /** Font weight override for the range label. Default: 500. */
747
+ fontWeight?: number;
734
748
  }
735
749
 
736
750
  /**