@opendata-ai/openchart-vanilla 8.0.0-rc.2 → 8.0.0-rc.21

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.
Files changed (145) hide show
  1. package/dist/{chunk-W34KN7FD.js → chunk-2NAQRP66.js} +2 -2
  2. package/dist/chunk-DCODCLWI.js +234 -0
  3. package/dist/chunk-DCODCLWI.js.map +1 -0
  4. package/dist/{chunk-5EOV5JQZ.js → chunk-EKAUQAAX.js} +50 -46
  5. package/dist/chunk-EKAUQAAX.js.map +1 -0
  6. package/dist/{chunk-ENXSUXC5.js → chunk-KNT5AZ2O.js} +7309 -5490
  7. package/dist/chunk-KNT5AZ2O.js.map +1 -0
  8. package/dist/chunk-RM5XS6NR.js +113 -0
  9. package/dist/chunk-RM5XS6NR.js.map +1 -0
  10. package/dist/chunk-T32UHMZA.js +282 -0
  11. package/dist/chunk-T32UHMZA.js.map +1 -0
  12. package/dist/export-gif.d.ts +113 -0
  13. package/dist/export-gif.js +21 -0
  14. package/dist/export-gif.js.map +1 -0
  15. package/dist/index.d.ts +283 -24
  16. package/dist/index.js +2012 -763
  17. package/dist/index.js.map +1 -1
  18. package/dist/{mount-CUKQLeqD.d.ts → mount-Dvv8NYIv.d.ts} +117 -3
  19. package/dist/simulation-worker.js +83 -2
  20. package/dist/static.js +7 -69
  21. package/dist/static.js.map +1 -1
  22. package/dist/story/index.d.ts +3 -2
  23. package/dist/story/index.js +5 -3
  24. package/dist/story/index.js.map +1 -1
  25. package/dist/styles.css +1 -1
  26. package/dist/styles.css.map +1 -1
  27. package/package.json +14 -6
  28. package/dist/chunk-5EOV5JQZ.js.map +0 -1
  29. package/dist/chunk-ENXSUXC5.js.map +0 -1
  30. package/src/__test-fixtures__/dom.ts +0 -70
  31. package/src/__test-fixtures__/specs.ts +0 -214
  32. package/src/__tests__/animation.test.ts +0 -358
  33. package/src/__tests__/annotations-render.test.ts +0 -201
  34. package/src/__tests__/compound-labels.test.ts +0 -122
  35. package/src/__tests__/continuous-legend.test.ts +0 -91
  36. package/src/__tests__/crosshair.test.ts +0 -130
  37. package/src/__tests__/edit-events.test.ts +0 -902
  38. package/src/__tests__/events.test.ts +0 -387
  39. package/src/__tests__/export.test.ts +0 -241
  40. package/src/__tests__/font-race.test.ts +0 -199
  41. package/src/__tests__/gradient-ids.test.ts +0 -159
  42. package/src/__tests__/gradient-utils.test.ts +0 -125
  43. package/src/__tests__/known-bugs-render.test.ts +0 -183
  44. package/src/__tests__/map.test.ts +0 -293
  45. package/src/__tests__/mount.test.ts +0 -357
  46. package/src/__tests__/pattern-fills.test.ts +0 -105
  47. package/src/__tests__/resize-timing.test.ts +0 -218
  48. package/src/__tests__/sankey.test.ts +0 -133
  49. package/src/__tests__/selection-events.test.ts +0 -869
  50. package/src/__tests__/series-search.test.ts +0 -311
  51. package/src/__tests__/static.test.ts +0 -141
  52. package/src/__tests__/svg-renderer.test.ts +0 -895
  53. package/src/__tests__/table-keyboard.test.ts +0 -361
  54. package/src/__tests__/table-mount.test.ts +0 -639
  55. package/src/__tests__/tilemap.test.ts +0 -158
  56. package/src/__tests__/tooltip.test.ts +0 -337
  57. package/src/__tests__/transition.test.ts +0 -1777
  58. package/src/__tests__/you-draw-it.test.ts +0 -273
  59. package/src/animation-vars.ts +0 -42
  60. package/src/animation.ts +0 -76
  61. package/src/barlist-mount.ts +0 -383
  62. package/src/barlist-renderer.ts +0 -267
  63. package/src/dom-helpers.ts +0 -17
  64. package/src/export.ts +0 -482
  65. package/src/gradient-utils.ts +0 -138
  66. package/src/graph/__tests__/canvas-renderer.test.ts +0 -713
  67. package/src/graph/__tests__/graph-mount.test.ts +0 -213
  68. package/src/graph/__tests__/interaction.test.ts +0 -205
  69. package/src/graph/__tests__/keyboard.test.ts +0 -653
  70. package/src/graph/__tests__/search.test.ts +0 -88
  71. package/src/graph/__tests__/simulation.test.ts +0 -233
  72. package/src/graph/__tests__/spatial-index.test.ts +0 -142
  73. package/src/graph/__tests__/zoom.test.ts +0 -203
  74. package/src/graph/canvas-renderer.ts +0 -747
  75. package/src/graph/interaction.ts +0 -367
  76. package/src/graph/keyboard.ts +0 -208
  77. package/src/graph/search.ts +0 -50
  78. package/src/graph/simulation-worker-url.ts +0 -12
  79. package/src/graph/simulation-worker.ts +0 -278
  80. package/src/graph/simulation.ts +0 -422
  81. package/src/graph/spatial-index.ts +0 -121
  82. package/src/graph/types.ts +0 -47
  83. package/src/graph/worker-protocol.ts +0 -70
  84. package/src/graph/zoom.ts +0 -134
  85. package/src/graph-mount.ts +0 -888
  86. package/src/index.ts +0 -76
  87. package/src/interactions/chart-events.ts +0 -139
  88. package/src/interactions/crosshair.ts +0 -233
  89. package/src/interactions/drag-handler.ts +0 -175
  90. package/src/interactions/editing-drags.ts +0 -515
  91. package/src/interactions/index.ts +0 -25
  92. package/src/interactions/keyboard-nav.ts +0 -111
  93. package/src/interactions/legend-interaction.ts +0 -46
  94. package/src/interactions/selection.ts +0 -280
  95. package/src/interactions/tooltip-events.ts +0 -72
  96. package/src/map-camera.ts +0 -88
  97. package/src/map-mount.ts +0 -701
  98. package/src/map-renderer.ts +0 -338
  99. package/src/map-transition.ts +0 -83
  100. package/src/measure-text.ts +0 -134
  101. package/src/mount.ts +0 -1382
  102. package/src/pattern-utils.ts +0 -110
  103. package/src/renderers/annotations.ts +0 -388
  104. package/src/renderers/axes.ts +0 -351
  105. package/src/renderers/brand.ts +0 -87
  106. package/src/renderers/chrome.ts +0 -196
  107. package/src/renderers/endpoint-labels.ts +0 -150
  108. package/src/renderers/legend.ts +0 -311
  109. package/src/renderers/marks.ts +0 -561
  110. package/src/renderers/metrics.ts +0 -63
  111. package/src/renderers/svg-dom.ts +0 -40
  112. package/src/renderers/table-cells.ts +0 -446
  113. package/src/resize-observer.ts +0 -46
  114. package/src/sankey-mount.ts +0 -622
  115. package/src/sankey-renderer.ts +0 -614
  116. package/src/series-search.ts +0 -392
  117. package/src/static.ts +0 -251
  118. package/src/story/__tests__/camera-math.test.ts +0 -145
  119. package/src/story/__tests__/create-chart-story.test.ts +0 -317
  120. package/src/story/__tests__/progress-math.test.ts +0 -95
  121. package/src/story/__tests__/scroll-driver.test.ts +0 -130
  122. package/src/story/__tests__/tween.test.ts +0 -159
  123. package/src/story/camera-math.ts +0 -125
  124. package/src/story/create-chart-story.ts +0 -353
  125. package/src/story/crossfade.ts +0 -70
  126. package/src/story/index.ts +0 -39
  127. package/src/story/progress-math.ts +0 -85
  128. package/src/story/resolve-camera-target.ts +0 -119
  129. package/src/story/scroll-driver.ts +0 -206
  130. package/src/story/story-camera.ts +0 -49
  131. package/src/story/tween.ts +0 -112
  132. package/src/story/types.ts +0 -76
  133. package/src/svg-ids.ts +0 -23
  134. package/src/svg-renderer.ts +0 -383
  135. package/src/table-keyboard.ts +0 -266
  136. package/src/table-mount.ts +0 -567
  137. package/src/table-renderer.ts +0 -414
  138. package/src/text-edit-overlay.ts +0 -255
  139. package/src/theme-tokens.ts +0 -51
  140. package/src/tilemap-mount.ts +0 -511
  141. package/src/tilemap-renderer.ts +0 -396
  142. package/src/tooltip.ts +0 -177
  143. package/src/transition.ts +0 -2816
  144. package/src/you-draw-it.ts +0 -480
  145. /package/dist/{chunk-W34KN7FD.js.map → chunk-2NAQRP66.js.map} +0 -0
