@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/dist/index.js +23 -19
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +165 -0
- package/src/layout/axes/ticks.ts +20 -5
- package/src/layout/axes.ts +40 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "7.
|
|
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.
|
|
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
|
+
});
|
package/src/layout/axes/ticks.ts
CHANGED
|
@@ -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(
|
|
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
|
-
/**
|
|
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) => {
|
package/src/layout/axes.ts
CHANGED
|
@@ -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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
|