@opendata-ai/openchart-engine 6.19.1 → 6.19.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.19.1",
3
+ "version": "6.19.3",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@opendata-ai/openchart-core": "6.19.1",
48
+ "@opendata-ai/openchart-core": "6.19.3",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -655,3 +655,71 @@ describe('axis config properties', () => {
655
655
  expect(axes.x!.labelFlush).toBeUndefined();
656
656
  });
657
657
  });
658
+
659
+ // ---------------------------------------------------------------------------
660
+ // Y-axis produces ~5 ticks at standard sizes
661
+ // ---------------------------------------------------------------------------
662
+
663
+ describe('y-axis tick density', () => {
664
+ it('produces 5+ y-axis ticks at standard chart size with full density', () => {
665
+ const scales = computeScales(lineSpec, chartArea, lineSpec.data);
666
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme);
667
+
668
+ // A 500x300 chart with domain [100, 500] should show ~5+ ticks, not just 2
669
+ expect(axes.y!.ticks.length).toBeGreaterThanOrEqual(5);
670
+ });
671
+
672
+ it('y-axis thinning uses vertical overlap, not horizontal text width', () => {
673
+ // Even with a measureText that reports very wide labels, y-axis should
674
+ // not thin aggressively because overlap is checked vertically
675
+ const wideMeasure = () => ({ width: 500, height: 12 });
676
+ const scales = computeScales(lineSpec, chartArea, lineSpec.data);
677
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme, wideMeasure);
678
+
679
+ // Wide label text shouldn't cause y-axis thinning (only height matters)
680
+ expect(axes.y!.ticks.length).toBeGreaterThanOrEqual(5);
681
+ });
682
+ });
683
+
684
+ // ---------------------------------------------------------------------------
685
+ // Vertical orientation overlap detection
686
+ // ---------------------------------------------------------------------------
687
+
688
+ describe('ticksOverlap with vertical orientation', () => {
689
+ const fontSize = 12;
690
+ const fontWeight = 400;
691
+
692
+ it('returns false when vertical ticks have sufficient spacing', () => {
693
+ // Labels at 30px intervals with 12px font (14.4px with lineHeight)
694
+ const ticks: AxisTick[] = [
695
+ { value: 0, position: 0, label: '100' },
696
+ { value: 1, position: 30, label: '200' },
697
+ { value: 2, position: 60, label: '300' },
698
+ { value: 3, position: 90, label: '400' },
699
+ { value: 4, position: 120, label: '500' },
700
+ ];
701
+ expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'vertical')).toBe(false);
702
+ });
703
+
704
+ it('returns true when vertical ticks are too close', () => {
705
+ // Labels at 10px intervals with 12px font - should overlap vertically
706
+ const ticks: AxisTick[] = [
707
+ { value: 0, position: 0, label: '100' },
708
+ { value: 1, position: 10, label: '200' },
709
+ { value: 2, position: 20, label: '300' },
710
+ ];
711
+ expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'vertical')).toBe(true);
712
+ });
713
+
714
+ it('ignores label text width for vertical orientation', () => {
715
+ // Very wide labels but well-spaced vertically - should NOT overlap
716
+ const ticks: AxisTick[] = [
717
+ { value: 0, position: 0, label: 'Very Long Label Text Here' },
718
+ { value: 1, position: 40, label: 'Another Very Long Label' },
719
+ { value: 2, position: 80, label: 'Yet Another Long Label' },
720
+ ];
721
+ // Horizontal would detect overlap, vertical should not
722
+ expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'horizontal')).toBe(true);
723
+ expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'vertical')).toBe(false);
724
+ });
725
+ });
@@ -122,15 +122,31 @@ function measureLabel(
122
122
  : estimateTextWidth(text, fontSize, fontWeight);
123
123
  }
124
124
 
125
- /** Check whether any adjacent tick labels overlap horizontally. */
125
+ /** Check whether any adjacent tick labels overlap along the axis direction. */
126
126
  export function ticksOverlap(
127
127
  ticks: AxisTick[],
128
128
  fontSize: number,
129
129
  fontWeight: number,
130
130
  measureText?: MeasureTextFn,
131
+ orientation: 'horizontal' | 'vertical' = 'horizontal',
131
132
  ): boolean {
132
133
  if (ticks.length < 2) return false;
133
134
  const minGap = fontSize * MIN_TICK_GAP_FACTOR;
135
+
136
+ if (orientation === 'vertical') {
137
+ // Y-axis: labels are stacked vertically. Check if vertical extent
138
+ // (based on font height) overlaps between adjacent ticks.
139
+ // Positions decrease going up in SVG coords, so sort ascending.
140
+ const sorted = [...ticks].sort((a, b) => a.position - b.position);
141
+ const labelHeight = fontSize * 1.2; // lineHeight
142
+ for (let i = 0; i < sorted.length - 1; i++) {
143
+ const aBottom = sorted[i].position + labelHeight / 2;
144
+ const bTop = sorted[i + 1].position - labelHeight / 2;
145
+ if (aBottom + minGap > bTop) return true;
146
+ }
147
+ return false;
148
+ }
149
+
134
150
  for (let i = 0; i < ticks.length - 1; i++) {
135
151
  const aWidth = measureLabel(ticks[i].label, fontSize, fontWeight, measureText);
136
152
  const bWidth = measureLabel(ticks[i + 1].label, fontSize, fontWeight, measureText);
@@ -151,8 +167,9 @@ export function thinTicksUntilFit(
151
167
  fontSize: number,
152
168
  fontWeight: number,
153
169
  measureText?: MeasureTextFn,
170
+ orientation: 'horizontal' | 'vertical' = 'horizontal',
154
171
  ): AxisTick[] {
155
- if (!ticksOverlap(ticks, fontSize, fontWeight, measureText)) return ticks;
172
+ if (!ticksOverlap(ticks, fontSize, fontWeight, measureText, orientation)) return ticks;
156
173
 
157
174
  let current = ticks;
158
175
  while (current.length > MIN_TICK_COUNT) {
@@ -164,7 +181,7 @@ export function thinTicksUntilFit(
164
181
  if (current.length > 1) thinned.push(current[current.length - 1]);
165
182
  current = thinned;
166
183
 
167
- if (!ticksOverlap(current, fontSize, fontWeight, measureText)) break;
184
+ if (!ticksOverlap(current, fontSize, fontWeight, measureText, orientation)) break;
168
185
  }
169
186
  return current;
170
187
  }
@@ -455,7 +472,7 @@ export function computeAxes(
455
472
  // Thin tick labels to prevent overlap (skip for band scales, explicit tickCount, and values).
456
473
  const shouldThin = scales.y.type !== 'band' && !axisConfig?.tickCount && !axisConfig?.values;
457
474
  const ticks = shouldThin
458
- ? thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText)
475
+ ? thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText, 'vertical')
459
476
  : allTicks;
460
477
 
461
478
  // Gridlines match the tick set so every gridline has a label.