@feedmepos/mf-report 5.31.2-beta.0 → 5.31.3-beta.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.
Files changed (38) hide show
  1. package/dist/{App-BS7p-1_C.js → App-j08Hk0tX.js} +4 -4
  2. package/dist/{DateRangeSelect-CU1zLhsi.js → DateRangeSelect-AJd7gYva.js} +1 -1
  3. package/dist/{Default.vue_vue_type_script_setup_true_lang-BbIP6Sxr.js → Default.vue_vue_type_script_setup_true_lang-CIVlgjSX.js} +1 -1
  4. package/dist/{DynamicWidget.vue_vue_type_script_setup_true_lang-CzSVopxn.js → DynamicWidget.vue_vue_type_script_setup_true_lang-BKHx9p3R.js} +7 -7
  5. package/dist/{GlobalFilterSelect.vue_vue_type_script_setup_true_lang-V9SzUzmd.js → GlobalFilterSelect.vue_vue_type_script_setup_true_lang-qQi-7kuX.js} +63 -63
  6. package/dist/{InsightView-BY5Sgoxt.js → InsightView-noFPHcRY.js} +5 -5
  7. package/dist/{Integration-YOGVHjkr.js → Integration-UwZP3vYt.js} +3 -3
  8. package/dist/{Integrations-DLn3wHzm.js → Integrations-BMhJuw20.js} +2 -2
  9. package/dist/{Layout-B8HhRlSf.js → Layout-CofH0rzo.js} +1 -1
  10. package/dist/{LocationSelect.vue_vue_type_script_setup_true_lang-C2yXmZru.js → LocationSelect.vue_vue_type_script_setup_true_lang-BsdJx7gt.js} +2 -2
  11. package/dist/{MenuTab.vue_vue_type_script_setup_true_lang-CZFM47wC.js → MenuTab.vue_vue_type_script_setup_true_lang-BMJHW2E2.js} +5 -5
  12. package/dist/{NavigationTab.vue_vue_type_script_setup_true_lang-BriXZRy-.js → NavigationTab.vue_vue_type_script_setup_true_lang-b6hgoVnw.js} +5 -5
  13. package/dist/{OverviewView-B4auEUPK.js → OverviewView-V-KcUYld.js} +5 -5
  14. package/dist/{Report-dar8N8tM.js → Report-Cxfxa7EK.js} +3 -3
  15. package/dist/{ReportCanvasView-J-TOAEDk.js → ReportCanvasView-DDNkVmlA.js} +435 -347
  16. package/dist/{ReportEditor-GUrAEUjy.js → ReportEditor-syPuiUDA.js} +7 -7
  17. package/dist/{ReportView-ok4zqyiS.js → ReportView-Dqc89voW.js} +6 -6
  18. package/dist/{ReportViewRoute-BcYLz9D0.js → ReportViewRoute-BE4Olg2t.js} +2 -2
  19. package/dist/{Setting-CW-iwHV7.js → Setting-CSZG2_pc.js} +3 -3
  20. package/dist/{Template-CYnlmr86.js → Template-DE8y5xA7.js} +3 -3
  21. package/dist/{TileEditor-btcTn9w6.js → TileEditor-DCLCIQqG.js} +6 -6
  22. package/dist/{TilePageStack.vue_vue_type_script_setup_true_lang-Be_qbuOb.js → TilePageStack.vue_vue_type_script_setup_true_lang-BKM30woJ.js} +2 -2
  23. package/dist/{TransitionFade-B3zeQDvF.js → TransitionFade-DSKakW7L.js} +5 -5
  24. package/dist/{app-Bw-zsS2R.js → app-BxEbZEnS.js} +76 -12
  25. package/dist/app.js +1 -1
  26. package/dist/assets/formatChartData.worker-R9EGYsk6.js +544 -0
  27. package/dist/assets/processTableData.worker-CxpDa0tB.js +544 -0
  28. package/dist/{currency-DmsNWBQ3.js → currency-lmnrd1Gz.js} +1 -1
  29. package/dist/{index-B5Reb-CL.js → index-Co3j0l3d.js} +2906 -2802
  30. package/dist/{report-CblaE0LI.js → report-Dsp2krIZ.js} +1 -1
  31. package/dist/report-canvas-libs/chartjs-chart-geo-4.js +2 -0
  32. package/dist/report-canvas-libs/chartjs-chart-sankey-0.js +7 -0
  33. package/dist/report-canvas-libs/components-1.js +1293 -50
  34. package/dist/report-canvas-libs/geo-outlines-1.js +5 -0
  35. package/dist/{useRestaurantPermission-BZR1kItv.js → useRestaurantPermission-D8xwhBHn.js} +1 -1
  36. package/package.json +3 -1
  37. package/dist/assets/formatChartData.worker-UoGYoYc3.js +0 -495
  38. package/dist/assets/processTableData.worker-DxE4bjxG.js +0 -495
@@ -443,11 +443,86 @@
443
443
  // number leaves, which at four money tiles across is none
444
444
  var delta = el("div", "fmr-kpi-delta")
445
445
  card.appendChild(delta)
446
+ // which way it went, never how much: the number above is the value
447
+ var spark = null
448
+ if (tileConfig.trend) {
449
+ var wrap = el("div", "fmr-kpi-spark")
450
+ wrap.style.height = "32px"
451
+ wrap.style.marginTop = "8px"
452
+ var canvas = document.createElement("canvas")
453
+ wrap.appendChild(canvas)
454
+ card.appendChild(wrap)
455
+ spark = { canvas: canvas, chart: null }
456
+ }
457
+ var meter = null
458
+ if (tileConfig.target !== undefined) {
459
+ var track = el("div", "fmr-kpi-meter")
460
+ track.style.height = "6px"
461
+ track.style.marginTop = "12px"
462
+ track.style.borderRadius = "var(--fm-radius-xs, 4px)"
463
+ track.style.background = "var(--fm-border-default, #f0f0f0)"
464
+ track.style.overflow = "hidden"
465
+ var reached = el("div")
466
+ reached.style.height = "100%"
467
+ reached.style.width = "0%"
468
+ reached.style.background = token("--fm-feature-aqua-fg", "#0a5384")
469
+ track.appendChild(reached)
470
+ var meterNote = el("div", "fmr-kpi-note")
471
+ card.appendChild(track)
472
+ card.appendChild(meterNote)
473
+ meter = { reached: reached, note: meterNote }
474
+ }
446
475
  if (tileConfig.caption) card.appendChild(el("div", "fmr-kpi-note", tileConfig.caption))
447
476
  row.appendChild(card)
448
- return { value: value, delta: delta }
477
+ return { value: value, delta: delta, spark: spark, meter: meter }
449
478
  })
450
479
 
480
+ function paintExtras(tileConfig, node, rows, raw) {
481
+ if (node.meter) {
482
+ var goal = typeof tileConfig.target === "number" ? tileConfig.target : rows.length ? num(rows[0][tileConfig.target.value]) : 0
483
+ var ratio = goal ? num(raw) / goal : 0
484
+ node.meter.reached.style.width = Math.max(0, Math.min(100, ratio * 100)) + "%"
485
+ node.meter.note.textContent =
486
+ raw === null || raw === undefined || !goal
487
+ ? ""
488
+ : text("kpi.ofTarget", "{share} of {target}")
489
+ .replace("{share}", intl(1).format(ratio * 100) + "%")
490
+ .replace("{target}", amountText(tileConfig.format, goal))
491
+ }
492
+ if (node.spark && window.Chart) {
493
+ var trendRows = FM.rows(tileConfig.trend.query)
494
+ var data = {
495
+ labels: trendRows.map(function (row) { return String(row[tileConfig.trend.x]) }),
496
+ datasets: [
497
+ {
498
+ data: trendRows.map(function (row) { return num(row[tileConfig.trend.y]) }),
499
+ borderColor: token("--fm-action-primary-bg", "#ff7823"),
500
+ borderWidth: 2,
501
+ pointRadius: 0,
502
+ tension: 0,
503
+ fill: false
504
+ }
505
+ ]
506
+ }
507
+ if (node.spark.chart) {
508
+ node.spark.chart.data = data
509
+ node.spark.chart.update()
510
+ } else {
511
+ node.spark.chart = new window.Chart(node.spark.canvas, {
512
+ type: "line",
513
+ data: data,
514
+ options: {
515
+ responsive: true,
516
+ maintainAspectRatio: false,
517
+ animation: false,
518
+ plugins: { legend: { display: false }, tooltip: { enabled: false } },
519
+ scales: { x: { display: false }, y: { display: false } }
520
+ }
521
+ })
522
+ }
523
+ }
524
+ }
525
+
451
526
  function paint() {
452
527
  tiles.forEach(function (tileConfig, index) {
453
528
  var rows = FM.rows(tileConfig.query)
@@ -476,12 +551,13 @@
476
551
  )
477
552
  }
478
553
  }
554
+ paintExtras(tileConfig, nodes[index], rows, raw)
479
555
  })
480
556
  }
481
557
 
482
558
  var seen = {}
