@opendata-ai/openchart-engine 6.25.2 → 6.25.4

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.2",
3
+ "version": "6.25.4",
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.2",
51
+ "@opendata-ai/openchart-core": "6.25.4",
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
@@ -237,14 +237,23 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
237
237
  },
238
238
  };
239
239
  }
240
- if (bp.labels) {
241
- chartSpec = {
242
- ...chartSpec,
243
- labels: {
244
- ...chartSpec.labels,
245
- ...(bp.labels as NormalizedChartSpec['labels']),
246
- },
247
- };
240
+ if (bp.labels !== undefined) {
241
+ if (typeof bp.labels === 'boolean') {
242
+ chartSpec = {
243
+ ...chartSpec,
244
+ labels: bp.labels
245
+ ? { density: 'auto', format: '', prefix: '' }
246
+ : { density: 'none', format: '', prefix: '' },
247
+ };
248
+ } else {
249
+ chartSpec = {
250
+ ...chartSpec,
251
+ labels: {
252
+ ...chartSpec.labels,
253
+ ...(bp.labels as NormalizedChartSpec['labels']),
254
+ },
255
+ };
256
+ }
248
257
  }
249
258
  if (bp.legend) {
250
259
  chartSpec = {
@@ -17,6 +17,7 @@ import type {
17
17
  Encoding,
18
18
  FieldType,
19
19
  GraphSpec,
20
+ LabelSpec,
20
21
  LayerSpec,
21
22
  SankeySpec,
22
23
  TableSpec,
@@ -189,6 +190,21 @@ function normalizeAnnotations(annotations: Annotation[] | undefined): Annotation
189
190
  });
190
191
  }
191
192
 
193
+ // ---------------------------------------------------------------------------
194
+ // Label normalization
195
+ // ---------------------------------------------------------------------------
196
+
197
+ function normalizeLabels(labels?: LabelSpec): NormalizedChartSpec['labels'] {
198
+ if (labels === false) return { density: 'none', format: '', prefix: '' };
199
+ if (labels === true || labels === undefined) return { density: 'auto', format: '', prefix: '' };
200
+ return {
201
+ density: labels.density ?? 'auto',
202
+ format: labels.format ?? '',
203
+ prefix: labels.prefix ?? '',
204
+ offsets: labels.offsets,
205
+ };
206
+ }
207
+
192
208
  // ---------------------------------------------------------------------------
193
209
  // Spec-level normalization
194
210
  // ---------------------------------------------------------------------------
@@ -205,12 +221,7 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar
205
221
  encoding,
206
222
  chrome: normalizeChrome(spec.chrome),
207
223
  annotations: normalizeAnnotations(spec.annotations),
208
- labels: {
209
- density: spec.labels?.density ?? 'auto',
210
- format: spec.labels?.format ?? '',
211
- prefix: spec.labels?.prefix ?? '',
212
- offsets: spec.labels?.offsets,
213
- },
224
+ labels: normalizeLabels(spec.labels),
214
225
  legend: spec.legend,
215
226
  responsive: spec.responsive ?? true,
216
227
  theme: spec.theme ?? {},
@@ -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
  }