@classic-homes/slides 0.5.12 → 0.6.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.
@@ -241,15 +241,40 @@ var CHART_TYPES = [
241
241
  "funnel",
242
242
  "gauge",
243
243
  "scatter",
244
- "radar"
244
+ "radar",
245
+ "percent-column",
246
+ "percent-bar",
247
+ "combo",
248
+ "waterfall",
249
+ "heatmap",
250
+ "dumbbell",
251
+ "bullet",
252
+ "treemap",
253
+ "ring",
254
+ "sankey",
255
+ "pictogram"
256
+ ];
257
+ var SINGLE_SERIES_CHARTS = [
258
+ "pie",
259
+ "donut",
260
+ "funnel",
261
+ "gauge",
262
+ "waterfall",
263
+ "treemap",
264
+ "ring",
265
+ "pictogram"
245
266
  ];
246
267
  var ChartSeries = z3.object({
247
268
  name: z3.string().min(1).max(40),
248
- values: z3.array(z3.number()).min(1).max(40)
269
+ values: z3.array(z3.number()).min(1).max(40),
270
+ as: z3.enum(["bar", "line"]).optional().describe(
271
+ "combo only: draw this series as bars or a line. Default: first series bars, the rest lines."
272
+ ),
273
+ axis: z3.enum(["primary", "secondary"]).optional().describe("combo only: secondary puts this series on a right-hand axis with its own scale.")
249
274
  });
