@opendata-ai/openchart-engine 2.9.1 → 2.11.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.
- package/dist/index.js +317 -62
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +4 -0
- package/src/__tests__/axes.test.ts +183 -2
- package/src/__tests__/legend.test.ts +4 -0
- package/src/annotations/__tests__/compute.test.ts +173 -4
- package/src/annotations/compute.ts +158 -41
- package/src/charts/column/__tests__/labels.test.ts +104 -0
- package/src/charts/dot/__tests__/labels.test.ts +98 -0
- package/src/charts/pie/__tests__/labels.test.ts +132 -0
- package/src/compile.ts +63 -13
- package/src/layout/axes.ts +131 -11
- package/src/layout/dimensions.ts +77 -4
- package/src/legend/compute.ts +105 -7
package/dist/index.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
// src/compile.ts
|
|
2
2
|
import {
|
|
3
3
|
adaptTheme as adaptTheme2,
|
|
4
|
+
BRAND_RESERVE_WIDTH as BRAND_RESERVE_WIDTH2,
|
|
5
|
+
computeLabelBounds,
|
|
4
6
|
generateAltText,
|
|
5
7
|
generateDataTable,
|
|
6
8
|
getBreakpoint,
|
|
9
|
+
getHeightClass,
|
|
7
10
|
getLayoutStrategy,
|
|
8
11
|
resolveTheme as resolveTheme2
|
|
9
12
|
} from "@opendata-ai/openchart-core";
|
|
10
13
|
|
|
11
14
|
// src/annotations/compute.ts
|
|
12
|
-
import { estimateTextWidth } from "@opendata-ai/openchart-core";
|
|
15
|
+
import { detectCollision, estimateTextWidth } from "@opendata-ai/openchart-core";
|
|
13
16
|
var DEFAULT_ANNOTATION_FONT_SIZE = 12;
|
|
14
17
|
var DEFAULT_ANNOTATION_FONT_WEIGHT = 400;
|
|
15
18
|
var DEFAULT_LINE_HEIGHT = 1.3;
|
|
@@ -324,41 +327,34 @@ function estimateLabelBounds(label) {
|
|
|
324
327
|
const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
325
328
|
return computeTextBounds(label.x, label.y, label.text, fontSize, fontWeight);
|
|
326
329
|
}
|
|
327
|
-
function rectsOverlap(a, b) {
|
|
328
|
-
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
329
|
-
}
|
|
330
330
|
var NUDGE_PADDING = 6;
|
|
331
|
+
function generateNudgeCandidates(selfBounds, obstacles, padding) {
|
|
332
|
+
const candidates = [];
|
|
333
|
+
for (const obs of obstacles) {
|
|
334
|
+
const belowDy = obs.y + obs.height + padding - selfBounds.y;
|
|
335
|
+
candidates.push({ dx: 0, dy: belowDy, distance: Math.abs(belowDy) });
|
|
336
|
+
const aboveDy = obs.y - padding - (selfBounds.y + selfBounds.height);
|
|
337
|
+
candidates.push({ dx: 0, dy: aboveDy, distance: Math.abs(aboveDy) });
|
|
338
|
+
const leftDx = obs.x - padding - (selfBounds.x + selfBounds.width);
|
|
339
|
+
candidates.push({ dx: leftDx, dy: 0, distance: Math.abs(leftDx) });
|
|
340
|
+
const rightDx = obs.x + obs.width + padding - selfBounds.x;
|
|
341
|
+
candidates.push({ dx: rightDx, dy: 0, distance: Math.abs(rightDx) });
|
|
342
|
+
}
|
|
343
|
+
candidates.sort((a, b) => a.distance - b.distance);
|
|
344
|
+
return candidates;
|
|
345
|
+
}
|
|
331
346
|
function nudgeAnnotationFromObstacles(annotation, originalAnnotation, scales, chartArea, obstacles) {
|
|
332
347
|
if (annotation.type !== "text" || !annotation.label) return false;
|
|
333
348
|
const labelBounds = estimateLabelBounds(annotation.label);
|
|
334
349
|
const collidingObs = obstacles.filter(
|
|
335
|
-
(obs) => obs.width > 0 && obs.height > 0 &&
|
|
350
|
+
(obs) => obs.width > 0 && obs.height > 0 && detectCollision(labelBounds, obs)
|
|
336
351
|
);
|
|
337
352
|
if (collidingObs.length === 0) return false;
|
|
338
353
|
const px = resolvePosition(originalAnnotation.x, scales.x);
|
|
339
354
|
const py = resolvePosition(originalAnnotation.y, scales.y);
|
|
340
355
|
if (px === null || py === null) return false;
|
|
341
|
-
const candidates =
|
|
356
|
+
const candidates = generateNudgeCandidates(labelBounds, collidingObs, NUDGE_PADDING);
|
|
342
357
|
const fontSize = labelBounds.height / Math.max(1, annotation.label.text.split("\n").length);
|
|
343
|
-
for (const obs of collidingObs) {
|
|
344
|
-
const currentLabelTop = labelBounds.y;
|
|
345
|
-
const targetLabelTop = obs.y + obs.height + NUDGE_PADDING;
|
|
346
|
-
const belowDy = targetLabelTop - currentLabelTop;
|
|
347
|
-
candidates.push({ dx: 0, dy: belowDy, distance: Math.abs(belowDy) });
|
|
348
|
-
const currentLabelBottom = labelBounds.y + labelBounds.height;
|
|
349
|
-
const targetLabelBottom = obs.y - NUDGE_PADDING;
|
|
350
|
-
const aboveDy = targetLabelBottom - currentLabelBottom;
|
|
351
|
-
candidates.push({ dx: 0, dy: aboveDy, distance: Math.abs(aboveDy) });
|
|
352
|
-
const currentLabelRight = labelBounds.x + labelBounds.width;
|
|
353
|
-
const targetLabelRight = obs.x - NUDGE_PADDING;
|
|
354
|
-
const leftDx = targetLabelRight - currentLabelRight;
|
|
355
|
-
candidates.push({ dx: leftDx, dy: 0, distance: Math.abs(leftDx) });
|
|
356
|
-
const currentLabelLeft = labelBounds.x;
|
|
357
|
-
const targetLabelLeft = obs.x + obs.width + NUDGE_PADDING;
|
|
358
|
-
const rightDx = targetLabelLeft - currentLabelLeft;
|
|
359
|
-
candidates.push({ dx: rightDx, dy: 0, distance: Math.abs(rightDx) });
|
|
360
|
-
}
|
|
361
|
-
candidates.sort((a, b) => a.distance - b.distance);
|
|
362
358
|
for (const { dx, dy } of candidates) {
|
|
363
359
|
const newLabelX = annotation.label.x + dx;
|
|
364
360
|
const newLabelY = annotation.label.y + dy;
|
|
@@ -387,12 +383,12 @@ function nudgeAnnotationFromObstacles(annotation, originalAnnotation, scales, ch
|
|
|
387
383
|
};
|
|
388
384
|
const candidateBounds = estimateLabelBounds(candidateLabel);
|
|
389
385
|
const stillCollides = obstacles.some(
|
|
390
|
-
(obs) => obs.width > 0 && obs.height > 0 &&
|
|
386
|
+
(obs) => obs.width > 0 && obs.height > 0 && detectCollision(candidateBounds, obs)
|
|
391
387
|
);
|
|
392
388
|
if (stillCollides) continue;
|
|
393
389
|
const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
|
|
394
390
|
const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
|
|
395
|
-
const inBounds = labelCenterX >= chartArea.x && labelCenterX <= chartArea.x + chartArea.width + 100 && labelCenterY >= chartArea.y - fontSize && labelCenterY <= chartArea.y + chartArea.height + fontSize;
|
|
391
|
+
const inBounds = labelCenterX >= chartArea.x && labelCenterX <= chartArea.x + chartArea.width + 100 && labelCenterY >= chartArea.y - fontSize && labelCenterY <= chartArea.y + chartArea.height + fontSize * 3;
|
|
396
392
|
if (inBounds) {
|
|
397
393
|
if (candidateLabel.connector && dx === 0 && dy !== 0) {
|
|
398
394
|
candidateLabel.connector = {
|
|
@@ -406,6 +402,74 @@ function nudgeAnnotationFromObstacles(annotation, originalAnnotation, scales, ch
|
|
|
406
402
|
}
|
|
407
403
|
return false;
|
|
408
404
|
}
|
|
405
|
+
function resolveAnnotationCollisions(annotations, originalSpecs, scales, chartArea) {
|
|
406
|
+
const placedBounds = [];
|
|
407
|
+
for (let i = 0; i < annotations.length; i++) {
|
|
408
|
+
const annotation = annotations[i];
|
|
409
|
+
if (annotation.type !== "text" || !annotation.label) {
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const bounds = estimateLabelBounds(annotation.label);
|
|
413
|
+
const collidingBounds = placedBounds.filter(
|
|
414
|
+
(pb) => pb.width > 0 && pb.height > 0 && detectCollision(bounds, pb)
|
|
415
|
+
);
|
|
416
|
+
if (collidingBounds.length > 0) {
|
|
417
|
+
const originalSpec = originalSpecs[i];
|
|
418
|
+
if (originalSpec?.type === "text") {
|
|
419
|
+
const px = resolvePosition(originalSpec.x, scales.x);
|
|
420
|
+
const py = resolvePosition(originalSpec.y, scales.y);
|
|
421
|
+
if (px !== null && py !== null) {
|
|
422
|
+
const candidates = generateNudgeCandidates(bounds, collidingBounds, NUDGE_PADDING);
|
|
423
|
+
const fontSize = bounds.height / Math.max(1, annotation.label.text.split("\n").length);
|
|
424
|
+
for (const { dx, dy } of candidates) {
|
|
425
|
+
const newLabelX = annotation.label.x + dx;
|
|
426
|
+
const newLabelY = annotation.label.y + dy;
|
|
427
|
+
const candidateLabel = {
|
|
428
|
+
...annotation.label,
|
|
429
|
+
x: newLabelX,
|
|
430
|
+
y: newLabelY
|
|
431
|
+
};
|
|
432
|
+
const candidateBounds = estimateLabelBounds(candidateLabel);
|
|
433
|
+
const stillCollides = placedBounds.some(
|
|
434
|
+
(pb) => pb.width > 0 && pb.height > 0 && detectCollision(candidateBounds, pb)
|
|
435
|
+
);
|
|
436
|
+
if (stillCollides) continue;
|
|
437
|
+
const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
|
|
438
|
+
const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
|
|
439
|
+
const inBounds = labelCenterX >= chartArea.x && labelCenterX <= chartArea.x + chartArea.width + 100 && labelCenterY >= chartArea.y - fontSize && labelCenterY <= chartArea.y + chartArea.height + fontSize;
|
|
440
|
+
if (inBounds) {
|
|
441
|
+
let newConnector = annotation.label.connector;
|
|
442
|
+
if (newConnector) {
|
|
443
|
+
const annFontSize = annotation.label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
444
|
+
const annFontWeight = annotation.label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
445
|
+
const connStyle = newConnector.style === "curve" ? "curve" : "straight";
|
|
446
|
+
const newFrom = computeConnectorOrigin(
|
|
447
|
+
newLabelX,
|
|
448
|
+
newLabelY,
|
|
449
|
+
annotation.label.text,
|
|
450
|
+
annFontSize,
|
|
451
|
+
annFontWeight,
|
|
452
|
+
px,
|
|
453
|
+
py,
|
|
454
|
+
connStyle
|
|
455
|
+
);
|
|
456
|
+
newConnector = { ...newConnector, from: newFrom };
|
|
457
|
+
}
|
|
458
|
+
annotation.label = {
|
|
459
|
+
...annotation.label,
|
|
460
|
+
x: newLabelX,
|
|
461
|
+
y: newLabelY,
|
|
462
|
+
connector: newConnector
|
|
463
|
+
};
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
placedBounds.push(estimateLabelBounds(annotation.label));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
409
473
|
function computeAnnotations(spec, scales, chartArea, strategy, isDark = false, obstacles = []) {
|
|
410
474
|
if (strategy.annotationPosition === "tooltip-only") {
|
|
411
475
|
return [];
|
|
@@ -431,6 +495,7 @@ function computeAnnotations(spec, scales, chartArea, strategy, isDark = false, o
|
|
|
431
495
|
annotations.push(resolved);
|
|
432
496
|
}
|
|
433
497
|
}
|
|
498
|
+
resolveAnnotationCollisions(annotations, spec.annotations, scales, chartArea);
|
|
434
499
|
annotations.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
|
|
435
500
|
return annotations;
|
|
436
501
|
}
|
|
@@ -5979,6 +6044,7 @@ var DEFAULT_COLLISION_PADDING = 5;
|
|
|
5979
6044
|
import {
|
|
5980
6045
|
abbreviateNumber as abbreviateNumber3,
|
|
5981
6046
|
buildD3Formatter as buildD3Formatter3,
|
|
6047
|
+
estimateTextWidth as estimateTextWidth7,
|
|
5982
6048
|
formatDate,
|
|
5983
6049
|
formatNumber as formatNumber3
|
|
5984
6050
|
} from "@opendata-ai/openchart-core";
|
|
@@ -5991,6 +6057,8 @@ var HEIGHT_MINIMAL_THRESHOLD = 120;
|
|
|
5991
6057
|
var HEIGHT_REDUCED_THRESHOLD = 200;
|
|
5992
6058
|
var WIDTH_MINIMAL_THRESHOLD = 150;
|
|
5993
6059
|
var WIDTH_REDUCED_THRESHOLD = 300;
|
|
6060
|
+
var MIN_TICK_GAP_FACTOR = 1;
|
|
6061
|
+
var MIN_TICK_COUNT = 2;
|
|
5994
6062
|
var DENSITY_ORDER = ["full", "reduced", "minimal"];
|
|
5995
6063
|
function effectiveDensity(baseDensity, axisLength, minimalThreshold, reducedThreshold) {
|
|
5996
6064
|
let density = baseDensity;
|
|
@@ -6003,17 +6071,49 @@ function effectiveDensity(baseDensity, axisLength, minimalThreshold, reducedThre
|
|
|
6003
6071
|
}
|
|
6004
6072
|
return density;
|
|
6005
6073
|
}
|
|
6006
|
-
function
|
|
6074
|
+
function measureLabel(text, fontSize, fontWeight, measureText) {
|
|
6075
|
+
return measureText ? measureText(text, fontSize, fontWeight).width : estimateTextWidth7(text, fontSize, fontWeight);
|
|
6076
|
+
}
|
|
6077
|
+
function ticksOverlap(ticks2, fontSize, fontWeight, measureText) {
|
|
6078
|
+
if (ticks2.length < 2) return false;
|
|
6079
|
+
const minGap = fontSize * MIN_TICK_GAP_FACTOR;
|
|
6080
|
+
for (let i = 0; i < ticks2.length - 1; i++) {
|
|
6081
|
+
const aWidth = measureLabel(ticks2[i].label, fontSize, fontWeight, measureText);
|
|
6082
|
+
const bWidth = measureLabel(ticks2[i + 1].label, fontSize, fontWeight, measureText);
|
|
6083
|
+
const aRight = ticks2[i].position + aWidth / 2;
|
|
6084
|
+
const bLeft = ticks2[i + 1].position - bWidth / 2;
|
|
6085
|
+
if (aRight + minGap > bLeft) return true;
|
|
6086
|
+
}
|
|
6087
|
+
return false;
|
|
6088
|
+
}
|
|
6089
|
+
function thinTicksUntilFit(ticks2, fontSize, fontWeight, measureText) {
|
|
6090
|
+
if (!ticksOverlap(ticks2, fontSize, fontWeight, measureText)) return ticks2;
|
|
6091
|
+
let current = ticks2;
|
|
6092
|
+
while (current.length > MIN_TICK_COUNT) {
|
|
6093
|
+
const thinned = [current[0]];
|
|
6094
|
+
for (let i = 2; i < current.length - 1; i += 2) {
|
|
6095
|
+
thinned.push(current[i]);
|
|
6096
|
+
}
|
|
6097
|
+
if (current.length > 1) thinned.push(current[current.length - 1]);
|
|
6098
|
+
current = thinned;
|
|
6099
|
+
if (!ticksOverlap(current, fontSize, fontWeight, measureText)) break;
|
|
6100
|
+
}
|
|
6101
|
+
return current;
|
|
6102
|
+
}
|
|
6103
|
+
function continuousTicks(resolvedScale, density, fontSize, fontWeight, measureText) {
|
|
6007
6104
|
const scale = resolvedScale.scale;
|
|
6008
|
-
const
|
|
6009
|
-
const
|
|
6010
|
-
|
|
6105
|
+
const explicitCount = resolvedScale.channel.axis?.tickCount;
|
|
6106
|
+
const count = explicitCount ?? TICK_COUNTS[density];
|
|
6107
|
+
const rawTicks = scale.ticks(count);
|
|
6108
|
+
const ticks2 = rawTicks.map((value) => ({
|
|
6011
6109
|
value,
|
|
6012
6110
|
position: scale(value),
|
|
6013
6111
|
label: formatTickLabel(value, resolvedScale)
|
|
6014
6112
|
}));
|
|
6113
|
+
if (explicitCount) return ticks2;
|
|
6114
|
+
return thinTicksUntilFit(ticks2, fontSize, fontWeight, measureText);
|
|
6015
6115
|
}
|
|
6016
|
-
function categoricalTicks(resolvedScale, density) {
|
|
6116
|
+
function categoricalTicks(resolvedScale, density, fontSize, fontWeight, measureText) {
|
|
6017
6117
|
const scale = resolvedScale.scale;
|
|
6018
6118
|
const domain = scale.domain();
|
|
6019
6119
|
const explicitTickCount = resolvedScale.channel.axis?.tickCount;
|
|
@@ -6023,7 +6123,7 @@ function categoricalTicks(resolvedScale, density) {
|
|
|
6023
6123
|
const step = Math.ceil(domain.length / maxTicks);
|
|
6024
6124
|
selectedValues = domain.filter((_, i) => i % step === 0);
|
|
6025
6125
|
}
|
|
6026
|
-
|
|
6126
|
+
const ticks2 = selectedValues.map((value) => {
|
|
6027
6127
|
const bandScale = resolvedScale.type === "band" ? scale : null;
|
|
6028
6128
|
const pos = bandScale ? (bandScale(value) ?? 0) + bandScale.bandwidth() / 2 : scale(value) ?? 0;
|
|
6029
6129
|
return {
|
|
@@ -6032,6 +6132,10 @@ function categoricalTicks(resolvedScale, density) {
|
|
|
6032
6132
|
label: value
|
|
6033
6133
|
};
|
|
6034
6134
|
});
|
|
6135
|
+
if (resolvedScale.type !== "band" && !explicitTickCount) {
|
|
6136
|
+
return thinTicksUntilFit(ticks2, fontSize, fontWeight, measureText);
|
|
6137
|
+
}
|
|
6138
|
+
return ticks2;
|
|
6035
6139
|
}
|
|
6036
6140
|
function formatTickLabel(value, resolvedScale) {
|
|
6037
6141
|
const formatStr = resolvedScale.channel.axis?.format;
|
|
@@ -6050,7 +6154,7 @@ function formatTickLabel(value, resolvedScale) {
|
|
|
6050
6154
|
}
|
|
6051
6155
|
return String(value);
|
|
6052
6156
|
}
|
|
6053
|
-
function computeAxes(scales, chartArea, strategy, theme) {
|
|
6157
|
+
function computeAxes(scales, chartArea, strategy, theme, measureText) {
|
|
6054
6158
|
const result = {};
|
|
6055
6159
|
const baseDensity = strategy.axisLabelDensity;
|
|
6056
6160
|
const yDensity = effectiveDensity(
|
|
@@ -6080,25 +6184,39 @@ function computeAxes(scales, chartArea, strategy, theme) {
|
|
|
6080
6184
|
fill: theme.colors.text,
|
|
6081
6185
|
lineHeight: 1.3
|
|
6082
6186
|
};
|
|
6187
|
+
const { fontSize } = tickLabelStyle;
|
|
6188
|
+
const { fontWeight } = tickLabelStyle;
|
|
6083
6189
|
if (scales.x) {
|
|
6084
|
-
const ticks2 = scales.x.type === "band" || scales.x.type === "point" || scales.x.type === "ordinal" ? categoricalTicks(scales.x, xDensity) : continuousTicks(scales.x, xDensity);
|
|
6190
|
+
const ticks2 = scales.x.type === "band" || scales.x.type === "point" || scales.x.type === "ordinal" ? categoricalTicks(scales.x, xDensity, fontSize, fontWeight, measureText) : continuousTicks(scales.x, xDensity, fontSize, fontWeight, measureText);
|
|
6085
6191
|
const gridlines = ticks2.map((t) => ({
|
|
6086
6192
|
position: t.position,
|
|
6087
6193
|
major: true
|
|
6088
6194
|
}));
|
|
6195
|
+
let tickAngle = scales.x.channel.axis?.tickAngle;
|
|
6196
|
+
if (tickAngle === void 0 && scales.x.type === "band" && ticks2.length > 1) {
|
|
6197
|
+
const bandwidth = scales.x.scale.bandwidth();
|
|
6198
|
+
let maxLabelWidth = 0;
|
|
6199
|
+
for (const t of ticks2) {
|
|
6200
|
+
const w = measureLabel(t.label, fontSize, fontWeight, measureText);
|
|
6201
|
+
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
6202
|
+
}
|
|
6203
|
+
if (maxLabelWidth > bandwidth * 0.85) {
|
|
6204
|
+
tickAngle = -45;
|
|
6205
|
+
}
|
|
6206
|
+
}
|
|
6089
6207
|
result.x = {
|
|
6090
6208
|
ticks: ticks2,
|
|
6091
6209
|
gridlines: scales.x.channel.axis?.grid ? gridlines : [],
|
|
6092
6210
|
label: scales.x.channel.axis?.label,
|
|
6093
6211
|
labelStyle: axisLabelStyle,
|
|
6094
6212
|
tickLabelStyle,
|
|
6095
|
-
tickAngle
|
|
6213
|
+
tickAngle,
|
|
6096
6214
|
start: { x: chartArea.x, y: chartArea.y + chartArea.height },
|
|
6097
6215
|
end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height }
|
|
6098
6216
|
};
|
|
6099
6217
|
}
|
|
6100
6218
|
if (scales.y) {
|
|
6101
|
-
const ticks2 = scales.y.type === "band" || scales.y.type === "point" || scales.y.type === "ordinal" ? categoricalTicks(scales.y, yDensity) : continuousTicks(scales.y, yDensity);
|
|
6219
|
+
const ticks2 = scales.y.type === "band" || scales.y.type === "point" || scales.y.type === "ordinal" ? categoricalTicks(scales.y, yDensity, fontSize, fontWeight, measureText) : continuousTicks(scales.y, yDensity, fontSize, fontWeight, measureText);
|
|
6102
6220
|
const gridlines = ticks2.map((t) => ({
|
|
6103
6221
|
position: t.position,
|
|
6104
6222
|
major: true
|
|
@@ -6119,7 +6237,7 @@ function computeAxes(scales, chartArea, strategy, theme) {
|
|
|
6119
6237
|
}
|
|
6120
6238
|
|
|
6121
6239
|
// src/layout/dimensions.ts
|
|
6122
|
-
import { computeChrome as computeChrome2, estimateTextWidth as
|
|
6240
|
+
import { computeChrome as computeChrome2, estimateTextWidth as estimateTextWidth8 } from "@opendata-ai/openchart-core";
|
|
6123
6241
|
function chromeToInput(chrome) {
|
|
6124
6242
|
return {
|
|
6125
6243
|
title: chrome.title,
|
|
@@ -6129,11 +6247,28 @@ function chromeToInput(chrome) {
|
|
|
6129
6247
|
footer: chrome.footer
|
|
6130
6248
|
};
|
|
6131
6249
|
}
|
|
6132
|
-
function
|
|
6250
|
+
function scalePadding(basePadding, width, height) {
|
|
6251
|
+
const minDim = Math.min(width, height);
|
|
6252
|
+
if (minDim >= 500) return basePadding;
|
|
6253
|
+
if (minDim <= 200) return Math.max(Math.round(basePadding * 0.5), 4);
|
|
6254
|
+
const t = (minDim - 200) / 300;
|
|
6255
|
+
return Math.max(Math.round(basePadding * (0.5 + t * 0.5)), 4);
|
|
6256
|
+
}
|
|
6257
|
+
var MIN_CHART_WIDTH = 60;
|
|
6258
|
+
var MIN_CHART_HEIGHT = 40;
|
|
6259
|
+
function computeDimensions(spec, options, legendLayout, theme, strategy) {
|
|
6133
6260
|
const { width, height } = options;
|
|
6134
|
-
const padding = theme.spacing.padding;
|
|
6261
|
+
const padding = scalePadding(theme.spacing.padding, width, height);
|
|
6135
6262
|
const axisMargin = theme.spacing.axisMargin;
|
|
6136
|
-
const
|
|
6263
|
+
const chromeMode = strategy?.chromeMode ?? "full";
|
|
6264
|
+
const chrome = computeChrome2(
|
|
6265
|
+
chromeToInput(spec.chrome),
|
|
6266
|
+
theme,
|
|
6267
|
+
width,
|
|
6268
|
+
options.measureText,
|
|
6269
|
+
chromeMode,
|
|
6270
|
+
padding
|
|
6271
|
+
);
|
|
6137
6272
|
const total = { x: 0, y: 0, width, height };
|
|
6138
6273
|
const isRadial = spec.type === "pie" || spec.type === "donut";
|
|
6139
6274
|
const encoding = spec.encoding;
|
|
@@ -6150,7 +6285,7 @@ function computeDimensions(spec, options, legendLayout, theme) {
|
|
|
6150
6285
|
if (xField) {
|
|
6151
6286
|
for (const row of spec.data) {
|
|
6152
6287
|
const label = String(row[xField] ?? "");
|
|
6153
|
-
const w =
|
|
6288
|
+
const w = estimateTextWidth8(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
|
|
6154
6289
|
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
6155
6290
|
}
|
|
6156
6291
|
}
|
|
@@ -6176,7 +6311,7 @@ function computeDimensions(spec, options, legendLayout, theme) {
|
|
|
6176
6311
|
const label = String(row[colorField] ?? "");
|
|
6177
6312
|
if (!seen.has(label)) {
|
|
6178
6313
|
seen.add(label);
|
|
6179
|
-
const w =
|
|
6314
|
+
const w = estimateTextWidth8(label, 11, 600);
|
|
6180
6315
|
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
6181
6316
|
}
|
|
6182
6317
|
}
|
|
@@ -6191,7 +6326,7 @@ function computeDimensions(spec, options, legendLayout, theme) {
|
|
|
6191
6326
|
let maxLabelWidth = 0;
|
|
6192
6327
|
for (const row of spec.data) {
|
|
6193
6328
|
const label = String(row[yField] ?? "");
|
|
6194
|
-
const w =
|
|
6329
|
+
const w = estimateTextWidth8(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
|
|
6195
6330
|
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
6196
6331
|
}
|
|
6197
6332
|
if (maxLabelWidth > 0) {
|
|
@@ -6213,7 +6348,7 @@ function computeDimensions(spec, options, legendLayout, theme) {
|
|
|
6213
6348
|
else sampleLabel = "0.0";
|
|
6214
6349
|
const negPrefix = spec.data.some((r) => Number(r[yField]) < 0) ? "-" : "";
|
|
6215
6350
|
const labelEst = negPrefix + sampleLabel;
|
|
6216
|
-
const labelWidth =
|
|
6351
|
+
const labelWidth = estimateTextWidth8(
|
|
6217
6352
|
labelEst,
|
|
6218
6353
|
theme.fonts.sizes.axisTick,
|
|
6219
6354
|
theme.fonts.weights.normal
|
|
@@ -6234,12 +6369,38 @@ function computeDimensions(spec, options, legendLayout, theme) {
|
|
|
6234
6369
|
margins.bottom += legendLayout.bounds.height + 4;
|
|
6235
6370
|
}
|
|
6236
6371
|
}
|
|
6237
|
-
|
|
6372
|
+
let chartArea = {
|
|
6238
6373
|
x: margins.left,
|
|
6239
6374
|
y: margins.top,
|
|
6240
6375
|
width: Math.max(0, width - margins.left - margins.right),
|
|
6241
6376
|
height: Math.max(0, height - margins.top - margins.bottom)
|
|
6242
6377
|
};
|
|
6378
|
+
if ((chartArea.width < MIN_CHART_WIDTH || chartArea.height < MIN_CHART_HEIGHT) && chromeMode !== "hidden") {
|
|
6379
|
+
const fallbackMode = chromeMode === "full" ? "compact" : "hidden";
|
|
6380
|
+
const fallbackChrome = computeChrome2(
|
|
6381
|
+
chromeToInput(spec.chrome),
|
|
6382
|
+
theme,
|
|
6383
|
+
width,
|
|
6384
|
+
options.measureText,
|
|
6385
|
+
fallbackMode,
|
|
6386
|
+
padding
|
|
6387
|
+
);
|
|
6388
|
+
const newTop = padding + fallbackChrome.topHeight + axisMargin;
|
|
6389
|
+
const topDelta = margins.top - newTop;
|
|
6390
|
+
const newBottom = padding + fallbackChrome.bottomHeight + xAxisHeight;
|
|
6391
|
+
const bottomDelta = margins.bottom - newBottom;
|
|
6392
|
+
if (topDelta > 0 || bottomDelta > 0) {
|
|
6393
|
+
margins.top = newTop + (legendLayout.entries.length > 0 && legendLayout.position === "top" ? legendLayout.bounds.height + 4 : 0);
|
|
6394
|
+
margins.bottom = newBottom;
|
|
6395
|
+
chartArea = {
|
|
6396
|
+
x: margins.left,
|
|
6397
|
+
y: margins.top,
|
|
6398
|
+
width: Math.max(0, width - margins.left - margins.right),
|
|
6399
|
+
height: Math.max(0, height - margins.top - margins.bottom)
|
|
6400
|
+
};
|
|
6401
|
+
return { total, chrome: fallbackChrome, chartArea, margins, theme };
|
|
6402
|
+
}
|
|
6403
|
+
}
|
|
6243
6404
|
return { total, chrome, chartArea, margins, theme };
|
|
6244
6405
|
}
|
|
6245
6406
|
|
|
@@ -6482,12 +6643,14 @@ function computeScales(spec, chartArea, data) {
|
|
|
6482
6643
|
}
|
|
6483
6644
|
|
|
6484
6645
|
// src/legend/compute.ts
|
|
6485
|
-
import { estimateTextWidth as
|
|
6646
|
+
import { BRAND_RESERVE_WIDTH, estimateTextWidth as estimateTextWidth9 } from "@opendata-ai/openchart-core";
|
|
6486
6647
|
var SWATCH_SIZE2 = 12;
|
|
6487
6648
|
var SWATCH_GAP2 = 6;
|
|
6488
6649
|
var ENTRY_GAP2 = 16;
|
|
6489
6650
|
var LEGEND_PADDING = 8;
|
|
6490
6651
|
var LEGEND_RIGHT_WIDTH = 120;
|
|
6652
|
+
var RIGHT_LEGEND_MAX_HEIGHT_RATIO = 0.4;
|
|
6653
|
+
var TOP_LEGEND_MAX_ROWS = 2;
|
|
6491
6654
|
function swatchShapeForType(chartType) {
|
|
6492
6655
|
switch (chartType) {
|
|
6493
6656
|
case "line":
|
|
@@ -6513,8 +6676,41 @@ function extractColorEntries(spec, theme) {
|
|
|
6513
6676
|
active: true
|
|
6514
6677
|
}));
|
|
6515
6678
|
}
|
|
6679
|
+
function entriesThatFit(entries, maxWidth, maxRows, labelStyle) {
|
|
6680
|
+
let row = 1;
|
|
6681
|
+
let rowWidth = 0;
|
|
6682
|
+
for (let i = 0; i < entries.length; i++) {
|
|
6683
|
+
const labelWidth = estimateTextWidth9(
|
|
6684
|
+
entries[i].label,
|
|
6685
|
+
labelStyle.fontSize,
|
|
6686
|
+
labelStyle.fontWeight
|
|
6687
|
+
);
|
|
6688
|
+
const entryWidth = SWATCH_SIZE2 + SWATCH_GAP2 + labelWidth + ENTRY_GAP2;
|
|
6689
|
+
if (rowWidth + entryWidth > maxWidth && rowWidth > 0) {
|
|
6690
|
+
row++;
|
|
6691
|
+
rowWidth = entryWidth;
|
|
6692
|
+
if (row > maxRows) return i;
|
|
6693
|
+
} else {
|
|
6694
|
+
rowWidth += entryWidth;
|
|
6695
|
+
}
|
|
6696
|
+
}
|
|
6697
|
+
return entries.length;
|
|
6698
|
+
}
|
|
6699
|
+
function truncateEntries(entries, maxCount) {
|
|
6700
|
+
if (maxCount >= entries.length || maxCount <= 0) return entries;
|
|
6701
|
+
const truncated = entries.slice(0, maxCount);
|
|
6702
|
+
const remaining = entries.length - maxCount;
|
|
6703
|
+
truncated.push({
|
|
6704
|
+
label: `+${remaining} more`,
|
|
6705
|
+
color: "#999999",
|
|
6706
|
+
shape: "square",
|
|
6707
|
+
active: false,
|
|
6708
|
+
overflow: true
|
|
6709
|
+
});
|
|
6710
|
+
return truncated;
|
|
6711
|
+
}
|
|
6516
6712
|
function computeLegend(spec, strategy, theme, chartArea) {
|
|
6517
|
-
if (spec.legend?.show === false) {
|
|
6713
|
+
if (spec.legend?.show === false || strategy.legendMaxHeight === 0) {
|
|
6518
6714
|
return {
|
|
6519
6715
|
position: "top",
|
|
6520
6716
|
entries: [],
|
|
@@ -6531,7 +6727,7 @@ function computeLegend(spec, strategy, theme, chartArea) {
|
|
|
6531
6727
|
entryGap: ENTRY_GAP2
|
|
6532
6728
|
};
|
|
6533
6729
|
}
|
|
6534
|
-
|
|
6730
|
+
let entries = extractColorEntries(spec, theme);
|
|
6535
6731
|
const labelStyle = {
|
|
6536
6732
|
fontFamily: theme.fonts.family,
|
|
6537
6733
|
fontSize: theme.fonts.sizes.small,
|
|
@@ -6553,13 +6749,22 @@ function computeLegend(spec, strategy, theme, chartArea) {
|
|
|
6553
6749
|
}
|
|
6554
6750
|
if (resolvedPosition === "right" || resolvedPosition === "bottom-right") {
|
|
6555
6751
|
const maxLabelWidth = Math.max(
|
|
6556
|
-
...entries.map((e) =>
|
|
6752
|
+
...entries.map((e) => estimateTextWidth9(e.label, labelStyle.fontSize, labelStyle.fontWeight))
|
|
6557
6753
|
);
|
|
6558
6754
|
const legendWidth = Math.min(
|
|
6559
6755
|
LEGEND_RIGHT_WIDTH,
|
|
6560
6756
|
SWATCH_SIZE2 + SWATCH_GAP2 + maxLabelWidth + LEGEND_PADDING * 2
|
|
6561
6757
|
);
|
|
6562
6758
|
const entryHeight = Math.max(SWATCH_SIZE2, labelStyle.fontSize * labelStyle.lineHeight);
|
|
6759
|
+
const maxHeightRatio = strategy.legendMaxHeight > 0 ? strategy.legendMaxHeight : RIGHT_LEGEND_MAX_HEIGHT_RATIO;
|
|
6760
|
+
const maxLegendHeight = chartArea.height * maxHeightRatio;
|
|
6761
|
+
const maxEntries = Math.max(
|
|
6762
|
+
1,
|
|
6763
|
+
Math.floor((maxLegendHeight - LEGEND_PADDING * 2) / (entryHeight + 4))
|
|
6764
|
+
);
|
|
6765
|
+
if (entries.length > maxEntries) {
|
|
6766
|
+
entries = truncateEntries(entries, maxEntries);
|
|
6767
|
+
}
|
|
6563
6768
|
const legendHeight2 = entries.length * entryHeight + (entries.length - 1) * 4 + LEGEND_PADDING * 2;
|
|
6564
6769
|
const clampedHeight = Math.min(legendHeight2, chartArea.height);
|
|
6565
6770
|
const legendY = resolvedPosition === "bottom-right" ? chartArea.y + chartArea.height - clampedHeight : chartArea.y;
|
|
@@ -6580,11 +6785,29 @@ function computeLegend(spec, strategy, theme, chartArea) {
|
|
|
6580
6785
|
entryGap: 4
|
|
6581
6786
|
};
|
|
6582
6787
|
}
|
|
6788
|
+
const availableWidth = chartArea.width - LEGEND_PADDING * 2 - BRAND_RESERVE_WIDTH;
|
|
6789
|
+
const maxFit = entriesThatFit(entries, availableWidth, TOP_LEGEND_MAX_ROWS, labelStyle);
|
|
6790
|
+
if (maxFit < entries.length) {
|
|
6791
|
+
entries = truncateEntries(entries, maxFit);
|
|
6792
|
+
}
|
|
6583
6793
|
const totalWidth = entries.reduce((sum, entry) => {
|
|
6584
|
-
const labelWidth =
|
|
6794
|
+
const labelWidth = estimateTextWidth9(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
6585
6795
|
return sum + SWATCH_SIZE2 + SWATCH_GAP2 + labelWidth + ENTRY_GAP2;
|
|
6586
6796
|
}, 0);
|
|
6587
|
-
|
|
6797
|
+
let rowCount = 1;
|
|
6798
|
+
let rowWidth = 0;
|
|
6799
|
+
for (const entry of entries) {
|
|
6800
|
+
const labelWidth = estimateTextWidth9(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
6801
|
+
const entryWidth = SWATCH_SIZE2 + SWATCH_GAP2 + labelWidth + ENTRY_GAP2;
|
|
6802
|
+
if (rowWidth + entryWidth > availableWidth && rowWidth > 0) {
|
|
6803
|
+
rowCount++;
|
|
6804
|
+
rowWidth = entryWidth;
|
|
6805
|
+
} else {
|
|
6806
|
+
rowWidth += entryWidth;
|
|
6807
|
+
}
|
|
6808
|
+
}
|
|
6809
|
+
const rowHeight = SWATCH_SIZE2 + 4;
|
|
6810
|
+
const legendHeight = rowCount * rowHeight + LEGEND_PADDING * 2;
|
|
6588
6811
|
const offsetDx = spec.legend?.offset?.dx ?? 0;
|
|
6589
6812
|
const offsetDy = spec.legend?.offset?.dy ?? 0;
|
|
6590
6813
|
return {
|
|
@@ -6593,7 +6816,7 @@ function computeLegend(spec, strategy, theme, chartArea) {
|
|
|
6593
6816
|
bounds: {
|
|
6594
6817
|
x: chartArea.x + offsetDx,
|
|
6595
6818
|
y: (resolvedPosition === "bottom" ? chartArea.y + chartArea.height - legendHeight : chartArea.y) + offsetDy,
|
|
6596
|
-
width: Math.min(totalWidth,
|
|
6819
|
+
width: Math.min(totalWidth, availableWidth),
|
|
6597
6820
|
height: legendHeight
|
|
6598
6821
|
},
|
|
6599
6822
|
labelStyle,
|
|
@@ -6604,7 +6827,7 @@ function computeLegend(spec, strategy, theme, chartArea) {
|
|
|
6604
6827
|
}
|
|
6605
6828
|
|
|
6606
6829
|
// src/tables/compile-table.ts
|
|
6607
|
-
import { computeChrome as computeChrome3, estimateTextWidth as
|
|
6830
|
+
import { computeChrome as computeChrome3, estimateTextWidth as estimateTextWidth10 } from "@opendata-ai/openchart-core";
|
|
6608
6831
|
|
|
6609
6832
|
// src/tables/bar-column.ts
|
|
6610
6833
|
var NEGATIVE_BAR_COLOR = "#c44e52";
|
|
@@ -7011,13 +7234,13 @@ function estimateColumnWidth(col, data, fontSize) {
|
|
|
7011
7234
|
if (col.image) return (col.image.width ?? 24) + PADDING;
|
|
7012
7235
|
if (col.flag) return 60;
|
|
7013
7236
|
const label = col.label ?? col.key;
|
|
7014
|
-
const headerWidth =
|
|
7237
|
+
const headerWidth = estimateTextWidth10(label, fontSize, 600) + PADDING;
|
|
7015
7238
|
const sampleSize = Math.min(100, data.length);
|
|
7016
7239
|
let maxDataWidth = 0;
|
|
7017
7240
|
for (let i = 0; i < sampleSize; i++) {
|
|
7018
7241
|
const val = data[i][col.key];
|
|
7019
7242
|
const text = val == null ? "" : String(val);
|
|
7020
|
-
const width =
|
|
7243
|
+
const width = estimateTextWidth10(text, fontSize, 400) + PADDING;
|
|
7021
7244
|
if (width > maxDataWidth) maxDataWidth = width;
|
|
7022
7245
|
}
|
|
7023
7246
|
return Math.max(MIN_WIDTH, headerWidth, maxDataWidth);
|
|
@@ -7374,8 +7597,28 @@ var builtinRenderers = {
|
|
|
7374
7597
|
for (const [type, renderer] of Object.entries(builtinRenderers)) {
|
|
7375
7598
|
registerChartRenderer(type, renderer);
|
|
7376
7599
|
}
|
|
7377
|
-
function
|
|
7378
|
-
if (
|
|
7600
|
+
function computeMarkObstacles(marks, scales) {
|
|
7601
|
+
if (scales.y?.type === "band") {
|
|
7602
|
+
return computeBandRowObstacles(marks, scales);
|
|
7603
|
+
}
|
|
7604
|
+
const obstacles = [];
|
|
7605
|
+
for (const mark of marks) {
|
|
7606
|
+
if (mark.type === "rect") {
|
|
7607
|
+
const rm = mark;
|
|
7608
|
+
obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
|
|
7609
|
+
} else if (mark.type === "point") {
|
|
7610
|
+
const pm = mark;
|
|
7611
|
+
obstacles.push({
|
|
7612
|
+
x: pm.cx - pm.r,
|
|
7613
|
+
y: pm.cy - pm.r,
|
|
7614
|
+
width: pm.r * 2,
|
|
7615
|
+
height: pm.r * 2
|
|
7616
|
+
});
|
|
7617
|
+
}
|
|
7618
|
+
}
|
|
7619
|
+
return obstacles;
|
|
7620
|
+
}
|
|
7621
|
+
function computeBandRowObstacles(marks, scales) {
|
|
7379
7622
|
const rows = /* @__PURE__ */ new Map();
|
|
7380
7623
|
for (const mark of marks) {
|
|
7381
7624
|
let cy;
|
|
@@ -7427,7 +7670,8 @@ function compileChart(spec, options) {
|
|
|
7427
7670
|
}
|
|
7428
7671
|
let chartSpec = normalized;
|
|
7429
7672
|
const breakpoint = getBreakpoint(options.width);
|
|
7430
|
-
const
|
|
7673
|
+
const heightClass = getHeightClass(options.height);
|
|
7674
|
+
const strategy = getLayoutStrategy(breakpoint, heightClass);
|
|
7431
7675
|
const rawSpec = spec;
|
|
7432
7676
|
const overrides = rawSpec.overrides;
|
|
7433
7677
|
if (overrides?.[breakpoint]) {
|
|
@@ -7478,7 +7722,7 @@ function compileChart(spec, options) {
|
|
|
7478
7722
|
height: options.height
|
|
7479
7723
|
};
|
|
7480
7724
|
const legendLayout = computeLegend(chartSpec, strategy, theme, preliminaryArea);
|
|
7481
|
-
const dims = computeDimensions(chartSpec, options, legendLayout, theme);
|
|
7725
|
+
const dims = computeDimensions(chartSpec, options, legendLayout, theme, strategy);
|
|
7482
7726
|
const chartArea = dims.chartArea;
|
|
7483
7727
|
const legendArea = { ...chartArea };
|
|
7484
7728
|
if (legendLayout.entries.length > 0) {
|
|
@@ -7533,7 +7777,7 @@ function compileChart(spec, options) {
|
|
|
7533
7777
|
}
|
|
7534
7778
|
scales.defaultColor = theme.colors.categorical[0];
|
|
7535
7779
|
const isRadial = chartSpec.type === "pie" || chartSpec.type === "donut";
|
|
7536
|
-
const axes = isRadial ? { x: void 0, y: void 0 } : computeAxes(scales, chartArea, strategy, theme);
|
|
7780
|
+
const axes = isRadial ? { x: void 0, y: void 0 } : computeAxes(scales, chartArea, strategy, theme, options.measureText);
|
|
7537
7781
|
if (!isRadial) {
|
|
7538
7782
|
computeGridlines(axes, chartArea);
|
|
7539
7783
|
}
|
|
@@ -7543,7 +7787,18 @@ function compileChart(spec, options) {
|
|
|
7543
7787
|
if (finalLegend.bounds.width > 0) {
|
|
7544
7788
|
obstacles.push(finalLegend.bounds);
|
|
7545
7789
|
}
|
|
7546
|
-
obstacles.push(...
|
|
7790
|
+
obstacles.push(...computeMarkObstacles(marks, scales));
|
|
7791
|
+
for (const mark of marks) {
|
|
7792
|
+
if (mark.type !== "area" && mark.label?.visible) {
|
|
7793
|
+
obstacles.push(computeLabelBounds(mark.label));
|
|
7794
|
+
}
|
|
7795
|
+
}
|
|
7796
|
+
const brandPadding = theme.spacing.padding;
|
|
7797
|
+
const brandX = dims.total.width - brandPadding - BRAND_RESERVE_WIDTH2;
|
|
7798
|
+
const xAxisExtent = axes.x?.label ? 48 : axes.x ? 26 : 0;
|
|
7799
|
+
const firstBottomChrome = dims.chrome.source ?? dims.chrome.byline ?? dims.chrome.footer;
|
|
7800
|
+
const brandY = firstBottomChrome ? chartArea.y + chartArea.height + xAxisExtent + firstBottomChrome.y : chartArea.y + chartArea.height + xAxisExtent + theme.spacing.chartToFooter;
|
|
7801
|
+
obstacles.push({ x: brandX, y: brandY, width: BRAND_RESERVE_WIDTH2, height: 30 });
|
|
7547
7802
|
const annotations = computeAnnotations(
|
|
7548
7803
|
chartSpec,
|
|
7549
7804
|
scales,
|