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.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 clamp11 = opts.clamp === true;
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 clamp11 ? clampValue(mapped, Math.min(r0, r1), Math.max(r0, r1)) : mapped;
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 = clamp11 ? clampValue(pixel, Math.min(r0, r1), Math.max(r0, r1)) : pixel;
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: clamp11 });
391
+ return linearScale({ domain: niceDomain(domain[0], domain[1], count), range, clamp: clamp16 });
392
392
  },
393
393
  copy() {
394
- return linearScale({ domain, range, clamp: clamp11 });
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 clamp11 = opts.clamp === true;
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 clamp11 ? clampValue2(r0, Math.min(r0, r1), Math.max(r0, r1)) : r0;
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 clamp11 ? clampValue2(mapped, Math.min(r0, r1), Math.max(r0, r1)) : mapped;
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 = clamp11 ? clampValue2(pixel, Math.min(r0, r1), Math.max(r0, r1)) : pixel;
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: clamp11 });
489
+ return logScale({ domain: niceLogDomain(domain, base), range, base, clamp: clamp16 });
490
490
  },
491
491
  copy() {
492
- return logScale({ domain, range, base, clamp: clamp11 });
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 clamp11 = opts.clamp === true;
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 clamp11 ? clampValue3(mapped, Math.min(r0, r1), Math.max(r0, r1)) : mapped;
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 = clamp11 ? clampValue3(pixel, Math.min(r0, r1), Math.max(r0, r1)) : pixel;
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: clamp11 });
543
+ return timeScale({ domain, range, clamp: clamp16 });
544
544
  }
545
- return timeScale({ domain: [values[0], values[values.length - 1]], range, clamp: clamp11 });
545
+ return timeScale({ domain: [values[0], values[values.length - 1]], range, clamp: clamp16 });
546
546
  },
