@opendata-ai/openchart-engine 6.26.0 → 6.27.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 +37 -1
- package/dist/index.js +166 -11
- 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__/compile-chart.test.ts +301 -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.ts +9 -2
- 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.ts
CHANGED
|
@@ -199,6 +199,13 @@ export interface AxesDataContext {
|
|
|
199
199
|
data: DataRow[];
|
|
200
200
|
/** The encoding object to resolve field names. */
|
|
201
201
|
encoding: Encoding;
|
|
202
|
+
/**
|
|
203
|
+
* When true, skip generating ticks/labels/title for the x-axis. Used by
|
|
204
|
+
* sparkline display mode when the user hasn't explicitly opted into axes.
|
|
205
|
+
*/
|
|
206
|
+
skipX?: boolean;
|
|
207
|
+
/** Same as skipX, for the y-axis. */
|
|
208
|
+
skipY?: boolean;
|
|
202
209
|
}
|
|
203
210
|
|
|
204
211
|
/**
|
|
@@ -257,7 +264,7 @@ export function computeAxes(
|
|
|
257
264
|
const { fontSize } = tickLabelStyle;
|
|
258
265
|
const { fontWeight } = tickLabelStyle;
|
|
259
266
|
|
|
260
|
-
if (scales.x) {
|
|
267
|
+
if (scales.x && !dataContext?.skipX) {
|
|
261
268
|
const axisConfig = scales.x.channel.axis;
|
|
262
269
|
const isContinuousX =
|
|
263
270
|
scales.x.type !== 'band' && scales.x.type !== 'point' && scales.x.type !== 'ordinal';
|
|
@@ -359,7 +366,7 @@ export function computeAxes(
|
|
|
359
366
|
};
|
|
360
367
|
}
|
|
361
368
|
|
|
362
|
-
if (scales.y) {
|
|
369
|
+
if (scales.y && !dataContext?.skipY) {
|
|
363
370
|
const axisConfig = scales.y.channel.axis;
|
|
364
371
|
const isContinuousY =
|
|
365
372
|
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: [],
|