@opendata-ai/openchart-engine 7.10.0 → 8.0.0-rc.1
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 +142 -7
- package/dist/index.js +16182 -10032
- package/dist/index.js.map +1 -1
- package/package.json +11 -4
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +170 -56
- package/src/__tests__/annotation-behavior.test.ts +721 -0
- package/src/__tests__/axes.test.ts +50 -3
- package/src/__tests__/compile-chart.test.ts +30 -0
- package/src/__tests__/compile-footnotes.test.ts +277 -0
- package/src/__tests__/compile-layer.test.ts +120 -1
- package/src/__tests__/dimensions.test.ts +49 -4
- package/src/__tests__/helpers/invariants.ts +54 -20
- package/src/__tests__/known-bugs.test.ts +455 -2
- package/src/__tests__/legend.test.ts +120 -4
- package/src/__tests__/ranking-recipes.test.ts +260 -0
- package/src/__tests__/scales.test.ts +9 -3
- package/src/__tests__/series-search.test.ts +92 -0
- package/src/__tests__/spec-sugar.test.ts +801 -0
- package/src/__tests__/you-draw-it.test.ts +160 -0
- package/src/annotations/__tests__/compute.test.ts +207 -92
- package/src/annotations/__tests__/geometry.test.ts +66 -0
- package/src/annotations/__tests__/resolve-text.test.ts +527 -27
- package/src/annotations/__tests__/rich-text.test.ts +91 -0
- package/src/annotations/__tests__/thinning.test.ts +148 -0
- package/src/annotations/collisions.ts +130 -84
- package/src/annotations/compute.ts +83 -85
- package/src/annotations/constants.ts +116 -6
- package/src/annotations/geometry.ts +222 -77
- package/src/annotations/move.ts +135 -0
- package/src/annotations/placement.ts +62 -14
- package/src/annotations/resolve-range.ts +2 -0
- package/src/annotations/resolve-refline.ts +2 -0
- package/src/annotations/resolve-text.ts +312 -111
- package/src/annotations/rich-text.ts +65 -0
- package/src/annotations/thinning.ts +84 -10
- package/src/barlist/compile-barlist.ts +16 -6
- package/src/charts/_shared/format-label-value.ts +9 -8
- package/src/charts/bar/__tests__/compute.test.ts +57 -10
- package/src/charts/bar/__tests__/labels.test.ts +5 -4
- package/src/charts/bar/compute.ts +2 -6
- package/src/charts/bar/index.ts +8 -1
- package/src/charts/bar/labels.ts +3 -3
- package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
- package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
- package/src/charts/beeswarm/compute.ts +203 -0
- package/src/charts/beeswarm/dodge.ts +155 -0
- package/src/charts/beeswarm/index.ts +32 -0
- package/src/charts/builtin.ts +12 -1
- package/src/charts/calendar/__tests__/compute.test.ts +417 -0
- package/src/charts/calendar/compute.ts +319 -0
- package/src/charts/calendar/index.ts +32 -0
- package/src/charts/column/__tests__/compute.test.ts +21 -7
- package/src/charts/column/__tests__/labels.test.ts +76 -1
- package/src/charts/column/compute.ts +2 -6
- package/src/charts/column/index.ts +10 -2
- package/src/charts/column/labels.ts +95 -21
- package/src/charts/dot/index.ts +8 -1
- package/src/charts/dot/labels.ts +3 -3
- package/src/charts/line/__tests__/compute.test.ts +15 -19
- package/src/charts/line/area.ts +8 -23
- package/src/charts/line/compute.ts +32 -2
- package/src/charts/parliament/__tests__/compute.test.ts +411 -0
- package/src/charts/parliament/compute.ts +313 -0
- package/src/charts/parliament/index.ts +32 -0
- package/src/charts/pie/__tests__/compute.test.ts +103 -0
- package/src/charts/pie/compute.ts +63 -8
- package/src/charts/pie/labels.ts +1 -0
- package/src/charts/range/__tests__/compute.test.ts +486 -0
- package/src/charts/range/compute.ts +273 -0
- package/src/charts/range/index.ts +82 -0
- package/src/charts/range/labels.ts +169 -0
- package/src/charts/rect/__tests__/compute.test.ts +120 -0
- package/src/charts/rect/index.ts +127 -0
- package/src/charts/scatter/compute.ts +7 -21
- package/src/charts/text/__tests__/compute.test.ts +307 -0
- package/src/charts/text/index.ts +44 -7
- package/src/charts/tick/__tests__/compute.test.ts +75 -0
- package/src/charts/tick/index.ts +7 -4
- package/src/charts/utils.ts +5 -3
- package/src/charts/waffle/__tests__/compute.test.ts +305 -0
- package/src/charts/waffle/compute.ts +197 -0
- package/src/charts/waffle/index.ts +31 -0
- package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
- package/src/compile/__tests__/data-clip.test.ts +68 -0
- package/src/compile/__tests__/fill-patterns.test.ts +154 -0
- package/src/compile/__tests__/size-scale.test.ts +108 -0
- package/src/compile/color-scale-range.ts +54 -33
- package/src/compile/data-clip.ts +21 -6
- package/src/compile/fill-patterns.ts +74 -0
- package/src/compile/layer.ts +117 -21
- package/src/compile/size-scale.ts +139 -0
- package/src/compile/spec-sugar.ts +758 -0
- package/src/compile/you-draw-it.ts +123 -0
- package/src/compile.ts +315 -103
- package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
- package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
- package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
- package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
- package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
- package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
- package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
- package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
- package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
- package/src/compiler/__tests__/normalize.test.ts +65 -1
- package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
- package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
- package/src/compiler/__tests__/validate.test.ts +156 -0
- package/src/compiler/__tests__/vl-parity.test.ts +68 -0
- package/src/compiler/a11y-warnings.ts +110 -0
- package/src/compiler/normalize.ts +150 -7
- package/src/compiler/types.ts +11 -0
- package/src/compiler/validate.ts +664 -18
- package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
- package/src/endpoint-labels/compute.ts +40 -12
- package/src/endpoint-labels/format.ts +45 -15
- package/src/endpoint-labels/predict.ts +50 -36
- package/src/format/__tests__/field-format.test.ts +164 -0
- package/src/format/field-format.ts +27 -0
- package/src/geo/__tests__/compile-map.test.ts +621 -0
- package/src/geo/compile-map.ts +856 -0
- package/src/geo/join.ts +115 -0
- package/src/geo/projections.ts +45 -0
- package/src/geo/types.ts +24 -0
- package/src/graphs/__tests__/compile-graph.test.ts +25 -0
- package/src/graphs/compile-graph.ts +27 -18
- package/src/index.ts +28 -2
- package/src/layout/axes/ticks.ts +59 -10
- package/src/layout/axes.ts +30 -2
- package/src/layout/dimensions.ts +178 -95
- package/src/layout/facet.ts +14 -3
- package/src/layout/plan.ts +86 -35
- package/src/layout/scales.ts +132 -15
- package/src/legend/__tests__/continuous.test.ts +318 -0
- package/src/legend/__tests__/size.test.ts +348 -0
- package/src/legend/__tests__/suppression.test.ts +17 -1
- package/src/legend/compute.ts +126 -14
- package/src/legend/continuous.ts +327 -0
- package/src/legend/size.ts +407 -0
- package/src/legend/suppression.ts +8 -7
- package/src/legend/wrap.ts +8 -0
- package/src/sankey/compile-sankey.ts +31 -19
- package/src/tables/format-cells.ts +16 -7
- package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
- package/src/tilemap/compile-tilemap.ts +24 -7
- package/src/tooltips/__tests__/compute.test.ts +2 -2
- package/src/tooltips/compute.ts +422 -41
- package/src/transforms/__tests__/conditional.test.ts +18 -0
- package/src/transforms/aggregate.ts +2 -1
package/src/layout/plan.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
CompileOptions,
|
|
3
3
|
Encoding,
|
|
4
|
+
EncodingChannel,
|
|
5
|
+
FieldFormatContext,
|
|
4
6
|
LayoutStrategy,
|
|
5
7
|
MeasureTextFn,
|
|
6
8
|
Rect,
|
|
@@ -9,15 +11,17 @@ import type {
|
|
|
9
11
|
} from '@opendata-ai/openchart-core';
|
|
10
12
|
import {
|
|
11
13
|
AXIS_TITLE_TRAILING_PAD,
|
|
12
|
-
abbreviateNumber,
|
|
13
14
|
axisTitleOffset,
|
|
14
15
|
BREAKPOINT_COMPACT_MAX,
|
|
16
|
+
COMPACT_WIDTH,
|
|
15
17
|
computeChrome,
|
|
18
|
+
computeFieldFormatContext,
|
|
16
19
|
computeXAxisExtentFromLabels,
|
|
20
|
+
defaultNumberFormatter,
|
|
17
21
|
estimateTextWidth,
|
|
18
|
-
formatNumber,
|
|
19
22
|
HPAD_COMPACT_FRACTION,
|
|
20
23
|
HPAD_COMPACT_MIN,
|
|
24
|
+
isAxislessMark,
|
|
21
25
|
LABEL_GAP_COMPACT,
|
|
22
26
|
LABEL_GAP_DEFAULT,
|
|
23
27
|
MAX_LEFT_LABEL_FRACTION_COMPACT,
|
|
@@ -25,14 +29,20 @@ import {
|
|
|
25
29
|
MAX_LEFT_LABEL_FRACTION_MEDIUM,
|
|
26
30
|
MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
|
|
27
31
|
NARROW_VIEWPORT_MAX,
|
|
32
|
+
resolveNumberFormatter,
|
|
28
33
|
TOP_PAD_EXTRA_NARROW,
|
|
29
34
|
} from '@opendata-ai/openchart-core';
|
|
30
|
-
import { format as d3Format } from 'd3-format';
|
|
31
35
|
|
|
32
36
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
33
37
|
import { predictEndpointLabelsWidth } from '../endpoint-labels/predict';
|
|
34
|
-
import { computeLegendContent, type LegendContent } from '../legend/compute';
|
|
35
|
-
import {
|
|
38
|
+
import { computeLegendContent, hasLegendContent, type LegendContent } from '../legend/compute';
|
|
39
|
+
import {
|
|
40
|
+
computeSizeLegendContent,
|
|
41
|
+
SIZE_LEGEND_GAP,
|
|
42
|
+
type SizeLegendContent,
|
|
43
|
+
sizeLegendScaleFor,
|
|
44
|
+
} from '../legend/size';
|
|
45
|
+
import { legendGap, TOP_LEGEND_GAP_ABOVE } from '../legend/wrap';
|
|
36
46
|
import { yTickPositionIsInline } from './axes';
|
|
37
47
|
import { resolveBandTickAngle } from './axes/rotation';
|
|
38
48
|
import { buildContinuousTicks, scaleSupportsTickCount, targetTickCount } from './axes/ticks';
|
|
@@ -55,6 +65,15 @@ export interface LayoutPlan {
|
|
|
55
65
|
leftGutter: number;
|
|
56
66
|
xAxisExtent: number;
|
|
57
67
|
legendContent: LegendContent;
|
|
68
|
+
/**
|
|
69
|
+
* Size legend content, when a quantitative `size` channel is encoded.
|
|
70
|
+
*
|
|
71
|
+
* Kept beside `legendContent` rather than inside it: a chart can carry BOTH a
|
|
72
|
+
* color legend and a size legend, and folding them into one slot is what made
|
|
73
|
+
* the size channel unkeyable in the first place. `dimensions.ts` reserves
|
|
74
|
+
* right-margin for this independently of the color legend's position.
|
|
75
|
+
*/
|
|
76
|
+
sizeLegendContent?: SizeLegendContent | null;
|
|
58
77
|
chrome: ResolvedChrome;
|
|
59
78
|
yTickValues: unknown[];
|
|
60
79
|
yTickCount: number;
|
|
@@ -85,9 +104,16 @@ export function createMeasureFn(measureText?: MeasureTextFn): MeasureFn {
|
|
|
85
104
|
// Helpers
|
|
86
105
|
// ---------------------------------------------------------------------------
|
|
87
106
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
107
|
+
function sampleLabelFromContext(
|
|
108
|
+
maxAbsVal: number,
|
|
109
|
+
yAxisFormat: string | undefined,
|
|
110
|
+
ctx: FieldFormatContext,
|
|
111
|
+
): string {
|
|
112
|
+
if (yAxisFormat) {
|
|
113
|
+
const fmt = resolveNumberFormatter(yAxisFormat, ctx);
|
|
114
|
+
if (fmt) return fmt(maxAbsVal);
|
|
115
|
+
}
|
|
116
|
+
return defaultNumberFormatter({ ...ctx, extent: ctx.extent ?? [0, maxAbsVal] })(maxAbsVal);
|
|
91
117
|
}
|
|
92
118
|
|
|
93
119
|
// ---------------------------------------------------------------------------
|
|
@@ -126,7 +152,7 @@ export function resolveLayoutPlan(
|
|
|
126
152
|
? Math.max(Math.round(padding * HPAD_COMPACT_FRACTION), HPAD_COMPACT_MIN)
|
|
127
153
|
: padding;
|
|
128
154
|
const encoding = renderSpec.encoding as Encoding;
|
|
129
|
-
const isRadial = renderSpec.markType
|
|
155
|
+
const isRadial = isAxislessMark(renderSpec.markType);
|
|
130
156
|
const axisMargin = theme.spacing.axisMargin;
|
|
131
157
|
|
|
132
158
|
// Resolve chromeMode
|
|
@@ -149,7 +175,7 @@ export function resolveLayoutPlan(
|
|
|
149
175
|
measure,
|
|
150
176
|
);
|
|
151
177
|
const bottomLegendReservation =
|
|
152
|
-
legendContent
|
|
178
|
+
hasLegendContent(legendContent) && legendContent.position === 'bottom'
|
|
153
179
|
? legendContent.height + legendGap(width)
|
|
154
180
|
: 0;
|
|
155
181
|
const chrome = computeChrome(
|
|
@@ -166,6 +192,8 @@ export function resolveLayoutPlan(
|
|
|
166
192
|
leftGutter: hPad,
|
|
167
193
|
xAxisExtent: 0,
|
|
168
194
|
legendContent,
|
|
195
|
+
// Sparkline / radial: no positional axes and no bubbles, so no size key.
|
|
196
|
+
sizeLegendContent: null,
|
|
169
197
|
chrome,
|
|
170
198
|
yTickValues: [],
|
|
171
199
|
yTickCount: 0,
|
|
@@ -231,7 +259,7 @@ export function resolveLayoutPlan(
|
|
|
231
259
|
leftGutter = Math.max(leftGutter, reserved);
|
|
232
260
|
}
|
|
233
261
|
} else {
|
|
234
|
-
// Quantitative: seed with a formatted label
|
|
262
|
+
// Quantitative: seed with a formatted label
|
|
235
263
|
const yField = encoding.y.field;
|
|
236
264
|
const yAxisFormat = yAxisCfg?.format as string | undefined;
|
|
237
265
|
let maxAbsVal = 0;
|
|
@@ -239,16 +267,8 @@ export function resolveLayoutPlan(
|
|
|
239
267
|
const v = Number(row[yField]);
|
|
240
268
|
if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
|
|
241
269
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
try {
|
|
245
|
-
sampleLabel = d3Format(yAxisFormat)(maxAbsVal);
|
|
246
|
-
} catch {
|
|
247
|
-
sampleLabel = String(maxAbsVal);
|
|
248
|
-
}
|
|
249
|
-
} else {
|
|
250
|
-
sampleLabel = sampleLabelFromMagnitude(maxAbsVal);
|
|
251
|
-
}
|
|
270
|
+
const ctx = computeFieldFormatContext(renderSpec.data.map((r) => r[yField]));
|
|
271
|
+
const sampleLabel = sampleLabelFromContext(maxAbsVal, yAxisFormat, ctx);
|
|
252
272
|
const negPrefix = renderSpec.data.some((r) => Number(r[yField]) < 0) ? '-' : '';
|
|
253
273
|
const labelWidth = measure(
|
|
254
274
|
negPrefix + sampleLabel,
|
|
@@ -300,6 +320,26 @@ export function resolveLayoutPlan(
|
|
|
300
320
|
}
|
|
301
321
|
}
|
|
302
322
|
|
|
323
|
+
// Size legend: keys a quantitative `size` channel with graduated circles.
|
|
324
|
+
// Independent of the color legend -- a bubble chart keys BOTH continent
|
|
325
|
+
// (color) and population (size), so this is computed alongside, not instead.
|
|
326
|
+
// Its content is width-independent, so it sits outside the convergence loop.
|
|
327
|
+
//
|
|
328
|
+
// Dropped below COMPACT_WIDTH. The block is a fixed ~90px (it can't reflow the
|
|
329
|
+
// way a categorical legend's entries can), which on a phone-width chart is a
|
|
330
|
+
// quarter of the SVG spent on the key and a plot too narrow to read the bubbles
|
|
331
|
+
// it explains. The same threshold the color legend goes compact at.
|
|
332
|
+
const sizeScaleOpts = sizeLegendScaleFor(renderSpec.markType);
|
|
333
|
+
const sizeLegendContent =
|
|
334
|
+
sizeScaleOpts && chartSpec.legend?.show !== false && width >= COMPACT_WIDTH
|
|
335
|
+
? computeSizeLegendContent(
|
|
336
|
+
renderSpec.encoding.size as EncodingChannel | undefined,
|
|
337
|
+
renderSpec.data,
|
|
338
|
+
theme,
|
|
339
|
+
sizeScaleOpts,
|
|
340
|
+
)
|
|
341
|
+
: null;
|
|
342
|
+
|
|
303
343
|
for (let iter = 0; iter < 2; iter++) {
|
|
304
344
|
// Legend content
|
|
305
345
|
const legendAvailWidth = width - padding - leftGutter;
|
|
@@ -316,7 +356,7 @@ export function resolveLayoutPlan(
|
|
|
316
356
|
|
|
317
357
|
// Chrome
|
|
318
358
|
const bottomLegendReservation =
|
|
319
|
-
legendContent
|
|
359
|
+
hasLegendContent(legendContent) && legendContent.position === 'bottom'
|
|
320
360
|
? legendContent.height + legendGap(width)
|
|
321
361
|
: 0;
|
|
322
362
|
const chrome = computeChrome(
|
|
@@ -336,7 +376,24 @@ export function resolveLayoutPlan(
|
|
|
336
376
|
// footprint in the bottom margin instead of the (smaller) flat one.
|
|
337
377
|
let effectiveXTickAngle = xTickAngle;
|
|
338
378
|
if (xTickAngle === undefined && xLabels.length > 1) {
|
|
339
|
-
|
|
379
|
+
// The right-hand legend column narrows the plot (dimensions.ts reserves
|
|
380
|
+
// it), so it has to be in this estimate too. Without it the planner sizes
|
|
381
|
+
// bands against a plot up to ~100px wider than the real one, concludes the
|
|
382
|
+
// labels fit flat, and computeAxes then rotates them against the true
|
|
383
|
+
// bandwidth -- reserving the flat footprint for rotated labels and clipping
|
|
384
|
+
// the axis. Bites a nominal-x beeswarm with a size channel.
|
|
385
|
+
//
|
|
386
|
+
// Mirrors the shared-column reservation in dimensions.ts: the color and
|
|
387
|
+
// size legends stack in ONE column, so it is the wider of the two, not the
|
|
388
|
+
// sum. These two must agree -- an estimate wider than the real margin
|
|
389
|
+
// rotates labels that would have fit.
|
|
390
|
+
const colorInGutter =
|
|
391
|
+
hasLegendContent(legendContent) &&
|
|
392
|
+
(legendContent.position === 'right' || legendContent.position === 'bottom-right');
|
|
393
|
+
const colorLegendReserve = colorInGutter ? legendContent.legendWidth + 8 : 0;
|
|
394
|
+
const sizeLegendReserve = sizeLegendContent ? sizeLegendContent.width + SIZE_LEGEND_GAP : 0;
|
|
395
|
+
const legendReserve = Math.max(colorLegendReserve, sizeLegendReserve);
|
|
396
|
+
const rightMarginEst = hPad + (isRadial ? hPad : axisMargin) + legendReserve;
|
|
340
397
|
const plotWidth = Math.max(0, width - leftGutter - Math.max(rightMarginEst, hPad));
|
|
341
398
|
// Mirror d3 scaleBand: step = plotWidth / (n - paddingInner + 2*paddingOuter),
|
|
342
399
|
// bandwidth = step * (1 - paddingInner). Uses the same override resolution
|
|
@@ -386,7 +443,7 @@ export function resolveLayoutPlan(
|
|
|
386
443
|
|
|
387
444
|
// Top margin
|
|
388
445
|
const topPad = width < NARROW_VIEWPORT_MAX ? padding + TOP_PAD_EXTRA_NARROW : padding;
|
|
389
|
-
const hasTopLegend = legendContent
|
|
446
|
+
const hasTopLegend = hasLegendContent(legendContent) && legendContent.position === 'top';
|
|
390
447
|
const gap = legendGap(width);
|
|
391
448
|
const inlineTickOverhang = yIsInline
|
|
392
449
|
? theme.fonts.sizes.axisTick + INLINE_TICK_OVERHANG_PAD
|
|
@@ -395,7 +452,8 @@ export function resolveLayoutPlan(
|
|
|
395
452
|
const effectiveTopAxisGap = hasTopLegend ? inlineTickOverhang : topAxisGap;
|
|
396
453
|
|
|
397
454
|
let topMargin = topPad + chrome.topHeight;
|
|
398
|
-
|
|
455
|
+
// Mirrors the dimensions.ts margin stack: min gap above the top legend.
|
|
456
|
+
if (hasTopLegend) topMargin += TOP_LEGEND_GAP_ABOVE + legendContent.height + gap;
|
|
399
457
|
topMargin += effectiveTopAxisGap;
|
|
400
458
|
|
|
401
459
|
const bMargin = bottomMargin(chrome.bottomHeight, padding, xAxisExtent);
|
|
@@ -500,16 +558,8 @@ export function resolveLayoutPlan(
|
|
|
500
558
|
const v = Number(row[yField]);
|
|
501
559
|
if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
|
|
502
560
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
try {
|
|
506
|
-
sample = d3Format(yAxisFormat)(maxAbsVal);
|
|
507
|
-
} catch {
|
|
508
|
-
sample = String(maxAbsVal);
|
|
509
|
-
}
|
|
510
|
-
} else {
|
|
511
|
-
sample = sampleLabelFromMagnitude(maxAbsVal);
|
|
512
|
-
}
|
|
561
|
+
const ctx = computeFieldFormatContext(renderSpec.data.map((r) => r[yField]));
|
|
562
|
+
const sample = sampleLabelFromContext(maxAbsVal, yAxisFormat, ctx);
|
|
513
563
|
const neg = renderSpec.data.some((r) => Number(r[encoding.y!.field]) < 0) ? '-' : '';
|
|
514
564
|
const maxLabelWidth = measure(
|
|
515
565
|
neg + sample,
|
|
@@ -561,6 +611,7 @@ export function resolveLayoutPlan(
|
|
|
561
611
|
leftGutter: gutterWithTitle,
|
|
562
612
|
xAxisExtent,
|
|
563
613
|
legendContent,
|
|
614
|
+
sizeLegendContent,
|
|
564
615
|
chrome,
|
|
565
616
|
yTickValues,
|
|
566
617
|
yTickCount,
|
package/src/layout/scales.ts
CHANGED
|
@@ -10,9 +10,11 @@ import type {
|
|
|
10
10
|
DataRow,
|
|
11
11
|
Encoding,
|
|
12
12
|
EncodingChannel,
|
|
13
|
+
FieldFormatContext,
|
|
13
14
|
Rect,
|
|
14
15
|
ScaleType,
|
|
15
16
|
} from '@opendata-ai/openchart-core';
|
|
17
|
+
import { computeFieldFormatContext } from '@opendata-ai/openchart-core';
|
|
16
18
|
import { extent, max, min } from 'd3-array';
|
|
17
19
|
import type {
|
|
18
20
|
ScaleBand,
|
|
@@ -44,6 +46,7 @@ import {
|
|
|
44
46
|
} from 'd3-scale';
|
|
45
47
|
|
|
46
48
|
import type { NormalizedChartSpec } from '../compiler/types';
|
|
49
|
+
import { DEFAULT_BIN_COUNT, sampleRampColors } from '../legend/continuous';
|
|
47
50
|
|
|
48
51
|
// ---------------------------------------------------------------------------
|
|
49
52
|
// Types
|
|
@@ -91,6 +94,8 @@ export interface ResolvedScale {
|
|
|
91
94
|
type: ResolvedScaleType;
|
|
92
95
|
/** The encoding channel this scale was derived from. */
|
|
93
96
|
channel: EncodingChannel;
|
|
97
|
+
/** Per-field formatting context (extent, allIntegers) from the ORIGINAL data. */
|
|
98
|
+
formatContext?: FieldFormatContext;
|
|
94
99
|
}
|
|
95
100
|
|
|
96
101
|
/** All resolved scales for a chart. */
|
|
@@ -145,12 +150,13 @@ function uniqueStrings(values: unknown[]): string[] {
|
|
|
145
150
|
* - 'ascending': sort alphabetically/numerically ascending
|
|
146
151
|
* - 'descending': sort descending
|
|
147
152
|
* - null | undefined: preserve data order (no sorting)
|
|
153
|
+
*
|
|
154
|
+
* VL sort forms ('-y', value arrays, { field, op, order }) are resolved into
|
|
155
|
+
* an explicit scale.domain by the pre-validation sugar expansion; any that
|
|
156
|
+
* reach this point fall back to data order.
|
|
148
157
|
*/
|
|
149
|
-
function applyCategoricalSort(
|
|
150
|
-
values
|
|
151
|
-
sort: 'ascending' | 'descending' | null | undefined,
|
|
152
|
-
): string[] {
|
|
153
|
-
if (!sort) return values;
|
|
158
|
+
export function applyCategoricalSort(values: string[], sort: EncodingChannel['sort']): string[] {
|
|
159
|
+
if (sort !== 'ascending' && sort !== 'descending') return values;
|
|
154
160
|
|
|
155
161
|
const sorted = [...values].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
156
162
|
if (sort === 'descending') sorted.reverse();
|
|
@@ -509,17 +515,27 @@ export function estimateBandStep(
|
|
|
509
515
|
return plotWidth / denom;
|
|
510
516
|
}
|
|
511
517
|
|
|
518
|
+
/**
|
|
519
|
+
* Gap between bands, as a fraction of the step. The 0.35 default is a *bar*
|
|
520
|
+
* value: bars need air between them to read as separate quantities. Heatmap
|
|
521
|
+
* cells are the opposite -- they tile, and the gap is a hairline gutter that
|
|
522
|
+
* only exists to keep adjacent fills from bleeding into each other.
|
|
523
|
+
*/
|
|
524
|
+
const DEFAULT_BAND_PADDING = 0.35;
|
|
525
|
+
const HEATMAP_BAND_PADDING = 0.04;
|
|
526
|
+
|
|
512
527
|
function buildBandScale(
|
|
513
528
|
channel: EncodingChannel,
|
|
514
529
|
data: DataRow[],
|
|
515
530
|
rangeStart: number,
|
|
516
531
|
rangeEnd: number,
|
|
532
|
+
defaultPadding: number = DEFAULT_BAND_PADDING,
|
|
517
533
|
): ResolvedScale {
|
|
518
534
|
const values = channel.scale?.domain
|
|
519
535
|
? (channel.scale.domain as string[])
|
|
520
536
|
: applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
|
|
521
537
|
|
|
522
|
-
const padding = channel.scale?.padding ??
|
|
538
|
+
const padding = channel.scale?.padding ?? defaultPadding;
|
|
523
539
|
const scale = scaleBand().domain(values).range([rangeStart, rangeEnd]).padding(padding);
|
|
524
540
|
|
|
525
541
|
if (channel.scale?.paddingInner !== undefined) {
|
|
@@ -594,10 +610,20 @@ function buildSequentialColorScale(
|
|
|
594
610
|
const explicitRange = channel.scale?.range as string[] | undefined;
|
|
595
611
|
const colors = explicitRange ?? palette;
|
|
596
612
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
613
|
+
// Explicit multi-stop ranges (e.g. a diverging scheme) interpolate piecewise
|
|
614
|
+
// through every stop, with evenly-spaced domain pivots to match. Without an
|
|
615
|
+
// explicit range the endpoints-only form is kept so applyColorScaleRange can
|
|
616
|
+
// swap in the theme ramp endpoints post-hoc.
|
|
617
|
+
const stops =
|
|
618
|
+
explicitRange && explicitRange.length > 2
|
|
619
|
+
? explicitRange
|
|
620
|
+
: [colors[0], colors[colors.length - 1]];
|
|
621
|
+
const domain =
|
|
622
|
+
stops.length > 2
|
|
623
|
+
? stops.map((_, i) => domainMin + ((domainMax - domainMin) * i) / (stops.length - 1))
|
|
624
|
+
: [domainMin, domainMax];
|
|
625
|
+
|
|
626
|
+
const scale = scaleLinear<string>().domain(domain).range(stops).clamp(true);
|
|
601
627
|
|
|
602
628
|
// Cast: sequential color scale (number -> string) is structurally incompatible
|
|
603
629
|
// with D3Scale (number -> number), but is only ever accessed via scales.color
|
|
@@ -605,6 +631,45 @@ function buildSequentialColorScale(
|
|
|
605
631
|
return { scale: scale as unknown as D3Scale, type: 'sequential', channel };
|
|
606
632
|
}
|
|
607
633
|
|
|
634
|
+
/**
|
|
635
|
+
* Build a binned color scale (quantile/quantize/threshold) for a quantitative
|
|
636
|
+
* color encoding with an explicit `scale.type`. The range holds colors: an
|
|
637
|
+
* explicit `scale.range` wins; otherwise a placeholder sampled from the
|
|
638
|
+
* default palette that `applyColorScaleRange` replaces with the theme's
|
|
639
|
+
* sequential ramp.
|
|
640
|
+
*/
|
|
641
|
+
function buildBinnedColorScale(
|
|
642
|
+
channel: EncodingChannel,
|
|
643
|
+
data: DataRow[],
|
|
644
|
+
palette: string[],
|
|
645
|
+
): ResolvedScale {
|
|
646
|
+
const values = parseNumbers(fieldValues(data, channel.field));
|
|
647
|
+
const explicitRange = channel.scale?.range as string[] | undefined;
|
|
648
|
+
const scaleType = channel.scale?.type as 'quantile' | 'quantize' | 'threshold';
|
|
649
|
+
|
|
650
|
+
if (scaleType === 'threshold') {
|
|
651
|
+
// Threshold scales require explicit domain breakpoints; colors = breaks + 1.
|
|
652
|
+
const breaks = (channel.scale?.domain as number[] | undefined) ?? [0.5];
|
|
653
|
+
const colors = sampleRampColors(explicitRange ?? palette, breaks.length + 1);
|
|
654
|
+
const scale = scaleThreshold<number, string>().domain(breaks).range(colors);
|
|
655
|
+
return { scale: scale as unknown as D3Scale, type: 'threshold', channel };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const binCount = explicitRange?.length ?? DEFAULT_BIN_COUNT;
|
|
659
|
+
const colors = explicitRange ?? sampleRampColors(palette, binCount);
|
|
660
|
+
|
|
661
|
+
if (scaleType === 'quantile') {
|
|
662
|
+
const scale = scaleQuantile<string>().domain(values).range(colors);
|
|
663
|
+
return { scale: scale as unknown as D3Scale, type: 'quantile', channel };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const explicitDomain = channel.scale?.domain as [number, number] | undefined;
|
|
667
|
+
const domainMin = explicitDomain?.[0] ?? min(values) ?? 0;
|
|
668
|
+
const domainMax = explicitDomain?.[1] ?? max(values) ?? 1;
|
|
669
|
+
const scale = scaleQuantize<string>().domain([domainMin, domainMax]).range(colors);
|
|
670
|
+
return { scale: scale as unknown as D3Scale, type: 'quantize', channel };
|
|
671
|
+
}
|
|
672
|
+
|
|
608
673
|
// ---------------------------------------------------------------------------
|
|
609
674
|
// Positional scale selection
|
|
610
675
|
// ---------------------------------------------------------------------------
|
|
@@ -661,9 +726,23 @@ function buildPositionalScale(
|
|
|
661
726
|
return buildLinearScale(channel, data, rangeStart, rangeEnd);
|
|
662
727
|
case 'nominal':
|
|
663
728
|
case 'ordinal':
|
|
664
|
-
// Bar charts use band scales for their categorical axis (both
|
|
729
|
+
// Bar and range charts use band scales for their categorical axis (both
|
|
730
|
+
// orientations). Beeswarm lanes are band scales too: each category gets
|
|
731
|
+
// a band whose center anchors one swarm, on whichever axis carries the
|
|
732
|
+
// nominal channel.
|
|
733
|
+
//
|
|
734
|
+
// `rect` is the only mark that needs a band on *both* axes: a heatmap cell
|
|
735
|
+
// is sized by the bandwidth of each axis, so it takes no `axis` guard. A
|
|
736
|
+
// point scale would give it zero width and height (and it did: `rect` cells
|
|
737
|
+
// rendered as invisible zero-area marks). It also tiles, so it takes the
|
|
738
|
+
// hairline gutter rather than the bar-sized gap.
|
|
739
|
+
if (chartType === 'rect') {
|
|
740
|
+
return buildBandScale(channel, data, rangeStart, rangeEnd, HEATMAP_BAND_PADDING);
|
|
741
|
+
}
|
|
665
742
|
if (
|
|
666
743
|
chartType === 'bar' ||
|
|
744
|
+
chartType === 'beeswarm' ||
|
|
745
|
+
chartType === 'range' ||
|
|
667
746
|
((chartType === 'circle' || chartType === 'lollipop') && axis === 'y')
|
|
668
747
|
) {
|
|
669
748
|
return buildBandScale(channel, data, rangeStart, rangeEnd);
|
|
@@ -694,8 +773,17 @@ export function computeScales(
|
|
|
694
773
|
const result: ResolvedScales = {};
|
|
695
774
|
const encoding = spec.encoding as Encoding;
|
|
696
775
|
|
|
697
|
-
//
|
|
698
|
-
|
|
776
|
+
// Marks that encode position (not length) default to zero: false so the
|
|
777
|
+
// domain fits the data range. Line charts show trends — anchoring at zero
|
|
778
|
+
// wastes space. Scatter/beeswarm encode raw observations whose spread is
|
|
779
|
+
// the story. Text marks must match the point layer they label.
|
|
780
|
+
if (
|
|
781
|
+
spec.markType === 'point' ||
|
|
782
|
+
spec.markType === 'line' ||
|
|
783
|
+
spec.markType === 'beeswarm' ||
|
|
784
|
+
spec.markType === 'range' ||
|
|
785
|
+
spec.markType === 'text'
|
|
786
|
+
) {
|
|
699
787
|
if (encoding.x?.type === 'quantitative' && encoding.x.scale?.zero === undefined) {
|
|
700
788
|
if (!encoding.x.scale) {
|
|
701
789
|
(encoding.x as { scale?: Record<string, unknown> }).scale = { zero: false };
|
|
@@ -717,6 +805,13 @@ export function computeScales(
|
|
|
717
805
|
// Without this, stacked bars would clip past the chart area.
|
|
718
806
|
let xData = data;
|
|
719
807
|
let xChannel = encoding.x;
|
|
808
|
+
// Range charts span x to x2: the x-domain must cover both endpoint fields.
|
|
809
|
+
// Synthetic rows map x2 values into the x field so buildLinearScale sees them.
|
|
810
|
+
if (spec.markType === 'range' && encoding.x2 && encoding.x.type === 'quantitative') {
|
|
811
|
+
const xField = encoding.x.field;
|
|
812
|
+
const x2Field = encoding.x2.field;
|
|
813
|
+
xData = [...data, ...data.map((row) => ({ [xField]: row[x2Field] }) as DataRow)];
|
|
814
|
+
}
|
|
720
815
|
const xStackEnabled =
|
|
721
816
|
encoding.x.stack === true ||
|
|
722
817
|
encoding.x.stack === 'zero' ||
|
|
@@ -788,6 +883,9 @@ export function computeScales(
|
|
|
788
883
|
spec.markType,
|
|
789
884
|
'x',
|
|
790
885
|
);
|
|
886
|
+
if (result.x && encoding.x.type === 'quantitative' && encoding.x.field) {
|
|
887
|
+
result.x.formatContext = computeFieldFormatContext(data.map((r) => r[encoding.x!.field]));
|
|
888
|
+
}
|
|
791
889
|
}
|
|
792
890
|
|
|
793
891
|
if (encoding.y) {
|
|
@@ -797,6 +895,12 @@ export function computeScales(
|
|
|
797
895
|
// Vertical bar = x is categorical and y is quantitative (old 'column' chart type).
|
|
798
896
|
let yData = data;
|
|
799
897
|
let yChannel = encoding.y;
|
|
898
|
+
// Range charts span y to y2 (vertical form): cover both endpoint fields.
|
|
899
|
+
if (spec.markType === 'range' && encoding.y2 && encoding.y.type === 'quantitative') {
|
|
900
|
+
const yField = encoding.y.field;
|
|
901
|
+
const y2Field = encoding.y2.field;
|
|
902
|
+
yData = [...data, ...data.map((row) => ({ [yField]: row[y2Field] }) as DataRow)];
|
|
903
|
+
}
|
|
800
904
|
const isVerticalBar =
|
|
801
905
|
spec.markType === 'bar' &&
|
|
802
906
|
(encoding.x?.type === 'nominal' || encoding.x?.type === 'ordinal') &&
|
|
@@ -904,6 +1008,9 @@ export function computeScales(
|
|
|
904
1008
|
spec.markType,
|
|
905
1009
|
'y',
|
|
906
1010
|
);
|
|
1011
|
+
if (result.y && encoding.y.type === 'quantitative' && encoding.y.field) {
|
|
1012
|
+
result.y.formatContext = computeFieldFormatContext(data.map((r) => r[encoding.y!.field]));
|
|
1013
|
+
}
|
|
907
1014
|
}
|
|
908
1015
|
|
|
909
1016
|
if (encoding.color) {
|
|
@@ -923,8 +1030,18 @@ export function computeScales(
|
|
|
923
1030
|
// Only build color scales for field-based encodings, not conditional value defs
|
|
924
1031
|
if ('field' in encoding.color) {
|
|
925
1032
|
if (encoding.color.type === 'quantitative') {
|
|
926
|
-
|
|
927
|
-
|
|
1033
|
+
const colorScaleType = encoding.color.scale?.type;
|
|
1034
|
+
if (
|
|
1035
|
+
colorScaleType === 'quantile' ||
|
|
1036
|
+
colorScaleType === 'quantize' ||
|
|
1037
|
+
colorScaleType === 'threshold'
|
|
1038
|
+
) {
|
|
1039
|
+
// Binned color scale: discrete classes for value-based coloring
|
|
1040
|
+
result.color = buildBinnedColorScale(encoding.color, data, defaultPalette);
|
|
1041
|
+
} else {
|
|
1042
|
+
// Sequential color scale for value-based coloring
|
|
1043
|
+
result.color = buildSequentialColorScale(encoding.color, data, defaultPalette);
|
|
1044
|
+
}
|
|
928
1045
|
} else {
|
|
929
1046
|
// Categorical color scale for nominal/ordinal grouping
|
|
930
1047
|
result.color = buildOrdinalColorScale(encoding.color, data, defaultPalette);
|