250
275
  var ChartSpec = z3.object({
251
276
  type: z3.enum(CHART_TYPES).describe(
252
- "column/bar compare categories; stacked-* show parts of a whole per category; line/area show change over time; pie/donut show shares (one series); funnel shows drop-off; gauge shows one value against a max; scatter pairs two series; radar compares profiles."
277
+ "column/bar compare categories; stacked-* show parts of a whole per category; percent-column/percent-bar show each category as 100%; line/area show change over time; combo mixes bars and lines (optionally on two axes); pie/donut show shares (one series); treemap shows shares of many categories; funnel shows drop-off; waterfall walks from a start to an end through gains and losses; gauge shows one value against a max; ring shows each label as a progress ring against max; bullet compares actual (series 1) with target (series 2); dumbbell compares two or more points per category (before/after); heatmap colours a rows \xD7 columns grid (labels are rows, series are columns); sankey shows flows (labels are sources, series names are destinations); pictogram counts in house icons; scatter pairs two series; radar compares profiles."
253
278
  ),
254
279
  labels: z3.array(z3.string().max(40)).min(1).max(40).describe("Category labels (x-axis, slices, stages). One per value."),
255
280
  series: z3.array(ChartSeries).min(1).max(6).describe(
@@ -261,7 +286,14 @@ var ChartSpec = z3.object({
261
286
  legend: z3.enum(["auto", "show", "hide"]).default("auto"),
262
287
  labelsOnData: z3.boolean().default(true).describe("Print values on bars, slices and points."),
263
288
  highlight: z3.string().optional().describe("A label to emphasise; other categories are muted."),
264
- max: z3.number().optional().describe("Axis or gauge maximum.")
289
+ max: z3.number().optional().describe("Axis, gauge, ring or bullet maximum."),
290
+ secondaryFormat: z3.enum(["number", "percent", "currency", "compact"]).optional().describe("combo only: how the secondary axis values are shown (default: format)."),
291
+ totals: z3.array(z3.string().max(40)).max(40).optional().describe(
292
+ 'waterfall only: labels drawn as running-total bars (e.g. "Subtotal", "End"); their values are ignored.'
293
+ ),
294
+ perIcon: z3.number().positive().optional().describe(
295
+ "pictogram only: what one house icon stands for (default: a round step, \u2264 20 icons)."
296
+ )
265
297
  }).strict();
266
298
  function chartIssues(spec) {
267
299
  const out = [];
@@ -275,8 +307,69 @@ function chartIssues(spec) {
275
307
  if (spec.type === "scatter" && spec.series.length < 2)
276
308
  out.push("Scatter needs two series (x and y).");
277
309
  if (spec.type === "gauge" && spec.max === void 0) out.push("Gauge needs a max.");
310
+ if (spec.type === "ring" && ringMax(spec) === void 0) {
311
+ out.push("Ring needs a max (or format percent, which defaults to 100).");
312
+ }
313
+ if (spec.type === "dumbbell" && spec.series.length < 2) {
314
+ out.push("Dumbbell needs at least two series (e.g. before and after).");
315
+ }
316
+ if (["treemap", "ring", "pictogram", "sankey"].includes(spec.type)) {
317
+ const used = spec.type === "sankey" ? spec.series : spec.series.slice(0, 1);
318
+ if (used.some((s) => s.values.some((v) => v < 0))) {
319
+ out.push(`${spec.type} values can't be negative.`);
320
+ }
321
+ }
322
+ if (spec.type === "waterfall") {
323
+ for (const t of spec.totals ?? []) {
324
+ if (!spec.labels.includes(t)) out.push(`Total "${t}" isn't one of the labels.`);
325
+ }
326
+ }
327
+ if (spec.type === "sankey") out.push(...sankeyIssues(spec));
328
+ if (spec.type === "pictogram" && spec.perIcon !== void 0) {
329
+ const most = Math.round(Math.max(...spec.series[0].values) / spec.perIcon);
330
+ if (most > 60)
331
+ out.push(`perIcon ${spec.perIcon} draws ${most} icons in a row; use a larger perIcon.`);
332
+ }
333
+ return out;
334
+ }
335
+ function ringMax(spec) {
336
+ return spec.max ?? (spec.format === "percent" ? 100 : void 0);
337
+ }
338
+ function sankeyIssues(spec) {
339
+ const out = [];
340
+ const seen = /* @__PURE__ */ new Set();
341
+ for (const l of spec.labels) {
342
+ if (seen.has(l)) out.push(`Source "${l}" appears twice; combine the rows.`);
343
+ seen.add(l);
344
+ }
345
+ const edges = sankeyLinks(spec);
346
+ if (!edges.length) out.push("Sankey needs at least one flow above zero.");
347
+ for (const e of edges) {
348
+ if (e.source === e.target) out.push(`"${e.source}" can't flow to itself.`);
349
+ }
350
+ const next = /* @__PURE__ */ new Map();
351
+ for (const e of edges) {
352
+ if (e.source !== e.target) next.set(e.source, [...next.get(e.source) ?? [], e.target]);
353
+ }
354
+ const state = /* @__PURE__ */ new Map();
355
+ const cyclic = (n) => {
356
+ if (state.get(n) === "open") return true;
357
+ if (state.get(n) === "done") return false;
358
+ state.set(n, "open");
359
+ const hit = (next.get(n) ?? []).some(cyclic);
360
+ state.set(n, "done");
361
+ return hit;
362
+ };
363
+ if (edges.some((e) => cyclic(e.source))) {
364
+ out.push("Flows loop back on themselves; a sankey must only flow one way.");
365
+ }
278
366
  return out;
279
367
  }
368
+ function sankeyLinks(spec) {
369
+ return spec.labels.flatMap(
370
+ (source, i) => spec.series.filter((s) => (s.values[i] ?? 0) > 0).map((s) => ({ source, target: s.name, value: s.values[i] }))
371
+ );
372
+ }
280
373
  function chartWarnings(spec) {
281
374
  const out = [];
282
375
  if (["pie", "donut"].includes(spec.type) && spec.labels.length > 7) {
@@ -284,10 +377,49 @@ function chartWarnings(spec) {
284
377
  `${spec.labels.length} slices is hard to read; group small ones into "Other" or use a bar chart.`
285
378
  );
286
379
  }
287
- if (["pie", "donut", "funnel", "gauge"].includes(spec.type) && spec.series.length > 1) {
380
+ if (SINGLE_SERIES_CHARTS.includes(spec.type) && spec.series.length > 1) {
288
381
  out.push(`${spec.type} charts only show the first series.`);
289
382
  }
290
- if (spec.labels.length > 16 && ["column", "bar", "stacked-column", "stacked-bar"].includes(spec.type)) {
383
+ if (spec.type === "bullet" && spec.series.length > 2) {
384
+ out.push("Bullet charts show series 1 as actual and series 2 as target; the rest are ignored.");
385
+ }
386
+ if (spec.type === "ring" && spec.labels.length > 4) {
387
+ out.push("More than 4 rings gets small; show the rest in a table or bar chart.");
388
+ }
389
+ if (spec.type === "treemap" && spec.labels.length > 20) {
390
+ out.push('More than 20 boxes is hard to label; group small ones into "Other".');
391
+ }
392
+ if (spec.type === "heatmap" && spec.series.length > 12) {
393
+ out.push("More than 12 columns crowds the heatmap.");
394
+ }
395
+ if (spec.type === "sankey" && (/* @__PURE__ */ new Set([...spec.labels, ...spec.series.map((s) => s.name)])).size > 12) {
396
+ out.push("More than 12 nodes makes a sankey hard to follow.");
397
+ }
398
+ if (spec.type === "pictogram" && spec.labels.length > 8) {
399
+ out.push("More than 8 rows of icons gets cramped; use a bar chart.");
400
+ }
401
+ if (spec.type !== "combo" && spec.series.some((s) => s.as || s.axis)) {
402
+ out.push('Series "as" and "axis" only apply to combo charts.');
403
+ }
404
+ if (spec.type !== "combo" && spec.secondaryFormat) {
405
+ out.push("secondaryFormat only applies to combo charts.");
406
+ }
407
+ if (spec.type !== "waterfall" && spec.totals?.length) {
408
+ out.push("totals only apply to waterfall charts.");
409
+ }
410
+ if (spec.type !== "pictogram" && spec.perIcon !== void 0) {
411
+ out.push("perIcon only applies to pictogram charts.");
412
+ }
413
+ if (spec.labels.length > 16 && [
414
+ "column",
415
+ "bar",
416
+ "stacked-column",
417
+ "stacked-bar",
418
+ "percent-column",
419
+ "percent-bar",
420
+ "combo",
421
+ "waterfall"
422
+ ].includes(spec.type)) {
291
423
  out.push("More than 16 categories crowds the axis; consider a line chart or fewer categories.");
292
424
  }
293
425
  return out;
@@ -320,6 +452,35 @@ function chartColors(palette) {
320
452
  var MUTED = "#c9ccc6";
321
453
  var TEXT = "#4f4f4f";
322
454
  var AXIS = "#d1d5db";
455
+ var TRACK = "#e5e7eb";
456
+ var SPLIT = "#eef0ec";
457
+ var HOUSE = "path://M12 2L1.5 11H5v11h5.5v-7h3v7H19V11h3.5z";
458
+ function mix(a, b, t) {
459
+ const ch = (hex, i) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16);
460
+ const out = [0, 1, 2].map((i) => Math.round(ch(a, i) + (ch(b, i) - ch(a, i)) * t));
461
+ return `#${out.map((n) => n.toString(16).padStart(2, "0")).join("")}`;
462
+ }
463
+ function niceCeil(v, steps = [1, 2, 2.5, 5, 10]) {
464
+ if (v <= 0) return 1;
465
+ const pow = 10 ** Math.floor(Math.log10(v));
466
+ return (steps.find((m) => m * pow >= v) ?? 10) * pow;
467
+ }
468
+ function toPercent(spec) {
469
+ const totals = spec.labels.map(
470
+ (_, i) => spec.series.reduce((sum, s) => sum + Math.abs(s.values[i] ?? 0), 0)
471
+ );
472
+ return {
473
+ ...spec,
474
+ type: spec.type === "percent-bar" ? "stacked-bar" : "stacked-column",
475
+ format: "percent",
476
+ unit: void 0,
477
+ max: 100,
478
+ series: spec.series.map((s) => ({
479
+ ...s,
480
+ values: s.values.map((v, i) => totals[i] ? Math.abs(v) / totals[i] * 100 : 0)
481
+ }))
482
+ };
483
+ }
323
484
  function chartOption(spec, palette) {
324
485
  const colors3 = chartColors(palette);
325
486
  const fmt = (v) => formatValue(v, spec);
@@ -339,6 +500,363 @@ function chartOption(spec, palette) {
339
500
  fontSize: 13,
340
501
  formatter: (p) => fmt(p.value)
341
502
  };
503
+ const categoryAxis = (gap) => ({
504
+ type: "category",
505
+ data: spec.labels,
506
+ axisLine: { lineStyle: { color: AXIS } },
507
+ axisTick: { show: false },
508
+ axisLabel: { color: TEXT, fontSize: 14, interval: 0, hideOverlap: true },
509
+ boundaryGap: gap
510
+ });
511
+ const valueAxis = (s = spec) => ({
512
+ type: "value",
513
+ max: spec.max,
514
+ axisLine: { show: false },
515
+ splitLine: { lineStyle: { color: SPLIT } },
516
+ axisLabel: {
517
+ color: "#7a7a7a",
518
+ fontSize: 13,
519
+ formatter: (v) => formatValue(v, { ...s, unit: void 0 })
520
+ }
521
+ });
522
+ const rowAxis = () => ({ ...categoryAxis(true), inverse: true });
523
+ if (spec.type === "percent-column" || spec.type === "percent-bar") {
524
+ return chartOption(toPercent(spec), palette);
525
+ }
526
+ if (spec.type === "waterfall") {
527
+ const totals = new Set(spec.totals ?? []);
528
+ let running = 0;
529
+ const steps = spec.labels.map((name, i) => {
530
+ if (totals.has(name)) return { name, base: 0, delta: running, total: true };
531
+ const delta = first.values[i] ?? 0;
532
+ const step = { name, base: running, delta, total: false };
533
+ running += delta;
534
+ return step;
535
+ });
536
+ const stack = { type: "bar", stack: "waterfall", stackStrategy: "all", barMaxWidth: 56 };
537
+ return {
538
+ ...base,
539
+ legend: { show: false },
540
+ grid: { left: 16, right: 24, top: 28, bottom: 12, containLabel: true },
541
+ xAxis: categoryAxis(true),
542
+ yAxis: valueAxis(),
543
+ series: [
544
+ // Invisible spacer lifts each step to the running total.
545
+ {
546
+ ...stack,
547
+ silent: true,
548
+ itemStyle: { color: "transparent" },
549
+ data: steps.map((s) => s.base)
550
+ },
551
+ {
552
+ ...stack,
553
+ data: steps.map((s) => ({
554
+ value: s.delta,
555
+ itemStyle: {
556
+ color: muted(s.name, s.total ? colors3[2] : s.delta >= 0 ? colors3[1] : colors3[0]),
557
+ borderRadius: 3
558
+ },
559
+ label: { position: s.delta < 0 ? "bottom" : "top" }
560
+ })),
561
+ label: {
562
+ ...labelStyle,
563
+ formatter: (p) => {
564
+ const s = steps[p.dataIndex];
565
+ if (s.total || p.dataIndex === 0) return fmt(s.delta);
566
+ return `${s.delta < 0 ? "\u2212" : "+"}${fmt(Math.abs(s.delta))}`;
567
+ }
568
+ }
569
+ }
570
+ ]
571
+ };
572
+ }
573
+ if (spec.type === "heatmap") {
574
+ const all = spec.series.flatMap((s) => s.values);
575
+ const lo = Math.min(...all);
576
+ const span = (spec.max ?? Math.max(...all)) - lo || 1;
577
+ const light = mix(colors3[1], "#ffffff", 0.88);
578
+ return {
579
+ ...base,
580
+ legend: { show: false },
581
+ // ECharts heatmaps need a visualMap; ours is hidden (cells carry their values).
582
+ visualMap: {
583
+ show: false,
584
+ min: lo,
585
+ max: lo + span,
586
+ dimension: 2,
587
+ inRange: { color: [light, colors3[1], colors3[2]] }
588
+ },
589
+ grid: { left: 16, right: 16, top: 36, bottom: 8, containLabel: true },
590
+ xAxis: {
591
+ type: "category",
592
+ data: spec.series.map((s) => s.name),
593
+ position: "top",
594
+ axisLine: { show: false },
595
+ axisTick: { show: false },
596
+ axisLabel: { color: TEXT, fontSize: 14, interval: 0 }
597
+ },
598
+ yAxis: { ...rowAxis(), axisLine: { show: false } },
599
+ series: [
600
+ {
601
+ type: "heatmap",
602
+ itemStyle: { borderColor: "#fff", borderWidth: 3 },
603
+ data: spec.labels.flatMap(
604
+ (_, y) => spec.series.map((s, x) => {
605
+ const v = s.values[y] ?? 0;
606
+ const t = Math.min(1, Math.max(0, (v - lo) / span));
607
+ return {
608
+ value: [x, y, v],
609
+ label: { color: t > 0.3 ? "#fff" : TEXT }
610
+ };
611
+ })
612
+ ),
613
+ label: {
614
+ show: spec.labelsOnData,
615
+ fontSize: 14,
616
+ formatter: (p) => fmt(p.value[2])
617
+ }
618
+ }
619
+ ]
620
+ };
621
+ }
622
+ if (spec.type === "dumbbell") {
623
+ const lows = spec.labels.map((_, i) => Math.min(...spec.series.map((s) => s.values[i] ?? 0)));
624
+ const highs = spec.labels.map((_, i) => Math.max(...spec.series.map((s) => s.values[i] ?? 0)));
625
+ const bar = { type: "bar", stack: "span", stackStrategy: "all", silent: true, barWidth: 4 };
626
+ return {
627
+ ...base,
628
+ legend: spec.legend === "hide" ? { show: false } : { ...base.legend, show: true, data: spec.series.map((s) => s.name) },
629
+ grid: { left: 16, right: 90, top: 48, bottom: 12, containLabel: true },
630
+ xAxis: { ...valueAxis(), scale: true },
631
+ yAxis: rowAxis(),
632
+ series: [
633
+ // The connector: an invisible spacer to the low dot, then a thin bar to the high one.
634
+ { ...bar, itemStyle: { color: "transparent" }, data: lows },
635
+ { ...bar, itemStyle: { color: AXIS }, data: highs.map((h, i) => h - lows[i]) },
636
+ ...spec.series.map((s, si) => ({
637
+ type: "scatter",
638
+ name: s.name,
639
+ symbolSize: 18,
640
+ z: 3,
641
+ itemStyle: { color: colors3[si % colors3.length], opacity: 1 },
642
+ data: s.values.map((v, i) => ({
643
+ value: [v, i],
644
+ itemStyle: { color: muted(spec.labels[i], colors3[si % colors3.length]), opacity: 1 },
645
+ label: {
646
+ position: lows[i] === highs[i] ? "top" : v === lows[i] ? "left" : v === highs[i] ? "right" : "top"
647
+ }
648
+ })),
649
+ label: {
650
+ ...labelStyle,
651
+ distance: 10,
652
+ formatter: (p) => fmt(p.value[0])
653
+ }
654
+ }))
655
+ ]
656
+ };
657
+ }
658
+ if (spec.type === "bullet") {
659
+ const [actual, target] = spec.series;
660
+ const top = spec.max ?? niceCeil(Math.max(...actual.values, ...target?.values ?? []) * 1.05);
661
+ const showTarget = Boolean(target) && spec.legend !== "hide";
662
+ return {
663
+ ...base,
664
+ legend: showTarget || spec.legend === "show" ? { ...base.legend, show: true, data: spec.series.slice(0, 2).map((s) => s.name) } : { show: false },
665
+ grid: { left: 16, right: 40, top: showTarget ? 48 : 20, bottom: 12, containLabel: true },
666
+ xAxis: { ...valueAxis(), max: top },
667
+ yAxis: rowAxis(),
668
+ series: [
669
+ {
670
+ type: "bar",
671
+ name: actual.name,
672
+ barWidth: 16,
673
+ showBackground: true,
674
+ backgroundStyle: { color: TRACK, borderRadius: 3 },
675
+ data: actual.values.map((v, i) => ({
676
+ value: v,
677
+ itemStyle: { color: muted(spec.labels[i], colors3[0]), borderRadius: 3 }
678
+ })),
679
+ label: { ...labelStyle, position: "right", distance: 10 }
680
+ },
681
+ ...target ? [
682
+ {
683
+ type: "scatter",
684
+ name: target.name,
685
+ symbol: "rect",
686
+ symbolSize: [5, 34],
687
+ z: 3,
688
+ itemStyle: { color: colors3[2] },
689
+ data: target.values.map((v, i) => [v, i])
690
+ }
691
+ ] : []
692
+ ]
693
+ };
694
+ }
695
+ if (spec.type === "treemap") {
696
+ return {
697
+ ...base,
698
+ legend: { show: false },
699
+ series: [
700
+ {
701
+ type: "treemap",
702
+ left: 0,
703
+ right: 0,
704
+ top: 0,
705
+ bottom: 0,
706
+ roam: false,
707
+ nodeClick: false,
708
+ breadcrumb: { show: false },
709
+ itemStyle: { borderColor: "#fff", borderWidth: 3, gapWidth: 3 },
710
+ data: spec.labels.map((name, i) => ({
711
+ name,
712
+ value: first.values[i],
713
+ itemStyle: { color: muted(name, colors3[i % colors3.length]) }
714
+ })),
715
+ label: {
716
+ show: true,
717
+ color: "#fff",
718
+ fontSize: 15,
719
+ overflow: "truncate",
720
+ formatter: (p) => spec.labelsOnData ? `${p.name}
721
+ ${fmt(p.value)}` : p.name
722
+ }
723
+ }
724
+ ]
725
+ };
726
+ }
727
+ if (spec.type === "ring") {
728
+ const top = ringMax(spec) ?? 100;
729
+ const n = Math.min(spec.labels.length, 4);
730
+ const outer = [72, 64, 46, 34][n - 1];
731
+ const size = [44, 40, 30, 24][n - 1];
732
+ const plain2 = (s) => s.replace(/[{}|]/g, "");
733
+ return {
734
+ ...base,
735
+ legend: { show: false },
736
+ series: spec.labels.slice(0, n).map((name, i) => {
737
+ const v = first.values[i] ?? 0;
738
+ return {
739
+ type: "pie",
740
+ silent: true,
741
+ startAngle: 90,
742
+ radius: [`${outer - 12}%`, `${outer}%`],
743
+ center: [`${(i + 0.5) / n * 100}%`, "50%"],
744
+ labelLine: { show: false },
745
+ data: [
746
+ {
747
+ value: Math.min(Math.max(v, 0), top),
748
+ name,
749
+ itemStyle: { color: muted(name, colors3[i % colors3.length]) },
750
+ label: {
751
+ show: true,
752
+ position: "center",
753
+ formatter: `{v|${plain2(fmt(v))}}
754
+ {l|${plain2(name)}}`,
755
+ rich: {
756
+ v: { fontSize: size, fontWeight: 600, color: colors3[2], lineHeight: size * 1.2 },
757
+ l: { fontSize: 15, color: TEXT, lineHeight: 22 }
758
+ }
759
+ }
760
+ },
761
+ {
762
+ value: Math.max(0, top - v),
763
+ name: "",
764
+ itemStyle: { color: TRACK },
765
+ label: { show: false }
766
+ }
767
+ ]
768
+ };
769
+ })
770
+ };
771
+ }
772
+ if (spec.type === "sankey") {
773
+ const nodes = [.../* @__PURE__ */ new Set([...spec.labels, ...spec.series.map((s) => s.name)])];
774
+ return {
775
+ ...base,
776
+ legend: { show: false },
777
+ series: [
778
+ {
779
+ type: "sankey",
780
+ left: 16,
781
+ right: 150,
782
+ top: 16,
783
+ bottom: 16,
784
+ nodeWidth: 18,
785
+ nodeGap: 14,
786
+ draggable: false,
787
+ emphasis: { disabled: true },
788
+ data: nodes.map((name, i) => ({
789
+ name,
790
+ itemStyle: { color: muted(name, colors3[i % colors3.length]), borderWidth: 0 }
791
+ })),
792
+ links: sankeyLinks(spec),
793
+ lineStyle: { color: "source", opacity: 0.3, curveness: 0.5 },
794
+ label: {
795
+ color: TEXT,
796
+ fontSize: 14,
797
+ formatter: (p) => spec.labelsOnData ? `${p.name} ${fmt(p.value)}` : p.name
798
+ }
799
+ }
800
+ ]
801
+ };
802
+ }
803
+ if (spec.type === "pictogram") {
804
+ const per = spec.perIcon ?? Math.max(1, niceCeil(Math.max(...first.values) / 20, [1, 2, 5, 10]));
805
+ const icons = first.values.map((v) => Math.round(v / per));
806
+ const slots = Math.max(1, ...icons);
807
+ const glyph = {
808
+ type: "pictorialBar",
809
+ symbol: HOUSE,
810
+ symbolRepeat: slots,
811
+ symbolBoundingData: slots,
812
+ // Fixed size keeps houses square; ECharts spaces them across each slot.
813
+ symbolSize: Math.round(Math.min(40, 260 / spec.labels.length)),
814
+ symbolClip: true,
815
+ barCategoryGap: "25%"
816
+ };
817
+ return {
818
+ ...base,
819
+ legend: { show: false },
820
+ grid: { left: 16, right: 16, top: 36, bottom: 8, containLabel: true },
821
+ xAxis: { type: "value", max: slots, show: false },
822
+ yAxis: [
823
+ {
824
+ ...rowAxis(),
825
+ axisLine: { show: false },
826
+ name: `\u2302 = ${formatValue(per, { ...spec, decimals: Math.min(3, (String(per).split(".")[1] ?? "").length) })}`,
827
+ nameLocation: "start",
828
+ nameTextStyle: { color: "#7a7a7a", fontSize: 13, align: "left" }
829
+ },
830
+ {
831
+ ...rowAxis(),
832
+ position: "right",
833
+ axisLine: { show: false },
834
+ data: first.values.map(fmt),
835
+ axisLabel: {
836
+ show: spec.labelsOnData,
837
+ color: colors3[2],
838
+ fontSize: 15,
839
+ fontWeight: 600
840
+ }
841
+ }
842
+ ],
843
+ series: [
844
+ // Faded full row behind the filled icons.
845
+ { ...glyph, silent: true, itemStyle: { color: TRACK }, data: icons.map(() => slots) },
846
+ {
847
+ ...glyph,
848
+ barGap: "-100%",
849
+ z: 3,
850
+ data: icons.map((v, i) => ({
851
+ value: v,
852
+ // Clipping a full row trims the last icon's edge; partial rows clip between icons.
853
+ symbolClip: v < slots,
854
+ itemStyle: { color: muted(spec.labels[i], colors3[0]) }
855
+ }))
856
+ }
857
+ ]
858
+ };
859
+ }
342
860
  if (spec.type === "pie" || spec.type === "donut") {
343
861
  return {
344
862
  ...base,
@@ -492,37 +1010,32 @@ ${fmt(p.value)}` : p.name
492
1010
  }
493
1011
  const horizontal = spec.type === "bar" || spec.type === "stacked-bar";
494
1012
  const stacked = spec.type.startsWith("stacked");
495
- const isLine = spec.type === "line" || spec.type === "area";
496
- const category = {
497
- type: "category",
498
- data: spec.labels,
499
- axisLine: { lineStyle: { color: AXIS } },
500
- axisTick: { show: false },
501
- axisLabel: { color: TEXT, fontSize: 14, interval: 0, hideOverlap: true },
502
- boundaryGap: !isLine
503
- };
504
- const value = {
505
- type: "value",
506
- max: spec.max,
507
- axisLine: { show: false },
508
- splitLine: { lineStyle: { color: "#eef0ec" } },
509
- axisLabel: {
510
- color: "#7a7a7a",
511
- fontSize: 13,
512
- formatter: (v) => formatValue(v, { ...spec, unit: void 0 })
513
- }
514
- };
1013
+ const combo = spec.type === "combo";
1014
+ const lineAt = (si) => combo ? (spec.series[si].as ?? (si === 0 ? "bar" : "line")) === "line" : spec.type === "line" || spec.type === "area";
1015
+ const secondaryAt = (si) => combo && spec.series[si].axis === "secondary";
1016
+ const hasSecondary = spec.series.some((_, si) => secondaryAt(si));
1017
+ const secondary = { ...spec, format: spec.secondaryFormat ?? spec.format };
1018
+ const category = categoryAxis(!spec.series.every((_, si) => lineAt(si)));
1019
+ const value = valueAxis();
1020
+ const yAxis = horizontal ? { ...category, inverse: true } : hasSecondary ? [
1021
+ value,
1022
+ { ...valueAxis(secondary), max: void 0, scale: true, splitLine: { show: false } }
1023
+ ] : value;
515
1024
  return {
516
1025
  ...base,
517
1026
  grid: { left: 16, right: 24, top: showLegend ? 48 : 20, bottom: 12, containLabel: true },
518
1027
  xAxis: horizontal ? value : category,
519
- yAxis: horizontal ? { ...category, inverse: true } : value,
1028
+ yAxis,
520
1029
  series: spec.series.map((s, si) => {
521
1030
  const color = colors3[si % colors3.length];
1031
+ const isLine = lineAt(si);
1032
+ const own = secondaryAt(si) ? secondary : spec;
522
1033
  return {
523
1034
  type: isLine ? "line" : "bar",
524
1035
  name: s.name,
525
1036
  stack: stacked ? "total" : void 0,
1037
+ yAxisIndex: secondaryAt(si) ? 1 : 0,
1038
+ z: isLine ? 3 : 2,
526
1039
  smooth: false,
527
1040
  symbolSize: 8,
528
1041
  lineStyle: isLine ? { width: 3 } : void 0,
@@ -538,9 +1051,12 @@ ${fmt(p.value)}` : p.name
538
1051
  })),
539
1052
  label: {
540
1053
  ...labelStyle,
1054
+ // Combo line labels sit over bars; a backing keeps them legible.
1055
+ ...combo && isLine ? { backgroundColor: "#fff", padding: [2, 5], borderRadius: 3 } : {},
1056
+ formatter: (p) => formatValue(p.value, own),
541
1057
  show: spec.labelsOnData && (!stacked || spec.series.length <= 3),
542
1058
  position: stacked ? "inside" : horizontal ? "right" : "top",
543
- color: stacked ? "#fff" : TEXT
1059
+ color: stacked ? "#fff" : combo && isLine ? color : TEXT
544
1060
  }
545
1061
  };
546
1062
  })
@@ -548,7 +1064,7 @@ ${fmt(p.value)}` : p.name
548
1064
  }
549
1065
  var renderer;
550
1066
  function loadChartRenderer() {
551
- renderer ??= import("./echarts-render-2QVSBZ6V.js").then((m) => m.createRenderer());
1067
+ renderer ??= import("./echarts-render-GDCT5QAF.js").then((m) => m.createRenderer());
552
1068
  return renderer;
553
1069
  }
554
1070
  async function renderChartSvg(spec, opts = {}) {
@@ -563,10 +1079,23 @@ async function renderChartSvg(spec, opts = {}) {
563
1079
  return svg.replace(/^<svg([^>]*?) width="\d+" height="\d+"/, "<svg$1").replace(/^<svg/, `<svg role="img" aria-label="${label}" preserveAspectRatio="xMidYMid meet"`);
564
1080
  }
565
1081
  function describeChart(spec) {
566
- const parts = spec.series.slice(0, ["pie", "donut", "funnel", "gauge"].includes(spec.type) ? 1 : 6).map(
567
- (s) => `${s.name}: ${spec.labels.map((l, i) => `${l} ${formatValue(s.values[i] ?? 0, spec)}`).join(", ")}`
1082
+ const kind = `${spec.type.replace("-", " ")} chart.`;
1083
+ const fmt = (v) => formatValue(v, spec);
1084
+ if (spec.type === "sankey") {
1085
+ const flows = sankeyLinks(spec).map((l) => `${l.source} to ${l.target} ${fmt(l.value)}`);
1086
+ return `${kind} ${flows.join(", ")}.`;
1087
+ }
1088
+ if (spec.type === "bullet") {
1089
+ const [actual, target] = spec.series;
1090
+ const rows = spec.labels.map(
1091
+ (l, i) => `${l} ${fmt(actual.values[i] ?? 0)}${target ? ` against a target of ${fmt(target.values[i] ?? 0)}` : ""}`
1092
+ );
1093
+ return `${kind} ${rows.join(", ")}.`;
1094
+ }
1095
+ const parts = spec.series.slice(0, SINGLE_SERIES_CHARTS.includes(spec.type) ? 1 : 6).map(
1096
+ (s) => `${s.name}: ${spec.labels.map((l, i) => `${l} ${fmt(s.values[i] ?? 0)}`).join(", ")}`
568
1097
  );
569
- return `${spec.type.replace("-", " ")} chart. ${parts.join(". ")}.`;
1098
+ return `${kind} ${parts.join(". ")}.`;
570
1099
  }
571
1100
 
572
1101
  // src/inline.ts
@@ -1074,7 +1603,7 @@ import { z as z5 } from "zod";
1074
1603
  var CHART_SIZE = { wide: { width: 1040, height: 500 }, side: { width: 720, height: 500 } };
1075
1604
  var chart = defineLayout({
1076
1605
  name: "chart",
1077
- description: "A branded chart (column, bar, stacked, line, area, pie, donut, funnel, gauge, scatter, radar) with an optional takeaway column.",
1606
+ description: "A branded chart (column, bar, stacked, 100% stacked, line, area, combo, pie, donut, treemap, funnel, waterfall, gauge, ring, bullet, dumbbell, heatmap, sankey, pictogram, scatter, radar) with an optional takeaway column.",
1078
1607
  whenToUse: "Numbers that tell a story: sales by quarter, timelines, shares, survey scores. State the takeaway in the title.",
1079
1608
  shape: {
1080
1609
  eyebrow: Text.optional(),
@@ -3939,6 +4468,7 @@ export {
3939
4468
  LOGO_SVG,
3940
4469
  logoLockup,
3941
4470
  CHART_TYPES,
4471
+ SINGLE_SERIES_CHARTS,
3942
4472
  ChartSpec,
3943
4473
  formatValue,
3944
4474
  chartOption,
@@ -3988,4 +4518,4 @@ export {
3988
4518
  inlineDeckImages,
3989
4519
  externalizeDeckImages
3990
4520
  };
3991
- //# sourceMappingURL=chunk-25NZMEQG.js.map
4521
+ //# sourceMappingURL=chunk-KQXT376T.js.map