547
547
  copy() {
548
- return timeScale({ domain, range, clamp: clamp11 });
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 text = t.text;
1869
- return typeof text === "string" && text.trim() ? text.trim() : void 0;
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(text) {
5415
- return text.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim().replace(/\b\w/g, (m) => m.toUpperCase());
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/surface.ts
5691
- var Surface = class {
5692
- constructor(container) {
5693
- __publicField(this, "container");
5694
- __publicField(this, "root");
5695
- __publicField(this, "marks");
5696
- __publicField(this, "interaction");
5697
- __publicField(this, "overlay");
5698
- /** Visually-hidden container for the screen-reader data-table fallback. */
5699
- __publicField(this, "a11y");
5700
- __publicField(this, "width", 0);
5701
- __publicField(this, "height", 0);
5702
- __publicField(this, "dpr", 1);
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
- /** Clear both canvas layers and empty the HTML overlay. */
5758
- clear() {
5759
- this.marks.clear();
5760
- this.interaction.clear();
5761
- this.overlay.replaceChildren();
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
- /** Remove all DOM created by this Surface. */
5764
- destroy() {
5765
- this.clear();
5766
- this.root.remove();
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/text.ts
5864
- var measureCtx;
5865
- var injectedCtx = null;
5866
- function setMeasureContext(ctx) {
5867
- injectedCtx = ctx;
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
- return measureCtx;
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
- ctx.font = font;
5887
- return { width: ctx.measureText(text).width };
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 fontString(size, family, weight = 400, style = "normal") {
5890
- return `${style} ${weight} ${size}px ${family}`;
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
- function ellipsize(text, maxWidth, font) {
5893
- if (maxWidth <= 0 || text.length === 0) return "";
5894
- if (measureText(text, font).width <= maxWidth) return text;
5895
- const ellipsis = "\u2026";
5896
- const ellipsisW = measureText(ellipsis, font).width;
5897
- if (ellipsisW > maxWidth) return "";
5898
- let lo = 0;
5899
- let hi = text.length;
5900
- while (lo < hi) {
5901
- const mid = Math.ceil((lo + hi) / 2);
5902
- if (measureText(text.slice(0, mid) + ellipsis, font).width <= maxWidth) lo = mid;
5903
- else hi = mid - 1;
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
- return text.slice(0, lo) + ellipsis;
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 PALETTE = categorical("graphein");
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: 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: 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
- if (theme == null) return lightTheme;
5980
- if (typeof theme === "string") return themes[theme] ?? lightTheme;
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
- return deepMerge(base, override);
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 repairSpec(spec) {
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) fixes.push(...finding.fix);
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 signature = JSON.stringify(fixes);
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, fixes);
6100
- applied.push(...fixes);
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 needed = widest + 10;
6146
- return available / labels.length >= needed ? 0 : 45;
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 LEGEND_SWATCH + LEGEND_SWATCH_GAP + Math.ceil(w);
7198
+ return m.legendSwatch + m.legendSwatchGap + Math.ceil(w);
6151
7199
  }
6152
- function layoutLegend(items, position, font, maxWidth, maxHeight) {
6153
- const rowH = Math.max(LEGEND_SWATCH, lineHeight(font.size.small));
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 rows = [[]];
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
- rows[row] = [];
7211
+ rows2[row] = [];
6164
7212
  x = 0;
6165
7213
  }
6166
- rows[row].push({ ...item, x, y: row * (rowH + LEGEND_ROW_GAP), width: w });
6167
- x += w + LEGEND_ITEM_GAP;
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
- ...rows.map((r) => r.reduce((acc, it) => acc + it.width + LEGEND_ITEM_GAP, -LEGEND_ITEM_GAP))
7219
+ ...rows2.map((r) => r.reduce((acc, it) => acc + it.width + m.legendItemGap, -16))
6172
7220
  );
6173
- const height2 = rows.length * rowH + (rows.length - 1) * LEGEND_ROW_GAP;
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 rows) {
6177
- const rowWidth = r.reduce((acc, it) => acc + it.width + LEGEND_ITEM_GAP, -LEGEND_ITEM_GAP);
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 + LEGEND_PAD * 2, items: positioned2 };
7229
+ return { width: blockWidth, height: height2 + m.legendPad * 2, items: positioned2, overflow: 0 };
6182
7230
  }
6183
- const maxRows = Math.max(1, Math.floor((maxHeight + LEGEND_ROW_GAP) / (rowH + LEGEND_ROW_GAP)));
6184
- const shown = items.slice(0, maxRows);
6185
- const width = Math.max(0, ...shown.map((it) => legendItemWidth(it.label, font)));
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 + LEGEND_ROW_GAP),
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 height = shown.length * rowH + (shown.length - 1) * LEGEND_ROW_GAP;
6193
- return { width: width + LEGEND_PAD * 2, height, items: positioned };
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 computeFrame(input) {
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(font.size.title);
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(font.size.small);
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 += font.size.base;
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, font, availW, availH);
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 + font.size.base;
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 + font.size.base;
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 + font.size.base;
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 + font.size.base;
7313
+ right -= block.width + layoutFont.size.base;
6240
7314
  }
6241
- const ox2 = frame.legendRect.x + LEGEND_PAD;
6242
- const oy2 = frame.legendRect.y + LEGEND_PAD;
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(font.size.small, font.family, font.weight.normal);
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
- gutter = Math.ceil(widest) + TICK_SIZE + AXIS_LABEL_GAP;
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(font.size.small) / 2);
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(font.size.small, font.family, font.weight.normal);
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) + TICK_SIZE + AXIS_LABEL_GAP;
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(font.size.small) / 2);
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, font);
7361
+ const angle = resolveXLabelAngle(input.xAxis, right - left, layoutFont);
6275
7362
  frame.xLabelAngle = angle;
6276
- const labelH = lineHeight(font.size.small);
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(font.size.small, font.family, font.weight.normal);
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) * 0.4);
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 + TICK_SIZE + AXIS_LABEL_GAP;
6286
- if (input.xAxis.title) gutter += lineHeight(font.size.base) + AXIS_TITLE_GAP;
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(font.size.small, font.family, font.weight.normal);
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 = o.text;
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: item.x + legendSwatchWidth(item.symbol, swatch) + 6,
7882
+ x: textX,
6700
7883
  y: midY,
