@opendata-ai/openchart-engine 7.1.2 → 7.1.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": "7.1.2",
3
+ "version": "7.1.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": "7.1.2",
51
+ "@opendata-ai/openchart-core": "7.1.3",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -5,17 +5,11 @@
5
5
  * not from the chart area. Density thinning lives in ./thinning.ts.
6
6
  */
7
7
 
8
- import type {
9
- AxisLabelDensity,
10
- AxisTick,
11
- DataRow,
12
- MeasureTextFn,
13
- } from '@opendata-ai/openchart-core';
8
+ import type { AxisLabelDensity, AxisTick, DataRow } from '@opendata-ai/openchart-core';
14
9
  import {
15
10
  abbreviateNumber,
16
11
  buildD3Formatter,
17
12
  buildTemporalFormatter,
18
- estimateTextWidth,
19
13
  formatDate,
20
14
  formatNumber,
21
15
  } from '@opendata-ai/openchart-core';
@@ -249,11 +243,6 @@ export function categoricalTicks(
249
243
  resolvedScale: ResolvedScale,
250
244
  density: AxisLabelDensity,
251
245
  orientation: 'horizontal' | 'vertical' = 'horizontal',
252
- bandwidth?: number,
253
- labelAngle?: number,
254
- fontSize?: number,
255
- fontWeight?: number,
256
- measureText?: MeasureTextFn,
257
246
  subtitleContext?: { data: DataRow[]; fieldName: string; labelField: string },
258
247
  ): AxisTick[] {
259
248
  const scale = resolvedScale.scale as D3CategoricalScale;
@@ -264,41 +253,13 @@ export function categoricalTicks(
264
253
  let selectedValues = domain;
265
254
 
266
255
  if (resolvedScale.type === 'band' && orientation === 'horizontal') {
267
- // Geometry-based thinning: check whether labels actually fit within the
268
- // bandwidth before deciding to thin. Rotated labels have a smaller
269
- // horizontal footprint (width * |cos(angle)|), so they can be much denser.
270
- if (bandwidth !== undefined && bandwidth > 0 && fontSize !== undefined) {
271
- const maxLabelWidth = domain.reduce((max, v) => {
272
- const w = measureText
273
- ? measureText(v, fontSize, fontWeight ?? 400).width
274
- : estimateTextWidth(v, fontSize, fontWeight ?? 400);
275
- return Math.max(max, w);
276
- }, 0);
277
-
278
- // At non-zero angles, horizontal footprint per label = width * |cos(angle)|
279
- const angleRad = labelAngle !== undefined ? (Math.abs(labelAngle) * Math.PI) / 180 : 0;
280
- const footprint = angleRad > 0 ? maxLabelWidth * Math.abs(Math.cos(angleRad)) : maxLabelWidth;
281
- const minGap = fontSize * 0.5;
282
-
283
- if (footprint + minGap > bandwidth) {
284
- // Labels don't fit -- thin proportionally to bandwidth, not density tier
285
- const maxFitting = Math.max(1, Math.floor(bandwidth / (footprint + minGap)));
286
- // Still respect explicit tickCount as an upper bound
287
- const cap =
288
- explicitTickCount ?? Math.min(domain.length, Math.max(maxFitting, TICK_COUNTS[density]));
289
- if (domain.length > cap) {
290
- const step = Math.ceil(domain.length / cap);
291
- selectedValues = domain.filter((_: string, i: number) => i % step === 0);
292
- }
293
- }
294
- // else: labels fit at this bandwidth -- show all of them
295
- } else {
296
- // No geometry info: fall back to density-count cap (original behavior)
297
- const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
298
- if ((explicitTickCount || density !== 'full') && domain.length > maxTicks) {
299
- const step = Math.ceil(domain.length / maxTicks);
300
- selectedValues = domain.filter((_: string, i: number) => i % step === 0);
301
- }
256
+ // Horizontal band scales delegate thinning to the caller (computeAxes)
257
+ // which knows the effective label angle after auto-rotation. Only apply
258
+ // an explicit tickCount cap here; density-based thinning happens
259
+ // downstream in thinBandTicksIfNeeded where rotation is accounted for.
260
+ if (explicitTickCount && domain.length > explicitTickCount) {
261
+ const step = Math.ceil(domain.length / explicitTickCount);
262
+ selectedValues = domain.filter((_: string, i: number) => i % step === 0);
302
263
  }
303
264
  } else if (resolvedScale.type !== 'band') {
304
265
  // Point/ordinal scales: thin by density count
@@ -190,6 +190,60 @@ function fitContinuousTicks(
190
190
  return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
191
191
  }
192
192
 
193
+ /**
194
+ * Check whether band-scale tick labels overlap at a given rotation angle,
195
+ * using position-based detection. Rotated labels extend diagonally past
196
+ * their band boundaries without colliding, so we check actual footprint
197
+ * along the axis rather than comparing against bandwidth.
198
+ */
199
+ function bandTicksOverlapAtAngle(
200
+ ticks: AxisTick[],
201
+ angleDeg: number,
202
+ fontSize: number,
203
+ fontWeight: number,
204
+ measureText?: MeasureTextFn,
205
+ ): boolean {
206
+ if (ticks.length < 2) return false;
207
+ const angleRad = (Math.abs(angleDeg) * Math.PI) / 180;
208
+ const cosA = angleRad > 0 ? Math.abs(Math.cos(angleRad)) : 1;
209
+ const minGap = fontSize * 0.5;
210
+ for (let i = 0; i < ticks.length - 1; i++) {
211
+ const aWidth = measureLabel(ticks[i].label, fontSize, fontWeight, measureText) * cosA;
212
+ const bWidth = measureLabel(ticks[i + 1].label, fontSize, fontWeight, measureText) * cosA;
213
+ const aRight = ticks[i].position + aWidth / 2;
214
+ const bLeft = ticks[i + 1].position - bWidth / 2;
215
+ if (aRight + minGap > bLeft) return true;
216
+ }
217
+ return false;
218
+ }
219
+
220
+ /**
221
+ * Thin band-scale tick labels only when they actually overlap at their
222
+ * effective angle. Most grouped bar charts keep every label even at -45°.
223
+ * Only extremely dense charts (50+ categories) will thin.
224
+ */
225
+ function thinBandTicksIfNeeded(
226
+ ticks: AxisTick[],
227
+ angleDeg: number,
228
+ fontSize: number,
229
+ fontWeight: number,
230
+ measureText?: MeasureTextFn,
231
+ ): AxisTick[] {
232
+ if (!bandTicksOverlapAtAngle(ticks, angleDeg, fontSize, fontWeight, measureText)) return ticks;
233
+
234
+ let current = ticks;
235
+ while (current.length > 2) {
236
+ const thinned = [current[0]];
237
+ for (let i = 2; i < current.length - 1; i += 2) {
238
+ thinned.push(current[i]);
239
+ }
240
+ if (current.length > 1) thinned.push(current[current.length - 1]);
241
+ current = thinned;
242
+ if (!bandTicksOverlapAtAngle(current, angleDeg, fontSize, fontWeight, measureText)) break;
243
+ }
244
+ return current;
245
+ }
246
+
193
247
  // ---------------------------------------------------------------------------
194
248
  // Public API
195
249
  // ---------------------------------------------------------------------------
@@ -291,18 +345,9 @@ export function computeAxes(
291
345
  if (axisConfig?.values) {
292
346
  allTicks = resolveExplicitTicks(axisConfig.values, scales.x);
293
347
  } else if (!isContinuousX) {
294
- const xBandwidth =
295
- scales.x.type === 'band' ? (scales.x.scale as ScaleBand<string>).bandwidth() : undefined;
296
- allTicks = categoricalTicks(
297
- scales.x,
298
- xDensity,
299
- 'horizontal',
300
- xBandwidth,
301
- axisConfig?.labelAngle,
302
- fontSize,
303
- fontWeight,
304
- measureText,
305
- );
348
+ // For band scales, generate all ticks first (no thinning). Rotation and
349
+ // thinning are resolved below once we know the effective label angle.
350
+ allTicks = categoricalTicks(scales.x, xDensity, 'horizontal');
306
351
  } else {
307
352
  allTicks = continuousTicks(scales.x, xDensity, xTargetCount);
308
353
  }
@@ -314,18 +359,32 @@ export function computeAxes(
314
359
  major: true,
315
360
  }));
316
361
 
317
- // Thin tick labels to prevent overlap (skip for band scales which use
318
- // auto-rotation, and when the user set explicit tick values).
319
- // When tickCount is set, we still thin if D3 overshot the requested count
320
- // (common with log scales where ticks(4) can return 26 values).
362
+ // Determine rotation before thinning so we know the effective label
363
+ // footprint. Band scales auto-rotate when horizontal labels don't fit.
364
+ let tickAngle = axisConfig?.labelAngle;
365
+ if (tickAngle === undefined && scales.x.type === 'band' && allTicks.length > 1) {
366
+ const bandwidth = (scales.x.scale as ScaleBand<string>).bandwidth();
367
+ let maxLabelWidth = 0;
368
+ for (const t of allTicks) {
369
+ const w = measureLabel(t.label, fontSize, fontWeight, measureText);
370
+ if (w > maxLabelWidth) maxLabelWidth = w;
371
+ }
372
+ if (maxLabelWidth > bandwidth * 0.85) {
373
+ tickAngle = -45;
374
+ }
375
+ }
376
+
377
+ // Thin tick labels to prevent overlap (skip for explicit tick values).
321
378
  const hasExplicitValues = !!axisConfig?.values;
322
- const shouldThin = scales.x.type !== 'band' && !hasExplicitValues;
323
379
  let ticks: AxisTick[];
324
- if (!shouldThin) {
380
+ if (hasExplicitValues) {
325
381
  ticks = allTicks;
382
+ } else if (scales.x.type === 'band') {
383
+ // Band scales: thin only when labels actually overlap at their
384
+ // effective angle. After rotation, most charts have room for every label.
385
+ const effectiveAngle = tickAngle ?? 0;
386
+ ticks = thinBandTicksIfNeeded(allTicks, effectiveAngle, fontSize, fontWeight, measureText);
326
387
  } else if (isContinuousX) {
327
- // Continuous x-axis: re-request ticks at a lower count on overlap so
328
- // time-scale quartile/monthly jumps don't leave a too-dense axis.
329
388
  ticks = fitContinuousTicks(
330
389
  scales.x,
331
390
  allTicks,
@@ -340,22 +399,6 @@ export function computeAxes(
340
399
  ticks = thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText);
341
400
  }
342
401
 
343
- // Auto-rotate labels when band scale labels would overlap.
344
- // Uses max label width (not average) since one long label is enough to overlap.
345
- let tickAngle = axisConfig?.labelAngle;
346
- if (tickAngle === undefined && scales.x.type === 'band' && ticks.length > 1) {
347
- const bandwidth = (scales.x.scale as ScaleBand<string>).bandwidth();
348
- let maxLabelWidth = 0;
349
- for (const t of ticks) {
350
- const w = measureLabel(t.label, fontSize, fontWeight, measureText);
351
- if (w > maxLabelWidth) maxLabelWidth = w;
352
- }
353
- // If the widest label exceeds 85% of the bandwidth, rotate to avoid overlap
354
- if (maxLabelWidth > bandwidth * 0.85) {
355
- tickAngle = -45;
356
- }
357
- }
358
-
359
402
  const axisTitle = axisConfig?.title;
360
403
  const xLabelColor = axisConfig?.labelColor;
361
404
  // X-axis defaults to gutter (no inline mode is sensible for the x axis
@@ -404,11 +447,6 @@ export function computeAxes(
404
447
  scales.y,
405
448
  yDensity,
406
449
  'vertical',
407
- undefined,
408
- undefined,
409
- undefined,
410
- undefined,
411
- undefined,
412
450
  yFieldName && yLabelField && dataContext
413
451
  ? { data: dataContext.data, fieldName: yFieldName, labelField: yLabelField }
414
452
  : undefined,