@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.
- package/dist/index.js +1459 -1404
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +108 -0
- package/src/__tests__/dimensions.test.ts +157 -5
- package/src/layout/axes.ts +142 -35
- package/src/layout/dimensions.ts +10 -11
- package/src/layout/plan.ts +11 -12
- package/src/tilemap/compile-tilemap.ts +93 -21
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
|
616
|
+
cornerRadius,
|
|
568
617
|
value,
|
|
569
618
|
formattedValue,
|
|
570
619
|
hasData,
|
|
571
620
|
label: {
|
|
572
621
|
text: stateCode,
|
|
573
622
|
x: tileCenterX,
|
|
574
|
-
y:
|
|
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
|
// ---------------------------------------------------------------------------
|