@opendata-ai/openchart-engine 6.24.2 → 6.25.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.
@@ -21,7 +21,23 @@ import type {
21
21
  ResolvedChrome,
22
22
  ResolvedTheme,
23
23
  } from '@opendata-ai/openchart-core';
24
- import { computeChrome, estimateTextWidth } from '@opendata-ai/openchart-core';
24
+ import {
25
+ AXIS_TITLE_TRAILING_PAD,
26
+ BREAKPOINT_COMPACT_MAX,
27
+ computeChrome,
28
+ estimateTextWidth,
29
+ getAxisTitleOffset,
30
+ HPAD_COMPACT_FRACTION,
31
+ HPAD_COMPACT_MIN,
32
+ LABEL_GAP_COMPACT,
33
+ LABEL_GAP_DEFAULT,
34
+ MAX_LEFT_LABEL_FRACTION_COMPACT,
35
+ MAX_LEFT_LABEL_FRACTION_DEFAULT,
36
+ MAX_LEFT_LABEL_FRACTION_MEDIUM,
37
+ MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
38
+ NARROW_VIEWPORT_MAX,
39
+ TOP_PAD_EXTRA_NARROW,
40
+ } from '@opendata-ai/openchart-core';
25
41
  import { format as d3Format } from 'd3-format';
26
42
 
27
43
  import type { NormalizedChartSpec, NormalizedChrome } from '../compiler/types';
@@ -102,6 +118,12 @@ export function computeDimensions(
102
118
  const { width, height } = options;
103
119
 
104
120
  const padding = scalePadding(theme.spacing.padding, width, height);
121
+ // Horizontal padding can be tighter than the chrome text padding on narrow
122
+ // containers because axis titles and tick labels tolerate closer edges.
123
+ const hPad =
124
+ width < BREAKPOINT_COMPACT_MAX
125
+ ? Math.max(Math.round(padding * HPAD_COMPACT_FRACTION), HPAD_COMPACT_MIN)
126
+ : padding;
105
127
  const axisMargin = theme.spacing.axisMargin;
106
128
  const chromeMode = strategy?.chromeMode ?? 'full';
107
129
 
@@ -160,11 +182,14 @@ export function computeDimensions(
160
182
  // added when there's actual chrome content that needs separation from the
161
183
  // chart area. When chrome is empty the margin is just padding.
162
184
  const topAxisGap = isRadial && chrome.topHeight === 0 ? 0 : axisMargin;
185
+ // Extra top padding on narrow viewports prevents iOS Safari from clipping
186
+ // the title chrome behind the browser UI.
187
+ const topPad = width < NARROW_VIEWPORT_MAX ? padding + TOP_PAD_EXTRA_NARROW : padding;
163
188
  const margins: Margins = {
164
- top: padding + chrome.topHeight + topAxisGap,
165
- right: padding + (isRadial ? padding : axisMargin),
189
+ top: topPad + chrome.topHeight + topAxisGap,
190
+ right: hPad + (isRadial ? hPad : axisMargin),
166
191
  bottom: padding + chrome.bottomHeight + xAxisHeight,
167
- left: padding + (isRadial ? padding : axisMargin),
192
+ left: hPad + (isRadial ? hPad : axisMargin),
168
193
  };
169
194
 
170
195
  // Dynamic right margin for line/area end-of-line labels.
@@ -191,7 +216,7 @@ export function computeDimensions(
191
216
  }
192
217
  }
193
218
  if (maxLabelWidth > 0) {
194
- margins.right = Math.max(margins.right, padding + maxLabelWidth + 8);
219
+ margins.right = Math.max(margins.right, hPad + maxLabelWidth + 8);
195
220
  }
196
221
  }
197
222
  }
@@ -232,7 +257,7 @@ export function computeDimensions(
232
257
  textWidth / 2; // centered (top/bottom/auto)
233
258
  const rightOverflow = Math.max(0, baseRightExtent + dx);
234
259
  if (rightOverflow > 0) {
235
- margins.right = Math.max(margins.right, padding + rightOverflow + 12);
260
+ margins.right = Math.max(margins.right, hPad + rightOverflow + 12);
236
261
  }
237
262
  }
238
263
  }