483
559
  tiles.forEach(function (tileConfig) {
484
- ;[tileConfig.query, tileConfig.compareQuery].forEach(function (queryId) {
560
+ ;[tileConfig.query, tileConfig.compareQuery, tileConfig.trend && tileConfig.trend.query].forEach(function (queryId) {
485
561
  if (!queryId || seen[queryId]) return
486
562
  seen[queryId] = true
487
563
  FM.onRows(queryId, paint)
@@ -497,7 +573,7 @@
497
573
  wrap.appendChild(canvas)
498
574
  body.appendChild(wrap)
499
575
  var chart = null
500
- return function paint(data, note) {
576
+ function paint(data, note) {
501
577
  if (!window.Chart) {
502
578
  body.textContent = ""
503
579
  body.appendChild(el("p", "fmr-note error", "chart library unavailable"))
@@ -523,6 +599,8 @@
523
599
  }
524
600
  }
525
601
  }
602
+ paint.chart = function () { return chart }
603
+ return paint
526
604
  }
527
605
 
528
606
  function renderTimeseries(root, config, base) {
@@ -532,22 +610,41 @@
532
610
  var body = part(el("div", "fmr-tile-body"), base + "chart")
533
611
  card.appendChild(body)
534
612
  var compareQuery = config.compare && (config.compare.query || config.query)
613
+ var allSeries = config.series || []
614
+ var rightIndex = allSeries.findIndex(function (series) { return series.axis === "right" })
535
615
  var paint = mountChart(body, function (data) {
536
616
  var ax = axis()
617
+ var scales = {
618
+ x: Object.assign({}, ax, { stacked: !!config.stack, grid: { display: false } }),
619
+ y: Object.assign({}, ax, {
620
+ // a value axis cropped above zero exaggerates every wiggle into a peak
621
+ beginAtZero: true,
622
+ stacked: !!config.stack,
623
+ grid: { color: gridColor() },
624
+ ticks: Object.assign({}, ax.ticks, { callback: abbrev(config.format) })
625
+ })
626
+ }
627
+ if (rightIndex >= 0) {
628
+ // two units on one chart: each axis says which series it reads, in that series' colour
629
+ var left = data.datasets.filter(function (set) { return set.yAxisID !== "y1" && !set.isCompare })
630
+ scales.y.title = {
631
+ display: true,
632
+ text: left.map(function (set) { return set.label }).join(", "),
633
+ color: left.length ? left[0].borderColor || left[0].backgroundColor : undefined
634
+ }
635
+ var rightSet = data.datasets.filter(function (set) { return set.yAxisID === "y1" })[0]
636
+ scales.y1 = Object.assign({}, ax, {
637
+ position: "right",
638
+ beginAtZero: true,
639
+ grid: { display: false },
640
+ title: { display: true, text: allSeries[rightIndex].label, color: rightSet ? rightSet.borderColor || rightSet.backgroundColor : undefined },
641
+ ticks: Object.assign({}, ax.ticks, { callback: abbrev(config.rightFormat) })
642
+ })
643
+ }
537
644
  return {
538
645
  type: "line",
539
646
  data: data,
540
- options: Object.assign({}, chartOpts(), {
541
- scales: {
542
- x: Object.assign({}, ax, { grid: { display: false } }),
543
- y: Object.assign({}, ax, {
544
- // a value axis cropped above zero exaggerates every wiggle into a peak
545
- beginAtZero: true,
546
- grid: { color: gridColor() },
547
- ticks: Object.assign({}, ax.ticks, { callback: abbrev(config.format) })
548
- })
549
- }
550
- })
647
+ options: Object.assign({}, chartOpts(), { scales: scales })
551
648
  }
552
649
  })
553
650
 
@@ -577,31 +674,42 @@
577
674
  var lineIndex = 0
578
675
  // few enough days to read one by one: draw the points, or the line is a bare triangle
579
676
  var marker = rows.length <= 14 ? 3 : 0
580
- var datasets = (config.series || []).map(function (series) {
677
+ var barIndex = 0
678
+ var datasets = (config.series || []).map(function (series, index) {
581
679
  var data = rows.map(function (row) { return num(row[series.y]) })
680
+ var axisId = series.axis === "right" ? "y1" : undefined
582
681
  if (series.kind === "bar") {
682
+ // stacked bars step within their family so the parts read as one whole
683
+ var fill = barIndex++ === 0 ? barColor() : tone(BAR_TONES.vertical, barIndex - 1)
583
684
  return {
584
685
  label: series.label,
585
686
  type: "bar",
586
687
  data: data,
587
- backgroundColor: barColor(),
688
+ backgroundColor: fill,
588
689
  borderWidth: 0,
589
690
  maxBarThickness: 20,
691
+ stack: config.stack ? "stack" : undefined,
692
+ yAxisID: axisId,
590
693
  order: 2
591
694
  }
592
695
  }
593
696
  var color = lineColor(lineIndex++)
697
+ var area = series.kind === "area"
594
698
  return {
595
699
  label: series.label,
596
700
  type: "line",
597
701
  data: data,
598
702
  borderColor: color,
599
- backgroundColor: color,
703
+ backgroundColor: area ? rgba(color, 0.24) : color,
600
704
  borderWidth: 2,
601
705
  tension: 0,
706
+ stepped: series.stepped ? "before" : false,
602
707
  pointRadius: marker,
603
708
  pointHoverRadius: 4,
604
- fill: false,
709
+ fill: area ? (config.stack ? "stack" : "origin") : false,
710
+ // a line is never part of a stack: each one runs on its own
711
+ stack: config.stack ? (area ? "stack" : "line-" + index) : undefined,
712
+ yAxisID: axisId,
605
713
  order: 1
606
714
  }
607
715
  })
@@ -610,6 +718,7 @@
610
718
  datasets.push({
611
719
  label: config.compare.label || "Previous period",
612
720
  type: "line",
721
+ isCompare: true,
613
722
  // aligned by row index: both windows are the same length by construction
614
723
  data: rows.map(function (row, index) {
615
724
  var match = compareRows[index]
@@ -632,23 +741,121 @@
632
741
  if (compareQuery && compareQuery !== config.query) FM.onRows(compareQuery, refresh)
633
742
  }
634
743
 
744
+ // a series beyond the first steps within the family, then takes the next families' accents
745
+ var BAR_TONES = {
746
+ vertical: [
747
+ ["--fm-feature-grape-fg", "#512191"],
748
+ ["--fm-feature-grape-accent", "#8f61e8"],
749
+ ["--fm-feature-aqua-accent", "#3ab0ff"],
750
+ ["--fm-feature-mustard-accent", "#ffc746"],
751
+ ["--fm-feature-berry-accent", "#e94850"],
752
+ ["--fm-feature-lime-accent", "#aeef4b"]
753
+ ],
754
+ horizontal: [
755
+ ["--fm-feature-mustard-accent", "#ffc746"],
756
+ ["--fm-feature-mustard-fg", "#903e1f"],
757
+ ["--fm-feature-aqua-accent", "#3ab0ff"],
758
+ ["--fm-feature-grape-accent", "#8f61e8"],
759
+ ["--fm-feature-berry-accent", "#e94850"],
760
+ ["--fm-feature-lime-accent", "#aeef4b"]
761
+ ]
762
+ }
763
+ function tone(list, index) {
764
+ var step = list[index % list.length]
765
+ return token(step[0], step[1])
766
+ }
767
+
768
+ /** The house legend, on the right of the card's title row, one swatch per series. */
769
+ function headLegend(card, items) {
770
+ var head = card.querySelector(".fmr-tile-head")
771
+ if (!head) {
772
+ head = el("div", "fmr-tile-head")
773
+ card.insertBefore(head, card.firstChild)
774
+ }
775
+ var legend = head.querySelector(".fmr-legend")
776
+ if (!legend) legend = head.appendChild(el("div", "fmr-legend"))
777
+ legend.textContent = ""
778
+ items.forEach(function (item) {
779
+ var entry = el("span", "fmr-legend-item")
780
+ var swatch = el("span", "fmr-swatch" + (item.line ? " line" : ""))
781
+ swatch.style.background = item.color
782
+ entry.appendChild(swatch)
783
+ entry.appendChild(document.createTextNode(item.label))
784
+ legend.appendChild(entry)
785
+ })
786
+ }
787
+
635
788
  function renderBar(root, config, base) {
636
789
  base = base || ""
637
790
  var card = tile(root, config.title, base)
638
791
  renderControls(card, config.controls, base)
639
792
  var body = part(el("div", "fmr-tile-body"), base + "chart")
640
793
  card.appendChild(body)
794
+ var horizontal = config.orientation === "horizontal"
795
+ var tones = BAR_TONES[horizontal ? "horizontal" : "vertical"]
796
+ var series = config.series || [{ label: config.y, y: config.y }]
797
+ var stacked = config.stack === "stacked" || config.stack === "percent"
798
+ var percent = config.stack === "percent"
799
+ var right = series.some(function (one) { return one.axis === "right" })
641
800
  var current = []
642
801
  var total = 0
802
+ var sums = []
643
803
  // shareDenominator only reads .share, so config doubles as a column
644
804
  var denominator = shareDenominator(config)
805
+ if (config.series || config.cumulative) {
806
+ // one unnamed figure needs no swatch of its own: the card's title says what the bars are
807
+ headLegend(
808
+ card,
809
+ (config.series ? series : [])
810
+ .map(function (one, index) { return { label: one.label, color: tone(tones, index) } })
811
+ .concat(config.cumulative ? [{ label: text("bar.cumulative", "Cumulative share"), color: lineColor(0), line: true }] : [])
812
+ )
813
+ }
814
+ function formatOf(one) {
815
+ return one && one.axis === "right" ? config.rightFormat : config.format
816
+ }
645
817
  var paint = mountChart(body, function (data) {
646
818
  var opts = chartOpts()
647
819
  var ax = axis()
820
+ var valueAxis = Object.assign({}, ax, {
821
+ // a value axis cropped above zero exaggerates every wiggle into a peak
822
+ beginAtZero: true,
823
+ stacked: stacked,
824
+ grid: { color: gridColor() },
825
+ ticks: Object.assign({}, ax.ticks, {
826
+ callback: percent ? function (value) { return value + "%" } : abbrev(config.format)
827
+ })
828
+ })
829
+ if (percent) valueAxis.max = 100
830
+ var categoryAxis = Object.assign({}, ax, { stacked: stacked, grid: { display: false } })
831
+ var scales = horizontal ? { x: valueAxis, y: categoryAxis } : { x: categoryAxis, y: valueAxis }
832
+ if (right) {
833
+ // two units on one chart: each axis says which series it reads, in that series' colour
834
+ var leftSeries = series.filter(function (one) { return one.axis !== "right" })
835
+ var rightIndex = series.findIndex(function (one) { return one.axis === "right" })
836
+ valueAxis.title = { display: true, text: leftSeries.map(function (one) { return one.label }).join(", "), color: tone(tones, 0) }
837
+ scales.y1 = Object.assign({}, ax, {
838
+ position: "right",
839
+ beginAtZero: true,
840
+ grid: { display: false },
841
+ title: { display: true, text: series[rightIndex].label, color: tone(tones, rightIndex) },
842
+ ticks: Object.assign({}, ax.ticks, { callback: abbrev(config.rightFormat) })
843
+ })
844
+ }
845
+ if (config.cumulative) {
846
+ scales.y1 = Object.assign({}, ax, {
847
+ position: "right",
848
+ min: 0,
849
+ max: 100,
850
+ grid: { display: false },
851
+ ticks: Object.assign({}, ax.ticks, { callback: function (value) { return value + "%" } })
852
+ })
853
+ }
648
854
  return {
649
855
  type: "bar",
650
856
  data: data,
651
857
  options: Object.assign({}, opts, {
858
+ indexAxis: horizontal ? "y" : "x",
652
859
  onClick: config.clickSet
653
860
  ? function (event, elements) {
654
861
  if (!elements || !elements.length) return
@@ -658,37 +865,41 @@
658
865
  }
659
866
  : undefined,
660
867
  plugins: Object.assign({}, opts.plugins, {
661
- tooltip: config.share
662
- ? Object.assign({}, opts.plugins.tooltip, {
663
- callbacks: {
664
- label: function (context) {
665
- var amount = context.formattedValue
666
- if (!total) return amount
667
- var row = current[context.dataIndex]
668
- var pct = intl(1).format((num(row ? row[config.y] : 0) / total) * 100)
669
- return amount + " · " + pct + "% of total"
868
+ // a plain bar keeps chart.js's own tooltip; the rest name what each figure is of
869
+ tooltip:
870
+ config.share || config.series || config.cumulative
871
+ ? Object.assign({}, opts.plugins.tooltip, {
872
+ callbacks: {
873
+ label: function (context) {
874
+ var row = current[context.dataIndex]
875
+ var one = series[context.datasetIndex || 0]
876
+ if (!one) return context.dataset.label + " · " + intl(1).format(context.raw) + "%"
877
+ var raw = num(row ? row[one.y] : 0)
878
+ if (!config.series) {
879
+ if (!total) return context.formattedValue
880
+ return context.formattedValue + " · " + share(raw, total) + " of total"
881
+ }
882
+ var amount = one.label + " · " + amountText(formatOf(one), raw)
883
+ return percent ? amount + " · " + share(raw, sums[context.dataIndex]) : amount
884
+ }
670
885
  }
671
- }
672
- })
673
- : opts.plugins.tooltip
886
+ })
887
+ : opts.plugins.tooltip
674
888
  }),
675
- scales: {
676
- x: Object.assign({}, ax, { grid: { display: false } }),
677
- y: Object.assign({}, ax, {
678
- // a value axis cropped above zero exaggerates every wiggle into a peak
679
- beginAtZero: true,
680
- grid: { color: gridColor() },
681
- ticks: Object.assign({}, ax.ticks, { callback: abbrev(config.format) })
682
- })
683
- }
889
+ scales: scales
684
890
  })
685
891
  }
686
892
  })
687
893
 
688
894
  function refresh() {
689
- var rows = FM.rows(config.query)
895
+ var rows = FM.rows(config.query).slice()
896
+ // a Pareto reads largest first, whatever order the query returned
897
+ if (config.cumulative) rows.sort(function (a, b) { return num(b[config.y]) - num(a[config.y]) })
690
898
  current = rows
691
- if (config.share) {
899
+ sums = rows.map(function (row) {
900
+ return series.reduce(function (sum, one) { return sum + num(row[one.y]) }, 0)
901
+ })
902
+ if (config.share || config.cumulative) {
692
903
  if (denominator) {
693
904
  var denominatorRows = FM.rows(denominator.query)
694
905
  total = denominatorRows.length ? num(denominatorRows[0][denominator.value]) : 0
@@ -700,20 +911,49 @@
700
911
  paint(null, emptyNote(config))
701
912
  return
702
913
  }
914
+ var datasets = series.map(function (one, index) {
915
+ return {
916
+ // one unnamed series keeps chart.js's tooltip to the bare figure, as before
917
+ label: config.series ? one.label : undefined,
918
+ data: rows.map(function (row, at) {
919
+ var value = num(row[one.y])
920
+ return percent ? (sums[at] ? (value / sums[at]) * 100 : 0) : value
921
+ }),
922
+ backgroundColor: tone(tones, index),
923
+ maxBarThickness: 20,
924
+ yAxisID: !horizontal && one.axis === "right" ? "y1" : undefined,
925
+ order: 2
926
+ }
927
+ })
928
+ if (config.cumulative) {
929
+ var running = 0
930
+ var color = lineColor(0)
931
+ datasets.push({
932
+ type: "line",
933
+ label: text("bar.cumulative", "Cumulative share"),
934
+ data: rows.map(function (row) {
935
+ running += num(row[config.y])
936
+ return total ? Math.min(100, (running / total) * 100) : 0
937
+ }),
938
+ borderColor: color,
939
+ backgroundColor: color,
940
+ borderWidth: 2,
941
+ tension: 0,
942
+ pointRadius: 3,
943
+ pointHoverRadius: 4,
944
+ fill: false,
945
+ yAxisID: "y1",
946
+ order: 1
947
+ })
948
+ }
703
949
  paint(
704
950
  {
705
951
  labels: rows.map(function (row) {
706
952
  var label = String(row[config.x])
707
953
  if (!config.share || !total) return label
708
- return label + " · " + intl(1).format((num(row[config.y]) / total) * 100) + "%"
954
+ return label + " · " + share(num(row[config.y]), total)
709
955
  }),
710
- datasets: [
711
- {
712
- data: rows.map(function (row) { return num(row[config.y]) }),
713
- backgroundColor: barColor(),
714
- maxBarThickness: 20
715
- }
716
- ]
956
+ datasets: datasets
717
957
  },
718
958
  null
719
959
  )
@@ -1041,6 +1281,103 @@
1041
1281
  })
1042
1282
  }
1043
1283
 
1284
+ /** A row's day in the report's zone: a bucketed day as it stands, an instant projected. */
1285
+ function dayKey(raw) {
1286
+ if (raw === null || raw === undefined || raw === "") return null
1287
+ if (typeof raw === "string" && /^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw
1288
+ var date = new Date(raw)
1289
+ if (Number.isNaN(date.getTime())) return null
1290
+ return new Intl.DateTimeFormat("en-CA", {
1291
+ timeZone: envOf("timezone", undefined),
1292
+ year: "numeric",
1293
+ month: "2-digit",
1294
+ day: "2-digit"
1295
+ }).format(date)
1296
+ }
1297
+
1298
+ /** One cell per day, a week per column and a weekday per row, between the first and last day. */
1299
+ function heatCalendar(body, rows, config, format) {
1300
+ var byDay = {}
1301
+ var max = 0
1302
+ rows.forEach(function (row) {
1303
+ var key = dayKey(row[config.x])
1304
+ if (!key) return
1305
+ byDay[key] = (byDay[key] || 0) + num(row[config.value])
1306
+ if (byDay[key] > max) max = byDay[key]
1307
+ })
1308
+ var keys = Object.keys(byDay).sort(function (a, b) { return a.localeCompare(b) })
1309
+ if (!keys.length) {
1310
+ body.appendChild(emptyNote(config))
1311
+ return
1312
+ }
1313
+ var DAY = 86400000
1314
+ var first = Date.parse(keys[0] + "T00:00:00Z")
1315
+ var last = Date.parse(keys[keys.length - 1] + "T00:00:00Z")
1316
+ var sunday = config.weekStart === "sunday"
1317
+ var offset = function (time) { var day = new Date(time).getUTCDay(); return sunday ? day : (day + 6) % 7 }
1318
+ var start = first - offset(first) * DAY
1319
+ var weeks = Math.floor((last - start) / (7 * DAY)) + 1
1320
+ var names = weekdayNames()
1321
+ var order = sunday ? [0, 1, 2, 3, 4, 5, 6] : [1, 2, 3, 4, 5, 6, 0]
1322
+ // a day is a fixed square, so a short range stays a strip and a year scrolls rather than squeezes
1323
+ var scroll = el("div")
1324
+ scroll.style.overflowX = "auto"
1325
+ var grid = el("div", "fmr-heatmap fmr-heatmap-calendar")
1326
+ grid.style.display = "grid"
1327
+ grid.style.gridTemplateColumns = "44px repeat(" + weeks + ", 18px)"
1328
+ grid.style.gap = "2px"
1329
+ grid.style.alignItems = "center"
1330
+ grid.appendChild(el("div"))
1331
+ // a month is named over the weeks from the one holding its 1st, or from the first week shown
1332
+ var months = []
1333
+ for (var w = 0; w < weeks; w++) {
1334
+ var month = ""
1335
+ for (var d = 0; d < 7; d++) {
1336
+ var time = start + (w * 7 + d) * DAY
1337
+ var date = new Date(time)
1338
+ if (time >= first && time <= last && (date.getUTCDate() === 1 || (w === 0 && d === offset(first)))) {
1339
+ month = date.toLocaleDateString(locale(), { month: "short", timeZone: "UTC" })
1340
+ }
1341
+ }
1342
+ if (month || !months.length) months.push({ name: month, weeks: 1 })
1343
+ else months[months.length - 1].weeks += 1
1344
+ }
1345
+ months.forEach(function (one) {
1346
+ var label = heatLabel(one.name, "left")
1347
+ label.style.gridColumn = "span " + one.weeks
1348
+ label.style.whiteSpace = "nowrap"
1349
+ grid.appendChild(label)
1350
+ })
1351
+ for (var row = 0; row < 7; row++) {
1352
+ grid.appendChild(heatLabel(names[order[row]], "left"))
1353
+ for (var week = 0; week < weeks; week++) {
1354
+ var at = start + (week * 7 + row) * DAY
1355
+ var cell = el("div", "fmr-heatmap-cell")
1356
+ cell.style.minHeight = "18px"
1357
+ cell.style.borderRadius = "var(--fm-radius-xs, 4px)"
1358
+ if (at < first || at > last) {
1359
+ cell.className += " fmr-heatmap-cell-outside"
1360
+ grid.appendChild(cell)
1361
+ continue
1362
+ }
1363
+ var key = new Date(at).toISOString().slice(0, 10)
1364
+ var when = new Date(at).toLocaleDateString(locale(), { dateStyle: "medium", timeZone: "UTC" }) + " — "
1365
+ if (byDay[key] === undefined) {
1366
+ cell.className += " fmr-heatmap-cell-empty"
1367
+ cell.style.background = NO_DATA
1368
+ cell.title = when + text("heatmap.noData", "No data")
1369
+ } else {
1370
+ cell.style.background = heatShade(max ? byDay[key] / max : 0)
1371
+ cell.title = when + formatValue(format, byDay[key])
1372
+ }
1373
+ grid.appendChild(cell)
1374
+ }
1375
+ }
1376
+ scroll.appendChild(grid)
1377
+ body.appendChild(scroll)
1378
+ body.appendChild(heatLegend())
1379
+ }
1380
+
1044
1381
  function renderHeatmap(root, config, base) {
1045
1382
  base = base || ""
1046
1383
  var card = tile(root, config.title, base)
@@ -1055,6 +1392,10 @@
1055
1392
  body.appendChild(emptyNote(config))
1056
1393
  return
1057
1394
  }
1395
+ if (config.layout === "calendar") {
1396
+ heatCalendar(body, rows, config, format)
1397
+ return
1398
+ }
1058
1399
  var ys = axisValues(rows, config.y)
1059
1400
  var xs = axisValues(rows, config.x)
1060
1401
  var weekdays = whole(ys, 6)
@@ -1155,6 +1496,901 @@
1155
1496
  sync()
1156
1497
  }
1157
1498
 
1499
+ // --- funnel, waterfall, donut: figures read from one row, or (a donut) one row per slice
1500
+
1501
+ /** An amount as the merchant reads it in a label: the currency code, then the figure. */
1502
+ function amountText(format, value) {
1503
+ if (format === "money") {
1504
+ var currency = envOf("currency", "")
1505
+ return (currency ? currency + " " : "") + formatValue("money", value)
1506
+ }
1507
+ return formatValue(format || "amount", value)
1508
+ }
1509
+
1510
+ function share(part, whole) {
1511
+ return whole ? intl(1).format((part / whole) * 100) + "%" : ""
1512
+ }
1513
+
1514
+ function figureRow(config) {
1515
+ var rows = FM.rows(config.query)
1516
+ return rows.length ? rows[0] : null
1517
+ }
1518
+
1519
+ function nothingToDraw(config, values) {
1520
+ return (
1521
+ FM.status(config.query) !== "loading" &&
1522
+ (!values.length || values.every(function (value) { return value === 0 }))
1523
+ )
1524
+ }
1525
+
1526
+ function renderFunnel(root, config, base) {
1527
+ base = base || ""
1528
+ var card = tile(root, config.title, base)
1529
+ renderControls(card, config.controls, base)
1530
+ var body = part(el("div", "fmr-tile-body"), base + "chart")
1531
+ card.appendChild(body)
1532
+ var steps = config.steps || []
1533
+ var values = []
1534
+ var paint = mountChart(body, function (data) {
1535
+ var opts = chartOpts()
1536
+ var ax = axis()
1537
+ return {
1538
+ type: "bar",
1539
+ data: data,
1540
+ options: Object.assign({}, opts, {
1541
+ indexAxis: "y",
1542
+ plugins: Object.assign({}, opts.plugins, {
1543
+ tooltip: Object.assign({}, opts.plugins.tooltip, {
1544
+ callbacks: {
1545
+ label: function (context) {
1546
+ var index = context.dataIndex
1547
+ var line = amountText(config.format, values[index])
1548
+ if (index === 0) return line
1549
+ var of = function (base) {
1550
+ return text("funnel.ofStep", "{share} of {step}")
1551
+ .replace("{share}", share(values[index], values[base]))
1552
+ .replace("{step}", steps[base].label)
1553
+ }
1554
+ return [line, of(0), of(index - 1)]
1555
+ }
1556
+ }
1557
+ })
1558
+ }),
1559
+ scales: {
1560
+ // the bars are centred on zero, so the value axis carries no meaning of its own
1561
+ x: { display: false, grid: { display: false } },
1562
+ y: Object.assign({}, ax, { grid: { display: false } })
1563
+ }
1564
+ })
1565
+ }
1566
+ })
1567
+
1568
+ function refresh() {
1569
+ var row = figureRow(config)
1570
+ values = steps.map(function (step) { return row ? num(row[step.value]) : 0 })
1571
+ if (nothingToDraw(config, values)) {
1572
+ paint(null, emptyNote(config))
1573
+ return
1574
+ }
1575
+ paint(
1576
+ {
1577
+ labels: steps.map(function (step, index) {
1578
+ var figure = amountText(config.format, values[index])
1579
+ return [step.label, index === 0 ? figure : figure + " · " + share(values[index], values[0])]
1580
+ }),
1581
+ datasets: [
1582
+ {
1583
+ data: values.map(function (value) { return [-value / 2, value / 2] }),
1584
+ backgroundColor: token("--fm-feature-mustard-accent", "#ffc746"),
1585
+ borderWidth: 0,
1586
+ barPercentage: 0.92,
1587
+ categoryPercentage: 0.92,
1588
+ borderSkipped: false
1589
+ }
1590
+ ]
1591
+ },
1592
+ null
1593
+ )
1594
+ }
1595
+
1596
+ FM.onRows(config.query, refresh)
1597
+ }
1598
+
1599
+ function renderWaterfall(root, config, base) {
1600
+ base = base || ""
1601
+ var card = tile(root, config.title, base)
1602
+ renderControls(card, config.controls, base)
1603
+ var body = part(el("div", "fmr-tile-body"), base + "chart")
1604
+ card.appendChild(body)
1605
+ var steps = config.steps || []
1606
+ var shown = []
1607
+ var paint = mountChart(body, function (data) {
1608
+ var opts = chartOpts()
1609
+ var ax = axis()
1610
+ return {
1611
+ type: "bar",
1612
+ data: data,
1613
+ options: Object.assign({}, opts, {
1614
+ plugins: Object.assign({}, opts.plugins, {
1615
+ tooltip: Object.assign({}, opts.plugins.tooltip, {
1616
+ callbacks: {
1617
+ // the bar's direction carries the sign, per the chart guide
1618
+ label: function (context) {
1619
+ var bar = shown[context.dataIndex]
1620
+ return bar ? amountText(config.format, Math.abs(bar.change)) : ""
1621
+ }
1622
+ }
1623
+ })
1624
+ }),
1625
+ scales: {
1626
+ x: Object.assign({}, ax, { grid: { display: false } }),
1627
+ y: Object.assign({}, ax, {
1628
+ beginAtZero: true,
1629
+ grid: { color: gridColor() },
1630
+ ticks: Object.assign({}, ax.ticks, { callback: abbrev(config.format) })
1631
+ })
1632
+ }
1633
+ })
1634
+ }
1635
+ })
1636
+
1637
+ function refresh() {
1638
+ var row = figureRow(config)
1639
+ var start = row ? num(row[config.start.value]) : 0
1640
+ var running = start
1641
+ shown = [{ label: config.start.label, from: 0, to: start, change: start, total: true }]
1642
+ steps.forEach(function (step) {
1643
+ var change = (step.subtract ? -1 : 1) * (row ? num(row[step.value]) : 0)
1644
+ shown.push({ label: step.label, from: running, to: running + change, change: change })
1645
+ running += change
1646
+ })
1647
+ var end = config.end && config.end.value && row ? num(row[config.end.value]) : running
1648
+ shown.push({ label: config.end.label, from: 0, to: end, change: end, total: true })
1649
+ if (nothingToDraw(config, shown.map(function (bar) { return bar.change }))) {
1650
+ paint(null, emptyNote(config))
1651
+ return
1652
+ }
1653
+ var total = token("--fm-feature-grape-fg", "#512191")
1654
+ var stepColor = token("--fm-feature-grape-accent", "#8f61e8")
1655
+ paint(
1656
+ {
1657
+ labels: shown.map(function (bar) { return bar.label }),
1658
+ datasets: [
1659
+ {
1660
+ data: shown.map(function (bar) { return [bar.from, bar.to] }),
1661
+ backgroundColor: shown.map(function (bar) { return bar.total ? total : stepColor }),
1662
+ borderWidth: 0,
1663
+ maxBarThickness: 36,
1664
+ borderSkipped: false
1665
+ }
1666
+ ]
1667
+ },
1668
+ null
1669
+ )
1670
+ }
1671
+
1672
+ FM.onRows(config.query, refresh)
1673
+ }
1674
+
1675
+ // categorical slices take successive family accents, per the chart guide's donut rule
1676
+ var SLICE_TONES = [
1677
+ ["--fm-feature-aqua-accent", "#3ab0ff"],
1678
+ ["--fm-feature-grape-accent", "#8f61e8"],
1679
+ ["--fm-feature-mustard-accent", "#ffc746"],
1680
+ ["--fm-feature-berry-accent", "#e94850"],
1681
+ ["--fm-feature-lime-accent", "#aeef4b"],
1682
+ ["--fm-feature-blossom-accent", "#ffc4c3"]
1683
+ ]
1684
+
1685
+ function renderDonut(root, config, base) {
1686
+ base = base || ""
1687
+ var card = tile(root, config.title, base)
1688
+ renderControls(card, config.controls, base)
1689
+ var body = part(el("div", "fmr-tile-body"), base + "chart")
1690
+ card.appendChild(body)
1691
+ // a ring's legend sits centred above it: the slices are read by name, not by angle
1692
+ var legend = el("div", "fmr-legend")
1693
+ legend.style.justifyContent = "center"
1694
+ legend.style.marginBottom = "12px"
1695
+ body.appendChild(legend)
1696
+ var shape = config.shape || "donut"
1697
+ var current = []
1698
+ var whole = 0
1699
+ var paint = mountChart(body, function (data) {
1700
+ var opts = chartOpts()
1701
+ var options = Object.assign({}, opts, {
1702
+ onClick: config.clickSet
1703
+ ? function (event, elements) {
1704
+ if (!elements || !elements.length) return
1705
+ var row = current[elements[0].index]
1706
+ FM.setParams(resolveSet(config.clickSet, row, row ? row[config.x] : null))
1707
+ }
1708
+ : undefined,
1709
+ plugins: Object.assign({}, opts.plugins, {
1710
+ tooltip: Object.assign({}, opts.plugins.tooltip, {
1711
+ callbacks: {
1712
+ label: function (context) {
1713
+ var value = Number(context.raw) || 0
1714
+ return amountText(config.format, value) + " · " + share(value, whole)
1715
+ }
1716
+ }
1717
+ })
1718
+ })
1719
+ })
1720
+ if (shape === "polar") {
1721
+ options.scales = { r: { ticks: { display: false }, grid: { color: gridColor() } } }
1722
+ return { type: "polarArea", data: data, options: options }
1723
+ }
1724
+ // a pie is the same ring with nothing cut out, so it stays one chart type
1725
+ options.cutout = shape === "pie" ? 0 : "40%"
1726
+ return { type: "doughnut", data: data, options: options }
1727
+ })
1728
+
1729
+ /** Largest slice first, clockwise from twelve; a clicked slice still finds its own row. */
1730
+ function slices() {
1731
+ var list
1732
+ if (config.slices) {
1733
+ var row = figureRow(config)
1734
+ list = config.slices.map(function (slice) {
1735
+ return { label: slice.label, value: row ? num(row[slice.value]) : 0, row: null }
1736
+ })
1737
+ } else {
1738
+ list = FM.rows(config.query).map(function (row) {
1739
+ return { label: String(row[config.x]), value: num(row[config.y]), row: row }
1740
+ })
1741
+ }
1742
+ list.sort(function (a, b) { return b.value - a.value })
1743
+ current = list.map(function (slice) { return slice.row })
1744
+ return list
1745
+ }
1746
+
1747
+ function refresh() {
1748
+ var list = slices()
1749
+ whole = list.reduce(function (sum, slice) { return sum + slice.value }, 0)
1750
+ legend.textContent = ""
1751
+ if (nothingToDraw(config, list.map(function (slice) { return slice.value }))) {
1752
+ legend.hidden = true
1753
+ paint(null, emptyNote(config))
1754
+ return
1755
+ }
1756
+ legend.hidden = false
1757
+ var colors = list.map(function (_slice, index) {
1758
+ var tone = SLICE_TONES[index % SLICE_TONES.length]
1759
+ return token(tone[0], tone[1])
1760
+ })
1761
+ list.forEach(function (slice, index) {
1762
+ var item = el("span", "fmr-legend-item")
1763
+ var swatch = el("span", "fmr-swatch")
1764
+ swatch.style.background = colors[index]
1765
+ item.appendChild(swatch)
1766
+ item.appendChild(
1767
+ document.createTextNode(slice.label + " · " + amountText(config.format, slice.value) + " · " + share(slice.value, whole))
1768
+ )
1769
+ legend.appendChild(item)
1770
+ })
1771
+ paint(
1772
+ {
1773
+ labels: list.map(function (slice) { return slice.label }),
1774
+ datasets: [
1775
+ {
1776
+ data: list.map(function (slice) { return slice.value }),
1777
+ backgroundColor: colors,
1778
+ borderWidth: 0
1779
+ }
1780
+ ]
1781
+ },
1782
+ null
1783
+ )
1784
+ }
1785
+
1786
+ FM.onRows(config.query, refresh)
1787
+ }
1788
+
1789
+ // --- map: rows on the outlines geo-outlines@1 ships, drawn by chartjs-chart-geo
1790
+
1791
+ var geoRegistered = false
1792
+ function geoReady() {
1793
+ var geo = window.ChartGeo
1794
+ if (!geo || !window.Chart || !FM.geoOutlines) return false
1795
+ if (!geoRegistered) {
1796
+ window.Chart.register(geo.ChoroplethController, geo.BubbleMapController, geo.GeoFeature, geo.ColorScale, geo.ProjectionScale, geo.SizeScale)
1797
+ geoRegistered = true
1798
+ }
1799
+ return true
1800
+ }
1801
+
1802
+ var featureCache = Object.create(null)
1803
+ function layerFeatures(layer) {
1804
+ if (!featureCache[layer.id]) {
1805
+ featureCache[layer.id] = window.ChartGeo.topojson.feature(layer.topology, layer.topology.objects.regions).features
1806
+ }
1807
+ return featureCache[layer.id]
1808
+ }
1809
+
1810
+ function featureBox(feature) {
1811
+ var box = [Infinity, Infinity, -Infinity, -Infinity]
1812
+ function walk(node) {
1813
+ if (typeof node[0] === "number") {
1814
+ box[0] = Math.min(box[0], node[0])
1815
+ box[1] = Math.min(box[1], node[1])
1816
+ box[2] = Math.max(box[2], node[0])
1817
+ box[3] = Math.max(box[3], node[1])
1818
+ return
1819
+ }
1820
+ node.forEach(walk)
1821
+ }
1822
+ walk(feature.geometry.coordinates)
1823
+ return box
1824
+ }
1825
+
1826
+ function within(box, point) {
1827
+ return point.lon >= box[0] && point.lon <= box[2] && point.lat >= box[1] && point.lat <= box[3]
1828
+ }
1829
+
1830
+ /**
1831
+ * The country holding most of the points, on district lines when they sit within one metro
1832
+ * area and every one of them falls inside the district layer, on state lines otherwise.
1833
+ */
1834
+ function pointLayer(points) {
1835
+ var layers = FM.geoOutlines.layers
1836
+ var held = {}
1837
+ layers.forEach(function (layer) {
1838
+ var count = points.filter(function (point) { return within(layer.bbox, point) }).length
1839
+ if (count) held[layer.country] = Math.max(held[layer.country] || 0, count)
1840
+ })
1841
+ var country = Object.keys(held).sort(function (a, b) { return held[b] - held[a] })[0]
1842
+ if (!country) return null
1843
+ var own = layers.filter(function (layer) { return layer.country === country })
1844
+ var lats = points.map(function (point) { return point.lat })
1845
+ var lons = points.map(function (point) { return point.lon })
1846
+ var spread = Math.max(Math.max.apply(null, lats) - Math.min.apply(null, lats), Math.max.apply(null, lons) - Math.min.apply(null, lons))
1847
+ var districts = own.filter(function (layer) {
1848
+ return layer.level === "districts" && points.every(function (point) { return within(layer.bbox, point) })
1849
+ })[0]
1850
+ var states = own.filter(function (layer) { return layer.level === "states" })[0]
1851
+ return (spread <= 1.5 && districts) || states || districts || own[0]
1852
+ }
1853
+
1854
+ /** The outlines around the points, with a margin, so a metro area fills the card. */
1855
+ function pointOutline(layer, points) {
1856
+ var lats = points.map(function (point) { return point.lat })
1857
+ var lons = points.map(function (point) { return point.lon })
1858
+ var box = [Math.min.apply(null, lons), Math.min.apply(null, lats), Math.max.apply(null, lons), Math.max.apply(null, lats)]
1859
+ var pad = Math.max(0.15 * Math.max(box[2] - box[0], box[3] - box[1]), 0.05)
1860
+ var view = [box[0] - pad, box[1] - pad, box[2] + pad, box[3] + pad]
1861
+ var near = layerFeatures(layer).filter(function (feature) {
1862
+ var b = featureBox(feature)
1863
+ return b[0] <= view[2] && b[2] >= view[0] && b[1] <= view[3] && b[3] >= view[1]
1864
+ })
1865
+ return near.length ? near : layerFeatures(layer)
1866
+ }
1867
+
1868
+ /** A region name as the outlines and the data both spell it, minus the words that vary. */
1869
+ function regionKey(name) {
1870
+ return String(name || "")
1871
+ .toLowerCase()
1872
+ .replace(/\b(w\.?\s?p\.?|wilayah persekutuan|federal territory( of)?|changwat|province( of)?|state( of)?|negeri)\b/g, " ")
1873
+ .replace(/[^\p{L}\p{N}]+/gu, "")
1874
+ }
1875
+
1876
+ /** The layer naming most of the regions, and which feature each row's region is. */
1877
+ function regionLayer(names) {
1878
+ var best = null
1879
+ FM.geoOutlines.layers.forEach(function (layer) {
1880
+ var byKey = Object.create(null)
1881
+ layerFeatures(layer).forEach(function (feature) {
1882
+ var props = feature.properties || {}
1883
+ ;[props.name].concat(String(props.aliases || "").split("|")).forEach(function (name) {
1884
+ var key = regionKey(name)
1885
+ if (key && !byKey[key]) byKey[key] = feature
1886
+ })
1887
+ })
1888
+ var matched = names.filter(function (name) { return byKey[regionKey(name)] }).length
1889
+ if (matched && (!best || matched > best.matched)) best = { layer: layer, byKey: byKey, matched: matched }
1890
+ })
1891
+ return best
1892
+ }
1893
+
1894
+ var PLACE_LIMIT = 10
1895
+
1896
+ function renderMap(root, config, base) {
1897
+ base = base || ""
1898
+ var card = tile(root, config.title, base)
1899
+ renderControls(card, config.controls, base)
1900
+ var body = part(el("div", "fmr-tile-body fmr-map"), base + "chart")
1901
+ card.appendChild(body)
1902
+ var foot = el("div", "fmr-tile-foot")
1903
+ var note = el("span")
1904
+ var source = el("span")
1905
+ foot.appendChild(note)
1906
+ foot.appendChild(source)
1907
+ card.appendChild(foot)
1908
+ var byRegion = !!config.region
1909
+ var drawn = []
1910
+ var paint = mountChart(body, function (data) {
1911
+ var opts = chartOpts()
1912
+ var scales = byRegion
1913
+ ? { projection: { axis: "x", projection: "mercator" }, color: { axis: "x", display: false, interpolate: heatShade, missing: token("--fm-bg-canvas", "#f3f3f6") } }
1914
+ : { projection: { axis: "x", projection: "mercator" }, size: { axis: "x", display: false, range: config.value ? [4, 18] : [6, 6] } }
1915
+ return {
1916
+ type: byRegion ? "choropleth" : "bubbleMap",
1917
+ data: data,
1918
+ options: Object.assign({}, opts, {
1919
+ onClick: config.clickSet
1920
+ ? function (event, elements) {
1921
+ if (!elements || !elements.length) return
1922
+ var row = drawn[elements[0].index]
1923
+ if (row) FM.setParams(resolveSet(config.clickSet, row, row[byRegion ? config.region : config.label]))
1924
+ }
1925
+ : undefined,
1926
+ plugins: Object.assign({}, opts.plugins, {
1927
+ tooltip: Object.assign({}, opts.plugins.tooltip, {
1928
+ callbacks: {
1929
+ title: function (items) {
1930
+ var row = items.length ? drawn[items[0].dataIndex] : null
1931
+ return row ? String(row[byRegion ? config.region : config.label] ?? "") : ""
1932
+ },
1933
+ label: function (context) {
1934
+ var row = drawn[context.dataIndex]
1935
+ return row && config.value ? amountText(config.format, num(row[config.value])) : ""
1936
+ }
1937
+ }
1938
+ })
1939
+ }),
1940
+ scales: scales
1941
+ })
1942
+ }
1943
+ })
1944
+ // a projection keeps its aspect, so a short wide plot draws a small map in the middle
1945
+ body.querySelector(".fmr-chart").style.height = "360px"
1946
+ var places = el("ol", "fmr-places")
1947
+ places.hidden = true
1948
+ body.appendChild(places)
1949
+
1950
+ function say(node, message) {
1951
+ node.hidden = !message
1952
+ node.textContent = message || ""
1953
+ foot.hidden = note.hidden && source.hidden
1954
+ }
1955
+
1956
+ /** Points the map at one place, so a row of the list finds its dot or region. */
1957
+ function pointAt(index) {
1958
+ var chart = paint.chart()
1959
+ if (!chart || !chart.setActiveElements) return
1960
+ var active = index === null ? [] : [{ datasetIndex: 0, index: index }]
1961
+ chart.setActiveElements(active)
1962
+ var mark = index === null ? null : chart.getDatasetMeta(0).data[index]
1963
+ if (chart.tooltip) chart.tooltip.setActiveElements(active, mark && mark.getCenterPoint ? mark.getCenterPoint() : { x: 0, y: 0 })
1964
+ chart.update("none")
1965
+ }
1966
+
1967
+ /** A figure drawn as a size or a shade is read off its list: largest first, the mark beside each as the key. */
1968
+ function listPlaces() {
1969
+ places.textContent = ""
1970
+ places.hidden = !config.value || !drawn.length
1971
+ if (places.hidden) return
1972
+ var chart = paint.chart()
1973
+ var scale = chart && chart.scales && chart.scales[byRegion ? "color" : "size"]
1974
+ var order = drawn.map(function (row, index) { return { row: row, index: index, value: num(row[config.value]) } })
1975
+ order.sort(function (a, b) { return b.value - a.value })
1976
+ var shown = order.length > PLACE_LIMIT ? order.slice(0, PLACE_LIMIT - 1) : order
1977
+ shown.forEach(function (entry) {
1978
+ var item = el("li", "fmr-place")
1979
+ var mark = el("span", byRegion ? "fmr-place-shade" : "fmr-place-dot")
1980
+ if (byRegion) {
1981
+ mark.style.background = scale && scale.getColorForValue ? scale.getColorForValue(entry.value) : token("--fm-feature-grape-fg", "#512191")
1982
+ } else {
1983
+ var radius = scale && scale.getSizeForValue ? scale.getSizeForValue(entry.value) : 4
1984
+ var size = Math.max(4, Math.round(radius * 2)) + "px"
1985
+ mark.style.width = mark.style.height = size
1986
+ mark.style.setProperty("--mark", size)
1987
+ }
1988
+ item.appendChild(mark)
1989
+ item.appendChild(el("span", "fmr-place-name", String(entry.row[byRegion ? config.region : config.label] ?? "")))
1990
+ item.appendChild(el("span", "fmr-place-value", amountText(config.format, entry.value)))
1991
+ item.addEventListener("mouseenter", function () { pointAt(entry.index) })
1992
+ item.addEventListener("mouseleave", function () { pointAt(null) })
1993
+ if (config.clickSet) {
1994
+ item.classList.add("fmr-place-link")
1995
+ item.tabIndex = 0
1996
+ var pick = function () { FM.setParams(resolveSet(config.clickSet, entry.row, entry.row[byRegion ? config.region : config.label])) }
1997
+ item.addEventListener("click", pick)
1998
+ item.addEventListener("keydown", function (event) { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); pick() } })
1999
+ }
2000
+ places.appendChild(item)
2001
+ })
2002
+ if (shown.length < order.length) {
2003
+ places.appendChild(el("li", "fmr-place fmr-place-more", text("map.more", "{count} more").replace("{count}", order.length - shown.length)))
2004
+ }
2005
+ }
2006
+
2007
+ function refresh() {
2008
+ var rows = FM.rows(config.query)
2009
+ if (!rows.length && FM.status(config.query) !== "loading") {
2010
+ say(note, "")
2011
+ say(source, "")
2012
+ places.hidden = true
2013
+ paint(null, emptyNote(config))
2014
+ return
2015
+ }
2016
+ if (!geoReady()) {
2017
+ body.textContent = ""
2018
+ body.appendChild(el("p", "fmr-note error", "map library unavailable"))
2019
+ return
2020
+ }
2021
+ var layer
2022
+ var dataset
2023
+ if (byRegion) {
2024
+ var names = rows.map(function (row) { return String(row[config.region] ?? "") })
2025
+ var found = regionLayer(names)
2026
+ if (!found) {
2027
+ say(note, text("map.unmatched", "Not on the map: {names}").replace("{names}", names.slice(0, 5).join(", ")))
2028
+ say(source, "")
2029
+ places.hidden = true
2030
+ paint(null, emptyNote(config))
2031
+ return
2032
+ }
2033
+ layer = found.layer
2034
+ drawn = []
2035
+ var missing = []
2036
+ var data = []
2037
+ rows.forEach(function (row) {
2038
+ var feature = found.byKey[regionKey(row[config.region])]
2039
+ if (!feature) return missing.push(String(row[config.region] ?? ""))
2040
+ drawn.push(row)
2041
+ data.push({ feature: feature, value: num(row[config.value]) })
2042
+ })
2043
+ say(note, missing.length ? text("map.unmatched", "Not on the map: {names}").replace("{names}", missing.slice(0, 5).join(", ")) : "")
2044
+ dataset = {
2045
+ label: config.title || "",
2046
+ outline: layerFeatures(layer),
2047
+ showOutline: true,
2048
+ outlineBackgroundColor: token("--fm-bg-canvas", "#f3f3f6"),
2049
+ outlineBorderColor: token("--fm-border-default", "#e5e5ea"),
2050
+ borderColor: token("--fm-bg-default", "#ffffff"),
2051
+ borderWidth: 1,
2052
+ data: data
2053
+ }
2054
+ } else {
2055
+ var placed = rows.filter(function (row) {
2056
+ var lat = Number(row[config.lat])
2057
+ var lon = Number(row[config.lon])
2058
+ return row[config.lat] !== null && row[config.lat] !== "" && Number.isFinite(lat) && Number.isFinite(lon) && !(lat === 0 && lon === 0)
2059
+ })
2060
+ var unplaced = rows.length - placed.length
2061
+ say(note, unplaced ? text("map.unplaced", "{count} of {total} have no location to place").replace("{count}", unplaced).replace("{total}", rows.length) : "")
2062
+ if (!placed.length) {
2063
+ say(source, "")
2064
+ places.hidden = true
2065
+ paint(null, emptyNote(config))
2066
+ return
2067
+ }
2068
+ var points = placed.map(function (row) { return { lat: Number(row[config.lat]), lon: Number(row[config.lon]) } })
2069
+ layer = pointLayer(points)
2070
+ drawn = placed
2071
+ dataset = {
2072
+ label: config.title || "",
2073
+ outline: layer ? pointOutline(layer, points) : [],
2074
+ showOutline: !!layer,
2075
+ outlineBackgroundColor: token("--fm-bg-canvas", "#f3f3f6"),
2076
+ outlineBorderColor: token("--fm-border-default", "#e5e5ea"),
2077
+ outlineBorderWidth: 1,
2078
+ backgroundColor: token("--fm-feature-grape-fg", "#512191"),
2079
+ borderColor: token("--fm-bg-default", "#ffffff"),
2080
+ borderWidth: 1.5,
2081
+ data: placed.map(function (row, index) {
2082
+ return { latitude: points[index].lat, longitude: points[index].lon, value: config.value ? num(row[config.value]) : 1 }
2083
+ })
2084
+ }
2085
+ }
2086
+ say(source, layer ? text("map.boundaries", "Boundaries: {source}").replace("{source}", layer.attribution) : text("map.noShapes", "No map shapes for this area"))
2087
+ paint({ labels: drawn.map(function (row) { return String(row[byRegion ? config.region : config.label] ?? "") }), datasets: [dataset] }, null)
2088
+ listPlaces()
2089
+ }
2090
+
2091
+ FM.onRows(config.query, refresh)
2092
+ }
2093
+
2094
+ // --- scatter: two figures per item, one across and one up; a third sizes the dot
2095
+
2096
+ function renderScatter(root, config, base) {
2097
+ base = base || ""
2098
+ var card = tile(root, config.title, base)
2099
+ renderControls(card, config.controls, base)
2100
+ var body = part(el("div", "fmr-tile-body"), base + "chart")
2101
+ card.appendChild(body)
2102
+ var current = []
2103
+ var paint = mountChart(body, function (data) {
2104
+ var opts = chartOpts()
2105
+ var ax = axis()
2106
+ // two measures, so each axis is named — the one place a chart here carries axis titles
2107
+ var measured = function (format, name) {
2108
+ return Object.assign({}, ax, {
2109
+ grid: { color: gridColor() },
2110
+ title: name ? { display: true, text: name, color: token("--fm-text-muted", "#6b6c70") } : { display: false },
2111
+ ticks: Object.assign({}, ax.ticks, { callback: abbrev(format) })
2112
+ })
2113
+ }
2114
+ return {
2115
+ type: config.size ? "bubble" : "scatter",
2116
+ data: data,
2117
+ options: Object.assign({}, opts, {
2118
+ onClick: config.clickSet
2119
+ ? function (event, elements) {
2120
+ if (!elements || !elements.length) return
2121
+ var row = current[elements[0].index]
2122
+ if (row) FM.setParams(resolveSet(config.clickSet, row, row[config.label]))
2123
+ }
2124
+ : undefined,
2125
+ plugins: Object.assign({}, opts.plugins, {
2126
+ tooltip: Object.assign({}, opts.plugins.tooltip, {
2127
+ callbacks: {
2128
+ title: function (items) {
2129
+ var row = items.length ? current[items[0].dataIndex] : null
2130
+ return row && config.label ? String(row[config.label] ?? "") : ""
2131
+ },
2132
+ label: function (context) {
2133
+ var row = current[context.dataIndex]
2134
+ if (!row) return ""
2135
+ var lines = [
2136
+ (config.xLabel ? config.xLabel + " · " : "") + amountText(config.xFormat, num(row[config.x])),
2137
+ (config.yLabel ? config.yLabel + " · " : "") + amountText(config.yFormat, num(row[config.y]))
2138
+ ]
2139
+ if (config.size) lines.push(amountText(config.sizeFormat, num(row[config.size])))
2140
+ return lines
2141
+ }
2142
+ }
2143
+ })
2144
+ }),
2145
+ scales: { x: measured(config.xFormat, config.xLabel), y: measured(config.yFormat, config.yLabel) }
2146
+ })
2147
+ }
2148
+ })
2149
+
2150
+ function refresh() {
2151
+ current = FM.rows(config.query).filter(function (row) {
2152
+ return row[config.x] !== null && row[config.y] !== null && Number.isFinite(Number(row[config.x])) && Number.isFinite(Number(row[config.y]))
2153
+ })
2154
+ if (!current.length && FM.status(config.query) !== "loading") {
2155
+ paint(null, emptyNote(config))
2156
+ return
2157
+ }
2158
+ // a dot's area, not its radius, follows the figure
2159
+ var biggest = config.size ? Math.max.apply(null, current.map(function (row) { return num(row[config.size]) })) || 1 : 1
2160
+ var color = token("--fm-feature-aqua-accent", "#3ab0ff")
2161
+ paint(
2162
+ {
2163
+ datasets: [
2164
+ {
2165
+ data: current.map(function (row) {
2166
+ var point = { x: num(row[config.x]), y: num(row[config.y]) }
2167
+ if (config.size) point.r = 4 + 16 * Math.sqrt(Math.max(0, num(row[config.size])) / biggest)
2168
+ return point
2169
+ }),
2170
+ backgroundColor: rgba(color, 0.7),
2171
+ borderColor: color,
2172
+ borderWidth: 1,
2173
+ pointRadius: 5,
2174
+ pointHoverRadius: 7
2175
+ }
2176
+ ]
2177
+ },
2178
+ null
2179
+ )
2180
+ }
2181
+
2182
+ FM.onRows(config.query, refresh)
2183
+ }
2184
+
2185
+ // --- radar: up to three items' profiles across the same measures
2186
+
2187
+ var RADAR_TONES = [
2188
+ ["--fm-feature-grape-fg", "#512191"],
2189
+ ["--fm-feature-aqua-accent", "#3ab0ff"],
2190
+ ["--fm-feature-mustard-accent", "#ffc746"]
2191
+ ]
2192
+
2193
+ function renderRadar(root, config, base) {
2194
+ base = base || ""
2195
+ var card = tile(root, config.title, base)
2196
+ renderControls(card, config.controls, base)
2197
+ var body = part(el("div", "fmr-tile-body"), base + "chart")
2198
+ card.appendChild(body)
2199
+ var axes = config.axes || []
2200
+ var paint = mountChart(body, function (data) {
2201
+ var opts = chartOpts()
2202
+ return {
2203
+ type: "radar",
2204
+ data: data,
2205
+ options: Object.assign({}, opts, {
2206
+ plugins: Object.assign({}, opts.plugins, {
2207
+ tooltip: Object.assign({}, opts.plugins.tooltip, {
2208
+ callbacks: {
2209
+ label: function (context) {
2210
+ return context.dataset.label + " · " + amountText(config.format, Number(context.raw) || 0)
2211
+ }
2212
+ }
2213
+ })
2214
+ }),
2215
+ scales: {
2216
+ r: {
2217
+ beginAtZero: true,
2218
+ grid: { color: gridColor() },
2219
+ angleLines: { color: gridColor() },
2220
+ ticks: { display: false },
2221
+ pointLabels: { color: token("--fm-text-muted", "#6b6c70"), font: { size: px("--fm-font-size-12", 12) } }
2222
+ }
2223
+ }
2224
+ })
2225
+ }
2226
+ })
2227
+
2228
+ function refresh() {
2229
+ var rows = FM.rows(config.query).slice(0, 3)
2230
+ if (!rows.length && FM.status(config.query) !== "loading") {
2231
+ paint(null, emptyNote(config))
2232
+ return
2233
+ }
2234
+ var datasets = rows.map(function (row, index) {
2235
+ var color = tone(RADAR_TONES, index)
2236
+ return {
2237
+ label: String(row[config.label] ?? ""),
2238
+ data: axes.map(function (one) { return num(row[one.value]) }),
2239
+ borderColor: color,
2240
+ backgroundColor: rgba(color, 0.16),
2241
+ borderWidth: 2,
2242
+ pointRadius: 3,
2243
+ pointBackgroundColor: color
2244
+ }
2245
+ })
2246
+ headLegend(card, datasets.map(function (set) { return { label: set.label, color: set.borderColor } }))
2247
+ paint({ labels: axes.map(function (one) { return one.label }), datasets: datasets }, null)
2248
+ }
2249
+
2250
+ FM.onRows(config.query, refresh)
2251
+ }
2252
+
2253
+ // --- flow: amounts moving from one set of things to another, drawn by chartjs-chart-sankey
2254
+
2255
+ // every family's accent, then its fg, so a dozen things never share a colour
2256
+ var FLOW_TONES = SLICE_TONES.concat([
2257
+ ["--fm-feature-aqua-fg", "#0a5384"],
2258
+ ["--fm-feature-grape-fg", "#512191"],
2259
+ ["--fm-feature-mustard-fg", "#903e1f"],
2260
+ ["--fm-feature-berry-fg", "#5f090a"],
2261
+ ["--fm-feature-lime-fg", "#306a01"],
2262
+ ["--fm-feature-blossom-fg", "#8c146a"]
2263
+ ])
2264
+ // the height the bands keep once a column's gaps are taken out of the plot
2265
+ var FLOW_BAND_ROOM = 160
2266
+
2267
+ // the most nodes one column holds: a node sits one past the deepest node flowing into it
2268
+ function busiestColumn(links) {
2269
+ var into = {}
2270
+ links.forEach(function (link) {
2271
+ ;(into[link.to] = into[link.to] || []).push(link.from)
2272
+ into[link.from] = into[link.from] || []
2273
+ })
2274
+ var depth = {}
2275
+ function level(key, seen) {
2276
+ if (depth[key] !== undefined) return depth[key]
2277
+ if (seen[key]) return 0
2278
+ seen[key] = true
2279
+ var deepest = 0
2280
+ into[key].forEach(function (from) { deepest = Math.max(deepest, level(from, seen) + 1) })
2281
+ return (depth[key] = deepest)
2282
+ }
2283
+ var count = {}
2284
+ var most = 0
2285
+ Object.keys(into).forEach(function (key) {
2286
+ var at = level(key, {})
2287
+ count[at] = (count[at] || 0) + 1
2288
+ most = Math.max(most, count[at])
2289
+ })
2290
+ return most
2291
+ }
2292
+
2293
+ function renderFlow(root, config, base) {
2294
+ base = base || ""
2295
+ var card = tile(root, config.title, base)
2296
+ renderControls(card, config.controls, base)
2297
+ var body = part(el("div", "fmr-tile-body"), base + "chart")
2298
+ card.appendChild(body)
2299
+ var current = []
2300
+ var nodeColor = {}
2301
+ var paint = mountChart(body, function (data) {
2302
+ var opts = chartOpts()
2303
+ return {
2304
+ type: "sankey",
2305
+ data: data,
2306
+ options: Object.assign({}, opts, {
2307
+ onClick: config.clickSet
2308
+ ? function (event, elements) {
2309
+ if (!elements || !elements.length) return
2310
+ var row = current[elements[0].index]
2311
+ if (row) FM.setParams(resolveSet(config.clickSet, row, row[config.to]))
2312
+ }
2313
+ : undefined,
2314
+ plugins: Object.assign({}, opts.plugins, {
2315
+ tooltip: Object.assign({}, opts.plugins.tooltip, {
2316
+ callbacks: {
2317
+ title: function () { return "" },
2318
+ label: function (context) {
2319
+ var row = current[context.dataIndex]
2320
+ if (!row) return ""
2321
+ return String(row[config.from]) + " → " + String(row[config.to]) + " · " + amountText(config.format, num(row[config.value]))
2322
+ }
2323
+ }
2324
+ })
2325
+ })
2326
+ })
2327
+ }
2328
+ })
2329
+
2330
+ function refresh() {
2331
+ current = FM.rows(config.query).filter(function (row) { return num(row[config.value]) > 0 })
2332
+ if (!current.length && FM.status(config.query) !== "loading") {
2333
+ paint(null, emptyNote(config))
2334
+ return
2335
+ }
2336
+ var registry = window.Chart && window.Chart.registry
2337
+ if (registry && !registry.controllers.get("sankey")) {
2338
+ body.textContent = ""
2339
+ body.appendChild(el("p", "fmr-note error", "flow library unavailable"))
2340
+ return
2341
+ }
2342
+ // each thing keeps one colour wherever it appears, in the order the rows first name it
2343
+ nodeColor = {}
2344
+ var named = 0
2345
+ current.forEach(function (row) {
2346
+ ;[row[config.from], row[config.to]].forEach(function (name) {
2347
+ var key = String(name)
2348
+ if (!nodeColor[key]) nodeColor[key] = tone(FLOW_TONES, named++)
2349
+ })
2350
+ })
2351
+ var links = current.map(function (row) {
2352
+ return { from: String(row[config.from]), to: String(row[config.to]), flow: num(row[config.value]) }
2353
+ })
2354
+ // one label line between thin nodes: the library shrinks its gaps with the bands, so size them to land at a line
2355
+ var fontSize = px("--fm-font-size-12", 12)
2356
+ var line = Math.ceil(fontSize * 1.2) + 2
2357
+ var gaps = busiestColumn(links) - 1
2358
+ var wrap = body.querySelector(".fmr-chart")
2359
+ wrap.style.minHeight = gaps * line + FLOW_BAND_ROOM + "px"
2360
+ var height = Math.max(wrap.clientHeight, gaps * line + FLOW_BAND_ROOM)
2361
+ paint(
2362
+ {
2363
+ datasets: [
2364
+ {
2365
+ data: links,
2366
+ nodePadding: (line * height) / (height - gaps * line),
2367
+ colorFrom: function (context) {
2368
+ var item = context.dataset.data[context.dataIndex]
2369
+ return item ? nodeColor[item.from] : undefined
2370
+ },
2371
+ colorTo: function (context) {
2372
+ var item = context.dataset.data[context.dataIndex]
2373
+ return item ? nodeColor[item.to] : undefined
2374
+ },
2375
+ colorMode: "gradient",
2376
+ // a part ends beside its siblings, not pushed to the last column
2377
+ modeX: "even",
2378
+ alpha: 0.6,
2379
+ borderWidth: 0,
2380
+ nodeWidth: 10,
2381
+ size: "max",
2382
+ color: token("--fm-text-primary", "#1c1c1e"),
2383
+ font: { size: fontSize }
2384
+ }
2385
+ ]
2386
+ },
2387
+ null
2388
+ )
2389
+ }
2390
+
2391
+ FM.onRows(config.query, refresh)
2392
+ }
2393
+
1158
2394
  var RENDERERS = {
1159
2395
  "filter-bar": renderFilterBar,
1160
2396
  "kpi-row": renderKpiRow,
@@ -1163,7 +2399,14 @@
1163
2399
  table: renderTable,
1164
2400
  heatmap: renderHeatmap,
1165
2401
  "detail-panel": renderDetailPanel,
1166
- "page-header": renderPageHeader
2402
+ "page-header": renderPageHeader,
2403
+ funnel: renderFunnel,
2404
+ waterfall: renderWaterfall,
2405
+ donut: renderDonut,
2406
+ map: renderMap,
2407
+ scatter: renderScatter,
2408
+ radar: renderRadar,
2409
+ flow: renderFlow
1167
2410
  }
1168
2411
 
1169
2412
  FM.components = {