@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.
@@ -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
 
@@ -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. */
@@ -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';
@@ -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 chromeMode = strategy?.chromeMode ?? 'full';
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 < MIN_CHART_WIDTH || chartArea.height < MIN_CHART_HEIGHT) &&
474
+ (chartArea.width < minDims.width || chartArea.height < minDims.height) &&
400
475
  chromeMode !== 'hidden'
401
476
  ) {
402
477
  // Try compact first, then hidden
@@ -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: [],