6701
- text: item.label,
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 swatch = document.createElement("span");
6721
- const size = 11;
6722
- swatch.style.flex = "0 0 auto";
6723
- swatch.style.background = item.color;
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
- swatch.style.width = `${size}px`;
6726
- swatch.style.height = `${size}px`;
6727
- swatch.style.borderRadius = "50%";
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
- swatch.style.width = `${size + 3}px`;
6730
- swatch.style.height = "3px";
6731
- swatch.style.borderRadius = "2px";
7926
+ swatchEl.style.width = `${size + 3}px`;
7927
+ swatchEl.style.height = "3px";
7928
+ swatchEl.style.borderRadius = "2px";
6732
7929
  } else {
6733
- swatch.style.width = `${size}px`;
6734
- swatch.style.height = `${size}px`;
6735
- swatch.style.borderRadius = "2px";
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
- label.textContent = item.label;
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(swatch);
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 clamp11 = args.scaleCfg?.clamp === true;
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: clamp11 }) : linearScale({ domain, range: args.range, clamp: clamp11 });
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 = o.text;
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
- if (surface.headless) {
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 e of entries) maxLabel = Math.max(maxLabel, measureText(e.label, font).width);
8329
- const colW = Math.ceil(swatch + 6 + maxLabel + 4);
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
- let y = area2.y + Math.max(0, (area2.height - entries.length * rowH) / 2);
8332
- for (const e of entries) {
8333
- const row = document.createElement("div");
8334
- row.style.position = "absolute";
8335
- row.style.left = `${x}px`;
8336
- row.style.top = `${y}px`;
8337
- row.style.display = "flex";
8338
- row.style.alignItems = "center";
8339
- row.style.gap = "6px";
8340
- row.style.pointerEvents = "none";
8341
- const label = document.createElement("span");
8342
- label.textContent = e.label;
8343
- label.style.font = font;
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
- return { x: area2.x, y: area2.y, width: Math.max(0, area2.width - colW - gap), height: area2.height };
8352
- }
8353
- const bar = document.createElement("div");
8354
- bar.style.position = "absolute";
8355
- bar.style.left = `${area2.x}px`;
8356
- bar.style.width = `${area2.width}px`;
8357
- bar.style.display = "flex";
8358
- bar.style.flexWrap = "wrap";
8359
- bar.style.justifyContent = "center";
8360
- bar.style.gap = `4px ${gap}px`;
8361
- bar.style.pointerEvents = "none";
8362
- for (const e of entries) {
8363
- const row = document.createElement("div");
8364
- row.style.display = "flex";
8365
- row.style.alignItems = "center";
8366
- row.style.gap = "6px";
8367
- const label = document.createElement("span");
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
- bar.style.top = `${area2.y + area2.height - barH}px`;
8383
- surface.overlay.appendChild(bar);
8384
- return { x: area2.x, y: area2.y, width: area2.width, height: Math.max(0, area2.height - barH - 6) };
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
- addOverlayText(surface, tokens, {
8441
- left: barX,
8442
- top: labelTop,
8443
- text: formatValue(min, spec.encoding.color.format),
8444
- color: tokens.color.textMuted,
8445
- size: tokens.font.size.small
8446
- });
8447
- addOverlayText(surface, tokens, {
8448
- left: barX + barWidth,
8449
- top: labelTop,
8450
- text: formatValue(max, spec.encoding.color.format),
8451
- color: tokens.color.textMuted,
8452
- size: tokens.font.size.small,
8453
- transform: "translateX(-100%)"
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: sy + yScale.bandwidth / 2,
8533
- width: Math.max(0, leftGutter - tokens.spacing.sm),
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
- return { index: slice.index, text, x: cx + insideR * dir.x, y: cy + insideR * dir.y };
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 / 2;
8758
- const bottom = area2.y + area2.height - lineHeight2 / 2;
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 legendPosition = resolveLegendPosition(pie, content.width, content.height);
8891
- const area2 = legendPosition ? drawCategoricalLegend(surface, tokens, content, uniqueLegendEntries(slices), legendPosition) : content;
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 midY = (band.top + band.bottom) / 2;
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: midY - tokens.font.size.small,
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: midY + 2,
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, midY);
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 clamp5(value, min, max) {
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 clamp5(Math.min(sizeByCard, sizeByText), lo, hi);
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 text = `${tri} ${formatValue(delta, cmp.format ?? ".0%")}`;
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) text += ` : ${formatValue(amount, cmp.amountFormat ?? parentFormat ?? ",.0f")}`;
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(clamp5(f.valueSize * 0.4, font.size.small, font.size.title));
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 = clamp5(Math.min(height, 40) * 0.12, 2.5, 4);
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 LABEL_GAP = 6;
11614
+ const LABEL_GAP2 = 6;
10187
11615
  const capGutter = content.width * 0.3;
10188
- const leftGutter = Math.min(colExtent(graph.columns[0] ?? []) + LABEL_GAP + 4, capGutter);
10189
- const rightGutter = graph.maxLayer > 0 ? Math.min(colExtent(graph.columns[graph.maxLayer] ?? []) + LABEL_GAP + 4, capGutter) : 0;
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
- for (const n of graph.nodes) {
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
- const cy = center(n);
10225
- const left = labelRight ? n.x1 + LABEL_GAP : n.x0 - LABEL_GAP;
10226
- const transform2 = labelRight ? "translateY(-50%)" : "translate(-100%,-50%)";
10227
- addOverlayText(surface, tokens, {
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 clamp6 = (v, lo, hi) => v < lo ? lo : v > hi ? hi : v;
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 = clamp6(lat, -85.0511, 85.0511) * Math.PI / 180;
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(clamp6(legend.width * 0.5, 120, 240));
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
- addOverlayText(surface, tokens, {
10389
- left: barX,
10390
- top: labelTop,
10391
- text: formatValue(domain[0], format),
10392
- color: tokens.color.textMuted,
10393
- size: tokens.font.size.small
10394
- });
10395
- addOverlayText(surface, tokens, {
10396
- left: barX + barWidth,
10397
- top: labelTop,
10398
- text: formatValue(domain[1], format),
10399
- color: tokens.color.textMuted,
10400
- size: tokens.font.size.small,
10401
- transform: "translateX(-100%)"
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 text = document.createElement("span");
11084
- text.textContent = cf.showValue === false ? "" : formatDisplayValue(cell.value, column);
11085
- setStyles(text, overlayTextStyles(opts.tokens.color.text));
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(text);
11094
- applyNegativeStyle(text, cell.value, column);
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, text);
11110
- applyNegativeStyle(text, cell.value, column);
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 text = conditional.showValue === false ? "" : formatDisplayValue(cell.value, column);
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 text = ellipsize(input.text, textMax, font);
11344
- const textW = measureText(text, font).width;
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, text, font, input.color, opts.tokens.font.size.small);
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 text = ellipsize(input.text, maxWidth, font);
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, text, font, input.color, opts.tokens.font.size.small, input.align);
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, text, font, color, size, align = "left") {
11374
- if (!text) return;
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 text = document.createElement("span");
12259
- text.textContent = label;
12260
- Object.assign(text.style, {
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(text);
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 text = document.createElement("span");
12325
- text.textContent = opts.label;
12326
- Object.assign(text.style, {
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(text);
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 = clamp7(opts.low, opts.min, opts.max);
12357
- let high = clamp7(opts.high, low, opts.max);
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 clamp7(snapped, opts.min, opts.max);
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 = clamp7(v, opts.min, high);
12460
- else high = clamp7(v, low, opts.max);
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 = clamp7(quantize(low + dir * step), opts.min, high);
12479
- else high = clamp7(quantize(high + dir * step), low, opts.max);
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 = clamp7(nextLow, opts.min, opts.max);
12490
- high = clamp7(nextHigh, low, opts.max);
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 clamp7(v, lo, hi) {
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 = clamp7(pct, 0, 100) / 100;
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 text = value || (s.placeholder ?? `Search ${s.field}\u2026`);
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
- if (surface.headless) {
13912
- const ctx = surface.marks.ctx;
13913
- for (const t of model.right.ticks) {
13914
- paintCanvasText(ctx, {
13915
- x: labelLeft,
13916
- y: t.pos,
13917
- text: t.label,
13918
- font: smallFont,
13919
- color: tokens.color.textMuted,
13920
- size: f.size.small,
13921
- baseline: "middle"
13922
- });
13923
- }
13924
- if (model.right.title) {
13925
- const titleFont = fontString(f.size.base, f.family, f.weight.medium);
13926
- paintCanvasText(
13927
- ctx,
13928
- overlayTextToCanvasCmd(
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
- const el = document.createElement("div");
13960
- el.textContent = model.right.title;
13961
- el.style.position = "absolute";
13962
- el.style.left = `${frame.width - Math.round(f.size.base * 0.4)}px`;
13963
- el.style.top = `${plot.y + plot.height / 2}px`;
13964
- el.style.font = fontString(f.size.base, f.family, f.weight.medium);
13965
- el.style.fontSize = `${f.size.base}px`;
13966
- el.style.fontWeight = String(f.weight.medium);
13967
- el.style.color = tokens.color.text;
13968
- el.style.whiteSpace = "nowrap";
13969
- el.style.transform = "translate(-50%, -50%) rotate(90deg)";
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 numericValues = color ? leaves.map((leaf) => leaf.colorNumber).filter((n) => n !== void 0) : [];
14297
- const numericColor = Boolean(color && (color.type === "quantitative" || numericValues.length > 0 && numericValues.length === leaves.length));
14298
- const domain = numericValues.length ? [Math.min(...numericValues), Math.max(...numericValues)] : [0, 1];
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 clamp8(value, min, max) {
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, text) {
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 LEGEND_SWATCH3 = 11;
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(LEGEND_SWATCH3 + 6 + maxLabel + 4);
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, LEGEND_SWATCH3);
16106
+ paintLegendSwatch(ctx, x, midY, "square", e.color, LEGEND_SWATCH4);
14584
16107
  paintCanvasText(ctx, {
14585
- x: x + LEGEND_SWATCH3 + 6,
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 = `${LEGEND_SWATCH3}px`;
14609
- swatch.style.height = `${LEGEND_SWATCH3}px`;
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 = clamp8(provR * 0.17, 14, 54);
14674
- const edgeGuard = trackWidth / 2 + clamp8(trackWidth * 0.3, 4, 10) + 1;
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 - clamp8((v - min) / (max - min), 0, 1) * 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 = clamp8(trackWidth * 0.42, 6, 22);
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 = clamp8(trackWidth * 0.3, 4, 10);
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 = clamp8(radius * 0.5, 22, 72);
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 clamp9(value, min, max) {
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 = clamp9(measureText(caption, labelFont).width + 14, 44, Math.max(72, content.width * 0.4));
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 = clamp9(content.height * 0.22, 28, 40);
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) => clamp9(bulletValueToX(v, min, max, track), track.x, track.x + track.width);
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 = clamp9(xOf(value) - valueLabelW / 2, content.x, content.x + content.width - valueLabelW);
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: clamp9(tx - tickW / 2, track.x, track.x + track.width - tickW),
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 xShow = spec.axes?.x?.show !== false;
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 = { ...spec.axes, x: { grid: false, ...spec.axes?.x } };
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 text = barLabelText(bar, model.format);
15468
- const top = Math.max(plot.y - 1, bar.rect.y - size - 3);
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: bar.rect.x - 6,
17017
+ left,
15471
17018
  top,
15472
- width: bar.rect.width + 12,
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
- leftLabelW = Math.ceil(leftLabelW);
15597
- rightLabelW = Math.ceil(rightLabelW);
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 + 4;
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 xShow = spec.axes?.x?.show !== false;
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: false },
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 = { ...spec.axes, y: { grid: false, show: false, ...spec.axes?.y } };
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
- addOverlayText(surface, tokens, {
15755
- left: first.x - 8 - leftW,
15756
- top: first.y - labelSize - 2,
15757
- width: leftW,
15758
- text: series.label,
15759
- color: series.color,
15760
- size: labelSize,
15761
- weight: tokens.font.weight.bold,
15762
- align: "right",
15763
- opacity
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: last.x + 8,
15778
- top: last.y - valueSize / 2,
15779
- width: rightW,
15780
- text: fmtValue(last.value, model.format),
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
- weight: tokens.font.weight.medium,
15784
- align: "left",
15785
- opacity
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: x,
17687
+ left,
16056
17688
  top: plot.y + plot.height + 6,
16057
- text: valueLabel(tick, model.format),
17689
+ width: budget,
17690
+ text: text2,
16058
17691
  color: tokens.color.textMuted,
16059
17692
  size: tokens.font.size.tiny,
16060
- transform: "translateX(-50%)"
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
- addOverlayText(surface, tokens, {
16089
- left: content.x,
16090
- top: cy,
16091
- width: Math.max(0, leftGutter - tokens.spacing.sm),
16092
- text: row.catLabel,
16093
- color: tokens.color.text,
16094
- size: tokens.font.size.small,
16095
- align: "right",
16096
- transform: "translateY(-50%)"
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 atLeft = i === 0 && pixelDots.length > 1;
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: 54,
16106
- text: formatDumbbellValue(dot.value, model.format),
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 labelBox(model, horizontalRule, pos, p) {
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 = labelBox(model, drawsHorizontalRule(model, axis), ann.labelPosition ?? "end", p);
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 text = `R\xB2=${g.fit.r2.toFixed(2)}`;
16472
- const labelW = measureText(text, font).width;
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 = text;
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
- checkAxisLabelOverlap(model, spec, tokens, add);
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 text = "";
19353
+ let text2 = "";
17594
19354
  try {
17595
- text = summarize(spec);
19355
+ text2 = summarize(spec);
17596
19356
  } catch {
17597
- text = "";
19357
+ text2 = "";
17598
19358
  }
17599
- body.textContent = text || "\u2014 (no summary for this chart type)";
17600
- if (!text) setStyle(body, { color: tokens.color.textMuted });
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 clamp10 = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
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 = clamp10(Math.round(w * effCols / group.baseCols), 1, effCols);
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, 5, 30);
19298
- ctx.fillStyle = tokens.color.text;
19299
- ctx.textBaseline = "top";
19300
- ctx.font = `${tokens.font.weight.bold} ${tokens.font.size.title}px ${tokens.font.family}`;
19301
- if (titleText3) ctx.fillText(titleText3, pad3 + 14, cursorY + 2);
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
- ctx.fillStyle = tokens.color.textMuted;
19304
- ctx.font = `${tokens.font.weight.normal} ${tokens.font.size.small}px ${tokens.font.family}`;
19305
- ctx.fillText(spec.subtitle, pad3 + 14, cursorY + 24);
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
- ctx.save();
19319
- ctx.fillStyle = tokens.color.text;
19320
- ctx.textBaseline = "top";
19321
- ctx.font = `${tokens.font.weight.bold} ${tokens.font.size.base}px ${tokens.font.family}`;
19322
- ctx.fillText(section.section.title, pad3, cursorY);
19323
- ctx.restore();
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
- const cols = Math.max(1, section.cols);
19327
- const innerW = target.width - pad3 * 2;
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, cols, layout2.preset);
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
- let gx = cell.x;
19334
- let gy = cell.y;
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 + cell.w - 1 > cols) {
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 += cell.w;
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 = cell.w * colW + (cell.w - 1) * gap;
21207
+ const cw = scaledW * colW + (scaledW - 1) * gap;
19349
21208
  const ch = cell.h * section.rowHeight + (cell.h - 1) * gap;
19350
- const viewSpec = inheritInto(cell.view.spec, spec, tokens);
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(cx, cy);
21258
+ ctx.translate(contentX, contentY);
19353
21259
  ctx.beginPath();
19354
- ctx.rect(0, 0, cw, ch);
21260
+ ctx.rect(0, 0, contentW, contentH);
19355
21261
  ctx.clip();
19356
21262
  try {
19357
- const r = renderToContext({ marks: ctx, interaction: target.interaction, width: cw, height: ch }, viewSpec);
19358
- markCount += r.markCount;
19359
- if (!r.ok) allOk = false;
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({ type, spec, data, tokens, size, model: reportModel });
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.1.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