@opendata-ai/openchart-engine 2.9.1 → 2.11.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 +317 -62
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +4 -0
- package/src/__tests__/axes.test.ts +183 -2
- package/src/__tests__/legend.test.ts +4 -0
- package/src/annotations/__tests__/compute.test.ts +173 -4
- package/src/annotations/compute.ts +158 -41
- package/src/charts/column/__tests__/labels.test.ts +104 -0
- package/src/charts/dot/__tests__/labels.test.ts +98 -0
- package/src/charts/pie/__tests__/labels.test.ts +132 -0
- package/src/compile.ts +63 -13
- package/src/layout/axes.ts +131 -11
- package/src/layout/dimensions.ts +77 -4
- package/src/legend/compute.ts +105 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.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.11.0",
|
|
49
49
|
"d3-array": "^3.2.0",
|
|
50
50
|
"d3-format": "^3.1.2",
|
|
51
51
|
"d3-interpolate": "^3.0.0",
|
|
@@ -24,6 +24,8 @@ export function makeFullStrategy(): LayoutStrategy {
|
|
|
24
24
|
legendPosition: 'right',
|
|
25
25
|
annotationPosition: 'inline',
|
|
26
26
|
axisLabelDensity: 'full',
|
|
27
|
+
chromeMode: 'full',
|
|
28
|
+
legendMaxHeight: -1,
|
|
27
29
|
};
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -34,6 +36,8 @@ export function makeCompactStrategy(): LayoutStrategy {
|
|
|
34
36
|
legendPosition: 'top',
|
|
35
37
|
annotationPosition: 'tooltip-only',
|
|
36
38
|
axisLabelDensity: 'minimal',
|
|
39
|
+
chromeMode: 'full',
|
|
40
|
+
legendMaxHeight: -1,
|
|
37
41
|
};
|
|
38
42
|
}
|
|
39
43
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { LayoutStrategy } from '@opendata-ai/openchart-core';
|
|
1
|
+
import type { AxisTick, 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, effectiveDensity } from '../layout/axes';
|
|
5
|
+
import { computeAxes, effectiveDensity, thinTicksUntilFit, ticksOverlap } from '../layout/axes';
|
|
6
6
|
import { computeScales } from '../layout/scales';
|
|
7
7
|
|
|
8
8
|
const lineSpec: NormalizedChartSpec = {
|
|
@@ -32,6 +32,8 @@ const fullStrategy: LayoutStrategy = {
|
|
|
32
32
|
legendPosition: 'right',
|
|
33
33
|
annotationPosition: 'inline',
|
|
34
34
|
axisLabelDensity: 'full',
|
|
35
|
+
chromeMode: 'full',
|
|
36
|
+
legendMaxHeight: -1,
|
|
35
37
|
};
|
|
36
38
|
|
|
37
39
|
const minimalStrategy: LayoutStrategy = {
|
|
@@ -39,6 +41,8 @@ const minimalStrategy: LayoutStrategy = {
|
|
|
39
41
|
legendPosition: 'top',
|
|
40
42
|
annotationPosition: 'tooltip-only',
|
|
41
43
|
axisLabelDensity: 'minimal',
|
|
44
|
+
chromeMode: 'full',
|
|
45
|
+
legendMaxHeight: -1,
|
|
42
46
|
};
|
|
43
47
|
|
|
44
48
|
describe('computeAxes', () => {
|
|
@@ -296,3 +300,180 @@ describe('effectiveDensity', () => {
|
|
|
296
300
|
expect(effectiveDensity('full', 400, X_MINIMAL, X_REDUCED)).toBe('full');
|
|
297
301
|
});
|
|
298
302
|
});
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// ticksOverlap unit tests
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
describe('ticksOverlap', () => {
|
|
309
|
+
const fontSize = 12;
|
|
310
|
+
const fontWeight = 400;
|
|
311
|
+
|
|
312
|
+
it('returns false for empty or single tick', () => {
|
|
313
|
+
expect(ticksOverlap([], fontSize, fontWeight)).toBe(false);
|
|
314
|
+
expect(ticksOverlap([{ value: 0, position: 100, label: 'A' }], fontSize, fontWeight)).toBe(
|
|
315
|
+
false,
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('returns false when ticks are well-spaced', () => {
|
|
320
|
+
const ticks: AxisTick[] = [
|
|
321
|
+
{ value: 0, position: 0, label: 'A' },
|
|
322
|
+
{ value: 1, position: 200, label: 'B' },
|
|
323
|
+
{ value: 2, position: 400, label: 'C' },
|
|
324
|
+
];
|
|
325
|
+
expect(ticksOverlap(ticks, fontSize, fontWeight)).toBe(false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('returns true when ticks are too close together', () => {
|
|
329
|
+
const ticks: AxisTick[] = [
|
|
330
|
+
{ value: 0, position: 0, label: 'January 2025' },
|
|
331
|
+
{ value: 1, position: 30, label: 'February 2025' },
|
|
332
|
+
{ value: 2, position: 60, label: 'March 2025' },
|
|
333
|
+
];
|
|
334
|
+
expect(ticksOverlap(ticks, fontSize, fontWeight)).toBe(true);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('uses measureText when provided', () => {
|
|
338
|
+
const ticks: AxisTick[] = [
|
|
339
|
+
{ value: 0, position: 0, label: 'A' },
|
|
340
|
+
{ value: 1, position: 100, label: 'B' },
|
|
341
|
+
];
|
|
342
|
+
|
|
343
|
+
// With a measureText that reports very wide labels, they should overlap
|
|
344
|
+
const wideMeasure = () => ({ width: 200, height: 12 });
|
|
345
|
+
expect(ticksOverlap(ticks, fontSize, fontWeight, wideMeasure)).toBe(true);
|
|
346
|
+
|
|
347
|
+
// With a measureText that reports very narrow labels, they should not overlap
|
|
348
|
+
const narrowMeasure = () => ({ width: 1, height: 12 });
|
|
349
|
+
expect(ticksOverlap(ticks, fontSize, fontWeight, narrowMeasure)).toBe(false);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// thinTicksUntilFit unit tests
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
describe('thinTicksUntilFit', () => {
|
|
358
|
+
const fontSize = 12;
|
|
359
|
+
const fontWeight = 400;
|
|
360
|
+
|
|
361
|
+
it('returns original array when no overlap', () => {
|
|
362
|
+
const ticks: AxisTick[] = [
|
|
363
|
+
{ value: 0, position: 0, label: 'A' },
|
|
364
|
+
{ value: 1, position: 200, label: 'B' },
|
|
365
|
+
{ value: 2, position: 400, label: 'C' },
|
|
366
|
+
];
|
|
367
|
+
const result = thinTicksUntilFit(ticks, fontSize, fontWeight);
|
|
368
|
+
expect(result).toBe(ticks); // Same reference, not a copy
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('thins overlapping ticks while keeping first and last', () => {
|
|
372
|
+
// Ticks at 10px intervals with long labels that will overlap
|
|
373
|
+
const ticks: AxisTick[] = Array.from({ length: 8 }, (_, i) => ({
|
|
374
|
+
value: i,
|
|
375
|
+
position: i * 10,
|
|
376
|
+
label: 'Long Label Text',
|
|
377
|
+
}));
|
|
378
|
+
|
|
379
|
+
const result = thinTicksUntilFit(ticks, fontSize, fontWeight);
|
|
380
|
+
|
|
381
|
+
// Should have fewer ticks than the original
|
|
382
|
+
expect(result.length).toBeLessThan(ticks.length);
|
|
383
|
+
// Should always keep first and last
|
|
384
|
+
expect(result[0]).toBe(ticks[0]);
|
|
385
|
+
expect(result[result.length - 1]).toBe(ticks[ticks.length - 1]);
|
|
386
|
+
// Should have at least MIN_TICK_COUNT (2)
|
|
387
|
+
expect(result.length).toBeGreaterThanOrEqual(2);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('returns at least 2 ticks even when labels are very wide', () => {
|
|
391
|
+
const ticks: AxisTick[] = [
|
|
392
|
+
{ value: 0, position: 0, label: 'Very Long Label That Is Wide' },
|
|
393
|
+
{ value: 1, position: 5, label: 'Another Very Long Label' },
|
|
394
|
+
{ value: 2, position: 10, label: 'Yet Another Long Label Here' },
|
|
395
|
+
];
|
|
396
|
+
const result = thinTicksUntilFit(ticks, fontSize, fontWeight);
|
|
397
|
+
expect(result.length).toBeGreaterThanOrEqual(2);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
// Text-aware tick density integration tests
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
describe('text-aware tick density', () => {
|
|
406
|
+
it('produces fewer x-axis ticks in narrow charts', () => {
|
|
407
|
+
const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
|
|
408
|
+
const wideArea = { x: 50, y: 50, width: 800, height: 300 };
|
|
409
|
+
|
|
410
|
+
const scalesNarrow = computeScales(lineSpec, narrowArea, lineSpec.data);
|
|
411
|
+
const scalesWide = computeScales(lineSpec, wideArea, lineSpec.data);
|
|
412
|
+
|
|
413
|
+
const axesNarrow = computeAxes(scalesNarrow, narrowArea, fullStrategy, theme);
|
|
414
|
+
const axesWide = computeAxes(scalesWide, wideArea, fullStrategy, theme);
|
|
415
|
+
|
|
416
|
+
expect(axesNarrow.x!.ticks.length).toBeLessThanOrEqual(axesWide.x!.ticks.length);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('does not thin x-axis ticks when explicit tickCount is set', () => {
|
|
420
|
+
const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
|
|
421
|
+
const specWithTickCount: NormalizedChartSpec = {
|
|
422
|
+
...lineSpec,
|
|
423
|
+
encoding: {
|
|
424
|
+
x: { field: 'date', type: 'temporal', axis: { tickCount: 8 } },
|
|
425
|
+
y: { field: 'value', type: 'quantitative' },
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const scales = computeScales(specWithTickCount, narrowArea, specWithTickCount.data);
|
|
430
|
+
const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
|
|
431
|
+
|
|
432
|
+
// With explicit tickCount, the engine should not thin
|
|
433
|
+
// D3 may return fewer than 8 for this small dataset, but the point is
|
|
434
|
+
// thinTicksUntilFit should not be called
|
|
435
|
+
expect(axes.x!.ticks.length).toBeGreaterThan(0);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('band scale shows all categories regardless of width', () => {
|
|
439
|
+
const categories = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo'];
|
|
440
|
+
const barSpec: NormalizedChartSpec = {
|
|
441
|
+
...lineSpec,
|
|
442
|
+
type: 'column',
|
|
443
|
+
data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
|
|
444
|
+
encoding: {
|
|
445
|
+
x: { field: 'cat', type: 'nominal' },
|
|
446
|
+
y: { field: 'val', type: 'quantitative' },
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
|
|
451
|
+
const scales = computeScales(barSpec, narrowArea, barSpec.data);
|
|
452
|
+
const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
|
|
453
|
+
|
|
454
|
+
// Band scales show all categories (auto-rotation handles overlap instead)
|
|
455
|
+
expect(axes.x!.ticks.length).toBe(categories.length);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('passes measureText to auto-rotation detection', () => {
|
|
459
|
+
const barSpec: NormalizedChartSpec = {
|
|
460
|
+
...lineSpec,
|
|
461
|
+
type: 'column',
|
|
462
|
+
data: [
|
|
463
|
+
{ cat: 'A', val: 10 },
|
|
464
|
+
{ cat: 'B', val: 20 },
|
|
465
|
+
],
|
|
466
|
+
encoding: {
|
|
467
|
+
x: { field: 'cat', type: 'nominal' },
|
|
468
|
+
y: { field: 'val', type: 'quantitative' },
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
// measureText that reports very wide labels should trigger rotation
|
|
473
|
+
const wideMeasure = () => ({ width: 1000, height: 12 });
|
|
474
|
+
const scales = computeScales(barSpec, chartArea, barSpec.data);
|
|
475
|
+
const axes = computeAxes(scales, chartArea, fullStrategy, theme, wideMeasure);
|
|
476
|
+
|
|
477
|
+
expect(axes.x!.tickAngle).toBe(-45);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
@@ -40,6 +40,8 @@ const fullStrategy: LayoutStrategy = {
|
|
|
40
40
|
legendPosition: 'right',
|
|
41
41
|
annotationPosition: 'inline',
|
|
42
42
|
axisLabelDensity: 'full',
|
|
43
|
+
chromeMode: 'full',
|
|
44
|
+
legendMaxHeight: -1,
|
|
43
45
|
};
|
|
44
46
|
|
|
45
47
|
const compactStrategy: LayoutStrategy = {
|
|
@@ -47,6 +49,8 @@ const compactStrategy: LayoutStrategy = {
|
|
|
47
49
|
legendPosition: 'top',
|
|
48
50
|
annotationPosition: 'tooltip-only',
|
|
49
51
|
axisLabelDensity: 'minimal',
|
|
52
|
+
chromeMode: 'full',
|
|
53
|
+
legendMaxHeight: -1,
|
|
50
54
|
};
|
|
51
55
|
|
|
52
56
|
describe('computeLegend', () => {
|
|
@@ -354,8 +354,8 @@ describe('computeAnnotations', () => {
|
|
|
354
354
|
// The offset annotation should be shifted by the dx/dy amount
|
|
355
355
|
const dx = withOffset[0].label!.x - withoutOffset[0].label!.x;
|
|
356
356
|
const dy = withOffset[0].label!.y - withoutOffset[0].label!.y;
|
|
357
|
-
expect(dx).
|
|
358
|
-
expect(dy).
|
|
357
|
+
expect(dx).toBeCloseTo(20);
|
|
358
|
+
expect(dy).toBeCloseTo(-30);
|
|
359
359
|
});
|
|
360
360
|
});
|
|
361
361
|
|
|
@@ -514,8 +514,8 @@ describe('computeAnnotations', () => {
|
|
|
514
514
|
|
|
515
515
|
const dx = withOffset[0].label!.x - withoutOffset[0].label!.x;
|
|
516
516
|
const dy = withOffset[0].label!.y - withoutOffset[0].label!.y;
|
|
517
|
-
expect(dx).
|
|
518
|
-
expect(dy).
|
|
517
|
+
expect(dx).toBeCloseTo(20);
|
|
518
|
+
expect(dy).toBeCloseTo(10);
|
|
519
519
|
});
|
|
520
520
|
});
|
|
521
521
|
|
|
@@ -773,4 +773,173 @@ describe('computeAnnotations', () => {
|
|
|
773
773
|
expect(connector.from.x).toBeCloseTo(label.x + 66, 1);
|
|
774
774
|
});
|
|
775
775
|
});
|
|
776
|
+
|
|
777
|
+
// -----------------------------------------------------------------
|
|
778
|
+
// Annotation-to-annotation collision resolution
|
|
779
|
+
// -----------------------------------------------------------------
|
|
780
|
+
|
|
781
|
+
describe('annotation-to-annotation collision', () => {
|
|
782
|
+
it('nudges second annotation when two overlap at the same data point', () => {
|
|
783
|
+
const spec = makeSpec([
|
|
784
|
+
{ type: 'text', x: '2020-01-01', y: 20, text: 'First note' },
|
|
785
|
+
{ type: 'text', x: '2020-01-01', y: 20, text: 'Second note' },
|
|
786
|
+
]);
|
|
787
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
788
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
789
|
+
|
|
790
|
+
expect(annotations).toHaveLength(2);
|
|
791
|
+
|
|
792
|
+
const label1 = annotations[0].label!;
|
|
793
|
+
const label2 = annotations[1].label!;
|
|
794
|
+
|
|
795
|
+
// Both should be visible
|
|
796
|
+
expect(label1.visible).toBe(true);
|
|
797
|
+
expect(label2.visible).toBe(true);
|
|
798
|
+
|
|
799
|
+
// Labels should not overlap: their positions should differ
|
|
800
|
+
const samePosition = label1.x === label2.x && label1.y === label2.y;
|
|
801
|
+
expect(samePosition).toBe(false);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it('nudges second annotation when nearby data points produce overlapping labels', () => {
|
|
805
|
+
const spec = makeSpec([
|
|
806
|
+
{ type: 'text', x: '2020-01-01', y: 20, text: 'First annotation' },
|
|
807
|
+
{ type: 'text', x: '2020-01-01', y: 21, text: 'Second annotation' },
|
|
808
|
+
]);
|
|
809
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
810
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
811
|
+
|
|
812
|
+
expect(annotations).toHaveLength(2);
|
|
813
|
+
|
|
814
|
+
const label1 = annotations[0].label!;
|
|
815
|
+
const label2 = annotations[1].label!;
|
|
816
|
+
|
|
817
|
+
// After collision resolution, bounding boxes should not overlap
|
|
818
|
+
// Check that at least one coordinate differs meaningfully
|
|
819
|
+
const dy = Math.abs(label1.y - label2.y);
|
|
820
|
+
const dx = Math.abs(label1.x - label2.x);
|
|
821
|
+
expect(dx + dy).toBeGreaterThan(5);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it('recomputes connector origin after nudging', () => {
|
|
825
|
+
const spec = makeSpec([
|
|
826
|
+
{ type: 'text', x: '2020-01-01', y: 20, text: 'First note' },
|
|
827
|
+
{ type: 'text', x: '2020-01-01', y: 20, text: 'Second note' },
|
|
828
|
+
]);
|
|
829
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
830
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
831
|
+
|
|
832
|
+
const nudgedLabel = annotations[1].label!;
|
|
833
|
+
|
|
834
|
+
// The nudged annotation should still have a connector
|
|
835
|
+
expect(nudgedLabel.connector).toBeDefined();
|
|
836
|
+
|
|
837
|
+
// The connector "from" should be near the nudged label position, not the original
|
|
838
|
+
const connFrom = nudgedLabel.connector!.from;
|
|
839
|
+
const labelCenterX = nudgedLabel.x;
|
|
840
|
+
const labelCenterY = nudgedLabel.y;
|
|
841
|
+
|
|
842
|
+
// Connector origin should be within a reasonable distance of the label
|
|
843
|
+
const distFromLabel = Math.sqrt(
|
|
844
|
+
(connFrom.x - labelCenterX) ** 2 + (connFrom.y - labelCenterY) ** 2,
|
|
845
|
+
);
|
|
846
|
+
// Should be within the label's bounding box range (text width + height)
|
|
847
|
+
expect(distFromLabel).toBeLessThan(200);
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it('resolves three overlapping annotations without any collision', () => {
|
|
851
|
+
const spec = makeSpec([
|
|
852
|
+
{ type: 'text', x: '2020-01-01', y: 20, text: 'Note A' },
|
|
853
|
+
{ type: 'text', x: '2020-01-01', y: 20, text: 'Note B' },
|
|
854
|
+
{ type: 'text', x: '2020-01-01', y: 20, text: 'Note C' },
|
|
855
|
+
]);
|
|
856
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
857
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
858
|
+
|
|
859
|
+
expect(annotations).toHaveLength(3);
|
|
860
|
+
|
|
861
|
+
// All three should be visible
|
|
862
|
+
for (const ann of annotations) {
|
|
863
|
+
expect(ann.label!.visible).toBe(true);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// All three should have distinct positions
|
|
867
|
+
const positions = annotations.map((a) => `${a.label!.x.toFixed(1)},${a.label!.y.toFixed(1)}`);
|
|
868
|
+
const unique = new Set(positions);
|
|
869
|
+
expect(unique.size).toBe(3);
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it('does not nudge annotations that already have distinct positions', () => {
|
|
873
|
+
const spec = makeSpec([
|
|
874
|
+
{
|
|
875
|
+
type: 'text',
|
|
876
|
+
x: '2020-01-01',
|
|
877
|
+
y: 10,
|
|
878
|
+
text: 'Low point',
|
|
879
|
+
anchor: 'bottom',
|
|
880
|
+
offset: { dx: 0, dy: 40 },
|
|
881
|
+
},
|
|
882
|
+
{
|
|
883
|
+
type: 'text',
|
|
884
|
+
x: '2022-01-01',
|
|
885
|
+
y: 40,
|
|
886
|
+
text: 'High point',
|
|
887
|
+
anchor: 'top',
|
|
888
|
+
offset: { dx: 0, dy: -40 },
|
|
889
|
+
},
|
|
890
|
+
]);
|
|
891
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
892
|
+
|
|
893
|
+
// Compute once to get positions
|
|
894
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
895
|
+
|
|
896
|
+
expect(annotations).toHaveLength(2);
|
|
897
|
+
|
|
898
|
+
// These annotations are far apart, so positions should match what they'd
|
|
899
|
+
// be without collision resolution (i.e., not nudged)
|
|
900
|
+
const specSingle1 = makeSpec([spec.annotations[0]]);
|
|
901
|
+
const specSingle2 = makeSpec([spec.annotations[1]]);
|
|
902
|
+
const single1 = computeAnnotations(specSingle1, scales, chartArea, fullStrategy);
|
|
903
|
+
const single2 = computeAnnotations(specSingle2, scales, chartArea, fullStrategy);
|
|
904
|
+
|
|
905
|
+
expect(annotations[0].label!.x).toBeCloseTo(single1[0].label!.x, 1);
|
|
906
|
+
expect(annotations[0].label!.y).toBeCloseTo(single1[0].label!.y, 1);
|
|
907
|
+
expect(annotations[1].label!.x).toBeCloseTo(single2[0].label!.x, 1);
|
|
908
|
+
expect(annotations[1].label!.y).toBeCloseTo(single2[0].label!.y, 1);
|
|
909
|
+
});
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
// -----------------------------------------------------------------
|
|
913
|
+
// Obstacle avoidance (label bounds as obstacles)
|
|
914
|
+
// -----------------------------------------------------------------
|
|
915
|
+
|
|
916
|
+
describe('obstacle avoidance', () => {
|
|
917
|
+
it('nudges annotation away from an obstacle rect at the same position', () => {
|
|
918
|
+
const spec = makeSpec([{ type: 'text', x: '2020-01-01', y: 20, text: 'Annotation' }]);
|
|
919
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
920
|
+
|
|
921
|
+
// Place an obstacle rect exactly where the annotation would land
|
|
922
|
+
const withoutObstacles = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
923
|
+
const originalLabel = withoutObstacles[0].label!;
|
|
924
|
+
|
|
925
|
+
const obstacle: Rect = {
|
|
926
|
+
x: originalLabel.x - 5,
|
|
927
|
+
y: originalLabel.y - 5,
|
|
928
|
+
width: 80,
|
|
929
|
+
height: 30,
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
const withObstacles = computeAnnotations(spec, scales, chartArea, fullStrategy, false, [
|
|
933
|
+
obstacle,
|
|
934
|
+
]);
|
|
935
|
+
|
|
936
|
+
expect(withObstacles).toHaveLength(1);
|
|
937
|
+
const nudgedLabel = withObstacles[0].label!;
|
|
938
|
+
expect(nudgedLabel.visible).toBe(true);
|
|
939
|
+
|
|
940
|
+
// The annotation should have moved away from the obstacle
|
|
941
|
+
const moved = nudgedLabel.x !== originalLabel.x || nudgedLabel.y !== originalLabel.y;
|
|
942
|
+
expect(moved).toBe(true);
|
|
943
|
+
});
|
|
944
|
+
});
|
|
776
945
|
});
|