@opendata-ai/openchart-engine 6.25.1 → 6.25.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.25.1",
3
+ "version": "6.25.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",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "6.25.1",
51
+ "@opendata-ai/openchart-core": "6.25.3",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -809,3 +809,78 @@ describe('ticksOverlap with vertical orientation', () => {
809
809
  expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'vertical')).toBe(false);
810
810
  });
811
811
  });
812
+
813
+ // ---------------------------------------------------------------------------
814
+ // Horizontal bar chart: y-axis category label regression
815
+ // Mobile/compact viewports must show all category labels on horizontal bar
816
+ // charts, regardless of axisLabelDensity. Thinning is only valid on x-axis
817
+ // band scales where many category names can overlap horizontally.
818
+ // ---------------------------------------------------------------------------
819
+
820
+ describe('horizontal bar y-axis label thinning regression', () => {
821
+ const countries = [
822
+ 'USA',
823
+ 'Germany',
824
+ 'France',
825
+ 'Japan',
826
+ 'UK',
827
+ 'Canada',
828
+ 'Australia',
829
+ 'Netherlands',
830
+ 'Sweden',
831
+ 'Switzerland',
832
+ ];
833
+
834
+ const hBarSpec: NormalizedChartSpec = {
835
+ markType: 'bar',
836
+ markDef: { type: 'bar', orient: 'horizontal' },
837
+ data: countries.map((country, i) => ({ country, value: (i + 1) * 100 })),
838
+ encoding: {
839
+ x: { field: 'value', type: 'quantitative' },
840
+ y: { field: 'country', type: 'nominal' },
841
+ },
842
+ chrome: {},
843
+ annotations: [],
844
+ responsive: true,
845
+ theme: {},
846
+ darkMode: 'off',
847
+ labels: { density: 'auto', format: '' },
848
+ };
849
+
850
+ it('shows all category labels on y-axis at minimal density (mobile regression)', () => {
851
+ const scales = computeScales(hBarSpec, chartArea, hBarSpec.data);
852
+ const axes = computeAxes(scales, chartArea, minimalStrategy, theme);
853
+
854
+ // Every bar must have a label -- thinning to 3 on mobile was the bug
855
+ expect(axes.y!.ticks.length).toBe(countries.length);
856
+ });
857
+
858
+ it('shows all category labels on y-axis at reduced density', () => {
859
+ const reducedStrategy: LayoutStrategy = {
860
+ ...minimalStrategy,
861
+ axisLabelDensity: 'reduced',
862
+ };
863
+ const scales = computeScales(hBarSpec, chartArea, hBarSpec.data);
864
+ const axes = computeAxes(scales, chartArea, reducedStrategy, theme);
865
+
866
+ expect(axes.y!.ticks.length).toBe(countries.length);
867
+ });
868
+
869
+ it('still thins x-axis band scale labels at minimal density (column chart)', () => {
870
+ const vBarSpec: NormalizedChartSpec = {
871
+ ...hBarSpec,
872
+ markDef: { type: 'bar', orient: 'vertical' },
873
+ encoding: {
874
+ x: { field: 'country', type: 'nominal' },
875
+ y: { field: 'value', type: 'quantitative' },
876
+ },
877
+ };
878
+ const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
879
+ const scales = computeScales(vBarSpec, narrowArea, vBarSpec.data);
880
+ const axes = computeAxes(scales, narrowArea, minimalStrategy, theme);
881
+
882
+ // X-axis band scale with 10 categories at minimal density on a narrow chart
883
+ // should thin -- showing all 10 on 200px would overlap
884
+ expect(axes.x!.ticks.length).toBeLessThan(countries.length);
885
+ });
886
+ });
package/src/compile.ts CHANGED
@@ -500,7 +500,15 @@ export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLay
500
500
  seenLabels.add(entry.label);
501
501
  }
502
502
 
