@opendata-ai/openchart-engine 2.12.1 → 2.12.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-engine",
3
- "version": "2.12.1",
3
+ "version": "2.12.2",
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": "2.12.1",
48
+ "@opendata-ai/openchart-core": "2.12.2",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -455,6 +455,38 @@ describe('text-aware tick density', () => {
455
455
  expect(axes.x!.ticks.length).toBe(categories.length);
456
456
  });
457
457
 
458
+ it('gridlines survive tick thinning', () => {
459
+ // Force thinning by using a measureText that reports wide labels
460
+ const wideMeasure = () => ({ width: 200, height: 12 });
461
+ const scales = computeScales(lineSpec, chartArea, lineSpec.data);
462
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme, wideMeasure);
463
+
464
+ // Ticks should be thinned (fewer labels) but gridlines should remain at
465
+ // all original tick positions
466
+ expect(axes.y!.gridlines.length).toBeGreaterThanOrEqual(axes.y!.ticks.length);
467
+ // With wide labels forcing thinning, gridlines should outnumber ticks
468
+ if (axes.y!.ticks.length < axes.y!.gridlines.length) {
469
+ // Gridlines retained positions that ticks lost — the fix is working
470
+ expect(axes.y!.gridlines.length).toBeGreaterThan(axes.y!.ticks.length);
471
+ }
472
+ });
473
+
474
+ it('x-axis gridlines survive tick thinning when grid is enabled', () => {
475
+ const specWithGrid: NormalizedChartSpec = {
476
+ ...lineSpec,
477
+ encoding: {
478
+ x: { field: 'date', type: 'temporal', axis: { grid: true } },
479
+ y: { field: 'value', type: 'quantitative' },
480
+ },
481
+ };
482
+
483
+ const wideMeasure = () => ({ width: 200, height: 12 });
484
+ const scales = computeScales(specWithGrid, chartArea, specWithGrid.data);
485
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme, wideMeasure);
486
+
487
+ expect(axes.x!.gridlines.length).toBeGreaterThanOrEqual(axes.x!.ticks.length);
488
+ });
489
+
458
490
  it('passes measureText to auto-rotation detection', () => {
459
491
  const barSpec: NormalizedChartSpec = {
460
492
  ...lineSpec,
@@ -173,13 +173,7 @@ export function thinTicksUntilFit(
173
173
  // ---------------------------------------------------------------------------
174
174
 
175
175
  /** Generate ticks for a continuous scale (linear, time, log). */
176
- function continuousTicks(
177
- resolvedScale: ResolvedScale,
178
- density: AxisLabelDensity,
179
- fontSize: number,
180
- fontWeight: number,
181
- measureText?: MeasureTextFn,
182
- ): AxisTick[] {
176
+ function continuousTicks(resolvedScale: ResolvedScale, density: AxisLabelDensity): AxisTick[] {
183
177
  const scale = resolvedScale.scale as D3ContinuousScale;
184
178
  const explicitCount = resolvedScale.channel.axis?.tickCount;
185
179
  const count = explicitCount ?? TICK_COUNTS[density];
@@ -191,20 +185,11 @@ function continuousTicks(
191
185
  label: formatTickLabel(value, resolvedScale),
192
186
  }));
193
187
 
194
- // Respect explicit tickCount: user asked for this many, don't override
195
- if (explicitCount) return ticks;
196
-
197
- return thinTicksUntilFit(ticks, fontSize, fontWeight, measureText);
188
+ return ticks;
198
189
  }
199
190
 
200
191
  /** Generate ticks for a band/point/ordinal scale. */
201
- function categoricalTicks(
202
- resolvedScale: ResolvedScale,
203
- density: AxisLabelDensity,
204
- fontSize: number,
205
- fontWeight: number,
206
- measureText?: MeasureTextFn,
207
- ): AxisTick[] {
192
+ function categoricalTicks(resolvedScale: ResolvedScale, density: AxisLabelDensity): AxisTick[] {
208
193
  const scale = resolvedScale.scale as D3CategoricalScale;
209
194
  const domain: string[] = scale.domain();
210
195
  const explicitTickCount = resolvedScale.channel.axis?.tickCount;
@@ -232,11 +217,6 @@ function categoricalTicks(
232
217
  };
233
218
  });
234
219
 
235
- // For non-band scales without explicit tickCount, thin based on label width
236
- if (resolvedScale.type !== 'band' && !explicitTickCount) {
237
- return thinTicksUntilFit(ticks, fontSize, fontWeight, measureText);
238
- }
239
-
240
220
  return ticks;
241
221
  }
242
222
 
@@ -328,16 +308,25 @@ export function computeAxes(
328
308
  const { fontWeight } = tickLabelStyle;
329
309
 
330
310
  if (scales.x) {
331
- const ticks =
311
+ const allTicks =
332
312
  scales.x.type === 'band' || scales.x.type === 'point' || scales.x.type === 'ordinal'
333
- ? categoricalTicks(scales.x, xDensity, fontSize, fontWeight, measureText)
334
- : continuousTicks(scales.x, xDensity, fontSize, fontWeight, measureText);
313
+ ? categoricalTicks(scales.x, xDensity)
314
+ : continuousTicks(scales.x, xDensity);
335
315
 
336
- const gridlines: Gridline[] = ticks.map((t) => ({
316
+ // Gridlines use the full tick set so they remain visible even when labels
317
+ // are thinned to prevent overlap.
318
+ const gridlines: Gridline[] = allTicks.map((t) => ({
337
319
  position: t.position,
338
320
  major: true,
339
321
  }));
340
322
 
323
+ // Thin tick labels to prevent overlap (skip for band scales which use
324
+ // auto-rotation, and when the user set an explicit tickCount).
325
+ const shouldThin = scales.x.type !== 'band' && !scales.x.channel.axis?.tickCount;
326
+ const ticks = shouldThin
327
+ ? thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText)
328
+ : allTicks;
329
+
341
330
  // Auto-rotate labels when band scale labels would overlap.
342
331
  // Uses max label width (not average) since one long label is enough to overlap.
343
332
  let tickAngle = scales.x.channel.axis?.tickAngle;
@@ -367,16 +356,23 @@ export function computeAxes(
367
356
  }
368
357
 
369
358
  if (scales.y) {
370
- const ticks =
359
+ const allTicks =
371
360
  scales.y.type === 'band' || scales.y.type === 'point' || scales.y.type === 'ordinal'
372
- ? categoricalTicks(scales.y, yDensity, fontSize, fontWeight, measureText)
373
- : continuousTicks(scales.y, yDensity, fontSize, fontWeight, measureText);
361
+ ? categoricalTicks(scales.y, yDensity)
362
+ : continuousTicks(scales.y, yDensity);
374
363
 
375
- const gridlines: Gridline[] = ticks.map((t) => ({
364
+ // Gridlines use the full tick set (label thinning shouldn't remove gridlines).
365
+ const gridlines: Gridline[] = allTicks.map((t) => ({
376
366
  position: t.position,
377
367
  major: true,
378
368
  }));
379
369
 
370
+ // Thin tick labels to prevent overlap (skip for band scales and explicit tickCount).
371
+ const shouldThin = scales.y.type !== 'band' && !scales.y.channel.axis?.tickCount;
372
+ const ticks = shouldThin
373
+ ? thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText)
374
+ : allTicks;
375
+
380
376
  result.y = {
381
377
  ticks,
382
378
  // Y-axis gridlines are shown by default (standard editorial practice)