graphein 0.17.0 → 0.18.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.d.ts +326 -73
- package/dist/index.js +2714 -716
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
package/dist/index.js
CHANGED
|
@@ -353,7 +353,7 @@ function clampValue(value, min, max) {
|
|
|
353
353
|
function linearScale(opts) {
|
|
354
354
|
const domain = [opts.domain[0], opts.domain[1]];
|
|
355
355
|
const range = [opts.range[0], opts.range[1]];
|
|
356
|
-
const
|
|
356
|
+
const clamp16 = opts.clamp === true;
|
|
357
357
|
const scale = {
|
|
358
358
|
get domain() {
|
|
359
359
|
return domain;
|
|
@@ -368,7 +368,7 @@ function linearScale(opts) {
|
|
|
368
368
|
return r0;
|
|
369
369
|
}
|
|
370
370
|
const mapped = r0 + (value - d0) / (d1 - d0) * (r1 - r0);
|
|
371
|
-
return
|
|
371
|
+
return clamp16 ? clampValue(mapped, Math.min(r0, r1), Math.max(r0, r1)) : mapped;
|
|
372
372
|
},
|
|
373
373
|
invert(pixel) {
|
|
374
374
|
const [d0, d1] = domain;
|
|
@@ -376,7 +376,7 @@ function linearScale(opts) {
|
|
|
376
376
|
if (r0 === r1) {
|
|
377
377
|
return d0;
|
|
378
378
|
}
|
|
379
|
-
const input =
|
|
379
|
+
const input = clamp16 ? clampValue(pixel, Math.min(r0, r1), Math.max(r0, r1)) : pixel;
|
|
380
380
|
return d0 + (input - r0) / (r1 - r0) * (d1 - d0);
|
|
381
381
|
},
|
|
382
382
|
ticks(count = 10) {
|
|
@@ -388,10 +388,10 @@ function linearScale(opts) {
|
|
|
388
388
|
return (value) => formatNumber(value, precision);
|
|
389
389
|
},
|
|
390
390
|
nice(count = 10) {
|
|
391
|
-
return linearScale({ domain: niceDomain(domain[0], domain[1], count), range, clamp:
|
|
391
|
+
return linearScale({ domain: niceDomain(domain[0], domain[1], count), range, clamp: clamp16 });
|
|
392
392
|
},
|
|
393
393
|
copy() {
|
|
394
|
-
return linearScale({ domain, range, clamp:
|
|
394
|
+
return linearScale({ domain, range, clamp: clamp16 });
|
|
395
395
|
}
|
|
396
396
|
};
|
|
397
397
|
return scale;
|
|
@@ -447,7 +447,7 @@ function logScale(opts) {
|
|
|
447
447
|
const domain = [opts.domain[0], opts.domain[1]];
|
|
448
448
|
const range = [opts.range[0], opts.range[1]];
|
|
449
449
|
const base = opts.base ?? 10;
|
|
450
|
-
const
|
|
450
|
+
const clamp16 = opts.clamp === true;
|
|
451
451
|
assertDomain(domain, base);
|
|
452
452
|
const sign2 = domain[0] < 0 ? -1 : 1;
|
|
453
453
|
const transformedDomain = [
|
|
@@ -466,17 +466,17 @@ function logScale(opts) {
|
|
|
466
466
|
const [r0, r1] = range;
|
|
467
467
|
if (!Number.isFinite(value) || value * sign2 <= 0) return NaN;
|
|
468
468
|
if (d0 === d1) {
|
|
469
|
-
return
|
|
469
|
+
return clamp16 ? clampValue2(r0, Math.min(r0, r1), Math.max(r0, r1)) : r0;
|
|
470
470
|
}
|
|
471
471
|
const transformed = transform(value, sign2, base);
|
|
472
472
|
const mapped = r0 + (transformed - d0) / (d1 - d0) * (r1 - r0);
|
|
473
|
-
return
|
|
473
|
+
return clamp16 ? clampValue2(mapped, Math.min(r0, r1), Math.max(r0, r1)) : mapped;
|
|
474
474
|
},
|
|
475
475
|
invert(pixel) {
|
|
476
476
|
const [d0, d1] = transformedDomain;
|
|
477
477
|
const [r0, r1] = range;
|
|
478
478
|
if (d0 === d1) return domain[0];
|
|
479
|
-
const input =
|
|
479
|
+
const input = clamp16 ? clampValue2(pixel, Math.min(r0, r1), Math.max(r0, r1)) : pixel;
|
|
480
480
|
return untransform(d0 + (input - r0) / (r1 - r0) * (d1 - d0), sign2, base);
|
|
481
481
|
},
|
|
482
482
|
ticks() {
|
|
@@ -486,10 +486,10 @@ function logScale(opts) {
|
|
|
486
486
|
return (value) => formatNumber(value, 12);
|
|
487
487
|
},
|
|
488
488
|
nice() {
|
|
489
|
-
return logScale({ domain: niceLogDomain(domain, base), range, base, clamp:
|
|
489
|
+
return logScale({ domain: niceLogDomain(domain, base), range, base, clamp: clamp16 });
|
|
490
490
|
},
|
|
491
491
|
copy() {
|
|
492
|
-
return logScale({ domain, range, base, clamp:
|
|
492
|
+
return logScale({ domain, range, base, clamp: clamp16 });
|
|
493
493
|
}
|
|
494
494
|
};
|
|
495
495
|
return scale;
|
|
@@ -505,7 +505,7 @@ function clampValue3(value, min, max) {
|
|
|
505
505
|
function timeScale(opts) {
|
|
506
506
|
const domain = [toMillis(opts.domain[0]), toMillis(opts.domain[1])];
|
|
507
507
|
const range = [opts.range[0], opts.range[1]];
|
|
508
|
-
const
|
|
508
|
+
const clamp16 = opts.clamp === true;
|
|
509
509
|
const scale = {
|
|
510
510
|
get domain() {
|
|
511
511
|
return domain;
|
|
@@ -520,7 +520,7 @@ function timeScale(opts) {
|
|
|
520
520
|
return r0;
|
|
521
521
|
}
|
|
522
522
|
const mapped = r0 + (value - d0) / (d1 - d0) * (r1 - r0);
|
|
523
|
-
return
|
|
523
|
+
return clamp16 ? clampValue3(mapped, Math.min(r0, r1), Math.max(r0, r1)) : mapped;
|
|
524
524
|
},
|
|
525
525
|
invert(pixel) {
|
|
526
526
|
const [d0, d1] = domain;
|
|
@@ -528,7 +528,7 @@ function timeScale(opts) {
|
|
|
528
528
|
if (r0 === r1) {
|
|
529
529
|
return d0;
|
|
530
530
|
}
|
|
531
|
-
const input =
|
|
531
|
+
const input = clamp16 ? clampValue3(pixel, Math.min(r0, r1), Math.max(r0, r1)) : pixel;
|
|
532
532
|
return d0 + (input - r0) / (r1 - r0) * (d1 - d0);
|
|
533
533
|
},
|
|
534
534
|
ticks(count = 10) {
|
|
@@ -540,12 +540,12 @@ function timeScale(opts) {
|
|
|
540
540
|
nice(count = 10) {
|
|
541
541
|
const values = timeTicks(domain[0], domain[1], count);
|
|
542
542
|
if (values.length === 0) {
|
|
543
|
-
return timeScale({ domain, range, clamp:
|
|
543
|
+
return timeScale({ domain, range, clamp: clamp16 });
|
|
544
544
|
}
|
|
545
|
-
return timeScale({ domain: [values[0], values[values.length - 1]], range, clamp:
|
|
545
|
+
return timeScale({ domain: [values[0], values[values.length - 1]], range, clamp: clamp16 });
|
|
546
546
|
},
|
|
547
547
|
copy() {
|
|
548
|
-
return timeScale({ domain, range, clamp:
|
|
548
|
+
return timeScale({ domain, range, clamp: clamp16 });
|
|
549
549
|
}
|
|
550
550
|
};
|
|
551
551
|
return scale;
|
|
@@ -736,6 +736,28 @@ function interpolateOklab(a, b) {
|
|
|
736
736
|
};
|
|
737
737
|
}
|
|
738
738
|
|
|
739
|
+
// src/color/contrast.ts
|
|
740
|
+
var WHITE = { r: 255, g: 255, b: 255, a: 1 };
|
|
741
|
+
var BLACK = { r: 0, g: 0, b: 0, a: 1 };
|
|
742
|
+
function relativeLuminance(c) {
|
|
743
|
+
const r = srgbToLinear(c.r / 255);
|
|
744
|
+
const g = srgbToLinear(c.g / 255);
|
|
745
|
+
const b = srgbToLinear(c.b / 255);
|
|
746
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
747
|
+
}
|
|
748
|
+
function contrastRatio(a, b) {
|
|
749
|
+
const la = relativeLuminance(a);
|
|
750
|
+
const lb = relativeLuminance(b);
|
|
751
|
+
const lighter = Math.max(la, lb);
|
|
752
|
+
const darker = Math.min(la, lb);
|
|
753
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
754
|
+
}
|
|
755
|
+
function readableTextColor(bg, options = {}) {
|
|
756
|
+
const light = options.light ?? WHITE;
|
|
757
|
+
const dark = options.dark ?? BLACK;
|
|
758
|
+
return contrastRatio(bg, light) >= contrastRatio(bg, dark) ? light : dark;
|
|
759
|
+
}
|
|
760
|
+
|
|
739
761
|
// src/color/palettes.ts
|
|
740
762
|
var CATEGORICAL = {
|
|
741
763
|
graphein: [
|
|
@@ -781,6 +803,25 @@ var must = (hex) => {
|
|
|
781
803
|
if (!c) throw new Error(`Graphein: invalid palette color ${hex}`);
|
|
782
804
|
return c;
|
|
783
805
|
};
|
|
806
|
+
function candidateWithChroma(color, lightness, chroma) {
|
|
807
|
+
const lch = oklabToOklch(rgbToOklab(color));
|
|
808
|
+
return oklabToRgb(oklchToOklab({ L: lightness, C: chroma, h: lch.h }), color.a);
|
|
809
|
+
}
|
|
810
|
+
function adaptPaletteForDarkBackground(palette, background = "#0b1220", minContrast = 3) {
|
|
811
|
+
const bg = parseColor(background);
|
|
812
|
+
if (!bg) return [...palette];
|
|
813
|
+
return palette.map((color) => {
|
|
814
|
+
const parsed = parseColor(color);
|
|
815
|
+
if (!parsed) return color;
|
|
816
|
+
const lch = oklabToOklch(rgbToOklab(parsed));
|
|
817
|
+
const lightness = Math.max(lch.L, 0.68);
|
|
818
|
+
for (let chroma = lch.C; chroma >= 0; chroma -= 2e-3) {
|
|
819
|
+
const candidate = candidateWithChroma(parsed, lightness, chroma);
|
|
820
|
+
if (contrastRatio(candidate, bg) >= minContrast) return toHex(candidate);
|
|
821
|
+
}
|
|
822
|
+
return toHex(candidateWithChroma(parsed, lightness, 0));
|
|
823
|
+
});
|
|
824
|
+
}
|
|
784
825
|
function rampFromStops(stops) {
|
|
785
826
|
const colors = stops.map(must);
|
|
786
827
|
if (colors.length === 1) return () => ({ ...colors[0] });
|
|
@@ -866,28 +907,6 @@ function ordinalColorScale(opts) {
|
|
|
866
907
|
};
|
|
867
908
|
}
|
|
868
909
|
|
|
869
|
-
// src/color/contrast.ts
|
|
870
|
-
var WHITE = { r: 255, g: 255, b: 255, a: 1 };
|
|
871
|
-
var BLACK = { r: 0, g: 0, b: 0, a: 1 };
|
|
872
|
-
function relativeLuminance(c) {
|
|
873
|
-
const r = srgbToLinear(c.r / 255);
|
|
874
|
-
const g = srgbToLinear(c.g / 255);
|
|
875
|
-
const b = srgbToLinear(c.b / 255);
|
|
876
|
-
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
877
|
-
}
|
|
878
|
-
function contrastRatio(a, b) {
|
|
879
|
-
const la = relativeLuminance(a);
|
|
880
|
-
const lb = relativeLuminance(b);
|
|
881
|
-
const lighter = Math.max(la, lb);
|
|
882
|
-
const darker = Math.min(la, lb);
|
|
883
|
-
return (lighter + 0.05) / (darker + 0.05);
|
|
884
|
-
}
|
|
885
|
-
function readableTextColor(bg, options = {}) {
|
|
886
|
-
const light = options.light ?? WHITE;
|
|
887
|
-
const dark = options.dark ?? BLACK;
|
|
888
|
-
return contrastRatio(bg, light) >= contrastRatio(bg, dark) ? light : dark;
|
|
889
|
-
}
|
|
890
|
-
|
|
891
910
|
// src/shape/curves.ts
|
|
892
911
|
var sign = (value) => value < 0 ? -1 : value > 0 ? 1 : 0;
|
|
893
912
|
function curveLinear(points, sink) {
|
|
@@ -1865,8 +1884,8 @@ function chartTitleText(spec) {
|
|
|
1865
1884
|
const t = spec.title;
|
|
1866
1885
|
if (typeof t === "string") return t.trim() || void 0;
|
|
1867
1886
|
if (t && typeof t === "object") {
|
|
1868
|
-
const
|
|
1869
|
-
return typeof
|
|
1887
|
+
const text2 = t.text;
|
|
1888
|
+
return typeof text2 === "string" && text2.trim() ? text2.trim() : void 0;
|
|
1870
1889
|
}
|
|
1871
1890
|
return void 0;
|
|
1872
1891
|
}
|
|
@@ -5411,8 +5430,8 @@ function profileColumns(data) {
|
|
|
5411
5430
|
cardinality: uniqueValues(data, field).length
|
|
5412
5431
|
}));
|
|
5413
5432
|
}
|
|
5414
|
-
function title(
|
|
5415
|
-
return
|
|
5433
|
+
function title(text2) {
|
|
5434
|
+
return text2.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim().replace(/\b\w/g, (m) => m.toUpperCase());
|
|
5416
5435
|
}
|
|
5417
5436
|
function fieldDef2(profile) {
|
|
5418
5437
|
return { field: profile.field, type: profile.type };
|
|
@@ -5431,6 +5450,77 @@ function pushValid(candidates, candidate, intent) {
|
|
|
5431
5450
|
function firstLowCardNominal(profiles, exclude = /* @__PURE__ */ new Set()) {
|
|
5432
5451
|
return profiles.find((p) => p.type === "nominal" && p.cardinality <= 12 && !exclude.has(p.field));
|
|
5433
5452
|
}
|
|
5453
|
+
function numericValues(data, field) {
|
|
5454
|
+
return data.map((row) => Number(row[field])).filter((value) => Number.isFinite(value));
|
|
5455
|
+
}
|
|
5456
|
+
function stringValues(data, field) {
|
|
5457
|
+
return data.map((row) => row[field]).filter((value) => value != null && value !== "").map((value) => String(value));
|
|
5458
|
+
}
|
|
5459
|
+
function hasPositiveValues(data, field) {
|
|
5460
|
+
const values = numericValues(data, field);
|
|
5461
|
+
return values.length > 0 && values.every((value) => value > 0);
|
|
5462
|
+
}
|
|
5463
|
+
function fieldNameMatches(field, words) {
|
|
5464
|
+
const normalized2 = field.toLowerCase();
|
|
5465
|
+
return words.some((word) => normalized2.includes(word));
|
|
5466
|
+
}
|
|
5467
|
+
function hasConversionVocabulary(values) {
|
|
5468
|
+
const vocabulary = [
|
|
5469
|
+
"visit",
|
|
5470
|
+
"lead",
|
|
5471
|
+
"signup",
|
|
5472
|
+
"sign up",
|
|
5473
|
+
"register",
|
|
5474
|
+
"activate",
|
|
5475
|
+
"subscribe",
|
|
5476
|
+
"convert",
|
|
5477
|
+
"purchase",
|
|
5478
|
+
"checkout",
|
|
5479
|
+
"renew",
|
|
5480
|
+
"trial",
|
|
5481
|
+
"demo",
|
|
5482
|
+
"onboard"
|
|
5483
|
+
];
|
|
5484
|
+
return values.some((value) => {
|
|
5485
|
+
const normalized2 = value.toLowerCase();
|
|
5486
|
+
return vocabulary.some((word) => normalized2.includes(word));
|
|
5487
|
+
});
|
|
5488
|
+
}
|
|
5489
|
+
function isNearMonotoneDecreasing(values) {
|
|
5490
|
+
if (values.length < 3) return false;
|
|
5491
|
+
let decreases = 0;
|
|
5492
|
+
for (let i = 1; i < values.length; i++) {
|
|
5493
|
+
if (values[i] <= values[i - 1] * 1.05) decreases++;
|
|
5494
|
+
}
|
|
5495
|
+
return decreases / (values.length - 1) >= 0.8;
|
|
5496
|
+
}
|
|
5497
|
+
function distinctDates(data, field) {
|
|
5498
|
+
return uniqueValues(data, field).map((value) => {
|
|
5499
|
+
if (value instanceof Date) return value;
|
|
5500
|
+
if (typeof value === "string" || typeof value === "number") return new Date(value);
|
|
5501
|
+
return null;
|
|
5502
|
+
}).filter((value) => value instanceof Date && !Number.isNaN(value.getTime())).sort((a, b) => a.getTime() - b.getTime());
|
|
5503
|
+
}
|
|
5504
|
+
function groupsWithAllValues(data, groupField, valueField4, expected) {
|
|
5505
|
+
const groups = /* @__PURE__ */ new Map();
|
|
5506
|
+
for (const row of data) {
|
|
5507
|
+
const group = row[groupField];
|
|
5508
|
+
const value = row[valueField4];
|
|
5509
|
+
if (group == null || value == null) continue;
|
|
5510
|
+
const key = String(group);
|
|
5511
|
+
const values = groups.get(key) ?? /* @__PURE__ */ new Set();
|
|
5512
|
+
values.add(String(value));
|
|
5513
|
+
groups.set(key, values);
|
|
5514
|
+
}
|
|
5515
|
+
let complete = 0;
|
|
5516
|
+
for (const values of groups.values()) {
|
|
5517
|
+
if ([...expected].every((value) => values.has(value))) complete++;
|
|
5518
|
+
}
|
|
5519
|
+
return complete;
|
|
5520
|
+
}
|
|
5521
|
+
function profileByName(profiles, words) {
|
|
5522
|
+
return profiles.find((profile) => fieldNameMatches(profile.field, words));
|
|
5523
|
+
}
|
|
5434
5524
|
function recommendChart(data, opts = {}) {
|
|
5435
5525
|
if (!Array.isArray(data) || data.length === 0) return [];
|
|
5436
5526
|
const profiles = profileColumns(data);
|
|
@@ -5440,7 +5530,285 @@ function recommendChart(data, opts = {}) {
|
|
|
5440
5530
|
const lowNominal = nominal.filter((p) => p.cardinality <= 12);
|
|
5441
5531
|
const manyNominal = nominal.filter((p) => p.cardinality > 12);
|
|
5442
5532
|
const candidates = [];
|
|
5443
|
-
const measure = quantitative[0];
|
|
5533
|
+
const measure = quantitative.find((p) => p.cardinality > 2) ?? quantitative[0];
|
|
5534
|
+
if (measure) {
|
|
5535
|
+
const stage = profiles.find(
|
|
5536
|
+
(p) => (p.type === "nominal" || p.type === "ordinal") && p.cardinality >= 3 && p.cardinality <= 12 && p.cardinality === data.length
|
|
5537
|
+
);
|
|
5538
|
+
if (stage && hasPositiveValues(data, measure.field)) {
|
|
5539
|
+
const values = numericValues(data, measure.field);
|
|
5540
|
+
const stages = stringValues(data, stage.field);
|
|
5541
|
+
const stageLike = fieldNameMatches(stage.field, ["stage", "step", "funnel", "phase", "status"]);
|
|
5542
|
+
const conversionLike = hasConversionVocabulary(stages);
|
|
5543
|
+
if (isNearMonotoneDecreasing(values) && (stageLike || conversionLike)) {
|
|
5544
|
+
pushValid(
|
|
5545
|
+
candidates,
|
|
5546
|
+
{
|
|
5547
|
+
spec: {
|
|
5548
|
+
type: "funnel",
|
|
5549
|
+
data,
|
|
5550
|
+
encoding: { stage: fieldDef2(stage), value: fieldDef2(measure) },
|
|
5551
|
+
title: `${title(measure.field)} funnel by ${title(stage.field)}`,
|
|
5552
|
+
percent: "first"
|
|
5553
|
+
},
|
|
5554
|
+
score: 0.88,
|
|
5555
|
+
family: "composition",
|
|
5556
|
+
rationale: `${title(stage.field)} looks like ordered funnel stages and ${title(measure.field)} mostly decreases, so a funnel shows conversion drop-off.`
|
|
5557
|
+
},
|
|
5558
|
+
opts.intent
|
|
5559
|
+
);
|
|
5560
|
+
}
|
|
5561
|
+
}
|
|
5562
|
+
}
|
|
5563
|
+
if (measure) {
|
|
5564
|
+
const stage = profiles.find(
|
|
5565
|
+
(p) => (p.type === "nominal" || p.type === "ordinal") && p.cardinality >= 3 && p.cardinality <= 20 && p.cardinality === data.length
|
|
5566
|
+
);
|
|
5567
|
+
if (stage) {
|
|
5568
|
+
const values = numericValues(data, measure.field);
|
|
5569
|
+
const hasIncrease = values.some((value) => value > 0);
|
|
5570
|
+
const hasDecrease = values.some((value) => value < 0);
|
|
5571
|
+
if (values.length === data.length && hasIncrease && hasDecrease) {
|
|
5572
|
+
pushValid(
|
|
5573
|
+
candidates,
|
|
5574
|
+
{
|
|
5575
|
+
spec: {
|
|
5576
|
+
type: "waterfall",
|
|
5577
|
+
data,
|
|
5578
|
+
encoding: { stage: fieldDef2(stage), value: fieldDef2(measure) },
|
|
5579
|
+
title: `${title(measure.field)} bridge by ${title(stage.field)}`,
|
|
5580
|
+
showTotal: true
|
|
5581
|
+
},
|
|
5582
|
+
score: 0.87,
|
|
5583
|
+
family: "comparison",
|
|
5584
|
+
rationale: `${title(measure.field)} contains signed positive and negative steps, so a waterfall shows how each ${title(stage.field)} changes the running total.`
|
|
5585
|
+
},
|
|
5586
|
+
opts.intent
|
|
5587
|
+
);
|
|
5588
|
+
}
|
|
5589
|
+
}
|
|
5590
|
+
}
|
|
5591
|
+
if (nominal.length >= 2 && measure && hasPositiveValues(data, measure.field)) {
|
|
5592
|
+
const source = profileByName(nominal, ["source", "from", "origin", "upstream"]) ?? nominal.find((profile) => profile.cardinality >= 2 && profile.cardinality <= 30);
|
|
5593
|
+
const target = profileByName(nominal.filter((profile) => profile.field !== source?.field), [
|
|
5594
|
+
"target",
|
|
5595
|
+
"to",
|
|
5596
|
+
"destination",
|
|
5597
|
+
"downstream"
|
|
5598
|
+
]) ?? nominal.find((profile) => profile.field !== source?.field && profile.cardinality >= 2 && profile.cardinality <= 30);
|
|
5599
|
+
if (source && target) {
|
|
5600
|
+
const sourceNames = new Set(stringValues(data, source.field));
|
|
5601
|
+
const targetNames = new Set(stringValues(data, target.field));
|
|
5602
|
+
const sharedNodes = [...sourceNames].filter((value) => targetNames.has(value)).length;
|
|
5603
|
+
const namedFlow = fieldNameMatches(source.field, ["source", "from", "origin", "upstream"]) || fieldNameMatches(target.field, ["target", "to", "destination", "downstream"]);
|
|
5604
|
+
if (namedFlow || sharedNodes > 0) {
|
|
5605
|
+
pushValid(
|
|
5606
|
+
candidates,
|
|
5607
|
+
{
|
|
5608
|
+
spec: {
|
|
5609
|
+
type: "sankey",
|
|
5610
|
+
data,
|
|
5611
|
+
encoding: { source: fieldDef2(source), target: fieldDef2(target), value: fieldDef2(measure) },
|
|
5612
|
+
title: `${title(measure.field)} flow from ${title(source.field)} to ${title(target.field)}`
|
|
5613
|
+
},
|
|
5614
|
+
score: 0.82,
|
|
5615
|
+
family: "relationship",
|
|
5616
|
+
rationale: `${title(source.field)} and ${title(target.field)} form source and target entities with positive ${title(measure.field)}, so a sankey shows flow magnitude.`
|
|
5617
|
+
},
|
|
5618
|
+
opts.intent
|
|
5619
|
+
);
|
|
5620
|
+
}
|
|
5621
|
+
}
|
|
5622
|
+
}
|
|
5623
|
+
if (measure && hasPositiveValues(data, measure.field)) {
|
|
5624
|
+
const category = nominal.find((profile) => profile.cardinality > 7);
|
|
5625
|
+
if (category) {
|
|
5626
|
+
const group = firstLowCardNominal(profiles, /* @__PURE__ */ new Set([category.field, measure.field]));
|
|
5627
|
+
pushValid(
|
|
5628
|
+
candidates,
|
|
5629
|
+
{
|
|
5630
|
+
spec: {
|
|
5631
|
+
type: "treemap",
|
|
5632
|
+
data,
|
|
5633
|
+
encoding: {
|
|
5634
|
+
category: fieldDef2(category),
|
|
5635
|
+
value: fieldDef2(measure),
|
|
5636
|
+
...group ? { group: fieldDef2(group) } : {}
|
|
5637
|
+
},
|
|
5638
|
+
title: `${title(measure.field)} by ${title(category.field)}`
|
|
5639
|
+
},
|
|
5640
|
+
score: 0.7,
|
|
5641
|
+
family: "composition",
|
|
5642
|
+
rationale: `${title(category.field)} has ${category.cardinality} categories, too many for a clear pie, so a treemap shows part-to-whole area by ${title(measure.field)}.`
|
|
5643
|
+
},
|
|
5644
|
+
opts.intent
|
|
5645
|
+
);
|
|
5646
|
+
}
|
|
5647
|
+
}
|
|
5648
|
+
if (measure) {
|
|
5649
|
+
const category = lowNominal.find((profile) => {
|
|
5650
|
+
const counts = /* @__PURE__ */ new Map();
|
|
5651
|
+
for (const row of data) {
|
|
5652
|
+
const value = row[profile.field];
|
|
5653
|
+
if (value == null) continue;
|
|
5654
|
+
const key = String(value);
|
|
5655
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
5656
|
+
}
|
|
5657
|
+
return counts.size >= 2 && [...counts.values()].every((count) => count >= 5);
|
|
5658
|
+
});
|
|
5659
|
+
if (category) {
|
|
5660
|
+
pushValid(
|
|
5661
|
+
candidates,
|
|
5662
|
+
{
|
|
5663
|
+
spec: {
|
|
5664
|
+
type: "box",
|
|
5665
|
+
data,
|
|
5666
|
+
encoding: { x: fieldDef2(category), y: fieldDef2(measure) },
|
|
5667
|
+
title: `${title(measure.field)} distribution by ${title(category.field)}`
|
|
5668
|
+
},
|
|
5669
|
+
score: 0.81,
|
|
5670
|
+
family: "distribution",
|
|
5671
|
+
rationale: `${title(category.field)} repeats with many ${title(measure.field)} observations per category, so a box plot summarizes spread and outliers.`
|
|
5672
|
+
},
|
|
5673
|
+
opts.intent
|
|
5674
|
+
);
|
|
5675
|
+
}
|
|
5676
|
+
}
|
|
5677
|
+
if (measure) {
|
|
5678
|
+
const date = profiles.find((profile) => profile.type === "temporal");
|
|
5679
|
+
if (date) {
|
|
5680
|
+
const dates = distinctDates(data, date.field);
|
|
5681
|
+
const spanDays = dates.length > 1 ? (dates[dates.length - 1].getTime() - dates[0].getTime()) / 864e5 : 0;
|
|
5682
|
+
const density = spanDays > 0 ? dates.length / spanDays : 0;
|
|
5683
|
+
if (dates.length >= 180 && spanDays >= 300 && density >= 0.45) {
|
|
5684
|
+
pushValid(
|
|
5685
|
+
candidates,
|
|
5686
|
+
{
|
|
5687
|
+
spec: {
|
|
5688
|
+
type: "calendarHeatmap",
|
|
5689
|
+
data,
|
|
5690
|
+
encoding: { date: fieldDef2(date), color: fieldDef2(measure) },
|
|
5691
|
+
title: `${title(measure.field)} by day`
|
|
5692
|
+
},
|
|
5693
|
+
score: 0.72,
|
|
5694
|
+
family: "density",
|
|
5695
|
+
rationale: `${title(date.field)} is daily temporal data spanning about a year, so a calendar heatmap shows day-by-day ${title(measure.field)} intensity.`
|
|
5696
|
+
},
|
|
5697
|
+
opts.intent
|
|
5698
|
+
);
|
|
5699
|
+
}
|
|
5700
|
+
}
|
|
5701
|
+
}
|
|
5702
|
+
if (measure) {
|
|
5703
|
+
const x = profiles.find(
|
|
5704
|
+
(profile) => (profile.type === "temporal" || profile.type === "nominal" || profile.type === "ordinal") && profile.cardinality === 2
|
|
5705
|
+
);
|
|
5706
|
+
const series = nominal.find((profile) => profile.field !== x?.field && profile.cardinality >= 2);
|
|
5707
|
+
if (x && series) {
|
|
5708
|
+
const xValues = new Set(stringValues(data, x.field));
|
|
5709
|
+
if (groupsWithAllValues(data, series.field, x.field, xValues) >= 2) {
|
|
5710
|
+
pushValid(
|
|
5711
|
+
candidates,
|
|
5712
|
+
{
|
|
5713
|
+
spec: {
|
|
5714
|
+
type: "slope",
|
|
5715
|
+
data,
|
|
5716
|
+
encoding: { x: fieldDef2(x), y: fieldDef2(measure), series: fieldDef2(series) },
|
|
5717
|
+
title: `${title(measure.field)} change by ${title(series.field)}`,
|
|
5718
|
+
colorByChange: true
|
|
5719
|
+
},
|
|
5720
|
+
score: 0.79,
|
|
5721
|
+
family: "trend",
|
|
5722
|
+
rationale: `${title(x.field)} has exactly two positions for each ${title(series.field)}, so a slope chart emphasizes before-and-after change in ${title(measure.field)}.`
|
|
5723
|
+
},
|
|
5724
|
+
opts.intent
|
|
5725
|
+
);
|
|
5726
|
+
}
|
|
5727
|
+
}
|
|
5728
|
+
}
|
|
5729
|
+
if (measure && nominal.length >= 1) {
|
|
5730
|
+
const group = profiles.find((profile) => profile.field !== measure.field && profile.cardinality === 2);
|
|
5731
|
+
const category = nominal.find((profile) => profile.field !== group?.field && profile.cardinality >= 2);
|
|
5732
|
+
if (group && category) {
|
|
5733
|
+
const groupValues = new Set(stringValues(data, group.field));
|
|
5734
|
+
if (groupsWithAllValues(data, category.field, group.field, groupValues) >= 2) {
|
|
5735
|
+
pushValid(
|
|
5736
|
+
candidates,
|
|
5737
|
+
{
|
|
5738
|
+
spec: {
|
|
5739
|
+
type: "dumbbell",
|
|
5740
|
+
data,
|
|
5741
|
+
encoding: { category: fieldDef2(category), value: fieldDef2(measure), group: fieldDef2(group) },
|
|
5742
|
+
title: `${title(measure.field)} gap by ${title(category.field)}`,
|
|
5743
|
+
sort: "gap"
|
|
5744
|
+
},
|
|
5745
|
+
score: 0.78,
|
|
5746
|
+
family: "comparison",
|
|
5747
|
+
rationale: `${title(group.field)} has exactly two groups within each ${title(category.field)}, so a dumbbell chart makes the ${title(measure.field)} gap easy to compare.`
|
|
5748
|
+
},
|
|
5749
|
+
opts.intent
|
|
5750
|
+
);
|
|
5751
|
+
}
|
|
5752
|
+
}
|
|
5753
|
+
}
|
|
5754
|
+
if (data.length === 1 && quantitative.length >= 2) {
|
|
5755
|
+
const value = profileByName(quantitative, ["value", "actual", "current", "sales", "revenue"]) ?? quantitative[0];
|
|
5756
|
+
const target = profileByName(
|
|
5757
|
+
quantitative.filter((profile) => profile.field !== value.field),
|
|
5758
|
+
["target", "goal", "budget", "plan", "quota"]
|
|
5759
|
+
);
|
|
5760
|
+
const max = profileByName(
|
|
5761
|
+
quantitative.filter((profile) => profile.field !== value.field && profile.field !== target?.field),
|
|
5762
|
+
["max", "total", "limit", "capacity"]
|
|
5763
|
+
);
|
|
5764
|
+
const valueNumber = numericValues(data, value.field)[0];
|
|
5765
|
+
const targetNumber = target ? numericValues(data, target.field)[0] : void 0;
|
|
5766
|
+
const maxNumber = max ? numericValues(data, max.field)[0] : void 0;
|
|
5767
|
+
if (target && Number.isFinite(targetNumber)) {
|
|
5768
|
+
pushValid(
|
|
5769
|
+
candidates,
|
|
5770
|
+
{
|
|
5771
|
+
spec: {
|
|
5772
|
+
type: "bullet",
|
|
5773
|
+
data,
|
|
5774
|
+
value: { field: value.field },
|
|
5775
|
+
target: { field: target.field },
|
|
5776
|
+
max: Number.isFinite(maxNumber) ? maxNumber : void 0,
|
|
5777
|
+
label: title(value.field),
|
|
5778
|
+
title: `${title(value.field)} vs ${title(target.field)}`
|
|
5779
|
+
},
|
|
5780
|
+
score: 0.86,
|
|
5781
|
+
family: "value",
|
|
5782
|
+
rationale: `${title(value.field)} is a single value with ${title(target.field)} as a target, so a bullet graph shows compact KPI attainment.`
|
|
5783
|
+
},
|
|
5784
|
+
opts.intent
|
|
5785
|
+
);
|
|
5786
|
+
}
|
|
5787
|
+
if ((max || target) && typeof valueNumber === "number" && Number.isFinite(valueNumber)) {
|
|
5788
|
+
const numericTarget = typeof targetNumber === "number" && Number.isFinite(targetNumber) ? targetNumber : valueNumber;
|
|
5789
|
+
const gaugeMax = typeof maxNumber === "number" && Number.isFinite(maxNumber) ? maxNumber : Math.max(valueNumber, numericTarget) * 1.2;
|
|
5790
|
+
if (gaugeMax > 0 && gaugeMax > valueNumber) {
|
|
5791
|
+
pushValid(
|
|
5792
|
+
candidates,
|
|
5793
|
+
{
|
|
5794
|
+
spec: {
|
|
5795
|
+
type: "gauge",
|
|
5796
|
+
data,
|
|
5797
|
+
value: { field: value.field },
|
|
5798
|
+
max: gaugeMax,
|
|
5799
|
+
target: target ? { field: target.field } : void 0,
|
|
5800
|
+
label: title(value.field),
|
|
5801
|
+
title: `${title(value.field)} attainment`
|
|
5802
|
+
},
|
|
5803
|
+
score: 0.84,
|
|
5804
|
+
family: "value",
|
|
5805
|
+
rationale: `${title(value.field)} is a single value against ${target ? title(target.field) : title(max?.field ?? "Maximum")}, so a gauge shows progress on a bounded scale.`
|
|
5806
|
+
},
|
|
5807
|
+
opts.intent
|
|
5808
|
+
);
|
|
5809
|
+
}
|
|
5810
|
+
}
|
|
5811
|
+
}
|
|
5444
5812
|
if (ordered.length > 0 && measure) {
|
|
5445
5813
|
const x = ordered[0];
|
|
5446
5814
|
const encoding = { x: fieldDef2(x), y: fieldDef2(measure) };
|
|
@@ -5687,85 +6055,56 @@ var CanvasLayer = class {
|
|
|
5687
6055
|
}
|
|
5688
6056
|
};
|
|
5689
6057
|
|
|
5690
|
-
// src/render/
|
|
5691
|
-
var
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
/**
|
|
5704
|
-
* When true, overlay text (axis labels, titles, legends, annotation labels) is
|
|
5705
|
-
* painted onto the `marks` canvas instead of the HTML overlay. Set by headless
|
|
5706
|
-
* renderers (`@graphein/node`) where there is no DOM to host crisp text.
|
|
5707
|
-
* Defaults to false (browser).
|
|
5708
|
-
*/
|
|
5709
|
-
__publicField(this, "headless", false);
|
|
5710
|
-
this.container = container;
|
|
5711
|
-
this.root = createDiv("graphein-root", {
|
|
5712
|
-
position: "relative",
|
|
5713
|
-
width: "100%",
|
|
5714
|
-
height: "100%",
|
|
5715
|
-
overflow: "hidden"
|
|
5716
|
-
});
|
|
5717
|
-
const marksCanvas = document.createElement("canvas");
|
|
5718
|
-
marksCanvas.className = "graphein-layer-marks";
|
|
5719
|
-
setStyle(marksCanvas, { ...fillParentStyle });
|
|
5720
|
-
const interactionCanvas = document.createElement("canvas");
|
|
5721
|
-
interactionCanvas.className = "graphein-layer-interaction";
|
|
5722
|
-
setStyle(interactionCanvas, { ...fillParentStyle, pointerEvents: "none" });
|
|
5723
|
-
this.overlay = createDiv("graphein-layer-overlay", {
|
|
5724
|
-
...fillParentStyle,
|
|
5725
|
-
width: "100%",
|
|
5726
|
-
height: "100%",
|
|
5727
|
-
pointerEvents: "none"
|
|
5728
|
-
});
|
|
5729
|
-
this.a11y = createDiv("graphein-a11y", {
|
|
5730
|
-
position: "absolute",
|
|
5731
|
-
width: "1px",
|
|
5732
|
-
height: "1px",
|
|
5733
|
-
overflow: "hidden",
|
|
5734
|
-
clip: "rect(0 0 0 0)",
|
|
5735
|
-
clipPath: "inset(50%)",
|
|
5736
|
-
whiteSpace: "nowrap",
|
|
5737
|
-
border: "0",
|
|
5738
|
-
margin: "-1px",
|
|
5739
|
-
padding: "0"
|
|
5740
|
-
});
|
|
5741
|
-
this.root.appendChild(marksCanvas);
|
|
5742
|
-
this.root.appendChild(interactionCanvas);
|
|
5743
|
-
this.root.appendChild(this.overlay);
|
|
5744
|
-
this.root.appendChild(this.a11y);
|
|
5745
|
-
container.appendChild(this.root);
|
|
5746
|
-
this.marks = new CanvasLayer(marksCanvas);
|
|
5747
|
-
this.interaction = new CanvasLayer(interactionCanvas);
|
|
5748
|
-
}
|
|
5749
|
-
resize(width, height, dpr = getDevicePixelRatio()) {
|
|
5750
|
-
this.width = width;
|
|
5751
|
-
this.height = height;
|
|
5752
|
-
this.dpr = dpr;
|
|
5753
|
-
this.marks.resize(width, height, dpr);
|
|
5754
|
-
this.interaction.resize(width, height, dpr);
|
|
5755
|
-
setStyle(this.overlay, { width: `${width}px`, height: `${height}px` });
|
|
6058
|
+
// src/render/text.ts
|
|
6059
|
+
var measureCtx;
|
|
6060
|
+
var injectedCtx = null;
|
|
6061
|
+
function setMeasureContext(ctx) {
|
|
6062
|
+
injectedCtx = ctx;
|
|
6063
|
+
}
|
|
6064
|
+
function getMeasureCtx() {
|
|
6065
|
+
if (injectedCtx) return injectedCtx;
|
|
6066
|
+
if (measureCtx !== void 0) return measureCtx;
|
|
6067
|
+
try {
|
|
6068
|
+
measureCtx = document.createElement("canvas").getContext("2d");
|
|
6069
|
+
} catch {
|
|
6070
|
+
measureCtx = null;
|
|
5756
6071
|
}
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
|
|
6072
|
+
return measureCtx;
|
|
6073
|
+
}
|
|
6074
|
+
function fallbackCharWidth(code) {
|
|
6075
|
+
const wide = code >= 4352 && code <= 4447 || code >= 11904 && code <= 42191 || code >= 44032 && code <= 55203 || code >= 63744 && code <= 64255 || code >= 65072 && code <= 65135 || code >= 65280 && code <= 65376 || code >= 65504 && code <= 65510 || code > 65535;
|
|
6076
|
+
return wide ? 1 : 0.55;
|
|
6077
|
+
}
|
|
6078
|
+
function measureText(text2, font) {
|
|
6079
|
+
const ctx = getMeasureCtx();
|
|
6080
|
+
if (!ctx) {
|
|
6081
|
+
const sizeMatch = /(\d+(?:\.\d+)?)px/.exec(font);
|
|
6082
|
+
const size = sizeMatch ? parseFloat(sizeMatch[1]) : 12;
|
|
6083
|
+
let ems = 0;
|
|
6084
|
+
for (const ch of text2) ems += fallbackCharWidth(ch.codePointAt(0) ?? 0);
|
|
6085
|
+
return { width: ems * size };
|
|
5762
6086
|
}
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
6087
|
+
ctx.font = font;
|
|
6088
|
+
return { width: ctx.measureText(text2).width };
|
|
6089
|
+
}
|
|
6090
|
+
function fontString(size, family, weight = 400, style = "normal") {
|
|
6091
|
+
return `${style} ${weight} ${size}px ${family}`;
|
|
6092
|
+
}
|
|
6093
|
+
function ellipsize(text2, maxWidth, font) {
|
|
6094
|
+
if (maxWidth <= 0 || text2.length === 0) return "";
|
|
6095
|
+
if (measureText(text2, font).width <= maxWidth) return text2;
|
|
6096
|
+
const ellipsis = "\u2026";
|
|
6097
|
+
const ellipsisW = measureText(ellipsis, font).width;
|
|
6098
|
+
if (ellipsisW > maxWidth) return "";
|
|
6099
|
+
let lo = 0;
|
|
6100
|
+
let hi = text2.length;
|
|
6101
|
+
while (lo < hi) {
|
|
6102
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
6103
|
+
if (measureText(text2.slice(0, mid) + ellipsis, font).width <= maxWidth) lo = mid;
|
|
6104
|
+
else hi = mid - 1;
|
|
5767
6105
|
}
|
|
5768
|
-
|
|
6106
|
+
return text2.slice(0, lo) + ellipsis;
|
|
6107
|
+
}
|
|
5769
6108
|
|
|
5770
6109
|
// src/render/overlayText.ts
|
|
5771
6110
|
function paintCanvasText(ctx, c) {
|
|
@@ -5860,53 +6199,155 @@ function paintLegendSwatch(ctx, x, midY, symbol, color, swatch = 11) {
|
|
|
5860
6199
|
ctx.restore();
|
|
5861
6200
|
}
|
|
5862
6201
|
|
|
5863
|
-
// src/render/
|
|
5864
|
-
var
|
|
5865
|
-
var
|
|
5866
|
-
|
|
5867
|
-
|
|
5868
|
-
}
|
|
5869
|
-
function getMeasureCtx() {
|
|
5870
|
-
if (injectedCtx) return injectedCtx;
|
|
5871
|
-
if (measureCtx !== void 0) return measureCtx;
|
|
5872
|
-
try {
|
|
5873
|
-
measureCtx = document.createElement("canvas").getContext("2d");
|
|
5874
|
-
} catch {
|
|
5875
|
-
measureCtx = null;
|
|
6202
|
+
// src/render/labelLedger.ts
|
|
6203
|
+
var LINE_HEIGHT = 1.2;
|
|
6204
|
+
var LabelLedger = class {
|
|
6205
|
+
constructor() {
|
|
6206
|
+
__publicField(this, "records", []);
|
|
5876
6207
|
}
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
function measureText(text, font) {
|
|
5880
|
-
const ctx = getMeasureCtx();
|
|
5881
|
-
if (!ctx) {
|
|
5882
|
-
const sizeMatch = /(\d+(?:\.\d+)?)px/.exec(font);
|
|
5883
|
-
const size = sizeMatch ? parseFloat(sizeMatch[1]) : 12;
|
|
5884
|
-
return { width: text.length * size * 0.55 };
|
|
6208
|
+
add(record) {
|
|
6209
|
+
this.records.push(record);
|
|
5885
6210
|
}
|
|
5886
|
-
|
|
5887
|
-
|
|
6211
|
+
clear() {
|
|
6212
|
+
this.records.length = 0;
|
|
6213
|
+
}
|
|
6214
|
+
/** Records matching any of `roles`. */
|
|
6215
|
+
byRole(...roles) {
|
|
6216
|
+
return this.records.filter((r) => roles.includes(r.role));
|
|
6217
|
+
}
|
|
6218
|
+
};
|
|
6219
|
+
function recordLabel(ledger, record) {
|
|
6220
|
+
ledger?.add(record);
|
|
6221
|
+
}
|
|
6222
|
+
function fitLabel(text2, budget, font) {
|
|
6223
|
+
if (budget == null || budget <= 0) return { text: text2, truncated: false };
|
|
6224
|
+
if (measureText(text2, font).width <= budget) return { text: text2, truncated: false };
|
|
6225
|
+
return { text: ellipsize(text2, budget, font), truncated: true };
|
|
6226
|
+
}
|
|
6227
|
+
function labelBox(o, font) {
|
|
6228
|
+
const cmd = overlayTextToCanvasCmd(o, font);
|
|
6229
|
+
const width = measureText(o.text, font).width;
|
|
6230
|
+
const height = o.size * LINE_HEIGHT;
|
|
6231
|
+
const x = cmd.align === "right" ? cmd.x - width : cmd.align === "center" ? cmd.x - width / 2 : cmd.x;
|
|
6232
|
+
const y = cmd.baseline === "middle" ? cmd.y - height / 2 : cmd.baseline === "alphabetic" ? cmd.y - height : cmd.y;
|
|
6233
|
+
const radians = cmd.rotate ?? 0;
|
|
6234
|
+
if (!radians) return { box: { x, y, width, height }, angle: 0 };
|
|
6235
|
+
const cos = Math.cos(radians);
|
|
6236
|
+
const sin = Math.sin(radians);
|
|
6237
|
+
const xs = [];
|
|
6238
|
+
const ys = [];
|
|
6239
|
+
for (const [px, py] of [
|
|
6240
|
+
[x, y],
|
|
6241
|
+
[x + width, y],
|
|
6242
|
+
[x, y + height],
|
|
6243
|
+
[x + width, y + height]
|
|
6244
|
+
]) {
|
|
6245
|
+
const dx = px - cmd.x;
|
|
6246
|
+
const dy = py - cmd.y;
|
|
6247
|
+
xs.push(cmd.x + dx * cos - dy * sin);
|
|
6248
|
+
ys.push(cmd.y + dx * sin + dy * cos);
|
|
6249
|
+
}
|
|
6250
|
+
const minX = Math.min(...xs);
|
|
6251
|
+
const minY = Math.min(...ys);
|
|
6252
|
+
return {
|
|
6253
|
+
box: { x: minX, y: minY, width: Math.max(...xs) - minX, height: Math.max(...ys) - minY },
|
|
6254
|
+
angle: radians * 180 / Math.PI
|
|
6255
|
+
};
|
|
5888
6256
|
}
|
|
5889
|
-
function
|
|
5890
|
-
return
|
|
6257
|
+
function boxesOverlap(a, b, tolerance2 = 0) {
|
|
6258
|
+
return a.x + a.width - tolerance2 > b.x && b.x + b.width - tolerance2 > a.x && a.y + a.height - tolerance2 > b.y && b.y + b.height - tolerance2 > a.y;
|
|
5891
6259
|
}
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
6260
|
+
|
|
6261
|
+
// src/render/surface.ts
|
|
6262
|
+
var Surface = class {
|
|
6263
|
+
constructor(container) {
|
|
6264
|
+
__publicField(this, "container");
|
|
6265
|
+
__publicField(this, "root");
|
|
6266
|
+
__publicField(this, "marks");
|
|
6267
|
+
__publicField(this, "interaction");
|
|
6268
|
+
__publicField(this, "overlay");
|
|
6269
|
+
/** Visually-hidden container for the screen-reader data-table fallback. */
|
|
6270
|
+
__publicField(this, "a11y");
|
|
6271
|
+
__publicField(this, "width", 0);
|
|
6272
|
+
__publicField(this, "height", 0);
|
|
6273
|
+
__publicField(this, "dpr", 1);
|
|
6274
|
+
/**
|
|
6275
|
+
* When true, overlay text (axis labels, titles, legends, annotation labels) is
|
|
6276
|
+
* painted onto the `marks` canvas instead of the HTML overlay. Set by headless
|
|
6277
|
+
* renderers (`@graphein/node`) where there is no DOM to host crisp text.
|
|
6278
|
+
* Defaults to false (browser).
|
|
6279
|
+
*/
|
|
6280
|
+
__publicField(this, "headless", false);
|
|
6281
|
+
/**
|
|
6282
|
+
* Every overlay label drawn during the current pass, recorded by the text
|
|
6283
|
+
* helpers in `axes/draw.ts` and `charts/chrome.ts`. The render report reads it
|
|
6284
|
+
* to detect clipped and colliding text for any chart type, cartesian or not.
|
|
6285
|
+
*/
|
|
6286
|
+
__publicField(this, "labels", new LabelLedger());
|
|
6287
|
+
this.container = container;
|
|
6288
|
+
this.root = createDiv("graphein-root", {
|
|
6289
|
+
position: "relative",
|
|
6290
|
+
width: "100%",
|
|
6291
|
+
height: "100%",
|
|
6292
|
+
overflow: "hidden"
|
|
6293
|
+
});
|
|
6294
|
+
const marksCanvas = document.createElement("canvas");
|
|
6295
|
+
marksCanvas.className = "graphein-layer-marks";
|
|
6296
|
+
setStyle(marksCanvas, { ...fillParentStyle });
|
|
6297
|
+
const interactionCanvas = document.createElement("canvas");
|
|
6298
|
+
interactionCanvas.className = "graphein-layer-interaction";
|
|
6299
|
+
setStyle(interactionCanvas, { ...fillParentStyle, pointerEvents: "none" });
|
|
6300
|
+
this.overlay = createDiv("graphein-layer-overlay", {
|
|
6301
|
+
...fillParentStyle,
|
|
6302
|
+
width: "100%",
|
|
6303
|
+
height: "100%",
|
|
6304
|
+
pointerEvents: "none"
|
|
6305
|
+
});
|
|
6306
|
+
this.a11y = createDiv("graphein-a11y", {
|
|
6307
|
+
position: "absolute",
|
|
6308
|
+
width: "1px",
|
|
6309
|
+
height: "1px",
|
|
6310
|
+
overflow: "hidden",
|
|
6311
|
+
clip: "rect(0 0 0 0)",
|
|
6312
|
+
clipPath: "inset(50%)",
|
|
6313
|
+
whiteSpace: "nowrap",
|
|
6314
|
+
border: "0",
|
|
6315
|
+
margin: "-1px",
|
|
6316
|
+
padding: "0"
|
|
6317
|
+
});
|
|
6318
|
+
this.root.appendChild(marksCanvas);
|
|
6319
|
+
this.root.appendChild(interactionCanvas);
|
|
6320
|
+
this.root.appendChild(this.overlay);
|
|
6321
|
+
this.root.appendChild(this.a11y);
|
|
6322
|
+
container.appendChild(this.root);
|
|
6323
|
+
this.marks = new CanvasLayer(marksCanvas);
|
|
6324
|
+
this.interaction = new CanvasLayer(interactionCanvas);
|
|
5904
6325
|
}
|
|
5905
|
-
|
|
5906
|
-
|
|
6326
|
+
resize(width, height, dpr = getDevicePixelRatio()) {
|
|
6327
|
+
this.width = width;
|
|
6328
|
+
this.height = height;
|
|
6329
|
+
this.dpr = dpr;
|
|
6330
|
+
this.marks.resize(width, height, dpr);
|
|
6331
|
+
this.interaction.resize(width, height, dpr);
|
|
6332
|
+
setStyle(this.overlay, { width: `${width}px`, height: `${height}px` });
|
|
6333
|
+
}
|
|
6334
|
+
/** Clear both canvas layers, empty the HTML overlay, and reset the ledger. */
|
|
6335
|
+
clear() {
|
|
6336
|
+
this.marks.clear();
|
|
6337
|
+
this.interaction.clear();
|
|
6338
|
+
this.overlay.replaceChildren();
|
|
6339
|
+
this.labels.clear();
|
|
6340
|
+
}
|
|
6341
|
+
/** Remove all DOM created by this Surface. */
|
|
6342
|
+
destroy() {
|
|
6343
|
+
this.clear();
|
|
6344
|
+
this.root.remove();
|
|
6345
|
+
}
|
|
6346
|
+
};
|
|
5907
6347
|
|
|
5908
6348
|
// src/theme/tokens.ts
|
|
5909
|
-
var
|
|
6349
|
+
var LIGHT_PALETTE = categorical("graphein");
|
|
6350
|
+
var DARK_PALETTE = adaptPaletteForDarkBackground(LIGHT_PALETTE, "#0b1220");
|
|
5910
6351
|
var FONT_FAMILY = 'Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
|
|
5911
6352
|
var SHARED = {
|
|
5912
6353
|
font: {
|
|
@@ -5930,7 +6371,7 @@ var lightTheme = {
|
|
|
5930
6371
|
grid: "#eef2f6",
|
|
5931
6372
|
border: "#e2e8f0",
|
|
5932
6373
|
accent: "#0d9488",
|
|
5933
|
-
palette:
|
|
6374
|
+
palette: LIGHT_PALETTE,
|
|
5934
6375
|
positive: "#16a34a",
|
|
5935
6376
|
negative: "#dc2626"
|
|
5936
6377
|
},
|
|
@@ -5948,7 +6389,7 @@ var darkTheme = {
|
|
|
5948
6389
|
grid: "#1c2740",
|
|
5949
6390
|
border: "#1e293b",
|
|
5950
6391
|
accent: "#2dd4bf",
|
|
5951
|
-
palette:
|
|
6392
|
+
palette: DARK_PALETTE,
|
|
5952
6393
|
positive: "#22c55e",
|
|
5953
6394
|
negative: "#f87171"
|
|
5954
6395
|
},
|
|
@@ -5961,6 +6402,18 @@ var themes = {
|
|
|
5961
6402
|
function isPlainObject(v) {
|
|
5962
6403
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
5963
6404
|
}
|
|
6405
|
+
var COLOR_FIELDS = [
|
|
6406
|
+
"background",
|
|
6407
|
+
"surface",
|
|
6408
|
+
"text",
|
|
6409
|
+
"textMuted",
|
|
6410
|
+
"axis",
|
|
6411
|
+
"grid",
|
|
6412
|
+
"border",
|
|
6413
|
+
"accent",
|
|
6414
|
+
"positive",
|
|
6415
|
+
"negative"
|
|
6416
|
+
];
|
|
5964
6417
|
function deepMerge(base, override) {
|
|
5965
6418
|
if (!isPlainObject(override)) return base;
|
|
5966
6419
|
if (!isPlainObject(base)) return override;
|
|
@@ -5975,13 +6428,57 @@ function deepMerge(base, override) {
|
|
|
5975
6428
|
}
|
|
5976
6429
|
return out;
|
|
5977
6430
|
}
|
|
6431
|
+
function addInvalidColorWarning(warnings, path) {
|
|
6432
|
+
warnings.push({ path, message: `Invalid color token at ${path}; using the base theme value.` });
|
|
6433
|
+
}
|
|
6434
|
+
function fallbackPaletteColor(base, index) {
|
|
6435
|
+
return base.color.palette[index] ?? base.color.palette[index % base.color.palette.length] ?? "#000000";
|
|
6436
|
+
}
|
|
6437
|
+
function sanitizeThemeOverride(base, override, warnings) {
|
|
6438
|
+
const sanitized = { ...override };
|
|
6439
|
+
if (!("color" in override)) return sanitized;
|
|
6440
|
+
const color = override.color;
|
|
6441
|
+
if (!isPlainObject(color)) {
|
|
6442
|
+
addInvalidColorWarning(warnings, "color");
|
|
6443
|
+
sanitized.color = base.color;
|
|
6444
|
+
return sanitized;
|
|
6445
|
+
}
|
|
6446
|
+
const colorOut = { ...color };
|
|
6447
|
+
for (const key of COLOR_FIELDS) {
|
|
6448
|
+
if (!(key in color)) continue;
|
|
6449
|
+
const value = color[key];
|
|
6450
|
+
if (typeof value === "string" && parseColor(value)) continue;
|
|
6451
|
+
addInvalidColorWarning(warnings, `color.${key}`);
|
|
6452
|
+
colorOut[key] = base.color[key];
|
|
6453
|
+
}
|
|
6454
|
+
if ("palette" in color) {
|
|
6455
|
+
const palette = color.palette;
|
|
6456
|
+
if (Array.isArray(palette)) {
|
|
6457
|
+
colorOut.palette = palette.map((value, index) => {
|
|
6458
|
+
if (typeof value === "string" && parseColor(value)) return value;
|
|
6459
|
+
addInvalidColorWarning(warnings, `color.palette.${index}`);
|
|
6460
|
+
return fallbackPaletteColor(base, index);
|
|
6461
|
+
});
|
|
6462
|
+
} else {
|
|
6463
|
+
addInvalidColorWarning(warnings, "color.palette");
|
|
6464
|
+
colorOut.palette = base.color.palette;
|
|
6465
|
+
}
|
|
6466
|
+
}
|
|
6467
|
+
sanitized.color = colorOut;
|
|
6468
|
+
return sanitized;
|
|
6469
|
+
}
|
|
5978
6470
|
function resolveTheme(theme) {
|
|
5979
|
-
|
|
5980
|
-
|
|
6471
|
+
return resolveThemeChecked(theme).tokens;
|
|
6472
|
+
}
|
|
6473
|
+
function resolveThemeChecked(theme) {
|
|
6474
|
+
if (theme == null) return { tokens: lightTheme, warnings: [] };
|
|
6475
|
+
if (typeof theme === "string") return { tokens: themes[theme] ?? lightTheme, warnings: [] };
|
|
5981
6476
|
const baseName = theme.base ?? (theme.dark ? "dark" : "light");
|
|
5982
6477
|
const base = themes[baseName] ?? lightTheme;
|
|
5983
6478
|
const { base: _ignored, ...override } = theme;
|
|
5984
|
-
|
|
6479
|
+
const warnings = [];
|
|
6480
|
+
const sanitized = sanitizeThemeOverride(base, override, warnings);
|
|
6481
|
+
return { tokens: deepMerge(base, sanitized), warnings };
|
|
5985
6482
|
}
|
|
5986
6483
|
|
|
5987
6484
|
// src/theme/sketch.ts
|
|
@@ -6082,24 +6579,131 @@ function inferFieldTypes(data) {
|
|
|
6082
6579
|
|
|
6083
6580
|
// src/spec/repair.ts
|
|
6084
6581
|
var MAX_PASSES = 8;
|
|
6085
|
-
function
|
|
6582
|
+
function isObject4(v) {
|
|
6583
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
6584
|
+
}
|
|
6585
|
+
function pathValue(doc, path) {
|
|
6586
|
+
if (path === "") return doc;
|
|
6587
|
+
let current = doc;
|
|
6588
|
+
for (const part of path.replace(/\[(\d+)\]/g, ".$1").split(".")) {
|
|
6589
|
+
if (part === "") continue;
|
|
6590
|
+
if (Array.isArray(current)) {
|
|
6591
|
+
current = current[Number(part)];
|
|
6592
|
+
} else if (isObject4(current)) {
|
|
6593
|
+
current = current[part];
|
|
6594
|
+
} else {
|
|
6595
|
+
return void 0;
|
|
6596
|
+
}
|
|
6597
|
+
}
|
|
6598
|
+
return current;
|
|
6599
|
+
}
|
|
6600
|
+
function dataRows(spec) {
|
|
6601
|
+
if (!isObject4(spec) || !Array.isArray(spec.data) || spec.data.length === 0) return void 0;
|
|
6602
|
+
return spec.data.filter(isObject4);
|
|
6603
|
+
}
|
|
6604
|
+
function dataColumns(spec) {
|
|
6605
|
+
const rows = dataRows(spec);
|
|
6606
|
+
if (!rows || rows.length === 0) return [];
|
|
6607
|
+
return [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
|
6608
|
+
}
|
|
6609
|
+
function rationaleForValidationFix(op) {
|
|
6610
|
+
return `validator-provided safe fix at "${op.path}"`;
|
|
6611
|
+
}
|
|
6612
|
+
function uniqueRecommendedSpec(spec) {
|
|
6613
|
+
const rows = dataRows(spec);
|
|
6614
|
+
if (!rows) return void 0;
|
|
6615
|
+
const recommendations = recommendChart(rows, { maxResults: 20 });
|
|
6616
|
+
if (recommendations.length === 0) return void 0;
|
|
6617
|
+
const [top, runnerUp] = recommendations;
|
|
6618
|
+
if (runnerUp && runnerUp.score === top.score) return void 0;
|
|
6619
|
+
return top.spec;
|
|
6620
|
+
}
|
|
6621
|
+
function uniqueRecommendationForType(spec, type) {
|
|
6622
|
+
const rows = dataRows(spec);
|
|
6623
|
+
if (!rows) return void 0;
|
|
6624
|
+
const matches = recommendChart(rows, { maxResults: 20 }).filter((rec) => {
|
|
6625
|
+
return isObject4(rec.spec) && rec.spec.type === type;
|
|
6626
|
+
});
|
|
6627
|
+
return matches.length === 1 ? matches[0].spec : void 0;
|
|
6628
|
+
}
|
|
6629
|
+
function collectDataFixes(spec, findings) {
|
|
6630
|
+
if (!isObject4(spec)) return [];
|
|
6631
|
+
const repairs = [];
|
|
6632
|
+
const columns = dataColumns(spec);
|
|
6633
|
+
for (const finding of findings) {
|
|
6634
|
+
if (finding.suggestion?.kind !== "field") continue;
|
|
6635
|
+
const value = pathValue(spec, finding.path);
|
|
6636
|
+
if (typeof value !== "string") continue;
|
|
6637
|
+
const best = closest(value, columns);
|
|
6638
|
+
if (!best || best === value) continue;
|
|
6639
|
+
repairs.push({
|
|
6640
|
+
op: { op: "replace", path: toPointer(finding.path), value: best },
|
|
6641
|
+
rationale: `field "${value}" \u2192 "${best}" (nearest column)`
|
|
6642
|
+
});
|
|
6643
|
+
}
|
|
6644
|
+
if (pathValue(spec, "type") === void 0) {
|
|
6645
|
+
const recommended = uniqueRecommendedSpec(spec);
|
|
6646
|
+
if (isObject4(recommended) && typeof recommended.type === "string") {
|
|
6647
|
+
repairs.push({
|
|
6648
|
+
op: { op: "add", path: "/type", value: recommended.type },
|
|
6649
|
+
rationale: `type "${recommended.type}" inferred from data shape`
|
|
6650
|
+
});
|
|
6651
|
+
}
|
|
6652
|
+
}
|
|
6653
|
+
const type = pathValue(spec, "type");
|
|
6654
|
+
const encoding = pathValue(spec, "encoding");
|
|
6655
|
+
if (typeof type === "string") {
|
|
6656
|
+
const recommended = uniqueRecommendationForType(spec, type);
|
|
6657
|
+
const recommendedEncoding = isObject4(recommended) ? recommended.encoding : void 0;
|
|
6658
|
+
if (isObject4(recommendedEncoding)) {
|
|
6659
|
+
if (!isObject4(encoding) && findings.some((finding) => finding.path === "encoding")) {
|
|
6660
|
+
repairs.push({
|
|
6661
|
+
op: { op: "add", path: "/encoding", value: recommendedEncoding },
|
|
6662
|
+
rationale: `encoding inferred from data shape for "${type}"`
|
|
6663
|
+
});
|
|
6664
|
+
} else if (isObject4(encoding)) {
|
|
6665
|
+
for (const finding of findings) {
|
|
6666
|
+
const match = /^encoding\.([^.]+)$/.exec(finding.path);
|
|
6667
|
+
if (!match) continue;
|
|
6668
|
+
const channel = match[1];
|
|
6669
|
+
if (encoding[channel] !== void 0 || recommendedEncoding[channel] === void 0) continue;
|
|
6670
|
+
repairs.push({
|
|
6671
|
+
op: { op: "add", path: toPointer(finding.path), value: recommendedEncoding[channel] },
|
|
6672
|
+
rationale: `channel "${channel}" inferred from data shape for "${type}"`
|
|
6673
|
+
});
|
|
6674
|
+
}
|
|
6675
|
+
}
|
|
6676
|
+
}
|
|
6677
|
+
}
|
|
6678
|
+
return repairs;
|
|
6679
|
+
}
|
|
6680
|
+
function repairSpec(spec, options = {}) {
|
|
6086
6681
|
let current = spec;
|
|
6087
6682
|
const applied = [];
|
|
6683
|
+
const appliedDetails = [];
|
|
6088
6684
|
let prevSignature = "";
|
|
6685
|
+
const level = options.level ?? "safe";
|
|
6089
6686
|
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
6090
6687
|
const { errors, warnings } = validateSpec(current);
|
|
6091
6688
|
const fixes = [];
|
|
6092
6689
|
for (const finding of [...errors, ...warnings]) {
|
|
6093
|
-
if (finding.fix && finding.fix.length > 0)
|
|
6690
|
+
if (finding.fix && finding.fix.length > 0) {
|
|
6691
|
+
fixes.push(...finding.fix.map((op) => ({ op, rationale: rationaleForValidationFix(op) })));
|
|
6692
|
+
}
|
|
6693
|
+
}
|
|
6694
|
+
if (level === "data" && fixes.length === 0) {
|
|
6695
|
+
fixes.push(...collectDataFixes(current, [...errors, ...warnings]));
|
|
6094
6696
|
}
|
|
6095
6697
|
if (fixes.length === 0) break;
|
|
6096
|
-
const
|
|
6698
|
+
const ops = fixes.map((fix) => fix.op);
|
|
6699
|
+
const signature = JSON.stringify(ops);
|
|
6097
6700
|
if (signature === prevSignature) break;
|
|
6098
6701
|
prevSignature = signature;
|
|
6099
|
-
current = applyPatch(current,
|
|
6100
|
-
applied.push(...
|
|
6702
|
+
current = applyPatch(current, ops);
|
|
6703
|
+
applied.push(...ops);
|
|
6704
|
+
appliedDetails.push(...fixes);
|
|
6101
6705
|
}
|
|
6102
|
-
return { spec: current, applied, remaining: validateSpec(current).errors };
|
|
6706
|
+
return { spec: current, applied, appliedDetails, remaining: validateSpec(current).errors };
|
|
6103
6707
|
}
|
|
6104
6708
|
|
|
6105
6709
|
// src/spec/sketch.ts
|
|
@@ -6125,75 +6729,534 @@ function resolveSketch(spec) {
|
|
|
6125
6729
|
};
|
|
6126
6730
|
}
|
|
6127
6731
|
|
|
6732
|
+
// src/spec/templates.ts
|
|
6733
|
+
var STARTER_SPECS = {
|
|
6734
|
+
line: {
|
|
6735
|
+
type: "line",
|
|
6736
|
+
title: "Monthly users",
|
|
6737
|
+
data: [
|
|
6738
|
+
{ month: "2024-01", users: 42 },
|
|
6739
|
+
{ month: "2024-02", users: 58 },
|
|
6740
|
+
{ month: "2024-03", users: 64 }
|
|
6741
|
+
],
|
|
6742
|
+
encoding: {
|
|
6743
|
+
x: { field: "month", type: "temporal" },
|
|
6744
|
+
y: { field: "users" }
|
|
6745
|
+
}
|
|
6746
|
+
},
|
|
6747
|
+
area: {
|
|
6748
|
+
type: "area",
|
|
6749
|
+
title: "Monthly revenue",
|
|
6750
|
+
data: [
|
|
6751
|
+
{ month: "2024-01", revenue: 120 },
|
|
6752
|
+
{ month: "2024-02", revenue: 150 },
|
|
6753
|
+
{ month: "2024-03", revenue: 180 }
|
|
6754
|
+
],
|
|
6755
|
+
encoding: {
|
|
6756
|
+
x: { field: "month", type: "temporal" },
|
|
6757
|
+
y: { field: "revenue" }
|
|
6758
|
+
}
|
|
6759
|
+
},
|
|
6760
|
+
bar: {
|
|
6761
|
+
type: "bar",
|
|
6762
|
+
title: "Sales by region",
|
|
6763
|
+
data: [
|
|
6764
|
+
{ region: "North", sales: 120 },
|
|
6765
|
+
{ region: "South", sales: 95 },
|
|
6766
|
+
{ region: "West", sales: 140 }
|
|
6767
|
+
],
|
|
6768
|
+
encoding: {
|
|
6769
|
+
x: { field: "region" },
|
|
6770
|
+
y: { field: "sales" }
|
|
6771
|
+
}
|
|
6772
|
+
},
|
|
6773
|
+
scatter: {
|
|
6774
|
+
type: "scatter",
|
|
6775
|
+
title: "Price vs. demand",
|
|
6776
|
+
data: [
|
|
6777
|
+
{ price: 10, demand: 80 },
|
|
6778
|
+
{ price: 15, demand: 65 },
|
|
6779
|
+
{ price: 20, demand: 48 }
|
|
6780
|
+
],
|
|
6781
|
+
encoding: {
|
|
6782
|
+
x: { field: "price" },
|
|
6783
|
+
y: { field: "demand" }
|
|
6784
|
+
}
|
|
6785
|
+
},
|
|
6786
|
+
combo: {
|
|
6787
|
+
type: "combo",
|
|
6788
|
+
title: "Revenue and margin",
|
|
6789
|
+
data: [
|
|
6790
|
+
{ month: "Jan", revenue: 120, margin: 0.31 },
|
|
6791
|
+
{ month: "Feb", revenue: 150, margin: 0.35 },
|
|
6792
|
+
{ month: "Mar", revenue: 135, margin: 0.33 }
|
|
6793
|
+
],
|
|
6794
|
+
encoding: {
|
|
6795
|
+
x: { field: "month" }
|
|
6796
|
+
},
|
|
6797
|
+
layers: [
|
|
6798
|
+
{ mark: "bar", encoding: { y: { field: "revenue" } } },
|
|
6799
|
+
{ mark: "line", axis: "right", encoding: { y: { field: "margin", format: ".0%" } } }
|
|
6800
|
+
]
|
|
6801
|
+
},
|
|
6802
|
+
histogram: {
|
|
6803
|
+
type: "histogram",
|
|
6804
|
+
title: "Order value distribution",
|
|
6805
|
+
data: [
|
|
6806
|
+
{ value: 12 },
|
|
6807
|
+
{ value: 18 },
|
|
6808
|
+
{ value: 25 },
|
|
6809
|
+
{ value: 32 }
|
|
6810
|
+
],
|
|
6811
|
+
encoding: {
|
|
6812
|
+
x: { field: "value" }
|
|
6813
|
+
}
|
|
6814
|
+
},
|
|
6815
|
+
pie: {
|
|
6816
|
+
type: "pie",
|
|
6817
|
+
title: "Share by channel",
|
|
6818
|
+
data: [
|
|
6819
|
+
{ channel: "Direct", share: 45 },
|
|
6820
|
+
{ channel: "Partner", share: 30 },
|
|
6821
|
+
{ channel: "Search", share: 25 }
|
|
6822
|
+
],
|
|
6823
|
+
encoding: {
|
|
6824
|
+
theta: { field: "share" },
|
|
6825
|
+
color: { field: "channel" }
|
|
6826
|
+
}
|
|
6827
|
+
},
|
|
6828
|
+
heatmap: {
|
|
6829
|
+
type: "heatmap",
|
|
6830
|
+
title: "Activity by day and hour",
|
|
6831
|
+
data: [
|
|
6832
|
+
{ day: "Mon", hour: "Morning", activity: 12 },
|
|
6833
|
+
{ day: "Mon", hour: "Evening", activity: 18 },
|
|
6834
|
+
{ day: "Tue", hour: "Morning", activity: 9 },
|
|
6835
|
+
{ day: "Tue", hour: "Evening", activity: 21 }
|
|
6836
|
+
],
|
|
6837
|
+
encoding: {
|
|
6838
|
+
x: { field: "day" },
|
|
6839
|
+
y: { field: "hour" },
|
|
6840
|
+
color: { field: "activity" }
|
|
6841
|
+
}
|
|
6842
|
+
},
|
|
6843
|
+
funnel: {
|
|
6844
|
+
type: "funnel",
|
|
6845
|
+
title: "Signup conversion",
|
|
6846
|
+
data: [
|
|
6847
|
+
{ stage: "Visitors", users: 1e3 },
|
|
6848
|
+
{ stage: "Trials", users: 420 },
|
|
6849
|
+
{ stage: "Customers", users: 180 }
|
|
6850
|
+
],
|
|
6851
|
+
encoding: {
|
|
6852
|
+
stage: { field: "stage" },
|
|
6853
|
+
value: { field: "users" }
|
|
6854
|
+
}
|
|
6855
|
+
},
|
|
6856
|
+
kpi: {
|
|
6857
|
+
type: "kpi",
|
|
6858
|
+
title: "Total revenue",
|
|
6859
|
+
data: [
|
|
6860
|
+
{ revenue: 120 },
|
|
6861
|
+
{ revenue: 150 }
|
|
6862
|
+
],
|
|
6863
|
+
value: { field: "revenue", aggregate: "sum" },
|
|
6864
|
+
label: "Revenue"
|
|
6865
|
+
},
|
|
6866
|
+
treemap: {
|
|
6867
|
+
type: "treemap",
|
|
6868
|
+
title: "Portfolio value",
|
|
6869
|
+
data: [
|
|
6870
|
+
{ category: "Product A", value: 60 },
|
|
6871
|
+
{ category: "Product B", value: 40 },
|
|
6872
|
+
{ category: "Product C", value: 25 }
|
|
6873
|
+
],
|
|
6874
|
+
encoding: {
|
|
6875
|
+
category: { field: "category" },
|
|
6876
|
+
value: { field: "value" }
|
|
6877
|
+
}
|
|
6878
|
+
},
|
|
6879
|
+
gauge: {
|
|
6880
|
+
type: "gauge",
|
|
6881
|
+
title: "Quota attainment",
|
|
6882
|
+
data: [
|
|
6883
|
+
{ attainment: 72 },
|
|
6884
|
+
{ attainment: 81 }
|
|
6885
|
+
],
|
|
6886
|
+
value: { field: "attainment", aggregate: "mean" },
|
|
6887
|
+
max: 100,
|
|
6888
|
+
label: "Attainment"
|
|
6889
|
+
},
|
|
6890
|
+
bullet: {
|
|
6891
|
+
type: "bullet",
|
|
6892
|
+
title: "Revenue vs. target",
|
|
6893
|
+
data: [
|
|
6894
|
+
{ revenue: 72 },
|
|
6895
|
+
{ revenue: 81 }
|
|
6896
|
+
],
|
|
6897
|
+
value: { field: "revenue", aggregate: "mean" },
|
|
6898
|
+
target: 90,
|
|
6899
|
+
max: 100,
|
|
6900
|
+
label: "Revenue"
|
|
6901
|
+
},
|
|
6902
|
+
calendarHeatmap: {
|
|
6903
|
+
type: "calendarHeatmap",
|
|
6904
|
+
title: "Daily activity",
|
|
6905
|
+
data: [
|
|
6906
|
+
{ date: "2024-01-01", value: 3 },
|
|
6907
|
+
{ date: "2024-01-02", value: 7 },
|
|
6908
|
+
{ date: "2024-01-03", value: 4 }
|
|
6909
|
+
],
|
|
6910
|
+
encoding: {
|
|
6911
|
+
date: { field: "date", type: "temporal" },
|
|
6912
|
+
color: { field: "value" }
|
|
6913
|
+
}
|
|
6914
|
+
},
|
|
6915
|
+
waterfall: {
|
|
6916
|
+
type: "waterfall",
|
|
6917
|
+
title: "Profit bridge",
|
|
6918
|
+
data: [
|
|
6919
|
+
{ stage: "Start", change: 100 },
|
|
6920
|
+
{ stage: "Growth", change: 35 },
|
|
6921
|
+
{ stage: "Costs", change: -20 }
|
|
6922
|
+
],
|
|
6923
|
+
encoding: {
|
|
6924
|
+
stage: { field: "stage" },
|
|
6925
|
+
value: { field: "change" }
|
|
6926
|
+
}
|
|
6927
|
+
},
|
|
6928
|
+
slope: {
|
|
6929
|
+
type: "slope",
|
|
6930
|
+
title: "Before and after",
|
|
6931
|
+
data: [
|
|
6932
|
+
{ period: "Before", score: 42, segment: "A" },
|
|
6933
|
+
{ period: "After", score: 58, segment: "A" },
|
|
6934
|
+
{ period: "Before", score: 35, segment: "B" },
|
|
6935
|
+
{ period: "After", score: 46, segment: "B" }
|
|
6936
|
+
],
|
|
6937
|
+
encoding: {
|
|
6938
|
+
x: { field: "period" },
|
|
6939
|
+
y: { field: "score" },
|
|
6940
|
+
series: { field: "segment" }
|
|
6941
|
+
}
|
|
6942
|
+
},
|
|
6943
|
+
dumbbell: {
|
|
6944
|
+
type: "dumbbell",
|
|
6945
|
+
title: "Before and after by category",
|
|
6946
|
+
data: [
|
|
6947
|
+
{ category: "A", value: 42, group: "Before" },
|
|
6948
|
+
{ category: "A", value: 58, group: "After" },
|
|
6949
|
+
{ category: "B", value: 35, group: "Before" },
|
|
6950
|
+
{ category: "B", value: 46, group: "After" }
|
|
6951
|
+
],
|
|
6952
|
+
encoding: {
|
|
6953
|
+
category: { field: "category" },
|
|
6954
|
+
value: { field: "value" },
|
|
6955
|
+
group: { field: "group" }
|
|
6956
|
+
}
|
|
6957
|
+
},
|
|
6958
|
+
table: {
|
|
6959
|
+
type: "table",
|
|
6960
|
+
title: "Regional sales",
|
|
6961
|
+
data: [
|
|
6962
|
+
{ region: "North", sales: 120 },
|
|
6963
|
+
{ region: "South", sales: 95 }
|
|
6964
|
+
]
|
|
6965
|
+
},
|
|
6966
|
+
matrix: {
|
|
6967
|
+
type: "matrix",
|
|
6968
|
+
title: "Sales matrix",
|
|
6969
|
+
data: [
|
|
6970
|
+
{ region: "North", segment: "Consumer", sales: 120 },
|
|
6971
|
+
{ region: "South", segment: "Business", sales: 95 }
|
|
6972
|
+
],
|
|
6973
|
+
rows: ["region"],
|
|
6974
|
+
columns: ["segment"],
|
|
6975
|
+
values: [{ field: "sales", op: "sum" }]
|
|
6976
|
+
},
|
|
6977
|
+
box: {
|
|
6978
|
+
type: "box",
|
|
6979
|
+
title: "Latency by tier",
|
|
6980
|
+
data: [
|
|
6981
|
+
{ tier: "Free", latency: 120 },
|
|
6982
|
+
{ tier: "Free", latency: 180 },
|
|
6983
|
+
{ tier: "Pro", latency: 90 },
|
|
6984
|
+
{ tier: "Pro", latency: 110 }
|
|
6985
|
+
],
|
|
6986
|
+
encoding: {
|
|
6987
|
+
x: { field: "tier" },
|
|
6988
|
+
y: { field: "latency" }
|
|
6989
|
+
}
|
|
6990
|
+
},
|
|
6991
|
+
sankey: {
|
|
6992
|
+
type: "sankey",
|
|
6993
|
+
title: "Traffic flow",
|
|
6994
|
+
data: [
|
|
6995
|
+
{ source: "Visits", target: "Signup", value: 100 },
|
|
6996
|
+
{ source: "Signup", target: "Trial", value: 60 },
|
|
6997
|
+
{ source: "Trial", target: "Paid", value: 25 }
|
|
6998
|
+
],
|
|
6999
|
+
encoding: {
|
|
7000
|
+
source: { field: "source" },
|
|
7001
|
+
target: { field: "target" },
|
|
7002
|
+
value: { field: "value" }
|
|
7003
|
+
}
|
|
7004
|
+
},
|
|
7005
|
+
choropleth: {
|
|
7006
|
+
type: "choropleth",
|
|
7007
|
+
title: "Regional index",
|
|
7008
|
+
geo: {
|
|
7009
|
+
type: "FeatureCollection",
|
|
7010
|
+
features: [
|
|
7011
|
+
{
|
|
7012
|
+
type: "Feature",
|
|
7013
|
+
properties: { id: "A" },
|
|
7014
|
+
geometry: { type: "Polygon", coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]] }
|
|
7015
|
+
},
|
|
7016
|
+
{
|
|
7017
|
+
type: "Feature",
|
|
7018
|
+
properties: { id: "B" },
|
|
7019
|
+
geometry: { type: "Polygon", coordinates: [[[1, 0], [2, 0], [2, 1], [1, 1], [1, 0]]] }
|
|
7020
|
+
}
|
|
7021
|
+
]
|
|
7022
|
+
},
|
|
7023
|
+
data: [
|
|
7024
|
+
{ region: "A", value: 42 },
|
|
7025
|
+
{ region: "B", value: 88 }
|
|
7026
|
+
],
|
|
7027
|
+
encoding: {
|
|
7028
|
+
key: { field: "region" },
|
|
7029
|
+
color: { field: "value" }
|
|
7030
|
+
},
|
|
7031
|
+
featureId: "id",
|
|
7032
|
+
projection: "identity"
|
|
7033
|
+
},
|
|
7034
|
+
dropdown: {
|
|
7035
|
+
type: "dropdown",
|
|
7036
|
+
title: "Region filter",
|
|
7037
|
+
data: [
|
|
7038
|
+
{ region: "North" },
|
|
7039
|
+
{ region: "South" }
|
|
7040
|
+
],
|
|
7041
|
+
field: "region"
|
|
7042
|
+
},
|
|
7043
|
+
search: {
|
|
7044
|
+
type: "search",
|
|
7045
|
+
title: "Customer search",
|
|
7046
|
+
data: [
|
|
7047
|
+
{ customer: "Acme" },
|
|
7048
|
+
{ customer: "Globex" }
|
|
7049
|
+
],
|
|
7050
|
+
field: "customer"
|
|
7051
|
+
},
|
|
7052
|
+
list: {
|
|
7053
|
+
type: "list",
|
|
7054
|
+
title: "Category filter",
|
|
7055
|
+
data: [
|
|
7056
|
+
{ category: "Hardware" },
|
|
7057
|
+
{ category: "Software" }
|
|
7058
|
+
],
|
|
7059
|
+
field: "category"
|
|
7060
|
+
},
|
|
7061
|
+
range: {
|
|
7062
|
+
type: "range",
|
|
7063
|
+
title: "Price range",
|
|
7064
|
+
data: [
|
|
7065
|
+
{ price: 10 },
|
|
7066
|
+
{ price: 25 }
|
|
7067
|
+
],
|
|
7068
|
+
field: "price"
|
|
7069
|
+
},
|
|
7070
|
+
dateRange: {
|
|
7071
|
+
type: "dateRange",
|
|
7072
|
+
title: "Date range",
|
|
7073
|
+
data: [
|
|
7074
|
+
{ date: "2024-01-01" },
|
|
7075
|
+
{ date: "2024-01-31" }
|
|
7076
|
+
],
|
|
7077
|
+
field: "date"
|
|
7078
|
+
}
|
|
7079
|
+
};
|
|
7080
|
+
var CHART_TYPE_PURPOSES = {
|
|
7081
|
+
line: "Trend over time",
|
|
7082
|
+
area: "Part-to-whole or filled trend over time",
|
|
7083
|
+
bar: "Compare categories",
|
|
7084
|
+
scatter: "Correlation or distribution across two measures",
|
|
7085
|
+
combo: "Two measures or mark types on a shared axis",
|
|
7086
|
+
histogram: "Distribution of one quantitative measure",
|
|
7087
|
+
pie: "Composition of a total",
|
|
7088
|
+
heatmap: "Density across two categories",
|
|
7089
|
+
funnel: "Conversion through stages",
|
|
7090
|
+
kpi: "Single headline value or compact scorecard",
|
|
7091
|
+
treemap: "Hierarchical or categorical part-to-whole",
|
|
7092
|
+
gauge: "Single value against a scale",
|
|
7093
|
+
bullet: "Compact KPI versus a target",
|
|
7094
|
+
calendarHeatmap: "Daily values over a year",
|
|
7095
|
+
waterfall: "Running total or bridge of signed deltas",
|
|
7096
|
+
slope: "Before/after change across series",
|
|
7097
|
+
dumbbell: "Gap between two groups by category",
|
|
7098
|
+
table: "Tabular detail",
|
|
7099
|
+
matrix: "Pivot or cross-tab summary",
|
|
7100
|
+
box: "Spread and distribution by group",
|
|
7101
|
+
sankey: "Flows between nodes",
|
|
7102
|
+
choropleth: "Values over map regions",
|
|
7103
|
+
dropdown: "Single- or multi-select field filter",
|
|
7104
|
+
search: "Text search filter for a field",
|
|
7105
|
+
list: "Checkbox list filter for a field",
|
|
7106
|
+
range: "Numeric range filter for a field",
|
|
7107
|
+
dateRange: "Temporal range filter for a field"
|
|
7108
|
+
};
|
|
7109
|
+
var REQUIRED_TEMPLATE_CHANNELS = {
|
|
7110
|
+
line: ["x", "y"],
|
|
7111
|
+
area: ["x", "y"],
|
|
7112
|
+
bar: ["x", "y"],
|
|
7113
|
+
scatter: ["x", "y"],
|
|
7114
|
+
combo: ["x", "layers[].mark", "layers[].encoding.y"],
|
|
7115
|
+
histogram: ["x"],
|
|
7116
|
+
pie: ["theta", "color"],
|
|
7117
|
+
heatmap: ["x", "y", "color"],
|
|
7118
|
+
funnel: ["stage", "value"],
|
|
7119
|
+
kpi: ["value"],
|
|
7120
|
+
treemap: ["category", "value"],
|
|
7121
|
+
gauge: ["value", "max"],
|
|
7122
|
+
bullet: ["value"],
|
|
7123
|
+
calendarHeatmap: ["date", "color"],
|
|
7124
|
+
waterfall: ["stage", "value"],
|
|
7125
|
+
slope: ["x", "y", "series"],
|
|
7126
|
+
dumbbell: ["category", "value", "group"],
|
|
7127
|
+
table: [],
|
|
7128
|
+
matrix: ["rows", "values"],
|
|
7129
|
+
box: ["x", "y"],
|
|
7130
|
+
sankey: ["source", "target", "value"],
|
|
7131
|
+
choropleth: ["geo", "key", "color", "featureId"],
|
|
7132
|
+
dropdown: ["field"],
|
|
7133
|
+
search: ["field"],
|
|
7134
|
+
list: ["field"],
|
|
7135
|
+
range: ["field"],
|
|
7136
|
+
dateRange: ["field"]
|
|
7137
|
+
};
|
|
7138
|
+
function cloneSpec(spec) {
|
|
7139
|
+
return JSON.parse(JSON.stringify(spec));
|
|
7140
|
+
}
|
|
7141
|
+
function starterSpec(type) {
|
|
7142
|
+
return cloneSpec(STARTER_SPECS[type]);
|
|
7143
|
+
}
|
|
7144
|
+
function listChartTypes() {
|
|
7145
|
+
return CHART_TYPES.map((type) => ({
|
|
7146
|
+
type,
|
|
7147
|
+
purpose: CHART_TYPE_PURPOSES[type],
|
|
7148
|
+
requiredChannels: [...REQUIRED_TEMPLATE_CHANNELS[type]],
|
|
7149
|
+
example: starterSpec(type)
|
|
7150
|
+
}));
|
|
7151
|
+
}
|
|
7152
|
+
|
|
6128
7153
|
// src/layout/frame.ts
|
|
6129
7154
|
var TICK_SIZE = 6;
|
|
6130
7155
|
var NO_PADDING = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
6131
7156
|
var AXIS_LABEL_GAP = 6;
|
|
6132
7157
|
var AXIS_TITLE_GAP = 4;
|
|
7158
|
+
var MAX_Y_GUTTER_FRACTION = 0.35;
|
|
7159
|
+
var MAX_X_BAND_FRACTION = 0.4;
|
|
7160
|
+
var MAX_LEGEND_WIDTH_FRACTION = 0.3;
|
|
6133
7161
|
var LEGEND_SWATCH = 11;
|
|
6134
7162
|
var LEGEND_SWATCH_GAP = 6;
|
|
6135
7163
|
var LEGEND_ITEM_GAP = 16;
|
|
6136
7164
|
var LEGEND_ROW_GAP = 6;
|
|
6137
7165
|
var LEGEND_PAD = 4;
|
|
7166
|
+
var MIN_VIABLE_PLOT_SIZE = 24;
|
|
7167
|
+
var COMPACT_PLOT_WIDTH = 300;
|
|
7168
|
+
var COMPACT_PLOT_HEIGHT = 180;
|
|
6138
7169
|
var lineHeight = (size) => Math.round(size * 1.35);
|
|
7170
|
+
function metricsForDensity(density, font) {
|
|
7171
|
+
return {
|
|
7172
|
+
axisLabelGap: AXIS_LABEL_GAP,
|
|
7173
|
+
axisTitleGap: AXIS_TITLE_GAP,
|
|
7174
|
+
tickSize: TICK_SIZE,
|
|
7175
|
+
legendSwatch: LEGEND_SWATCH,
|
|
7176
|
+
legendSwatchGap: LEGEND_SWATCH_GAP,
|
|
7177
|
+
legendItemGap: LEGEND_ITEM_GAP,
|
|
7178
|
+
legendRowGap: LEGEND_ROW_GAP,
|
|
7179
|
+
legendPad: LEGEND_PAD,
|
|
7180
|
+
titleGap: font.size.base
|
|
7181
|
+
};
|
|
7182
|
+
}
|
|
7183
|
+
function fontForDensity(font, density) {
|
|
7184
|
+
return font;
|
|
7185
|
+
}
|
|
6139
7186
|
function resolveXLabelAngle(axis, available, font) {
|
|
6140
7187
|
if (axis.labelAngle != null) return Math.max(0, Math.min(90, axis.labelAngle));
|
|
6141
7188
|
const labels = axis.labels;
|
|
6142
7189
|
if (axis.edgeAnchored || labels.length < 2 || available <= 0) return 0;
|
|
6143
7190
|
const wfont = fontString(font.size.small, font.family, font.weight.normal);
|
|
6144
7191
|
const widest = Math.max(...labels.map((l) => measureText(l, wfont).width));
|
|
6145
|
-
const
|
|
6146
|
-
|
|
7192
|
+
const slot = available / labels.length;
|
|
7193
|
+
if (slot >= widest + 10) return 0;
|
|
7194
|
+
return slot * Math.SQRT1_2 >= font.size.small ? 45 : 90;
|
|
6147
7195
|
}
|
|
6148
|
-
function legendItemWidth(label, font) {
|
|
7196
|
+
function legendItemWidth(label, font, m) {
|
|
6149
7197
|
const w = measureText(label, fontString(font.size.small, font.family, font.weight.normal)).width;
|
|
6150
|
-
return
|
|
7198
|
+
return m.legendSwatch + m.legendSwatchGap + Math.ceil(w);
|
|
6151
7199
|
}
|
|
6152
|
-
function layoutLegend(items, position, font, maxWidth, maxHeight) {
|
|
6153
|
-
const rowH = Math.max(
|
|
7200
|
+
function layoutLegend(items, position, font, m, maxWidth, maxHeight) {
|
|
7201
|
+
const rowH = Math.max(m.legendSwatch, lineHeight(font.size.small));
|
|
6154
7202
|
const horizontal = position === "top" || position === "bottom";
|
|
6155
7203
|
if (horizontal) {
|
|
6156
|
-
const
|
|
7204
|
+
const rows2 = [[]];
|
|
6157
7205
|
let x = 0;
|
|
6158
7206
|
let row = 0;
|
|
6159
7207
|
for (const item of items) {
|
|
6160
|
-
const w = legendItemWidth(item.label, font);
|
|
7208
|
+
const w = legendItemWidth(item.label, font, m);
|
|
6161
7209
|
if (x > 0 && x + w > maxWidth) {
|
|
6162
7210
|
row++;
|
|
6163
|
-
|
|
7211
|
+
rows2[row] = [];
|
|
6164
7212
|
x = 0;
|
|
6165
7213
|
}
|
|
6166
|
-
|
|
6167
|
-
x += w +
|
|
7214
|
+
rows2[row].push({ ...item, x, y: row * (rowH + m.legendRowGap), width: w });
|
|
7215
|
+
x += w + m.legendItemGap;
|
|
6168
7216
|
}
|
|
6169
7217
|
const widest = Math.max(
|
|
6170
7218
|
0,
|
|
6171
|
-
...
|
|
7219
|
+
...rows2.map((r) => r.reduce((acc, it) => acc + it.width + m.legendItemGap, -16))
|
|
6172
7220
|
);
|
|
6173
|
-
const height2 =
|
|
7221
|
+
const height2 = rows2.length * rowH + (rows2.length - 1) * m.legendRowGap;
|
|
6174
7222
|
const blockWidth = Math.min(maxWidth, widest);
|
|
6175
7223
|
const positioned2 = [];
|
|
6176
|
-
for (const r of
|
|
6177
|
-
const rowWidth = r.reduce((acc, it) => acc + it.width +
|
|
7224
|
+
for (const r of rows2) {
|
|
7225
|
+
const rowWidth = r.reduce((acc, it) => acc + it.width + m.legendItemGap, -16);
|
|
6178
7226
|
const offset = Math.max(0, (blockWidth - rowWidth) / 2);
|
|
6179
7227
|
for (const it of r) positioned2.push({ ...it, x: it.x + offset });
|
|
6180
7228
|
}
|
|
6181
|
-
return { width: blockWidth, height: height2 +
|
|
7229
|
+
return { width: blockWidth, height: height2 + m.legendPad * 2, items: positioned2, overflow: 0 };
|
|
6182
7230
|
}
|
|
6183
|
-
const maxRows = Math.max(1, Math.floor((maxHeight +
|
|
6184
|
-
const
|
|
6185
|
-
const
|
|
7231
|
+
const maxRows = Math.max(1, Math.floor((maxHeight + m.legendRowGap) / (rowH + m.legendRowGap)));
|
|
7232
|
+
const overflowing = items.length > maxRows;
|
|
7233
|
+
const shown = overflowing ? items.slice(0, Math.max(0, maxRows - 1)) : items;
|
|
7234
|
+
const overflow = items.length - shown.length;
|
|
7235
|
+
const cap2 = Math.max(0, maxWidth * MAX_LEGEND_WIDTH_FRACTION);
|
|
7236
|
+
const natural = Math.max(0, ...shown.map((it) => legendItemWidth(it.label, font, m)));
|
|
7237
|
+
const width = Math.min(natural, cap2);
|
|
6186
7238
|
const positioned = shown.map((it, i) => ({
|
|
6187
7239
|
...it,
|
|
6188
7240
|
x: 0,
|
|
6189
|
-
y: i * (rowH +
|
|
6190
|
-
width: legendItemWidth(it.label, font)
|
|
7241
|
+
y: i * (rowH + m.legendRowGap),
|
|
7242
|
+
width: Math.min(legendItemWidth(it.label, font, m), width)
|
|
6191
7243
|
}));
|
|
6192
|
-
const
|
|
6193
|
-
|
|
7244
|
+
const rows = shown.length + (overflow > 0 ? 1 : 0);
|
|
7245
|
+
const height = Math.max(0, rows * rowH + (rows - 1) * m.legendRowGap);
|
|
7246
|
+
const overflowRow = overflow > 0 ? { x: 0, y: shown.length * (rowH + m.legendRowGap), width, height: rowH } : void 0;
|
|
7247
|
+
return { width: width + m.legendPad * 2, height, items: positioned, overflow, overflowRow };
|
|
6194
7248
|
}
|
|
6195
|
-
function
|
|
7249
|
+
function viable(plot) {
|
|
7250
|
+
return plot.width >= MIN_VIABLE_PLOT_SIZE && plot.height >= MIN_VIABLE_PLOT_SIZE;
|
|
7251
|
+
}
|
|
7252
|
+
function resolveDensity(frame) {
|
|
7253
|
+
return frame.plot.width < COMPACT_PLOT_WIDTH || frame.plot.height < COMPACT_PLOT_HEIGHT ? "compact" : "comfortable";
|
|
7254
|
+
}
|
|
7255
|
+
function computeFramePass(input, density, dropped) {
|
|
6196
7256
|
const { width, height, padding, font } = input;
|
|
7257
|
+
const layoutFont = fontForDensity(font);
|
|
7258
|
+
const m = metricsForDensity(density, layoutFont);
|
|
7259
|
+
const drop = new Set(dropped);
|
|
6197
7260
|
const ox = input.originX ?? 0;
|
|
6198
7261
|
const oy = input.originY ?? 0;
|
|
6199
7262
|
let top = oy + padding.top;
|
|
@@ -6206,87 +7269,115 @@ function computeFrame(input) {
|
|
|
6206
7269
|
originX: ox,
|
|
6207
7270
|
originY: oy,
|
|
6208
7271
|
plot: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
|
|
6209
|
-
xLabelAngle: 0
|
|
7272
|
+
xLabelAngle: 0,
|
|
7273
|
+
xLabelMaxWidth: Math.max(0, right - left),
|
|
7274
|
+
yLabelMaxWidth: Math.max(0, right - left),
|
|
7275
|
+
legendOverflow: 0,
|
|
7276
|
+
legendLabelMaxWidth: Math.max(0, right - left),
|
|
7277
|
+
density,
|
|
7278
|
+
dropped
|
|
6210
7279
|
};
|
|
6211
|
-
if (input.title?.text) {
|
|
6212
|
-
const titleH = lineHeight(
|
|
7280
|
+
if (input.title?.text && !drop.has("title")) {
|
|
7281
|
+
const titleH = lineHeight(layoutFont.size.title);
|
|
6213
7282
|
frame.titleRect = { x: left, y: top, width: right - left, height: titleH };
|
|
6214
7283
|
top += titleH;
|
|
6215
|
-
if (input.title.subtitle) {
|
|
6216
|
-
const subH = lineHeight(
|
|
7284
|
+
if (input.title.subtitle && !drop.has("subtitle")) {
|
|
7285
|
+
const subH = lineHeight(layoutFont.size.small);
|
|
6217
7286
|
frame.subtitleRect = { x: left, y: top, width: right - left, height: subH };
|
|
6218
7287
|
top += subH;
|
|
6219
7288
|
}
|
|
6220
|
-
top +=
|
|
7289
|
+
top += m.titleGap;
|
|
6221
7290
|
}
|
|
6222
|
-
if (input.legend && input.legend.items.length > 0) {
|
|
7291
|
+
if (input.legend && input.legend.items.length > 0 && !drop.has("legend")) {
|
|
6223
7292
|
const pos = input.legend.position;
|
|
6224
7293
|
const availW = right - left;
|
|
6225
7294
|
const availH = bottom - top;
|
|
6226
|
-
const block = layoutLegend(input.legend.items, pos,
|
|
7295
|
+
const block = layoutLegend(input.legend.items, pos, layoutFont, m, availW, availH);
|
|
6227
7296
|
frame.legendPosition = pos;
|
|
7297
|
+
frame.legendOverflow = block.overflow;
|
|
7298
|
+
frame.legendLabelMaxWidth = Math.max(
|
|
7299
|
+
0,
|
|
7300
|
+
block.width - m.legendPad * 2 - m.legendSwatch - m.legendSwatchGap
|
|
7301
|
+
);
|
|
6228
7302
|
if (pos === "top") {
|
|
6229
7303
|
frame.legendRect = { x: left, y: top, width: availW, height: block.height };
|
|
6230
|
-
top += block.height +
|
|
7304
|
+
top += block.height + layoutFont.size.base;
|
|
6231
7305
|
} else if (pos === "bottom") {
|
|
6232
7306
|
frame.legendRect = { x: left, y: bottom - block.height, width: availW, height: block.height };
|
|
6233
|
-
bottom -= block.height +
|
|
7307
|
+
bottom -= block.height + layoutFont.size.base;
|
|
6234
7308
|
} else if (pos === "left") {
|
|
6235
7309
|
frame.legendRect = { x: left, y: top, width: block.width, height: availH };
|
|
6236
|
-
left += block.width +
|
|
7310
|
+
left += block.width + layoutFont.size.base;
|
|
6237
7311
|
} else {
|
|
6238
7312
|
frame.legendRect = { x: right - block.width, y: top, width: block.width, height: availH };
|
|
6239
|
-
right -= block.width +
|
|
7313
|
+
right -= block.width + layoutFont.size.base;
|
|
6240
7314
|
}
|
|
6241
|
-
const ox2 = frame.legendRect.x +
|
|
6242
|
-
const oy2 = frame.legendRect.y +
|
|
7315
|
+
const ox2 = frame.legendRect.x + m.legendPad;
|
|
7316
|
+
const oy2 = frame.legendRect.y + m.legendPad;
|
|
6243
7317
|
frame.legendItems = block.items.map((it) => ({ ...it, x: it.x + ox2, y: it.y + oy2 }));
|
|
7318
|
+
if (block.overflowRow) {
|
|
7319
|
+
frame.legendOverflowRect = {
|
|
7320
|
+
...block.overflowRow,
|
|
7321
|
+
x: block.overflowRow.x + ox2,
|
|
7322
|
+
y: block.overflowRow.y + oy2
|
|
7323
|
+
};
|
|
7324
|
+
}
|
|
6244
7325
|
}
|
|
6245
7326
|
if (input.yAxis?.show) {
|
|
6246
7327
|
let gutter = 0;
|
|
6247
7328
|
if (input.yAxis.labels.length) {
|
|
6248
|
-
const wfont = fontString(
|
|
7329
|
+
const wfont = fontString(layoutFont.size.small, layoutFont.family, layoutFont.weight.normal);
|
|
6249
7330
|
const widest = Math.max(...input.yAxis.labels.map((l) => measureText(l, wfont).width));
|
|
6250
|
-
|
|
7331
|
+
const capped = Math.min(widest, Math.max(0, (right - left) * MAX_Y_GUTTER_FRACTION));
|
|
7332
|
+
frame.yLabelMaxWidth = capped;
|
|
7333
|
+
gutter = Math.ceil(capped) + m.tickSize + m.axisLabelGap;
|
|
7334
|
+
}
|
|
7335
|
+
if (input.yAxis.title && !drop.has("axis-titles")) {
|
|
7336
|
+
gutter += lineHeight(layoutFont.size.base) + m.axisTitleGap;
|
|
6251
7337
|
}
|
|
6252
|
-
if (input.yAxis.title) gutter += lineHeight(font.size.base) + AXIS_TITLE_GAP;
|
|
6253
7338
|
left += gutter;
|
|
6254
|
-
const yHalf = Math.ceil(lineHeight(
|
|
7339
|
+
const yHalf = Math.ceil(lineHeight(layoutFont.size.small) / 2);
|
|
6255
7340
|
top += yHalf;
|
|
6256
7341
|
bottom -= yHalf;
|
|
6257
7342
|
}
|
|
6258
7343
|
if (input.y2Axis?.show) {
|
|
6259
7344
|
let gutter = 0;
|
|
6260
7345
|
if (input.y2Axis.labels.length) {
|
|
6261
|
-
const wfont = fontString(
|
|
7346
|
+
const wfont = fontString(layoutFont.size.small, layoutFont.family, layoutFont.weight.normal);
|
|
6262
7347
|
const widest = Math.max(...input.y2Axis.labels.map((l) => measureText(l, wfont).width));
|
|
6263
|
-
gutter = Math.ceil(widest) +
|
|
7348
|
+
gutter = Math.ceil(widest) + m.tickSize + m.axisLabelGap;
|
|
7349
|
+
}
|
|
7350
|
+
if (input.y2Axis.title && !drop.has("axis-titles")) {
|
|
7351
|
+
gutter += lineHeight(layoutFont.size.base) + m.axisTitleGap;
|
|
6264
7352
|
}
|
|
6265
|
-
if (input.y2Axis.title) gutter += lineHeight(font.size.base) + AXIS_TITLE_GAP;
|
|
6266
7353
|
right -= gutter;
|
|
6267
7354
|
if (!input.yAxis?.show) {
|
|
6268
|
-
const yHalf = Math.ceil(lineHeight(
|
|
7355
|
+
const yHalf = Math.ceil(lineHeight(layoutFont.size.small) / 2);
|
|
6269
7356
|
top += yHalf;
|
|
6270
7357
|
bottom -= yHalf;
|
|
6271
7358
|
}
|
|
6272
7359
|
}
|
|
6273
7360
|
if (input.xAxis?.show) {
|
|
6274
|
-
const angle = resolveXLabelAngle(input.xAxis, right - left,
|
|
7361
|
+
const angle = resolveXLabelAngle(input.xAxis, right - left, layoutFont);
|
|
6275
7362
|
frame.xLabelAngle = angle;
|
|
6276
|
-
const labelH = lineHeight(
|
|
7363
|
+
const labelH = lineHeight(layoutFont.size.small);
|
|
6277
7364
|
let labelBand = labelH;
|
|
7365
|
+
frame.xLabelMaxWidth = Math.max(0, right - left);
|
|
6278
7366
|
if (angle > 0 && input.xAxis.labels.length) {
|
|
6279
|
-
const wfont = fontString(
|
|
7367
|
+
const wfont = fontString(layoutFont.size.small, layoutFont.family, layoutFont.weight.normal);
|
|
6280
7368
|
const widest = Math.max(...input.xAxis.labels.map((l) => measureText(l, wfont).width));
|
|
6281
|
-
const cappedW = Math.min(widest, (bottom - top) *
|
|
7369
|
+
const cappedW = Math.min(widest, (bottom - top) * MAX_X_BAND_FRACTION);
|
|
7370
|
+
frame.xLabelMaxWidth = Math.max(0, cappedW);
|
|
6282
7371
|
const rad = angle * Math.PI / 180;
|
|
6283
7372
|
labelBand = Math.ceil(cappedW * Math.sin(rad) + labelH * Math.cos(rad));
|
|
6284
7373
|
}
|
|
6285
|
-
let gutter = labelBand +
|
|
6286
|
-
if (input.xAxis.title
|
|
7374
|
+
let gutter = labelBand + m.tickSize + m.axisLabelGap;
|
|
7375
|
+
if (input.xAxis.title && !drop.has("axis-titles")) {
|
|
7376
|
+
gutter += lineHeight(layoutFont.size.base) + m.axisTitleGap;
|
|
7377
|
+
}
|
|
6287
7378
|
bottom -= gutter;
|
|
6288
7379
|
if (input.xAxis.edgeAnchored && angle === 0 && input.xAxis.labels.length) {
|
|
6289
|
-
const wfont = fontString(
|
|
7380
|
+
const wfont = fontString(layoutFont.size.small, layoutFont.family, layoutFont.weight.normal);
|
|
6290
7381
|
const labels = input.xAxis.labels;
|
|
6291
7382
|
const firstHalf = Math.ceil(measureText(labels[0], wfont).width / 2);
|
|
6292
7383
|
const lastHalf = Math.ceil(measureText(labels[labels.length - 1], wfont).width / 2);
|
|
@@ -6302,6 +7393,22 @@ function computeFrame(input) {
|
|
|
6302
7393
|
};
|
|
6303
7394
|
return frame;
|
|
6304
7395
|
}
|
|
7396
|
+
function computeFrame(input) {
|
|
7397
|
+
const comfortable = computeFramePass(input, "comfortable", []);
|
|
7398
|
+
const density = resolveDensity(comfortable);
|
|
7399
|
+
const dropped = [];
|
|
7400
|
+
let frame = density === "comfortable" ? comfortable : computeFramePass(input, density, dropped);
|
|
7401
|
+
const ladder = ["subtitle", "axis-titles", "legend", "title"];
|
|
7402
|
+
for (const rung of ladder) {
|
|
7403
|
+
if (viable(frame.plot)) break;
|
|
7404
|
+
if (rung === "subtitle" && !input.title?.subtitle || rung === "axis-titles" && !input.xAxis?.title && !input.yAxis?.title && !input.y2Axis?.title || rung === "legend" && (!input.legend || input.legend.items.length === 0) || rung === "title" && !input.title?.text) {
|
|
7405
|
+
continue;
|
|
7406
|
+
}
|
|
7407
|
+
dropped.push(rung);
|
|
7408
|
+
frame = computeFramePass(input, density, [...dropped]);
|
|
7409
|
+
}
|
|
7410
|
+
return frame;
|
|
7411
|
+
}
|
|
6305
7412
|
|
|
6306
7413
|
// src/util/math.ts
|
|
6307
7414
|
function clamp(v, min, max) {
|
|
@@ -6314,19 +7421,38 @@ function crisp(v, strokeWidth = 1) {
|
|
|
6314
7421
|
|
|
6315
7422
|
// src/axes/draw.ts
|
|
6316
7423
|
function addText(surface, font, o) {
|
|
7424
|
+
const budget = o.maxWidth ?? o.width;
|
|
7425
|
+
const fitted = fitLabel(o.text, budget, font);
|
|
7426
|
+
const geom = {
|
|
7427
|
+
left: o.left ?? 0,
|
|
7428
|
+
top: o.top ?? 0,
|
|
7429
|
+
width: o.width,
|
|
7430
|
+
text: fitted.text,
|
|
7431
|
+
color: o.color,
|
|
7432
|
+
size: o.size,
|
|
7433
|
+
align: o.align,
|
|
7434
|
+
transform: o.transform
|
|
7435
|
+
};
|
|
7436
|
+
const { box, angle } = labelBox(geom, font);
|
|
7437
|
+
recordLabel(surface.labels, {
|
|
7438
|
+
role: o.role ?? "other",
|
|
7439
|
+
text: fitted.text,
|
|
7440
|
+
fullText: o.text,
|
|
7441
|
+
truncated: fitted.truncated,
|
|
7442
|
+
box,
|
|
7443
|
+
angle,
|
|
7444
|
+
size: o.size,
|
|
7445
|
+
budget,
|
|
7446
|
+
axis: o.axis
|
|
7447
|
+
});
|
|
6317
7448
|
if (surface.headless) {
|
|
6318
|
-
paintCanvasText(
|
|
6319
|
-
surface.marks.ctx,
|
|
6320
|
-
overlayTextToCanvasCmd(
|
|
6321
|
-
{ left: o.left ?? 0, top: o.top ?? 0, width: o.width, text: o.text, color: o.color, size: o.size, align: o.align, transform: o.transform },
|
|
6322
|
-
font
|
|
6323
|
-
)
|
|
6324
|
-
);
|
|
7449
|
+
paintCanvasText(surface.marks.ctx, overlayTextToCanvasCmd(geom, font));
|
|
6325
7450
|
return;
|
|
6326
7451
|
}
|
|
6327
7452
|
const overlay = surface.overlay;
|
|
6328
7453
|
const el = document.createElement("div");
|
|
6329
|
-
el.textContent =
|
|
7454
|
+
el.textContent = fitted.text;
|
|
7455
|
+
if (fitted.truncated) el.title = o.text;
|
|
6330
7456
|
el.style.position = "absolute";
|
|
6331
7457
|
el.style.font = font;
|
|
6332
7458
|
el.style.fontSize = `${o.size}px`;
|
|
@@ -6492,6 +7618,19 @@ function thinXTicks(model, avgCharPx) {
|
|
|
6492
7618
|
const stride = Math.ceil(needed / available);
|
|
6493
7619
|
return ticks2.filter((_, i) => i % stride === 0);
|
|
6494
7620
|
}
|
|
7621
|
+
function thinGutterTicks(ticks2, fontSize) {
|
|
7622
|
+
if (ticks2.length <= 2) return ticks2;
|
|
7623
|
+
const minGap = fontSize * 1.35;
|
|
7624
|
+
const span = Math.abs(ticks2[ticks2.length - 1].pos - ticks2[0].pos);
|
|
7625
|
+
const gap = span / (ticks2.length - 1);
|
|
7626
|
+
if (gap >= minGap) return ticks2;
|
|
7627
|
+
const stride = Math.ceil(minGap / Math.max(gap, 1e-6));
|
|
7628
|
+
const kept = ticks2.filter((_, i) => i % stride === 0);
|
|
7629
|
+
const last = ticks2[ticks2.length - 1];
|
|
7630
|
+
const tail = kept[kept.length - 1];
|
|
7631
|
+
if (tail !== last && Math.abs(last.pos - tail.pos) >= minGap) kept.push(last);
|
|
7632
|
+
return kept;
|
|
7633
|
+
}
|
|
6495
7634
|
function placeXLabels(ticks2, originX, frameWidth, font) {
|
|
6496
7635
|
const edgePad = 2;
|
|
6497
7636
|
const minGap = 6;
|
|
@@ -6557,7 +7696,7 @@ function drawOverlay(surface, model) {
|
|
|
6557
7696
|
const gutterLabelsOn = horizontal ? model.spec.axes?.x?.labels !== false : model.spec.axes?.y?.labels !== false;
|
|
6558
7697
|
if (gutterLabelsOn) {
|
|
6559
7698
|
const gutterRight = plot.x - TICK_SIZE - 4;
|
|
6560
|
-
for (const t of gutterTicks) {
|
|
7699
|
+
for (const t of thinGutterTicks(gutterTicks, f.size.small)) {
|
|
6561
7700
|
addText(surface, smallFont, {
|
|
6562
7701
|
left: 0,
|
|
6563
7702
|
top: t.pos,
|
|
@@ -6566,7 +7705,9 @@ function drawOverlay(surface, model) {
|
|
|
6566
7705
|
color: tokens.color.textMuted,
|
|
6567
7706
|
size: f.size.small,
|
|
6568
7707
|
align: "right",
|
|
6569
|
-
transform: "translateY(-50%)"
|
|
7708
|
+
transform: "translateY(-50%)",
|
|
7709
|
+
role: "axis-label",
|
|
7710
|
+
axis: horizontal ? "x" : "y"
|
|
6570
7711
|
});
|
|
6571
7712
|
}
|
|
6572
7713
|
}
|
|
@@ -6581,7 +7722,10 @@ function drawOverlay(surface, model) {
|
|
|
6581
7722
|
text: p.text,
|
|
6582
7723
|
color: tokens.color.textMuted,
|
|
6583
7724
|
size: f.size.small,
|
|
6584
|
-
transform: p.transform
|
|
7725
|
+
transform: p.transform,
|
|
7726
|
+
maxWidth: frame.xLabelMaxWidth,
|
|
7727
|
+
role: "axis-label",
|
|
7728
|
+
axis: "y"
|
|
6585
7729
|
});
|
|
6586
7730
|
}
|
|
6587
7731
|
} else {
|
|
@@ -6595,7 +7739,10 @@ function drawOverlay(surface, model) {
|
|
|
6595
7739
|
size: f.size.small,
|
|
6596
7740
|
align: rotated ? "right" : void 0,
|
|
6597
7741
|
transform: p.transform,
|
|
6598
|
-
transformOrigin: rotated ? "100% 0%" : void 0
|
|
7742
|
+
transformOrigin: rotated ? "100% 0%" : void 0,
|
|
7743
|
+
maxWidth: frame.xLabelMaxWidth,
|
|
7744
|
+
role: "axis-label",
|
|
7745
|
+
axis: "x"
|
|
6599
7746
|
});
|
|
6600
7747
|
}
|
|
6601
7748
|
}
|
|
@@ -6619,7 +7766,10 @@ function drawAxisTitles(surface, model) {
|
|
|
6619
7766
|
color: tokens.color.text,
|
|
6620
7767
|
size: f.size.base,
|
|
6621
7768
|
weight: f.weight.medium,
|
|
6622
|
-
transform: "translateX(-50%)"
|
|
7769
|
+
transform: "translateX(-50%)",
|
|
7770
|
+
maxWidth: plot.width,
|
|
7771
|
+
role: "axis-title",
|
|
7772
|
+
axis: "y"
|
|
6623
7773
|
});
|
|
6624
7774
|
}
|
|
6625
7775
|
if (xTitle) {
|
|
@@ -6630,7 +7780,10 @@ function drawAxisTitles(surface, model) {
|
|
|
6630
7780
|
color: tokens.color.text,
|
|
6631
7781
|
size: f.size.base,
|
|
6632
7782
|
weight: f.weight.medium,
|
|
6633
|
-
transform: "translate(-50%, -50%) rotate(-90deg)"
|
|
7783
|
+
transform: "translate(-50%, -50%) rotate(-90deg)",
|
|
7784
|
+
maxWidth: plot.height,
|
|
7785
|
+
role: "axis-title",
|
|
7786
|
+
axis: "x"
|
|
6634
7787
|
});
|
|
6635
7788
|
}
|
|
6636
7789
|
return;
|
|
@@ -6643,7 +7796,10 @@ function drawAxisTitles(surface, model) {
|
|
|
6643
7796
|
color: tokens.color.text,
|
|
6644
7797
|
size: f.size.base,
|
|
6645
7798
|
weight: f.weight.medium,
|
|
6646
|
-
transform: "translateX(-50%)"
|
|
7799
|
+
transform: "translateX(-50%)",
|
|
7800
|
+
maxWidth: plot.width,
|
|
7801
|
+
role: "axis-title",
|
|
7802
|
+
axis: "x"
|
|
6647
7803
|
});
|
|
6648
7804
|
}
|
|
6649
7805
|
if (yTitle) {
|
|
@@ -6654,7 +7810,10 @@ function drawAxisTitles(surface, model) {
|
|
|
6654
7810
|
color: tokens.color.text,
|
|
6655
7811
|
size: f.size.base,
|
|
6656
7812
|
weight: f.weight.medium,
|
|
6657
|
-
transform: "translate(-50%, -50%) rotate(-90deg)"
|
|
7813
|
+
transform: "translate(-50%, -50%) rotate(-90deg)",
|
|
7814
|
+
maxWidth: plot.height,
|
|
7815
|
+
role: "axis-title",
|
|
7816
|
+
axis: "y"
|
|
6658
7817
|
});
|
|
6659
7818
|
}
|
|
6660
7819
|
}
|
|
@@ -6672,7 +7831,8 @@ function drawTitle(surface, frame, spec, tokens) {
|
|
|
6672
7831
|
color: tokens.color.text,
|
|
6673
7832
|
size: f.size.title,
|
|
6674
7833
|
weight: f.weight.bold,
|
|
6675
|
-
align
|
|
7834
|
+
align,
|
|
7835
|
+
role: "title"
|
|
6676
7836
|
});
|
|
6677
7837
|
if (frame.subtitleRect && title2.subtitle) {
|
|
6678
7838
|
addText(surface, fontString(f.size.small, f.family, f.weight.normal), {
|
|
@@ -6682,28 +7842,65 @@ function drawTitle(surface, frame, spec, tokens) {
|
|
|
6682
7842
|
text: title2.subtitle,
|
|
6683
7843
|
color: tokens.color.textMuted,
|
|
6684
7844
|
size: f.size.small,
|
|
6685
|
-
align
|
|
7845
|
+
align,
|
|
7846
|
+
role: "subtitle"
|
|
6686
7847
|
});
|
|
6687
7848
|
}
|
|
6688
7849
|
}
|
|
6689
7850
|
function drawLegend(surface, items, model) {
|
|
6690
7851
|
const f = model.tokens.font;
|
|
7852
|
+
const frame = model.frame;
|
|
6691
7853
|
const legendFont = fontString(f.size.small, f.family, f.weight.normal);
|
|
7854
|
+
const swatch = 11;
|
|
7855
|
+
const budget = frame.legendLabelMaxWidth;
|
|
7856
|
+
const record = (text2, full, truncated, x, midY) => {
|
|
7857
|
+
recordLabel(surface.labels, {
|
|
7858
|
+
role: "legend",
|
|
7859
|
+
text: text2,
|
|
7860
|
+
fullText: full,
|
|
7861
|
+
truncated,
|
|
7862
|
+
box: {
|
|
7863
|
+
x,
|
|
7864
|
+
y: midY - f.size.small * 0.6,
|
|
7865
|
+
width: measureText(text2, legendFont).width,
|
|
7866
|
+
height: f.size.small * 1.2
|
|
7867
|
+
},
|
|
7868
|
+
angle: 0,
|
|
7869
|
+
size: f.size.small,
|
|
7870
|
+
budget
|
|
7871
|
+
});
|
|
7872
|
+
};
|
|
7873
|
+
const overflowText = frame.legendOverflow > 0 ? `+${frame.legendOverflow} more` : null;
|
|
6692
7874
|
if (surface.headless) {
|
|
6693
7875
|
const ctx = surface.marks.ctx;
|
|
6694
|
-
const swatch = 11;
|
|
6695
7876
|
for (const item of items) {
|
|
6696
7877
|
const midY = item.y + swatch / 2;
|
|
7878
|
+
const textX = item.x + legendSwatchWidth(item.symbol, swatch) + 6;
|
|
7879
|
+
const fitted = fitLabel(item.label, budget, legendFont);
|
|
6697
7880
|
paintLegendSwatch(ctx, item.x, midY, item.symbol, item.color, swatch);
|
|
6698
7881
|
paintCanvasText(ctx, {
|
|
6699
|
-
x:
|
|
7882
|
+
x: textX,
|
|
6700
7883
|
y: midY,
|
|
6701
|
-
text:
|
|
7884
|
+
text: fitted.text,
|
|
6702
7885
|
font: legendFont,
|
|
6703
7886
|
color: model.tokens.color.text,
|
|
6704
7887
|
size: f.size.small,
|
|
6705
7888
|
baseline: "middle"
|
|
6706
7889
|
});
|
|
7890
|
+
record(fitted.text, item.label, fitted.truncated, textX, midY);
|
|
7891
|
+
}
|
|
7892
|
+
if (overflowText && frame.legendOverflowRect) {
|
|
7893
|
+
const r = frame.legendOverflowRect;
|
|
7894
|
+
paintCanvasText(ctx, {
|
|
7895
|
+
x: r.x,
|
|
7896
|
+
y: r.y + r.height / 2,
|
|
7897
|
+
text: overflowText,
|
|
7898
|
+
font: legendFont,
|
|
7899
|
+
color: model.tokens.color.textMuted,
|
|
7900
|
+
size: f.size.small,
|
|
7901
|
+
baseline: "middle"
|
|
7902
|
+
});
|
|
7903
|
+
record(overflowText, overflowText, false, r.x, r.y + r.height / 2);
|
|
6707
7904
|
}
|
|
6708
7905
|
return;
|
|
6709
7906
|
}
|
|
@@ -6717,32 +7914,56 @@ function drawLegend(surface, items, model) {
|
|
|
6717
7914
|
row.style.alignItems = "center";
|
|
6718
7915
|
row.style.gap = "6px";
|
|
6719
7916
|
row.style.pointerEvents = "none";
|
|
6720
|
-
const
|
|
6721
|
-
const size =
|
|
6722
|
-
|
|
6723
|
-
|
|
7917
|
+
const swatchEl = document.createElement("span");
|
|
7918
|
+
const size = swatch;
|
|
7919
|
+
swatchEl.style.flex = "0 0 auto";
|
|
7920
|
+
swatchEl.style.background = item.color;
|
|
6724
7921
|
if (item.symbol === "circle") {
|
|
6725
|
-
|
|
6726
|
-
|
|
6727
|
-
|
|
7922
|
+
swatchEl.style.width = `${size}px`;
|
|
7923
|
+
swatchEl.style.height = `${size}px`;
|
|
7924
|
+
swatchEl.style.borderRadius = "50%";
|
|
6728
7925
|
} else if (item.symbol === "line") {
|
|
6729
|
-
|
|
6730
|
-
|
|
6731
|
-
|
|
7926
|
+
swatchEl.style.width = `${size + 3}px`;
|
|
7927
|
+
swatchEl.style.height = "3px";
|
|
7928
|
+
swatchEl.style.borderRadius = "2px";
|
|
6732
7929
|
} else {
|
|
6733
|
-
|
|
6734
|
-
|
|
6735
|
-
|
|
7930
|
+
swatchEl.style.width = `${size}px`;
|
|
7931
|
+
swatchEl.style.height = `${size}px`;
|
|
7932
|
+
swatchEl.style.borderRadius = "2px";
|
|
6736
7933
|
}
|
|
6737
7934
|
const label = document.createElement("span");
|
|
6738
|
-
|
|
7935
|
+
const fitted = fitLabel(item.label, budget, legendFont);
|
|
7936
|
+
label.textContent = fitted.text;
|
|
7937
|
+
if (fitted.truncated) label.title = item.label;
|
|
6739
7938
|
label.style.font = legendFont;
|
|
6740
7939
|
label.style.fontSize = `${f.size.small}px`;
|
|
6741
7940
|
label.style.color = model.tokens.color.text;
|
|
6742
7941
|
label.style.whiteSpace = "nowrap";
|
|
6743
|
-
row.appendChild(
|
|
7942
|
+
row.appendChild(swatchEl);
|
|
6744
7943
|
row.appendChild(label);
|
|
6745
7944
|
overlay.appendChild(row);
|
|
7945
|
+
record(
|
|
7946
|
+
fitted.text,
|
|
7947
|
+
item.label,
|
|
7948
|
+
fitted.truncated,
|
|
7949
|
+
item.x + legendSwatchWidth(item.symbol, swatch) + 6,
|
|
7950
|
+
item.y + swatch / 2
|
|
7951
|
+
);
|
|
7952
|
+
}
|
|
7953
|
+
if (overflowText && frame.legendOverflowRect) {
|
|
7954
|
+
const r = frame.legendOverflowRect;
|
|
7955
|
+
const chip = document.createElement("div");
|
|
7956
|
+
chip.textContent = overflowText;
|
|
7957
|
+
chip.style.position = "absolute";
|
|
7958
|
+
chip.style.left = `${r.x}px`;
|
|
7959
|
+
chip.style.top = `${r.y}px`;
|
|
7960
|
+
chip.style.font = legendFont;
|
|
7961
|
+
chip.style.fontSize = `${f.size.small}px`;
|
|
7962
|
+
chip.style.color = model.tokens.color.textMuted;
|
|
7963
|
+
chip.style.whiteSpace = "nowrap";
|
|
7964
|
+
chip.style.pointerEvents = "none";
|
|
7965
|
+
overlay.appendChild(chip);
|
|
7966
|
+
record(overflowText, overflowText, false, r.x, r.y + r.height / 2);
|
|
6746
7967
|
}
|
|
6747
7968
|
}
|
|
6748
7969
|
|
|
@@ -7324,8 +8545,8 @@ function buildXModel(kind, args) {
|
|
|
7324
8545
|
};
|
|
7325
8546
|
}
|
|
7326
8547
|
const domain = args.domainNum ?? [0, 1];
|
|
7327
|
-
const
|
|
7328
|
-
const continuous = kind === "time" ? timeScale({ domain, range: args.range }) : args.scaleCfg?.type === "log" ? logScale({ domain, range: args.range, base: args.scaleCfg.base ?? 10, clamp:
|
|
8548
|
+
const clamp16 = args.scaleCfg?.clamp === true;
|
|
8549
|
+
const continuous = kind === "time" ? timeScale({ domain, range: args.range }) : args.scaleCfg?.type === "log" ? logScale({ domain, range: args.range, base: args.scaleCfg.base ?? 10, clamp: clamp16 }) : linearScale({ domain, range: args.range, clamp: clamp16 });
|
|
7329
8550
|
return {
|
|
7330
8551
|
kind,
|
|
7331
8552
|
field: args.field,
|
|
@@ -7776,6 +8997,27 @@ function drawArea(surface, model) {
|
|
|
7776
8997
|
function finite2(value) {
|
|
7777
8998
|
return Number.isFinite(value);
|
|
7778
8999
|
}
|
|
9000
|
+
function readableWithin(text2, maxWidth, font) {
|
|
9001
|
+
if (maxWidth <= 0) return false;
|
|
9002
|
+
if (measureText(text2, font).width <= maxWidth) return true;
|
|
9003
|
+
return ellipsize(text2, maxWidth, font).length / Math.max(1, text2.length) >= 0.5;
|
|
9004
|
+
}
|
|
9005
|
+
function prepareAxisLabels(model) {
|
|
9006
|
+
if (model.x.kind !== "band" || model.xTicks.length <= 1) return;
|
|
9007
|
+
const { font, spacing } = model.tokens;
|
|
9008
|
+
const smallFont = fontString(font.size.small, font.family, font.weight.normal);
|
|
9009
|
+
const budget = Math.max(0, model.frame.xLabelMaxWidth);
|
|
9010
|
+
const originalCount = model.xTicks.length;
|
|
9011
|
+
model.xTicks = model.xTicks.filter((t) => readableWithin(t.label, budget, smallFont));
|
|
9012
|
+
if (model.orientation !== "horizontal") model.frame.xLabelAngle = 0;
|
|
9013
|
+
const widest = model.xTicks.reduce((m, t) => Math.max(m, measureText(t.label, smallFont).width), 0);
|
|
9014
|
+
const slot = model.plot.width / Math.max(1, model.xTicks.length);
|
|
9015
|
+
const crowding = originalCount > 30 ? 2 : 1;
|
|
9016
|
+
const minStep = Math.max(font.size.small * 1.35, widest * crowding + spacing.sm);
|
|
9017
|
+
if (slot >= minStep) return;
|
|
9018
|
+
const stride = Math.ceil(minStep / Math.max(slot, 1e-6));
|
|
9019
|
+
model.xTicks = model.xTicks.filter((_, i) => i % stride === 0);
|
|
9020
|
+
}
|
|
7779
9021
|
function defaultRadius(model, spec) {
|
|
7780
9022
|
return Math.max(0, spec.cornerRadius ?? Math.min(4, model.tokens.radius.sm));
|
|
7781
9023
|
}
|
|
@@ -7895,6 +9137,7 @@ function drawBar(surface, model) {
|
|
|
7895
9137
|
const radius = defaultRadius(model, spec);
|
|
7896
9138
|
const pen = model.sketch ? new RoughPen(ctx, model.sketch) : null;
|
|
7897
9139
|
if (model.x.kind !== "band" || model.x.bandwidth <= 0 || model.series.length === 0) return;
|
|
9140
|
+
prepareAxisLabels(model);
|
|
7898
9141
|
ctx.save();
|
|
7899
9142
|
ctx.beginPath();
|
|
7900
9143
|
ctx.rect(plot.x, plot.y, plot.width, plot.height);
|
|
@@ -7908,6 +9151,25 @@ function drawBar(surface, model) {
|
|
|
7908
9151
|
}
|
|
7909
9152
|
|
|
7910
9153
|
// src/charts/box.ts
|
|
9154
|
+
function readableWithin2(text2, maxWidth, font) {
|
|
9155
|
+
if (maxWidth <= 0) return false;
|
|
9156
|
+
if (measureText(text2, font).width <= maxWidth) return true;
|
|
9157
|
+
return ellipsize(text2, maxWidth, font).length / Math.max(1, text2.length) >= 0.5;
|
|
9158
|
+
}
|
|
9159
|
+
function prepareAxisLabels2(model) {
|
|
9160
|
+
if (model.x.kind !== "band" || model.xTicks.length <= 1) return;
|
|
9161
|
+
const { font, spacing } = model.tokens;
|
|
9162
|
+
const smallFont = fontString(font.size.small, font.family, font.weight.normal);
|
|
9163
|
+
const budget = Math.max(0, model.frame.xLabelMaxWidth);
|
|
9164
|
+
model.xTicks = model.xTicks.filter((t) => readableWithin2(t.label, budget, smallFont));
|
|
9165
|
+
model.frame.xLabelAngle = 0;
|
|
9166
|
+
const widest = model.xTicks.reduce((m, t) => Math.max(m, measureText(t.label, smallFont).width), 0);
|
|
9167
|
+
const slot = model.plot.width / Math.max(1, model.xTicks.length);
|
|
9168
|
+
const minStep = Math.max(font.size.small * 1.35, widest + spacing.sm);
|
|
9169
|
+
if (slot >= minStep) return;
|
|
9170
|
+
const stride = Math.ceil(minStep / Math.max(slot, 1e-6));
|
|
9171
|
+
model.xTicks = model.xTicks.filter((_, i) => i % stride === 0);
|
|
9172
|
+
}
|
|
7911
9173
|
function quantileSorted(sorted, p) {
|
|
7912
9174
|
const n = sorted.length;
|
|
7913
9175
|
if (n === 0) return NaN;
|
|
@@ -8075,6 +9337,7 @@ function hitsBoxGeom(model, geom, showOutliers, px, py) {
|
|
|
8075
9337
|
function drawBox(surface, model) {
|
|
8076
9338
|
const layout2 = computeBoxes(model);
|
|
8077
9339
|
if (!layout2) return;
|
|
9340
|
+
prepareAxisLabels2(model);
|
|
8078
9341
|
const ctx = surface.marks.ctx;
|
|
8079
9342
|
const { plot } = model;
|
|
8080
9343
|
const emphasis = model.emphasis ?? null;
|
|
@@ -8162,29 +9425,38 @@ function resolveTitle2(title2) {
|
|
|
8162
9425
|
}
|
|
8163
9426
|
function addOverlayText(surface, tokens, o) {
|
|
8164
9427
|
const font = fontString(o.size, tokens.font.family, o.weight ?? tokens.font.weight.normal);
|
|
9428
|
+
const budget = o.maxWidth ?? o.width;
|
|
9429
|
+
const fitted = fitLabel(o.text, budget, font);
|
|
9430
|
+
const geom = {
|
|
9431
|
+
left: o.left,
|
|
9432
|
+
top: o.top,
|
|
9433
|
+
width: o.width,
|
|
9434
|
+
text: fitted.text,
|
|
9435
|
+
color: o.color,
|
|
9436
|
+
size: o.size,
|
|
9437
|
+
align: o.align,
|
|
9438
|
+
transform: o.transform,
|
|
9439
|
+
opacity: o.opacity,
|
|
9440
|
+
pill: o.pill
|
|
9441
|
+
};
|
|
9442
|
+
const { box, angle } = labelBox(geom, font);
|
|
9443
|
+
recordLabel(surface.labels, {
|
|
9444
|
+
role: o.role ?? "data-label",
|
|
9445
|
+
text: fitted.text,
|
|
9446
|
+
fullText: o.text,
|
|
9447
|
+
truncated: fitted.truncated,
|
|
9448
|
+
box,
|
|
9449
|
+
angle,
|
|
9450
|
+
size: o.size,
|
|
9451
|
+
budget
|
|
9452
|
+
});
|
|
8165
9453
|
if (surface.headless) {
|
|
8166
|
-
paintCanvasText(
|
|
8167
|
-
surface.marks.ctx,
|
|
8168
|
-
overlayTextToCanvasCmd(
|
|
8169
|
-
{
|
|
8170
|
-
left: o.left,
|
|
8171
|
-
top: o.top,
|
|
8172
|
-
width: o.width,
|
|
8173
|
-
text: o.text,
|
|
8174
|
-
color: o.color,
|
|
8175
|
-
size: o.size,
|
|
8176
|
-
align: o.align,
|
|
8177
|
-
transform: o.transform,
|
|
8178
|
-
opacity: o.opacity,
|
|
8179
|
-
pill: o.pill
|
|
8180
|
-
},
|
|
8181
|
-
font
|
|
8182
|
-
)
|
|
8183
|
-
);
|
|
9454
|
+
paintCanvasText(surface.marks.ctx, overlayTextToCanvasCmd(geom, font));
|
|
8184
9455
|
return null;
|
|
8185
9456
|
}
|
|
8186
9457
|
const el = document.createElement("div");
|
|
8187
|
-
el.textContent =
|
|
9458
|
+
el.textContent = fitted.text;
|
|
9459
|
+
if (fitted.truncated) el.title = o.text;
|
|
8188
9460
|
el.style.position = "absolute";
|
|
8189
9461
|
el.style.font = font;
|
|
8190
9462
|
el.style.color = o.color;
|
|
@@ -8224,7 +9496,8 @@ function drawTitleBlock(surface, tokens, size, title2) {
|
|
|
8224
9496
|
color: tokens.color.text,
|
|
8225
9497
|
size: f.size.title,
|
|
8226
9498
|
weight: f.weight.bold,
|
|
8227
|
-
align: t.align
|
|
9499
|
+
align: t.align,
|
|
9500
|
+
role: "title"
|
|
8228
9501
|
});
|
|
8229
9502
|
top += Math.round(f.size.title * 1.3);
|
|
8230
9503
|
if (t.subtitle) {
|
|
@@ -8235,7 +9508,8 @@ function drawTitleBlock(surface, tokens, size, title2) {
|
|
|
8235
9508
|
text: t.subtitle,
|
|
8236
9509
|
color: tokens.color.textMuted,
|
|
8237
9510
|
size: f.size.small,
|
|
8238
|
-
align: t.align
|
|
9511
|
+
align: t.align,
|
|
9512
|
+
role: "subtitle"
|
|
8239
9513
|
});
|
|
8240
9514
|
top += Math.round(f.size.small * 1.5);
|
|
8241
9515
|
} else {
|
|
@@ -8249,139 +9523,164 @@ function drawTitleBlock(surface, tokens, size, title2) {
|
|
|
8249
9523
|
height: Math.max(0, size.height - top - CHROME_PAD.bottom)
|
|
8250
9524
|
};
|
|
8251
9525
|
}
|
|
9526
|
+
var MAX_LEGEND_WIDTH_FRACTION2 = 0.3;
|
|
9527
|
+
var LEGEND_SWATCH3 = 11;
|
|
9528
|
+
var LEGEND_LABEL_GAP = 6;
|
|
9529
|
+
var LEGEND_ITEM_GAP2 = 16;
|
|
9530
|
+
var LEGEND_ROW_GAP2 = 4;
|
|
9531
|
+
function paintLegendRow(surface, tokens, font, labelSize, row) {
|
|
9532
|
+
const symbol = row.entry?.symbol;
|
|
9533
|
+
const textX = row.entry ? row.x + legendSwatchWidth(symbol, LEGEND_SWATCH3) + LEGEND_LABEL_GAP : row.x;
|
|
9534
|
+
if (surface.headless) {
|
|
9535
|
+
const ctx = surface.marks.ctx;
|
|
9536
|
+
if (row.entry) paintLegendSwatch(ctx, row.x, row.midY, symbol, row.entry.color, LEGEND_SWATCH3);
|
|
9537
|
+
paintCanvasText(ctx, {
|
|
9538
|
+
x: textX,
|
|
9539
|
+
y: row.midY,
|
|
9540
|
+
text: row.text,
|
|
9541
|
+
font,
|
|
9542
|
+
color: row.color,
|
|
9543
|
+
size: labelSize,
|
|
9544
|
+
baseline: "middle"
|
|
9545
|
+
});
|
|
9546
|
+
} else {
|
|
9547
|
+
const el = document.createElement("div");
|
|
9548
|
+
el.style.position = "absolute";
|
|
9549
|
+
el.style.left = `${row.x}px`;
|
|
9550
|
+
el.style.top = `${row.midY - LEGEND_SWATCH3 / 2}px`;
|
|
9551
|
+
el.style.height = `${LEGEND_SWATCH3}px`;
|
|
9552
|
+
el.style.display = "flex";
|
|
9553
|
+
el.style.alignItems = "center";
|
|
9554
|
+
el.style.gap = `${LEGEND_LABEL_GAP}px`;
|
|
9555
|
+
el.style.pointerEvents = "none";
|
|
9556
|
+
if (row.entry) {
|
|
9557
|
+
const s = document.createElement("span");
|
|
9558
|
+
s.style.flex = "0 0 auto";
|
|
9559
|
+
s.style.background = row.entry.color;
|
|
9560
|
+
if (symbol === "line") {
|
|
9561
|
+
s.style.width = `${LEGEND_SWATCH3 + 3}px`;
|
|
9562
|
+
s.style.height = "3px";
|
|
9563
|
+
s.style.borderRadius = "2px";
|
|
9564
|
+
} else {
|
|
9565
|
+
s.style.width = `${LEGEND_SWATCH3}px`;
|
|
9566
|
+
s.style.height = `${LEGEND_SWATCH3}px`;
|
|
9567
|
+
s.style.borderRadius = symbol === "circle" ? "50%" : "2px";
|
|
9568
|
+
}
|
|
9569
|
+
el.appendChild(s);
|
|
9570
|
+
}
|
|
9571
|
+
const label = document.createElement("span");
|
|
9572
|
+
label.textContent = row.text;
|
|
9573
|
+
if (row.truncated) label.title = row.fullText;
|
|
9574
|
+
label.style.font = font;
|
|
9575
|
+
label.style.color = row.color;
|
|
9576
|
+
label.style.whiteSpace = "nowrap";
|
|
9577
|
+
el.appendChild(label);
|
|
9578
|
+
surface.overlay.appendChild(el);
|
|
9579
|
+
}
|
|
9580
|
+
recordLabel(surface.labels, {
|
|
9581
|
+
role: "legend",
|
|
9582
|
+
text: row.text,
|
|
9583
|
+
fullText: row.fullText,
|
|
9584
|
+
truncated: row.truncated,
|
|
9585
|
+
box: {
|
|
9586
|
+
x: textX,
|
|
9587
|
+
y: row.midY - labelSize * 0.6,
|
|
9588
|
+
width: measureText(row.text, font).width,
|
|
9589
|
+
height: labelSize * 1.2
|
|
9590
|
+
},
|
|
9591
|
+
angle: 0,
|
|
9592
|
+
size: labelSize,
|
|
9593
|
+
budget: row.budget
|
|
9594
|
+
});
|
|
9595
|
+
}
|
|
8252
9596
|
function drawCategoricalLegend(surface, tokens, area2, entries, position = "right") {
|
|
8253
9597
|
if (entries.length === 0) return area2;
|
|
8254
9598
|
const f = tokens.font;
|
|
8255
9599
|
const labelSize = f.size.small;
|
|
8256
9600
|
const rowH = Math.round(labelSize * 1.7);
|
|
8257
|
-
const swatch = 11;
|
|
8258
|
-
const gap = 16;
|
|
8259
9601
|
const font = fontString(labelSize, f.family, f.weight.normal);
|
|
8260
|
-
|
|
8261
|
-
const ctx = surface.marks.ctx;
|
|
8262
|
-
if (position === "right") {
|
|
8263
|
-
let maxLabel = 0;
|
|
8264
|
-
for (const e of entries) maxLabel = Math.max(maxLabel, measureText(e.label, font).width);
|
|
8265
|
-
const colW = Math.ceil(swatch + 6 + maxLabel + 4);
|
|
8266
|
-
const x = area2.x + area2.width - colW;
|
|
8267
|
-
let y = area2.y + Math.max(0, (area2.height - entries.length * rowH) / 2);
|
|
8268
|
-
for (const e of entries) {
|
|
8269
|
-
const midY2 = y + rowH / 2;
|
|
8270
|
-
paintLegendSwatch(ctx, x, midY2, e.symbol, e.color, swatch);
|
|
8271
|
-
paintCanvasText(ctx, {
|
|
8272
|
-
x: x + legendSwatchWidth(e.symbol, swatch) + 6,
|
|
8273
|
-
y: midY2,
|
|
8274
|
-
text: e.label,
|
|
8275
|
-
font,
|
|
8276
|
-
color: tokens.color.text,
|
|
8277
|
-
size: labelSize,
|
|
8278
|
-
baseline: "middle"
|
|
8279
|
-
});
|
|
8280
|
-
y += rowH;
|
|
8281
|
-
}
|
|
8282
|
-
return { x: area2.x, y: area2.y, width: Math.max(0, area2.width - colW - gap), height: area2.height };
|
|
8283
|
-
}
|
|
8284
|
-
const widths = entries.map(
|
|
8285
|
-
(e) => legendSwatchWidth(e.symbol, swatch) + 6 + measureText(e.label, font).width
|
|
8286
|
-
);
|
|
8287
|
-
const total = widths.reduce((a, b) => a + b, 0) + gap * Math.max(0, entries.length - 1);
|
|
8288
|
-
let cx = area2.x + Math.max(0, (area2.width - total) / 2);
|
|
8289
|
-
const barH2 = rowH;
|
|
8290
|
-
const barTop = position === "top" ? area2.y : area2.y + area2.height - barH2;
|
|
8291
|
-
const midY = barTop + barH2 / 2;
|
|
8292
|
-
entries.forEach((e, i) => {
|
|
8293
|
-
paintLegendSwatch(ctx, cx, midY, e.symbol, e.color, swatch);
|
|
8294
|
-
paintCanvasText(ctx, {
|
|
8295
|
-
x: cx + legendSwatchWidth(e.symbol, swatch) + 6,
|
|
8296
|
-
y: midY,
|
|
8297
|
-
text: e.label,
|
|
8298
|
-
font,
|
|
8299
|
-
color: tokens.color.text,
|
|
8300
|
-
size: labelSize,
|
|
8301
|
-
baseline: "middle"
|
|
8302
|
-
});
|
|
8303
|
-
cx += widths[i] + gap;
|
|
8304
|
-
});
|
|
8305
|
-
return position === "top" ? { x: area2.x, y: area2.y + barH2 + 6, width: area2.width, height: Math.max(0, area2.height - barH2 - 6) } : { x: area2.x, y: area2.y, width: area2.width, height: Math.max(0, area2.height - barH2 - 6) };
|
|
8306
|
-
}
|
|
8307
|
-
const makeSwatch = (e) => {
|
|
8308
|
-
const s = document.createElement("span");
|
|
8309
|
-
s.style.flex = "0 0 auto";
|
|
8310
|
-
s.style.background = e.color;
|
|
8311
|
-
if (e.symbol === "line") {
|
|
8312
|
-
s.style.width = `${swatch + 3}px`;
|
|
8313
|
-
s.style.height = "3px";
|
|
8314
|
-
s.style.borderRadius = "2px";
|
|
8315
|
-
} else if (e.symbol === "circle") {
|
|
8316
|
-
s.style.width = `${swatch}px`;
|
|
8317
|
-
s.style.height = `${swatch}px`;
|
|
8318
|
-
s.style.borderRadius = "50%";
|
|
8319
|
-
} else {
|
|
8320
|
-
s.style.width = `${swatch}px`;
|
|
8321
|
-
s.style.height = `${swatch}px`;
|
|
8322
|
-
s.style.borderRadius = "2px";
|
|
8323
|
-
}
|
|
8324
|
-
return s;
|
|
8325
|
-
};
|
|
9602
|
+
const swatchFor = (e) => legendSwatchWidth(e.symbol, LEGEND_SWATCH3) + LEGEND_LABEL_GAP;
|
|
8326
9603
|
if (position === "right") {
|
|
9604
|
+
const cap2 = Math.max(0, area2.width * MAX_LEGEND_WIDTH_FRACTION2);
|
|
9605
|
+
const maxRows = Math.max(1, Math.floor(area2.height / rowH));
|
|
9606
|
+
const overflowing = entries.length > maxRows;
|
|
9607
|
+
const shown = overflowing ? entries.slice(0, Math.max(0, maxRows - 1)) : entries;
|
|
9608
|
+
const overflow = entries.length - shown.length;
|
|
9609
|
+
const budget2 = Math.max(0, cap2 - LEGEND_SWATCH3 - 3 - LEGEND_LABEL_GAP);
|
|
9610
|
+
const fitted2 = shown.map((e) => ({ entry: e, ...fitLabel(e.label, budget2, font) }));
|
|
9611
|
+
const overflowText = overflow > 0 ? `+${overflow} more` : null;
|
|
8327
9612
|
let maxLabel = 0;
|
|
8328
|
-
for (const
|
|
8329
|
-
|
|
9613
|
+
for (const it of fitted2) maxLabel = Math.max(maxLabel, measureText(it.text, font).width);
|
|
9614
|
+
if (overflowText) maxLabel = Math.max(maxLabel, measureText(overflowText, font).width);
|
|
9615
|
+
const colW = Math.ceil(LEGEND_SWATCH3 + LEGEND_LABEL_GAP + maxLabel + 4);
|
|
8330
9616
|
const x = area2.x + area2.width - colW;
|
|
8331
|
-
|
|
8332
|
-
|
|
8333
|
-
|
|
8334
|
-
|
|
8335
|
-
|
|
8336
|
-
|
|
8337
|
-
|
|
8338
|
-
|
|
8339
|
-
|
|
8340
|
-
|
|
8341
|
-
|
|
8342
|
-
|
|
8343
|
-
|
|
8344
|
-
label.style.color = tokens.color.text;
|
|
8345
|
-
label.style.whiteSpace = "nowrap";
|
|
8346
|
-
row.appendChild(makeSwatch(e));
|
|
8347
|
-
row.appendChild(label);
|
|
8348
|
-
surface.overlay.appendChild(row);
|
|
9617
|
+
const rows2 = fitted2.length + (overflowText ? 1 : 0);
|
|
9618
|
+
let y = area2.y + Math.max(0, (area2.height - rows2 * rowH) / 2);
|
|
9619
|
+
for (const it of fitted2) {
|
|
9620
|
+
paintLegendRow(surface, tokens, font, labelSize, {
|
|
9621
|
+
x,
|
|
9622
|
+
midY: y + rowH / 2,
|
|
9623
|
+
text: it.text,
|
|
9624
|
+
fullText: it.entry.label,
|
|
9625
|
+
truncated: it.truncated,
|
|
9626
|
+
budget: budget2,
|
|
9627
|
+
color: tokens.color.text,
|
|
9628
|
+
entry: it.entry
|
|
9629
|
+
});
|
|
8349
9630
|
y += rowH;
|
|
8350
9631
|
}
|
|
8351
|
-
|
|
8352
|
-
|
|
8353
|
-
|
|
8354
|
-
|
|
8355
|
-
|
|
8356
|
-
|
|
8357
|
-
|
|
8358
|
-
|
|
8359
|
-
|
|
8360
|
-
|
|
8361
|
-
|
|
8362
|
-
|
|
8363
|
-
|
|
8364
|
-
|
|
8365
|
-
|
|
8366
|
-
|
|
8367
|
-
|
|
8368
|
-
label.textContent = e.label;
|
|
8369
|
-
label.style.font = font;
|
|
8370
|
-
label.style.color = tokens.color.text;
|
|
8371
|
-
label.style.whiteSpace = "nowrap";
|
|
8372
|
-
row.appendChild(makeSwatch(e));
|
|
8373
|
-
row.appendChild(label);
|
|
8374
|
-
bar.appendChild(row);
|
|
8375
|
-
}
|
|
8376
|
-
const barH = rowH;
|
|
8377
|
-
if (position === "top") {
|
|
8378
|
-
bar.style.top = `${area2.y}px`;
|
|
8379
|
-
surface.overlay.appendChild(bar);
|
|
8380
|
-
return { x: area2.x, y: area2.y + barH + 6, width: area2.width, height: Math.max(0, area2.height - barH - 6) };
|
|
9632
|
+
if (overflowText) {
|
|
9633
|
+
paintLegendRow(surface, tokens, font, labelSize, {
|
|
9634
|
+
x,
|
|
9635
|
+
midY: y + rowH / 2,
|
|
9636
|
+
text: overflowText,
|
|
9637
|
+
fullText: overflowText,
|
|
9638
|
+
truncated: false,
|
|
9639
|
+
budget: budget2,
|
|
9640
|
+
color: tokens.color.textMuted
|
|
9641
|
+
});
|
|
9642
|
+
}
|
|
9643
|
+
return {
|
|
9644
|
+
x: area2.x,
|
|
9645
|
+
y: area2.y,
|
|
9646
|
+
width: Math.max(0, area2.width - colW - LEGEND_ITEM_GAP2),
|
|
9647
|
+
height: area2.height
|
|
9648
|
+
};
|
|
8381
9649
|
}
|
|
8382
|
-
|
|
8383
|
-
|
|
8384
|
-
|
|
9650
|
+
const budget = Math.max(0, area2.width - LEGEND_SWATCH3 - 3 - LEGEND_LABEL_GAP);
|
|
9651
|
+
const fitted = entries.map((e) => ({ entry: e, ...fitLabel(e.label, budget, font) }));
|
|
9652
|
+
const widths = fitted.map((it) => swatchFor(it.entry) + measureText(it.text, font).width);
|
|
9653
|
+
const rows = [{ items: [], width: 0 }];
|
|
9654
|
+
widths.forEach((w, i) => {
|
|
9655
|
+
const row = rows[rows.length - 1];
|
|
9656
|
+
if (row.items.length && row.width + LEGEND_ITEM_GAP2 + w > area2.width) {
|
|
9657
|
+
rows.push({ items: [i], width: w });
|
|
9658
|
+
} else {
|
|
9659
|
+
row.width += (row.items.length ? LEGEND_ITEM_GAP2 : 0) + w;
|
|
9660
|
+
row.items.push(i);
|
|
9661
|
+
}
|
|
9662
|
+
});
|
|
9663
|
+
const barH = rows.length * rowH + (rows.length - 1) * LEGEND_ROW_GAP2;
|
|
9664
|
+
const barTop = position === "top" ? area2.y : area2.y + area2.height - barH;
|
|
9665
|
+
rows.forEach((row, r) => {
|
|
9666
|
+
let cx = area2.x + Math.max(0, (area2.width - row.width) / 2);
|
|
9667
|
+
const midY = barTop + r * (rowH + LEGEND_ROW_GAP2) + rowH / 2;
|
|
9668
|
+
for (const i of row.items) {
|
|
9669
|
+
const it = fitted[i];
|
|
9670
|
+
paintLegendRow(surface, tokens, font, labelSize, {
|
|
9671
|
+
x: cx,
|
|
9672
|
+
midY,
|
|
9673
|
+
text: it.text,
|
|
9674
|
+
fullText: it.entry.label,
|
|
9675
|
+
truncated: it.truncated,
|
|
9676
|
+
budget,
|
|
9677
|
+
color: tokens.color.text,
|
|
9678
|
+
entry: it.entry
|
|
9679
|
+
});
|
|
9680
|
+
cx += widths[i] + LEGEND_ITEM_GAP2;
|
|
9681
|
+
}
|
|
9682
|
+
});
|
|
9683
|
+
return position === "top" ? { x: area2.x, y: area2.y + barH + 6, width: area2.width, height: Math.max(0, area2.height - barH - 6) } : { x: area2.x, y: area2.y, width: area2.width, height: Math.max(0, area2.height - barH - 6) };
|
|
8385
9684
|
}
|
|
8386
9685
|
|
|
8387
9686
|
// src/charts/heatmap.ts
|
|
@@ -8401,6 +9700,11 @@ function measureMax(labels, font) {
|
|
|
8401
9700
|
for (const label of labels) max = Math.max(max, measureText(label, font).width);
|
|
8402
9701
|
return max;
|
|
8403
9702
|
}
|
|
9703
|
+
function readableWithin3(text2, maxWidth, font) {
|
|
9704
|
+
if (maxWidth <= 0) return false;
|
|
9705
|
+
if (measureText(text2, font).width <= maxWidth) return true;
|
|
9706
|
+
return ellipsize(text2, maxWidth, font).length / Math.max(1, text2.length) >= 0.5;
|
|
9707
|
+
}
|
|
8404
9708
|
function heatmapColorDomain(rows, field) {
|
|
8405
9709
|
const read = accessor(field);
|
|
8406
9710
|
let min = Infinity;
|
|
@@ -8437,21 +9741,33 @@ function drawLegend2(surface, tokens, legend, min, max, spec) {
|
|
|
8437
9741
|
ctx.lineWidth = 1;
|
|
8438
9742
|
ctx.strokeRect(barX + 0.5, barY + 0.5, Math.max(0, barWidth - 1), Math.max(0, barHeight - 1));
|
|
8439
9743
|
const labelTop = barY + barHeight + 3;
|
|
8440
|
-
|
|
8441
|
-
|
|
8442
|
-
|
|
8443
|
-
|
|
8444
|
-
|
|
8445
|
-
|
|
8446
|
-
|
|
8447
|
-
|
|
8448
|
-
|
|
8449
|
-
|
|
8450
|
-
|
|
8451
|
-
|
|
8452
|
-
|
|
8453
|
-
|
|
8454
|
-
}
|
|
9744
|
+
const labelBudget = Math.max(0, barWidth / 2 - 4);
|
|
9745
|
+
const labelFont = fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.normal);
|
|
9746
|
+
const minLabel = formatValue(min, spec.encoding.color.format);
|
|
9747
|
+
const maxLabel = formatValue(max, spec.encoding.color.format);
|
|
9748
|
+
if (readableWithin3(minLabel, labelBudget, labelFont)) {
|
|
9749
|
+
addOverlayText(surface, tokens, {
|
|
9750
|
+
left: barX,
|
|
9751
|
+
top: labelTop,
|
|
9752
|
+
text: minLabel,
|
|
9753
|
+
color: tokens.color.textMuted,
|
|
9754
|
+
size: tokens.font.size.small,
|
|
9755
|
+
maxWidth: labelBudget,
|
|
9756
|
+
role: "legend"
|
|
9757
|
+
});
|
|
9758
|
+
}
|
|
9759
|
+
if (readableWithin3(maxLabel, labelBudget, labelFont)) {
|
|
9760
|
+
addOverlayText(surface, tokens, {
|
|
9761
|
+
left: barX + barWidth,
|
|
9762
|
+
top: labelTop,
|
|
9763
|
+
text: maxLabel,
|
|
9764
|
+
color: tokens.color.textMuted,
|
|
9765
|
+
size: tokens.font.size.small,
|
|
9766
|
+
transform: "translateX(-100%)",
|
|
9767
|
+
maxWidth: labelBudget,
|
|
9768
|
+
role: "legend"
|
|
9769
|
+
});
|
|
9770
|
+
}
|
|
8455
9771
|
}
|
|
8456
9772
|
function drawHeatmap(surface, spec, tokens, size, context) {
|
|
8457
9773
|
const heatmap = spec;
|
|
@@ -8524,19 +9840,28 @@ function drawHeatmap(surface, spec, tokens, size, context) {
|
|
|
8524
9840
|
ctx.lineWidth = 1;
|
|
8525
9841
|
ctx.strokeRect(grid.x + 0.5, grid.y + 0.5, Math.max(0, grid.width - 1), Math.max(0, grid.height - 1));
|
|
8526
9842
|
}
|
|
9843
|
+
const yLabelBudget = Math.max(0, leftGutter - tokens.spacing.sm);
|
|
9844
|
+
const yLabelMinGap = labelSize * 1.35;
|
|
9845
|
+
let lastYLabelMid = -Infinity;
|
|
8527
9846
|
for (const cat of yCats) {
|
|
8528
9847
|
const sy = yScale.map(cat);
|
|
8529
9848
|
if (sy === void 0) continue;
|
|
9849
|
+
const mid = sy + yScale.bandwidth / 2;
|
|
9850
|
+
if (mid - lastYLabelMid < yLabelMinGap) continue;
|
|
9851
|
+
if (!readableWithin3(cat, yLabelBudget, labelFont)) continue;
|
|
8530
9852
|
addOverlayText(surface, tokens, {
|
|
8531
9853
|
left: content.x,
|
|
8532
|
-
top:
|
|
8533
|
-
width:
|
|
9854
|
+
top: mid,
|
|
9855
|
+
width: yLabelBudget,
|
|
8534
9856
|
text: cat,
|
|
8535
9857
|
color: tokens.color.textMuted,
|
|
8536
9858
|
size: labelSize,
|
|
8537
9859
|
align: "right",
|
|
8538
|
-
transform: "translateY(-50%)"
|
|
9860
|
+
transform: "translateY(-50%)",
|
|
9861
|
+
maxWidth: yLabelBudget,
|
|
9862
|
+
role: "axis-label"
|
|
8539
9863
|
});
|
|
9864
|
+
lastYLabelMid = mid;
|
|
8540
9865
|
}
|
|
8541
9866
|
const widestX = measureMax(xCats, labelFont);
|
|
8542
9867
|
const every = Math.max(1, Math.ceil((widestX + tokens.spacing.md) / Math.max(1, xScale.step)));
|
|
@@ -8545,13 +9870,17 @@ function drawHeatmap(surface, spec, tokens, size, context) {
|
|
|
8545
9870
|
if (index % every !== 0) return;
|
|
8546
9871
|
const sx = xScale.map(cat);
|
|
8547
9872
|
if (sx === void 0) return;
|
|
9873
|
+
const maxWidth = Math.max(0, Math.min(xScale.step - tokens.spacing.xs, size.width - 4));
|
|
9874
|
+
if (!readableWithin3(cat, maxWidth, labelFont)) return;
|
|
8548
9875
|
addOverlayText(surface, tokens, {
|
|
8549
9876
|
left: sx + xScale.bandwidth / 2,
|
|
8550
9877
|
top: xLabelTop,
|
|
8551
9878
|
text: cat,
|
|
8552
9879
|
color: tokens.color.textMuted,
|
|
8553
9880
|
size: labelSize,
|
|
8554
|
-
transform: "translateX(-50%)"
|
|
9881
|
+
transform: "translateX(-50%)",
|
|
9882
|
+
maxWidth,
|
|
9883
|
+
role: "axis-label"
|
|
8555
9884
|
});
|
|
8556
9885
|
});
|
|
8557
9886
|
drawLegend2(
|
|
@@ -8712,6 +10041,21 @@ function planPieLabels(options) {
|
|
|
8712
10041
|
const fitsRadial = lineHeight2 * 0.8 <= radialBand;
|
|
8713
10042
|
goesInside = fitsTangential && fitsRadial;
|
|
8714
10043
|
}
|
|
10044
|
+
if (outsideSlices.length > 0) {
|
|
10045
|
+
const top2 = area2.y + lineHeight2 * 1.25;
|
|
10046
|
+
const bottom2 = area2.y + area2.height - lineHeight2 * 1.25;
|
|
10047
|
+
const capacity = Math.max(1, Math.floor((bottom2 - top2 + lineHeight2) / lineHeight2));
|
|
10048
|
+
for (const side of ["left", "right"]) {
|
|
10049
|
+
const group = outsideSlices.filter((o) => o.side === side);
|
|
10050
|
+
if (group.length <= capacity) continue;
|
|
10051
|
+
const keep = new Set(
|
|
10052
|
+
[...group].sort((a, b) => b.slice.value - a.slice.value || a.slice.index - b.slice.index).slice(0, capacity).map((o) => o.slice.index)
|
|
10053
|
+
);
|
|
10054
|
+
for (let i = outsideSlices.length - 1; i >= 0; i -= 1) {
|
|
10055
|
+
if (outsideSlices[i].side === side && !keep.has(outsideSlices[i].slice.index)) outsideSlices.splice(i, 1);
|
|
10056
|
+
}
|
|
10057
|
+
}
|
|
10058
|
+
}
|
|
8715
10059
|
if (goesInside) {
|
|
8716
10060
|
insideSlices.push({ slice, mid, text: labelText(slice, "inside", options, percents) });
|
|
8717
10061
|
} else {
|
|
@@ -8749,13 +10093,14 @@ function planPieLabels(options) {
|
|
|
8749
10093
|
cx = (leftBound + rightBound) / 2;
|
|
8750
10094
|
}
|
|
8751
10095
|
const insideR = innerR > 0 ? (innerR + outerR) / 2 : outerR * 0.62;
|
|
8752
|
-
const inside2 = insideSlices.map(({ slice, mid, text }) => {
|
|
10096
|
+
const inside2 = insideSlices.map(({ slice, mid, text: text2 }) => {
|
|
8753
10097
|
const dir = direction(mid);
|
|
8754
|
-
|
|
8755
|
-
|
|
10098
|
+
const arcWidth = Math.max(8, (slice.endAngle - slice.startAngle) * insideR - INSIDE_PAD);
|
|
10099
|
+
return { index: slice.index, text: text2, maxWidth: arcWidth, x: cx + insideR * dir.x, y: cy + insideR * dir.y };
|
|
10100
|
+
}).filter((label) => measure(label.text) <= label.maxWidth);
|
|
8756
10101
|
const elbowR = outerR + ELBOW;
|
|
8757
|
-
const top = area2.y + lineHeight2
|
|
8758
|
-
const bottom = area2.y + area2.height - lineHeight2
|
|
10102
|
+
const top = area2.y + lineHeight2 * 1.25;
|
|
10103
|
+
const bottom = area2.y + area2.height - lineHeight2 * 1.25;
|
|
8759
10104
|
const outside = [];
|
|
8760
10105
|
for (const side of ["left", "right"]) {
|
|
8761
10106
|
const group = outsideSlices.filter((o) => o.side === side);
|
|
@@ -8768,11 +10113,14 @@ function planPieLabels(options) {
|
|
|
8768
10113
|
const elbow = { x: cx + elbowR * dir.x, y: cy + elbowR * dir.y };
|
|
8769
10114
|
const labelY = ys[i];
|
|
8770
10115
|
const textX = side === "right" ? elbow.x + GAP : elbow.x - GAP;
|
|
10116
|
+
const maxWidth = side === "right" ? Math.max(0, area2.x + area2.width - textX) : Math.max(0, textX - area2.x);
|
|
10117
|
+
if (maxWidth < 8 || measure(o.text) > maxWidth) return;
|
|
8771
10118
|
outside.push({
|
|
8772
10119
|
index: o.slice.index,
|
|
8773
10120
|
text: o.text,
|
|
8774
10121
|
x: textX,
|
|
8775
10122
|
y: labelY,
|
|
10123
|
+
maxWidth,
|
|
8776
10124
|
align: side === "right" ? "left" : "right",
|
|
8777
10125
|
side,
|
|
8778
10126
|
points: [anchor, elbow, { x: textX, y: labelY }]
|
|
@@ -8887,8 +10235,12 @@ function drawPie(surface, spec, tokens, size, context) {
|
|
|
8887
10235
|
const sweepEnd = TAU3 * sweepT;
|
|
8888
10236
|
const content = drawTitleBlock(surface, tokens, size, pie.title);
|
|
8889
10237
|
const slices = buildSlices(pie, tokens.color.palette);
|
|
8890
|
-
const
|
|
8891
|
-
const
|
|
10238
|
+
const labelCfg = resolvePieLabels(pie);
|
|
10239
|
+
const labelFont = fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.normal);
|
|
10240
|
+
const legendEntries = uniqueLegendEntries(slices);
|
|
10241
|
+
const suppressAutoLegend = labelCfg && pie.legend == null && legendEntries.some((entry) => measureText(entry.label, labelFont).width > content.width * 0.25);
|
|
10242
|
+
const legendPosition = suppressAutoLegend ? null : resolveLegendPosition(pie, content.width, content.height);
|
|
10243
|
+
const area2 = legendPosition ? drawCategoricalLegend(surface, tokens, content, legendEntries, legendPosition) : content;
|
|
8892
10244
|
const initialCx = area2.x + area2.width / 2;
|
|
8893
10245
|
const initialCy = area2.y + area2.height / 2;
|
|
8894
10246
|
const initialOuterR = Math.max(0, Math.min(area2.width, area2.height) / 2 * 0.92);
|
|
@@ -8908,8 +10260,6 @@ function drawPie(surface, spec, tokens, size, context) {
|
|
|
8908
10260
|
});
|
|
8909
10261
|
return;
|
|
8910
10262
|
}
|
|
8911
|
-
const labelCfg = resolvePieLabels(pie);
|
|
8912
|
-
const labelFont = fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.normal);
|
|
8913
10263
|
const labelPlan = labelCfg && planPieLabels({
|
|
8914
10264
|
slices: slices.map((s, i) => ({
|
|
8915
10265
|
index: i,
|
|
@@ -8998,7 +10348,8 @@ function drawPie(surface, spec, tokens, size, context) {
|
|
|
8998
10348
|
size: tokens.font.size.small,
|
|
8999
10349
|
transform: o.align === "right" ? "translate(-100%,-50%)" : "translateY(-50%)",
|
|
9000
10350
|
opacity: alpha === 1 ? void 0 : alpha,
|
|
9001
|
-
pill: { background: tokens.color.surface, border: tokens.color.border, padX: 6 }
|
|
10351
|
+
pill: { background: tokens.color.surface, border: tokens.color.border, padX: 6 },
|
|
10352
|
+
maxWidth: o.maxWidth
|
|
9002
10353
|
});
|
|
9003
10354
|
}
|
|
9004
10355
|
for (const l of labelPlan.inside) {
|
|
@@ -9013,18 +10364,21 @@ function drawPie(surface, spec, tokens, size, context) {
|
|
|
9013
10364
|
weight: tokens.font.weight.bold,
|
|
9014
10365
|
transform: "translate(-50%,-50%)",
|
|
9015
10366
|
opacity: alpha === 1 ? void 0 : alpha,
|
|
9016
|
-
pill: { background: tokens.color.surface, border: tokens.color.border, padX: 6 }
|
|
10367
|
+
pill: { background: tokens.color.surface, border: tokens.color.border, padX: 6 },
|
|
10368
|
+
maxWidth: l.maxWidth
|
|
9017
10369
|
});
|
|
9018
10370
|
}
|
|
9019
10371
|
}
|
|
9020
10372
|
if (sweepT >= 1 && innerRadius >= 34) {
|
|
10373
|
+
const centerBudget = innerRadius * 1.7;
|
|
9021
10374
|
addOverlayText(surface, tokens, {
|
|
9022
10375
|
left: cx,
|
|
9023
10376
|
top: cy - tokens.font.size.small - 2,
|
|
9024
10377
|
text: pie.encoding.theta.title ?? "Total",
|
|
9025
10378
|
color: tokens.color.textMuted,
|
|
9026
10379
|
size: tokens.font.size.tiny,
|
|
9027
|
-
transform: "translate(-50%,-50%)"
|
|
10380
|
+
transform: "translate(-50%,-50%)",
|
|
10381
|
+
maxWidth: centerBudget
|
|
9028
10382
|
});
|
|
9029
10383
|
addOverlayText(surface, tokens, {
|
|
9030
10384
|
left: cx,
|
|
@@ -9033,7 +10387,8 @@ function drawPie(surface, spec, tokens, size, context) {
|
|
|
9033
10387
|
color: tokens.color.text,
|
|
9034
10388
|
size: tokens.font.size.large,
|
|
9035
10389
|
weight: tokens.font.weight.bold,
|
|
9036
|
-
transform: "translate(-50%,-50%)"
|
|
10390
|
+
transform: "translate(-50%,-50%)",
|
|
10391
|
+
maxWidth: centerBudget
|
|
9037
10392
|
});
|
|
9038
10393
|
}
|
|
9039
10394
|
if (!pieTooltipEnabled(pie)) return;
|
|
@@ -9133,6 +10488,30 @@ function buildStages(spec, palette) {
|
|
|
9133
10488
|
function safeRatio(numerator, denominator) {
|
|
9134
10489
|
return Number.isFinite(numerator) && Number.isFinite(denominator) && denominator !== 0 ? numerator / denominator : 0;
|
|
9135
10490
|
}
|
|
10491
|
+
function clamp5(value, min, max) {
|
|
10492
|
+
return Math.min(max, Math.max(min, value));
|
|
10493
|
+
}
|
|
10494
|
+
function spreadCenters(ideal, minGap, top, bottom) {
|
|
10495
|
+
const ordered = ideal.map((y, i) => ({ i, y })).sort((a, b) => a.y - b.y);
|
|
10496
|
+
for (let i = 1; i < ordered.length; i += 1) {
|
|
10497
|
+
if (ordered[i].y < ordered[i - 1].y + minGap) ordered[i].y = ordered[i - 1].y + minGap;
|
|
10498
|
+
}
|
|
10499
|
+
const last = ordered[ordered.length - 1];
|
|
10500
|
+
if (last && last.y > bottom) {
|
|
10501
|
+
const shift = last.y - bottom;
|
|
10502
|
+
for (const item of ordered) item.y -= shift;
|
|
10503
|
+
}
|
|
10504
|
+
if (ordered.length && ordered[0].y < top) {
|
|
10505
|
+
let prev = top - minGap;
|
|
10506
|
+
for (const item of ordered) {
|
|
10507
|
+
item.y = Math.max(item.y, prev + minGap);
|
|
10508
|
+
prev = item.y;
|
|
10509
|
+
}
|
|
10510
|
+
}
|
|
10511
|
+
const out = new Array(ideal.length);
|
|
10512
|
+
for (const item of ordered) out[item.i] = item.y;
|
|
10513
|
+
return out;
|
|
10514
|
+
}
|
|
9136
10515
|
function funnelPercents(stages, mode = "first") {
|
|
9137
10516
|
return stages.map((stage, i) => {
|
|
9138
10517
|
if (mode === "previous") return i === 0 ? 1 : safeRatio(stage.value, stages[i - 1].value);
|
|
@@ -9215,33 +10594,57 @@ function drawFunnel(surface, spec, tokens, size, context) {
|
|
|
9215
10594
|
return band.topHalf + (band.botHalf - band.topHalf) * t;
|
|
9216
10595
|
};
|
|
9217
10596
|
if (showLabels) {
|
|
10597
|
+
const rowGap = 2;
|
|
10598
|
+
const valueFont = fontString(tokens.font.size.tiny, tokens.font.family, tokens.font.weight.normal);
|
|
10599
|
+
const labelBlockH = tokens.font.size.small * 1.2 + rowGap + tokens.font.size.tiny * 1.2;
|
|
10600
|
+
const capacity = Math.max(1, Math.floor(content.height / Math.max(labelBlockH, tokens.font.size.small * 1.2)));
|
|
10601
|
+
const labelBudget = Math.max(0, labelW - 8);
|
|
10602
|
+
const candidates = stages.map((stage, i) => ({ i, value: stage.value })).filter(
|
|
10603
|
+
({ i }) => labelBudget >= 8 && measureText(stages[i].label, labelFont).width <= labelBudget && measureText(formatValue(stages[i].value, valueFormat), valueFont).width <= labelBudget
|
|
10604
|
+
);
|
|
10605
|
+
const labelled = new Set(
|
|
10606
|
+
(candidates.length <= capacity ? candidates.map((item) => item.i) : candidates.sort((a, b) => b.value - a.value || a.i - b.i).slice(0, capacity).map((item) => item.i)).sort((a, b) => a - b)
|
|
10607
|
+
);
|
|
10608
|
+
const labelOrder = [...labelled];
|
|
10609
|
+
const labelYs = spreadCenters(
|
|
10610
|
+
labelOrder.map((i) => (bands[i].top + bands[i].bottom) / 2),
|
|
10611
|
+
labelBlockH,
|
|
10612
|
+
content.y + labelBlockH / 2,
|
|
10613
|
+
content.y + content.height - labelBlockH / 2
|
|
10614
|
+
);
|
|
10615
|
+
const labelYByIndex = new Map(labelOrder.map((i, k) => [i, labelYs[k]]));
|
|
9218
10616
|
for (let i = 0; i < n; i++) {
|
|
10617
|
+
if (!labelled.has(i)) continue;
|
|
9219
10618
|
const s = stages[i];
|
|
9220
10619
|
const band = bands[i];
|
|
9221
|
-
const
|
|
10620
|
+
const bandMidY = (band.top + band.bottom) / 2;
|
|
10621
|
+
const midY = labelYByIndex.get(i) ?? bandMidY;
|
|
9222
10622
|
const dim = alphaOf(s) !== 1;
|
|
10623
|
+
const labelTop = clamp5(midY - labelBlockH / 2, content.y, content.y + content.height - labelBlockH);
|
|
9223
10624
|
addOverlayText(surface, tokens, {
|
|
9224
10625
|
left: content.x,
|
|
9225
|
-
top:
|
|
10626
|
+
top: labelTop,
|
|
9226
10627
|
text: s.label,
|
|
9227
10628
|
color: tokens.color.text,
|
|
9228
10629
|
size: tokens.font.size.small,
|
|
9229
10630
|
weight: tokens.font.weight.bold,
|
|
9230
|
-
opacity: dim ? 0.5 : 1
|
|
10631
|
+
opacity: dim ? 0.5 : 1,
|
|
10632
|
+
maxWidth: labelBudget
|
|
9231
10633
|
});
|
|
9232
10634
|
addOverlayText(surface, tokens, {
|
|
9233
10635
|
left: content.x,
|
|
9234
|
-
top:
|
|
10636
|
+
top: labelTop + tokens.font.size.small * 1.2 + rowGap,
|
|
9235
10637
|
text: formatValue(s.value, valueFormat),
|
|
9236
10638
|
color: tokens.color.textMuted,
|
|
9237
10639
|
size: tokens.font.size.tiny,
|
|
9238
|
-
opacity: dim ? 0.5 : 1
|
|
10640
|
+
opacity: dim ? 0.5 : 1,
|
|
10641
|
+
maxWidth: labelBudget
|
|
9239
10642
|
});
|
|
9240
10643
|
const pct = pctOf(i);
|
|
9241
10644
|
const pctText2 = `${Math.round(pct * 100)}%`;
|
|
9242
10645
|
const padX = 7;
|
|
9243
10646
|
const badgeW = Math.ceil(measureText(pctText2, labelFont).width) + padX * 2 + 2;
|
|
9244
|
-
const innerHalf = halfAtY(band,
|
|
10647
|
+
const innerHalf = halfAtY(band, bandMidY);
|
|
9245
10648
|
const fitsInside = badgeW + 8 <= innerHalf * 2;
|
|
9246
10649
|
let badgeLeft = cx;
|
|
9247
10650
|
let badgeTransform = "translate(-50%,-50%)";
|
|
@@ -9260,7 +10663,8 @@ function drawFunnel(surface, spec, tokens, size, context) {
|
|
|
9260
10663
|
weight: tokens.font.weight.bold,
|
|
9261
10664
|
opacity: dim ? 0.55 : 1,
|
|
9262
10665
|
transform: badgeTransform,
|
|
9263
|
-
pill: { background: tokens.color.surface, border: tokens.color.border, padX }
|
|
10666
|
+
pill: { background: tokens.color.surface, border: tokens.color.border, padX },
|
|
10667
|
+
maxWidth: badgeW
|
|
9264
10668
|
});
|
|
9265
10669
|
}
|
|
9266
10670
|
}
|
|
@@ -9302,7 +10706,7 @@ function drawFunnel(surface, spec, tokens, size, context) {
|
|
|
9302
10706
|
|
|
9303
10707
|
// src/charts/kpi.ts
|
|
9304
10708
|
var SVG_NS = "http://www.w3.org/2000/svg";
|
|
9305
|
-
function
|
|
10709
|
+
function clamp6(value, min, max) {
|
|
9306
10710
|
return Math.max(min, Math.min(max, value));
|
|
9307
10711
|
}
|
|
9308
10712
|
function valueField(ref) {
|
|
@@ -9336,7 +10740,7 @@ function valueFontSize(rect, contentWidth, valueText, unit, isRich) {
|
|
|
9336
10740
|
const sizeByText = contentWidth / Math.max(1, effectiveLen * 0.58);
|
|
9337
10741
|
const lo = isRich ? 22 : 26;
|
|
9338
10742
|
const hi = isRich ? 52 : 76;
|
|
9339
|
-
return
|
|
10743
|
+
return clamp6(Math.min(sizeByCard, sizeByText), lo, hi);
|
|
9340
10744
|
}
|
|
9341
10745
|
function unitFontSize(valueSize) {
|
|
9342
10746
|
return Math.max(12, valueSize * 0.46);
|
|
@@ -9374,12 +10778,12 @@ function resolveComparison(cmp, data, parentFormat) {
|
|
|
9374
10778
|
if (delta == null) return null;
|
|
9375
10779
|
const rawPositive = delta >= 0;
|
|
9376
10780
|
const tri = rawPositive ? "\u25B2" : "\u25BC";
|
|
9377
|
-
let
|
|
10781
|
+
let text2 = `${tri} ${formatValue(delta, cmp.format ?? ".0%")}`;
|
|
9378
10782
|
if (cmp.amount !== void 0) {
|
|
9379
10783
|
const amount = resolveValueRef(cmp.amount, data);
|
|
9380
|
-
if (amount != null)
|
|
10784
|
+
if (amount != null) text2 += ` : ${formatValue(amount, cmp.amountFormat ?? parentFormat ?? ",.0f")}`;
|
|
9381
10785
|
}
|
|
9382
|
-
return { label: cmp.label, text, positive: cmp.invert ? !rawPositive : rawPositive };
|
|
10786
|
+
return { label: cmp.label, text: text2, positive: cmp.invert ? !rawPositive : rawPositive };
|
|
9383
10787
|
}
|
|
9384
10788
|
function layoutKpi(rect, tokens, framed, f) {
|
|
9385
10789
|
const font = tokens.font;
|
|
@@ -9388,7 +10792,7 @@ function layoutKpi(rect, tokens, framed, f) {
|
|
|
9388
10792
|
const contentWidth = Math.max(1, rect.width - pad3 * 2);
|
|
9389
10793
|
const availTop = rect.y + pad3;
|
|
9390
10794
|
const availH = Math.max(0, rect.height - pad3 * 2);
|
|
9391
|
-
const labelSize = Math.round(
|
|
10795
|
+
const labelSize = Math.round(clamp6(f.valueSize * 0.4, font.size.small, font.size.title));
|
|
9392
10796
|
const labelH = f.hasLabel ? Math.round(labelSize * 1.35) : 0;
|
|
9393
10797
|
const valueH = Math.round(f.valueSize * 1.06);
|
|
9394
10798
|
const deltaSize = font.size.large;
|
|
@@ -9481,7 +10885,7 @@ function sparkMarkers(values, xAt, yAt, r, tokens) {
|
|
|
9481
10885
|
}
|
|
9482
10886
|
function sparkScales(values, width, height, markers) {
|
|
9483
10887
|
const n = values.length;
|
|
9484
|
-
const r =
|
|
10888
|
+
const r = clamp6(Math.min(height, 40) * 0.12, 2.5, 4);
|
|
9485
10889
|
const padX = markers ? r + 1 : 1.75;
|
|
9486
10890
|
const padY = markers ? r + 1 : 1.75;
|
|
9487
10891
|
const innerW = Math.max(1, width - padX * 2);
|
|
@@ -9926,9 +11330,33 @@ function drawKpiCanvas(surface, kpi, tokens, p) {
|
|
|
9926
11330
|
});
|
|
9927
11331
|
}
|
|
9928
11332
|
}
|
|
9929
|
-
|
|
9930
|
-
// src/charts/sankey.ts
|
|
9931
|
-
var center = (n) => (n.y0 + n.y1) / 2;
|
|
11333
|
+
|
|
11334
|
+
// src/charts/sankey.ts
|
|
11335
|
+
var center = (n) => (n.y0 + n.y1) / 2;
|
|
11336
|
+
function clamp7(value, min, max) {
|
|
11337
|
+
return Math.min(max, Math.max(min, value));
|
|
11338
|
+
}
|
|
11339
|
+
function spreadCenters2(ideal, minGap, top, bottom) {
|
|
11340
|
+
const ordered = ideal.map((y, i) => ({ i, y })).sort((a, b) => a.y - b.y);
|
|
11341
|
+
for (let i = 1; i < ordered.length; i += 1) {
|
|
11342
|
+
if (ordered[i].y < ordered[i - 1].y + minGap) ordered[i].y = ordered[i - 1].y + minGap;
|
|
11343
|
+
}
|
|
11344
|
+
const last = ordered[ordered.length - 1];
|
|
11345
|
+
if (last && last.y > bottom) {
|
|
11346
|
+
const shift = last.y - bottom;
|
|
11347
|
+
for (const item of ordered) item.y -= shift;
|
|
11348
|
+
}
|
|
11349
|
+
if (ordered.length && ordered[0].y < top) {
|
|
11350
|
+
let prev = top - minGap;
|
|
11351
|
+
for (const item of ordered) {
|
|
11352
|
+
item.y = Math.max(item.y, prev + minGap);
|
|
11353
|
+
prev = item.y;
|
|
11354
|
+
}
|
|
11355
|
+
}
|
|
11356
|
+
const out = new Array(ideal.length);
|
|
11357
|
+
for (const item of ordered) out[item.i] = item.y;
|
|
11358
|
+
return out;
|
|
11359
|
+
}
|
|
9932
11360
|
function buildGraph(spec, palette) {
|
|
9933
11361
|
const readS = accessor(spec.encoding.source.field);
|
|
9934
11362
|
const readT = accessor(spec.encoding.target.field);
|
|
@@ -10183,10 +11611,10 @@ function drawSankey(surface, spec, tokens, size) {
|
|
|
10183
11611
|
return Math.max(nameW, valW);
|
|
10184
11612
|
};
|
|
10185
11613
|
const colExtent = (col) => col.reduce((m, n) => Math.max(m, labelExtent(n)), 0);
|
|
10186
|
-
const
|
|
11614
|
+
const LABEL_GAP2 = 6;
|
|
10187
11615
|
const capGutter = content.width * 0.3;
|
|
10188
|
-
const leftGutter = Math.min(colExtent(graph.columns[0] ?? []) +
|
|
10189
|
-
const rightGutter = graph.maxLayer > 0 ? Math.min(colExtent(graph.columns[graph.maxLayer] ?? []) +
|
|
11616
|
+
const leftGutter = Math.min(colExtent(graph.columns[0] ?? []) + LABEL_GAP2 + 4, capGutter);
|
|
11617
|
+
const rightGutter = graph.maxLayer > 0 ? Math.min(colExtent(graph.columns[graph.maxLayer] ?? []) + LABEL_GAP2 + 4, capGutter) : 0;
|
|
10190
11618
|
const nodeWidth = sankey.nodeWidth ?? 16;
|
|
10191
11619
|
const pad3 = sankey.nodePadding ?? 14;
|
|
10192
11620
|
const area2 = {
|
|
@@ -10217,30 +11645,112 @@ function drawSankey(surface, spec, tokens, size) {
|
|
|
10217
11645
|
}
|
|
10218
11646
|
ctx.restore();
|
|
10219
11647
|
const mid = area2.x + area2.width / 2;
|
|
10220
|
-
|
|
11648
|
+
const labelRows = graph.nodes.map((n) => {
|
|
10221
11649
|
const outerLeft = n.layer === 0;
|
|
10222
11650
|
const outerRight = n.layer === graph.maxLayer && graph.maxLayer > 0;
|
|
10223
11651
|
const labelRight = outerRight ? true : outerLeft ? false : n.x0 < mid;
|
|
10224
|
-
|
|
10225
|
-
const
|
|
10226
|
-
const
|
|
10227
|
-
|
|
11652
|
+
let left = labelRight ? n.x1 + LABEL_GAP2 : n.x0 - LABEL_GAP2;
|
|
11653
|
+
const nextCol = graph.columns[n.layer + 1] ?? [];
|
|
11654
|
+
const prevCol = graph.columns[n.layer - 1] ?? [];
|
|
11655
|
+
const rightLimit = outerRight || nextCol.length === 0 ? content.x + content.width : Math.min(...nextCol.map((node) => node.x0)) - LABEL_GAP2;
|
|
11656
|
+
const leftLimit = outerLeft || prevCol.length === 0 ? content.x : Math.max(...prevCol.map((node) => node.x1)) + LABEL_GAP2;
|
|
11657
|
+
let maxWidth = labelRight ? rightLimit - left : left - leftLimit;
|
|
11658
|
+
maxWidth = Math.max(0, Math.min(content.width * 0.32, maxWidth));
|
|
11659
|
+
if (labelRight) {
|
|
11660
|
+
left = clamp7(left, content.x, content.x + content.width - 1);
|
|
11661
|
+
} else {
|
|
11662
|
+
left = clamp7(left, content.x + 1, content.x + content.width);
|
|
11663
|
+
}
|
|
11664
|
+
return {
|
|
11665
|
+
node: n,
|
|
11666
|
+
key: `${n.layer}:${labelRight ? "r" : "l"}`,
|
|
11667
|
+
labelRight,
|
|
11668
|
+
cy: center(n),
|
|
10228
11669
|
left,
|
|
11670
|
+
maxWidth
|
|
11671
|
+
};
|
|
11672
|
+
});
|
|
11673
|
+
const rowGap = 2;
|
|
11674
|
+
const rowHeight = showValues ? f.size.small * 1.2 + rowGap + f.size.tiny * 1.2 : f.size.small * 1.2;
|
|
11675
|
+
const keep = /* @__PURE__ */ new Set();
|
|
11676
|
+
const adjustedY = /* @__PURE__ */ new Map();
|
|
11677
|
+
const groups = /* @__PURE__ */ new Map();
|
|
11678
|
+
for (const row of labelRows) groups.set(row.key, [...groups.get(row.key) ?? [], row]);
|
|
11679
|
+
for (const rows of groups.values()) {
|
|
11680
|
+
const capacity = Math.max(1, Math.floor(content.height / rowHeight));
|
|
11681
|
+
const selected = rows.length <= capacity ? rows : [...rows].sort((a, b) => b.node.value - a.node.value || a.node.index - b.node.index).slice(0, capacity).sort((a, b) => a.cy - b.cy);
|
|
11682
|
+
const centers = spreadCenters2(
|
|
11683
|
+
selected.map((row) => row.cy),
|
|
11684
|
+
rowHeight,
|
|
11685
|
+
content.y + rowHeight / 2,
|
|
11686
|
+
content.y + content.height - rowHeight / 2
|
|
11687
|
+
);
|
|
11688
|
+
selected.forEach((row, i) => {
|
|
11689
|
+
keep.add(row.node);
|
|
11690
|
+
adjustedY.set(row.node, centers[i]);
|
|
11691
|
+
});
|
|
11692
|
+
}
|
|
11693
|
+
const drawableRows = labelRows.filter(
|
|
11694
|
+
(row) => keep.has(row.node) && row.maxWidth >= 8 && measureText(row.node.name, labelFont).width <= row.maxWidth && (!showValues || measureText(formatValue(row.node.value, sankey.encoding.value.format), valueFont).width <= row.maxWidth)
|
|
11695
|
+
);
|
|
11696
|
+
const accepted = [];
|
|
11697
|
+
const occupied = [];
|
|
11698
|
+
const labelBoxFor = (row, text2, font, sizePx, top) => {
|
|
11699
|
+
const width = Math.min(measureText(text2, font).width, row.maxWidth);
|
|
11700
|
+
return {
|
|
11701
|
+
x: row.labelRight ? row.left : row.left - width,
|
|
11702
|
+
y: top - sizePx * 1.2 / 2,
|
|
11703
|
+
width,
|
|
11704
|
+
height: sizePx * 1.2
|
|
11705
|
+
};
|
|
11706
|
+
};
|
|
11707
|
+
for (const row of [...drawableRows].sort((a, b) => {
|
|
11708
|
+
const aOuter = a.node.layer === 0 || a.node.layer === graph.maxLayer ? 1 : 0;
|
|
11709
|
+
const bOuter = b.node.layer === 0 || b.node.layer === graph.maxLayer ? 1 : 0;
|
|
11710
|
+
return bOuter - aOuter || b.node.value - a.node.value || a.node.index - b.node.index;
|
|
11711
|
+
})) {
|
|
11712
|
+
const cy = adjustedY.get(row.node) ?? row.cy;
|
|
11713
|
+
const boxes = [
|
|
11714
|
+
labelBoxFor(row, row.node.name, labelFont, f.size.small, showValues ? cy - f.size.small * 0.62 : cy)
|
|
11715
|
+
];
|
|
11716
|
+
if (showValues) {
|
|
11717
|
+
boxes.push(
|
|
11718
|
+
labelBoxFor(
|
|
11719
|
+
row,
|
|
11720
|
+
formatValue(row.node.value, sankey.encoding.value.format),
|
|
11721
|
+
valueFont,
|
|
11722
|
+
f.size.tiny,
|
|
11723
|
+
cy + f.size.small * 0.62
|
|
11724
|
+
)
|
|
11725
|
+
);
|
|
11726
|
+
}
|
|
11727
|
+
if (boxes.some((box) => occupied.some((other) => boxesOverlap(box, other, 1)))) continue;
|
|
11728
|
+
occupied.push(...boxes);
|
|
11729
|
+
accepted.push(row);
|
|
11730
|
+
}
|
|
11731
|
+
for (const row of accepted.sort((a, b) => a.node.index - b.node.index)) {
|
|
11732
|
+
const n = row.node;
|
|
11733
|
+
const cy = adjustedY.get(n) ?? row.cy;
|
|
11734
|
+
const transform2 = row.labelRight ? "translateY(-50%)" : "translate(-100%,-50%)";
|
|
11735
|
+
addOverlayText(surface, tokens, {
|
|
11736
|
+
left: row.left,
|
|
10229
11737
|
top: showValues ? cy - f.size.small * 0.62 : cy,
|
|
10230
11738
|
text: n.name,
|
|
10231
11739
|
color: tokens.color.text,
|
|
10232
11740
|
size: f.size.small,
|
|
10233
11741
|
weight: f.weight.medium,
|
|
10234
|
-
transform: transform2
|
|
11742
|
+
transform: transform2,
|
|
11743
|
+
maxWidth: row.maxWidth
|
|
10235
11744
|
});
|
|
10236
11745
|
if (showValues) {
|
|
10237
11746
|
addOverlayText(surface, tokens, {
|
|
10238
|
-
left,
|
|
11747
|
+
left: row.left,
|
|
10239
11748
|
top: cy + f.size.small * 0.62,
|
|
10240
11749
|
text: formatValue(n.value, sankey.encoding.value.format),
|
|
10241
11750
|
color: tokens.color.textMuted,
|
|
10242
11751
|
size: f.size.tiny,
|
|
10243
|
-
transform: transform2
|
|
11752
|
+
transform: transform2,
|
|
11753
|
+
maxWidth: row.maxWidth
|
|
10244
11754
|
});
|
|
10245
11755
|
}
|
|
10246
11756
|
}
|
|
@@ -10285,7 +11795,12 @@ function drawSankey(surface, spec, tokens, size) {
|
|
|
10285
11795
|
}
|
|
10286
11796
|
|
|
10287
11797
|
// src/charts/choropleth.ts
|
|
10288
|
-
var
|
|
11798
|
+
var clamp8 = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v;
|
|
11799
|
+
function readableWithin4(text2, maxWidth, font) {
|
|
11800
|
+
if (maxWidth <= 0) return false;
|
|
11801
|
+
if (measureText(text2, font).width <= maxWidth) return true;
|
|
11802
|
+
return ellipsize(text2, maxWidth, font).length / Math.max(1, text2.length) >= 0.5;
|
|
11803
|
+
}
|
|
10289
11804
|
function centralMeridian(features) {
|
|
10290
11805
|
const lons = [];
|
|
10291
11806
|
for (const feature of features) {
|
|
@@ -10326,7 +11841,7 @@ function projector(kind, lon0) {
|
|
|
10326
11841
|
return ([lon, lat]) => [wrapLon(lon), lat];
|
|
10327
11842
|
}
|
|
10328
11843
|
return ([lon, lat]) => {
|
|
10329
|
-
const phi =
|
|
11844
|
+
const phi = clamp8(lat, -85.0511, 85.0511) * Math.PI / 180;
|
|
10330
11845
|
return [wrapLon(lon), Math.log(Math.tan(Math.PI / 4 + phi / 2)) * 180 / Math.PI];
|
|
10331
11846
|
};
|
|
10332
11847
|
}
|
|
@@ -10371,7 +11886,7 @@ function drawLegend3(surface, tokens, legend, domain, scheme, format) {
|
|
|
10371
11886
|
if (legend.width <= 0 || legend.height <= 0) return;
|
|
10372
11887
|
const ctx = surface.marks.ctx;
|
|
10373
11888
|
const interp = sequential(scheme);
|
|
10374
|
-
const barWidth = Math.floor(
|
|
11889
|
+
const barWidth = Math.floor(clamp8(legend.width * 0.5, 120, 240));
|
|
10375
11890
|
const barHeight = 10;
|
|
10376
11891
|
const barX = Math.round(legend.x + (legend.width - barWidth) / 2);
|
|
10377
11892
|
const barY = Math.round(legend.y + 4);
|
|
@@ -10385,21 +11900,33 @@ function drawLegend3(surface, tokens, legend, domain, scheme, format) {
|
|
|
10385
11900
|
ctx.lineWidth = 1;
|
|
10386
11901
|
ctx.strokeRect(barX + 0.5, barY + 0.5, Math.max(0, barWidth - 1), Math.max(0, barHeight - 1));
|
|
10387
11902
|
const labelTop = barY + barHeight + 3;
|
|
10388
|
-
|
|
10389
|
-
|
|
10390
|
-
|
|
10391
|
-
|
|
10392
|
-
|
|
10393
|
-
|
|
10394
|
-
|
|
10395
|
-
|
|
10396
|
-
|
|
10397
|
-
|
|
10398
|
-
|
|
10399
|
-
|
|
10400
|
-
|
|
10401
|
-
|
|
10402
|
-
}
|
|
11903
|
+
const labelBudget = Math.max(0, barWidth / 2 - 4);
|
|
11904
|
+
const labelFont = fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.normal);
|
|
11905
|
+
const minLabel = formatValue(domain[0], format);
|
|
11906
|
+
const maxLabel = formatValue(domain[1], format);
|
|
11907
|
+
if (readableWithin4(minLabel, labelBudget, labelFont)) {
|
|
11908
|
+
addOverlayText(surface, tokens, {
|
|
11909
|
+
left: barX,
|
|
11910
|
+
top: labelTop,
|
|
11911
|
+
text: minLabel,
|
|
11912
|
+
color: tokens.color.textMuted,
|
|
11913
|
+
size: tokens.font.size.small,
|
|
11914
|
+
maxWidth: labelBudget,
|
|
11915
|
+
role: "legend"
|
|
11916
|
+
});
|
|
11917
|
+
}
|
|
11918
|
+
if (readableWithin4(maxLabel, labelBudget, labelFont)) {
|
|
11919
|
+
addOverlayText(surface, tokens, {
|
|
11920
|
+
left: barX + barWidth,
|
|
11921
|
+
top: labelTop,
|
|
11922
|
+
text: maxLabel,
|
|
11923
|
+
color: tokens.color.textMuted,
|
|
11924
|
+
size: tokens.font.size.small,
|
|
11925
|
+
transform: "translateX(-100%)",
|
|
11926
|
+
maxWidth: labelBudget,
|
|
11927
|
+
role: "legend"
|
|
11928
|
+
});
|
|
11929
|
+
}
|
|
10403
11930
|
}
|
|
10404
11931
|
function drawChoropleth(surface, spec, tokens, size, context) {
|
|
10405
11932
|
const choropleth = spec;
|
|
@@ -11080,9 +12607,9 @@ function applyConditionalFormatting(td, opts, column, colIndex, cell, rowBg, row
|
|
|
11080
12607
|
const baseline = diverging2 ? normalized(0, domain) : 0;
|
|
11081
12608
|
const left = diverging2 ? Math.min(baseline, normalized(numeric, domain)) : 0;
|
|
11082
12609
|
const width = diverging2 ? Math.abs(normalized(numeric, domain) - baseline) : ratio;
|
|
11083
|
-
const
|
|
11084
|
-
|
|
11085
|
-
setStyles(
|
|
12610
|
+
const text2 = document.createElement("span");
|
|
12611
|
+
text2.textContent = cf.showValue === false ? "" : formatDisplayValue(cell.value, column);
|
|
12612
|
+
setStyles(text2, overlayTextStyles(opts.tokens.color.text));
|
|
11086
12613
|
td.style.background = rowBg;
|
|
11087
12614
|
if (opts.sketch) {
|
|
11088
12615
|
const colWidth = opts.widths?.[colIndex] ?? column.width ?? defaultColumnWidth(column);
|
|
@@ -11090,8 +12617,8 @@ function applyConditionalFormatting(td, opts, column, colIndex, cell, rowBg, row
|
|
|
11090
12617
|
const seed = (opts.sketch.seed ^ Math.imul(rowIndex + 1, 374761393) ^ Math.imul(colIndex + 1, 668265263)) >>> 0;
|
|
11091
12618
|
const canvas = buildSketchDataBar(opts.sketch, barColor, colWidth, rowHeight, left, width, seed);
|
|
11092
12619
|
if (canvas) td.appendChild(canvas);
|
|
11093
|
-
td.appendChild(
|
|
11094
|
-
applyNegativeStyle(
|
|
12620
|
+
td.appendChild(text2);
|
|
12621
|
+
applyNegativeStyle(text2, cell.value, column);
|
|
11095
12622
|
return;
|
|
11096
12623
|
}
|
|
11097
12624
|
const bar = document.createElement("div");
|
|
@@ -11106,8 +12633,8 @@ function applyConditionalFormatting(td, opts, column, colIndex, cell, rowBg, row
|
|
|
11106
12633
|
opacity: "0.9",
|
|
11107
12634
|
pointerEvents: "none"
|
|
11108
12635
|
});
|
|
11109
|
-
td.append(bar,
|
|
11110
|
-
applyNegativeStyle(
|
|
12636
|
+
td.append(bar, text2);
|
|
12637
|
+
applyNegativeStyle(text2, cell.value, column);
|
|
11111
12638
|
}
|
|
11112
12639
|
function buildSketchDataBar(sketch, color, colWidth, rowHeight, leftRatio, widthRatio, seed) {
|
|
11113
12640
|
const avail = Math.max(0, colWidth - 8);
|
|
@@ -11246,7 +12773,7 @@ function paintBodyRowCanvas(opts, widths, rowIndex, y, metrics, pen) {
|
|
|
11246
12773
|
paintCellBox(opts.ctx, x, y, width, metrics.rowHeight, bg, opts.tokens.color.border, false, pen);
|
|
11247
12774
|
if (conditional.bar) paintDataBar(opts, x, y, width, metrics.rowHeight, conditional.bar, pen);
|
|
11248
12775
|
const indent = opts.cellIndent?.(rowIndex, colIndex) ?? 0;
|
|
11249
|
-
const
|
|
12776
|
+
const text2 = conditional.showValue === false ? "" : formatDisplayValue(cell.value, column);
|
|
11250
12777
|
const color = negativeTextColor(cell.value, column, conditional.color ?? opts.tokens.color.text);
|
|
11251
12778
|
const weight = conditional.weight ?? (cls === "normal" ? opts.tokens.font.weight.normal : opts.tokens.font.weight.bold);
|
|
11252
12779
|
paintCellValueWithIcon(opts, {
|
|
@@ -11254,7 +12781,7 @@ function paintBodyRowCanvas(opts, widths, rowIndex, y, metrics, pen) {
|
|
|
11254
12781
|
y,
|
|
11255
12782
|
width,
|
|
11256
12783
|
height: metrics.rowHeight,
|
|
11257
|
-
text,
|
|
12784
|
+
text: text2,
|
|
11258
12785
|
align: column.align,
|
|
11259
12786
|
color,
|
|
11260
12787
|
weight,
|
|
@@ -11340,8 +12867,8 @@ function paintCellValueWithIcon(opts, input) {
|
|
|
11340
12867
|
const iconFont = fontString(opts.tokens.font.size.small, opts.tokens.font.family, opts.tokens.font.weight.bold);
|
|
11341
12868
|
const iconW = input.icon ? measureText(input.icon, iconFont).width : 0;
|
|
11342
12869
|
const textMax = Math.max(0, input.width - input.paddingX * 2 - iconW - gap);
|
|
11343
|
-
const
|
|
11344
|
-
const textW = measureText(
|
|
12870
|
+
const text2 = ellipsize(input.text, textMax, font);
|
|
12871
|
+
const textW = measureText(text2, font).width;
|
|
11345
12872
|
const groupW = textW + iconW + gap;
|
|
11346
12873
|
let start = input.x + input.paddingX;
|
|
11347
12874
|
if (input.align === "right") start = input.x + input.width - input.paddingX - groupW;
|
|
@@ -11361,21 +12888,21 @@ function paintCellValueWithIcon(opts, input) {
|
|
|
11361
12888
|
baseline: "middle"
|
|
11362
12889
|
});
|
|
11363
12890
|
}
|
|
11364
|
-
paintRawCellText(opts, textX, midY,
|
|
12891
|
+
paintRawCellText(opts, textX, midY, text2, font, input.color, opts.tokens.font.size.small);
|
|
11365
12892
|
}
|
|
11366
12893
|
function paintCellText(opts, input) {
|
|
11367
12894
|
const font = fontString(opts.tokens.font.size.small, opts.tokens.font.family, input.weight);
|
|
11368
12895
|
const maxWidth = Math.max(0, input.width - input.paddingX * 2);
|
|
11369
|
-
const
|
|
12896
|
+
const text2 = ellipsize(input.text, maxWidth, font);
|
|
11370
12897
|
const x = input.align === "right" ? input.x + input.width - input.paddingX : input.align === "center" ? input.x + input.width / 2 : input.x + input.paddingX;
|
|
11371
|
-
paintRawCellText(opts, x, input.y + input.height / 2,
|
|
12898
|
+
paintRawCellText(opts, x, input.y + input.height / 2, text2, font, input.color, opts.tokens.font.size.small, input.align);
|
|
11372
12899
|
}
|
|
11373
|
-
function paintRawCellText(opts, x, y,
|
|
11374
|
-
if (!
|
|
12900
|
+
function paintRawCellText(opts, x, y, text2, font, color, size, align = "left") {
|
|
12901
|
+
if (!text2) return;
|
|
11375
12902
|
paintCanvasText(opts.ctx, {
|
|
11376
12903
|
x,
|
|
11377
12904
|
y,
|
|
11378
|
-
text,
|
|
12905
|
+
text: text2,
|
|
11379
12906
|
font,
|
|
11380
12907
|
color,
|
|
11381
12908
|
size,
|
|
@@ -12255,9 +13782,9 @@ function makeSelectButton(tokens, label) {
|
|
|
12255
13782
|
justifyContent: "space-between",
|
|
12256
13783
|
gap: `${tokens.spacing.sm}px`
|
|
12257
13784
|
});
|
|
12258
|
-
const
|
|
12259
|
-
|
|
12260
|
-
Object.assign(
|
|
13785
|
+
const text2 = document.createElement("span");
|
|
13786
|
+
text2.textContent = label;
|
|
13787
|
+
Object.assign(text2.style, {
|
|
12261
13788
|
overflow: "hidden",
|
|
12262
13789
|
textOverflow: "ellipsis",
|
|
12263
13790
|
whiteSpace: "nowrap"
|
|
@@ -12266,7 +13793,7 @@ function makeSelectButton(tokens, label) {
|
|
|
12266
13793
|
caret.textContent = "\u25BE";
|
|
12267
13794
|
caret.style.color = tokens.color.textMuted;
|
|
12268
13795
|
caret.style.flex = "0 0 auto";
|
|
12269
|
-
btn.appendChild(
|
|
13796
|
+
btn.appendChild(text2);
|
|
12270
13797
|
btn.appendChild(caret);
|
|
12271
13798
|
return btn;
|
|
12272
13799
|
}
|
|
@@ -12321,13 +13848,13 @@ function makeOptionRow(tokens, opts) {
|
|
|
12321
13848
|
if (opts.selected) row.style.background = withAlpha2(c.accent, 0.14);
|
|
12322
13849
|
row.addEventListener("click", () => opts.onToggle());
|
|
12323
13850
|
}
|
|
12324
|
-
const
|
|
12325
|
-
|
|
12326
|
-
Object.assign(
|
|
13851
|
+
const text2 = document.createElement("span");
|
|
13852
|
+
text2.textContent = opts.label;
|
|
13853
|
+
Object.assign(text2.style, {
|
|
12327
13854
|
overflow: "hidden",
|
|
12328
13855
|
textOverflow: "ellipsis"
|
|
12329
13856
|
});
|
|
12330
|
-
row.appendChild(
|
|
13857
|
+
row.appendChild(text2);
|
|
12331
13858
|
return row;
|
|
12332
13859
|
}
|
|
12333
13860
|
function makeChip(tokens, label, active = false) {
|
|
@@ -12353,8 +13880,8 @@ function makeDualSlider(tokens, opts) {
|
|
|
12353
13880
|
const span = Math.max(1e-9, opts.max - opts.min);
|
|
12354
13881
|
const step = opts.step && opts.step > 0 ? opts.step : span / 100;
|
|
12355
13882
|
const fmt = opts.format ?? ((v) => String(Math.round(v)));
|
|
12356
|
-
let low =
|
|
12357
|
-
let high =
|
|
13883
|
+
let low = clamp9(opts.low, opts.min, opts.max);
|
|
13884
|
+
let high = clamp9(opts.high, low, opts.max);
|
|
12358
13885
|
const wrap = document.createElement("div");
|
|
12359
13886
|
Object.assign(wrap.style, {
|
|
12360
13887
|
position: "relative",
|
|
@@ -12448,7 +13975,7 @@ function makeDualSlider(tokens, opts) {
|
|
|
12448
13975
|
paint();
|
|
12449
13976
|
const quantize = (v) => {
|
|
12450
13977
|
const snapped = Math.round((v - opts.min) / step) * step + opts.min;
|
|
12451
|
-
return
|
|
13978
|
+
return clamp9(snapped, opts.min, opts.max);
|
|
12452
13979
|
};
|
|
12453
13980
|
const dragThumb = (which, startEvent) => {
|
|
12454
13981
|
startEvent.preventDefault();
|
|
@@ -12456,8 +13983,8 @@ function makeDualSlider(tokens, opts) {
|
|
|
12456
13983
|
const move = (e) => {
|
|
12457
13984
|
const ratio = rect.width <= 0 ? 0 : (e.clientX - rect.left) / rect.width;
|
|
12458
13985
|
const v = quantize(opts.min + ratio * span);
|
|
12459
|
-
if (which === "low") low =
|
|
12460
|
-
else high =
|
|
13986
|
+
if (which === "low") low = clamp9(v, opts.min, high);
|
|
13987
|
+
else high = clamp9(v, low, opts.max);
|
|
12461
13988
|
paint();
|
|
12462
13989
|
opts.onInput?.(low, high);
|
|
12463
13990
|
};
|
|
@@ -12475,8 +14002,8 @@ function makeDualSlider(tokens, opts) {
|
|
|
12475
14002
|
const dir = e.key === "ArrowRight" || e.key === "ArrowUp" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : 0;
|
|
12476
14003
|
if (dir === 0) return;
|
|
12477
14004
|
e.preventDefault();
|
|
12478
|
-
if (which === "low") low =
|
|
12479
|
-
else high =
|
|
14005
|
+
if (which === "low") low = clamp9(quantize(low + dir * step), opts.min, high);
|
|
14006
|
+
else high = clamp9(quantize(high + dir * step), low, opts.max);
|
|
12480
14007
|
paint();
|
|
12481
14008
|
opts.onInput?.(low, high);
|
|
12482
14009
|
opts.onChange(low, high);
|
|
@@ -12486,8 +14013,8 @@ function makeDualSlider(tokens, opts) {
|
|
|
12486
14013
|
return {
|
|
12487
14014
|
el: wrap,
|
|
12488
14015
|
set(nextLow, nextHigh) {
|
|
12489
|
-
low =
|
|
12490
|
-
high =
|
|
14016
|
+
low = clamp9(nextLow, opts.min, opts.max);
|
|
14017
|
+
high = clamp9(nextHigh, low, opts.max);
|
|
12491
14018
|
paint();
|
|
12492
14019
|
}
|
|
12493
14020
|
};
|
|
@@ -12513,11 +14040,11 @@ function positionPopover(pop, anchor) {
|
|
|
12513
14040
|
pop.style.maxHeight = `${Math.max(80, spaceAbove - gap)}px`;
|
|
12514
14041
|
}
|
|
12515
14042
|
}
|
|
12516
|
-
function
|
|
14043
|
+
function clamp9(v, lo, hi) {
|
|
12517
14044
|
return v < lo ? lo : v > hi ? hi : v;
|
|
12518
14045
|
}
|
|
12519
14046
|
function thumbLeftCss(pct) {
|
|
12520
|
-
const fraction =
|
|
14047
|
+
const fraction = clamp9(pct, 0, 100) / 100;
|
|
12521
14048
|
return `calc(8px + ${fraction} * (100% - 16px))`;
|
|
12522
14049
|
}
|
|
12523
14050
|
function withAlpha2(color, alpha) {
|
|
@@ -12781,7 +14308,7 @@ function drawSearchCanvas(surface, s, tokens, size, context) {
|
|
|
12781
14308
|
const rect = drawTitleBlock(surface, tokens, size, s.title ?? slicerLabel(s));
|
|
12782
14309
|
const current = currentValue(s, context);
|
|
12783
14310
|
const value = current?.kind === "text" ? current.query : "";
|
|
12784
|
-
const
|
|
14311
|
+
const text2 = value || (s.placeholder ?? `Search ${s.field}\u2026`);
|
|
12785
14312
|
const h = Math.min(42, Math.max(32, rect.height));
|
|
12786
14313
|
const y = rect.y + Math.max(0, (rect.height - h) * 0.12);
|
|
12787
14314
|
ctx.save();
|
|
@@ -12801,7 +14328,7 @@ function drawSearchCanvas(surface, s, tokens, size, context) {
|
|
|
12801
14328
|
paintCanvasText(ctx, {
|
|
12802
14329
|
x: rect.x + tokens.spacing.sm,
|
|
12803
14330
|
y: y + h / 2,
|
|
12804
|
-
text,
|
|
14331
|
+
text: text2,
|
|
12805
14332
|
font,
|
|
12806
14333
|
color: value ? tokens.color.text : tokens.color.textMuted,
|
|
12807
14334
|
size: tokens.font.size.base,
|
|
@@ -13908,71 +15435,67 @@ function drawRightAxisLabels(surface, model) {
|
|
|
13908
15435
|
const f = tokens.font;
|
|
13909
15436
|
const smallFont = fontString(f.size.small, f.family, f.weight.normal);
|
|
13910
15437
|
const labelLeft = plot.x + plot.width + TICK_SIZE + 4;
|
|
13911
|
-
|
|
13912
|
-
|
|
13913
|
-
|
|
13914
|
-
|
|
13915
|
-
|
|
13916
|
-
|
|
13917
|
-
|
|
13918
|
-
|
|
13919
|
-
|
|
13920
|
-
|
|
13921
|
-
|
|
13922
|
-
|
|
13923
|
-
|
|
13924
|
-
|
|
13925
|
-
|
|
13926
|
-
|
|
13927
|
-
|
|
13928
|
-
|
|
13929
|
-
|
|
13930
|
-
left: frame.width - Math.round(f.size.base * 0.4),
|
|
13931
|
-
top: plot.y + plot.height / 2,
|
|
13932
|
-
text: model.right.title,
|
|
13933
|
-
color: tokens.color.text,
|
|
13934
|
-
size: f.size.base,
|
|
13935
|
-
transform: "translate(-50%, -50%) rotate(90deg)"
|
|
13936
|
-
},
|
|
13937
|
-
titleFont
|
|
13938
|
-
)
|
|
13939
|
-
);
|
|
13940
|
-
}
|
|
13941
|
-
return;
|
|
13942
|
-
}
|
|
13943
|
-
const overlay = surface.overlay;
|
|
13944
|
-
for (const t of model.right.ticks) {
|
|
13945
|
-
const el = document.createElement("div");
|
|
13946
|
-
el.textContent = t.label;
|
|
13947
|
-
el.style.position = "absolute";
|
|
13948
|
-
el.style.left = `${labelLeft}px`;
|
|
13949
|
-
el.style.top = `${t.pos}px`;
|
|
13950
|
-
el.style.font = smallFont;
|
|
13951
|
-
el.style.fontSize = `${f.size.small}px`;
|
|
13952
|
-
el.style.color = tokens.color.textMuted;
|
|
13953
|
-
el.style.whiteSpace = "nowrap";
|
|
13954
|
-
el.style.transform = "translateY(-50%)";
|
|
13955
|
-
el.style.pointerEvents = "none";
|
|
13956
|
-
overlay.appendChild(el);
|
|
15438
|
+
const labelBudget = Math.max(0, frame.originX + frame.width - labelLeft - 2);
|
|
15439
|
+
const readable = (text2, maxWidth) => maxWidth > 0 && (measureText(text2, smallFont).width <= maxWidth || ellipsize(text2, maxWidth, smallFont).length / Math.max(1, text2.length) >= 0.5);
|
|
15440
|
+
const ticks2 = model.right.ticks.filter((t) => readable(t.label, labelBudget));
|
|
15441
|
+
const minGap = f.size.small * 1.35;
|
|
15442
|
+
const keptTicks = [];
|
|
15443
|
+
for (const t of ticks2) {
|
|
15444
|
+
if (keptTicks.every((k) => Math.abs(k.pos - t.pos) >= minGap)) keptTicks.push(t);
|
|
15445
|
+
}
|
|
15446
|
+
for (const t of keptTicks) {
|
|
15447
|
+
addOverlayText(surface, tokens, {
|
|
15448
|
+
left: labelLeft,
|
|
15449
|
+
top: t.pos,
|
|
15450
|
+
text: t.label,
|
|
15451
|
+
color: tokens.color.textMuted,
|
|
15452
|
+
size: f.size.small,
|
|
15453
|
+
transform: "translateY(-50%)",
|
|
15454
|
+
maxWidth: labelBudget,
|
|
15455
|
+
role: "axis-label"
|
|
15456
|
+
});
|
|
13957
15457
|
}
|
|
13958
15458
|
if (model.right.title) {
|
|
13959
|
-
|
|
13960
|
-
|
|
13961
|
-
|
|
13962
|
-
|
|
13963
|
-
|
|
13964
|
-
|
|
13965
|
-
|
|
13966
|
-
|
|
13967
|
-
|
|
13968
|
-
|
|
13969
|
-
|
|
13970
|
-
el.style.pointerEvents = "none";
|
|
13971
|
-
overlay.appendChild(el);
|
|
15459
|
+
addOverlayText(surface, tokens, {
|
|
15460
|
+
left: frame.width - Math.round(f.size.base * 0.4),
|
|
15461
|
+
top: plot.y + plot.height / 2,
|
|
15462
|
+
text: model.right.title,
|
|
15463
|
+
color: tokens.color.text,
|
|
15464
|
+
size: f.size.base,
|
|
15465
|
+
weight: f.weight.medium,
|
|
15466
|
+
transform: "translate(-50%, -50%) rotate(90deg)",
|
|
15467
|
+
maxWidth: plot.height,
|
|
15468
|
+
role: "axis-title"
|
|
15469
|
+
});
|
|
13972
15470
|
}
|
|
13973
15471
|
}
|
|
15472
|
+
function prepareAxisLabels3(model) {
|
|
15473
|
+
if (model.frame.density === "compact") {
|
|
15474
|
+
model.base.spec.encoding.y.title = void 0;
|
|
15475
|
+
if (model.base.spec.axes?.y) model.base.spec.axes.y.title = void 0;
|
|
15476
|
+
if (model.right) model.right.title = void 0;
|
|
15477
|
+
}
|
|
15478
|
+
if (model.x.kind !== "band" || model.xTicks.length <= 1) return;
|
|
15479
|
+
const { font, spacing } = model.tokens;
|
|
15480
|
+
const smallFont = fontString(font.size.small, font.family, font.weight.normal);
|
|
15481
|
+
const budget = Math.max(0, model.frame.xLabelMaxWidth);
|
|
15482
|
+
const readable = (text2) => budget > 0 && (measureText(text2, smallFont).width <= budget || ellipsize(text2, budget, smallFont).length / Math.max(1, text2.length) >= 0.5);
|
|
15483
|
+
model.xTicks = model.xTicks.filter((t) => readable(t.label));
|
|
15484
|
+
model.base.xTicks = model.xTicks;
|
|
15485
|
+
model.frame.xLabelAngle = 0;
|
|
15486
|
+
const widest = model.xTicks.reduce((m, t) => Math.max(m, measureText(t.label, smallFont).width), 0);
|
|
15487
|
+
const slot = model.plot.width / Math.max(1, model.xTicks.length);
|
|
15488
|
+
const minStep = Math.max(font.size.small * 1.35, widest + spacing.sm);
|
|
15489
|
+
if (slot < minStep) {
|
|
15490
|
+
const stride = Math.ceil(minStep / Math.max(slot, 1e-6));
|
|
15491
|
+
model.xTicks = model.xTicks.filter((_, i) => i % stride === 0);
|
|
15492
|
+
model.base.xTicks = model.xTicks;
|
|
15493
|
+
}
|
|
15494
|
+
for (const lm of model.layers) lm.model.xTicks = model.xTicks;
|
|
15495
|
+
}
|
|
13974
15496
|
function drawCombo(surface, spec, tokens, size, context) {
|
|
13975
15497
|
const model = buildComboModel(spec, tokens, size);
|
|
15498
|
+
prepareAxisLabels3(model);
|
|
13976
15499
|
const emphasis = context?.emphasis ?? null;
|
|
13977
15500
|
for (const lm of model.layers) lm.model.emphasis = emphasis;
|
|
13978
15501
|
drawAxesUnderlay(surface, model.base);
|
|
@@ -14293,9 +15816,9 @@ function colorLeaves(leaves, treemap, tokens) {
|
|
|
14293
15816
|
const fallback = parseColor(tokens.color.surface) ?? parseColor(tokens.color.palette[0] ?? "#14b8a6") ?? { r: 20, g: 184, b: 166, a: 1 };
|
|
14294
15817
|
const color = treemap.encoding.color;
|
|
14295
15818
|
const ordinal = ordinalColorScale({ palette: tokens.color.palette });
|
|
14296
|
-
const
|
|
14297
|
-
const numericColor = Boolean(color && (color.type === "quantitative" ||
|
|
14298
|
-
const domain =
|
|
15819
|
+
const numericValues2 = color ? leaves.map((leaf) => leaf.colorNumber).filter((n) => n !== void 0) : [];
|
|
15820
|
+
const numericColor = Boolean(color && (color.type === "quantitative" || numericValues2.length > 0 && numericValues2.length === leaves.length));
|
|
15821
|
+
const domain = numericValues2.length ? [Math.min(...numericValues2), Math.max(...numericValues2)] : [0, 1];
|
|
14299
15822
|
const continuous = sequentialColorScale({ domain, interpolator: sequential(treemap.scheme ?? "teal") });
|
|
14300
15823
|
for (const leaf of leaves) {
|
|
14301
15824
|
let fill = fallback;
|
|
@@ -14508,7 +16031,7 @@ function drawTreemap(surface, spec, tokens, size, context) {
|
|
|
14508
16031
|
}
|
|
14509
16032
|
|
|
14510
16033
|
// src/charts/gauge.ts
|
|
14511
|
-
function
|
|
16034
|
+
function clamp10(value, min, max) {
|
|
14512
16035
|
return Math.max(min, Math.min(max, value));
|
|
14513
16036
|
}
|
|
14514
16037
|
function valueField2(ref) {
|
|
@@ -14551,22 +16074,22 @@ function strokePoints(ctx, pen, points, color, width, cap2 = "butt") {
|
|
|
14551
16074
|
ctx.stroke();
|
|
14552
16075
|
ctx.restore();
|
|
14553
16076
|
}
|
|
14554
|
-
function drawCenteredMessage(surface, tokens, rect,
|
|
16077
|
+
function drawCenteredMessage(surface, tokens, rect, text2) {
|
|
14555
16078
|
addOverlayText(surface, tokens, {
|
|
14556
16079
|
left: rect.x,
|
|
14557
16080
|
top: rect.y + Math.max(CHROME_PAD.top, rect.height / 2 - tokens.font.size.small / 2),
|
|
14558
16081
|
width: rect.width,
|
|
14559
|
-
text,
|
|
16082
|
+
text: text2,
|
|
14560
16083
|
color: tokens.color.textMuted,
|
|
14561
16084
|
size: tokens.font.size.small,
|
|
14562
16085
|
align: "center"
|
|
14563
16086
|
});
|
|
14564
16087
|
}
|
|
14565
|
-
var
|
|
16088
|
+
var LEGEND_SWATCH4 = 11;
|
|
14566
16089
|
function measureLegendWidth(entries, font) {
|
|
14567
16090
|
let maxLabel = 0;
|
|
14568
16091
|
for (const e of entries) maxLabel = Math.max(maxLabel, measureText(e.label, font).width);
|
|
14569
|
-
return Math.ceil(
|
|
16092
|
+
return Math.ceil(LEGEND_SWATCH4 + 6 + maxLabel + 4);
|
|
14570
16093
|
}
|
|
14571
16094
|
function drawGaugeLegend(surface, tokens, area2, entries) {
|
|
14572
16095
|
if (entries.length === 0) return;
|
|
@@ -14580,9 +16103,9 @@ function drawGaugeLegend(surface, tokens, area2, entries) {
|
|
|
14580
16103
|
const ctx = surface.marks.ctx;
|
|
14581
16104
|
for (const e of entries) {
|
|
14582
16105
|
const midY = y + rowH / 2;
|
|
14583
|
-
paintLegendSwatch(ctx, x, midY, "square", e.color,
|
|
16106
|
+
paintLegendSwatch(ctx, x, midY, "square", e.color, LEGEND_SWATCH4);
|
|
14584
16107
|
paintCanvasText(ctx, {
|
|
14585
|
-
x: x +
|
|
16108
|
+
x: x + LEGEND_SWATCH4 + 6,
|
|
14586
16109
|
y: midY,
|
|
14587
16110
|
text: e.label,
|
|
14588
16111
|
font,
|
|
@@ -14605,8 +16128,8 @@ function drawGaugeLegend(surface, tokens, area2, entries) {
|
|
|
14605
16128
|
row.style.pointerEvents = "none";
|
|
14606
16129
|
const swatch = document.createElement("span");
|
|
14607
16130
|
swatch.style.flex = "0 0 auto";
|
|
14608
|
-
swatch.style.width = `${
|
|
14609
|
-
swatch.style.height = `${
|
|
16131
|
+
swatch.style.width = `${LEGEND_SWATCH4}px`;
|
|
16132
|
+
swatch.style.height = `${LEGEND_SWATCH4}px`;
|
|
14610
16133
|
swatch.style.borderRadius = "2px";
|
|
14611
16134
|
swatch.style.background = e.color;
|
|
14612
16135
|
const label = document.createElement("span");
|
|
@@ -14670,14 +16193,14 @@ function drawGauge(surface, spec, tokens, size, context) {
|
|
|
14670
16193
|
const dialW = Math.max(40, content.width - legendW);
|
|
14671
16194
|
const availH = Math.max(0, content.height - captionReserve);
|
|
14672
16195
|
const provR = Math.max(18, Math.min(dialW / 2, availH));
|
|
14673
|
-
const trackWidth =
|
|
14674
|
-
const edgeGuard = trackWidth / 2 +
|
|
16196
|
+
const trackWidth = clamp10(provR * 0.17, 14, 54);
|
|
16197
|
+
const edgeGuard = trackWidth / 2 + clamp10(trackWidth * 0.3, 4, 10) + 1;
|
|
14675
16198
|
const radius = Math.max(18, Math.min(provR, dialW / 2 - edgeGuard, availH - edgeGuard));
|
|
14676
16199
|
const cx = content.x + dialW / 2;
|
|
14677
16200
|
const blockH = radius + trackWidth / 2 + captionReserve;
|
|
14678
16201
|
const topMargin = Math.max(0, (content.height - blockH) / 2);
|
|
14679
16202
|
const cy = content.y + topMargin + radius + trackWidth / 2;
|
|
14680
|
-
const toAngle = (v) => Math.PI -
|
|
16203
|
+
const toAngle = (v) => Math.PI - clamp10((v - min) / (max - min), 0, 1) * Math.PI;
|
|
14681
16204
|
const sweepT = context?.sweepProgress ?? 1;
|
|
14682
16205
|
const from = context?.valueFrom ?? min;
|
|
14683
16206
|
const displayValue = from + (value - from) * sweepT;
|
|
@@ -14688,7 +16211,7 @@ function drawGauge(surface, spec, tokens, size, context) {
|
|
|
14688
16211
|
strokePoints(ctx, pen, arcPoints(cx, cy, radius, Math.PI, valueAngle), fill, trackWidth);
|
|
14689
16212
|
}
|
|
14690
16213
|
if (compare2 != null) {
|
|
14691
|
-
const innerWidth =
|
|
16214
|
+
const innerWidth = clamp10(trackWidth * 0.42, 6, 22);
|
|
14692
16215
|
const innerRadius = Math.max(radius * 0.35, radius - trackWidth / 2 - 2 - innerWidth / 2);
|
|
14693
16216
|
const compareFrom = context?.compareFrom ?? min;
|
|
14694
16217
|
const displayCompare = compareFrom + (compare2 - compareFrom) * sweepT;
|
|
@@ -14699,7 +16222,7 @@ function drawGauge(surface, spec, tokens, size, context) {
|
|
|
14699
16222
|
}
|
|
14700
16223
|
if (targetMark != null) {
|
|
14701
16224
|
const a = toAngle(targetMark);
|
|
14702
|
-
const overhang =
|
|
16225
|
+
const overhang = clamp10(trackWidth * 0.3, 4, 10);
|
|
14703
16226
|
const outer = pointOnArc(cx, cy, radius + trackWidth / 2 + overhang, a);
|
|
14704
16227
|
const inner = pointOnArc(cx, cy, radius - trackWidth / 2 - overhang, a);
|
|
14705
16228
|
strokePoints(ctx, pen, [outer, inner], markColor, 3, "round");
|
|
@@ -14707,7 +16230,7 @@ function drawGauge(surface, spec, tokens, size, context) {
|
|
|
14707
16230
|
if (legendEntries.length) {
|
|
14708
16231
|
drawGaugeLegend(surface, tokens, { x: content.x, y: content.y, width: content.width, height: content.height }, legendEntries);
|
|
14709
16232
|
}
|
|
14710
|
-
const valueSize =
|
|
16233
|
+
const valueSize = clamp10(radius * 0.5, 22, 72);
|
|
14711
16234
|
addOverlayText(surface, tokens, {
|
|
14712
16235
|
left: content.x,
|
|
14713
16236
|
top: cy - radius * 0.32 - valueSize / 2,
|
|
@@ -14750,7 +16273,7 @@ function drawGauge(surface, spec, tokens, size, context) {
|
|
|
14750
16273
|
}
|
|
14751
16274
|
|
|
14752
16275
|
// src/charts/bullet.ts
|
|
14753
|
-
function
|
|
16276
|
+
function clamp11(value, min, max) {
|
|
14754
16277
|
return Math.max(min, Math.min(max, value));
|
|
14755
16278
|
}
|
|
14756
16279
|
function titleText2(title2) {
|
|
@@ -14815,11 +16338,11 @@ function drawBullet(surface, spec, tokens, size, context) {
|
|
|
14815
16338
|
}
|
|
14816
16339
|
const labelFont = fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.bold);
|
|
14817
16340
|
const valueFont = fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.normal);
|
|
14818
|
-
const labelW =
|
|
16341
|
+
const labelW = clamp11(measureText(caption, labelFont).width + 14, 44, Math.max(72, content.width * 0.4));
|
|
14819
16342
|
const axisReserve = Math.max(24, tokens.font.size.tiny * 2);
|
|
14820
16343
|
const availableTrackW = Math.max(0, content.width - labelW);
|
|
14821
16344
|
const trackW = Math.max(0, availableTrackW - 4);
|
|
14822
|
-
const trackH =
|
|
16345
|
+
const trackH = clamp11(content.height * 0.22, 28, 40);
|
|
14823
16346
|
const trackX = content.x + labelW + 4;
|
|
14824
16347
|
const trackY = content.y + Math.max(0, (content.height - axisReserve - trackH) / 2);
|
|
14825
16348
|
const track = { x: trackX, y: trackY, width: trackW, height: trackH };
|
|
@@ -14839,7 +16362,7 @@ function drawBullet(surface, spec, tokens, size, context) {
|
|
|
14839
16362
|
b: greyStart.b + (greyEnd.b - greyStart.b) * t,
|
|
14840
16363
|
a: 1
|
|
14841
16364
|
});
|
|
14842
|
-
const xOf = (v) =>
|
|
16365
|
+
const xOf = (v) => clamp11(bulletValueToX(v, min, max, track), track.x, track.x + track.width);
|
|
14843
16366
|
addOverlayText(surface, tokens, {
|
|
14844
16367
|
left: content.x,
|
|
14845
16368
|
top: track.y + track.height / 2 - tokens.font.size.small / 2,
|
|
@@ -14880,7 +16403,7 @@ function drawBullet(surface, spec, tokens, size, context) {
|
|
|
14880
16403
|
ctx.restore();
|
|
14881
16404
|
const valueText = formatValue(value, fmt);
|
|
14882
16405
|
const valueLabelW = measureText(valueText, valueFont).width + 14;
|
|
14883
|
-
const valueLeft =
|
|
16406
|
+
const valueLeft = clamp11(xOf(value) - valueLabelW / 2, content.x, content.x + content.width - valueLabelW);
|
|
14884
16407
|
addOverlayText(surface, tokens, {
|
|
14885
16408
|
left: valueLeft,
|
|
14886
16409
|
top: Math.max(content.y, barY - tokens.font.size.small - 6),
|
|
@@ -14896,7 +16419,7 @@ function drawBullet(surface, spec, tokens, size, context) {
|
|
|
14896
16419
|
const tx = xOf(tick);
|
|
14897
16420
|
drawLine2(ctx, tx, track.y + track.height, tx, track.y + track.height + 4, tokens.color.textMuted, 1);
|
|
14898
16421
|
addOverlayText(surface, tokens, {
|
|
14899
|
-
left:
|
|
16422
|
+
left: clamp11(tx - tickW / 2, track.x, track.x + track.width - tickW),
|
|
14900
16423
|
top: track.y + track.height + 6,
|
|
14901
16424
|
text: tickText,
|
|
14902
16425
|
color: tokens.color.textMuted,
|
|
@@ -15286,7 +16809,10 @@ function buildWaterfallModel(spec, tokens, opts) {
|
|
|
15286
16809
|
const yLabels = yTickValues.map((v) => fmt ? formatValue(v, fmt) : formatNumber2(v, ","));
|
|
15287
16810
|
const categories = bars.map((b) => b.label);
|
|
15288
16811
|
const padding = { ...NO_PADDING, ...spec.padding };
|
|
15289
|
-
const
|
|
16812
|
+
const xFont = fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.normal);
|
|
16813
|
+
const widestXLabel = categories.length ? Math.max(...categories.map((c) => measureText(c, xFont).width)) : 0;
|
|
16814
|
+
const readableXLabels = categories.length <= Math.max(2, Math.floor(opts.width / 52)) && widestXLabel <= opts.width * 0.35;
|
|
16815
|
+
const xShow = spec.axes?.x?.show !== false && readableXLabels;
|
|
15290
16816
|
const yShow = spec.axes?.y?.show !== false;
|
|
15291
16817
|
const xTitle = spec.axes?.x?.title ?? spec.encoding.stage.title;
|
|
15292
16818
|
const yTitle = spec.axes?.y?.title ?? spec.encoding.value.title;
|
|
@@ -15331,7 +16857,10 @@ function buildWaterfallModel(spec, tokens, opts) {
|
|
|
15331
16857
|
y: yPixel(a.cumulative)
|
|
15332
16858
|
});
|
|
15333
16859
|
}
|
|
15334
|
-
const baseAxes = {
|
|
16860
|
+
const baseAxes = {
|
|
16861
|
+
...spec.axes,
|
|
16862
|
+
x: { ...spec.axes?.x, grid: false, show: xShow, labels: xShow ? spec.axes?.x?.labels : false }
|
|
16863
|
+
};
|
|
15335
16864
|
const baseSpec = {
|
|
15336
16865
|
type: "bar",
|
|
15337
16866
|
data,
|
|
@@ -15458,23 +16987,42 @@ function barLabelText(bar, format) {
|
|
|
15458
16987
|
const body = format ? formatValue(bar.delta, format) : formatNumber2(bar.delta, ",");
|
|
15459
16988
|
return bar.delta > 0 ? `+${body}` : body;
|
|
15460
16989
|
}
|
|
16990
|
+
function clamp12(value, min, max) {
|
|
16991
|
+
if (max < min) return min;
|
|
16992
|
+
return Math.max(min, Math.min(max, value));
|
|
16993
|
+
}
|
|
16994
|
+
function boxesOverlap2(a, b) {
|
|
16995
|
+
return a.x + a.width > b.x + 2 && b.x + b.width > a.x + 2 && a.y + a.height > b.y + 2 && b.y + b.height > a.y + 2;
|
|
16996
|
+
}
|
|
15461
16997
|
function drawLabels(surface, model) {
|
|
15462
16998
|
if (!model.showLabels) return;
|
|
15463
16999
|
const { tokens, plot } = model;
|
|
15464
17000
|
const size = tokens.font.size.tiny;
|
|
17001
|
+
const lineH = size * 1.2;
|
|
17002
|
+
const font = fontString(size, tokens.font.family, tokens.font.weight.medium);
|
|
17003
|
+
const placed = [];
|
|
15465
17004
|
for (const bar of model.bars) {
|
|
15466
17005
|
if (bar.rect.width <= 0 || bar.rect.height <= 0) continue;
|
|
15467
|
-
const
|
|
15468
|
-
const
|
|
17006
|
+
const text2 = barLabelText(bar, model.format);
|
|
17007
|
+
const textW = measureText(text2, font).width;
|
|
17008
|
+
const budget = Math.min(Math.max(0, bar.rect.width + 12), Math.max(0, surface.width - 2));
|
|
17009
|
+
if (budget < 12 || textW > budget && budget < textW * 0.5) continue;
|
|
17010
|
+
const left = clamp12(bar.rect.x + bar.rect.width / 2 - budget / 2, 1, surface.width - budget - 1);
|
|
17011
|
+
const above = bar.rect.y - lineH - 3;
|
|
17012
|
+
const top = above >= plot.y ? above : clamp12(bar.rect.y + 2, plot.y, plot.y + plot.height - lineH);
|
|
17013
|
+
const box = { x: left, y: top, width: Math.min(textW, budget), height: lineH };
|
|
17014
|
+
if (placed.some((p) => boxesOverlap2(p, box))) continue;
|
|
17015
|
+
placed.push(box);
|
|
15469
17016
|
addOverlayText(surface, tokens, {
|
|
15470
|
-
left
|
|
17017
|
+
left,
|
|
15471
17018
|
top,
|
|
15472
|
-
width:
|
|
15473
|
-
text,
|
|
17019
|
+
width: budget,
|
|
17020
|
+
text: text2,
|
|
15474
17021
|
color: tokens.color.text,
|
|
15475
17022
|
size,
|
|
15476
17023
|
weight: tokens.font.weight.medium,
|
|
15477
|
-
align: "center"
|
|
17024
|
+
align: "center",
|
|
17025
|
+
maxWidth: budget
|
|
15478
17026
|
});
|
|
15479
17027
|
}
|
|
15480
17028
|
}
|
|
@@ -15593,13 +17141,18 @@ function buildSlopeModel(spec, tokens, opts) {
|
|
|
15593
17141
|
);
|
|
15594
17142
|
rightLabelW = Math.max(rightLabelW, measureText(fmtTick2(lastV, fmt), normTiny).width);
|
|
15595
17143
|
}
|
|
15596
|
-
|
|
15597
|
-
|
|
17144
|
+
const maxLeft = Math.max(18, Math.floor(opts.width * 0.28));
|
|
17145
|
+
const maxRight = Math.max(18, Math.floor(opts.width * 0.18));
|
|
17146
|
+
leftLabelW = Math.max(18, Math.min(maxLeft, Math.ceil(leftLabelW)));
|
|
17147
|
+
rightLabelW = Math.max(18, Math.min(maxRight, Math.ceil(rightLabelW)));
|
|
15598
17148
|
}
|
|
15599
|
-
const labelBand = tokens.font.size.tiny +
|
|
17149
|
+
const labelBand = tokens.font.size.tiny * 2.4 + 8;
|
|
15600
17150
|
const basePadding = showLabels ? { top: labelBand, right: rightLabelW + 8, bottom: labelBand, left: leftLabelW + 8 } : { top: 0, right: 0, bottom: 0, left: 0 };
|
|
15601
17151
|
const padding = { ...basePadding, ...spec.padding };
|
|
15602
|
-
const
|
|
17152
|
+
const xFont = fontString(tokens.font.size.small, tokens.font.family, tokens.font.weight.normal);
|
|
17153
|
+
const widestXLabel = categories.length ? Math.max(...categories.map((c) => measureText(c, xFont).width)) : 0;
|
|
17154
|
+
const readableXLabels = categories.length <= Math.max(2, Math.floor(opts.width / 52)) && widestXLabel <= opts.width * 0.35;
|
|
17155
|
+
const xShow = spec.axes?.x?.show !== false && readableXLabels;
|
|
15603
17156
|
const yShow = spec.axes?.y?.show === true;
|
|
15604
17157
|
const xTitle = spec.axes?.x?.title ?? spec.encoding.x.title;
|
|
15605
17158
|
const yTitle = spec.axes?.y?.title ?? spec.encoding.y.title;
|
|
@@ -15609,7 +17162,7 @@ function buildSlopeModel(spec, tokens, opts) {
|
|
|
15609
17162
|
padding,
|
|
15610
17163
|
font: tokens.font,
|
|
15611
17164
|
title: titleInput2(spec),
|
|
15612
|
-
xAxis: { show: xShow, labels: categories, title: xTitle, edgeAnchored:
|
|
17165
|
+
xAxis: { show: xShow, labels: categories, title: xTitle, edgeAnchored: categories.length <= 2 },
|
|
15613
17166
|
yAxis: { show: yShow, labels: yLabels, title: yTitle }
|
|
15614
17167
|
});
|
|
15615
17168
|
const plot = frame.plot;
|
|
@@ -15632,7 +17185,11 @@ function buildSlopeModel(spec, tokens, opts) {
|
|
|
15632
17185
|
}
|
|
15633
17186
|
return { key: group.key, label: group.label, value: group.value, color, points };
|
|
15634
17187
|
});
|
|
15635
|
-
const baseAxes = {
|
|
17188
|
+
const baseAxes = {
|
|
17189
|
+
...spec.axes,
|
|
17190
|
+
x: { ...spec.axes?.x, show: xShow, labels: xShow ? spec.axes?.x?.labels : false },
|
|
17191
|
+
y: { ...spec.axes?.y, grid: false, show: false, labels: false }
|
|
17192
|
+
};
|
|
15636
17193
|
const baseSpec = {
|
|
15637
17194
|
type: "line",
|
|
15638
17195
|
data,
|
|
@@ -15693,9 +17250,13 @@ function buildSlopeModel(spec, tokens, opts) {
|
|
|
15693
17250
|
|
|
15694
17251
|
// src/charts/slope.ts
|
|
15695
17252
|
var HIT_RADIUS2 = 8;
|
|
17253
|
+
var LABEL_GAP = 3;
|
|
15696
17254
|
function fmtValue(v, format) {
|
|
15697
17255
|
return format ? formatValue(v, format) : formatNumber2(v, ",");
|
|
15698
17256
|
}
|
|
17257
|
+
function retainsHalf(text2, tokens, size, weight, budget) {
|
|
17258
|
+
return measureText(text2, fontString(size, tokens.font.family, weight)).width <= budget * 2;
|
|
17259
|
+
}
|
|
15699
17260
|
function drawLines(surface, model, alphaOf) {
|
|
15700
17261
|
const ctx = surface.marks.ctx;
|
|
15701
17262
|
const pen = model.sketch ? new RoughPen(ctx, model.sketch) : null;
|
|
@@ -15739,6 +17300,28 @@ function drawLines(surface, model, alphaOf) {
|
|
|
15739
17300
|
}
|
|
15740
17301
|
ctx.restore();
|
|
15741
17302
|
}
|
|
17303
|
+
function placeBlocks(blocks, minY, maxY) {
|
|
17304
|
+
const sorted = blocks.slice().sort((a, b) => a.desired - b.desired);
|
|
17305
|
+
if (sorted.length === 0 || maxY <= minY) return [];
|
|
17306
|
+
const height = Math.max(...sorted.map((b) => b.height));
|
|
17307
|
+
const capacity = Math.max(1, Math.floor((maxY - minY + LABEL_GAP) / (height + LABEL_GAP)));
|
|
17308
|
+
const candidates = sorted.length <= capacity ? sorted : sorted.filter((_b, i) => {
|
|
17309
|
+
if (capacity === 1) return i === Math.floor((sorted.length - 1) / 2);
|
|
17310
|
+
const pick = Math.round(i * (capacity - 1) / (sorted.length - 1));
|
|
17311
|
+
return Math.round(pick * (sorted.length - 1) / (capacity - 1)) === i;
|
|
17312
|
+
});
|
|
17313
|
+
let cursor = minY;
|
|
17314
|
+
const placed = candidates.map((block) => {
|
|
17315
|
+
const top = Math.max(cursor, Math.min(block.desired - block.height / 2, maxY - block.height));
|
|
17316
|
+
cursor = top + block.height + LABEL_GAP;
|
|
17317
|
+
return { ...block, top };
|
|
17318
|
+
});
|
|
17319
|
+
const overflow = placed.length ? placed[placed.length - 1].top + placed[placed.length - 1].height - maxY : 0;
|
|
17320
|
+
if (overflow > 0) {
|
|
17321
|
+
for (const block of placed) block.top = Math.max(minY, block.top - overflow);
|
|
17322
|
+
}
|
|
17323
|
+
return placed;
|
|
17324
|
+
}
|
|
15742
17325
|
function drawEndLabels(surface, model, alphaOf) {
|
|
15743
17326
|
if (!model.showLabels) return;
|
|
15744
17327
|
const { tokens } = model;
|
|
@@ -15746,46 +17329,75 @@ function drawEndLabels(surface, model, alphaOf) {
|
|
|
15746
17329
|
const valueSize = tokens.font.size.tiny;
|
|
15747
17330
|
const leftW = model.leftLabelW;
|
|
15748
17331
|
const rightW = model.rightLabelW;
|
|
17332
|
+
const minY = model.plot.y;
|
|
17333
|
+
const maxY = model.plot.y + model.plot.height;
|
|
17334
|
+
const leftLineH = labelSize * 1.2;
|
|
17335
|
+
const valueLineH = valueSize * 1.2;
|
|
17336
|
+
const leftBlocks = [];
|
|
17337
|
+
const rightBlocks = [];
|
|
15749
17338
|
for (const series of model.series) {
|
|
15750
17339
|
if (series.points.length === 0) continue;
|
|
15751
|
-
const opacity = alphaOf(series) === 1 ? 1 : 0.5;
|
|
15752
17340
|
const first = series.points[0];
|
|
15753
17341
|
const last = series.points[series.points.length - 1];
|
|
15754
|
-
|
|
15755
|
-
|
|
15756
|
-
|
|
15757
|
-
|
|
15758
|
-
|
|
15759
|
-
|
|
15760
|
-
|
|
15761
|
-
|
|
15762
|
-
|
|
15763
|
-
|
|
15764
|
-
|
|
15765
|
-
addOverlayText(surface, tokens, {
|
|
15766
|
-
left: first.x - 8 - leftW,
|
|
15767
|
-
top: first.y + 1,
|
|
15768
|
-
width: leftW,
|
|
15769
|
-
text: fmtValue(first.value, model.format),
|
|
15770
|
-
color: tokens.color.textMuted,
|
|
15771
|
-
size: valueSize,
|
|
15772
|
-
align: "right",
|
|
15773
|
-
opacity
|
|
15774
|
-
});
|
|
15775
|
-
if (last !== first) {
|
|
17342
|
+
leftBlocks.push({ item: series, desired: first.y, height: leftLineH + valueLineH + 3 });
|
|
17343
|
+
if (last !== first) rightBlocks.push({ item: series, desired: last.y, height: valueLineH });
|
|
17344
|
+
}
|
|
17345
|
+
for (const block of placeBlocks(leftBlocks, minY, maxY)) {
|
|
17346
|
+
const series = block.item;
|
|
17347
|
+
const opacity = alphaOf(series) === 1 ? 1 : 0.5;
|
|
17348
|
+
const first = series.points[0];
|
|
17349
|
+
const left = Math.max(0, first.x - 8 - leftW);
|
|
17350
|
+
const budget = Math.max(0, first.x - 8 - left);
|
|
17351
|
+
if (budget < 12) continue;
|
|
17352
|
+
if (retainsHalf(series.label, tokens, labelSize, tokens.font.weight.bold, budget)) {
|
|
15776
17353
|
addOverlayText(surface, tokens, {
|
|
15777
|
-
left
|
|
15778
|
-
top:
|
|
15779
|
-
width:
|
|
15780
|
-
text:
|
|
17354
|
+
left,
|
|
17355
|
+
top: block.top,
|
|
17356
|
+
width: budget,
|
|
17357
|
+
text: series.label,
|
|
15781
17358
|
color: series.color,
|
|
17359
|
+
size: labelSize,
|
|
17360
|
+
weight: tokens.font.weight.bold,
|
|
17361
|
+
align: "right",
|
|
17362
|
+
opacity,
|
|
17363
|
+
maxWidth: budget
|
|
17364
|
+
});
|
|
17365
|
+
}
|
|
17366
|
+
const firstValue = fmtValue(first.value, model.format);
|
|
17367
|
+
if (retainsHalf(firstValue, tokens, valueSize, tokens.font.weight.normal, budget)) {
|
|
17368
|
+
addOverlayText(surface, tokens, {
|
|
17369
|
+
left,
|
|
17370
|
+
top: block.top + leftLineH + 3,
|
|
17371
|
+
width: budget,
|
|
17372
|
+
text: firstValue,
|
|
17373
|
+
color: tokens.color.textMuted,
|
|
15782
17374
|
size: valueSize,
|
|
15783
|
-
|
|
15784
|
-
|
|
15785
|
-
|
|
17375
|
+
align: "right",
|
|
17376
|
+
opacity,
|
|
17377
|
+
maxWidth: budget
|
|
15786
17378
|
});
|
|
15787
17379
|
}
|
|
15788
17380
|
}
|
|
17381
|
+
for (const block of placeBlocks(rightBlocks, minY, maxY)) {
|
|
17382
|
+
if (model.series.length > 8) break;
|
|
17383
|
+
const series = block.item;
|
|
17384
|
+
const opacity = alphaOf(series) === 1 ? 1 : 0.5;
|
|
17385
|
+
const last = series.points[series.points.length - 1];
|
|
17386
|
+
const lastValue = fmtValue(last.value, model.format);
|
|
17387
|
+
if (!retainsHalf(lastValue, tokens, valueSize, tokens.font.weight.medium, rightW)) continue;
|
|
17388
|
+
addOverlayText(surface, tokens, {
|
|
17389
|
+
left: last.x + 8,
|
|
17390
|
+
top: block.top,
|
|
17391
|
+
width: rightW,
|
|
17392
|
+
text: lastValue,
|
|
17393
|
+
color: series.color,
|
|
17394
|
+
size: valueSize,
|
|
17395
|
+
weight: tokens.font.weight.medium,
|
|
17396
|
+
align: "left",
|
|
17397
|
+
opacity,
|
|
17398
|
+
maxWidth: rightW
|
|
17399
|
+
});
|
|
17400
|
+
}
|
|
15789
17401
|
}
|
|
15790
17402
|
function nearestPoint(model, px, py) {
|
|
15791
17403
|
let best = null;
|
|
@@ -15950,6 +17562,19 @@ function measureMax2(labels, font) {
|
|
|
15950
17562
|
function valueLabel(value, format) {
|
|
15951
17563
|
return format ? formatValue(value, format) : formatNumber2(value, ",");
|
|
15952
17564
|
}
|
|
17565
|
+
function clamp13(value, min, max) {
|
|
17566
|
+
if (max < min) return min;
|
|
17567
|
+
return Math.max(min, Math.min(max, value));
|
|
17568
|
+
}
|
|
17569
|
+
function limitedLegend(entries, width, tokens) {
|
|
17570
|
+
const maxItems = Math.max(2, Math.floor(width / 90));
|
|
17571
|
+
if (entries.length <= maxItems) return entries;
|
|
17572
|
+
const shown = Math.max(1, maxItems - 1);
|
|
17573
|
+
return [
|
|
17574
|
+
...entries.slice(0, shown),
|
|
17575
|
+
{ label: `+${entries.length - shown} more`, color: tokens.color.textMuted, symbol: "circle" }
|
|
17576
|
+
];
|
|
17577
|
+
}
|
|
15953
17578
|
function drawConnector(ctx, pen, x1, x2, y, width, tokens) {
|
|
15954
17579
|
if (pen) {
|
|
15955
17580
|
pen.polyline(
|
|
@@ -16011,7 +17636,7 @@ function drawDumbbell(surface, spec, tokens, size, context) {
|
|
|
16011
17636
|
const categoryField = dumbbell.encoding.category.field;
|
|
16012
17637
|
let content = drawTitleBlock(surface, tokens, size, dumbbell.title);
|
|
16013
17638
|
const legendEntries = model.groups.map((group) => ({ label: group.label, color: group.color, symbol: "circle" }));
|
|
16014
|
-
content = drawCategoricalLegend(surface, tokens, content, legendEntries, "top");
|
|
17639
|
+
content = drawCategoricalLegend(surface, tokens, content, limitedLegend(legendEntries, content.width, tokens), "top");
|
|
16015
17640
|
if (model.rows.length === 0 || model.groups.length === 0 || content.width <= 0 || content.height <= 0) {
|
|
16016
17641
|
addOverlayText(surface, tokens, {
|
|
16017
17642
|
left: content.x,
|
|
@@ -16045,26 +17670,37 @@ function drawDumbbell(surface, spec, tokens, size, context) {
|
|
|
16045
17670
|
ctx.save();
|
|
16046
17671
|
ctx.strokeStyle = tokens.color.grid;
|
|
16047
17672
|
ctx.lineWidth = 1;
|
|
17673
|
+
let tickRight = -Infinity;
|
|
17674
|
+
const tickFont = fontString(tokens.font.size.tiny, tokens.font.family, tokens.font.weight.normal);
|
|
16048
17675
|
for (const tick of ticks2) {
|
|
16049
17676
|
const x = xScale.map(tick);
|
|
16050
17677
|
ctx.beginPath();
|
|
16051
17678
|
ctx.moveTo(x, plot.y);
|
|
16052
17679
|
ctx.lineTo(x, plot.y + plot.height);
|
|
16053
17680
|
ctx.stroke();
|
|
17681
|
+
const text2 = valueLabel(tick, model.format);
|
|
17682
|
+
const textW = measureText(text2, tickFont).width;
|
|
17683
|
+
const budget = Math.min(86, Math.max(0, plot.x + plot.width - plot.x));
|
|
17684
|
+
const left = clamp13(x - budget / 2, 0, size.width - budget);
|
|
17685
|
+
if (budget < 12 || textW > budget || left < tickRight + 4) continue;
|
|
16054
17686
|
addOverlayText(surface, tokens, {
|
|
16055
|
-
left
|
|
17687
|
+
left,
|
|
16056
17688
|
top: plot.y + plot.height + 6,
|
|
16057
|
-
|
|
17689
|
+
width: budget,
|
|
17690
|
+
text: text2,
|
|
16058
17691
|
color: tokens.color.textMuted,
|
|
16059
17692
|
size: tokens.font.size.tiny,
|
|
16060
|
-
|
|
17693
|
+
align: "center",
|
|
17694
|
+
maxWidth: budget
|
|
16061
17695
|
});
|
|
17696
|
+
tickRight = left + Math.min(textW, budget);
|
|
16062
17697
|
}
|
|
16063
17698
|
ctx.restore();
|
|
16064
17699
|
const pixelRows = [];
|
|
16065
17700
|
const dotR = Math.max(4, Math.min(13, yBand.bandwidth * 0.16));
|
|
16066
17701
|
const connectorW = Math.max(3, Math.min(9, dotR * 0.7));
|
|
16067
17702
|
const labelGap = Math.round(dotR + 4);
|
|
17703
|
+
const rowLabelEvery = Math.max(1, Math.ceil((tokens.font.size.small * 1.2 + 2) / Math.max(1, yBand.step)));
|
|
16068
17704
|
for (const row of model.rows) {
|
|
16069
17705
|
const bandTop = yBand.map(row.catKey);
|
|
16070
17706
|
if (bandTop === void 0) continue;
|
|
@@ -16085,29 +17721,45 @@ function drawDumbbell(surface, spec, tokens, size, context) {
|
|
|
16085
17721
|
drawConnector(ctx, pen, pixelDots[0].x, pixelDots[pixelDots.length - 1].x, cy, connectorW, tokens);
|
|
16086
17722
|
for (const dot of pixelDots) drawDot(ctx, pen, dot.x, cy, dotR, dot.color, tokens);
|
|
16087
17723
|
ctx.globalAlpha = 1;
|
|
16088
|
-
|
|
16089
|
-
|
|
16090
|
-
|
|
16091
|
-
|
|
16092
|
-
|
|
16093
|
-
|
|
16094
|
-
|
|
16095
|
-
|
|
16096
|
-
|
|
16097
|
-
|
|
17724
|
+
const rowIndex = pixelRows.length - 1;
|
|
17725
|
+
if (rowIndex % rowLabelEvery === 0) {
|
|
17726
|
+
const labelWidth = Math.max(0, leftGutter - tokens.spacing.sm);
|
|
17727
|
+
if (labelWidth >= 12 && measureText(row.catLabel, labelFont).width <= labelWidth * 2) {
|
|
17728
|
+
addOverlayText(surface, tokens, {
|
|
17729
|
+
left: content.x,
|
|
17730
|
+
top: cy,
|
|
17731
|
+
width: labelWidth,
|
|
17732
|
+
text: row.catLabel,
|
|
17733
|
+
color: tokens.color.text,
|
|
17734
|
+
size: tokens.font.size.small,
|
|
17735
|
+
align: "right",
|
|
17736
|
+
transform: "translateY(-50%)",
|
|
17737
|
+
maxWidth: labelWidth
|
|
17738
|
+
});
|
|
17739
|
+
}
|
|
17740
|
+
}
|
|
16098
17741
|
if (dumbbell.labels) {
|
|
16099
17742
|
for (let i = 0; i < pixelDots.length; i++) {
|
|
16100
17743
|
const dot = pixelDots[i];
|
|
16101
|
-
const
|
|
17744
|
+
const text2 = formatDumbbellValue(dot.value, model.format);
|
|
17745
|
+
const valueFont = fontString(tokens.font.size.tiny, tokens.font.family, tokens.font.weight.normal);
|
|
17746
|
+
const textW = measureText(text2, valueFont).width;
|
|
17747
|
+
const preferLeft = i === 0 && pixelDots.length > 1;
|
|
17748
|
+
const leftSpace = Math.max(0, dot.x - labelGap);
|
|
17749
|
+
const rightSpace = Math.max(0, size.width - dot.x - labelGap);
|
|
17750
|
+
const atLeft = preferLeft ? leftSpace >= 14 || leftSpace >= rightSpace : rightSpace < 14 && leftSpace > rightSpace;
|
|
17751
|
+
const budget = Math.min(64, atLeft ? leftSpace : rightSpace);
|
|
17752
|
+
if (budget < 12 || textW > budget && budget < textW * 0.45) continue;
|
|
16102
17753
|
addOverlayText(surface, tokens, {
|
|
16103
17754
|
left: atLeft ? dot.x - labelGap : dot.x + labelGap,
|
|
16104
17755
|
top: cy,
|
|
16105
|
-
width:
|
|
16106
|
-
text:
|
|
17756
|
+
width: budget,
|
|
17757
|
+
text: text2,
|
|
16107
17758
|
color: tokens.color.textMuted,
|
|
16108
17759
|
size: tokens.font.size.tiny,
|
|
16109
17760
|
align: atLeft ? "right" : "left",
|
|
16110
|
-
transform: atLeft ? "translate(-100%,-50%)" : "translateY(-50%)"
|
|
17761
|
+
transform: atLeft ? "translate(-100%,-50%)" : "translateY(-50%)",
|
|
17762
|
+
maxWidth: budget
|
|
16111
17763
|
});
|
|
16112
17764
|
}
|
|
16113
17765
|
}
|
|
@@ -16283,7 +17935,7 @@ function drawAnnotations(surface, model) {
|
|
|
16283
17935
|
}
|
|
16284
17936
|
ctx.restore();
|
|
16285
17937
|
}
|
|
16286
|
-
function
|
|
17938
|
+
function labelBox2(model, horizontalRule, pos, p) {
|
|
16287
17939
|
const { plot } = model;
|
|
16288
17940
|
const inset = 6;
|
|
16289
17941
|
if (horizontalRule) {
|
|
@@ -16325,7 +17977,7 @@ function drawAnnotationLabels(surface, model) {
|
|
|
16325
17977
|
const axis = ann.axis === "x" ? "x" : "y";
|
|
16326
17978
|
const p = anchorPixel(model, ann, axis, kindOf(ann));
|
|
16327
17979
|
if (p === void 0) continue;
|
|
16328
|
-
box =
|
|
17980
|
+
box = labelBox2(model, drawsHorizontalRule(model, axis), ann.labelPosition ?? "end", p);
|
|
16329
17981
|
}
|
|
16330
17982
|
const color = ann.color ?? tokens.color.text;
|
|
16331
17983
|
const pill = { background: withAlpha4(tokens.color.background, 0.72), radius: 3, padX: 3, padY: 1 };
|
|
@@ -16468,8 +18120,8 @@ function drawTrendlineLabels(surface, model) {
|
|
|
16468
18120
|
const x1 = model.x.pixel(g.xMax);
|
|
16469
18121
|
const y1 = model.y.pixel(g.fit.predict(g.xMax));
|
|
16470
18122
|
if (x1 == null || !Number.isFinite(x1) || !Number.isFinite(y1)) continue;
|
|
16471
|
-
const
|
|
16472
|
-
const labelW = measureText(
|
|
18123
|
+
const text2 = `R\xB2=${g.fit.r2.toFixed(2)}`;
|
|
18124
|
+
const labelW = measureText(text2, font).width;
|
|
16473
18125
|
const nearRight = x1 + 6 + labelW > plot.x + plot.width;
|
|
16474
18126
|
const left = nearRight ? x1 - 6 : x1 + 6;
|
|
16475
18127
|
const align = nearRight ? "right" : "left";
|
|
@@ -16479,12 +18131,12 @@ function drawTrendlineLabels(surface, model) {
|
|
|
16479
18131
|
if (surface.headless) {
|
|
16480
18132
|
paintCanvasText(
|
|
16481
18133
|
surface.marks.ctx,
|
|
16482
|
-
overlayTextToCanvasCmd({ left, top, text, color, size: f.size.small, align, transform: transform2 }, font)
|
|
18134
|
+
overlayTextToCanvasCmd({ left, top, text: text2, color, size: f.size.small, align, transform: transform2 }, font)
|
|
16483
18135
|
);
|
|
16484
18136
|
continue;
|
|
16485
18137
|
}
|
|
16486
18138
|
const el = document.createElement("div");
|
|
16487
|
-
el.textContent =
|
|
18139
|
+
el.textContent = text2;
|
|
16488
18140
|
el.style.position = "absolute";
|
|
16489
18141
|
el.style.font = font;
|
|
16490
18142
|
el.style.fontSize = `${f.size.small}px`;
|
|
@@ -16986,6 +18638,8 @@ var MARK_CONTRAST_MIN = 1.5;
|
|
|
16986
18638
|
var TEXT_CONTRAST_MIN = 4.5;
|
|
16987
18639
|
var MAX_DISTINGUISHABLE_SERIES = 8;
|
|
16988
18640
|
var DEFAULT_POINT_MARKER_RADIUS = 3.5;
|
|
18641
|
+
var MAX_READABLE_CATEGORIES = 30;
|
|
18642
|
+
var SEVERE_TRUNCATION = 0.5;
|
|
16989
18643
|
var SEVERITY_RANK = { error: 0, warning: 1, info: 2 };
|
|
16990
18644
|
var VALUE_CHART_TYPES = /* @__PURE__ */ new Set(["kpi", "gauge", "bullet"]);
|
|
16991
18645
|
var CONTINUOUS_COLOR_TYPES = /* @__PURE__ */ new Set([
|
|
@@ -17044,9 +18698,13 @@ function buildRenderReport(input) {
|
|
|
17044
18698
|
checkDegenerateAxis(model, add);
|
|
17045
18699
|
checkClippedMarks(model, spec, add);
|
|
17046
18700
|
checkAnnotationOverflow(model, spec, tokens, add);
|
|
17047
|
-
|
|
18701
|
+
if (input.labels) checkAxisLabelDensity(model, spec, input.labels, add);
|
|
18702
|
+
else checkAxisLabelOverlap(model, spec, tokens, add);
|
|
17048
18703
|
checkLegendOverflow(model, spec, add);
|
|
18704
|
+
checkCategoryCount(model, add);
|
|
18705
|
+
checkTruncatedBaseline(model, spec, add);
|
|
17049
18706
|
}
|
|
18707
|
+
if (input.labels?.length) checkLabels(input.labels, size, add);
|
|
17050
18708
|
if (bg) {
|
|
17051
18709
|
const specColor = spec.color;
|
|
17052
18710
|
const usedColors = seriesColors.length ? seriesColors : specColor ? [specColor] : [];
|
|
@@ -17226,6 +18884,23 @@ function overflowSides(left, top, right, bottom, plot, eps) {
|
|
|
17226
18884
|
if (bottom > plot.y + plot.height + eps) sides.push("bottom");
|
|
17227
18885
|
return sides;
|
|
17228
18886
|
}
|
|
18887
|
+
function checkAxisLabelDensity(model, spec, labels, add) {
|
|
18888
|
+
if (model.x.kind !== "band") return;
|
|
18889
|
+
const total = model.xTicks.length;
|
|
18890
|
+
if (total < 2) return;
|
|
18891
|
+
const drawn = labels.filter((l) => l.role === "axis-label" && l.axis === "x").length;
|
|
18892
|
+
if (drawn === 0 || drawn >= total) return;
|
|
18893
|
+
add({
|
|
18894
|
+
code: "axis-label-overlap",
|
|
18895
|
+
severity: "warning",
|
|
18896
|
+
axis: "x",
|
|
18897
|
+
message: `Only ${drawn} of ${total} category labels fit on the x-axis; the rest were dropped so the remaining ones stay readable. Widen the chart, reduce categories, or aggregate the long tail.`,
|
|
18898
|
+
details: { drawn, total },
|
|
18899
|
+
...xLabelAngleFix(spec) ?? {
|
|
18900
|
+
hint: "Rotate x-axis labels, facet the chart, widen it, or aggregate the long tail."
|
|
18901
|
+
}
|
|
18902
|
+
});
|
|
18903
|
+
}
|
|
17229
18904
|
function checkAxisLabelOverlap(model, spec, tokens, add) {
|
|
17230
18905
|
const ticks2 = model.xTicks;
|
|
17231
18906
|
if (ticks2.length < 2) return;
|
|
@@ -17273,6 +18948,91 @@ function checkLegendOverflow(model, spec, add) {
|
|
|
17273
18948
|
function isRecord(v) {
|
|
17274
18949
|
return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
|
|
17275
18950
|
}
|
|
18951
|
+
var MAX_COLLISION_LABELS = 300;
|
|
18952
|
+
var LABEL_OVERLAP_TOLERANCE = 2;
|
|
18953
|
+
function checkLabels(labels, size, add) {
|
|
18954
|
+
const truncated = labels.filter((l) => l.truncated);
|
|
18955
|
+
if (truncated.length > 0) {
|
|
18956
|
+
const severe = truncated.filter(
|
|
18957
|
+
(l) => l.text.length / Math.max(1, l.fullText.length) < SEVERE_TRUNCATION
|
|
18958
|
+
);
|
|
18959
|
+
const worst2 = severe[0] ?? truncated[0];
|
|
18960
|
+
add({
|
|
18961
|
+
code: "text-clipped",
|
|
18962
|
+
severity: severe.length > 0 ? "warning" : "info",
|
|
18963
|
+
message: `${truncated.length} label${truncated.length === 1 ? " was" : "s were"} shortened to fit (e.g. "${worst2.fullText}" \u2192 "${worst2.text}")` + (severe.length > 0 ? `; ${severe.length} lost more than half their text and no longer read clearly.` : "."),
|
|
18964
|
+
details: {
|
|
18965
|
+
count: truncated.length,
|
|
18966
|
+
severe: severe.length,
|
|
18967
|
+
roles: [...new Set(truncated.map((l) => l.role))],
|
|
18968
|
+
example: { role: worst2.role, full: worst2.fullText, drawn: worst2.text }
|
|
18969
|
+
},
|
|
18970
|
+
hint: "Widen the chart, shorten the label values, or move the legend to the top/bottom."
|
|
18971
|
+
});
|
|
18972
|
+
}
|
|
18973
|
+
const occluded = labels.filter((l) => {
|
|
18974
|
+
const slack = Math.max(1, l.size * 0.25);
|
|
18975
|
+
return l.box.x < -slack || l.box.y < -slack || l.box.x + l.box.width > size.width + slack || l.box.y + l.box.height > size.height + slack;
|
|
18976
|
+
});
|
|
18977
|
+
if (occluded.length > 0) {
|
|
18978
|
+
add({
|
|
18979
|
+
code: "text-occluded",
|
|
18980
|
+
severity: "warning",
|
|
18981
|
+
message: `${occluded.length} label${occluded.length === 1 ? "" : "s"} extend past the chart's edge and are cut off (e.g. the ${occluded[0].role} "${occluded[0].fullText}").`,
|
|
18982
|
+
details: { count: occluded.length, roles: [...new Set(occluded.map((l) => l.role))] },
|
|
18983
|
+
hint: "Increase the chart size, or reduce the chrome competing for space."
|
|
18984
|
+
});
|
|
18985
|
+
}
|
|
18986
|
+
if (labels.length <= MAX_COLLISION_LABELS) {
|
|
18987
|
+
let collisions = 0;
|
|
18988
|
+
let example = null;
|
|
18989
|
+
for (let i = 0; i < labels.length; i += 1) {
|
|
18990
|
+
for (let j = i + 1; j < labels.length; j += 1) {
|
|
18991
|
+
if (!boxesOverlap(labels[i].box, labels[j].box, LABEL_OVERLAP_TOLERANCE)) continue;
|
|
18992
|
+
collisions += 1;
|
|
18993
|
+
if (!example) example = [labels[i], labels[j]];
|
|
18994
|
+
}
|
|
18995
|
+
}
|
|
18996
|
+
if (example) {
|
|
18997
|
+
add({
|
|
18998
|
+
code: "label-collision",
|
|
18999
|
+
severity: "warning",
|
|
19000
|
+
message: `${collisions} pair${collisions === 1 ? "" : "s"} of labels overlap and are hard to read (e.g. the ${example[0].role} "${example[0].text}" over the ${example[1].role} "${example[1].text}").`,
|
|
19001
|
+
details: { collisions, example: [example[0].text, example[1].text] },
|
|
19002
|
+
hint: "Give the chart more room, show fewer categories, or turn off data labels."
|
|
19003
|
+
});
|
|
19004
|
+
}
|
|
19005
|
+
}
|
|
19006
|
+
}
|
|
19007
|
+
function checkCategoryCount(model, add) {
|
|
19008
|
+
if (model.x.kind !== "band") return;
|
|
19009
|
+
const categories = model.xTicks.length;
|
|
19010
|
+
if (categories <= MAX_READABLE_CATEGORIES) return;
|
|
19011
|
+
add({
|
|
19012
|
+
code: "too-many-categories",
|
|
19013
|
+
severity: "info",
|
|
19014
|
+
axis: "x",
|
|
19015
|
+
message: `${categories} categories share one axis \u2014 beyond ~${MAX_READABLE_CATEGORIES} the bands get too thin to compare individually.`,
|
|
19016
|
+
details: { categories, max: MAX_READABLE_CATEGORIES },
|
|
19017
|
+
hint: 'Aggregate the long tail into an "Other" bucket, filter to a top-N, or use a treemap.'
|
|
19018
|
+
});
|
|
19019
|
+
}
|
|
19020
|
+
function checkTruncatedBaseline(model, spec, add) {
|
|
19021
|
+
if (spec.type !== "bar") return;
|
|
19022
|
+
const enc = spec.encoding;
|
|
19023
|
+
const y = isRecord(enc) ? enc.y : void 0;
|
|
19024
|
+
const scale = isRecord(y) ? y.scale : void 0;
|
|
19025
|
+
const domain = isRecord(scale) ? scale.domain : void 0;
|
|
19026
|
+
if (!Array.isArray(domain) || typeof domain[0] !== "number" || domain[0] <= 0) return;
|
|
19027
|
+
add({
|
|
19028
|
+
code: "truncated-baseline",
|
|
19029
|
+
severity: "warning",
|
|
19030
|
+
axis: "y",
|
|
19031
|
+
message: `The bar value axis starts at ${domain[0]} rather than 0, so bar lengths no longer encode magnitude and small differences look large.`,
|
|
19032
|
+
details: { start: domain[0] },
|
|
19033
|
+
fix: [{ op: "remove", path: "/encoding/y/scale/domain" }]
|
|
19034
|
+
});
|
|
19035
|
+
}
|
|
17276
19036
|
function xLabelAngleFix(spec) {
|
|
17277
19037
|
const axes = spec.axes;
|
|
17278
19038
|
if (!isRecord(axes)) return void 0;
|
|
@@ -17590,14 +19350,14 @@ ${e instanceof Error ? e.message : String(e)}`;
|
|
|
17590
19350
|
}
|
|
17591
19351
|
function buildSummaryPanel(spec, tokens) {
|
|
17592
19352
|
const { card, body } = makePanel("Summary", tokens);
|
|
17593
|
-
let
|
|
19353
|
+
let text2 = "";
|
|
17594
19354
|
try {
|
|
17595
|
-
|
|
19355
|
+
text2 = summarize(spec);
|
|
17596
19356
|
} catch {
|
|
17597
|
-
|
|
19357
|
+
text2 = "";
|
|
17598
19358
|
}
|
|
17599
|
-
body.textContent =
|
|
17600
|
-
if (!
|
|
19359
|
+
body.textContent = text2 || "\u2014 (no summary for this chart type)";
|
|
19360
|
+
if (!text2) setStyle(body, { color: tokens.color.textMuted });
|
|
17601
19361
|
return card;
|
|
17602
19362
|
}
|
|
17603
19363
|
function diagnosticRow(path, message, color, tokens) {
|
|
@@ -18206,7 +19966,8 @@ function render(target, spec, options) {
|
|
|
18206
19966
|
data: filtered,
|
|
18207
19967
|
tokens,
|
|
18208
19968
|
size,
|
|
18209
|
-
model: reportModel
|
|
19969
|
+
model: reportModel,
|
|
19970
|
+
labels: surface.labels.records
|
|
18210
19971
|
});
|
|
18211
19972
|
if (!entrance) signalReady(surface);
|
|
18212
19973
|
armFontRefresh();
|
|
@@ -18695,7 +20456,7 @@ function stripCellHeight(view, rowHeight) {
|
|
|
18695
20456
|
if (view.spec.type === "range") return Math.max(base, 132 + titlePad);
|
|
18696
20457
|
return Math.max(base, 96 + titlePad);
|
|
18697
20458
|
}
|
|
18698
|
-
var
|
|
20459
|
+
var clamp14 = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
18699
20460
|
function defaultSpan(spec, cols) {
|
|
18700
20461
|
const t = spec.type;
|
|
18701
20462
|
if (isSlicerType(t)) return { w: Math.min(3, cols), h: 2 };
|
|
@@ -19165,7 +20926,7 @@ function renderDashboard(target, spec, options) {
|
|
|
19165
20926
|
if (resp?.hidden) continue;
|
|
19166
20927
|
const w = resp?.w ?? c.w;
|
|
19167
20928
|
const h = resp?.h ?? c.h;
|
|
19168
|
-
const scaledW =
|
|
20929
|
+
const scaledW = clamp14(Math.round(w * effCols / group.baseCols), 1, effCols);
|
|
19169
20930
|
c.el.style.gridColumn = fullWidth && c.x != null ? `${c.x} / span ${Math.min(w, group.baseCols)}` : `span ${scaledW}`;
|
|
19170
20931
|
c.el.style.gridRow = fullWidth && c.y != null ? `${c.y} / span ${h}` : `span ${h}`;
|
|
19171
20932
|
}
|
|
@@ -19279,12 +21040,76 @@ function renderDashboard(target, spec, options) {
|
|
|
19279
21040
|
// src/runtime/dashboardHeadless.ts
|
|
19280
21041
|
var HEADER_H = 44;
|
|
19281
21042
|
var SECTION_TITLE_H = 26;
|
|
21043
|
+
var SECTION_SUBTITLE_H = 18;
|
|
21044
|
+
var CARD_HEADER_TITLE_H = 22;
|
|
21045
|
+
var CARD_HEADER_SUBTITLE_H = 18;
|
|
21046
|
+
var ACCENT_W = 5;
|
|
21047
|
+
var DEFAULT_BREAKPOINTS2 = [
|
|
21048
|
+
{ maxWidth: 600, cols: 1 },
|
|
21049
|
+
{ maxWidth: 960, cols: 6 }
|
|
21050
|
+
];
|
|
21051
|
+
function clamp15(v, lo, hi) {
|
|
21052
|
+
return Math.max(lo, Math.min(hi, v));
|
|
21053
|
+
}
|
|
21054
|
+
function text(target, ledger, opts) {
|
|
21055
|
+
const font = fontString(opts.size, resolveTheme(void 0).font.family, opts.weight ?? 400);
|
|
21056
|
+
const fitted = fitLabel(opts.text, opts.width, font);
|
|
21057
|
+
const overlay = {
|
|
21058
|
+
left: opts.x,
|
|
21059
|
+
top: opts.y,
|
|
21060
|
+
width: opts.width,
|
|
21061
|
+
text: fitted.text,
|
|
21062
|
+
color: opts.color,
|
|
21063
|
+
size: opts.size,
|
|
21064
|
+
align: opts.align
|
|
21065
|
+
};
|
|
21066
|
+
paintCanvasText(target.marks, {
|
|
21067
|
+
x: opts.x,
|
|
21068
|
+
y: opts.y,
|
|
21069
|
+
text: fitted.text,
|
|
21070
|
+
font,
|
|
21071
|
+
color: opts.color,
|
|
21072
|
+
size: opts.size,
|
|
21073
|
+
align: opts.align,
|
|
21074
|
+
baseline: "top"
|
|
21075
|
+
});
|
|
21076
|
+
const box = labelBox(overlay, font);
|
|
21077
|
+
recordLabel(ledger, {
|
|
21078
|
+
role: opts.role,
|
|
21079
|
+
text: fitted.text,
|
|
21080
|
+
fullText: opts.text,
|
|
21081
|
+
truncated: fitted.truncated,
|
|
21082
|
+
box: box.box,
|
|
21083
|
+
angle: box.angle,
|
|
21084
|
+
size: opts.size,
|
|
21085
|
+
budget: opts.width
|
|
21086
|
+
});
|
|
21087
|
+
}
|
|
21088
|
+
function childDiagnostic(view, diagnostic) {
|
|
21089
|
+
return {
|
|
21090
|
+
...diagnostic,
|
|
21091
|
+
message: `View "${view.id}": ${diagnostic.message}`,
|
|
21092
|
+
details: { ...diagnostic.details ?? {}, viewId: view.id, viewType: view.spec.type }
|
|
21093
|
+
};
|
|
21094
|
+
}
|
|
19282
21095
|
function renderDashboardToContext(target, spec) {
|
|
19283
21096
|
const ctx = target.marks;
|
|
19284
21097
|
const tokens = resolveTheme(spec.theme);
|
|
19285
21098
|
const layout2 = resolveDashboardLayout(spec.layout);
|
|
19286
21099
|
const gap = layout2.gap;
|
|
19287
21100
|
const pad3 = layout2.padding ?? Math.round(gap * 1.4);
|
|
21101
|
+
const pageW = layout2.maxWidth ? Math.min(target.width, layout2.maxWidth) : target.width;
|
|
21102
|
+
const pageX = Math.max(0, (target.width - pageW) / 2);
|
|
21103
|
+
const innerW = Math.max(1, pageW - pad3 * 2);
|
|
21104
|
+
const ledger = new LabelLedger();
|
|
21105
|
+
const diagnostics = [];
|
|
21106
|
+
const breakpoints = (layout2.breakpoints ?? DEFAULT_BREAKPOINTS2).slice().sort((a, b) => a.maxWidth - b.maxWidth);
|
|
21107
|
+
const effectiveCols = (width, cols) => {
|
|
21108
|
+
for (const bp of breakpoints) {
|
|
21109
|
+
if (width <= bp.maxWidth) return Math.max(1, Math.min(bp.cols, cols));
|
|
21110
|
+
}
|
|
21111
|
+
return cols;
|
|
21112
|
+
};
|
|
19288
21113
|
ctx.save();
|
|
19289
21114
|
ctx.fillStyle = spec.background ?? tokens.color.background;
|
|
19290
21115
|
ctx.fillRect(0, 0, target.width, target.height);
|
|
@@ -19294,15 +21119,30 @@ function renderDashboardToContext(target, spec) {
|
|
|
19294
21119
|
if (titleText3 || spec.subtitle) {
|
|
19295
21120
|
ctx.save();
|
|
19296
21121
|
ctx.fillStyle = tokens.color.accent;
|
|
19297
|
-
ctx.fillRect(pad3, cursorY + 2,
|
|
19298
|
-
|
|
19299
|
-
|
|
19300
|
-
|
|
19301
|
-
|
|
21122
|
+
ctx.fillRect(pageX + pad3, cursorY + 2, ACCENT_W, 30);
|
|
21123
|
+
if (titleText3) {
|
|
21124
|
+
text(target, ledger, {
|
|
21125
|
+
text: titleText3,
|
|
21126
|
+
x: pageX + pad3 + 14,
|
|
21127
|
+
y: cursorY + 2,
|
|
21128
|
+
width: Math.max(1, innerW - 14),
|
|
21129
|
+
color: tokens.color.text,
|
|
21130
|
+
size: tokens.font.size.title,
|
|
21131
|
+
weight: tokens.font.weight.bold,
|
|
21132
|
+
role: "title"
|
|
21133
|
+
});
|
|
21134
|
+
}
|
|
19302
21135
|
if (spec.subtitle) {
|
|
19303
|
-
|
|
19304
|
-
|
|
19305
|
-
|
|
21136
|
+
text(target, ledger, {
|
|
21137
|
+
text: spec.subtitle,
|
|
21138
|
+
x: pageX + pad3 + 14,
|
|
21139
|
+
y: cursorY + 24,
|
|
21140
|
+
width: Math.max(1, innerW - 14),
|
|
21141
|
+
color: tokens.color.textMuted,
|
|
21142
|
+
size: tokens.font.size.small,
|
|
21143
|
+
weight: tokens.font.weight.normal,
|
|
21144
|
+
role: "subtitle"
|
|
21145
|
+
});
|
|
19306
21146
|
}
|
|
19307
21147
|
ctx.restore();
|
|
19308
21148
|
cursorY += HEADER_H;
|
|
@@ -19315,50 +21155,133 @@ function renderDashboardToContext(target, spec) {
|
|
|
19315
21155
|
for (const section of sections) {
|
|
19316
21156
|
if (section.collapsed) continue;
|
|
19317
21157
|
if (section.section?.title) {
|
|
19318
|
-
|
|
19319
|
-
|
|
19320
|
-
|
|
19321
|
-
|
|
19322
|
-
|
|
19323
|
-
|
|
21158
|
+
text(target, ledger, {
|
|
21159
|
+
text: section.section.title,
|
|
21160
|
+
x: pageX + pad3,
|
|
21161
|
+
y: cursorY,
|
|
21162
|
+
width: innerW,
|
|
21163
|
+
color: tokens.color.text,
|
|
21164
|
+
size: tokens.font.size.base,
|
|
21165
|
+
weight: tokens.font.weight.bold,
|
|
21166
|
+
role: "title"
|
|
21167
|
+
});
|
|
19324
21168
|
cursorY += SECTION_TITLE_H;
|
|
19325
21169
|
}
|
|
19326
|
-
|
|
19327
|
-
|
|
21170
|
+
if (section.section?.subtitle) {
|
|
21171
|
+
text(target, ledger, {
|
|
21172
|
+
text: section.section.subtitle,
|
|
21173
|
+
x: pageX + pad3,
|
|
21174
|
+
y: cursorY - 8,
|
|
21175
|
+
width: innerW,
|
|
21176
|
+
color: tokens.color.textMuted,
|
|
21177
|
+
size: tokens.font.size.small,
|
|
21178
|
+
weight: tokens.font.weight.normal,
|
|
21179
|
+
role: "subtitle"
|
|
21180
|
+
});
|
|
21181
|
+
cursorY += SECTION_SUBTITLE_H;
|
|
21182
|
+
}
|
|
21183
|
+
const baseCols = Math.max(1, section.cols);
|
|
21184
|
+
const cols = effectiveCols(innerW, baseCols);
|
|
19328
21185
|
const colW = (innerW - gap * (cols - 1)) / cols;
|
|
19329
|
-
const cells = placeViews(section.views,
|
|
21186
|
+
const cells = placeViews(section.views, baseCols, layout2.preset);
|
|
19330
21187
|
const flow = { x: 1, y: 1, rowH: 0 };
|
|
19331
21188
|
let maxBottom = cursorY;
|
|
19332
21189
|
for (const cell of cells) {
|
|
19333
|
-
|
|
19334
|
-
|
|
21190
|
+
const scaledW = clamp15(Math.round(cell.w * cols / baseCols), 1, cols);
|
|
21191
|
+
const fullWidth = cols === baseCols;
|
|
21192
|
+
let gx = fullWidth ? cell.x : void 0;
|
|
21193
|
+
let gy = fullWidth ? cell.y : void 0;
|
|
19335
21194
|
if (gx == null || gy == null) {
|
|
19336
|
-
if (flow.x +
|
|
21195
|
+
if (flow.x + scaledW - 1 > cols) {
|
|
19337
21196
|
flow.x = 1;
|
|
19338
21197
|
flow.y += Math.max(1, flow.rowH);
|
|
19339
21198
|
flow.rowH = 0;
|
|
19340
21199
|
}
|
|
19341
21200
|
gx = flow.x;
|
|
19342
21201
|
gy = flow.y;
|
|
19343
|
-
flow.x +=
|
|
21202
|
+
flow.x += scaledW;
|
|
19344
21203
|
flow.rowH = Math.max(flow.rowH, cell.h);
|
|
19345
21204
|
}
|
|
19346
|
-
const cx = pad3 + (gx - 1) * (colW + gap);
|
|
21205
|
+
const cx = pageX + pad3 + (gx - 1) * (colW + gap);
|
|
19347
21206
|
const cy = cursorY + (gy - 1) * (section.rowHeight + gap);
|
|
19348
|
-
const cw =
|
|
21207
|
+
const cw = scaledW * colW + (scaledW - 1) * gap;
|
|
19349
21208
|
const ch = cell.h * section.rowHeight + (cell.h - 1) * gap;
|
|
19350
|
-
const
|
|
21209
|
+
const framed = cell.view.frame ?? true;
|
|
21210
|
+
const hasHeader = !!(cell.view.title || cell.view.subtitle);
|
|
21211
|
+
const inset = cell.view.padding === "none" ? 0 : tokens.spacing.sm;
|
|
21212
|
+
const headerH = hasHeader ? tokens.spacing.sm + (cell.view.title ? CARD_HEADER_TITLE_H : 0) + (cell.view.subtitle ? CARD_HEADER_SUBTITLE_H : 0) + tokens.spacing.xs : 0;
|
|
21213
|
+
ctx.save();
|
|
21214
|
+
if (cell.view.background || framed) {
|
|
21215
|
+
ctx.fillStyle = cell.view.background ?? tokens.color.surface;
|
|
21216
|
+
ctx.fillRect(cx, cy, cw, ch);
|
|
21217
|
+
}
|
|
21218
|
+
if (framed) {
|
|
21219
|
+
ctx.strokeStyle = tokens.color.border;
|
|
21220
|
+
ctx.lineWidth = 1;
|
|
21221
|
+
ctx.strokeRect(cx, cy, cw, ch);
|
|
21222
|
+
}
|
|
21223
|
+
if (cell.view.accent) {
|
|
21224
|
+
ctx.fillStyle = cell.view.accent;
|
|
21225
|
+
ctx.fillRect(cx, cy, ACCENT_W, ch);
|
|
21226
|
+
}
|
|
21227
|
+
if (cell.view.title) {
|
|
21228
|
+
text(target, ledger, {
|
|
21229
|
+
text: cell.view.title,
|
|
21230
|
+
x: cx + tokens.spacing.md,
|
|
21231
|
+
y: cy + tokens.spacing.sm,
|
|
21232
|
+
width: Math.max(1, cw - tokens.spacing.md * 2),
|
|
21233
|
+
color: tokens.color.text,
|
|
21234
|
+
size: tokens.font.size.base,
|
|
21235
|
+
weight: tokens.font.weight.medium,
|
|
21236
|
+
role: "title"
|
|
21237
|
+
});
|
|
21238
|
+
}
|
|
21239
|
+
if (cell.view.subtitle) {
|
|
21240
|
+
text(target, ledger, {
|
|
21241
|
+
text: cell.view.subtitle,
|
|
21242
|
+
x: cx + tokens.spacing.md,
|
|
21243
|
+
y: cy + tokens.spacing.sm + (cell.view.title ? CARD_HEADER_TITLE_H : 0),
|
|
21244
|
+
width: Math.max(1, cw - tokens.spacing.md * 2),
|
|
21245
|
+
color: tokens.color.textMuted,
|
|
21246
|
+
size: tokens.font.size.small,
|
|
21247
|
+
weight: tokens.font.weight.normal,
|
|
21248
|
+
role: "subtitle"
|
|
21249
|
+
});
|
|
21250
|
+
}
|
|
21251
|
+
ctx.restore();
|
|
21252
|
+
const contentX = cx + inset;
|
|
21253
|
+
const contentY = cy + headerH + inset;
|
|
21254
|
+
const contentW = cw - inset * 2;
|
|
21255
|
+
const contentH = ch - headerH - inset * 2;
|
|
21256
|
+
const viewSpec = inheritInto(cell.view.spec, spec, tokens, hasHeader);
|
|
19351
21257
|
ctx.save();
|
|
19352
|
-
ctx.translate(
|
|
21258
|
+
ctx.translate(contentX, contentY);
|
|
19353
21259
|
ctx.beginPath();
|
|
19354
|
-
ctx.rect(0, 0,
|
|
21260
|
+
ctx.rect(0, 0, contentW, contentH);
|
|
19355
21261
|
ctx.clip();
|
|
19356
21262
|
try {
|
|
19357
|
-
|
|
19358
|
-
|
|
19359
|
-
|
|
21263
|
+
if (contentW <= 1 || contentH <= 1) {
|
|
21264
|
+
diagnostics.push({
|
|
21265
|
+
code: "empty-view",
|
|
21266
|
+
severity: "error",
|
|
21267
|
+
message: `View "${cell.view.id}" collapsed to ${Math.round(contentW)}\xD7${Math.round(contentH)}px.`,
|
|
21268
|
+
details: { viewId: cell.view.id, viewType: cell.view.spec.type }
|
|
21269
|
+
});
|
|
21270
|
+
allOk = false;
|
|
21271
|
+
} else {
|
|
21272
|
+
const r = renderToContext({ marks: ctx, interaction: target.interaction, width: contentW, height: contentH }, viewSpec);
|
|
21273
|
+
markCount += r.markCount;
|
|
21274
|
+
diagnostics.push(...r.diagnostics.map((d) => childDiagnostic(cell.view, d)));
|
|
21275
|
+
if (!r.ok) allOk = false;
|
|
21276
|
+
}
|
|
19360
21277
|
views++;
|
|
19361
|
-
} catch {
|
|
21278
|
+
} catch (error) {
|
|
21279
|
+
diagnostics.push({
|
|
21280
|
+
code: "view-render-error",
|
|
21281
|
+
severity: "error",
|
|
21282
|
+
message: `View "${cell.view.id}" failed to render: ${error?.message ?? String(error)}`,
|
|
21283
|
+
details: { viewId: cell.view.id, viewType: cell.view.spec.type }
|
|
21284
|
+
});
|
|
19362
21285
|
allOk = false;
|
|
19363
21286
|
}
|
|
19364
21287
|
ctx.restore();
|
|
@@ -19366,14 +21289,27 @@ function renderDashboardToContext(target, spec) {
|
|
|
19366
21289
|
}
|
|
19367
21290
|
cursorY = maxBottom + gap;
|
|
19368
21291
|
}
|
|
21292
|
+
const chromeReport = buildRenderReport({
|
|
21293
|
+
type: "kpi",
|
|
21294
|
+
spec: { type: "kpi", value: 1, theme: spec.theme, background: spec.background ?? tokens.color.background },
|
|
21295
|
+
data: [{ value: 1 }],
|
|
21296
|
+
tokens,
|
|
21297
|
+
size: { width: target.width, height: target.height },
|
|
21298
|
+
labels: ledger.records
|
|
21299
|
+
});
|
|
21300
|
+
diagnostics.push(...chromeReport.diagnostics.map((d) => ({ ...d, message: `Dashboard chrome: ${d.message}` })));
|
|
21301
|
+
diagnostics.sort((a, b) => {
|
|
21302
|
+
const rank2 = { error: 0, warning: 1, info: 2 };
|
|
21303
|
+
return rank2[a.severity] - rank2[b.severity];
|
|
21304
|
+
});
|
|
19369
21305
|
return {
|
|
19370
21306
|
type: "dashboard",
|
|
19371
21307
|
size: { width: target.width, height: target.height },
|
|
19372
21308
|
markCount,
|
|
19373
21309
|
seriesCount: views,
|
|
19374
21310
|
colorCount: 1,
|
|
19375
|
-
ok: allOk,
|
|
19376
|
-
diagnostics
|
|
21311
|
+
ok: allOk && !diagnostics.some((d) => d.severity === "error" || d.severity === "warning"),
|
|
21312
|
+
diagnostics,
|
|
19377
21313
|
summary: `Dashboard of ${views} view${views === 1 ? "" : "s"}.`
|
|
19378
21314
|
};
|
|
19379
21315
|
}
|
|
@@ -19398,7 +21334,8 @@ function renderToContext(target, spec) {
|
|
|
19398
21334
|
height: size.height,
|
|
19399
21335
|
dpr: 1,
|
|
19400
21336
|
marks: { ctx },
|
|
19401
|
-
interaction: { ctx: target.interaction ?? ctx }
|
|
21337
|
+
interaction: { ctx: target.interaction ?? ctx },
|
|
21338
|
+
labels: new LabelLedger()
|
|
19402
21339
|
};
|
|
19403
21340
|
const bg = spec.background ?? tokens.color.background;
|
|
19404
21341
|
ctx.save();
|
|
@@ -19449,12 +21386,73 @@ function renderToContext(target, spec) {
|
|
|
19449
21386
|
};
|
|
19450
21387
|
renderer(surface, effectiveSpec, tokens, size, rctx);
|
|
19451
21388
|
}
|
|
19452
|
-
return buildRenderReport({
|
|
21389
|
+
return buildRenderReport({
|
|
21390
|
+
type,
|
|
21391
|
+
spec,
|
|
21392
|
+
data,
|
|
21393
|
+
tokens,
|
|
21394
|
+
size,
|
|
21395
|
+
model: reportModel,
|
|
21396
|
+
labels: surface.labels.records
|
|
21397
|
+
});
|
|
21398
|
+
}
|
|
21399
|
+
|
|
21400
|
+
// src/runtime/draft.ts
|
|
21401
|
+
function isDashboard(spec) {
|
|
21402
|
+
return typeof spec === "object" && spec !== null && spec.type === "dashboard";
|
|
21403
|
+
}
|
|
21404
|
+
function draft(spec, options = {}) {
|
|
21405
|
+
const shouldRepair = options.repair ?? true;
|
|
21406
|
+
const level = options.repairLevel ?? "safe";
|
|
21407
|
+
let working = spec;
|
|
21408
|
+
let patches = [];
|
|
21409
|
+
let repairs = [];
|
|
21410
|
+
let validation = validateSpec(working);
|
|
21411
|
+
if (shouldRepair && (!validation.valid || level === "data")) {
|
|
21412
|
+
const repaired = repairSpec(working, { level });
|
|
21413
|
+
working = repaired.spec;
|
|
21414
|
+
patches = repaired.applied;
|
|
21415
|
+
repairs = repaired.appliedDetails;
|
|
21416
|
+
validation = validateSpec(working);
|
|
21417
|
+
}
|
|
21418
|
+
if (!validation.valid) {
|
|
21419
|
+
return {
|
|
21420
|
+
spec: working,
|
|
21421
|
+
valid: false,
|
|
21422
|
+
rendered: false,
|
|
21423
|
+
patches,
|
|
21424
|
+
repairs,
|
|
21425
|
+
errors: validation.errors,
|
|
21426
|
+
warnings: validation.warnings
|
|
21427
|
+
};
|
|
21428
|
+
}
|
|
21429
|
+
const result = {
|
|
21430
|
+
spec: working,
|
|
21431
|
+
valid: true,
|
|
21432
|
+
rendered: false,
|
|
21433
|
+
patches,
|
|
21434
|
+
repairs,
|
|
21435
|
+
errors: [],
|
|
21436
|
+
warnings: validation.warnings,
|
|
21437
|
+
summary: isDashboard(working) ? void 0 : summarize(working) || void 0
|
|
21438
|
+
};
|
|
21439
|
+
if (options.target) {
|
|
21440
|
+
result.report = renderToContext(options.target, working);
|
|
21441
|
+
result.rendered = true;
|
|
21442
|
+
result.summary = result.report.summary ?? result.summary;
|
|
21443
|
+
} else if (options.container && !isDashboard(working)) {
|
|
21444
|
+
const instance = render(options.container, working, options.renderOptions);
|
|
21445
|
+
result.instance = instance;
|
|
21446
|
+
result.report = instance.report();
|
|
21447
|
+
result.rendered = true;
|
|
21448
|
+
result.summary = result.report.summary ?? result.summary;
|
|
21449
|
+
}
|
|
21450
|
+
return result;
|
|
19453
21451
|
}
|
|
19454
21452
|
|
|
19455
21453
|
// src/index.ts
|
|
19456
|
-
var VERSION = "0.
|
|
21454
|
+
var VERSION = "0.18.0";
|
|
19457
21455
|
|
|
19458
|
-
export { CARTESIAN_TYPES, CHART_TYPES, CanvasLayer, DEFAULT_ENTRANCE_DURATION, DEFAULT_ROUGH_STYLE, DEFAULT_UPDATE_DURATION, DIM_ALPHA, ExpressionError, FACETABLE_TYPES2 as FACETABLE_TYPES, FUNCTION_NAMES, InteractionController, NO_PADDING, RoughPen, SKETCH_FONT_FAMILY, SKETCH_FONT_NAME, SLICER_TYPES, SPEC_ATTR, SR_ONLY_STYLE, Surface, TICK_SIZE, TIME_UNITS, TRANSFORM_KINDS, TYPE_ATTR, Tooltip, VERSION, aggregateValues, analyzeChart, animate, applyA11y, applyAggregate, applyBin, applyCalculate, applyFilter, applyFold, applyPatch, applyPick, applyTimeUnit, applyTransform, applyTransforms, arc, area, assertValidSpec, autoInsightAnnotations, backOut, bandScale, bounceOut, buildCartesianInteraction, buildCartesianModel, buildComboModel, buildDataTableFallback, buildFacetModels, buildHistogramModel, buildRenderReport, cartesianInteractionBuilders, cartesianRenderers, categorical, categoricalSchemes, chartTitleText, chartTypeLabel, checkExpression, clamp012 as clamp01, clamp255, compileExpression, compilePredicate, computeBackingSize, computeBins, computeFrame, computeSeriesInsights, contrastRatio, createDiv, createInspector, createRoughPen, createSelectionStore, cubicIn, cubicInOut, cubicOut, curveCatmullRom, curveLinear, curveMonotoneX, curveStep, curveStepAfter, curveStepBefore, customRenderers, darkTheme, decimate, defaultSpan, dependentParams, diverging, divergingColorScale, divergingSchemes, drawAnnotationLabels, drawAnnotations, drawAxesUnderlay, drawDebugCanvas, drawFacet, drawLegend, drawOverlay, drawTitle, drawTrendlineLabels, drawTrendlines, easings, elasticOut, ellipsize, ensureSketchFont, expoInOut, expoOut, facetReport, fillParentStyle, filterRows, fontString, formatCountUp, formatDate, formatNumber2 as formatNumber, formatValue, getDevicePixelRatio, groupBy, hashString, inferFieldType, inferFieldTypes, inferValueType, inheritInto, interpolateArray, interpolateNumber, interpolateNumberArray, interpolateObject, interpolateOklab, interpolateRgb, isBrowser, isEmptyValue, isFaceted, isParamClause, isSlicerType, keyFields, legendSwatchWidth, lightTheme, line, linear, linearRegression, linearScale, linearToSrgb, lintSpec, literalToValue, logScale, lttbRun, makeMatcher, matchesValue, measureText, monotoneXTangents, mulberry32, niceDomain, oklabToOklch, oklabToRgb, oklchToOklab, ordinalColorScale, overlayTextToCanvasCmd, paintCanvasText, paintLegendSwatch, parseColor, parseNumberFormat, pivot, placeViews, pointScale, polygonHachureLines, prefersReducedMotion, prettyDate, profileColumns, quadIn, quadInOut, quadOut, rampFromStops, readableTextColor, recommendChart, relativeLuminance, render, renderDashboard, renderDebugView, renderToContext, repairReport, repairSpec, resolveCurve, resolveDashboardLayout, resolveDashboardSections, resolveEmphasis, resolveEntrance, resolveFilterValues, resolveInsightOptions, resolveSketch, resolveTheme, resolveUpdate, resolveValueRef, rgbToOklab, rgbaToCss, rotatePoint, roundedRect, sampleArc, sequential, sequentialColorScale, sequentialSchemes, serializeSpec, setMeasureContext, setStyle, sinInOut, slicerParamName, smartDate, specFields, srgbToLinear, stripData, summarize, summarizeChart, themes, tickIncrement, tickStep, ticks, timeScale, timeTickFormat, timeTicks, toHex, toPointer, tooltipEnabled, truncateTo, validateDashboard, validateSpec, wireViews, withAlpha, withSketchFont };
|
|
21456
|
+
export { CARTESIAN_TYPES, CHART_TYPES, CanvasLayer, DEFAULT_ENTRANCE_DURATION, DEFAULT_ROUGH_STYLE, DEFAULT_UPDATE_DURATION, DIM_ALPHA, ExpressionError, FACETABLE_TYPES2 as FACETABLE_TYPES, FUNCTION_NAMES, InteractionController, LabelLedger, MIN_VIABLE_PLOT_SIZE, NO_PADDING, RoughPen, SKETCH_FONT_FAMILY, SKETCH_FONT_NAME, SLICER_TYPES, SPEC_ATTR, SR_ONLY_STYLE, Surface, TICK_SIZE, TIME_UNITS, TRANSFORM_KINDS, TYPE_ATTR, Tooltip, VERSION, adaptPaletteForDarkBackground, aggregateValues, analyzeChart, animate, applyA11y, applyAggregate, applyBin, applyCalculate, applyFilter, applyFold, applyPatch, applyPick, applyTimeUnit, applyTransform, applyTransforms, arc, area, assertValidSpec, autoInsightAnnotations, backOut, bandScale, bounceOut, boxesOverlap, buildCartesianInteraction, buildCartesianModel, buildComboModel, buildDataTableFallback, buildFacetModels, buildHistogramModel, buildRenderReport, cartesianInteractionBuilders, cartesianRenderers, categorical, categoricalSchemes, chartTitleText, chartTypeLabel, checkExpression, clamp012 as clamp01, clamp255, compileExpression, compilePredicate, computeBackingSize, computeBins, computeFrame, computeSeriesInsights, contrastRatio, createDiv, createInspector, createRoughPen, createSelectionStore, cubicIn, cubicInOut, cubicOut, curveCatmullRom, curveLinear, curveMonotoneX, curveStep, curveStepAfter, curveStepBefore, customRenderers, darkTheme, decimate, defaultSpan, dependentParams, diverging, divergingColorScale, divergingSchemes, draft, drawAnnotationLabels, drawAnnotations, drawAxesUnderlay, drawDebugCanvas, drawFacet, drawLegend, drawOverlay, drawTitle, drawTrendlineLabels, drawTrendlines, easings, elasticOut, ellipsize, ensureSketchFont, expoInOut, expoOut, facetReport, fillParentStyle, filterRows, fitLabel, fontString, formatCountUp, formatDate, formatNumber2 as formatNumber, formatValue, getDevicePixelRatio, groupBy, hashString, inferFieldType, inferFieldTypes, inferValueType, inheritInto, interpolateArray, interpolateNumber, interpolateNumberArray, interpolateObject, interpolateOklab, interpolateRgb, isBrowser, isEmptyValue, isFaceted, isParamClause, isSlicerType, keyFields, labelBox, legendSwatchWidth, lightTheme, line, linear, linearRegression, linearScale, linearToSrgb, lintSpec, listChartTypes, literalToValue, logScale, lttbRun, makeMatcher, matchesValue, measureText, monotoneXTangents, mulberry32, niceDomain, oklabToOklch, oklabToRgb, oklchToOklab, ordinalColorScale, overlayTextToCanvasCmd, paintCanvasText, paintLegendSwatch, parseColor, parseNumberFormat, pivot, placeViews, pointScale, polygonHachureLines, prefersReducedMotion, prettyDate, profileColumns, quadIn, quadInOut, quadOut, rampFromStops, readableTextColor, recommendChart, recordLabel, relativeLuminance, render, renderDashboard, renderDebugView, renderToContext, repairReport, repairSpec, resolveCurve, resolveDashboardLayout, resolveDashboardSections, resolveEmphasis, resolveEntrance, resolveFilterValues, resolveInsightOptions, resolveSketch, resolveTheme, resolveThemeChecked, resolveUpdate, resolveValueRef, rgbToOklab, rgbaToCss, rotatePoint, roundedRect, sampleArc, sequential, sequentialColorScale, sequentialSchemes, serializeSpec, setMeasureContext, setStyle, sinInOut, slicerParamName, smartDate, specFields, srgbToLinear, starterSpec, stripData, summarize, summarizeChart, themes, tickIncrement, tickStep, ticks, timeScale, timeTickFormat, timeTicks, toHex, toPointer, tooltipEnabled, truncateTo, validateDashboard, validateSpec, wireViews, withAlpha, withSketchFont };
|
|
19459
21457
|
//# sourceMappingURL=index.js.map
|
|
19460
21458
|
//# sourceMappingURL=index.js.map
|