@opendata-ai/openchart-engine 7.9.0 → 7.9.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.
@@ -38,6 +38,9 @@ import {
38
38
  computeChrome,
39
39
  estimateTextWidth,
40
40
  formatNumber,
41
+ getBreakpoint,
42
+ HPAD_COMPACT_FRACTION,
43
+ HPAD_COMPACT_MIN,
41
44
  pickLabelColor,
42
45
  resolveTheme,
43
46
  SEQUENTIAL_PALETTES,
@@ -59,6 +62,10 @@ const LEGEND_SWATCH_SIZE = 10;
59
62
  const LEGEND_SWATCH_GAP = 6;
60
63
  const LEGEND_ENTRY_GAP = 16;
61
64
 
65
+ function clamp(value: number, min: number, max: number): number {
66
+ return Math.min(max, Math.max(min, value));
67
+ }
68
+
62
69
  // ---------------------------------------------------------------------------
63
70
  // Public API
64
71
  // ---------------------------------------------------------------------------
@@ -118,7 +125,15 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL
118
125
  );
119
126
 
120
127
  // 4. Compute drawing area (total space minus chrome)
121
- const padding = theme.spacing.padding;
128
+ // On compact (mobile) widths, reclaim horizontal space by shrinking the
129
+ // padding — every reclaimed pixel widens the tiles, which are width-bound at
130
+ // these sizes. Mirrors how the chart pipeline uses HPAD_COMPACT_* on narrow
131
+ // viewports. The tile-area, chrome, and content-height math all read this one
132
+ // value, so overriding it here keeps every derived measurement consistent.
133
+ const padding =
134
+ getBreakpoint(options.width) === 'compact'
135
+ ? Math.max(HPAD_COMPACT_MIN, Math.round(theme.spacing.padding * HPAD_COMPACT_FRACTION))
136
+ : theme.spacing.padding;
122
137
  const fullArea = {
123
138
  x: padding,
124
139
  y: padding + chrome.topHeight,
@@ -269,7 +284,9 @@ function compileQuantitative(
269
284
  };
270
285
 
271
286
  const resolvedAnimation: ResolvedAnimation | undefined = resolveAnimation(tilemapSpec.animation);
272
- const padding = theme.spacing.padding;
287
+ // fullArea.x is the (possibly compact-reduced) padding chosen in compileTileMap;
288
+ // reuse it so contentHeight matches the drawing area exactly.
289
+ const padding = fullArea.x;
273
290
  const contentHeight =
274
291
  tileGridOffsetY +
275
292
  tilePositions.gridHeight +
@@ -354,11 +371,18 @@ function compileCategorical(
354
371
  }
355
372
 
356
373
  const showLegend = tilemapSpec.legend?.show !== false;
374
+ const legendLabelFontSize = 11;
357
375
  const legendRowHeight = LEGEND_SWATCH_SIZE + 6;
358
- const legendTotalHeight = showLegend ? legendRowHeight : 0;
376
+ const legendLabels = categories.map(formatCategoryLabel);
359
377
 
378
+ // Estimate legend rows against the full width to reserve tile-area height.
379
+ // The renderer wraps against the (narrower) grid width, so we recompute the
380
+ // real row count below and use that for final placement — never under-reserve.
360
381
  const legendGap = showLegend ? 8 : 0;
361
- const tileAreaHeight = fullArea.height - legendTotalHeight - legendGap;
382
+ const estimatedRows = showLegend
383
+ ? countLegendRows(legendLabels, fullArea.width, legendLabelFontSize)
384
+ : 0;
385
+ const tileAreaHeight = fullArea.height - legendRowHeight * estimatedRows - legendGap;
362
386
  const tilePositions = computeTilePositions(fullArea.width, tileAreaHeight, 5);
363
387
 
364
388
  const tileGridOffsetX = fullArea.x + (fullArea.width - tilePositions.gridWidth) / 2;
@@ -367,6 +391,13 @@ function compileCategorical(
367
391
  const legendY = tileGridOffsetY + tilePositions.gridHeight + legendGap;
368
392
  const legendWidth = tilePositions.gridWidth;
369
393
 
394
+ // Reserve height for every wrapped row so a legend that spills onto a second
395
+ // line (common on narrow mobile widths) doesn't collide with the source text.
396
+ const legendRows = showLegend
397
+ ? countLegendRows(legendLabels, legendWidth, legendLabelFontSize)
398
+ : 0;
399
+ const legendTotalHeight = showLegend ? legendRowHeight * legendRows : 0;
400
+
370
401
  const neutralFill = isDarkMode ? '#1e2a30' : '#e0e0e0';
371
402
  const neutralStroke = isDarkMode ? 'rgba(255,255,255,0.08)' : '#d0d0d0';
372
403
 
@@ -411,7 +442,7 @@ function compileCategorical(
411
442
  if (showLegend) {
412
443
  const labelStyle: TextStyle = {
413
444
  fontFamily: theme.fonts.family,
414
- fontSize: 11,
445
+ fontSize: legendLabelFontSize,
415
446
  fontWeight: 400,
416
447
  fill: theme.colors.text,
417
448
  lineHeight: 1.2,
@@ -426,7 +457,7 @@ function compileCategorical(
426
457
  categoricalLegend = {
427
458
  type: 'categorical',
428
459
  position: 'bottom',
429
- bounds: { x: legendX, y: legendY, width: legendWidth, height: legendRowHeight },
460
+ bounds: { x: legendX, y: legendY, width: legendWidth, height: legendTotalHeight },
430
461
  labelStyle,
431
462
  entries,
432
463
  swatchSize: LEGEND_SWATCH_SIZE,
@@ -446,7 +477,9 @@ function compileCategorical(
446
477
  };
447
478
 
448
479
  const resolvedAnimation: ResolvedAnimation | undefined = resolveAnimation(tilemapSpec.animation);
449
- const padding = theme.spacing.padding;
480
+ // fullArea.x is the (possibly compact-reduced) padding chosen in compileTileMap;
481
+ // reuse it so contentHeight matches the drawing area exactly.
482
+ const padding = fullArea.x;
450
483
  const contentHeight =
451
484
  tileGridOffsetY +
452
485
  tilePositions.gridHeight +
@@ -518,9 +551,20 @@ function buildTileMark(opts: TileMarkOptions): TileMapTileMark {
518
551
  const textColor = hasData ? pickLabelColor(fill, isDarkMode) : '#ffffff';
519
552
  const isLightText = textColor === '#ffffff';
520
553
 
554
+ // A two-line layout (code over value) only fits once tiles are large enough;
555
+ // below that we drop the value and center the code so nothing overflows.
556
+ const showValue = tileSize >= 24;
557
+
558
+ // Scale the code font with the tile so a 2-char label always fits its box,
559
+ // rather than snapping between two fixed sizes. A 2-char string is ~1.2x the
560
+ // font size wide; capping font at ~0.5x tile width keeps it inside the tile
561
+ // with margin, then clamp to a legible [6.5, 11] range.
562
+ const codeFontSize = clamp(Math.round(tileSize * 0.5 * 10) / 10, 6.5, 11);
563
+ const valueFontSize = clamp(Math.round(tileSize * 0.42 * 10) / 10, 6.5, 10);
564
+
521
565
  const labelStyle: TextStyle = {
522
566
  fontFamily: theme.fonts.family,
523
- fontSize: tileSize > 24 ? 10 : 7,
567
+ fontSize: codeFontSize,
524
568
  fontWeight: 700,
525
569
  fill: textColor,
526
570
  lineHeight: 1.2,
@@ -528,22 +572,27 @@ function buildTileMark(opts: TileMarkOptions): TileMapTileMark {
528
572
 
529
573
  const valueLabelStyle: TextStyle = {
530
574
  fontFamily: theme.fonts.family,
531
- fontSize: tileSize > 24 ? 10 : 7,
575
+ fontSize: valueFontSize,
532
576
  fontWeight: 300,
533
577
  fill: isLightText ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.45)',
534
578
  lineHeight: 1.2,
535
579
  };
536
580
 
537
- const valueLabel =
538
- tileSize < 24
539
- ? { text: '', x: 0, y: 0, style: valueLabelStyle, visible: false }
540
- : {
541
- text: formattedValue,
542
- x: tileCenterX,
543
- y: tileTopY + tileSize * 0.78,
544
- style: valueLabelStyle,
545
- visible: true,
546
- };
581
+ // Code sits in the upper third when paired with a value, otherwise centered.
582
+ const codeCenterY = showValue ? tileTopY + tileSize * 0.28 : tileTopY + tileSize * 0.5;
583
+
584
+ const valueLabel = showValue
585
+ ? {
586
+ text: formattedValue,
587
+ x: tileCenterX,
588
+ y: tileTopY + tileSize * 0.78,
589
+ style: valueLabelStyle,
590
+ visible: true,
591
+ }
592
+ : { text: '', x: 0, y: 0, style: valueLabelStyle, visible: false };
593
+
594
+ // Keep corners visibly rounded-rectangular, never near-circular on small tiles.
595
+ const cornerRadius = Math.min(TILE_CORNER_RADIUS, Math.round(tileSize * 0.22));
547
596
 
548
597
  const data: Record<string, unknown> = {
549
598
  state: stateCode,
@@ -564,14 +613,14 @@ function buildTileMark(opts: TileMarkOptions): TileMapTileMark {
564
613
  fillOpacity,
565
614
  stroke,
566
615
  strokeWidth: TILE_STROKE_WIDTH,
567
- cornerRadius: TILE_CORNER_RADIUS,
616
+ cornerRadius,
568
617
  value,
569
618
  formattedValue,
570
619
  hasData,
571
620
  label: {
572
621
  text: stateCode,
573
622
  x: tileCenterX,
574
- y: tileTopY + tileSize * 0.28,
623
+ y: codeCenterY,
575
624
  style: labelStyle,
576
625
  visible: true,
577
626
  },
@@ -617,6 +666,29 @@ function formatCategoryLabel(category: string): string {
617
666
  return category.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
618
667
  }
619
668
 
669
+ /**
670
+ * Count how many rows a horizontal categorical legend wraps into for a given
671
+ * available width. Mirrors the wrap logic in the legend renderer so the compiler
672
+ * can reserve enough vertical space and avoid the legend overlapping the source
673
+ * line on narrow (mobile) containers.
674
+ */
675
+ function countLegendRows(labels: string[], availableWidth: number, fontSize: number): number {
676
+ if (labels.length === 0) return 0;
677
+ let rows = 1;
678
+ let offsetX = 0;
679
+ for (let i = 0; i < labels.length; i++) {
680
+ const labelWidth = estimateTextWidth(labels[i], fontSize);
681
+ const entryWidth = LEGEND_SWATCH_SIZE + LEGEND_SWATCH_GAP + labelWidth + LEGEND_ENTRY_GAP;
682
+ if (i > 0 && offsetX + entryWidth > availableWidth) {
683
+ rows++;
684
+ offsetX = entryWidth;
685
+ } else {
686
+ offsetX += entryWidth;
687
+ }
688
+ }
689
+ return rows;
690
+ }
691
+
620
692
  // ---------------------------------------------------------------------------
621
693
  // Empty layout fallback
622
694
  // ---------------------------------------------------------------------------