@opendata-ai/openchart-engine 7.2.4 → 7.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.d.ts +88 -39
- package/dist/index.js +4134 -2930
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +143 -78
- package/src/__tests__/axes.test.ts +129 -1
- package/src/__tests__/compile-chart.test.ts +6 -0
- package/src/__tests__/compile-layer.test.ts +49 -0
- package/src/__tests__/dimensions.test.ts +70 -2
- package/src/__tests__/helpers/invariants.ts +265 -0
- package/src/__tests__/known-bugs.test.ts +113 -0
- package/src/__tests__/layout-fuzz.test.ts +358 -0
- package/src/__tests__/layout-invariants.test.ts +535 -0
- package/src/__tests__/legend.test.ts +37 -0
- package/src/annotations/__tests__/compute.test.ts +8 -15
- package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
- package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
- package/src/annotations/__tests__/placement.test.ts +316 -0
- package/src/annotations/collisions.ts +19 -7
- package/src/annotations/compute.ts +257 -16
- package/src/annotations/geometry.ts +73 -15
- package/src/annotations/placement.ts +588 -0
- package/src/annotations/resolve-text.ts +58 -10
- package/src/charts/__tests__/post-process.test.ts +3 -3
- package/src/charts/bar/__tests__/compute.test.ts +131 -45
- package/src/charts/bar/compute.ts +44 -4
- package/src/charts/bar/index.ts +9 -2
- package/src/charts/bar/labels.ts +77 -26
- package/src/charts/column/labels.ts +29 -16
- package/src/charts/post-process.ts +21 -7
- package/src/charts/registry.ts +4 -2
- package/src/compile/layer.ts +31 -9
- package/src/compile.ts +88 -149
- package/src/index.ts +9 -0
- package/src/layout/axes/rotation.ts +36 -0
- package/src/layout/axes.ts +130 -8
- package/src/layout/dimensions.ts +80 -98
- package/src/layout/plan.ts +550 -0
- package/src/layout/scales.ts +22 -0
- package/src/layout/shared.ts +44 -0
- package/src/legend/compute.ts +315 -78
- package/src/legend/wrap.ts +11 -7
- package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
- package/src/tables/__tests__/heatmap.test.ts +24 -20
- package/src/tables/heatmap.ts +25 -9
|
@@ -17,7 +17,7 @@ import type {
|
|
|
17
17
|
Rect,
|
|
18
18
|
RectMark,
|
|
19
19
|
} from '@opendata-ai/openchart-core';
|
|
20
|
-
import { isGradientDef } from '@opendata-ai/openchart-core';
|
|
20
|
+
import { isGradientDef, NARROW_VIEWPORT_MAX } from '@opendata-ai/openchart-core';
|
|
21
21
|
import type { ScaleBand, ScaleLinear } from 'd3-scale';
|
|
22
22
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
23
23
|
import type { ResolvedScales } from '../../layout/scales';
|
|
@@ -54,6 +54,16 @@ function orientGradientForHorizontalBar(grad: GradientDef): GradientDef {
|
|
|
54
54
|
|
|
55
55
|
const MIN_BAR_WIDTH = 1;
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Readable floor (px) for a single grouped-bar thickness. Value labels render at
|
|
59
|
+
* fontSize 10 with ~12px line height; below this a bar is thinner than its own
|
|
60
|
+
* label and the chart reads as stripes (observed on iOS at 375-430px). When
|
|
61
|
+
* grouped sub-bars would fall under this, we reclaim the band scale's reserved
|
|
62
|
+
* inter-category whitespace (step - bandwidth) before letting bars shrink,
|
|
63
|
+
* trading padding for legibility rather than inventing extra height.
|
|
64
|
+
*/
|
|
65
|
+
const MIN_GROUPED_BAR_THICKNESS = 8;
|
|
66
|
+
|
|
57
67
|
// ---------------------------------------------------------------------------
|
|
58
68
|
// Public API
|
|
59
69
|
// ---------------------------------------------------------------------------
|
|
@@ -70,6 +80,7 @@ export function computeBarMarks(
|
|
|
70
80
|
scales: ResolvedScales,
|
|
71
81
|
_chartArea: Rect,
|
|
72
82
|
_strategy: LayoutStrategy,
|
|
83
|
+
containerWidth: number,
|
|
73
84
|
): RectMark[] {
|
|
74
85
|
const encoding = spec.encoding as Encoding;
|
|
75
86
|
const xChannel = encoding.x;
|
|
@@ -144,6 +155,7 @@ export function computeBarMarks(
|
|
|
144
155
|
bandwidth,
|
|
145
156
|
baseline,
|
|
146
157
|
scales,
|
|
158
|
+
containerWidth,
|
|
147
159
|
);
|
|
148
160
|
} else {
|
|
149
161
|
const stackMode =
|
|
@@ -267,6 +279,7 @@ function computeGroupedBars(
|
|
|
267
279
|
bandwidth: number,
|
|
268
280
|
baseline: number,
|
|
269
281
|
scales: ResolvedScales,
|
|
282
|
+
containerWidth: number,
|
|
270
283
|
): RectMark[] {
|
|
271
284
|
const marks: RectMark[] = [];
|
|
272
285
|
const categoryGroups = groupByField(data, categoryField);
|
|
@@ -282,9 +295,36 @@ function computeGroupedBars(
|
|
|
282
295
|
const groupCount = groupIndexMap.size;
|
|
283
296
|
if (groupCount === 0) return marks;
|
|
284
297
|
|
|
285
|
-
// Subdivide the band height by group count with a small gap
|
|
298
|
+
// Subdivide the band height by group count with a small gap.
|
|
286
299
|
const gap = Math.min(1, bandwidth * 0.05);
|
|
287
|
-
|
|
300
|
+
let groupHeight = bandwidth;
|
|
301
|
+
let subBandHeight = (groupHeight - gap * (groupCount - 1)) / groupCount;
|
|
302
|
+
|
|
303
|
+
// At tight bandwidths sub-bars fall below the readable floor and read as
|
|
304
|
+
// stripes. The band scale reserves whitespace between categories (step -
|
|
305
|
+
// bandwidth); reclaim it by growing the group height toward the full step
|
|
306
|
+
// before letting bars shrink. Bars stay centered in the band so the widened
|
|
307
|
+
// group doesn't touch its neighbors. A one-pixel residual keeps adjacent
|
|
308
|
+
// categories visually separable even at full reclaim.
|
|
309
|
+
//
|
|
310
|
+
// Gated to narrow containers (< NARROW_VIEWPORT_MAX): the fix targets phones
|
|
311
|
+
// where this was observed. Gating on the container width (not the derived plot
|
|
312
|
+
// width) keeps desktop embeds pixel-identical even when a wide container's
|
|
313
|
+
// left gutter shrinks the plot below 500px.
|
|
314
|
+
const step = typeof yScale.step === 'function' ? yScale.step() : bandwidth;
|
|
315
|
+
const maxGroupHeight = Math.max(bandwidth, step - MIN_BAR_WIDTH);
|
|
316
|
+
if (
|
|
317
|
+
containerWidth < NARROW_VIEWPORT_MAX &&
|
|
318
|
+
subBandHeight < MIN_GROUPED_BAR_THICKNESS &&
|
|
319
|
+
maxGroupHeight > bandwidth
|
|
320
|
+
) {
|
|
321
|
+
const needed = MIN_GROUPED_BAR_THICKNESS * groupCount + gap * (groupCount - 1);
|
|
322
|
+
groupHeight = Math.min(Math.max(groupHeight, needed), maxGroupHeight);
|
|
323
|
+
subBandHeight = (groupHeight - gap * (groupCount - 1)) / groupCount;
|
|
324
|
+
}
|
|
325
|
+
subBandHeight = Math.max(subBandHeight, MIN_BAR_WIDTH);
|
|
326
|
+
// Center the (possibly widened) group within the band.
|
|
327
|
+
const groupOffset = (bandwidth - groupHeight) / 2;
|
|
288
328
|
|
|
289
329
|
for (const [category, rows] of categoryGroups) {
|
|
290
330
|
const bandY = yScale(category);
|
|
@@ -299,7 +339,7 @@ function computeGroupedBars(
|
|
|
299
339
|
const color = getColor(scales, groupKey);
|
|
300
340
|
const xPos = value >= 0 ? baseline : xScale(value);
|
|
301
341
|
const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
|
|
302
|
-
const subY = bandY + groupIndex * (subBandHeight + gap);
|
|
342
|
+
const subY = bandY + groupOffset + groupIndex * (subBandHeight + gap);
|
|
303
343
|
|
|
304
344
|
const aria: MarkAria = {
|
|
305
345
|
label: `${category}, ${groupKey}: ${formatLabelValue(value)}`,
|
package/src/charts/bar/index.ts
CHANGED
|
@@ -13,8 +13,15 @@ import { computeBarLabels } from './labels';
|
|
|
13
13
|
// Bar chart renderer
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
|
|
16
|
-
export const barRenderer: ChartRenderer = (
|
|
17
|
-
|
|
16
|
+
export const barRenderer: ChartRenderer = (
|
|
17
|
+
spec,
|
|
18
|
+
scales,
|
|
19
|
+
chartArea,
|
|
20
|
+
strategy,
|
|
21
|
+
theme,
|
|
22
|
+
containerWidth,
|
|
23
|
+
) => {
|
|
24
|
+
const marks = computeBarMarks(spec, scales, chartArea, strategy, containerWidth);
|
|
18
25
|
|
|
19
26
|
// Compute and attach value labels (respects spec.labels.density)
|
|
20
27
|
const valueField =
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* if the bar is wide enough, or outside (to the right) otherwise.
|
|
6
6
|
*
|
|
7
7
|
* Respects the spec's label density setting:
|
|
8
|
-
* - 'all':
|
|
9
|
-
*
|
|
8
|
+
* - 'all': consider every bar's label (no density pre-filter), but still run
|
|
9
|
+
* collision resolution so colliding losers are hidden rather than drawn on
|
|
10
|
+
* top of each other. Composes with the stacked-segment pre-hide pass below.
|
|
11
|
+
* - 'auto': existing behavior (density pre-filter + collision detection)
|
|
10
12
|
* - 'endpoints': first and last bars only
|
|
11
13
|
* - 'none': return empty array
|
|
12
14
|
*/
|
|
@@ -106,6 +108,10 @@ export function computeBarLabels(
|
|
|
106
108
|
// Track whether each candidate fits within its stacked segment.
|
|
107
109
|
// Non-stacked bars are always considered fitting (undefined = fits).
|
|
108
110
|
const fitsInSegment: boolean[] = [];
|
|
111
|
+
// The display anchor x (start/end/middle reference point) for each candidate.
|
|
112
|
+
// Candidates carry the box's LEFT edge for collision math; on emit we map any
|
|
113
|
+
// horizontal nudge back onto this display anchor so textAnchor stays honored.
|
|
114
|
+
const displayAnchorX: number[] = [];
|
|
109
115
|
|
|
110
116
|
const formatter = buildD3Formatter(labelFormat);
|
|
111
117
|
|
|
@@ -183,17 +189,32 @@ export function computeBarLabels(
|
|
|
183
189
|
}
|
|
184
190
|
}
|
|
185
191
|
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
192
|
+
// Collision math works in top-left-edge space so the AABB
|
|
193
|
+
// (rect = { x: anchorX, y: anchorY, width, height }) matches the label's
|
|
194
|
+
// real footprint. The renderer anchors text by textAnchor (start/end/
|
|
195
|
+
// middle) and centers it vertically (dominant-baseline: central), so we
|
|
196
|
+
// convert the display anchor to the box's top-left corner here and shift
|
|
197
|
+
// back on emit. Feeding the display anchor directly desynced the collision
|
|
198
|
+
// box from the drawn text (by width for end/middle, by textHeight/2
|
|
199
|
+
// vertically), letting adjacent bar labels pass the overlap check while
|
|
200
|
+
// visibly colliding.
|
|
201
|
+
const centerY = mark.y + mark.height / 2;
|
|
202
|
+
const anchorY = centerY - textHeight / 2;
|
|
203
|
+
const leftEdgeX =
|
|
204
|
+
textAnchor === 'end'
|
|
205
|
+
? anchorX - textWidth
|
|
206
|
+
: textAnchor === 'middle'
|
|
207
|
+
? anchorX - textWidth / 2
|
|
208
|
+
: anchorX;
|
|
189
209
|
|
|
190
210
|
// Check if label text fits within the stacked segment
|
|
191
211
|
const fits = !(isStacked && textWidth > mark.width - 2 * LABEL_PADDING);
|
|
192
212
|
|
|
213
|
+
displayAnchorX.push(anchorX);
|
|
193
214
|
fitsInSegment.push(fits);
|
|
194
215
|
candidates.push({
|
|
195
216
|
text: valuePart,
|
|
196
|
-
anchorX,
|
|
217
|
+
anchorX: leftEdgeX,
|
|
197
218
|
anchorY,
|
|
198
219
|
width: textWidth,
|
|
199
220
|
height: textHeight,
|
|
@@ -212,46 +233,76 @@ export function computeBarLabels(
|
|
|
212
233
|
|
|
213
234
|
if (candidates.length === 0) return [];
|
|
214
235
|
|
|
215
|
-
// 'all'
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
text: c.text,
|
|
220
|
-
x: c.anchorX,
|
|
221
|
-
y: c.anchorY,
|
|
222
|
-
style: c.style,
|
|
223
|
-
visible: fitsInSegment[i] !== false,
|
|
224
|
-
}));
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// For 'auto' and 'endpoints': pre-mark candidates that don't fit their
|
|
228
|
-
// stacked segment as hidden before running collision detection.
|
|
236
|
+
// Every density that reaches here ('all' and 'auto') pre-hides candidates
|
|
237
|
+
// that don't fit their stacked segment, then runs collision resolution.
|
|
238
|
+
// 'all' just skips the density pre-filter above so every mark is a
|
|
239
|
+
// candidate; colliding losers are still hidden by resolveCollisions.
|
|
229
240
|
const fittingCandidates: LabelCandidate[] = [];
|
|
230
241
|
const unfittingIndices = new Set<number>();
|
|
231
242
|
for (let i = 0; i < candidates.length; i++) {
|
|
232
243
|
if (fitsInSegment[i] === false) {
|
|
233
244
|
unfittingIndices.add(i);
|
|
234
245
|
} else {
|
|
235
|
-
|
|
246
|
+
// Carry the original candidate index so we can zip the resolved output
|
|
247
|
+
// back to it regardless of any reordering resolveCollisions performs.
|
|
248
|
+
fittingCandidates.push({ ...candidates[i], index: i });
|
|
236
249
|
}
|
|
237
250
|
}
|
|
238
251
|
|
|
239
|
-
|
|
252
|
+
// resolveCollisions may reorder its output (it sorts by priority), so key the
|
|
253
|
+
// result by the source index we carried rather than by position.
|
|
254
|
+
const resolvedByOrig = new Map<number, ResolvedLabel>();
|
|
255
|
+
for (const r of resolveCollisions(fittingCandidates)) {
|
|
256
|
+
if (r.index !== undefined) resolvedByOrig.set(r.index, r);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Shift resolved y back from top-edge space to the vertical center that the
|
|
260
|
+
// central-baseline renderer expects (see anchorY derivation above).
|
|
261
|
+
const halfHeight = FONT_SIZE * 1.2 * 0.5;
|
|
240
262
|
|
|
241
263
|
// Re-insert hidden labels for candidates that didn't fit, preserving order
|
|
242
264
|
const results: ResolvedLabel[] = [];
|
|
243
|
-
let resolvedIdx = 0;
|
|
244
265
|
for (let i = 0; i < candidates.length; i++) {
|
|
245
266
|
if (unfittingIndices.has(i)) {
|
|
246
267
|
results.push({
|
|
247
268
|
text: candidates[i].text,
|
|
248
|
-
x:
|
|
249
|
-
y: candidates[i].anchorY,
|
|
269
|
+
x: displayAnchorX[i],
|
|
270
|
+
y: candidates[i].anchorY + halfHeight,
|
|
250
271
|
style: candidates[i].style,
|
|
251
272
|
visible: false,
|
|
252
273
|
});
|
|
253
274
|
} else {
|
|
254
|
-
|
|
275
|
+
const r = resolvedByOrig.get(i);
|
|
276
|
+
if (!r) continue;
|
|
277
|
+
// Collision math ran in left-edge/top space; renderers draw at the display
|
|
278
|
+
// anchor with central baseline. The inverse mapping shifts every point by
|
|
279
|
+
// dx (left-edge -> display anchor) on x and +halfHeight on y. Apply it to
|
|
280
|
+
// the label AND both connector endpoints, or the leader line for an
|
|
281
|
+
// end/middle-anchored label points to the box's top-left corner (up to
|
|
282
|
+
// textWidth + halfHeight away from the text it's calling out).
|
|
283
|
+
const dx = displayAnchorX[i] - candidates[i].anchorX;
|
|
284
|
+
// Drop the internal zip index so it doesn't leak into the label output.
|
|
285
|
+
const { index: _index, ...rest } = r;
|
|
286
|
+
results.push({
|
|
287
|
+
...rest,
|
|
288
|
+
x: r.x + dx,
|
|
289
|
+
y: r.y + halfHeight,
|
|
290
|
+
connector: r.connector
|
|
291
|
+
? {
|
|
292
|
+
...r.connector,
|
|
293
|
+
from: {
|
|
294
|
+
...r.connector.from,
|
|
295
|
+
x: r.connector.from.x + dx,
|
|
296
|
+
y: r.connector.from.y + halfHeight,
|
|
297
|
+
},
|
|
298
|
+
to: {
|
|
299
|
+
...r.connector.to,
|
|
300
|
+
x: r.connector.to.x + dx,
|
|
301
|
+
y: r.connector.to.y + halfHeight,
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
: undefined,
|
|
305
|
+
});
|
|
255
306
|
}
|
|
256
307
|
}
|
|
257
308
|
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* or below (for negative values).
|
|
6
6
|
*
|
|
7
7
|
* Respects the spec's label density setting:
|
|
8
|
-
* - 'all':
|
|
9
|
-
*
|
|
8
|
+
* - 'all': consider every column's label (no density pre-filter), but still
|
|
9
|
+
* run collision resolution so colliding losers are hidden rather than drawn
|
|
10
|
+
* on top of each other
|
|
11
|
+
* - 'auto': existing behavior (density pre-filter + collision detection)
|
|
10
12
|
* - 'endpoints': first and last columns only
|
|
11
13
|
* - 'none': return empty array
|
|
12
14
|
*/
|
|
@@ -22,6 +24,7 @@ import {
|
|
|
22
24
|
estimateTextWidth,
|
|
23
25
|
getRepresentativeColor,
|
|
24
26
|
resolveCollisions,
|
|
27
|
+
textAscent,
|
|
25
28
|
} from '@opendata-ai/openchart-core';
|
|
26
29
|
import { filterByDensity } from '../_shared/density-filter';
|
|
27
30
|
import { formatLabelValue } from '../_shared/format-label-value';
|
|
@@ -97,7 +100,8 @@ export function computeColumnLabels(
|
|
|
97
100
|
|
|
98
101
|
// anchorY is the TOP of the label bounding box so the collision system's
|
|
99
102
|
// AABB check (rect = { y: anchorY, height: textHeight }) is geometrically
|
|
100
|
-
// correct.
|
|
103
|
+
// correct. Collision math stays in top-coordinate space; the ascent shift
|
|
104
|
+
// to the alphabetic baseline is applied only at emission (see below).
|
|
101
105
|
// Positive bar: top = barTop - LABEL_OFFSET_Y - textHeight, text floats above
|
|
102
106
|
// Negative bar: top = barBottom + LABEL_OFFSET_Y, text hangs below
|
|
103
107
|
const anchorX = mark.x + mark.width / 2;
|
|
@@ -119,23 +123,32 @@ export function computeColumnLabels(
|
|
|
119
123
|
fill: labelColor ?? getRepresentativeColor(mark.fill),
|
|
120
124
|
lineHeight: 1.2,
|
|
121
125
|
textAnchor: 'middle',
|
|
122
|
-
dominantBaseline: 'hanging',
|
|
123
126
|
},
|
|
124
127
|
});
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
if (candidates.length === 0) return [];
|
|
128
131
|
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
132
|
+
// Collision resolution keeps y in top-edge coordinates; renderers position
|
|
133
|
+
// text on the alphabetic baseline, so shift every emitted y down by the
|
|
134
|
+
// ascent instead of relying on dominant-baseline:hanging (WebKit computes
|
|
135
|
+
// hanging from different font metrics than Blink, clipping labels on iOS).
|
|
136
|
+
const ascent = textAscent(FONT_SIZE);
|
|
137
|
+
|
|
138
|
+
// Every density that reaches here ('all' and 'auto') runs collision
|
|
139
|
+
// resolution. 'all' just skips the density pre-filter above so every mark is
|
|
140
|
+
// a candidate; colliding losers are still hidden by resolveCollisions rather
|
|
141
|
+
// than stacked on top of each other.
|
|
142
|
+
return resolveCollisions(candidates).map((label) => ({
|
|
143
|
+
...label,
|
|
144
|
+
y: label.y + ascent,
|
|
145
|
+
// The connector originates at the label; shift its start to the baseline
|
|
146
|
+
// too so the leader stays glued to the shifted text.
|
|
147
|
+
connector: label.connector
|
|
148
|
+
? {
|
|
149
|
+
...label.connector,
|
|
150
|
+
from: { ...label.connector.from, y: label.connector.from.y + ascent },
|
|
151
|
+
}
|
|
152
|
+
: undefined,
|
|
153
|
+
}));
|
|
141
154
|
}
|
|
@@ -5,14 +5,17 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type {
|
|
8
|
+
AreaMark,
|
|
8
9
|
Encoding,
|
|
10
|
+
LineMark,
|
|
9
11
|
Mark,
|
|
10
12
|
MarkDef,
|
|
11
13
|
PointMark,
|
|
12
|
-
Rect,
|
|
13
14
|
RectMark,
|
|
14
15
|
ResolvedAnimation,
|
|
15
16
|
} from '@opendata-ai/openchart-core';
|
|
17
|
+
import type { PlacementObstacle } from '../annotations/placement';
|
|
18
|
+
import { sampleAreaObstacles, samplePolylineObstacles } from '../annotations/placement';
|
|
16
19
|
import type { ResolvedScales } from '../layout/scales';
|
|
17
20
|
|
|
18
21
|
// ---------------------------------------------------------------------------
|
|
@@ -28,18 +31,17 @@ import type { ResolvedScales } from '../layout/scales';
|
|
|
28
31
|
* For other charts (column, scatter): returns individual mark bounds so
|
|
29
32
|
* annotations avoid overlapping any visible data mark.
|
|
30
33
|
*/
|
|
31
|
-
export function computeMarkObstacles(marks: Mark[], scales: ResolvedScales):
|
|
34
|
+
export function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): PlacementObstacle[] {
|
|
32
35
|
// Band-scale y-axis: group marks by row for efficient obstacle computation
|
|
33
36
|
if (scales.y?.type === 'band') {
|
|
34
37
|
return computeBandRowObstacles(marks, scales);
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
const obstacles: Rect[] = [];
|
|
40
|
+
const obstacles: PlacementObstacle[] = [];
|
|
39
41
|
for (const mark of marks) {
|
|
40
42
|
if (mark.type === 'rect') {
|
|
41
43
|
const rm = mark as RectMark;
|
|
42
|
-
obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
|
|
44
|
+
obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height, kind: 'mark' });
|
|
43
45
|
} else if (mark.type === 'point') {
|
|
44
46
|
const pm = mark as PointMark;
|
|
45
47
|
obstacles.push({
|
|
@@ -47,14 +49,25 @@ export function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): Rec
|
|
|
47
49
|
y: pm.cy - pm.r,
|
|
48
50
|
width: pm.r * 2,
|
|
49
51
|
height: pm.r * 2,
|
|
52
|
+
kind: 'mark',
|
|
50
53
|
});
|
|
54
|
+
} else if (mark.type === 'line') {
|
|
55
|
+
const lm = mark as LineMark;
|
|
56
|
+
if (lm.points.length > 0) {
|
|
57
|
+
obstacles.push(...samplePolylineObstacles(lm.points, 'line'));
|
|
58
|
+
}
|
|
59
|
+
} else if (mark.type === 'area') {
|
|
60
|
+
const am = mark as AreaMark;
|
|
61
|
+
if (am.topPoints.length > 0) {
|
|
62
|
+
obstacles.push(...sampleAreaObstacles(am.topPoints, am.bottomPoints));
|
|
63
|
+
}
|
|
51
64
|
}
|
|
52
65
|
}
|
|
53
66
|
return obstacles;
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
/** Group band-scale marks by row, returning one obstacle per band. */
|
|
57
|
-
function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales):
|
|
70
|
+
function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): PlacementObstacle[] {
|
|
58
71
|
const rows = new Map<number, { minX: number; maxX: number; bandY: number }>();
|
|
59
72
|
|
|
60
73
|
for (const mark of marks) {
|
|
@@ -92,13 +105,14 @@ function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[]
|
|
|
92
105
|
const bandwidth = bandScale.bandwidth?.() ?? 0;
|
|
93
106
|
if (bandwidth === 0) return [];
|
|
94
107
|
|
|
95
|
-
const obstacles:
|
|
108
|
+
const obstacles: PlacementObstacle[] = [];
|
|
96
109
|
for (const { minX, maxX, bandY } of rows.values()) {
|
|
97
110
|
obstacles.push({
|
|
98
111
|
x: minX,
|
|
99
112
|
y: bandY - bandwidth / 2,
|
|
100
113
|
width: maxX - minX,
|
|
101
114
|
height: bandwidth,
|
|
115
|
+
kind: 'mark',
|
|
102
116
|
});
|
|
103
117
|
}
|
|
104
118
|
|
package/src/charts/registry.ts
CHANGED
|
@@ -19,8 +19,9 @@ import type { ResolvedScales } from '../layout/scales';
|
|
|
19
19
|
* A chart renderer function.
|
|
20
20
|
*
|
|
21
21
|
* Takes a normalized spec, resolved scales, chart area, layout strategy,
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* the resolved theme for theme-aware styling (e.g. label colors), and the
|
|
23
|
+
* full container width (used by geometry gates that must key off the
|
|
24
|
+
* container, not the derived plot width). Returns an array of marks to render.
|
|
24
25
|
*/
|
|
25
26
|
export type ChartRenderer = (
|
|
26
27
|
spec: NormalizedChartSpec,
|
|
@@ -28,6 +29,7 @@ export type ChartRenderer = (
|
|
|
28
29
|
chartArea: Rect,
|
|
29
30
|
strategy: LayoutStrategy,
|
|
30
31
|
theme: ResolvedTheme,
|
|
32
|
+
containerWidth: number,
|
|
31
33
|
) => Mark[];
|
|
32
34
|
|
|
33
35
|
// ---------------------------------------------------------------------------
|
package/src/compile/layer.ts
CHANGED
|
@@ -9,8 +9,10 @@ import type {
|
|
|
9
9
|
} from '@opendata-ai/openchart-core';
|
|
10
10
|
import {
|
|
11
11
|
AXIS_TITLE_TRAILING_PAD,
|
|
12
|
+
abbreviateNumber,
|
|
12
13
|
BREAKPOINT_COMPACT_MAX,
|
|
13
14
|
estimateTextWidth,
|
|
15
|
+
formatNumber,
|
|
14
16
|
getAxisTitleOffset,
|
|
15
17
|
resolveTheme,
|
|
16
18
|
TICK_LABEL_OFFSET,
|
|
@@ -19,6 +21,8 @@ import { format as d3Format } from 'd3-format';
|
|
|
19
21
|
import { scaleLinear } from 'd3-scale';
|
|
20
22
|
import { curveMonotoneX, area as d3area, line as d3line } from 'd3-shape';
|
|
21
23
|
import { flattenLayers } from '../compiler/index';
|
|
24
|
+
import type { MeasureFn } from '../layout/plan';
|
|
25
|
+
import { createMeasureFn } from '../layout/plan';
|
|
22
26
|
|
|
23
27
|
type ChartCompiler = (spec: unknown, options: CompileOptions) => ChartLayout;
|
|
24
28
|
|
|
@@ -100,18 +104,21 @@ function estimateYAxisLabelWidth(
|
|
|
100
104
|
data: DataRow[],
|
|
101
105
|
encoding: Encoding | undefined,
|
|
102
106
|
baseFontSize: number,
|
|
107
|
+
measure?: MeasureFn,
|
|
103
108
|
): number {
|
|
104
109
|
if (!encoding?.y) return 40;
|
|
105
110
|
const yEnc = encoding.y;
|
|
106
111
|
const yField = yEnc.field;
|
|
107
112
|
if (!yField) return 40;
|
|
108
113
|
|
|
114
|
+
const m = measure ?? estimateTextWidth;
|
|
115
|
+
|
|
109
116
|
const yType = yEnc.type;
|
|
110
117
|
if (yType === 'nominal' || yType === 'ordinal') {
|
|
111
118
|
let maxWidth = 0;
|
|
112
119
|
for (const row of data) {
|
|
113
120
|
const label = String(row[yField] ?? '');
|
|
114
|
-
const w =
|
|
121
|
+
const w = m(label, baseFontSize, 400);
|
|
115
122
|
if (w > maxWidth) maxWidth = w;
|
|
116
123
|
}
|
|
117
124
|
return maxWidth > 0 ? maxWidth + 10 : 40;
|
|
@@ -134,16 +141,13 @@ function estimateYAxisLabelWidth(
|
|
|
134
141
|
sampleLabel = String(maxAbsVal);
|
|
135
142
|
}
|
|
136
143
|
} else {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
else if (maxAbsVal >= 100) sampleLabel = '100';
|
|
141
|
-
else if (maxAbsVal >= 10) sampleLabel = '10';
|
|
142
|
-
else sampleLabel = '0.0';
|
|
144
|
+
// Use the same formatting as tick labels instead of hardcoded magnitude guesses
|
|
145
|
+
sampleLabel =
|
|
146
|
+
Math.abs(maxAbsVal) >= 1000 ? abbreviateNumber(maxAbsVal) : formatNumber(maxAbsVal);
|
|
143
147
|
}
|
|
144
148
|
const hasNeg = data.some((r) => Number(r[yField]) < 0);
|
|
145
149
|
const labelEst = (hasNeg ? '-' : '') + sampleLabel;
|
|
146
|
-
return
|
|
150
|
+
return m(labelEst, baseFontSize, 400) + 10;
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
function compileLayerIndependent(
|
|
@@ -173,7 +177,13 @@ function compileLayerIndependent(
|
|
|
173
177
|
|
|
174
178
|
const theme = resolveTheme(layerSpec.theme ?? leaf1.theme);
|
|
175
179
|
const axisFontSize = theme.fonts?.sizes?.axisTick ?? 11;
|
|
176
|
-
const
|
|
180
|
+
const measureFn = createMeasureFn(options.measureText);
|
|
181
|
+
const rightAxisWidth = estimateYAxisLabelWidth(
|
|
182
|
+
leaf1.data,
|
|
183
|
+
leaf1.encoding,
|
|
184
|
+
axisFontSize,
|
|
185
|
+
measureFn,
|
|
186
|
+
);
|
|
177
187
|
const yAxisConfig = leaf1.encoding?.y?.axis || undefined;
|
|
178
188
|
const hasRightAxisTitle = !!yAxisConfig?.title;
|
|
179
189
|
const tickExtent = TICK_LABEL_OFFSET + rightAxisWidth;
|
|
@@ -227,6 +237,18 @@ function compileLayerIndependent(
|
|
|
227
237
|
x: layout0.area.x + layout0.area.width,
|
|
228
238
|
y: layout0.area.y + layout0.area.height,
|
|
229
239
|
},
|
|
240
|
+
...(layout1.axes.y.label
|
|
241
|
+
? {
|
|
242
|
+
titlePosition: {
|
|
243
|
+
x:
|
|
244
|
+
layout0.area.x +
|
|
245
|
+
layout0.area.width +
|
|
246
|
+
getAxisTitleOffset(layout0.dimensions.width),
|
|
247
|
+
y: layout0.area.y + layout0.area.height / 2,
|
|
248
|
+
angle: 90,
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
: {}),
|
|
230
252
|
}
|
|
231
253
|
: undefined;
|
|
232
254
|
|