503
- for (const leaf of leaves) {
503
+ // Sort leaves by zIndex for render order while preserving original indices
504
+ // for axis assignment. Default zIndex is the array position.
505
+ const indexedLeaves = leaves.map((leaf, i) => ({
506
+ leaf,
507
+ zIndex: (leaf as ChartSpec).zIndex ?? i,
508
+ }));
509
+ indexedLeaves.sort((a, b) => a.zIndex - b.zIndex);
510
+
511
+ for (const { leaf } of indexedLeaves) {
504
512
  const leafLayout = compileChart(leaf as unknown, options);
505
513
 
506
514
  allMarks.push(...leafLayout.marks);
@@ -821,6 +829,14 @@ function compileLayerIndependent(
821
829
  }
822
830
  }
823
831
 
832
+ // Determine mark render order. By default, layer 0 paints first (behind),
833
+ // layer 1 paints second (on top). zIndex on the original leaf specs can
834
+ // reverse this so e.g. a line in layer 0 renders on top of bars in layer 1.
835
+ const z0 = leaf0.zIndex ?? 0;
836
+ const z1 = leaf1.zIndex ?? 1;
837
+ const marks =
838
+ z0 <= z1 ? [...adjustedMarks0, ...taggedMarks1] : [...taggedMarks1, ...adjustedMarks0];
839
+
824
840
  return {
825
841
  ...layout0,
826
842
  axes: {
@@ -828,7 +844,7 @@ function compileLayerIndependent(
828
844
  y: layout0.axes.y,
829
845
  y2: y2Axis,
830
846
  },
831
- marks: [...adjustedMarks0, ...taggedMarks1],
847
+ marks,
832
848
  legend: {
833
849
  ...layout0.legend,
834
850
  entries: mergedLegendEntries,
@@ -5,11 +5,12 @@
5
5
  * not from the chart area. Density thinning lives in ./thinning.ts.
6
6
  */
7
7
 
8
- import type { AxisLabelDensity, AxisTick } from '@opendata-ai/openchart-core';
8
+ import type { AxisLabelDensity, AxisTick, MeasureTextFn } from '@opendata-ai/openchart-core';
9
9
  import {
10
10
  abbreviateNumber,
11
11
  buildD3Formatter,
12
12
  buildTemporalFormatter,
13
+ estimateTextWidth,
13
14
  formatDate,
14
15
  formatNumber,
15
16
  } from '@opendata-ai/openchart-core';
@@ -201,24 +202,78 @@ export function scaleSupportsTickCount(resolvedScale: ResolvedScale): boolean {
201
202
  return 'ticks' in scale && typeof scale.ticks === 'function';
202
203
  }
203
204
 
204
- /** Generate ticks for a band/point/ordinal scale. */
205
+ /**
206
+ * Generate ticks for a band/point/ordinal scale.
207
+ *
208
+ * For horizontal x-axis band scales, thinning is geometry-aware: if
209
+ * `bandwidth` and `fontSize`/`fontWeight` are provided, labels are only
210
+ * thinned when the estimated label footprint (accounting for `labelAngle`)
211
+ * actually exceeds the bandwidth. When labels are rotated, their horizontal
212
+ * footprint shrinks by |cos(angle)|, so far fewer need to be removed.
213
+ * Falls back to a density-count cap when geometry info is unavailable.
214
+ */
205
215
  export function categoricalTicks(
206
216
  resolvedScale: ResolvedScale,
207
217
  density: AxisLabelDensity,
218
+ orientation: 'horizontal' | 'vertical' = 'horizontal',
219
+ bandwidth?: number,
220
+ labelAngle?: number,
221
+ fontSize?: number,
222
+ fontWeight?: number,
223
+ measureText?: MeasureTextFn,
208
224
  ): AxisTick[] {
209
225
  const scale = resolvedScale.scale as D3CategoricalScale;
210
226
  const domain: string[] = scale.domain();
211
227
  const explicitTickCount = resolvedScale.channel.axis?.tickCount;
212
- const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
213
228
 
214
- // Band scales show all labels at full density but thin at reduced/minimal
215
- // to prevent overlap on narrow containers (e.g. 17 bars on mobile).
216
229
  let selectedValues = domain;
217
- const shouldThinBand = resolvedScale.type === 'band' && (explicitTickCount || density !== 'full');
218
- if ((resolvedScale.type !== 'band' || shouldThinBand) && domain.length > maxTicks) {
219
- const step = Math.ceil(domain.length / maxTicks);
220
- selectedValues = domain.filter((_: string, i: number) => i % step === 0);
230
+
231
+ if (resolvedScale.type === 'band' && orientation === 'horizontal') {
232
+ // Geometry-based thinning: check whether labels actually fit within the
233
+ // bandwidth before deciding to thin. Rotated labels have a smaller
234
+ // horizontal footprint (width * |cos(angle)|), so they can be much denser.
235
+ if (bandwidth !== undefined && bandwidth > 0 && fontSize !== undefined) {
236
+ const maxLabelWidth = domain.reduce((max, v) => {
237
+ const w = measureText
238
+ ? measureText(v, fontSize, fontWeight ?? 400).width
239
+ : estimateTextWidth(v, fontSize, fontWeight ?? 400);
240
+ return Math.max(max, w);
241
+ }, 0);
242
+
243
+ // At non-zero angles, horizontal footprint per label = width * |cos(angle)|
244
+ const angleRad = labelAngle !== undefined ? (Math.abs(labelAngle) * Math.PI) / 180 : 0;
245
+ const footprint = angleRad > 0 ? maxLabelWidth * Math.abs(Math.cos(angleRad)) : maxLabelWidth;
246
+ const minGap = fontSize * 0.5;
247
+
248
+ if (footprint + minGap > bandwidth) {
249
+ // Labels don't fit -- thin proportionally to bandwidth, not density tier
250
+ const maxFitting = Math.max(1, Math.floor(bandwidth / (footprint + minGap)));
251
+ // Still respect explicit tickCount as an upper bound
252
+ const cap =
253
+ explicitTickCount ?? Math.min(domain.length, Math.max(maxFitting, TICK_COUNTS[density]));
254
+ if (domain.length > cap) {
255
+ const step = Math.ceil(domain.length / cap);
256
+ selectedValues = domain.filter((_: string, i: number) => i % step === 0);
257
+ }
258
+ }
259
+ // else: labels fit at this bandwidth -- show all of them
260
+ } else {
261
+ // No geometry info: fall back to density-count cap (original behavior)
262
+ const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
263
+ if ((explicitTickCount || density !== 'full') && domain.length > maxTicks) {
264
+ const step = Math.ceil(domain.length / maxTicks);
265
+ selectedValues = domain.filter((_: string, i: number) => i % step === 0);
266
+ }
267
+ }
268
+ } else if (resolvedScale.type !== 'band') {
269
+ // Point/ordinal scales: thin by density count
270
+ const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
271
+ if (domain.length > maxTicks) {
272
+ const step = Math.ceil(domain.length / maxTicks);
273
+ selectedValues = domain.filter((_: string, i: number) => i % step === 0);
274
+ }
221
275
  }
276
+ // vertical band scale (horizontal bar y-axis): always show all labels
222
277
 
223
278
  const ticks = selectedValues.map((value: string) => {
224
279
  // Band scales: use the center of the band
@@ -259,7 +259,18 @@ export function computeAxes(
259
259
  if (axisConfig?.values) {
260
260
  allTicks = resolveExplicitTicks(axisConfig.values, scales.x);
261
261
  } else if (!isContinuousX) {
262
- allTicks = categoricalTicks(scales.x, xDensity);
262
+ const xBandwidth =
263
+ scales.x.type === 'band' ? (scales.x.scale as ScaleBand<string>).bandwidth() : undefined;
264
+ allTicks = categoricalTicks(
265
+ scales.x,
266
+ xDensity,
267
+ 'horizontal',
268
+ xBandwidth,
269
+ axisConfig?.labelAngle,
270
+ fontSize,
271
+ fontWeight,
272
+ measureText,
273
+ );
263
274
  } else {
264
275
  allTicks = continuousTicks(scales.x, xDensity, xTargetCount);
265
276
  }
@@ -351,7 +362,7 @@ export function computeAxes(
351
362
  if (axisConfig?.values) {
352
363
  allTicks = resolveExplicitTicks(axisConfig.values, scales.y);
353
364
  } else if (!isContinuousY) {
354
- allTicks = categoricalTicks(scales.y, yDensity);
365
+ allTicks = categoricalTicks(scales.y, yDensity, 'vertical');
355
366
  } else {
356
367
  allTicks = continuousTicks(scales.y, yDensity, yTargetCount);
357
368
  }