@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.
@@ -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. */
@@ -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: 55,
46
- reduced: 90,
47
- minimal: 140,
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
- return raw.map((value: unknown) => ({
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),
@@ -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 = 120;
49
- const HEIGHT_REDUCED_THRESHOLD = 200;
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';
@@ -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: [],