@@ -258,13 +283,18 @@ export function computeDimensions(
258
283
  }
259
284
  if (maxLabelWidth > 0) {
260
285
  // Tighter label-to-chart gap on narrow containers
261
- const labelGap = width < 500 ? 8 : 12;
286
+ const labelGap = width < NARROW_VIEWPORT_MAX ? LABEL_GAP_COMPACT : LABEL_GAP_DEFAULT;
262
287
  // Clamp reservation so bars keep at least ~45% of container width on
263
288
  // narrow viewports. Labels that exceed the cap will be truncated by
264
289
  // the axis renderer (see axes.ts).
265
- const maxLeftFraction = width < 400 ? 0.45 : width < 600 ? 0.55 : 1;
290
+ const maxLeftFraction =
291
+ width < BREAKPOINT_COMPACT_MAX
292
+ ? MAX_LEFT_LABEL_FRACTION_COMPACT
293
+ : width < MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX
294
+ ? MAX_LEFT_LABEL_FRACTION_MEDIUM
295
+ : MAX_LEFT_LABEL_FRACTION_DEFAULT;
266
296
  const maxLeftReserved = Math.floor(width * maxLeftFraction);
267
- const reserved = Math.min(padding + maxLabelWidth + labelGap, maxLeftReserved);
297
+ const reserved = Math.min(hPad + maxLabelWidth + labelGap, maxLeftReserved);
268
298
  margins.left = Math.max(margins.left, reserved);
269
299
  }
270
300
  } else if (encoding.y.type === 'quantitative' || encoding.y.type === 'temporal') {
@@ -306,15 +336,26 @@ export function computeDimensions(
306
336
  theme.fonts.weights.normal,
307
337
  );
308
338
  // 6px gap between label and chart area edge
309
- margins.left = Math.max(margins.left, padding + labelWidth + 10);
339
+ margins.left = Math.max(margins.left, hPad + labelWidth + 10);
310
340
  }
311
341
  }
312
342
 
313
- // Rotated y-axis label needs extra left margin (rendered at area.x - 45 in SVG)
343
+ // Rotated y-axis label needs extra left margin (rendered at area.x - offset in SVG).
344
+ // Tighter on compact viewports where horizontal space is scarce.
314
345
  const yAxis = encoding.y?.axis as Record<string, unknown> | undefined;
315
346
  if (yAxis && (yAxis.title || yAxis.label) && !isRadial) {
316
- const rotatedLabelMargin = 45 + Math.ceil(theme.fonts.sizes.body / 2) + 4;
317
- margins.left = Math.max(margins.left, padding + rotatedLabelMargin);
347
+ const axisTitleOffset = getAxisTitleOffset(width);
348
+ const halfGlyph = Math.ceil(theme.fonts.sizes.body / 2);
349
+ const rotatedLabelMargin =
350
+ axisTitleOffset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
351
+ margins.left = Math.max(margins.left, hPad + rotatedLabelMargin);
352
+ }
353
+
354
+ // Reserve space for a secondary (right) y-axis in dual-axis charts.
355
+ // Use Math.max (not +=) to mirror the left-margin pattern: the reserve
356
+ // replaces the base axisMargin when it's larger, instead of stacking.
357
+ if (options.rightAxisReserve && options.rightAxisReserve > 0) {
358
+ margins.right = Math.max(margins.right, hPad + options.rightAxisReserve);
318
359
  }
319
360
 
320
361
  // Reserve legend space
@@ -354,9 +395,10 @@ export function computeDimensions(
354
395
  watermark,
355
396
  );
356
397
 
357
- // Recalculate top/bottom margins with stripped chrome
398
+ // Recalculate top/bottom margins with stripped chrome.
399
+ // Use topPad (not padding) to preserve the iOS Safari clearance on narrow viewports.
358
400
  const fallbackTopAxisGap = isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin;
359
- const newTop = padding + fallbackChrome.topHeight + fallbackTopAxisGap;
401
+ const newTop = topPad + fallbackChrome.topHeight + fallbackTopAxisGap;
360
402
  const topDelta = margins.top - newTop;
361
403
  const newBottom = padding + fallbackChrome.bottomHeight + xAxisHeight;
362
404
  const bottomDelta = margins.bottom - newBottom;