@opendata-ai/openchart-engine 7.9.3 → 7.10.0

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.9.3",
3
+ "version": "7.10.0",
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.9.3",
51
+ "@opendata-ai/openchart-core": "7.10.0",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -1365,3 +1365,168 @@ describe('quantitative axis minimum tick floor', () => {
1365
1365
  }
1366
1366
  });
1367
1367
  });
1368
+
1369
+ // ---------------------------------------------------------------------------
1370
+ // Compact temporal tick formats: pinned pre-change behavior
1371
+ // ---------------------------------------------------------------------------
1372
+ // These two tests pin the exact tick labels produced BEFORE the compact
1373
+ // temporal format rung lands in fitContinuousTicks. They must stay green
1374
+ // through that change:
1375
+ // - wide desktop axes have room for full-format labels, so compact never fires
1376
+ // - temporal y ticks are never compact (planner/renderer gutter agreement;
1377
+ // see plans/compact-temporal-ticks.md)
1378
+
1379
+ describe('compact temporal ticks: pinned pre-change output', () => {
1380
+ const monthlyData = Array.from({ length: 12 }, (_, i) => ({
1381
+ date: `2025-${String(i + 1).padStart(2, '0')}-01`,
1382
+ value: 100 + i * 10,
1383
+ }));
1384
+
1385
+ const monthlySpec: NormalizedChartSpec = {
1386
+ ...lineSpec,
1387
+ data: monthlyData,
1388
+ };
1389
+
1390
+ it('desktop unchanged: monthly temporal x at ~900px keeps full-format labels', () => {
1391
+ const wideArea = { x: 50, y: 50, width: 900, height: 300 };
1392
+ const scales = computeScales(monthlySpec, wideArea, monthlySpec.data);
1393
+ const axes = computeAxes(scales, wideArea, fullStrategy, theme);
1394
+
1395
+ expect(axes.x!.ticks.map((t) => t.label)).toEqual(['2025', 'Apr 2025', 'Jul 2025', 'Oct 2025']);
1396
+ });
1397
+
1398
+ it('temporal y-axis unaffected: labels at ~360px stay full-format', () => {
1399
+ const ySpec: NormalizedChartSpec = {
1400
+ ...monthlySpec,
1401
+ markType: 'point',
1402
+ markDef: { type: 'point' },
1403
+ encoding: {
1404
+ x: { field: 'value', type: 'quantitative' },
1405
+ y: { field: 'date', type: 'temporal' },
1406
+ },
1407
+ };
1408
+ const narrowArea = { x: 40, y: 40, width: 360, height: 300 };
1409
+ const scales = computeScales(ySpec, narrowArea, ySpec.data);
1410
+ const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
1411
+
1412
+ expect(axes.y!.ticks.map((t) => t.label)).toEqual(['2025', 'Apr 2025', 'Jul 2025', 'Oct 2025']);
1413
+ });
1414
+
1415
+ it('temporal y-axis stays full-format even at starved heights (fallback path)', () => {
1416
+ // Regression guard for the fallback path: compact is x-axis-only (the
1417
+ // planner/renderer y pair must format identically), so even a height so
1418
+ // short that fitContinuousTicks falls through to thinning must keep the
1419
+ // year on every label — never a bare compact month like 'Oct'.
1420
+ const ySpec: NormalizedChartSpec = {
1421
+ ...monthlySpec,
1422
+ markType: 'point',
1423
+ markDef: { type: 'point' },
1424
+ encoding: {
1425
+ x: { field: 'value', type: 'quantitative' },
1426
+ y: { field: 'date', type: 'temporal' },
1427
+ },
1428
+ };
1429
+ const starvedArea = { x: 40, y: 40, width: 360, height: 55 };
1430
+ const scales = computeScales(ySpec, starvedArea, ySpec.data);
1431
+ const axes = computeAxes(scales, starvedArea, fullStrategy, theme);
1432
+
1433
+ const labels = axes.y!.ticks.map((t) => t.label);
1434
+ expect(labels.length).toBeGreaterThanOrEqual(2);
1435
+ for (const label of labels) {
1436
+ expect(label).toMatch(/\d{4}/);
1437
+ }
1438
+ });
1439
+ });
1440
+
1441
+ // ---------------------------------------------------------------------------
1442
+ // Compact temporal tick formats: the fitting rung
1443
+ // ---------------------------------------------------------------------------
1444
+ // When full-format labels overlap on a temporal x-axis, fitContinuousTicks
1445
+ // tries the compact format table at the SAME count before dropping ticks.
1446
+ // Pre-change counts below were measured by running these exact inputs against
1447
+ // the unmodified engine (git stash) — see plans/compact-temporal-ticks.md.
1448
+
1449
+ describe('compact temporal ticks: fitting rung', () => {
1450
+ const monthlySpecOver = (
1451
+ months: number,
1452
+ axis?: Record<string, unknown>,
1453
+ ): NormalizedChartSpec => ({
1454
+ ...lineSpec,
1455
+ data: Array.from({ length: months }, (_, i) => ({
1456
+ date: `2025-${String(i + 1).padStart(2, '0')}-01`,
1457
+ value: 100 + i * 10,
1458
+ })),
1459
+ encoding: {
1460
+ x: { field: 'date', type: 'temporal', ...(axis ? { axis } : {}) },
1461
+ y: { field: 'value', type: 'quantitative' },
1462
+ },
1463
+ });
1464
+
1465
+ const xLabelsAt = (spec: NormalizedChartSpec, width: number): string[] => {
1466
+ const area = { x: 40, y: 40, width, height: 300 };
1467
+ const scales = computeScales(spec, area, spec.data);
1468
+ const axes = computeAxes(scales, area, fullStrategy, theme);
1469
+ return axes.x!.ticks.map((t) => t.label);
1470
+ };
1471
+
1472
+ const COMPACT_MONTH = /^[A-Z][a-z]{2}$/; // 'Feb'
1473
+ const FULL_MONTH = /^[A-Z][a-z]{2} \d{4}$/; // 'Feb 2025'
1474
+ const YEAR_ONLY = /^\d{4}$/; // year-boundary tick, same in both tables
1475
+
1476
+ it('keeps more ticks than pre-change when full labels overlap (Jan-Dec 2025 at 160px)', () => {
1477
+ const labels = xLabelsAt(monthlySpecOver(12), 160);
1478
+
1479
+ // Pre-change output at this width: ['2025', 'Oct 2025'] — the overlap
1480
+ // forced a count step-down to 2. Compact keeps all 4 quarterly ticks.
1481
+ expect(labels).toEqual(['2025', 'Apr', 'Jul', 'Oct']);
1482
+ expect(labels.length).toBeGreaterThan(2);
1483
+
1484
+ // No label carries a year except year-boundary ticks.
1485
+ for (const label of labels) {
1486
+ if (/\d{4}/.test(label)) expect(label).toMatch(YEAR_ONLY);
1487
+ }
1488
+ });
1489
+
1490
+ it('renders bare compact months at monthly tick granularity (Jan-Apr 2025 at 160px)', () => {
1491
+ // End the domain mid-April: a domain ending exactly on Apr 1 includes the
1492
+ // Apr tick only when the runner's timezone is UTC (local time scales place
1493
+ // month boundaries at local midnight, so negative offsets push Apr 1 past
1494
+ // the domain end).
1495
+ const base = monthlySpecOver(4);
1496
+ const spec = { ...base, data: [...base.data, { date: '2025-04-15', value: 145 }] };
1497
+ const labels = xLabelsAt(spec, 160);
1498
+
1499
+ // Pre-change output at this width: ['2025', 'Mar 2025'] (2 ticks).
1500
+ expect(labels).toEqual(['2025', 'Feb', 'Mar', 'Apr']);
1501
+ expect(labels).toContain('Feb');
1502
+ });
1503
+
1504
+ it('refit stays internally consistent when D3 overshoots the requested count', () => {
1505
+ // Jan-May 2025: D3 returns 5 monthly ticks for a request of 3 (> the 1.5x
1506
+ // overshoot tolerance), forcing the refit loop. Whatever count/format the
1507
+ // loop lands on, the returned set must be ONE format family — never a mix
1508
+ // of 'Feb' and 'Mar 2025'.
1509
+ const spec = monthlySpecOver(5);
1510
+ for (const width of [100, 140, 180, 240]) {
1511
+ const labels = xLabelsAt(spec, width);
1512
+ const monthLabels = labels.filter((l) => !YEAR_ONLY.test(l));
1513
+ expect(monthLabels.length, `width ${width}`).toBeGreaterThan(0);
1514
+ const allCompact = monthLabels.every((l) => COMPACT_MONTH.test(l));
1515
+ const allFull = monthLabels.every((l) => FULL_MONTH.test(l));
1516
+ expect(
1517
+ allCompact || allFull,
1518
+ `mixed format families at width ${width}: ${JSON.stringify(labels)}`,
1519
+ ).toBe(true);
1520
+ }
1521
+ });
1522
+
1523
+ it('explicit axis.format wins over compact at narrow width', () => {
1524
+ const labels = xLabelsAt(monthlySpecOver(12, { format: '%Y-%m' }), 160);
1525
+
1526
+ expect(labels.length).toBeGreaterThan(0);
1527
+ for (const label of labels) {
1528
+ expect(label).toMatch(/^\d{4}-\d{2}$/);
1529
+ }
1530
+ expect(labels).toEqual(['2025-01', '2025-10']);
1531
+ });
1532
+ });
@@ -123,16 +123,17 @@ const NUMERIC_SCALE_TYPES = new Set([
123
123
  const TEMPORAL_SCALE_TYPES = new Set(['time', 'utc']);
124
124
 
125
125
  /** Format a tick value based on the scale type. */
126
- function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
126
+ function formatTickLabel(value: unknown, resolvedScale: ResolvedScale, compact = false): string {
127
127
  const axisConfig = resolvedScale.channel.axis || undefined;
128
128
  const formatStr = axisConfig?.format;
129
129
  const suffix = axisConfig?.labelSuffix ?? '';
130
130
 
131
131
  if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
132
+ // Explicit user axis.format always wins, compact or not.
132
133
  const temporalFmt = buildTemporalFormatter(formatStr);
133
134
  if (temporalFmt) return temporalFmt(value as Date) + suffix;
134
135
  const useUtc = resolvedScale.type === 'utc';
135
- return formatDate(value as Date, undefined, undefined, useUtc) + suffix;
136
+ return formatDate(value as Date, undefined, undefined, useUtc, compact) + suffix;
136
137
  }
137
138
 
138
139
  if (NUMERIC_SCALE_TYPES.has(resolvedScale.type)) {
@@ -188,8 +189,15 @@ export function continuousTicks(
188
189
  * requesting a smaller `n` never produces squished neighbors — unlike
189
190
  * "keep first+last, drop middle" pruning which can stack the last tick
190
191
  * next to an endpoint and cascade to 2 ticks.
192
+ *
193
+ * `compact` switches temporal labels to the compact format table (bare '%b'
194
+ * months etc.); numeric and string labels ignore it.
191
195
  */
192
- export function buildContinuousTicks(resolvedScale: ResolvedScale, count: number): AxisTick[] {
196
+ export function buildContinuousTicks(
197
+ resolvedScale: ResolvedScale,
198
+ count: number,
199
+ compact = false,
200
+ ): AxisTick[] {
193
201
  const scale = resolvedScale.scale as D3ContinuousScale;
194
202
  if (!('ticks' in scale) || typeof scale.ticks !== 'function') {
195
203
  return continuousTicks(resolvedScale, 'full');
@@ -220,7 +228,7 @@ export function buildContinuousTicks(resolvedScale: ResolvedScale, count: number
220
228
  return ticks.map((value: unknown) => ({
221
229
  value,
222
230
  position: scale(value as number & Date) as number,
223
- label: formatTickLabel(value, resolvedScale),
231
+ label: formatTickLabel(value, resolvedScale, compact),
224
232
  }));
225
233
  }
226
234
 
@@ -315,7 +323,14 @@ export function categoricalTicks(
315
323
  return ticks;
316
324
  }
317
325
 
318
- /** Resolve explicit tick values from axis config into positioned ticks. */
326
+ /**
327
+ * Resolve explicit tick values from axis config into positioned ticks.
328
+ *
329
+ * Deliberately never compact: this renders the layout planner's precomputed
330
+ * ticks, and planner/renderer gutter agreement holds only because both sides
331
+ * format identically. Making this compact without making the planner compact
332
+ * recreates the reserved-space divergence bug class behind the 7.9.x saga.
333
+ */
319
334
  export function resolveExplicitTicks(values: unknown[], resolvedScale: ResolvedScale): AxisTick[] {
320
335
  const scale = resolvedScale.scale;
321
336
  return values.map((value) => {
@@ -208,13 +208,14 @@ function ensureMinContinuousTicks(
208
208
  scale: ResolvedScales['x' | 'y'],
209
209
  ticks: AxisTick[],
210
210
  requestedCount: number,
211
+ compact = false,
211
212
  ): AxisTick[] {
212
213
  if (ticks.length >= MIN_QUANTITATIVE_TICKS) return ticks;
213
214
  if (!scale || !scaleSupportsTickCount(scale)) return ticks;
214
215
 
215
216
  let best = ticks;
216
217
  for (let n = Math.max(requestedCount, MIN_QUANTITATIVE_TICKS) + 1; n <= 10; n++) {
217
- const candidate = buildContinuousTicks(scale, n);
218
+ const candidate = buildContinuousTicks(scale, n, compact);
218
219
  if (candidate.length >= MIN_QUANTITATIVE_TICKS) return candidate;
219
220
  if (candidate.length > best.length) best = candidate;
220
221
  }
@@ -260,6 +261,27 @@ function fitContinuousTicks(
260
261
  const overlaps = ticksOverlap(flooredTicks, fontSize, fontWeight, measureText, orientation);
261
262
  if (!overshoots && !overlaps) return flooredTicks;
262
263
 
264
+ // Compact temporal formats are a rung between "full labels overlap" and
265
+ // "drop a tick": at each candidate count, prefer the full format, then try
266
+ // the compact table at the SAME count before stepping the count down. Every
267
+ // returned set is internally consistent — one format family per layout pass.
268
+ // X-axes only: the planner/renderer y pair must keep formatting identically
269
+ // (see resolveExplicitTicks), so vertical axes never compact — including
270
+ // the fallback path below.
271
+ const useCompact =
272
+ (scale.type === 'time' || scale.type === 'utc') && orientation === 'horizontal';
273
+
274
+ const fitsCleanly = (candidate: AxisTick[]): boolean =>
275
+ candidate.length <= tolerance &&
276
+ !ticksOverlap(candidate, fontSize, fontWeight, measureText, orientation);
277
+
278
+ // Compact-saves-the-count case: full format overlapped at the initial
279
+ // count, but compact labels may fit without giving up a single tick.
280
+ if (useCompact) {
281
+ const compactAtInitial = buildContinuousTicks(scale, initialCount, true);
282
+ if (fitsCleanly(compactAtInitial)) return compactAtInitial;
283
+ }
284
+
263
285
  // Enforce the floor only when the axis is long enough to fit that many
264
286
  // labels without overlap. Very short axes can fall below.
265
287
  const minThreshold =
@@ -268,29 +290,32 @@ function fitContinuousTicks(
268
290
 
269
291
  // Track the smallest candidate that meets the floor. If no candidate fits
270
292
  // cleanly, we thin this instead of the overshot `initialTicks` so the
271
- // fallback doesn't reintroduce the cascading-to-2-ticks bug.
293
+ // fallback doesn't reintroduce the cascading-to-2-ticks bug. Bookkeeping
294
+ // runs against the full-format candidate, exactly as before the compact rung.
272
295
  let bestWithinFloor: AxisTick[] | undefined;
296
+ let bestWithinFloorCount: number | undefined;
273
297
  for (let n = initialCount - 1; n >= 2; n--) {
274
298
  const candidate = buildContinuousTicks(scale, n);
275
- const candidateOvershoots = candidate.length > tolerance;
276
- const candidateOverlaps = ticksOverlap(
277
- candidate,
278
- fontSize,
279
- fontWeight,
280
- measureText,
281
- orientation,
282
- );
283
- if (!candidateOvershoots && !candidateOverlaps) {
284
- return candidate;
299
+ if (fitsCleanly(candidate)) return candidate;
300
+ if (useCompact) {
301
+ const compact = buildContinuousTicks(scale, n, true);
302
+ if (fitsCleanly(compact)) return compact;
303
+ }
304
+ if (candidate.length >= floor) {
305
+ bestWithinFloor = candidate;
306
+ bestWithinFloorCount = n;
285
307
  }
286
- if (candidate.length >= floor) bestWithinFloor = candidate;
287
308
  }
288
309
 
289
310
  // No candidate fit cleanly. Thin whatever most recently met the floor; if
290
311
  // nothing did, synthesize a floor-count set directly from the scale so we
291
312
  // never hand the overshot initialTicks to the middle-pruning thinner.
292
- const rawFallback = bestWithinFloor ?? buildContinuousTicks(scale, floor);
293
- const fallback = ensureMinContinuousTicks(scale, rawFallback, floor);
313
+ // Compacting axes rebuild the fallback compact: if the axis is so starved
314
+ // that we're thinning below the floor, shorter labels are strictly better.
315
+ const rawFallback = useCompact
316
+ ? buildContinuousTicks(scale, bestWithinFloorCount ?? floor, true)
317
+ : (bestWithinFloor ?? buildContinuousTicks(scale, floor));
318
+ const fallback = ensureMinContinuousTicks(scale, rawFallback, floor, useCompact);
294
319
  return thinTicksUntilFit(fallback, fontSize, fontWeight, measureText, orientation);
295
320
  }
296
321