@opendata-ai/openchart-engine 2.3.4 → 2.4.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 +56 -5
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +185 -1
- package/src/__tests__/compile-chart.test.ts +52 -0
- package/src/__tests__/dimensions.test.ts +68 -0
- package/src/layout/axes.ts +76 -5
- package/src/layout/dimensions.ts +28 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"typecheck": "tsc --noEmit"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@opendata-ai/openchart-core": "2.
|
|
48
|
+
"@opendata-ai/openchart-core": "2.4.0",
|
|
49
49
|
"d3-array": "^3.2.0",
|
|
50
50
|
"d3-format": "^3.1.2",
|
|
51
51
|
"d3-interpolate": "^3.0.0",
|
|
@@ -2,7 +2,7 @@ import type { LayoutStrategy } from '@opendata-ai/openchart-core';
|
|
|
2
2
|
import { resolveTheme } from '@opendata-ai/openchart-core';
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
4
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
5
|
-
import { computeAxes } from '../layout/axes';
|
|
5
|
+
import { computeAxes, effectiveDensity } from '../layout/axes';
|
|
6
6
|
import { computeScales } from '../layout/scales';
|
|
7
7
|
|
|
8
8
|
const lineSpec: NormalizedChartSpec = {
|
|
@@ -111,4 +111,188 @@ describe('computeAxes', () => {
|
|
|
111
111
|
expect(axes.y!.start.x).toBe(chartArea.x);
|
|
112
112
|
expect(axes.y!.end.x).toBe(chartArea.x);
|
|
113
113
|
});
|
|
114
|
+
|
|
115
|
+
// -------------------------------------------------------------------------
|
|
116
|
+
// Height-aware y-axis tick reduction
|
|
117
|
+
// -------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
it('reduces y-axis ticks for very short chart areas (< 120px)', () => {
|
|
120
|
+
const shortArea = { x: 50, y: 50, width: 500, height: 80 };
|
|
121
|
+
const scales = computeScales(lineSpec, shortArea, lineSpec.data);
|
|
122
|
+
const axesShort = computeAxes(scales, shortArea, fullStrategy, theme);
|
|
123
|
+
|
|
124
|
+
// Even though the strategy says 'full', height < 120 forces minimal (3 ticks)
|
|
125
|
+
expect(axesShort.y!.ticks.length).toBeLessThanOrEqual(3);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('reduces y-axis ticks for medium-short chart areas (120-200px)', () => {
|
|
129
|
+
const mediumArea = { x: 50, y: 50, width: 500, height: 160 };
|
|
130
|
+
const tallArea = { x: 50, y: 50, width: 500, height: 400 };
|
|
131
|
+
|
|
132
|
+
const scalesMedium = computeScales(lineSpec, mediumArea, lineSpec.data);
|
|
133
|
+
const scalesTall = computeScales(lineSpec, tallArea, lineSpec.data);
|
|
134
|
+
|
|
135
|
+
const axesMedium = computeAxes(scalesMedium, mediumArea, fullStrategy, theme);
|
|
136
|
+
const axesTall = computeAxes(scalesTall, tallArea, fullStrategy, theme);
|
|
137
|
+
|
|
138
|
+
// Medium height should have fewer ticks than a tall chart with same 'full' density
|
|
139
|
+
expect(axesMedium.y!.ticks.length).toBeLessThanOrEqual(axesTall.y!.ticks.length);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('does not increase y-axis ticks beyond base density for short charts', () => {
|
|
143
|
+
const shortArea = { x: 50, y: 50, width: 500, height: 80 };
|
|
144
|
+
const scales = computeScales(lineSpec, shortArea, lineSpec.data);
|
|
145
|
+
|
|
146
|
+
// Strategy already says minimal - short height shouldn't change anything
|
|
147
|
+
const axes = computeAxes(scales, shortArea, minimalStrategy, theme);
|
|
148
|
+
expect(axes.y!.ticks.length).toBeLessThanOrEqual(3);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// -------------------------------------------------------------------------
|
|
152
|
+
// Width-aware x-axis tick reduction
|
|
153
|
+
// -------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
it('reduces x-axis ticks for very narrow chart areas (< 150px)', () => {
|
|
156
|
+
const narrowArea = { x: 50, y: 50, width: 100, height: 300 };
|
|
157
|
+
const scales = computeScales(lineSpec, narrowArea, lineSpec.data);
|
|
158
|
+
const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
|
|
159
|
+
|
|
160
|
+
// Width < 150 forces minimal density for x-axis
|
|
161
|
+
expect(axes.x!.ticks.length).toBeLessThanOrEqual(3);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('reduces x-axis ticks for medium-narrow chart areas (150-300px)', () => {
|
|
165
|
+
const mediumArea = { x: 50, y: 50, width: 250, height: 300 };
|
|
166
|
+
const wideArea = { x: 50, y: 50, width: 600, height: 300 };
|
|
167
|
+
|
|
168
|
+
const scalesMedium = computeScales(lineSpec, mediumArea, lineSpec.data);
|
|
169
|
+
const scalesWide = computeScales(lineSpec, wideArea, lineSpec.data);
|
|
170
|
+
|
|
171
|
+
const axesMedium = computeAxes(scalesMedium, mediumArea, fullStrategy, theme);
|
|
172
|
+
const axesWide = computeAxes(scalesWide, wideArea, fullStrategy, theme);
|
|
173
|
+
|
|
174
|
+
expect(axesMedium.x!.ticks.length).toBeLessThanOrEqual(axesWide.x!.ticks.length);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// -------------------------------------------------------------------------
|
|
178
|
+
// Both axes constrained simultaneously (thumbnail scenario)
|
|
179
|
+
// -------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
it('reduces ticks on both axes for thumbnail-sized charts', () => {
|
|
182
|
+
const thumbnailArea = { x: 10, y: 10, width: 120, height: 80 };
|
|
183
|
+
const fullArea = { x: 50, y: 50, width: 600, height: 400 };
|
|
184
|
+
|
|
185
|
+
const scalesThumb = computeScales(lineSpec, thumbnailArea, lineSpec.data);
|
|
186
|
+
const scalesFull = computeScales(lineSpec, fullArea, lineSpec.data);
|
|
187
|
+
|
|
188
|
+
const axesThumb = computeAxes(scalesThumb, thumbnailArea, fullStrategy, theme);
|
|
189
|
+
const axesFull = computeAxes(scalesFull, fullArea, fullStrategy, theme);
|
|
190
|
+
|
|
191
|
+
// Both axes should have minimal ticks in a thumbnail
|
|
192
|
+
expect(axesThumb.x!.ticks.length).toBeLessThanOrEqual(3);
|
|
193
|
+
expect(axesThumb.y!.ticks.length).toBeLessThanOrEqual(3);
|
|
194
|
+
|
|
195
|
+
// And fewer than full-size
|
|
196
|
+
expect(axesThumb.x!.ticks.length).toBeLessThanOrEqual(axesFull.x!.ticks.length);
|
|
197
|
+
expect(axesThumb.y!.ticks.length).toBeLessThanOrEqual(axesFull.y!.ticks.length);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// -------------------------------------------------------------------------
|
|
201
|
+
// tickAngle propagation
|
|
202
|
+
// -------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
it('propagates tickAngle from encoding to x-axis layout', () => {
|
|
205
|
+
const specWithAngle: NormalizedChartSpec = {
|
|
206
|
+
...lineSpec,
|
|
207
|
+
type: 'column',
|
|
208
|
+
data: [
|
|
209
|
+
{ cat: 'California', val: 10 },
|
|
210
|
+
{ cat: 'New York', val: 20 },
|
|
211
|
+
],
|
|
212
|
+
encoding: {
|
|
213
|
+
x: { field: 'cat', type: 'nominal', axis: { tickAngle: -90 } },
|
|
214
|
+
y: { field: 'val', type: 'quantitative' },
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
const scales = computeScales(specWithAngle, chartArea, specWithAngle.data);
|
|
218
|
+
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
219
|
+
|
|
220
|
+
expect(axes.x!.tickAngle).toBe(-90);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('leaves tickAngle undefined when not specified', () => {
|
|
224
|
+
const scales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
225
|
+
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
226
|
+
|
|
227
|
+
expect(axes.x!.tickAngle).toBeUndefined();
|
|
228
|
+
expect(axes.y!.tickAngle).toBeUndefined();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('propagates tickAngle to y-axis layout', () => {
|
|
232
|
+
const specWithAngle: NormalizedChartSpec = {
|
|
233
|
+
...lineSpec,
|
|
234
|
+
encoding: {
|
|
235
|
+
x: { field: 'date', type: 'temporal' },
|
|
236
|
+
y: { field: 'value', type: 'quantitative', axis: { tickAngle: -45 } },
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
const scales = computeScales(specWithAngle, chartArea, specWithAngle.data);
|
|
240
|
+
const axes = computeAxes(scales, chartArea, fullStrategy, theme);
|
|
241
|
+
|
|
242
|
+
expect(axes.y!.tickAngle).toBe(-45);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// effectiveDensity unit tests
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
describe('effectiveDensity', () => {
|
|
251
|
+
const MINIMAL_THRESHOLD = 120;
|
|
252
|
+
const REDUCED_THRESHOLD = 200;
|
|
253
|
+
|
|
254
|
+
it('returns base density when axis length exceeds all thresholds', () => {
|
|
255
|
+
expect(effectiveDensity('full', 500, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('full');
|
|
256
|
+
expect(effectiveDensity('reduced', 500, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
|
|
257
|
+
expect(effectiveDensity('minimal', 500, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('forces minimal density below the minimal threshold', () => {
|
|
261
|
+
expect(effectiveDensity('full', 80, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
|
|
262
|
+
expect(effectiveDensity('reduced', 80, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
|
|
263
|
+
expect(effectiveDensity('minimal', 80, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('caps at reduced density between thresholds', () => {
|
|
267
|
+
// 'full' base should step down to 'reduced'
|
|
268
|
+
expect(effectiveDensity('full', 160, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('does not increase density beyond base when between thresholds', () => {
|
|
272
|
+
// 'minimal' base should stay 'minimal' even between thresholds
|
|
273
|
+
expect(effectiveDensity('minimal', 160, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
|
|
274
|
+
// 'reduced' base should stay 'reduced'
|
|
275
|
+
expect(effectiveDensity('reduced', 160, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('handles exact threshold boundaries', () => {
|
|
279
|
+
// At exactly the minimal threshold, we're NOT below it
|
|
280
|
+
expect(effectiveDensity('full', 120, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
|
|
281
|
+
// At exactly the reduced threshold, we're NOT below it
|
|
282
|
+
expect(effectiveDensity('full', 200, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('full');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('handles zero and negative lengths', () => {
|
|
286
|
+
expect(effectiveDensity('full', 0, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
|
|
287
|
+
expect(effectiveDensity('full', -10, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('works with custom thresholds (x-axis uses different values)', () => {
|
|
291
|
+
const X_MINIMAL = 150;
|
|
292
|
+
const X_REDUCED = 300;
|
|
293
|
+
|
|
294
|
+
expect(effectiveDensity('full', 100, X_MINIMAL, X_REDUCED)).toBe('minimal');
|
|
295
|
+
expect(effectiveDensity('full', 200, X_MINIMAL, X_REDUCED)).toBe('reduced');
|
|
296
|
+
expect(effectiveDensity('full', 400, X_MINIMAL, X_REDUCED)).toBe('full');
|
|
297
|
+
});
|
|
114
298
|
});
|
|
@@ -457,4 +457,56 @@ describe('compileGraph', () => {
|
|
|
457
457
|
),
|
|
458
458
|
).toThrow('compileGraph received a scatter spec');
|
|
459
459
|
});
|
|
460
|
+
|
|
461
|
+
it('propagates tickAngle through the full compilation pipeline', () => {
|
|
462
|
+
const columnSpec = {
|
|
463
|
+
type: 'column' as const,
|
|
464
|
+
data: [
|
|
465
|
+
{ state: 'California', pop: 39000000 },
|
|
466
|
+
{ state: 'Texas', pop: 29000000 },
|
|
467
|
+
{ state: 'Florida', pop: 22000000 },
|
|
468
|
+
{ state: 'New York', pop: 20000000 },
|
|
469
|
+
{ state: 'Pennsylvania', pop: 13000000 },
|
|
470
|
+
],
|
|
471
|
+
encoding: {
|
|
472
|
+
x: { field: 'state', type: 'nominal' as const, axis: { tickAngle: -90 } },
|
|
473
|
+
y: { field: 'pop', type: 'quantitative' as const },
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const layout = compileChart(columnSpec, { width: 400, height: 300 });
|
|
478
|
+
|
|
479
|
+
// tickAngle should be propagated to the x-axis layout
|
|
480
|
+
expect(layout.axes.x!.tickAngle).toBe(-90);
|
|
481
|
+
// y-axis should not have a tickAngle
|
|
482
|
+
expect(layout.axes.y!.tickAngle).toBeUndefined();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('reserves extra bottom margin for rotated x-axis labels', () => {
|
|
486
|
+
const baseColumnSpec = {
|
|
487
|
+
type: 'column' as const,
|
|
488
|
+
data: [
|
|
489
|
+
{ state: 'California', pop: 39000000 },
|
|
490
|
+
{ state: 'Texas', pop: 29000000 },
|
|
491
|
+
{ state: 'Massachusetts', pop: 7000000 },
|
|
492
|
+
],
|
|
493
|
+
encoding: {
|
|
494
|
+
x: { field: 'state', type: 'nominal' as const },
|
|
495
|
+
y: { field: 'pop', type: 'quantitative' as const },
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
const rotatedColumnSpec = {
|
|
499
|
+
...baseColumnSpec,
|
|
500
|
+
encoding: {
|
|
501
|
+
x: { field: 'state', type: 'nominal' as const, axis: { tickAngle: -90 } },
|
|
502
|
+
y: { field: 'pop', type: 'quantitative' as const },
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const layoutNormal = compileChart(baseColumnSpec, { width: 400, height: 300 });
|
|
507
|
+
const layoutRotated = compileChart(rotatedColumnSpec, { width: 400, height: 300 });
|
|
508
|
+
|
|
509
|
+
// Rotated labels should shrink the chart area height
|
|
510
|
+
expect(layoutRotated.area.height).toBeLessThan(layoutNormal.area.height);
|
|
511
|
+
});
|
|
460
512
|
});
|
|
@@ -148,4 +148,72 @@ describe('computeDimensions', () => {
|
|
|
148
148
|
expect(dims.chartArea.width).toBeGreaterThanOrEqual(0);
|
|
149
149
|
expect(dims.chartArea.height).toBeGreaterThanOrEqual(0);
|
|
150
150
|
});
|
|
151
|
+
|
|
152
|
+
it('reserves extra bottom space for rotated x-axis labels', () => {
|
|
153
|
+
const rotatedSpec: NormalizedChartSpec = {
|
|
154
|
+
...baseSpec,
|
|
155
|
+
type: 'column',
|
|
156
|
+
data: [
|
|
157
|
+
{ category: 'California', value: 10 },
|
|
158
|
+
{ category: 'New York', value: 20 },
|
|
159
|
+
{ category: 'Massachusetts', value: 15 },
|
|
160
|
+
],
|
|
161
|
+
encoding: {
|
|
162
|
+
x: { field: 'category', type: 'nominal', axis: { tickAngle: -90 } },
|
|
163
|
+
y: { field: 'value', type: 'quantitative' },
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
const normalSpec: NormalizedChartSpec = {
|
|
167
|
+
...baseSpec,
|
|
168
|
+
type: 'column',
|
|
169
|
+
data: rotatedSpec.data,
|
|
170
|
+
encoding: {
|
|
171
|
+
x: { field: 'category', type: 'nominal' },
|
|
172
|
+
y: { field: 'value', type: 'quantitative' },
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const dimsRotated = computeDimensions(
|
|
177
|
+
rotatedSpec,
|
|
178
|
+
{ width: 600, height: 400 },
|
|
179
|
+
emptyLegend,
|
|
180
|
+
lightTheme,
|
|
181
|
+
);
|
|
182
|
+
const dimsNormal = computeDimensions(
|
|
183
|
+
normalSpec,
|
|
184
|
+
{ width: 600, height: 400 },
|
|
185
|
+
emptyLegend,
|
|
186
|
+
lightTheme,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// Rotated labels should reserve more bottom space, shrinking the chart area
|
|
190
|
+
expect(dimsRotated.chartArea.height).toBeLessThan(dimsNormal.chartArea.height);
|
|
191
|
+
expect(dimsRotated.margins.bottom).toBeGreaterThan(dimsNormal.margins.bottom);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('does not change bottom space for small tick angles', () => {
|
|
195
|
+
const smallAngleSpec: NormalizedChartSpec = {
|
|
196
|
+
...baseSpec,
|
|
197
|
+
encoding: {
|
|
198
|
+
x: { field: 'date', type: 'temporal', axis: { tickAngle: 5 } },
|
|
199
|
+
y: { field: 'value', type: 'quantitative' },
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const dimsSmall = computeDimensions(
|
|
204
|
+
smallAngleSpec,
|
|
205
|
+
{ width: 600, height: 400 },
|
|
206
|
+
emptyLegend,
|
|
207
|
+
lightTheme,
|
|
208
|
+
);
|
|
209
|
+
const dimsNone = computeDimensions(
|
|
210
|
+
baseSpec,
|
|
211
|
+
{ width: 600, height: 400 },
|
|
212
|
+
emptyLegend,
|
|
213
|
+
lightTheme,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// Small angles (< 10 degrees) should not trigger rotated label logic
|
|
217
|
+
expect(dimsSmall.margins.bottom).toBe(dimsNone.margins.bottom);
|
|
218
|
+
});
|
|
151
219
|
});
|
package/src/layout/axes.ts
CHANGED
|
@@ -35,6 +35,60 @@ const TICK_COUNTS: Record<AxisLabelDensity, number> = {
|
|
|
35
35
|
minimal: 3,
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Height thresholds for reducing y-axis tick density.
|
|
40
|
+
* Below these pixel heights, we step down the density regardless of the
|
|
41
|
+
* width-based strategy. This prevents overlapping y-axis labels in short
|
|
42
|
+
* containers like thumbnail previews.
|
|
43
|
+
*/
|
|
44
|
+
const HEIGHT_MINIMAL_THRESHOLD = 120;
|
|
45
|
+
const HEIGHT_REDUCED_THRESHOLD = 200;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Width thresholds for reducing x-axis tick density.
|
|
49
|
+
* Mirrors the height logic for the x-axis: narrow containers get fewer ticks.
|
|
50
|
+
*/
|
|
51
|
+
const WIDTH_MINIMAL_THRESHOLD = 150;
|
|
52
|
+
const WIDTH_REDUCED_THRESHOLD = 300;
|
|
53
|
+
|
|
54
|
+
/** Ordered densities from most to fewest ticks. */
|
|
55
|
+
const DENSITY_ORDER: AxisLabelDensity[] = ['full', 'reduced', 'minimal'];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compute effective axis tick density by considering available space.
|
|
59
|
+
*
|
|
60
|
+
* The width-based breakpoint system sets a base density, but it doesn't know
|
|
61
|
+
* about the actual chart area dimensions (which shrink after chrome/legend
|
|
62
|
+
* allocation). This function steps density down further when the axis
|
|
63
|
+
* dimension is too small for the requested tick count.
|
|
64
|
+
*
|
|
65
|
+
* @param baseDensity - The density from the responsive layout strategy.
|
|
66
|
+
* @param axisLength - Available pixels along this axis (height for y, width for x).
|
|
67
|
+
* @param minimalThreshold - Below this pixel size, force minimal density.
|
|
68
|
+
* @param reducedThreshold - Below this pixel size, cap at reduced density.
|
|
69
|
+
* @returns The effective density, never looser than the base.
|
|
70
|
+
*/
|
|
71
|
+
export function effectiveDensity(
|
|
72
|
+
baseDensity: AxisLabelDensity,
|
|
73
|
+
axisLength: number,
|
|
74
|
+
minimalThreshold: number,
|
|
75
|
+
reducedThreshold: number,
|
|
76
|
+
): AxisLabelDensity {
|
|
77
|
+
let density = baseDensity;
|
|
78
|
+
|
|
79
|
+
if (axisLength < minimalThreshold) {
|
|
80
|
+
density = 'minimal';
|
|
81
|
+
} else if (axisLength < reducedThreshold) {
|
|
82
|
+
// Don't increase density beyond what the base strategy allows.
|
|
83
|
+
// If base is already 'minimal', keep it.
|
|
84
|
+
const baseIdx = DENSITY_ORDER.indexOf(baseDensity);
|
|
85
|
+
const reducedIdx = DENSITY_ORDER.indexOf('reduced');
|
|
86
|
+
density = DENSITY_ORDER[Math.max(baseIdx, reducedIdx)];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return density;
|
|
90
|
+
}
|
|
91
|
+
|
|
38
92
|
// ---------------------------------------------------------------------------
|
|
39
93
|
// Tick generation
|
|
40
94
|
// ---------------------------------------------------------------------------
|
|
@@ -127,7 +181,22 @@ export function computeAxes(
|
|
|
127
181
|
theme: ResolvedTheme,
|
|
128
182
|
): AxesResult {
|
|
129
183
|
const result: AxesResult = {};
|
|
130
|
-
const
|
|
184
|
+
const baseDensity = strategy.axisLabelDensity;
|
|
185
|
+
|
|
186
|
+
// Compute per-axis density based on available space.
|
|
187
|
+
// Y-axis density adapts to chart height; X-axis density adapts to chart width.
|
|
188
|
+
const yDensity = effectiveDensity(
|
|
189
|
+
baseDensity,
|
|
190
|
+
chartArea.height,
|
|
191
|
+
HEIGHT_MINIMAL_THRESHOLD,
|
|
192
|
+
HEIGHT_REDUCED_THRESHOLD,
|
|
193
|
+
);
|
|
194
|
+
const xDensity = effectiveDensity(
|
|
195
|
+
baseDensity,
|
|
196
|
+
chartArea.width,
|
|
197
|
+
WIDTH_MINIMAL_THRESHOLD,
|
|
198
|
+
WIDTH_REDUCED_THRESHOLD,
|
|
199
|
+
);
|
|
131
200
|
|
|
132
201
|
const tickLabelStyle: TextStyle = {
|
|
133
202
|
fontFamily: theme.fonts.family,
|
|
@@ -149,8 +218,8 @@ export function computeAxes(
|
|
|
149
218
|
if (scales.x) {
|
|
150
219
|
const ticks =
|
|
151
220
|
scales.x.type === 'band' || scales.x.type === 'point' || scales.x.type === 'ordinal'
|
|
152
|
-
? categoricalTicks(scales.x,
|
|
153
|
-
: continuousTicks(scales.x,
|
|
221
|
+
? categoricalTicks(scales.x, xDensity)
|
|
222
|
+
: continuousTicks(scales.x, xDensity);
|
|
154
223
|
|
|
155
224
|
const gridlines: Gridline[] = ticks.map((t) => ({
|
|
156
225
|
position: t.position,
|
|
@@ -163,6 +232,7 @@ export function computeAxes(
|
|
|
163
232
|
label: scales.x.channel.axis?.label,
|
|
164
233
|
labelStyle: axisLabelStyle,
|
|
165
234
|
tickLabelStyle,
|
|
235
|
+
tickAngle: scales.x.channel.axis?.tickAngle,
|
|
166
236
|
start: { x: chartArea.x, y: chartArea.y + chartArea.height },
|
|
167
237
|
end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height },
|
|
168
238
|
};
|
|
@@ -171,8 +241,8 @@ export function computeAxes(
|
|
|
171
241
|
if (scales.y) {
|
|
172
242
|
const ticks =
|
|
173
243
|
scales.y.type === 'band' || scales.y.type === 'point' || scales.y.type === 'ordinal'
|
|
174
|
-
? categoricalTicks(scales.y,
|
|
175
|
-
: continuousTicks(scales.y,
|
|
244
|
+
? categoricalTicks(scales.y, yDensity)
|
|
245
|
+
: continuousTicks(scales.y, yDensity);
|
|
176
246
|
|
|
177
247
|
const gridlines: Gridline[] = ticks.map((t) => ({
|
|
178
248
|
position: t.position,
|
|
@@ -186,6 +256,7 @@ export function computeAxes(
|
|
|
186
256
|
label: scales.y.channel.axis?.label,
|
|
187
257
|
labelStyle: axisLabelStyle,
|
|
188
258
|
tickLabelStyle,
|
|
259
|
+
tickAngle: scales.y.channel.axis?.tickAngle,
|
|
189
260
|
start: { x: chartArea.x, y: chartArea.y },
|
|
190
261
|
end: { x: chartArea.x, y: chartArea.y + chartArea.height },
|
|
191
262
|
};
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -90,8 +90,34 @@ export function computeDimensions(
|
|
|
90
90
|
// Estimate x-axis height below chart area: tick labels sit 14px below,
|
|
91
91
|
// axis title sits 35px below. These extend past the chart area bottom
|
|
92
92
|
// and source/footer chrome must be positioned below them.
|
|
93
|
-
const
|
|
94
|
-
const
|
|
93
|
+
const xAxis = encoding.x?.axis as (Record<string, unknown> & { tickAngle?: number }) | undefined;
|
|
94
|
+
const hasXAxisLabel = !!xAxis?.label;
|
|
95
|
+
const xTickAngle = xAxis?.tickAngle;
|
|
96
|
+
|
|
97
|
+
let xAxisHeight: number;
|
|
98
|
+
if (isRadial) {
|
|
99
|
+
xAxisHeight = 0;
|
|
100
|
+
} else if (xTickAngle && Math.abs(xTickAngle) > 10) {
|
|
101
|
+
// Rotated labels: estimate height from the longest label text.
|
|
102
|
+
// At -90 degrees, the label height = text width. At -45, it's width * sin(45).
|
|
103
|
+
const angleRad = Math.abs(xTickAngle) * (Math.PI / 180);
|
|
104
|
+
const xField = encoding.x?.field;
|
|
105
|
+
let maxLabelWidth = 40; // fallback
|
|
106
|
+
if (xField) {
|
|
107
|
+
for (const row of spec.data) {
|
|
108
|
+
const label = String(row[xField] ?? '');
|
|
109
|
+
const w = estimateTextWidth(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
|
|
110
|
+
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Rotated label height: width * sin(angle), plus a small gap
|
|
114
|
+
const rotatedHeight = maxLabelWidth * Math.sin(angleRad) + 6;
|
|
115
|
+
// Cap at a reasonable max to avoid absurd margins
|
|
116
|
+
const labelHeight = Math.min(rotatedHeight, 120);
|
|
117
|
+
xAxisHeight = hasXAxisLabel ? labelHeight + 20 : labelHeight;
|
|
118
|
+
} else {
|
|
119
|
+
xAxisHeight = hasXAxisLabel ? 48 : 26;
|
|
120
|
+
}
|
|
95
121
|
|
|
96
122
|
// Build margins: padding + chrome + axis space
|
|
97
123
|
const margins: Margins = {
|