@@ -1,199 +0,0 @@
1
- /**
2
- * Font-race regression tests.
3
- *
4
- * On real devices the primary webfont (Inter via display=swap) swaps in after
5
- * first paint, changing text metrics. These tests cover the two halves of the
6
- * fix: the measurer honors the provided font family, and the mount recompiles
7
- * exactly once when a late FontFaceSet resolves.
8
- */
9
-
10
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11
- import { createContainer } from '../__test-fixtures__/dom';
12
- import { lineSpec } from '../__test-fixtures__/specs';
13
- import { createMeasureText } from '../measure-text';
14
- import { createChart } from '../mount';
15
-
16
- // ---------------------------------------------------------------------------
17
- // Canvas ctx stub: happy-dom's getContext returns null, so stub it to capture
18
- // the font string createMeasureText writes.
19
- // ---------------------------------------------------------------------------
20
-
21
- interface FakeCtx {
22
- font: string;
23
- measureText(text: string): { width: number };
24
- }
25
-
26
- function stubCanvasContext(): FakeCtx {
27
- const ctx: FakeCtx = {
28
- font: '',
29
- measureText: (text: string) => ({ width: text.length * 7 }),
30
- };
31
- vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
32
- ctx as unknown as CanvasRenderingContext2D,
33
- );
34
- return ctx;
35
- }
36
-
37
- // ---------------------------------------------------------------------------
38
- // Controllable FontFaceSet stub. check() reports the font missing; ready is a
39
- // promise we resolve on demand to simulate a late font swap.
40
- // ---------------------------------------------------------------------------
41
-
42
- interface FakeFontFaceSet {
43
- check: () => boolean;
44
- ready: Promise<unknown>;
45
- resolveReady: () => void;
46
- }
47
-
48
- function makeFontFaceSet(loaded: boolean): FakeFontFaceSet {
49
- let resolveReady: () => void = () => {};
50
- const ready = new Promise<void>((resolve) => {
51
- resolveReady = resolve;
52
- });
53
- return {
54
- check: () => loaded,
55
- ready,
56
- resolveReady,
57
- };
58
- }
59
-
60
- function installFonts(set: FakeFontFaceSet | undefined): void {
61
- Object.defineProperty(document, 'fonts', {
62
- configurable: true,
63
- value: set,
64
- });
65
- }
66
-
67
- describe('createMeasureText font family', () => {
68
- afterEach(() => {
69
- vi.restoreAllMocks();
70
- });
71
-
72
- it('uses the provided font family in ctx.font', () => {
73
- const ctx = stubCanvasContext();
74
- const measure = createMeasureText('"My Font", Georgia, serif');
75
-
76
- measure('hello', 14, 600);
77
-
78
- expect(ctx.font).toBe('600 14px "My Font", Georgia, serif');
79
- });
80
-
81
- it('defaults to the Inter fallback stack when no family is given', () => {
82
- const ctx = stubCanvasContext();
83
- const measure = createMeasureText();
84
-
85
- measure('hi', 12);
86
-
87
- expect(ctx.font).toBe('400 12px Inter, sans-serif');
88
- });
89
- });
90
-
91
- describe('createChart font-load recompile', () => {
92
- let container: HTMLDivElement;
93
- const originalFonts = Object.getOwnPropertyDescriptor(document, 'fonts');
94
-
95
- beforeEach(() => {
96
- container = createContainer();
97
- });
98
-
99
- afterEach(() => {
100
- vi.restoreAllMocks();
101
- if (originalFonts) {
102
- Object.defineProperty(document, 'fonts', originalFonts);
103
- } else {
104
- // happy-dom has no document.fonts by default; remove our stub.
105
- Object.defineProperty(document, 'fonts', { configurable: true, value: undefined });
106
- }
107
- document.body.innerHTML = '';
108
- });
109
-
110
- it('marks pending then ready and re-renders once when fonts resolve late', async () => {
111
- const fonts = makeFontFaceSet(false);
112
- installFonts(fonts);
113
-
114
- const chart = createChart(container, lineSpec);
115
-
116
- expect(container.dataset.ocFontsState).toBe('pending');
117
- const genBefore = Number(container.dataset.ocRenderGen);
118
- expect(genBefore).toBeGreaterThanOrEqual(1);
119
-
120
- fonts.resolveReady();
121
- await fonts.ready;
122
- // Let the .then() microtask flush.
123
- await Promise.resolve();
124
-
125
- expect(container.dataset.ocFontsState).toBe('ready');
126
- const genAfter = Number(container.dataset.ocRenderGen);
127
- expect(genAfter).toBe(genBefore + 1);
128
-
129
- chart.destroy();
130
- });
131
-
132
- it('marks ready immediately and does not re-render when fonts already loaded', async () => {
133
- const fonts = makeFontFaceSet(true);
134
- installFonts(fonts);
135
-
136
- const chart = createChart(container, lineSpec);
137
-
138
- expect(container.dataset.ocFontsState).toBe('ready');
139
- const genBefore = Number(container.dataset.ocRenderGen);
140
-
141
- fonts.resolveReady();
142
- await Promise.resolve();
143
-
144
- expect(Number(container.dataset.ocRenderGen)).toBe(genBefore);
145
-
146
- chart.destroy();
147
- });
148
-
149
- it('defers ready until the post-animation recompile, not the deferred resize', async () => {
150
- // Entrance animation running -> resize() defers to pendingResize. The
151
- // fonts-ready flip must wait for the recompile that actually happens on the
152
- // pendingResize replay, so 'ready' never claims final-font layout early.
153
- vi.useFakeTimers();
154
- try {
155
- const fonts = makeFontFaceSet(false);
156
- installFonts(fonts);
157
-
158
- const animatedSpec = { ...lineSpec, animation: true as const };
159
- const chart = createChart(container, animatedSpec);
160
- expect(container.dataset.ocFontsState).toBe('pending');
161
- const genBefore = Number(container.dataset.ocRenderGen);
162
-
163
- fonts.resolveReady();
164
- await fonts.ready;
165
- await Promise.resolve();
166
-
167
- // resize() deferred (animation in flight): no recompile yet, still pending.
168
- expect(container.dataset.ocRenderGen).toBe(String(genBefore));
169
- expect(container.dataset.ocFontsState).toBe('pending');
170
-
171
- // Animation completes -> pendingResize replays -> recompile -> ready.
172
- vi.runAllTimers();
173
- expect(Number(container.dataset.ocRenderGen)).toBe(genBefore + 1);
174
- expect(container.dataset.ocFontsState).toBe('ready');
175
-
176
- chart.destroy();
177
- } finally {
178
- vi.useRealTimers();
179
- }
180
- });
181
-
182
- it('does not re-render after the chart is destroyed', async () => {
183
- const fonts = makeFontFaceSet(false);
184
- installFonts(fonts);
185
-
186
- const chart = createChart(container, lineSpec);
187
- expect(container.dataset.ocFontsState).toBe('pending');
188
- const genBefore = Number(container.dataset.ocRenderGen);
189
-
190
- chart.destroy();
191
-
192
- fonts.resolveReady();
193
- await fonts.ready;
194
- await Promise.resolve();
195
-
196
- // Destroyed chart must not bump the render generation.
197
- expect(Number(container.dataset.ocRenderGen)).toBe(genBefore);
198
- });
199
- });
@@ -1,159 +0,0 @@
1
- /**
2
- * Multi-chart SVG ID uniqueness: gradients AND clip-paths.
3
- *
4
- * Originally locked gradient-counter behavior from commit 73ef048 (fix for
5
- * random-hex gradient collisions). Step 6 of refactor/v7-cohesion unified
6
- * gradient and clip-path ID generation under `nextSvgId` in `svg-ids.ts`,
7
- * so this test now guards both halves.
8
- *
9
- * Why it matters: SVG `url(#id)` resolves against the full document. If two
10
- * charts on the same page generate overlapping IDs, one chart silently
11
- * inherits the other's gradient fill or clip region. The shared monotonic
12
- * counter makes uniqueness unconditional.
13
- */
14
-
15
- import type { ChartSpec, LinearGradient } from '@opendata-ai/openchart-core';
16
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
17
- import { createContainer } from '../__test-fixtures__/dom';
18
- import { createChart } from '../mount';
19
-
20
- const barGradient: LinearGradient = {
21
- gradient: 'linear',
22
- x1: 0,
23
- y1: 0,
24
- x2: 1,
25
- y2: 0,
26
- stops: [
27
- { offset: 0, color: '#1b7fa3', opacity: 0.4 },
28
- { offset: 1, color: '#1b7fa3' },
29
- ],
30
- };
31
-
32
- // A visibly different gradient so charts have distinct gradient defs, not
33
- // identical ones that could be deduped by the gradient key system.
34
- const altGradient: LinearGradient = {
35
- gradient: 'linear',
36
- x1: 0,
37
- y1: 0,
38
- x2: 1,
39
- y2: 0,
40
- stops: [
41
- { offset: 0, color: '#cc3366', opacity: 0.2 },
42
- { offset: 1, color: '#cc3366' },
43
- ],
44
- };
45
-
46
- function makeBarSpec(fill: LinearGradient): ChartSpec {
47
- return {
48
- mark: { type: 'bar', fill },
49
- data: [
50
- { category: 'A', value: 10 },
51
- { category: 'B', value: 20 },
52
- { category: 'C', value: 30 },
53
- ],
54
- encoding: {
55
- x: { field: 'value', type: 'quantitative' },
56
- y: { field: 'category', type: 'nominal' },
57
- },
58
- };
59
- }
60
-
61
- function collectGradientIds(root: HTMLElement): string[] {
62
- const ids: string[] = [];
63
- for (const el of root.querySelectorAll('linearGradient, radialGradient')) {
64
- const id = el.getAttribute('id');
65
- if (id) ids.push(id);
66
- }
67
- return ids;
68
- }
69
-
70
- function collectClipPathIds(root: HTMLElement): string[] {
71
- const ids: string[] = [];
72
- for (const el of root.querySelectorAll('clipPath')) {
73
- const id = el.getAttribute('id');
74
- if (id) ids.push(id);
75
- }
76
- return ids;
77
- }
78
-
79
- describe('SVG ID uniqueness across charts', () => {
80
- let a: HTMLDivElement;
81
- let b: HTMLDivElement;
82
-
83
- beforeEach(() => {
84
- a = createContainer();
85
- b = createContainer();
86
- });
87
-
88
- afterEach(() => {
89
- document.body.innerHTML = '';
90
- });
91
-
92
- it('two charts produce disjoint gradient IDs', () => {
93
- const chartA = createChart(a, makeBarSpec(barGradient));
94
- const chartB = createChart(b, makeBarSpec(altGradient));
95
-
96
- const idsA = collectGradientIds(a);
97
- const idsB = collectGradientIds(b);
98
-
99
- // Each chart must have produced at least one gradient def.
100
- expect(idsA.length).toBeGreaterThan(0);
101
- expect(idsB.length).toBeGreaterThan(0);
102
-
103
- // Union has no duplicates: size of the Set equals the total count.
104
- const all = [...idsA, ...idsB];
105
- expect(new Set(all).size).toBe(all.length);
106
-
107
- // Every generated id follows the locked "oc-grad-N" shape.
108
- for (const id of all) {
109
- expect(id).toMatch(/^oc-grad-\d+$/);
110
- }
111
-
112
- chartA.destroy();
113
- chartB.destroy();
114
- });
115
-
116
- it('two charts produce disjoint clip-path IDs', () => {
117
- // Every chart render creates a <clipPath> for the plot area, so any spec
118
- // works here. Reuse the gradient specs for consistency.
119
- const chartA = createChart(a, makeBarSpec(barGradient));
120
- const chartB = createChart(b, makeBarSpec(altGradient));
121
-
122
- const idsA = collectClipPathIds(a);
123
- const idsB = collectClipPathIds(b);
124
-
125
- expect(idsA.length).toBeGreaterThan(0);
126
- expect(idsB.length).toBeGreaterThan(0);
127
-
128
- const all = [...idsA, ...idsB];
129
- expect(new Set(all).size).toBe(all.length);
130
-
131
- for (const id of all) {
132
- expect(id).toMatch(/^oc-clip-\d+$/);
133
- }
134
-
135
- chartA.destroy();
136
- chartB.destroy();
137
- });
138
-
139
- it('gradient and clip-path IDs never collide across two charts', () => {
140
- // Core guarantee of the unified nextSvgId counter: even though gradients
141
- // and clip-paths use different prefixes, the counter values are shared.
142
- // Collecting every ID from both <defs> trees should yield a strict set.
143
- const chartA = createChart(a, makeBarSpec(barGradient));
144
- const chartB = createChart(b, makeBarSpec(altGradient));
145
-
146
- const all = [
147
- ...collectGradientIds(a),
148
- ...collectClipPathIds(a),
149
- ...collectGradientIds(b),
150
- ...collectClipPathIds(b),
151
- ];
152
-
153
- expect(all.length).toBeGreaterThan(0);
154
- expect(new Set(all).size).toBe(all.length);
155
-
156
- chartA.destroy();
157
- chartB.destroy();
158
- });
159
- });
@@ -1,125 +0,0 @@
1
- import type { GradientDef } from '@opendata-ai/openchart-core';
2
- import { describe, expect, it } from 'vitest';
3
- import { buildGradientDefs, resolveMarkFill } from '../gradient-utils';
4
-
5
- const SVG_NS = 'http://www.w3.org/2000/svg';
6
-
7
- function createDefs(): SVGElement {
8
- return document.createElementNS(SVG_NS, 'defs') as SVGElement;
9
- }
10
-
11
- const linearGrad: GradientDef = {
12
- gradient: 'linear',
13
- stops: [
14
- { offset: 0, color: '#f00' },
15
- { offset: 1, color: '#00f' },
16
- ],
17
- };
18
-
19
- const radialGrad: GradientDef = {
20
- gradient: 'radial',
21
- stops: [
22
- { offset: 0, color: '#fff' },
23
- { offset: 1, color: '#000' },
24
- ],
25
- };
26
-
27
- describe('buildGradientDefs', () => {
28
- it('creates a linearGradient element for a linear gradient fill', () => {
29
- const defs = createDefs();
30
- buildGradientDefs([{ fill: linearGrad }], defs);
31
- const el = defs.querySelector('linearGradient');
32
- expect(el).not.toBeNull();
33
- });
34
-
35
- it('creates a radialGradient element for a radial gradient fill', () => {
36
- const defs = createDefs();
37
- buildGradientDefs([{ fill: radialGrad }], defs);
38
- const el = defs.querySelector('radialGradient');
39
- expect(el).not.toBeNull();
40
- });
41
-
42
- it('deduplicates identical gradients', () => {
43
- const defs = createDefs();
44
- buildGradientDefs([{ fill: linearGrad }, { fill: linearGrad }], defs);
45
- const els = defs.querySelectorAll('linearGradient');
46
- expect(els.length).toBe(1);
47
- });
48
-
49
- it('creates separate gradient elements for different gradients', () => {
50
- const defs = createDefs();
51
- buildGradientDefs([{ fill: linearGrad }, { fill: radialGrad }], defs);
52
- expect(defs.querySelector('linearGradient')).not.toBeNull();
53
- expect(defs.querySelector('radialGradient')).not.toBeNull();
54
- expect(defs.children.length).toBe(2);
55
- });
56
-
57
- it('skips marks with string fills', () => {
58
- const defs = createDefs();
59
- buildGradientDefs([{ fill: '#ff0000' }, { fill: 'steelblue' }], defs);
60
- expect(defs.children.length).toBe(0);
61
- });
62
-
63
- it('handles empty marks array', () => {
64
- const defs = createDefs();
65
- const map = buildGradientDefs([], defs);
66
- expect(map.size).toBe(0);
67
- expect(defs.children.length).toBe(0);
68
- });
69
-
70
- it('sets correct default attributes on linearGradient', () => {
71
- const defs = createDefs();
72
- buildGradientDefs([{ fill: linearGrad }], defs);
73
- const el = defs.querySelector('linearGradient')!;
74
- expect(el.getAttribute('x1')).toBe('0');
75
- expect(el.getAttribute('y1')).toBe('0');
76
- expect(el.getAttribute('x2')).toBe('0');
77
- expect(el.getAttribute('y2')).toBe('1');
78
- });
79
-
80
- it('sets correct stop attributes', () => {
81
- const grad: GradientDef = {
82
- gradient: 'linear',
83
- stops: [
84
- { offset: 0, color: '#f00', opacity: 0.5 },
85
- { offset: 1, color: '#00f' },
86
- ],
87
- };
88
- const defs = createDefs();
89
- buildGradientDefs([{ fill: grad }], defs);
90
- const stops = defs.querySelectorAll('stop');
91
- expect(stops.length).toBe(2);
92
- expect(stops[0].getAttribute('offset')).toBe('0');
93
- expect(stops[0].getAttribute('stop-color')).toBe('#f00');
94
- expect(stops[0].getAttribute('stop-opacity')).toBe('0.5');
95
- expect(stops[1].getAttribute('offset')).toBe('1');
96
- expect(stops[1].getAttribute('stop-color')).toBe('#00f');
97
- expect(stops[1].hasAttribute('stop-opacity')).toBe(false);
98
- });
99
-
100
- it('sets gradientUnits="objectBoundingBox"', () => {
101
- const defs = createDefs();
102
- buildGradientDefs([{ fill: linearGrad }], defs);
103
- const el = defs.querySelector('linearGradient')!;
104
- expect(el.getAttribute('gradientUnits')).toBe('objectBoundingBox');
105
- });
106
- });
107
-
108
- describe('resolveMarkFill', () => {
109
- it('returns the string directly for string fills', () => {
110
- const map = new Map<string, string>();
111
- expect(resolveMarkFill('#ff0000', map)).toBe('#ff0000');
112
- });
113
-
114
- it('returns url(#id) for gradient fills that are in the map', () => {
115
- const defs = createDefs();
116
- const map = buildGradientDefs([{ fill: linearGrad }], defs);
117
- const result = resolveMarkFill(linearGrad, map);
118
- expect(result).toMatch(/^url\(#oc-grad-\d+\)$/);
119
- });
120
-
121
- it('returns #000000 for gradient fills not in the map', () => {
122
- const map = new Map<string, string>();
123
- expect(resolveMarkFill(linearGrad, map)).toBe('#000000');
124
- });
125
- });
@@ -1,183 +0,0 @@
1
- /**
2
- * Red-locked tests for known layout bugs.
3
- *
4
- * These use `test.fails(...)` so they pass today (the assertion is inverted).
5
- * When the underlying bug is fixed, the test will start failing, signaling
6
- * that the red-lock can be converted to a normal passing test.
7
- */
8
-
9
- import type { CategoricalLegendLayout } from '@opendata-ai/openchart-core';
10
- import { estimateTextWidth } from '@opendata-ai/openchart-core';
11
- import { compileChart } from '@opendata-ai/openchart-engine';
12
- import { describe, expect, test } from 'vitest';
13
- import { renderLegend } from '../renderers/legend';
14
-
15
- // ---------------------------------------------------------------------------
16
- // Helpers
17
- // ---------------------------------------------------------------------------
18
-
19
- /** Collect distinct y-attribute values from <text> elements, sorted ascending. */
20
- function distinctLabelYValues(svg: SVGElement): number[] {
21
- const texts = svg.querySelectorAll('text');
22
- const ySet = new Set<number>();
23
- for (const t of texts) {
24
- const y = t.getAttribute('y');
25
- if (y != null) ySet.add(Number(y));
26
- }
27
- return [...ySet].sort((a, b) => a - b);
28
- }
29
-
30
- // ---------------------------------------------------------------------------
31
- // Constants (mirrors packages/engine/src/legend/wrap.ts)
32
- // ---------------------------------------------------------------------------
33
-
34
- const SWATCH_SIZE = 12;
35
- const SWATCH_GAP = 6;
36
- const ENTRY_GAP = 16;
37
- const ENGINE_ROW_HEIGHT = SWATCH_SIZE + 4; // 16 - what the engine reserves
38
- const LEGEND_PADDING = 8;
39
-
40
- // ---------------------------------------------------------------------------
41
- // Fixtures
42
- // ---------------------------------------------------------------------------
43
-
44
- const entries: CategoricalLegendLayout['entries'] = [
45
- { label: 'United States of America', color: '#1f77b4', shape: 'square', active: true },
46
- { label: 'United Kingdom', color: '#ff7f0e', shape: 'square', active: true },
47
- { label: 'Federal Republic of Germany', color: '#2ca02c', shape: 'square', active: true },
48
- { label: 'French Republic', color: '#d62728', shape: 'square', active: true },
49
- { label: 'Kingdom of Spain', color: '#9467bd', shape: 'square', active: true },
50
- { label: 'Republic of Italy', color: '#8c564b', shape: 'square', active: true },
51
- { label: 'Kingdom of Netherlands', color: '#e377c2', shape: 'square', active: true },
52
- { label: 'Swiss Confederation', color: '#7f7f7f', shape: 'square', active: true },
53
- ];
54
-
55
- const labelStyle = {
56
- fontSize: 11,
57
- lineHeight: 1.3,
58
- fontWeight: 400 as const,
59
- fontFamily: 'sans-serif',
60
- fill: '#333',
61
- };
62
-
63
- /**
64
- * Compute row count by simulating the same wrap logic the engine uses
65
- * (measureLegendWrap in packages/engine/src/legend/wrap.ts).
66
- */
67
- function computeRowCount(maxWidth: number): number {
68
- let rowCount = 1;
69
- let rowWidth = 0;
70
- for (const entry of entries) {
71
- const labelWidth = estimateTextWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
72
- const entryWidth = SWATCH_SIZE + SWATCH_GAP + labelWidth + ENTRY_GAP;
73
- if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
74
- rowCount++;
75
- rowWidth = entryWidth;
76
- } else {
77
- rowWidth += entryWidth;
78
- }
79
- }
80
- return rowCount;
81
- }
82
-
83
- // ---------------------------------------------------------------------------
84
- // Tests
85
- // ---------------------------------------------------------------------------
86
-
87
- describe('known layout bugs', () => {
88
- // Red-locked: fixed by docs/plans/04-resolved-layout-contract.md
89
- test.fails('5a.1: legend row advancement matches engine row height', () => {
90
- const boundsWidth = 400;
91
- const rowCount = computeRowCount(boundsWidth);
92
- const boundsHeight = rowCount * ENGINE_ROW_HEIGHT + LEGEND_PADDING * 2;
93
-
94
- const legendFixture: CategoricalLegendLayout = {
95
- position: 'top',
96
- entries,
97
- swatchSize: SWATCH_SIZE,
98
- swatchGap: SWATCH_GAP,
99
- entryGap: ENTRY_GAP,
100
- swatchChipFill: '#f0f0f0',
101
- labelStyle,
102
- bounds: { x: 0, y: 0, width: boundsWidth, height: boundsHeight },
103
- };
104
-
105
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
106
- renderLegend(svg, legendFixture);
107
-
108
- const yValues = distinctLabelYValues(svg);
109
- // With 4 rows we expect 4 distinct y-values. Consecutive rows should
110
- // differ by ENGINE_ROW_HEIGHT (16). The renderer uses swatchSize + 6 = 18,
111
- // so this assertion will fail until the drift is fixed.
112
- expect(yValues.length).toBeGreaterThanOrEqual(2);
113
- for (let i = 1; i < yValues.length; i++) {
114
- const gap = yValues[i] - yValues[i - 1];
115
- expect(gap).toBe(ENGINE_ROW_HEIGHT);
116
- }
117
- });
118
-
119
- // Red-locked: fixed by docs/plans/04-resolved-layout-contract.md and docs/plans/03-measure-then-freeze-layout.md
120
- test.fails('5a.2: rows drawn by renderer match rows reserved by engine', () => {
121
- // Build a line spec with 8 series whose labels vary in length.
122
- const countries = [
123
- 'United States of America',
124
- 'United Kingdom',
125
- 'Federal Republic of Germany',
126
- 'French Republic',
127
- 'Kingdom of Spain',
128
- 'Republic of Italy',
129
- 'Kingdom of Netherlands',
130
- 'Swiss Confederation',
131
- ];
132
-
133
- const data = countries.flatMap((country) => [
134
- { date: '2020-01-01', value: 10, country },
135
- { date: '2021-01-01', value: 20, country },
136
- ]);
137
-
138
- const spec = {
139
- mark: 'line' as const,
140
- data,
141
- encoding: {
142
- x: { field: 'date', type: 'temporal' as const },
143
- y: { field: 'value', type: 'quantitative' as const },
144
- color: { field: 'country', type: 'nominal' as const },
145
- },
146
- legend: { position: 'top' as const },
147
- };
148
-
149
- // Try widths 500-700 to find one where the legend wraps AND the
150
- // bounds.width narrowing causes extra rows in the renderer.
151
- let foundMismatch = false;
152
-
153
- for (let width = 500; width <= 700; width += 10) {
154
- const layout = compileChart(spec, { width, height: 400 });
155
- const legend = layout.legend as CategoricalLegendLayout;
156
- if (!legend || !legend.entries || legend.entries.length === 0) continue;
157
-
158
- // Rows reserved by the engine
159
- const effectivePadding = width < 420 ? 2 : LEGEND_PADDING;
160
- const rowsReserved = Math.round(
161
- (legend.bounds.height - effectivePadding * 2) / ENGINE_ROW_HEIGHT,
162
- );
163
- if (rowsReserved <= 1) continue; // only interesting when wrapping occurs
164
-
165
- // Render and count rows drawn
166
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
167
- renderLegend(svg, legend);
168
- const yValues = distinctLabelYValues(svg);
169
- const rowsDrawn = yValues.length;
170
-
171
- if (rowsDrawn !== rowsReserved) {
172
- foundMismatch = true;
173
- // This is the bug: the renderer drew more rows than the engine reserved.
174
- expect(rowsDrawn).toBe(rowsReserved);
175
- return;
176
- }
177
- }
178
-
179
- // If no width triggered a mismatch, fail so test.fails still inverts.
180
- // If this path fires, widen the width range above.
181
- expect(foundMismatch).toBe(true);
182
- });
183
- });