@opendata-ai/openchart-engine 6.26.0 → 6.27.2
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 +37 -1
- package/dist/index.js +206 -21
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +33 -0
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +6 -0
- package/src/__tests__/axes.test.ts +101 -3
- package/src/__tests__/compile-chart.test.ts +301 -0
- package/src/annotations/__tests__/compute.test.ts +175 -0
- package/src/annotations/position.ts +37 -1
- package/src/annotations/resolve-range.ts +5 -5
- package/src/charts/bar/__tests__/compute.test.ts +102 -0
- package/src/charts/bar/compute.ts +1 -0
- package/src/charts/line/area.ts +1 -1
- package/src/charts/line/compute.ts +7 -1
- package/src/compile.ts +175 -4
- package/src/compiler/normalize.ts +26 -0
- package/src/compiler/types.ts +38 -0
- package/src/layout/axes/ticks.ts +31 -4
- package/src/layout/axes.ts +18 -4
- package/src/layout/dimensions.ts +77 -2
- package/src/legend/compute.ts +6 -1
|
@@ -217,6 +217,19 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar
|
|
|
217
217
|
const encoding = inferEncodingTypes(spec.encoding, spec.data, warnings);
|
|
218
218
|
const markType = resolveMarkType(spec.mark);
|
|
219
219
|
const markDef = resolveMarkDef(spec.mark);
|
|
220
|
+
const display = spec.display ?? 'full';
|
|
221
|
+
|
|
222
|
+
if (
|
|
223
|
+
display === 'sparkline' &&
|
|
224
|
+
markType !== 'line' &&
|
|
225
|
+
markType !== 'area' &&
|
|
226
|
+
markType !== 'bar' &&
|
|
227
|
+
markType !== 'point'
|
|
228
|
+
) {
|
|
229
|
+
warnings.push(
|
|
230
|
+
`[openchart] display: 'sparkline' works best with mark: 'line' | 'area' | 'bar' | 'point'. Got mark: '${markType}' — rendering may degrade.`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
220
233
|
|
|
221
234
|
return {
|
|
222
235
|
markType,
|
|
@@ -233,6 +246,19 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar
|
|
|
233
246
|
hiddenSeries: spec.hiddenSeries ?? [],
|
|
234
247
|
seriesStyles: spec.seriesStyles ?? {},
|
|
235
248
|
watermark: spec.watermark ?? true,
|
|
249
|
+
display,
|
|
250
|
+
// Default empty userExplicit; compileChart overwrites this with the real
|
|
251
|
+
// descriptor built from the raw expanded spec before normalize runs.
|
|
252
|
+
userExplicit: {
|
|
253
|
+
chrome: false,
|
|
254
|
+
legend: false,
|
|
255
|
+
xAxis: false,
|
|
256
|
+
yAxis: false,
|
|
257
|
+
labels: false,
|
|
258
|
+
animation: false,
|
|
259
|
+
watermark: false,
|
|
260
|
+
crosshair: false,
|
|
261
|
+
},
|
|
236
262
|
};
|
|
237
263
|
}
|
|
238
264
|
|
package/src/compiler/types.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
ColumnConfig,
|
|
16
16
|
DarkMode,
|
|
17
17
|
DataRow,
|
|
18
|
+
Display,
|
|
18
19
|
Encoding,
|
|
19
20
|
FieldType,
|
|
20
21
|
GraphEncoding,
|
|
@@ -61,6 +62,35 @@ export interface NormalizedEncodingChannel {
|
|
|
61
62
|
// NormalizedSpec types
|
|
62
63
|
// ---------------------------------------------------------------------------
|
|
63
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Tracks which top-level fields the user explicitly set in their input spec.
|
|
67
|
+
*
|
|
68
|
+
* Built from the raw expandedSpec (post-breakpoint-merge, pre-normalize) so
|
|
69
|
+
* that "user wrote chrome.title" vs "user wrote nothing" is distinguishable
|
|
70
|
+
* after normalization fills in defaults.
|
|
71
|
+
*
|
|
72
|
+
* Used by sparkline display mode to decide whether to suppress chrome/axes/
|
|
73
|
+
* legend/etc. by default vs. respecting an explicit user opt-in.
|
|
74
|
+
*/
|
|
75
|
+
export interface UserExplicit {
|
|
76
|
+
/** True if user wrote `chrome` (any non-empty chrome). */
|
|
77
|
+
chrome: boolean;
|
|
78
|
+
/** True if user wrote `legend`. */
|
|
79
|
+
legend: boolean;
|
|
80
|
+
/** True if user wrote `encoding.x.axis`. */
|
|
81
|
+
xAxis: boolean;
|
|
82
|
+
/** True if user wrote `encoding.y.axis`. */
|
|
83
|
+
yAxis: boolean;
|
|
84
|
+
/** True if user wrote `labels`. */
|
|
85
|
+
labels: boolean;
|
|
86
|
+
/** True if user wrote `animation`. */
|
|
87
|
+
animation: boolean;
|
|
88
|
+
/** True if user wrote `watermark`. */
|
|
89
|
+
watermark: boolean;
|
|
90
|
+
/** True if user wrote `crosshair`. */
|
|
91
|
+
crosshair: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
64
94
|
/** A ChartSpec with all optional fields filled with sensible defaults. */
|
|
65
95
|
export interface NormalizedChartSpec {
|
|
66
96
|
/** Resolved mark type string (extracted from spec.mark). */
|
|
@@ -85,6 +115,14 @@ export interface NormalizedChartSpec {
|
|
|
85
115
|
hiddenSeries: string[];
|
|
86
116
|
/** Per-series visual style overrides. */
|
|
87
117
|
seriesStyles: Record<string, import('@opendata-ai/openchart-core').SeriesStyle>;
|
|
118
|
+
/** Display mode controlling chrome/axes/legend stripping. Defaults to `'full'`. */
|
|
119
|
+
display: Display;
|
|
120
|
+
/**
|
|
121
|
+
* Which top-level fields the user explicitly set. Populated by compileChart
|
|
122
|
+
* from the raw expanded spec before normalization. NormalizeChartSpec runs
|
|
123
|
+
* with a default-empty descriptor; compileChart overwrites it post-normalize.
|
|
124
|
+
*/
|
|
125
|
+
userExplicit: UserExplicit;
|
|
88
126
|
}
|
|
89
127
|
|
|
90
128
|
/** A TableSpec with all optional fields filled with sensible defaults. */
|
package/src/layout/axes/ticks.ts
CHANGED
|
@@ -38,13 +38,18 @@ import type { D3CategoricalScale, D3ContinuousScale, ResolvedScale } from '../sc
|
|
|
38
38
|
* "full" is the publication-ready default; "reduced" and "minimal" step down as the
|
|
39
39
|
* responsive breakpoint system shifts to smaller containers.
|
|
40
40
|
*
|
|
41
|
+
* Y full is set to 40px/tick (tighter than Observable Plot's 50) because chart areas
|
|
42
|
+
* are measured after chrome subtraction. A 400px container with title+subtitle leaves
|
|
43
|
+
* ~270px of chart area; 55px/tick would only produce 4 ticks. 40px/tick reaches 5-6
|
|
44
|
+
* on typical chart areas (150-300px) and the overlap check acts as a safety net.
|
|
45
|
+
*
|
|
41
46
|
* @internal — these are tuning constants, not part of the configuration API.
|
|
42
47
|
* Consumers should configure tick density through `axis.tickCount` on the spec.
|
|
43
48
|
*/
|
|
44
49
|
const Y_PX_PER_TICK: Record<AxisLabelDensity, number> = {
|
|
45
|
-
full:
|
|
46
|
-
reduced:
|
|
47
|
-
minimal:
|
|
50
|
+
full: 40,
|
|
51
|
+
reduced: 70,
|
|
52
|
+
minimal: 120,
|
|
48
53
|
};
|
|
49
54
|
|
|
50
55
|
const X_PX_PER_TICK: Record<AxisLabelDensity, number> = {
|
|
@@ -194,7 +199,29 @@ export function buildContinuousTicks(resolvedScale: ResolvedScale, count: number
|
|
|
194
199
|
return continuousTicks(resolvedScale, 'full');
|
|
195
200
|
}
|
|
196
201
|
const raw: unknown[] = scale.ticks(count);
|
|
197
|
-
|
|
202
|
+
|
|
203
|
+
// D3 log scales ignore the count hint and return ticks at every sub-power
|
|
204
|
+
// position (e.g. 5, 6, 7, 8, 9, 10, 20, 30... for a domain of [5, 25000]).
|
|
205
|
+
// Filter down to powers of the base only when the raw set overshoots.
|
|
206
|
+
let ticks = raw;
|
|
207
|
+
if (resolvedScale.type === 'log' && raw.length > count) {
|
|
208
|
+
const base = resolvedScale.channel.scale?.base ?? 10;
|
|
209
|
+
const logBase = Math.log(base);
|
|
210
|
+
const powered = raw.filter((v) => {
|
|
211
|
+
const n = v as number;
|
|
212
|
+
if (n <= 0) return false;
|
|
213
|
+
const exp = Math.log(n) / logBase;
|
|
214
|
+
return Math.abs(exp - Math.round(exp)) < 1e-9;
|
|
215
|
+
});
|
|
216
|
+
// Only use the filtered set if it has at least 2 ticks; otherwise fall back
|
|
217
|
+
// to raw ticks. This handles domains like [5, 9] (no powers of 10 at all) or
|
|
218
|
+
// [5, 50] (only one power: 10) where filtering would leave too few meaningful ticks.
|
|
219
|
+
if (powered.length >= 2) {
|
|
220
|
+
ticks = powered;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return ticks.map((value: unknown) => ({
|
|
198
225
|
value,
|
|
199
226
|
position: scale(value as number & Date) as number,
|
|
200
227
|
label: formatTickLabel(value, resolvedScale),
|
package/src/layout/axes.ts
CHANGED
|
@@ -44,9 +44,16 @@ export { thinTicksUntilFit, ticksOverlap } from './axes/thinning';
|
|
|
44
44
|
* Below these pixel heights, we step down the density regardless of the
|
|
45
45
|
* width-based strategy. This prevents overlapping y-axis labels in short
|
|
46
46
|
* containers like thumbnail previews.
|
|
47
|
+
*
|
|
48
|
+
* These thresholds apply to the chart area height (after chrome/margins),
|
|
49
|
+
* not the total container height. A 400px container with title+subtitle
|
|
50
|
+
* leaves ~270px of chart area; a 320px container leaves ~186px. The old
|
|
51
|
+
* HEIGHT_REDUCED_THRESHOLD of 200 kicked in on nearly every common chart
|
|
52
|
+
* size, producing only 3 ticks. Lowering to 100 keeps 'full' density for
|
|
53
|
+
* all but the most compact thumbnail-style containers.
|
|
47
54
|
*/
|
|
48
|
-
const HEIGHT_MINIMAL_THRESHOLD =
|
|
49
|
-
const HEIGHT_REDUCED_THRESHOLD =
|
|
55
|
+
const HEIGHT_MINIMAL_THRESHOLD = 80;
|
|
56
|
+
const HEIGHT_REDUCED_THRESHOLD = 100;
|
|
50
57
|
|
|
51
58
|
/**
|
|
52
59
|
* Width thresholds for reducing x-axis tick density.
|
|
@@ -199,6 +206,13 @@ export interface AxesDataContext {
|
|
|
199
206
|
data: DataRow[];
|
|
200
207
|
/** The encoding object to resolve field names. */
|
|
201
208
|
encoding: Encoding;
|
|
209
|
+
/**
|
|
210
|
+
* When true, skip generating ticks/labels/title for the x-axis. Used by
|
|
211
|
+
* sparkline display mode when the user hasn't explicitly opted into axes.
|
|
212
|
+
*/
|
|
213
|
+
skipX?: boolean;
|
|
214
|
+
/** Same as skipX, for the y-axis. */
|
|
215
|
+
skipY?: boolean;
|
|
202
216
|
}
|
|
203
217
|
|
|
204
218
|
/**
|
|
@@ -257,7 +271,7 @@ export function computeAxes(
|
|
|
257
271
|
const { fontSize } = tickLabelStyle;
|
|
258
272
|
const { fontWeight } = tickLabelStyle;
|
|
259
273
|
|
|
260
|
-
if (scales.x) {
|
|
274
|
+
if (scales.x && !dataContext?.skipX) {
|
|
261
275
|
const axisConfig = scales.x.channel.axis;
|
|
262
276
|
const isContinuousX =
|
|
263
277
|
scales.x.type !== 'band' && scales.x.type !== 'point' && scales.x.type !== 'ordinal';
|
|
@@ -359,7 +373,7 @@ export function computeAxes(
|
|
|
359
373
|
};
|
|
360
374
|
}
|
|
361
375
|
|
|
362
|
-
if (scales.y) {
|
|
376
|
+
if (scales.y && !dataContext?.skipY) {
|
|
363
377
|
const axisConfig = scales.y.channel.axis;
|
|
364
378
|
const isContinuousY =
|
|
365
379
|
scales.y.type !== 'band' && scales.y.type !== 'point' && scales.y.type !== 'ordinal';
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -93,6 +93,30 @@ function scalePadding(basePadding: number, width: number, height: number): numbe
|
|
|
93
93
|
const MIN_CHART_WIDTH = 60;
|
|
94
94
|
const MIN_CHART_HEIGHT = 40;
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Per-display minimum chart dimensions. Sparkline mode allows much smaller
|
|
98
|
+
* containers (down to ~30x20px) since the entire chart is just the mark.
|
|
99
|
+
*/
|
|
100
|
+
function getMinChartDims(display: import('@opendata-ai/openchart-core').Display): {
|
|
101
|
+
width: number;
|
|
102
|
+
height: number;
|
|
103
|
+
} {
|
|
104
|
+
return display === 'sparkline'
|
|
105
|
+
? { width: 30, height: 20 }
|
|
106
|
+
: { width: MIN_CHART_WIDTH, height: MIN_CHART_HEIGHT };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the per-side safety padding for sparkline mode. Padding scales with
|
|
111
|
+
* the user-configured mark stroke width so a thick line doesn't clip at the
|
|
112
|
+
* container edge. Per-side padding = max(strokeWidth/2 + 1, 2) so even a 1px
|
|
113
|
+
* stroke gets at least 2px breathing room.
|
|
114
|
+
*/
|
|
115
|
+
function getSparklinePad(spec: NormalizedChartSpec): number {
|
|
116
|
+
const strokeWidth = (spec.markDef as { strokeWidth?: number }).strokeWidth ?? 2;
|
|
117
|
+
return Math.max(strokeWidth / 2 + 1, 2);
|
|
118
|
+
}
|
|
119
|
+
|
|
96
120
|
// ---------------------------------------------------------------------------
|
|
97
121
|
// Public API
|
|
98
122
|
// ---------------------------------------------------------------------------
|
|
@@ -125,7 +149,16 @@ export function computeDimensions(
|
|
|
125
149
|
? Math.max(Math.round(padding * HPAD_COMPACT_FRACTION), HPAD_COMPACT_MIN)
|
|
126
150
|
: padding;
|
|
127
151
|
const axisMargin = theme.spacing.axisMargin;
|
|
128
|
-
const
|
|
152
|
+
const userExplicit = spec.userExplicit;
|
|
153
|
+
const isSparkline = spec.display === 'sparkline';
|
|
154
|
+
|
|
155
|
+
// Sparkline mode forces chrome hidden unless the user opted in explicitly.
|
|
156
|
+
// Force-hiding chrome here also short-circuits the watermark (which is
|
|
157
|
+
// rendered as part of chrome), so we don't need a separate watermark gate.
|
|
158
|
+
let chromeMode = strategy?.chromeMode ?? 'full';
|
|
159
|
+
if (isSparkline && !userExplicit.chrome) {
|
|
160
|
+
chromeMode = 'hidden';
|
|
161
|
+
}
|
|
129
162
|
|
|
130
163
|
// Compute chrome with mode and scaled padding
|
|
131
164
|
const chrome = computeChrome(
|
|
@@ -138,6 +171,47 @@ export function computeDimensions(
|
|
|
138
171
|
watermark,
|
|
139
172
|
);
|
|
140
173
|
|
|
174
|
+
// Sparkline mode: produce a near-edge-to-edge layout. Only stroke-width-based
|
|
175
|
+
// safety padding plus chrome (if user-explicit). Skip axis space, label
|
|
176
|
+
// reservations, annotation reservations, and legend reservations unless the
|
|
177
|
+
// user opted in to those individually.
|
|
178
|
+
if (isSparkline) {
|
|
179
|
+
const total: Rect = { x: 0, y: 0, width, height };
|
|
180
|
+
const sparkPad = getSparklinePad(spec);
|
|
181
|
+
|
|
182
|
+
// Axis space only when user explicitly set encoding.x/y.axis.
|
|
183
|
+
const xAxisSpace = userExplicit.xAxis ? 26 : 0;
|
|
184
|
+
const yAxisSpace = userExplicit.yAxis ? 30 : 0;
|
|
185
|
+
|
|
186
|
+
const margins: Margins = {
|
|
187
|
+
top: chrome.topHeight + sparkPad,
|
|
188
|
+
right: sparkPad,
|
|
189
|
+
bottom: chrome.bottomHeight + sparkPad + xAxisSpace,
|
|
190
|
+
left: sparkPad + yAxisSpace,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// Reserve legend space only when user explicitly opted into a legend.
|
|
194
|
+
if (userExplicit.legend && 'entries' in legendLayout && legendLayout.entries.length > 0) {
|
|
195
|
+
const gap = legendGap(width);
|
|
196
|
+
if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
|
|
197
|
+
margins.right += legendLayout.bounds.width + 8;
|
|
198
|
+
} else if (legendLayout.position === 'top') {
|
|
199
|
+
margins.top += legendLayout.bounds.height + gap;
|
|
200
|
+
} else if (legendLayout.position === 'bottom') {
|
|
201
|
+
margins.bottom += legendLayout.bounds.height + gap;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const chartArea: Rect = {
|
|
206
|
+
x: margins.left,
|
|
207
|
+
y: margins.top,
|
|
208
|
+
width: Math.max(0, width - margins.left - margins.right),
|
|
209
|
+
height: Math.max(0, height - margins.top - margins.bottom),
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return { total, chrome, chartArea, margins, theme };
|
|
213
|
+
}
|
|
214
|
+
|
|
141
215
|
// Start with the total rect
|
|
142
216
|
const total: Rect = { x: 0, y: 0, width, height };
|
|
143
217
|
|
|
@@ -395,8 +469,9 @@ export function computeDimensions(
|
|
|
395
469
|
};
|
|
396
470
|
|
|
397
471
|
// Guardrail: if chart area is too small, progressively strip chrome
|
|
472
|
+
const minDims = getMinChartDims(spec.display);
|
|
398
473
|
if (
|
|
399
|
-
(chartArea.width <
|
|
474
|
+
(chartArea.width < minDims.width || chartArea.height < minDims.height) &&
|
|
400
475
|
chromeMode !== 'hidden'
|
|
401
476
|
) {
|
|
402
477
|
// Try compact first, then hidden
|
package/src/legend/compute.ts
CHANGED
|
@@ -143,8 +143,13 @@ export function computeLegend(
|
|
|
143
143
|
chartArea: Rect,
|
|
144
144
|
watermark: boolean = true,
|
|
145
145
|
): LegendLayout {
|
|
146
|
+
// Sparkline mode: legend hidden by default unless the user opted in. Color
|
|
147
|
+
// scales still resolve normally (legend hidden != no colors), so multi-series
|
|
148
|
+
// sparklines retain their categorical palette.
|
|
149
|
+
const sparklineHidden = spec.display === 'sparkline' && !spec.userExplicit.legend;
|
|
150
|
+
|
|
146
151
|
// Legend explicitly hidden via show: false, or height strategy says no legend
|
|
147
|
-
if (spec.legend?.show === false || strategy.legendMaxHeight === 0) {
|
|
152
|
+
if (sparklineHidden || spec.legend?.show === false || strategy.legendMaxHeight === 0) {
|
|
148
153
|
return {
|
|
149
154
|
position: 'top',
|
|
150
155
|
entries: [],
|