@feedmepos/mf-report 5.31.3 → 5.32.0-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 (40) hide show
  1. package/dist/App-o4z-EAO1.js +339 -0
  2. package/dist/{DateRangeSelect-kBTiWzl8.js → DateRangeSelect-DO3T_rQs.js} +18 -18
  3. package/dist/{Default.vue_vue_type_script_setup_true_lang-QJJFd-aD.js → Default.vue_vue_type_script_setup_true_lang-CW_VllVL.js} +1 -1
  4. package/dist/DigestListView-DG-vojKl.js +637 -0
  5. package/dist/DigestReaderView-bRfEYtZA.js +507 -0
  6. package/dist/{DynamicWidget.vue_vue_type_script_setup_true_lang-dhMaJAWF.js → DynamicWidget.vue_vue_type_script_setup_true_lang-Bfmd5eMI.js} +173 -173
  7. package/dist/{GlobalFilterSelect.vue_vue_type_script_setup_true_lang-Odt_cfjt.js → GlobalFilterSelect.vue_vue_type_script_setup_true_lang-vy0FZKv0.js} +154 -154
  8. package/dist/{InsightView-CoyqETUU.js → InsightView-sLKiFc_-.js} +16 -16
  9. package/dist/{Integration-nK20FSKv.js → Integration-CZn5gvF8.js} +3 -3
  10. package/dist/{Integrations-B2zhjy-7.js → Integrations-I0rHAfPo.js} +2 -2
  11. package/dist/{Layout-Dl7UP2Mh.js → Layout-DPEfiH3f.js} +1 -1
  12. package/dist/{LocationSelect.vue_vue_type_script_setup_true_lang-DroCP4X4.js → LocationSelect.vue_vue_type_script_setup_true_lang-DFQ943gj.js} +2 -2
  13. package/dist/{MenuTab.vue_vue_type_script_setup_true_lang-Es9KEHbl.js → MenuTab.vue_vue_type_script_setup_true_lang-BjJcvN_z.js} +9 -9
  14. package/dist/{NavigationTab.vue_vue_type_script_setup_true_lang-BohRRfew.js → NavigationTab.vue_vue_type_script_setup_true_lang-zUF-G98m.js} +6 -6
  15. package/dist/{OverviewView-_5c2H2MT.js → OverviewView-CJS82eII.js} +5 -5
  16. package/dist/{Report-2w23PAp1.js → Report-DDYgnr3v.js} +30 -30
  17. package/dist/ReportCanvasView-m_qW7QgV.js +966 -0
  18. package/dist/{ReportEditor-Bd6WesQn.js → ReportEditor-CrMd2q2a.js} +7 -7
  19. package/dist/{ReportView-DlYt2nTi.js → ReportView-CKLzDw5j.js} +6 -6
  20. package/dist/{ReportViewRoute-37OLAHAq.js → ReportViewRoute-BrLi7iXr.js} +2 -2
  21. package/dist/{Setting-PmW3vgf2.js → Setting-BUmSYXCO.js} +3 -3
  22. package/dist/{Template-DkTDUZL3.js → Template-CljYVpCi.js} +18 -18
  23. package/dist/{TileEditor-B_X1Z1IF.js → TileEditor-3fV8xOVc.js} +6 -6
  24. package/dist/{TilePageStack.vue_vue_type_script_setup_true_lang-Dj9ImYdH.js → TilePageStack.vue_vue_type_script_setup_true_lang-Biwl30EU.js} +2 -2
  25. package/dist/{TransitionFade-k7vzRKon.js → TransitionFade-BUSdiKFH.js} +5 -5
  26. package/dist/{app-DOyssexi.js → app-BXVd3ce-.js} +3354 -2967
  27. package/dist/app.js +4 -4
  28. package/dist/assets/{formatChartData.worker-R9EGYsk6.js → formatChartData.worker-BprlZnJs.js} +80 -58
  29. package/dist/assets/{processTableData.worker-CxpDa0tB.js → processTableData.worker-D5GSFMnH.js} +83 -61
  30. package/dist/{currency-Dzf-h7uE.js → currency-C6h0cwip.js} +1 -1
  31. package/dist/{index-uhMfapU4.js → index-BoeJLw2U.js} +1279 -1244
  32. package/dist/{report-D1hOlAxl.js → report-DG-20Pq8.js} +18 -18
  33. package/dist/report-canvas-libs/components-1.js +1126 -71
  34. package/dist/report-canvas-libs/geo-outlines-1.js +1 -1
  35. package/dist/style.css +1 -1
  36. package/dist/useDigestFont-Gf1l5qYq.js +205 -0
  37. package/dist/{ReportCanvasView-C0WPmew5.js → useReportCanvasPanel-CquUHqIK.js} +2933 -3477
  38. package/dist/{useRestaurantPermission-DdscVgP6.js → useRestaurantPermission-BNu9cN13.js} +1 -1
  39. package/package.json +1 -1
  40. package/dist/App-CAzuIycx.js +0 -339
@@ -104,6 +104,26 @@
104
104
  return envOf("timezone", undefined)
105
105
  }
106
106
 
107
+ /** The day a value names when it is a plain date or a local midnight, as a query's truncated timestamp reads ("2026-08-03 00:00:00"); else null. */
108
+ function plainDay(value) {
109
+ var match = /^(\d{4}-\d{2}-\d{2})(?:[ T]00:00(?::00(?:\.0+)?)?)?$/.exec(String(value))
110
+ return match ? match[1] : null
111
+ }
112
+
113
+ /** A plain day as a digest bar's category reads it: its weekday and date, never the query's ISO text. */
114
+ function dayName(value) {
115
+ var at = plainDay(value)
116
+ var day = new Date(at)
117
+ return isNaN(day) ? String(value) : day.toLocaleDateString(locale(), { weekday: "short", month: "short", day: "numeric", timeZone: zoneFor(at) })
118
+ }
119
+
120
+ /** The decimals a value needs at two-decimal precision: 0 for 2, 1 for 2.9. */
121
+ function decimalsOf(value) {
122
+ var text = String(Math.round(num(value) * 100) / 100)
123
+ var at = text.indexOf(".")
124
+ return at < 0 ? 0 : Math.min(2, text.length - at - 1)
125
+ }
126
+
107
127
  function intl(digits) {
108
128
  return new Intl.NumberFormat(locale(), {
109
129
  minimumFractionDigits: digits,
@@ -117,6 +137,8 @@
117
137
  case "money":
118
138
  return intl(2).format(num(value))
119
139
  case "amount":
140
+ // a digest's amounts are units, never money: 300 units, 2.5 kg
141
+ if (isDigest()) return new Intl.NumberFormat(locale(), { maximumFractionDigits: 2 }).format(num(value))
120
142
  return intl(2).format(num(value))
121
143
  case "count":
122
144
  return intl(0).format(num(value))
@@ -135,15 +157,18 @@
135
157
  }
136
158
  }
137
159
 
138
- /** Axis ticks: RM 0, RM 2K, RM 10K — the locale's own compact form, per the chart guide. */
160
+ /** Axis ticks: RM 0, RM 2K, RM 10K, 5% — the locale's own compact form, per the chart guide. */
139
161
  function abbrev(format) {
162
+ // a percent's ticks carry their sign, as its figures do; a ratio's read as the percent it is
163
+ var unit = format === "percent" || format === "ratio" ? "%" : ""
140
164
  return function (value) {
141
- var v = num(value)
165
+ var v = format === "ratio" ? num(value) * 100 : num(value)
142
166
  var currency = format === "money" ? envOf("currency", "") : ""
143
167
  // a tiny domain otherwise rounds every tick to the same label
144
168
  var small = Math.abs(v) > 0 && Math.abs(v) < 10
169
+ // a digest's ticks drop the zeros a whole tick does not need, so 1 sits beside 10
145
170
  var spec = small
146
- ? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
171
+ ? { minimumFractionDigits: isDigest() ? 0 : 2, maximumFractionDigits: 2 }
147
172
  : { notation: "compact", maximumFractionDigits: 1, minimumFractionDigits: 0 }
148
173
  if (currency) {
149
174
  spec.style = "currency"
@@ -151,13 +176,13 @@
151
176
  spec.currencyDisplay = "code"
152
177
  }
153
178
  try {
154
- return new Intl.NumberFormat(locale(), spec).format(v)
179
+ return new Intl.NumberFormat(locale(), spec).format(v) + unit
155
180
  } catch (err) {
156
181
  // an unknown currency code is not a reason to draw no axis
157
182
  delete spec.style
158
183
  delete spec.currency
159
184
  delete spec.currencyDisplay
160
- return (currency ? currency + " " : "") + new Intl.NumberFormat(locale(), spec).format(v)
185
+ return (currency ? currency + " " : "") + new Intl.NumberFormat(locale(), spec).format(v) + unit
161
186
  }
162
187
  }
163
188
  }
@@ -533,7 +558,8 @@
533
558
  var raw = rows.length ? rows[0][tileConfig.value] : null
534
559
  if (tileConfig.format === "money" && raw !== null && raw !== undefined) {
535
560
  target.appendChild(el("span", "fmr-kpi-cur", (window.env && window.env.currency) || ""))
536
- target.appendChild(document.createTextNode(formatValue("amount", raw)))
561
+ // money to two decimals, as the words beside it are; a digest's amounts are units
562
+ target.appendChild(document.createTextNode(formatValue("money", raw)))
537
563
  } else {
538
564
  target.appendChild(document.createTextNode(formatValue(tileConfig.format || "amount", raw)))
539
565
  }
@@ -595,14 +621,223 @@
595
621
  chart.data = data
596
622
  chart.update()
597
623
  } else {
624
+ if (isDigest() && window.Chart.register && !numberRoomOn) {
625
+ window.Chart.register(numberRoom)
626
+ numberRoomOn = true
627
+ }
598
628
  chart = new window.Chart(canvas, build(data))
599
629
  }
600
630
  }
601
631
  }
602
632
  paint.chart = function () { return chart }
633
+ // the next paint builds the chart anew, as when it turns on its side
634
+ paint.rebuild = function () {
635
+ if (chart) chart.destroy()
636
+ chart = null
637
+ }
603
638
  return paint
604
639
  }
605
640
 
641
+ function figureFont() {
642
+ return "600 12px " + (getComputedStyle(document.body).fontFamily || "sans-serif")
643
+ }
644
+
645
+ var measuring = null
646
+ function measurer() {
647
+ if (!measuring) measuring = document.createElement("canvas").getContext("2d")
648
+ return measuring
649
+ }
650
+
651
+ function textWidth(ctx, font, label) {
652
+ if (!ctx) return String(label).length * 7
653
+ ctx.save()
654
+ ctx.font = font
655
+ var width = ctx.measureText(String(label)).width
656
+ ctx.restore()
657
+ return width
658
+ }
659
+
660
+ /** A name's words in lines no wider than `room` in the font `spec`, no word cut. */
661
+ function wrapWords(ctx, spec, label, room) {
662
+ var lines = []
663
+ String(label).split(/\s+/).forEach(function (word) {
664
+ var line = lines[lines.length - 1]
665
+ if (line !== undefined && textWidth(ctx, spec, line + " " + word) <= room) lines[lines.length - 1] = line + " " + word
666
+ else lines.push(word)
667
+ })
668
+ return lines
669
+ }
670
+
671
+ /** A scale's tick font as a canvas font. */
672
+ function tickSpec(scale) {
673
+ var font = scale.options.ticks.font
674
+ return (font.weight ? font.weight + " " : "") + font.size + "px " + (font.family || getComputedStyle(document.body).fontFamily || "sans-serif")
675
+ }
676
+
677
+ /** A name's words in lines no wider than `room`, by default its bar's share of the axis, no word cut. */
678
+ function wordLines(scale, label, room) {
679
+ if (room === undefined) room = scale.width / Math.max(1, scale.chart.data.labels.length) - 4
680
+ var lines = wrapWords(scale.ctx, tickSpec(scale), label, room)
681
+ return lines.length > 1 ? lines : lines[0]
682
+ }
683
+
684
+ // chart.js skips a flat axis's labels at the width it had before the axes beside it took theirs
685
+ var numberRoomOn = false
686
+ /** A digest's flat number axis keeps every label, then every second, third… from the first, until each has its skip padding at the width the layout gave it. */
687
+ var numberRoom = {
688
+ id: "fmrNumberRoom",
689
+ afterLayout: function (chart) {
690
+ if (!isDigest()) return
691
+ Object.keys(chart.scales).forEach(function (id) {
692
+ var scale = chart.scales[id]
693
+ var ticks = scale.ticks || []
694
+ if (!scale.isHorizontal() || scale.type === "category" || scale.options.display === false || !scale.options.ticks.display || ticks.length < 2) return
695
+ var pad = scale.options.ticks.autoSkipPadding || 0
696
+ var spec = tickSpec(scale)
697
+ var ends = ticks.map(function (tick) {
698
+ var width = [].concat(tick.label).reduce(function (most, line) { return Math.max(most, textWidth(scale.ctx, spec, line)) }, 0)
699
+ var at = scale.getPixelForValue(tick.value)
700
+ return [at - width / 2, at + width / 2]
701
+ })
702
+ var step = 1
703
+ var crowded = function (at) { return at >= step && at % step === 0 && ends[at][0] - ends[at - step][1] < pad }
704
+ while (step < ticks.length && ticks.some(function (_, at) { return crowded(at) })) step++
705
+ if (step === 1) return
706
+ scale.ticks = ticks.filter(function (_, at) { return at % step === 0 })
707
+ scale._labelItems = null
708
+ scale._labelSizes = null
709
+ })
710
+ }
711
+ }
712
+
713
+ /** A bar's widest: a digest's page is wide and read, so its bars fill more of their slot than a builder canvas's. */
714
+ function barThickness() {
715
+ return isDigest() ? 32 : 20
716
+ }
717
+
718
+ /** A chart on its side gives each row room for every line its longest name wraps to at half the card's width, where sideNames wraps them, so no two names overlap. */
719
+ function sideHeight(box, names) {
720
+ var size = px("--fm-font-size-14", 14)
721
+ var spec = size + "px " + (getComputedStyle(document.body).fontFamily || "sans-serif")
722
+ var room = Math.max(60, box.clientWidth / 2 - 14)
723
+ var lines = names.reduce(function (most, name) { return Math.max(most, wrapWords(measurer(), spec, name, room).length) }, 1)
724
+ return Math.max(240, names.length * Math.max(38, lines * Math.ceil(size * 1.2) + 12) + 48)
725
+ }
726
+
727
+ /** Sizes a chart on its side for its names (none: back to its own height), measured again whenever its card's width moves, as a card first drawn narrow later widens. */
728
+ function sideFit(body) {
729
+ var names = null
730
+ var width = -1
731
+ var due = false
732
+ function fit() {
733
+ var box = body.querySelector(".fmr-chart")
734
+ if (!box) return
735
+ width = box.clientWidth
736
+ var height = names ? sideHeight(box, names) + "px" : ""
737
+ if (box.style.height !== height) box.style.height = height
738
+ }
739
+ if (typeof ResizeObserver !== "undefined") {
740
+ // measured a frame later, so a height set here never resizes inside the observer's loop
741
+ new ResizeObserver(function () {
742
+ if (due || !names) return
743
+ due = true
744
+ requestAnimationFrame(function () {
745
+ due = false
746
+ var box = body.querySelector(".fmr-chart")
747
+ if (box && names && box.clientWidth !== width) fit()
748
+ })
749
+ }).observe(body)
750
+ }
751
+ return function (next) {
752
+ names = next
753
+ fit()
754
+ }
755
+ }
756
+
757
+ /** A digest never tilts a number: a tick that does not fit is skipped; the canvas builder's own charts keep chart.js's ticks. */
758
+ function numberTicks(ticks) {
759
+ return isDigest() ? Object.assign(ticks, { maxRotation: 0, autoSkip: true, autoSkipPadding: 6 }) : ticks
760
+ }
761
+
762
+ /** A digest's upright names never tilt: numbers skip what does not fit, names wrap by word and, with `turn`, a name wider than its bar turns the chart. */
763
+ function uprightNames(labels, turn) {
764
+ var numbers = labels.every(function (label) { return /^-?\d+(\.\d+)?$/.test(String(label).trim()) })
765
+ if (numbers) return numberTicks({})
766
+ if (!turn) return { maxRotation: 0, autoSkip: false, callback: function (value) { return wrapLabel(this.getLabelForValue(value), Math.max(4, Math.floor(this.width / Math.max(1, labels.length) / 7.6))) } }
767
+ return { autoSkip: false, callback: function (value) { return wordLines(this, this.getLabelForValue(value)) } }
768
+ }
769
+
770
+ /** A digest chart's side: it turns when its names or figures do not fit upright, and tries upright again once its card is wider. */
771
+ function sideTurner(body, redraw) {
772
+ var side = { sideways: false, at: 0 }
773
+ side.turn = function (onSide) {
774
+ if (side.sideways === onSide) return
775
+ side.sideways = onSide
776
+ side.at = onSide ? body.clientWidth : 0
777
+ redraw()
778
+ }
779
+ side.onResize = function () {
780
+ if (side.sideways && body.clientWidth > side.at) Promise.resolve().then(function () { side.turn(false) })
781
+ }
782
+ return side
783
+ }
784
+
785
+ /** The names down a digest chart on its side, wrapped by measure to the width chart.js gives the axis (half the chart less its padding), so none runs off the canvas. */
786
+ function sideNames(ax) {
787
+ return Object.assign({}, ax.ticks, {
788
+ autoSkip: false,
789
+ callback: function (value) {
790
+ var grid = this.options.grid || {}
791
+ var marks = grid.drawTicks === false ? 0 : grid.tickLength || 0
792
+ return wordLines(this, this.getLabelForValue(value), (this.maxWidth || this.chart.width / 2) - marks - 2 * (this.options.ticks.padding || 0))
793
+ }
794
+ })
795
+ }
796
+
797
+ /** Turns an upright chart on its side before it draws, when chart.js would tilt its names or a figure overflows its bar. */
798
+ function turnPlugin(turn, figures) {
799
+ return {
800
+ id: "fmrTurn",
801
+ afterLayout: function (chart) {
802
+ if (chart.options.indexAxis === "y") return
803
+ var room = chart.chartArea.width / Math.max(1, chart.data.labels.length) - 4
804
+ var widest = (figures ? figures() : []).reduce(function (most, label) { return Math.max(most, textWidth(chart.ctx, figureFont(), label)) }, 0)
805
+ chart.$fmrTurn = chart.scales.x.labelRotation > 0 || widest > room
806
+ },
807
+ beforeDraw: function (chart) {
808
+ if (!chart.$fmrTurn) return
809
+ chart.$fmrTurn = false
810
+ Promise.resolve().then(turn)
811
+ return false
812
+ }
813
+ }
814
+ }
815
+
816
+ /** Writes each bar's figure at its end: above an upright bar, past the end of one on its side. */
817
+ function figuresPlugin(figures) {
818
+ return {
819
+ id: "fmrFigures",
820
+ afterDatasetsDraw: function (chart) {
821
+ var ctx = chart.ctx
822
+ var meta = chart.getDatasetMeta(0)
823
+ var sideways = chart.options.indexAxis === "y"
824
+ ctx.save()
825
+ ctx.font = figureFont()
826
+ ctx.fillStyle = token("--fm-text-default", "#1c1c1e")
827
+ ctx.textAlign = sideways ? "left" : "center"
828
+ ctx.textBaseline = sideways ? "middle" : "bottom"
829
+ figures().forEach(function (label, index) {
830
+ var bar = meta.data[index]
831
+ if (!bar || !label) return
832
+ var at = bar.getProps(["x", "y", "base"], true)
833
+ if (sideways) ctx.fillText(label, Math.max(at.x, at.base) + 6, at.y)
834
+ else ctx.fillText(label, at.x, Math.min(at.y, at.base) - 4)
835
+ })
836
+ ctx.restore()
837
+ }
838
+ }
839
+ }
840
+
606
841
  function renderTimeseries(root, config, base) {
607
842
  base = base || ""
608
843
  var card = tile(root, config.title, base)
@@ -612,18 +847,39 @@
612
847
  var compareQuery = config.compare && (config.compare.query || config.query)
613
848
  var allSeries = config.series || []
614
849
  var rightIndex = allSeries.findIndex(function (series) { return series.axis === "right" })
850
+ var rawXs = []
851
+ // digest only: the points of the report's own week, when the line also runs over the weeks before it
852
+ var picked = []
615
853
  var paint = mountChart(body, function (data) {
616
854
  var ax = axis()
617
855
  var scales = {
618
856
  x: Object.assign({}, ax, { stacked: !!config.stack, grid: { display: false } }),
619
857
  y: Object.assign({}, ax, {
620
- // a value axis cropped above zero exaggerates every wiggle into a peak
621
- beginAtZero: true,
858
+ // a value axis cropped above zero exaggerates every wiggle into a peak, unless a shaded usual range gives the scale
859
+ beginAtZero: !(isDigest() && config.band),
622
860
  stacked: !!config.stack,
623
861
  grid: { color: gridColor() },
624
862
  ticks: Object.assign({}, ax.ticks, { callback: abbrev(config.format) })
625
863
  })
626
864
  }
865
+ // a digest leaves headroom over the tallest mark, so the top tick sits above it, and draws five ticks at most
866
+ if (isDigest()) {
867
+ scales.y.grace = "8%"
868
+ scales.y.ticks.maxTicksLimit = 5
869
+ scales.x.ticks = Object.assign({}, ax.ticks, {
870
+ font: function (context) { return Object.assign({}, ax.ticks.font, picked[context.tick ? context.tick.value : context.index] ? { weight: "700" } : {}) },
871
+ color: function (context) { return picked[context.tick ? context.tick.value : context.index] ? token("--fm-text-primary", "#1c1c1e") : ax.ticks.color },
872
+ // dates thin out counting back from the report week's (else the last), so its date always reads
873
+ autoSkip: false,
874
+ maxRotation: 0,
875
+ callback: function (value, index) {
876
+ var count = this.chart.data.labels.length
877
+ var anchor = picked.lastIndexOf(true) >= 0 ? picked.lastIndexOf(true) : count - 1
878
+ var every = Math.ceil(count / Math.max(2, Math.floor(this.width / 64)))
879
+ return Math.abs(index - anchor) % every === 0 ? this.getLabelForValue(value) : ""
880
+ }
881
+ })
882
+ }
627
883
  if (rightIndex >= 0) {
628
884
  // two units on one chart: each axis says which series it reads, in that series' colour
629
885
  var left = data.datasets.filter(function (set) { return set.yAxisID !== "y1" && !set.isCompare })
@@ -641,11 +897,17 @@
641
897
  ticks: Object.assign({}, ax.ticks, { callback: abbrev(config.rightFormat) })
642
898
  })
643
899
  }
644
- return {
900
+ var built = {
645
901
  type: "line",
646
902
  data: data,
647
903
  options: Object.assign({}, chartOpts(), { scales: scales })
648
904
  }
905
+ // digest only: notes pinned above the points they name
906
+ if (config.annotations && config.annotations.length) {
907
+ built.options.layout = { padding: { top: 26 } }
908
+ built.plugins = [notesPlugin(config.annotations, function () { return rawXs })]
909
+ }
910
+ return built
649
911
  })
650
912
 
651
913
  function refresh() {
@@ -654,16 +916,25 @@
654
916
  paint(null, emptyNote(config))
655
917
  return
656
918
  }
919
+ rawXs = rows.map(function (row) { return String(row[config.x]) })
920
+ // a line over days reads them as days, whether or not its config names the axis's type
921
+ var days = config.xType === "day" || (!config.xType && rows.length > 0 && rows.every(function (row) { return plainDay(row[config.x]) }))
922
+ var hits = isDigest() && days ? rows.map(function (row) { return inReportWeek(row[config.x]) }) : []
923
+ picked = hits.some(Boolean) && !hits.every(Boolean) ? hits : []
657
924
  var labels = rows.map(function (row) {
658
925
  var raw = row[config.x]
659
- if (config.xType === "day") {
660
- var day = new Date(raw)
926
+ if (days) {
927
+ var at = plainDay(raw) || raw
928
+ var day = new Date(at)
661
929
  if (!isNaN(day)) {
662
- return day.toLocaleDateString(locale(), {
930
+ var date = day.toLocaleDateString(locale(), {
663
931
  month: "short",
664
932
  day: "numeric",
665
- timeZone: zoneFor(raw)
933
+ timeZone: zoneFor(at)
666
934
  })
935
+ // a digest's week reads by weekday first, the date under it
936
+ if (isDigest() && rows.length <= 14) return [day.toLocaleDateString(locale(), { weekday: "short", timeZone: zoneFor(at) }), date]
937
+ return date
667
938
  }
668
939
  }
669
940
  return String(raw)
@@ -675,25 +946,28 @@
675
946
  // few enough days to read one by one: draw the points, or the line is a bare triangle
676
947
  var marker = rows.length <= 14 ? 3 : 0
677
948
  var barIndex = 0
949
+ // several bar series fade into one another, so only a lone series dims the weeks outside the report's; the axis still bolds its week
950
+ var fades = (config.series || []).filter(function (series) { return series.kind === "bar" }).length === 1
951
+ var palette = familyPalette(config)
678
952
  var datasets = (config.series || []).map(function (series, index) {
679
953
  var data = rows.map(function (row) { return num(row[series.y]) })
680
954
  var axisId = series.axis === "right" ? "y1" : undefined
681
955
  if (series.kind === "bar") {
682
956
  // 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)
957
+ var fill = palette ? tone(palette, index) : barIndex++ === 0 ? barColor() : tone(BAR_TONES.vertical, barIndex - 1)
684
958
  return {
685
959
  label: series.label,
686
960
  type: "bar",
687
961
  data: data,
688
- backgroundColor: fill,
962
+ backgroundColor: picked.length && fades ? picked.map(function (hit) { return hit ? fill : rgba(fill, 0.35) }) : fill,
689
963
  borderWidth: 0,
690
- maxBarThickness: 20,
964
+ maxBarThickness: barThickness(),
691
965
  stack: config.stack ? "stack" : undefined,
692
966
  yAxisID: axisId,
693
967
  order: 2
694
968
  }
695
969
  }
696
- var color = lineColor(lineIndex++)
970
+ var color = palette ? tone(palette, index) : lineColor(lineIndex++)
697
971
  var area = series.kind === "area"
698
972
  return {
699
973
  label: series.label,
@@ -704,7 +978,9 @@
704
978
  borderWidth: 2,
705
979
  tension: 0,
706
980
  stepped: series.stepped ? "before" : false,
707
- pointRadius: marker,
981
+ pointRadius: picked.length ? picked.map(function (hit) { return hit ? 5.5 : marker }) : marker,
982
+ pointBorderColor: picked.length ? picked.map(function (hit) { return hit ? token("--fm-bg-default", "#fff") : color }) : color,
983
+ pointBorderWidth: picked.length ? picked.map(function (hit) { return hit ? 2 : 1 }) : 1,
708
984
  pointHoverRadius: 4,
709
985
  fill: area ? (config.stack ? "stack" : "origin") : false,
710
986
  // a line is never part of a stack: each one runs on its own
@@ -715,14 +991,15 @@
715
991
  })
716
992
  if (config.compare) {
717
993
  var compareRows = FM.rows(compareQuery)
718
- datasets.push({
719
- label: config.compare.label || "Previous period",
994
+ var compareSet = {
995
+ label: config.compare.label || (isDigest() ? text("digest.lastWeek", "Last week") : "Previous period"),
720
996
  type: "line",
721
997
  isCompare: true,
722
998
  // aligned by row index: both windows are the same length by construction
723
999
  data: rows.map(function (row, index) {
724
1000
  var match = compareRows[index]
725
- return match ? num(match[config.compare.y]) : null
1001
+ if (!match || (isDigest() && (match[config.compare.y] === null || match[config.compare.y] === undefined))) return null
1002
+ return num(match[config.compare.y])
726
1003
  }),
727
1004
  borderColor: compareColor(),
728
1005
  borderDash: [4, 4],
@@ -732,13 +1009,163 @@
732
1009
  pointHoverRadius: 4,
733
1010
  fill: false,
734
1011
  order: 3
1012
+ }
1013
+ // digest days: the same day before is a ring over its bar, not a line across the days
1014
+ if (comparePoints(config)) {
1015
+ compareSet.showLine = false
1016
+ compareSet.order = 0
1017
+ compareSet.pointRadius = 4.5
1018
+ compareSet.pointHoverRadius = 5.5
1019
+ compareSet.pointBorderWidth = 1.8
1020
+ compareSet.pointBackgroundColor = token("--fm-bg-default", "#fff")
1021
+ compareSet.pointBorderColor = compareColor()
1022
+ }
1023
+ datasets.push(compareSet)
1024
+ }
1025
+ // digest only: the usual range, shaded between a low and a high column of rows keyed by x
1026
+ if (config.band) {
1027
+ var byX = {}
1028
+ FM.rows(config.band.query || config.query).forEach(function (row) {
1029
+ byX[String(row[config.x])] = row
735
1030
  })
1031
+ var bandLabel = config.band.label || text("digest.usualRange", "Usual range")
1032
+ var bandOf = function (key) {
1033
+ return rows.map(function (row) {
1034
+ var match = byX[String(row[config.x])]
1035
+ return match && match[key] !== null && match[key] !== undefined ? num(match[key]) : null
1036
+ })
1037
+ }
1038
+ var bandFill = rgba(token("--fm-feature-aqua-fg", "#0a5384"), 0.14)
1039
+ var lows = bandOf(config.band.low)
1040
+ var highs = bandOf(config.band.high)
1041
+ if ((config.series || [])[0] && config.series[0].kind === "bar") {
1042
+ // each bar's usual range is a pale box behind it, as wide as a day
1043
+ datasets.push({
1044
+ label: bandLabel,
1045
+ type: "bar",
1046
+ isBand: true,
1047
+ data: lows.map(function (low, index) { return low === null || highs[index] === null ? null : [low, highs[index]] }),
1048
+ backgroundColor: rgba(token("--fm-text-disabled", "#aeaeb2"), 0.28),
1049
+ borderWidth: 0,
1050
+ borderRadius: px("--fm-radius-xs", 4),
1051
+ grouped: false,
1052
+ maxBarThickness: 34,
1053
+ order: 5
1054
+ })
1055
+ } else {
1056
+ datasets.push(
1057
+ { label: bandLabel, type: "line", isBand: true, data: lows, borderWidth: 0, pointRadius: 0, pointHoverRadius: 0, tension: 0, fill: false, order: 4 },
1058
+ { label: bandLabel, type: "line", isBand: true, data: highs, borderWidth: 0, pointRadius: 0, pointHoverRadius: 0, tension: 0, backgroundColor: bandFill, fill: "-1", order: 4 }
1059
+ )
1060
+ }
736
1061
  }
737
1062
  paint({ labels: labels, datasets: datasets }, null)
738
1063
  }
739
1064
 
1065
+ if (isDigest() && (config.band || config.compare || (config.series || []).length > 1)) card.appendChild(seriesKey(config))
1066
+ else if (config.band) card.appendChild(el("div", "fmr-note fmr-band-note", "▨ " + (config.band.label || text("digest.usualRange", "Usual range"))))
740
1067
  FM.onRows(config.query, refresh)
741
1068
  if (compareQuery && compareQuery !== config.query) FM.onRows(compareQuery, refresh)
1069
+ if (config.band && config.band.query && config.band.query !== config.query && config.band.query !== compareQuery) FM.onRows(config.band.query, refresh)
1070
+ }
1071
+
1072
+ /** In a digest, a comparison over bars reads as a ring per bar. */
1073
+ function comparePoints(config) {
1074
+ return isDigest() && !!(config.series || [])[0] && config.series[0].kind === "bar"
1075
+ }
1076
+
1077
+ /** A digest chart's key: each series in its colour, the comparison line and the usual range. */
1078
+ function seriesKey(config) {
1079
+ var key = el("div", "fmr-d-legend fmr-d-key")
1080
+ var bars = 0
1081
+ var lines = 0
1082
+ var palette = familyPalette(config)
1083
+ ;(config.series || []).forEach(function (series, index) {
1084
+ var bar = series.kind === "bar"
1085
+ var color = palette ? tone(palette, index) : bar ? (bars++ === 0 ? barColor() : tone(BAR_TONES.vertical, bars - 1)) : lineColor(lines++)
1086
+ var entry = swatch(bar ? "" : "line", series.label || series.y)
1087
+ entry.firstChild.style[bar ? "background" : "borderColor"] = color
1088
+ key.appendChild(entry)
1089
+ })
1090
+ if (config.compare) {
1091
+ var compare = swatch(comparePoints(config) ? "ring" : "line dash", config.compare.label || text("digest.lastWeek", "Last week"))
1092
+ compare.firstChild.style.borderColor = compareColor()
1093
+ key.appendChild(compare)
1094
+ }
1095
+ if (config.band) key.appendChild(swatch("band", config.band.label || text("digest.usualRange", "Usual range")))
1096
+ return key
1097
+ }
1098
+
1099
+ /** Draws each note above the highest mark at its x, in the house's annotation colour. */
1100
+ function notesPlugin(notes, xs) {
1101
+ return {
1102
+ id: "fmrNotes",
1103
+ afterDatasetsDraw: function (chart) {
1104
+ var ctx = chart.ctx
1105
+ var keys = xs()
1106
+ ctx.save()
1107
+ ctx.font = "600 12px " + (getComputedStyle(document.body).fontFamily || "sans-serif")
1108
+ ctx.fillStyle = token("--fm-feature-grape-fg", "#512191")
1109
+ ctx.strokeStyle = ctx.fillStyle
1110
+ ctx.lineWidth = 1.2
1111
+ var area = chart.chartArea
1112
+ var drawn = []
1113
+ notes.forEach(function (note) {
1114
+ var index = keys.indexOf(String(note.x))
1115
+ if (index < 0) return
1116
+ var marks = []
1117
+ chart.data.datasets.forEach(function (_, datasetIndex) {
1118
+ var meta = chart.getDatasetMeta(datasetIndex)
1119
+ if (!meta.hidden && meta.data[index]) marks.push(meta.data[index])
1120
+ })
1121
+ var label = String(note.label)
1122
+ var w = ctx.measureText(label).width + 8
1123
+ if (chart.options.indexAxis === "y") {
1124
+ // a bar on its side: the note in its row past the bar's end, or past zero when the end leaves no room, never over the names
1125
+ if (!marks.length) return
1126
+ var bar = marks.reduce(function (far, one) { return Math.abs(one.x - one.base) > Math.abs(far.x - far.base) ? one : far })
1127
+ var rightward = bar.x >= bar.base
1128
+ var fits = function (at) { return at >= area.left && at + w <= chart.width }
1129
+ var left = rightward ? bar.x + 6 : bar.x - 6 - w
1130
+ if (!fits(left)) left = rightward ? bar.base - 6 - w : bar.base + 6
1131
+ if (!fits(left)) left = Math.max(area.left, Math.min(left, chart.width - w))
1132
+ ctx.textAlign = "center"
1133
+ ctx.fillText(label, left + w / 2, bar.y + 4)
1134
+ return
1135
+ }
1136
+ var x = chart.scales.x.getPixelForValue(index)
1137
+ var x0 = Math.max(0, Math.min(x - w / 2, chart.width - w))
1138
+ // an upright bar below zero ends at its foot: the note goes under it, or over zero when the axis leaves no room
1139
+ var below = marks.length > 0 && marks.every(function (one) { return one.base !== undefined && one.y > one.base })
1140
+ if (below) {
1141
+ var foot = Math.max.apply(null, marks.map(function (one) { return one.y }))
1142
+ var under = foot + 16
1143
+ var low = under <= area.bottom - 2 ? under : marks[0].base - 8
1144
+ // a note that would cover one already drawn moves a line further from its bar
1145
+ var away = low === under ? 14 : -14
1146
+ while (drawn.some(function (box) { return x0 < box.x1 && x0 + w > box.x0 && Math.abs(low - box.y) < 14 })) low += away
1147
+ drawn.push({ x0: x0, x1: x0 + w, y: low })
1148
+ ctx.textAlign = "center"
1149
+ ctx.fillText(label, x0 + w / 2, low)
1150
+ return
1151
+ }
1152
+ var top = area.bottom
1153
+ marks.forEach(function (one) { top = Math.min(top, one.y) })
1154
+ var ty = Math.max(12, top - 16)
1155
+ // a note that would cover one already drawn moves up a line
1156
+ while (drawn.some(function (box) { return x0 < box.x1 && x0 + w > box.x0 && Math.abs(ty - box.y) < 14 })) ty -= 14
1157
+ ty = Math.max(12, ty)
1158
+ drawn.push({ x0: x0, x1: x0 + w, y: ty })
1159
+ ctx.textAlign = "center"
1160
+ ctx.beginPath()
1161
+ ctx.moveTo(x, top - 4)
1162
+ ctx.lineTo(x, ty + 4)
1163
+ ctx.stroke()
1164
+ ctx.fillText(label, x0 + w / 2, ty)
1165
+ })
1166
+ ctx.restore()
1167
+ }
1168
+ }
742
1169
  }
743
1170
 
744
1171
  // a series beyond the first steps within the family, then takes the next families' accents
@@ -765,6 +1192,17 @@
765
1192
  return token(step[0], step[1])
766
1193
  }
767
1194
 
1195
+ /** A digest chart's palette: its family's ink and accent first, then the other families' accents; grape when it names none; null outside a digest. */
1196
+ function familyPalette(config) {
1197
+ var family = (config && config.family) || "grape"
1198
+ if (!isDigest() || ["grape", "blossom", "aqua", "mustard", "lime", "berry", "stone"].indexOf(family) < 0) return null
1199
+ var steps = [["--fm-feature-" + family + "-fg", "#512191"], ["--fm-feature-" + family + "-accent", "#8f61e8"]]
1200
+ ;["grape", "aqua", "mustard", "blossom", "lime", "berry", "stone"].forEach(function (other) {
1201
+ if (other !== family) steps.push(["--fm-feature-" + other + "-accent", "#8f61e8"])
1202
+ })
1203
+ return steps
1204
+ }
1205
+
768
1206
  /** The house legend, on the right of the card's title row, one swatch per series. */
769
1207
  function headLegend(card, items) {
770
1208
  var head = card.querySelector(".fmr-tile-head")
@@ -792,11 +1230,13 @@
792
1230
  var body = part(el("div", "fmr-tile-body"), base + "chart")
793
1231
  card.appendChild(body)
794
1232
  var horizontal = config.orientation === "horizontal"
795
- var tones = BAR_TONES[horizontal ? "horizontal" : "vertical"]
1233
+ var tones = familyPalette(config) || BAR_TONES[horizontal ? "horizontal" : "vertical"]
796
1234
  var series = config.series || [{ label: config.y, y: config.y }]
797
1235
  var stacked = config.stack === "stacked" || config.stack === "percent"
798
1236
  var percent = config.stack === "percent"
799
1237
  var right = series.some(function (one) { return one.axis === "right" })
1238
+ // a digest's upright bars turn on their side when their names do not fit, in the writer's colours; a second axis or a running share stays upright
1239
+ var side = isDigest() && !horizontal && !right && !config.cumulative ? sideTurner(body, function () { paint.rebuild(); refresh() }) : null
800
1240
  var current = []
801
1241
  var total = 0
802
1242
  var sums = []
@@ -822,13 +1262,18 @@
822
1262
  beginAtZero: true,
823
1263
  stacked: stacked,
824
1264
  grid: { color: gridColor() },
825
- ticks: Object.assign({}, ax.ticks, {
1265
+ ticks: numberTicks(Object.assign({}, ax.ticks, {
826
1266
  callback: percent ? function (value) { return value + "%" } : abbrev(config.format)
827
- })
1267
+ }))
828
1268
  })
829
1269
  if (percent) valueAxis.max = 100
1270
+ else if (isDigest()) valueAxis.grace = "8%"
1271
+ if (isDigest()) valueAxis.ticks.maxTicksLimit = 5
830
1272
  var categoryAxis = Object.assign({}, ax, { stacked: stacked, grid: { display: false } })
831
- var scales = horizontal ? { x: valueAxis, y: categoryAxis } : { x: categoryAxis, y: valueAxis }
1273
+ var sideways = horizontal || (side && side.sideways)
1274
+ if (sideways && isDigest()) categoryAxis.ticks = sideNames(ax)
1275
+ else if (isDigest()) categoryAxis.ticks = Object.assign({}, ax.ticks, uprightNames(data.labels, side && function () { side.turn(true) }))
1276
+ var scales = sideways ? { x: valueAxis, y: categoryAxis } : { x: categoryAxis, y: valueAxis }
832
1277
  if (right) {
833
1278
  // two units on one chart: each axis says which series it reads, in that series' colour
834
1279
  var leftSeries = series.filter(function (one) { return one.axis !== "right" })
@@ -851,11 +1296,11 @@
851
1296
  ticks: Object.assign({}, ax.ticks, { callback: function (value) { return value + "%" } })
852
1297
  })
853
1298
  }
854
- return {
1299
+ var built = {
855
1300
  type: "bar",
856
1301
  data: data,
857
1302
  options: Object.assign({}, opts, {
858
- indexAxis: horizontal ? "y" : "x",
1303
+ indexAxis: sideways ? "y" : "x",
859
1304
  onClick: config.clickSet
860
1305
  ? function (event, elements) {
861
1306
  if (!elements || !elements.length) return
@@ -879,7 +1324,7 @@
879
1324
  if (!total) return context.formattedValue
880
1325
  return context.formattedValue + " · " + share(raw, total) + " of total"
881
1326
  }
882
- var amount = one.label + " · " + amountText(formatOf(one), raw)
1327
+ var amount = one.label + " · " + amountText(formatOf(one), raw, current.map(function (each) { return num(each[one.y]) }))
883
1328
  return percent ? amount + " · " + share(raw, sums[context.dataIndex]) : amount
884
1329
  }
885
1330
  }
@@ -889,7 +1334,18 @@
889
1334
  scales: scales
890
1335
  })
891
1336
  }
1337
+ if (side && side.sideways) built.options.onResize = side.onResize
1338
+ else if (side) built.plugins = [turnPlugin(function () { side.turn(true) })]
1339
+ // digest only: notes pinned by the bars they name, beside the side-turner's own
1340
+ if (config.annotations && config.annotations.length) {
1341
+ // room over upright bars, or at the right of bars on their side, where a note past zero or past a bar's end lands
1342
+ var longest = Math.max.apply(null, config.annotations.map(function (note) { return String(note.label).length }))
1343
+ built.options.layout = { padding: sideways ? { right: Math.min(160, longest * 7 + 8) } : { top: 26 } }
1344
+ built.plugins = (built.plugins || []).concat(notesPlugin(config.annotations, function () { return current.map(function (row) { return String(row[config.x]) }) }))
1345
+ }
1346
+ return built
892
1347
  })
1348
+ var fitSide = isDigest() && (horizontal || side) ? sideFit(body) : null
893
1349
 
894
1350
  function refresh() {
895
1351
  var rows = FM.rows(config.query).slice()
@@ -911,6 +1367,7 @@
911
1367
  paint(null, emptyNote(config))
912
1368
  return
913
1369
  }
1370
+ if (fitSide) fitSide(horizontal || side.sideways ? rows.map(function (row) { return String(row[config.x]) }) : null)
914
1371
  var datasets = series.map(function (one, index) {
915
1372
  return {
916
1373
  // one unnamed series keeps chart.js's tooltip to the bare figure, as before
@@ -920,7 +1377,7 @@
920
1377
  return percent ? (sums[at] ? (value / sums[at]) * 100 : 0) : value
921
1378
  }),
922
1379
  backgroundColor: tone(tones, index),
923
- maxBarThickness: 20,
1380
+ maxBarThickness: barThickness(),
924
1381
  yAxisID: !horizontal && one.axis === "right" ? "y1" : undefined,
925
1382
  order: 2
926
1383
  }
@@ -946,10 +1403,12 @@
946
1403
  order: 1
947
1404
  })
948
1405
  }
1406
+ // a digest's bars over days name each day as a date, as its lines do
1407
+ var days = isDigest() && rows.length > 0 && rows.every(function (row) { return plainDay(row[config.x]) })
949
1408
  paint(
950
1409
  {
951
1410
  labels: rows.map(function (row) {
952
- var label = String(row[config.x])
1411
+ var label = days ? dayName(row[config.x]) : String(row[config.x])
953
1412
  if (!config.share || !total) return label
954
1413
  return label + " · " + share(num(row[config.y]), total)
955
1414
  }),
@@ -1002,8 +1461,12 @@
1002
1461
  var sortKey = null
1003
1462
  var sortDir = "desc"
1004
1463
  var shareTotals = {}
1464
+ var amountDigits = {}
1465
+ var moneyDigitsOf = {}
1005
1466
 
1006
1467
  var columns = []
1468
+ // digest only: the columns the writer marked with which way is good
1469
+ var signed = (isDigest() && config.goodWhen) || {}
1007
1470
  ;(config.columns || []).forEach(function (column, index) {
1008
1471
  var format = column.format
1009
1472
  columns.push({
@@ -1013,7 +1476,16 @@
1013
1476
  align: column.align || (format && format !== "text" && format !== "datetime" && format !== "image" ? "right" : "left"),
1014
1477
  dim: column.dim,
1015
1478
  image: column.image,
1016
- format: format === "image" ? "image" : function (value) { return formatValue(format, value) }
1479
+ className: signed[column.key] ? "fmr-d-signed" : undefined,
1480
+ format: format === "image" ? "image" : function (value) {
1481
+ // a digest's amount column keeps one precision down its rows: 2.0 beside 2.9
1482
+ if (isDigest() && format === "amount" && value !== null && value !== undefined && value !== "") return intl(amountDigits[column.key] || 0).format(num(value))
1483
+ // and its money column one too: whole units once any row reaches 1,000
1484
+ var money = isDigest() && format === "money" && value !== null && value !== undefined && value !== ""
1485
+ var shown = function (figure) { return money ? intl(moneyDigitsOf[column.key] === undefined ? 2 : moneyDigitsOf[column.key]).format(num(figure)) : formatValue(format, figure) }
1486
+ if (!signed[column.key] || value === null || value === undefined || value === "" || !num(value)) return shown(value)
1487
+ return (num(value) > 0 ? "▲ " : "▼ ") + shown(Math.abs(num(value)))
1488
+ }
1017
1489
  })
1018
1490
  if (column.share) {
1019
1491
  columns.push({
@@ -1044,6 +1516,11 @@
1044
1516
 
1045
1517
  function ordered() {
1046
1518
  var rows = FM.rows(config.query).slice()
1519
+ if (isDigest())
1520
+ (config.columns || []).forEach(function (column) {
1521
+ if (column.format === "amount") amountDigits[column.key] = rows.reduce(function (most, row) { return Math.max(most, decimalsOf(row[column.key])) }, 0)
1522
+ if (column.format === "money") moneyDigitsOf[column.key] = moneyDigits(rows.map(function (row) { return row[column.key] }))
1523
+ })
1047
1524
  ;(config.columns || []).forEach(function (column) {
1048
1525
  if (!column.share) return
1049
1526
  var denominator = shareDenominator(column)
@@ -1088,9 +1565,30 @@
1088
1565
  sortKey = column.key
1089
1566
  }
1090
1567
  markSorted(th, sortDir)
1091
- control.setRows(ordered())
1568
+ var sorted = ordered()
1569
+ control.setRows(sorted)
1570
+ dressRows(sorted)
1092
1571
  })
1093
1572
  })
1573
+ // digest only, after the table's last render: each cell carries its column's label, which a phone shows beside it,
1574
+ // and a marked column reads green where it moved the good way and red where it moved the bad way
1575
+ function dressRows(rows) {
1576
+ if (!isDigest()) return
1577
+ var trs = control.element.querySelectorAll("tbody tr")
1578
+ rows.forEach(function (row, at) {
1579
+ if (!trs[at]) return
1580
+ columns.forEach(function (column, index) {
1581
+ var cell = trs[at].children[index]
1582
+ if (!cell) return
1583
+ cell.setAttribute("data-label", column.label)
1584
+ var goodWhen = signed[column.key]
1585
+ if (!goodWhen || /\.share$/.test(column.part)) return
1586
+ var value = num(row[column.key])
1587
+ cell.classList.remove("up", "down")
1588
+ if (value) cell.classList.add((value > 0) === (goodWhen === "up") ? "up" : "down")
1589
+ })
1590
+ })
1591
+ }
1094
1592
  // before any click, the arrow shows the one column the query's order follows
1095
1593
  function markFollowed(rows) {
1096
1594
  if (sortKey) return
@@ -1113,6 +1611,7 @@
1113
1611
  control.setRows(rows)
1114
1612
  // the query bounded the ranking; the pager says how long the set behind it is
1115
1613
  if (queryId === config.query && control.setTotal) control.setTotal(FM.total(queryId))
1614
+ dressRows(rows)
1116
1615
  })
1117
1616
  }
1118
1617
  subscribe(config.query)
@@ -1310,6 +1809,7 @@
1310
1809
  body.appendChild(emptyNote(config))
1311
1810
  return
1312
1811
  }
1812
+ var dayFigure = heatFigure(format, keys.map(function (key) { return byDay[key] }))
1313
1813
  var DAY = 86400000
1314
1814
  var first = Date.parse(keys[0] + "T00:00:00Z")
1315
1815
  var last = Date.parse(keys[keys.length - 1] + "T00:00:00Z")
@@ -1368,7 +1868,7 @@
1368
1868
  cell.title = when + text("heatmap.noData", "No data")
1369
1869
  } else {
1370
1870
  cell.style.background = heatShade(max ? byDay[key] / max : 0)
1371
- cell.title = when + formatValue(format, byDay[key])
1871
+ cell.title = when + dayFigure(byDay[key])
1372
1872
  }
1373
1873
  grid.appendChild(cell)
1374
1874
  }
@@ -1378,6 +1878,12 @@
1378
1878
  body.appendChild(heatLegend())
1379
1879
  }
1380
1880
 
1881
+ /** A heatmap figure, bare as its cells are small; in a digest, money at the precision of the figures read with it. */
1882
+ function heatFigure(format, among) {
1883
+ var digits = format === "money" && isDigest() ? moneyDigits(among) : null
1884
+ return function (value) { return digits === null ? formatValue(format, value) : intl(digits).format(num(value)) }
1885
+ }
1886
+
1381
1887
  function renderHeatmap(root, config, base) {
1382
1888
  base = base || ""
1383
1889
  var card = tile(root, config.title, base)
@@ -1404,6 +1910,13 @@
1404
1910
  // shows the whole day, so a quiet hour reads as quiet rather than missing
1405
1911
  if (weekdays) ys = (config.weekStart === "sunday" ? [0, 1, 2, 3, 4, 5, 6] : [1, 2, 3, 4, 5, 6, 0])
1406
1912
  if (hours) xs = Array.apply(null, { length: 24 }).map(function (_, h) { return h })
1913
+ // digest only: the hours between the first and last that traded, so a short day is not mostly empty
1914
+ if (hours && config.trimHours && rows.length) {
1915
+ var traded = rows.map(function (row) { return num(row[config.x]) })
1916
+ var first = Math.min.apply(null, traded)
1917
+ var last = Math.max.apply(null, traded)
1918
+ xs = xs.filter(function (h) { return h >= first && h <= last })
1919
+ }
1407
1920
  var names = weekdays ? weekdayNames() : null
1408
1921
  var yLabel = function (y) { return names ? names[y] : String(y) }
1409
1922
  var xLabel = function (x) { return hours ? String(x) + ":00" : String(x) }
@@ -1425,8 +1938,14 @@
1425
1938
  grid.style.gap = "2px"
1426
1939
  grid.style.alignItems = "center"
1427
1940
  grid.appendChild(el("div"))
1428
- xs.forEach(function (x) { grid.appendChild(heatLabel(hours ? String(x) : xLabel(x), "center")) })
1941
+ xs.forEach(function (x) {
1942
+ var label = heatLabel(hours ? String(x) : xLabel(x), "center")
1943
+ if (hours && x % 2 === 1) label.className += " fmr-heatmap-odd"
1944
+ grid.appendChild(label)
1945
+ })
1429
1946
  if (config.rowTotals) grid.appendChild(heatLabel(text("heatmap.total", "Total"), "right"))
1947
+ var cellFigure = heatFigure(format, Object.keys(cells).map(function (key) { return cells[key] }))
1948
+ var totalFigure = heatFigure(format, Object.keys(totals).map(function (key) { return totals[key] }))
1430
1949
  ys.forEach(function (y) {
1431
1950
  grid.appendChild(heatLabel(yLabel(y), "left"))
1432
1951
  xs.forEach(function (x) {
@@ -1441,11 +1960,11 @@
1441
1960
  cell.title = where + text("heatmap.noData", "No data")
1442
1961
  } else {
1443
1962
  cell.style.background = heatShade(max ? value / max : 0)
1444
- cell.title = where + formatValue(format, value)
1963
+ cell.title = where + cellFigure(value)
1445
1964
  }
1446
1965
  grid.appendChild(cell)
1447
1966
  })
1448
- if (config.rowTotals) grid.appendChild(heatLabel(formatValue(format, totals[y] || 0), "right"))
1967
+ if (config.rowTotals) grid.appendChild(heatLabel(totalFigure(totals[y] || 0), "right"))
1449
1968
  })
1450
1969
  body.appendChild(grid)
1451
1970
  body.appendChild(heatLegend())
@@ -1499,7 +2018,9 @@
1499
2018
  // --- funnel, waterfall, donut: figures read from one row, or (a donut) one row per slice
1500
2019
 
1501
2020
  /** An amount as the merchant reads it in a label: the currency code, then the figure. */
1502
- function amountText(format, value) {
2021
+ /** A figure with its unit; in a digest, money at the precision of the figures read with it (`among`). */
2022
+ function amountText(format, value, among) {
2023
+ if (format === "money" && isDigest()) return digestValue(format, value, moneyDigits(among || [value]))
1503
2024
  if (format === "money") {
1504
2025
  var currency = envOf("currency", "")
1505
2026
  return (currency ? currency + " " : "") + formatValue("money", value)
@@ -1544,7 +2065,7 @@
1544
2065
  callbacks: {
1545
2066
  label: function (context) {
1546
2067
  var index = context.dataIndex
1547
- var line = amountText(config.format, values[index])
2068
+ var line = amountText(config.format, values[index], values)
1548
2069
  if (index === 0) return line
1549
2070
  var of = function (base) {
1550
2071
  return text("funnel.ofStep", "{share} of {step}")
@@ -1575,7 +2096,7 @@
1575
2096
  paint(
1576
2097
  {
1577
2098
  labels: steps.map(function (step, index) {
1578
- var figure = amountText(config.format, values[index])
2099
+ var figure = amountText(config.format, values[index], values)
1579
2100
  return [step.label, index === 0 ? figure : figure + " · " + share(values[index], values[0])]
1580
2101
  }),
1581
2102
  datasets: [
@@ -1596,6 +2117,16 @@
1596
2117
  FM.onRows(config.query, refresh)
1597
2118
  }
1598
2119
 
2120
+ /** Where a digest waterfall's totals start: zero, unless every step together is under a quarter of the tallest bar; then as far under the lowest point as the steps span, so they fill half the chart. */
2121
+ function waterfallFloor(bars) {
2122
+ var ends = []
2123
+ bars.forEach(function (bar) { ends.push(bar.to); if (!bar.total) ends.push(bar.from) })
2124
+ var low = Math.min.apply(null, ends)
2125
+ var high = Math.max.apply(null, ends)
2126
+ if (!(low > 0) || low === high || (high - low) * 4 >= high) return 0
2127
+ return low - (high - low)
2128
+ }
2129
+
1599
2130
  function renderWaterfall(root, config, base) {
1600
2131
  base = base || ""
1601
2132
  var card = tile(root, config.title, base)
@@ -1604,10 +2135,26 @@
1604
2135
  card.appendChild(body)
1605
2136
  var steps = config.steps || []
1606
2137
  var shown = []
2138
+ // a digest writes each bar's figure at its end, so a step too small to see beside the totals still reads
2139
+ var side = isDigest() ? sideTurner(body, function () { paint.rebuild(); refresh() }) : null
2140
+ function figures() {
2141
+ var digits = config.format === "money" ? moneyDigits(shown.map(function (bar) { return bar.change })) : null
2142
+ return shown.map(function (bar) {
2143
+ var bare = digits === null ? formatValue(config.format || "amount", Math.abs(bar.change)) : intl(digits).format(Math.abs(bar.change))
2144
+ return (bar.change < 0 ? "−" : bar.total ? "" : "+") + bare
2145
+ })
2146
+ }
1607
2147
  var paint = mountChart(body, function (data) {
1608
2148
  var opts = chartOpts()
1609
2149
  var ax = axis()
1610
- return {
2150
+ var category = Object.assign({}, ax, { grid: { display: false } })
2151
+ var value = Object.assign({}, ax, {
2152
+ // a digest's totals rise from the floor its data sets, zero unless the steps would not show
2153
+ beginAtZero: !side,
2154
+ grid: { color: gridColor() },
2155
+ ticks: numberTicks(Object.assign({}, ax.ticks, { callback: abbrev(config.format) }))
2156
+ })
2157
+ var built = {
1611
2158
  type: "bar",
1612
2159
  data: data,
1613
2160
  options: Object.assign({}, opts, {
@@ -1617,22 +2164,31 @@
1617
2164
  // the bar's direction carries the sign, per the chart guide
1618
2165
  label: function (context) {
1619
2166
  var bar = shown[context.dataIndex]
1620
- return bar ? amountText(config.format, Math.abs(bar.change)) : ""
2167
+ return bar ? amountText(config.format, Math.abs(bar.change), shown.map(function (each) { return each.change })) : ""
1621
2168
  }
1622
2169
  }
1623
2170
  })
1624
2171
  }),
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
- }
2172
+ scales: { x: category, y: value }
1633
2173
  })
1634
2174
  }
2175
+ if (!side) return built
2176
+ value.ticks.maxTicksLimit = 5
2177
+ built.plugins = [figuresPlugin(figures)]
2178
+ if (side.sideways) {
2179
+ category.ticks = sideNames(ax)
2180
+ built.options.indexAxis = "y"
2181
+ built.options.scales = { x: value, y: category }
2182
+ built.options.layout = { padding: { right: figures().reduce(function (most, label) { return Math.max(most, textWidth(measurer(), figureFont(), label)) }, 0) + 8 } }
2183
+ built.options.onResize = side.onResize
2184
+ } else {
2185
+ category.ticks = Object.assign({}, ax.ticks, uprightNames(data.labels, function () { side.turn(true) }))
2186
+ built.options.layout = { padding: { top: 18 } }
2187
+ built.plugins.push(turnPlugin(function () { side.turn(true) }, figures))
2188
+ }
2189
+ return built
1635
2190
  })
2191
+ var fitSide = side ? sideFit(body) : null
1636
2192
 
1637
2193
  function refresh() {
1638
2194
  var row = figureRow(config)
@@ -1650,17 +2206,20 @@
1650
2206
  paint(null, emptyNote(config))
1651
2207
  return
1652
2208
  }
2209
+ var floor = side ? waterfallFloor(shown) : 0
1653
2210
  var total = token("--fm-feature-grape-fg", "#512191")
1654
2211
  var stepColor = token("--fm-feature-grape-accent", "#8f61e8")
2212
+ if (fitSide) fitSide(side.sideways ? shown.map(function (bar) { return bar.label }) : null)
1655
2213
  paint(
1656
2214
  {
1657
2215
  labels: shown.map(function (bar) { return bar.label }),
1658
2216
  datasets: [
1659
2217
  {
1660
- data: shown.map(function (bar) { return [bar.from, bar.to] }),
2218
+ data: shown.map(function (bar) { return [bar.total ? floor : bar.from, bar.to] }),
1661
2219
  backgroundColor: shown.map(function (bar) { return bar.total ? total : stepColor }),
1662
2220
  borderWidth: 0,
1663
2221
  maxBarThickness: 36,
2222
+ minBarLength: side ? 2 : undefined,
1664
2223
  borderSkipped: false
1665
2224
  }
1666
2225
  ]
@@ -1711,7 +2270,7 @@
1711
2270
  callbacks: {
1712
2271
  label: function (context) {
1713
2272
  var value = Number(context.raw) || 0
1714
- return amountText(config.format, value) + " · " + share(value, whole)
2273
+ return amountText(config.format, value, context.dataset.data) + " · " + share(value, whole)
1715
2274
  }
1716
2275
  }
1717
2276
  })
@@ -1754,9 +2313,10 @@
1754
2313
  return
1755
2314
  }
1756
2315
  legend.hidden = false
2316
+ var palette = familyPalette(config) || SLICE_TONES
1757
2317
  var colors = list.map(function (_slice, index) {
1758
- var tone = SLICE_TONES[index % SLICE_TONES.length]
1759
- return token(tone[0], tone[1])
2318
+ var step = palette[index % palette.length]
2319
+ return token(step[0], step[1])
1760
2320
  })
1761
2321
  list.forEach(function (slice, index) {
1762
2322
  var item = el("span", "fmr-legend-item")
@@ -1764,7 +2324,7 @@
1764
2324
  swatch.style.background = colors[index]
1765
2325
  item.appendChild(swatch)
1766
2326
  item.appendChild(
1767
- document.createTextNode(slice.label + " · " + amountText(config.format, slice.value) + " · " + share(slice.value, whole))
2327
+ document.createTextNode(slice.label + " · " + amountText(config.format, slice.value, list.map(function (each) { return each.value })) + " · " + share(slice.value, whole))
1768
2328
  )
1769
2329
  legend.appendChild(item)
1770
2330
  })
@@ -1827,18 +2387,54 @@
1827
2387
  return point.lon >= box[0] && point.lon <= box[2] && point.lat >= box[1] && point.lat <= box[3]
1828
2388
  }
1829
2389
 
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
- */
2390
+ var boxCache = Object.create(null)
2391
+ function layerBoxes(layer) {
2392
+ if (!boxCache[layer.id]) boxCache[layer.id] = layerFeatures(layer).map(featureBox)
2393
+ return boxCache[layer.id]
2394
+ }
2395
+
2396
+ /** Whether a point falls inside an outline: an odd number of ring edges to its east. */
2397
+ function inside(feature, point) {
2398
+ var geometry = feature.geometry || {}
2399
+ var polygons = geometry.type === "Polygon" ? [geometry.coordinates] : geometry.type === "MultiPolygon" ? geometry.coordinates : []
2400
+ return polygons.some(function (rings) {
2401
+ var odd = false
2402
+ rings.forEach(function (ring) {
2403
+ for (var i = 0, j = ring.length - 1; i < ring.length; j = i++) {
2404
+ var a = ring[i]
2405
+ var b = ring[j]
2406
+ if (a[1] > point.lat !== b[1] > point.lat && point.lon < ((b[0] - a[0]) * (point.lat - a[1])) / (b[1] - a[1]) + a[0]) odd = !odd
2407
+ }
2408
+ })
2409
+ return odd
2410
+ })
2411
+ }
2412
+
2413
+ /** How many of the points fall inside the layer's outlines. */
2414
+ function landed(layer, points) {
2415
+ var features = layerFeatures(layer)
2416
+ var boxes = layerBoxes(layer)
2417
+ return points.filter(function (point) {
2418
+ return features.some(function (feature, index) { return within(boxes[index], point) && inside(feature, point) })
2419
+ }).length
2420
+ }
2421
+
2422
+ /** The country whose outlines hold most of the points, on district lines when they sit in one metro area inside the district layer, else on state lines. */
1834
2423
  function pointLayer(points) {
1835
2424
  var layers = FM.geoOutlines.layers
1836
2425
  var held = {}
1837
2426
  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)
2427
+ var near = points.filter(function (point) { return within(layer.bbox, point) })
2428
+ if (!near.length) return
2429
+ var one = held[layer.country] || (held[layer.country] = { landed: 0, near: 0, area: Infinity })
2430
+ one.landed = Math.max(one.landed, landed(layer, near))
2431
+ one.near = Math.max(one.near, near.length)
2432
+ one.area = Math.min(one.area, (layer.bbox[2] - layer.bbox[0]) * (layer.bbox[3] - layer.bbox[1]))
1840
2433
  })
1841
- var country = Object.keys(held).sort(function (a, b) { return held[b] - held[a] })[0]
2434
+ // a point just off a simplified coast lands in no outline: then the boxes decide, the tighter on a tie
2435
+ var country = Object.keys(held).sort(function (a, b) {
2436
+ return held[b].landed - held[a].landed || held[b].near - held[a].near || held[a].area - held[b].area
2437
+ })[0]
1842
2438
  if (!country) return null
1843
2439
  var own = layers.filter(function (layer) { return layer.country === country })
1844
2440
  var lats = points.map(function (point) { return point.lat })
@@ -1932,7 +2528,7 @@
1932
2528
  },
1933
2529
  label: function (context) {
1934
2530
  var row = drawn[context.dataIndex]
1935
- return row && config.value ? amountText(config.format, num(row[config.value])) : ""
2531
+ return row && config.value ? amountText(config.format, num(row[config.value]), drawn.map(function (each) { return num(each[config.value]) })) : ""
1936
2532
  }
1937
2533
  }
1938
2534
  })
@@ -1987,7 +2583,7 @@
1987
2583
  }
1988
2584
  item.appendChild(mark)
1989
2585
  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)))
2586
+ item.appendChild(el("span", "fmr-place-value", amountText(config.format, entry.value, order.map(function (each) { return each.value }))))
1991
2587
  item.addEventListener("mouseenter", function () { pointAt(entry.index) })
1992
2588
  item.addEventListener("mouseleave", function () { pointAt(null) })
1993
2589
  if (config.clickSet) {
@@ -2108,7 +2704,7 @@
2108
2704
  return Object.assign({}, ax, {
2109
2705
  grid: { color: gridColor() },
2110
2706
  title: name ? { display: true, text: name, color: token("--fm-text-muted", "#6b6c70") } : { display: false },
2111
- ticks: Object.assign({}, ax.ticks, { callback: abbrev(format) })
2707
+ ticks: numberTicks(Object.assign({}, ax.ticks, { callback: abbrev(format) }))
2112
2708
  })
2113
2709
  }
2114
2710
  return {
@@ -2132,11 +2728,12 @@
2132
2728
  label: function (context) {
2133
2729
  var row = current[context.dataIndex]
2134
2730
  if (!row) return ""
2731
+ var column = function (key) { return current.map(function (each) { return num(each[key]) }) }
2135
2732
  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]))
2733
+ (config.xLabel ? config.xLabel + " · " : "") + amountText(config.xFormat, num(row[config.x]), column(config.x)),
2734
+ (config.yLabel ? config.yLabel + " · " : "") + amountText(config.yFormat, num(row[config.y]), column(config.y))
2138
2735
  ]
2139
- if (config.size) lines.push(amountText(config.sizeFormat, num(row[config.size])))
2736
+ if (config.size) lines.push(amountText(config.sizeFormat, num(row[config.size]), column(config.size)))
2140
2737
  return lines
2141
2738
  }
2142
2739
  }
@@ -2207,7 +2804,8 @@
2207
2804
  tooltip: Object.assign({}, opts.plugins.tooltip, {
2208
2805
  callbacks: {
2209
2806
  label: function (context) {
2210
- return context.dataset.label + " · " + amountText(config.format, Number(context.raw) || 0)
2807
+ var among = context.chart.data.datasets.reduce(function (all, one) { return all.concat(one.data) }, [])
2808
+ return context.dataset.label + " · " + amountText(config.format, Number(context.raw) || 0, among)
2211
2809
  }
2212
2810
  }
2213
2811
  })
@@ -2318,7 +2916,7 @@
2318
2916
  label: function (context) {
2319
2917
  var row = current[context.dataIndex]
2320
2918
  if (!row) return ""
2321
- return String(row[config.from]) + " → " + String(row[config.to]) + " · " + amountText(config.format, num(row[config.value]))
2919
+ return String(row[config.from]) + " → " + String(row[config.to]) + " · " + amountText(config.format, num(row[config.value]), current.map(function (each) { return num(each[config.value]) }))
2322
2920
  }
2323
2921
  }
2324
2922
  })
@@ -2391,6 +2989,454 @@
2391
2989
  FM.onRows(config.query, refresh)
2392
2990
  }
2393
2991
 
2992
+ // --- digest mode: how a saved weekly digest's frame draws canvas components; a builder canvas never turns it on
2993
+
2994
+ var FAMILIES = ["grape", "blossom", "aqua", "mustard", "lime", "berry", "stone"]
2995
+
2996
+ // every rule sits under the digest's root class or a digest component's own class, so a builder canvas looks as before
2997
+ var DIGEST_STYLE =
2998
+ "html.fmr-digest{--fmr-display:'FM Digest Display','Avenir Next','Segoe UI',system-ui,sans-serif}" +
2999
+ "html.fmr-digest body{background:var(--fm-bg-canvas,#f3f3f6)}" +
3000
+ // a wider screen holds the page to 1,680 px, centred; words keep their reading measure by running in columns, not by a narrow page
3001
+ "html.fmr-digest #report{gap:14px;padding:16px 18px 32px;align-items:stretch;max-width:1680px;margin-inline:auto}" +
3002
+ "html.fmr-digest #report>section,html.fmr-digest #report>.fm-group>section{container:fmr-part/inline-size}" +
3003
+ "html.fmr-digest #report>section>.fmr-card,html.fmr-digest #report>section>.fmr-d-card,html.fmr-digest #report>.fm-group>section>.fmr-card{height:100%;box-sizing:border-box}" +
3004
+ "html.fmr-digest #report>section.fmr-d-apart,html.fmr-digest #report>.fm-group>section.fmr-d-apart{grid-column:1/-1}" +
3005
+ // a story is one card: its head, its words in one column and its charts drawn flat on it, every part's content on the charts' own 20 px inset
3006
+ "html.fmr-digest #report>.fm-group{background:var(--fm-bg-default,#fff);border:1px solid var(--fm-border-default,#ebebef);border-radius:14px;padding:24px 0 8px;gap:14px 8px;align-items:stretch}" +
3007
+ "html.fmr-digest #report>.fm-group:not(:has(>section:last-child>.fmr-card)){padding-bottom:24px}" +
3008
+ "html.fmr-digest .fm-group .fmr-card{background:none;border-color:transparent}" +
3009
+ "html.fmr-digest .fm-group .fmr-d-chapter{padding:0 20px}" +
3010
+ "html.fmr-digest .fm-group .fmr-d-note{border:0;padding:0 20px;margin:0}html.fmr-digest .fm-group .fmr-d-note-body{columns:auto;max-width:68ch}html.fmr-digest .fm-group .fmr-d-note p:not(:last-child){margin-bottom:12px}" +
3011
+ "html.fmr-digest .fmr-card{border-radius:14px}" +
3012
+ FAMILIES.map(function (name) {
3013
+ return ".fmr-fam-" + name + "{--fmr-c:var(--fm-feature-" + name + "-fg);--fmr-ca:var(--fm-feature-" + name + "-accent);--fmr-cb:var(--fm-feature-" + name + "-bg)}"
3014
+ }).join("") +
3015
+ ".fmr-digest .fmr-tile-title{font-weight:650}" +
3016
+ ".fmr-digest-takeaway{margin:2px 16px 0;color:var(--fm-text-secondary,#3a3a40);font-size:13.5px;line-height:1.5}" +
3017
+ ".fmr-d-card{background:var(--fm-bg-default,#fff);border:1px solid var(--fm-border-default,#ebebef);border-radius:14px;padding:18px 20px;min-width:0}" +
3018
+ ".fmr-d-eyebrow{font-size:12px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;color:var(--fm-text-muted,#6b6c70)}" +
3019
+ ".fmr-d-title{font-weight:650;font-size:15px;line-height:1.35;margin:2px 0 0}" +
3020
+ // a chip keeps its own height, whatever height the tiles' shared row gives the chips
3021
+ ".fmr-d-chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;align-content:flex-start}" +
3022
+ ".fmr-d-chip{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600;line-height:1.45;white-space:nowrap;font-variant-numeric:tabular-nums}" +
3023
+ ".fmr-d-chip small{font-weight:500;opacity:.8;font-size:12px}" +
3024
+ ".fmr-d-chip.up{color:var(--fm-text-on-success,#1f7735);background:var(--fm-bg-success,#ebf9ee)}" +
3025
+ ".fmr-d-chip.down{color:var(--fm-text-error,#99231d);background:var(--fm-bg-error,#ffebea)}" +
3026
+ ".fmr-d-chip.flat{color:var(--fm-text-muted,#6b6c70);background:var(--fm-bg-secondary,#f5f5f7)}" +
3027
+ ".fmr-d-brief{padding:26px 28px 24px;border-radius:18px;container-type:inline-size}" +
3028
+ ".fmr-d-docket{display:flex;flex-wrap:wrap;gap:4px 14px;padding-bottom:12px;margin-bottom:16px;border-bottom:1.5px dashed var(--fm-border-strong,#dcdce2)}" +
3029
+ ".fmr-d-brief h1{font:800 40px/1.06 var(--fmr-display);letter-spacing:-.022em;margin:0;text-wrap:balance}" +
3030
+ ".fmr-d-lead{font-size:16px;line-height:1.55;color:var(--fm-text-secondary,#3a3a40);margin:14px 0 0;max-width:58ch}.fmr-d-brief-body>.fmr-d-lead:first-child{margin-top:0}" +
3031
+ // a brief with the row to itself sets its headline beside the summary, or a lone summary in two columns
3032
+ "@container (min-width:760px){.fmr-d-brief-body.has-head{display:grid;grid-template-columns:minmax(0,1.12fr) minmax(0,1fr);gap:28px;align-items:start}.fmr-d-brief-body.has-head .fmr-d-lead{margin-top:4px}.fmr-d-brief-body:not(.has-head) .fmr-d-lead{max-width:none;column-count:2;column-gap:28px}}" +
3033
+ ".fmr-d-dot{width:9px;height:9px;border-radius:50%;display:inline-block;flex:none;background:var(--fmr-ca,#8f61e8)}" +
3034
+ ".fmr-d-legend{display:flex;flex-wrap:wrap;gap:4px 16px;margin-top:6px;font-size:12px;color:var(--fm-text-muted,#6b6c70)}.fmr-d-legend span{display:inline-flex;align-items:center;gap:6px}" +
3035
+ ".fmr-d-legend .sw{width:12px;height:12px;border-radius:3px;display:inline-block}.fmr-d-legend .sw.hi{background:var(--fmr-c,#512191);border-radius:50%}.fmr-d-legend .sw.band{background:var(--fm-bg-secondary,#f5f5f7);border:1px solid var(--fm-border-default,#ebebef)}" +
3036
+ ".fmr-d-legend .sw.bar{background:var(--fmr-ca,#8f61e8);border-radius:2px}.fmr-d-legend .sw.whisker{width:10px;border-radius:0;border-top:1.5px solid var(--fmr-c,#512191);border-bottom:1.5px solid var(--fmr-c,#512191);background:linear-gradient(var(--fmr-c,#512191),var(--fmr-c,#512191)) center/1.5px 100% no-repeat}.fmr-d-legend .sw.line{height:0;width:16px;border-radius:0;border-top:2.5px solid var(--fmr-ca,#8f61e8)}.fmr-d-legend .sw.dash{border-top-style:dashed}.fmr-d-legend .sw.ring{width:9px;height:9px;border-radius:50%;border:1.8px solid;background:var(--fm-bg-default,#fff)}.fmr-d-key{margin:4px 16px 0}" +
3037
+ ".fmr-d-svg{display:block;width:100%;overflow:visible}" +
3038
+ ".fmr-d-svg .spark-area{fill:var(--fmr-cb,#eddfff)}.fmr-d-svg .spark-line{fill:none;stroke:var(--fmr-ca,#8f61e8);stroke-width:1.8;stroke-linejoin:round}.fmr-d-svg .spark-dot{fill:var(--fmr-c,#512191)}" +
3039
+ ".fmr-d-kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px}" +
3040
+ ".fmr-d-kpi{display:grid;row-gap:6px;align-content:start;grid-row:span 5;grid-template-rows:subgrid}" +
3041
+ ".fmr-d-kpi .fmr-d-value{font:750 28px/1.05 var(--fmr-display);letter-spacing:-.015em;font-variant-numeric:tabular-nums;margin-top:2px}" +
3042
+ ".fmr-d-kpi .fmr-d-value small{font:700 15px/1 var(--fmr-display);color:var(--fm-text-muted,#6b6c70);margin-right:3px}" +
3043
+ ".fmr-d-base{font-size:12px;color:var(--fm-text-muted,#6b6c70)}" +
3044
+ ".fmr-d-chapter{display:flex;flex-wrap:wrap;gap:12px 18px;align-items:flex-end;padding:26px 4px 4px}" +
3045
+ ".fmr-d-chapter .no{flex:none;font:800 54px/.8 var(--fmr-display);color:var(--fmr-c)}" +
3046
+ // a head too narrow for its title and its key figures side by side puts the figures under the title
3047
+ ".fmr-d-chapter .heading{flex:1 1 18em;min-width:0}" +
3048
+ ".fmr-d-chapter .label{font-size:12px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--fmr-c);display:inline-flex;gap:8px;align-items:center}.fmr-d-chapter .label .n{display:none}" +
3049
+ ".fmr-d-chapter h2{font:750 26px/1.15 var(--fmr-display);letter-spacing:-.015em;margin:8px 0 0;max-width:36ch;text-wrap:balance}" +
3050
+ ".fmr-d-chapter .stats{display:flex;gap:22px;margin-left:auto}.fmr-d-chapter .stat{text-align:right}" +
3051
+ ".fmr-d-chapter .stat b{display:block;font:750 20px/1.1 var(--fmr-display);font-variant-numeric:tabular-nums}.fmr-d-chapter .stat span{font-size:12px;color:var(--fm-text-muted,#6b6c70)}" +
3052
+ // a head beside its words starts level with them, its key figures under the title from the title's own edge
3053
+ "@container fmr-part (max-width:760px){.fmr-d-chapter{display:grid;grid-template-columns:auto minmax(0,1fr);gap:12px 18px;padding-top:2px}.fmr-d-chapter .stats{grid-column:2;margin-left:0;flex-wrap:wrap;gap:10px 18px}.fmr-d-chapter .stat{text-align:left}}" +
3054
+ // a narrow story head lists its key figures, each value beside what it is
3055
+ "@container fmr-part (max-width:760px){html.fmr-digest .fm-group .fmr-d-chapter .stats{display:grid;grid-template-columns:auto minmax(0,1fr);gap:6px 12px;align-items:baseline}html.fmr-digest .fm-group .fmr-d-chapter .stat{display:contents}html.fmr-digest .fm-group .fmr-d-chapter .stat b{text-align:right}html.fmr-digest .fm-group .fmr-d-chapter .stat span{font-size:13px}}" +
3056
+ // a story's words and a card's fill their width, in two columns of 22em at least once there is room, each paragraph whole when there are several; no line runs past half of 1,160 px
3057
+ ".fmr-d-note{border-left:3px solid var(--fm-border-strong,#dcdce2);padding:2px 0 2px 16px;margin:0 4px}.fmr-d-note-body,.fmr-d-card-body{columns:22em 2;column-gap:40px;max-width:1160px}.fmr-d-note-body:has(p + p) p,.fmr-d-card-body:has(p + p) p{break-inside:avoid}" +
3058
+ ".fmr-d-note p,.fmr-d-text p{font-size:15px;line-height:1.65;color:var(--fm-text-primary,#1c1c1e);margin:0 0 10px;white-space:pre-line}.fmr-d-note p:last-child,.fmr-d-text p:last-child{margin:0}" +
3059
+ ".fmr-d-note b,.fmr-d-text b{font-weight:650;white-space:nowrap}.fmr-d-text .fmr-d-title{margin:0 0 8px}" +
3060
+ // an idea for a story: a warm card, the last part of the story's own card
3061
+ ".fmr-d-idea{background:var(--fm-bg-warning,#fff4e6);border:1px solid #f6d9c4;border-radius:12px;padding:18px 22px;display:flex;flex-direction:column;gap:8px;min-width:0}html.fmr-digest .fm-group .fmr-d-idea{margin:4px 20px 0}" +
3062
+ ".fmr-d-idea-top{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.fmr-d-idea-top>svg{flex:none;color:#9a3412}.fmr-d-idea-eyebrow{font-size:12px;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:#9a3412}" +
3063
+ ".fmr-d-idea-tag{margin-left:auto;display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;background:var(--fm-bg-default,#fff);border:1px solid var(--fm-border-strong,#dcdce2);font-size:12px;font-weight:600;color:var(--fm-text-secondary,#3a3a40)}" +
3064
+ ".fmr-d-idea h3{margin:2px 0 0;font:650 19px/1.25 var(--fmr-display);color:var(--fm-text-primary,#1c1c1e)}.fmr-d-idea p{margin:0;max-width:78ch;font-size:15px;line-height:1.6;color:var(--fm-text-secondary,#3a3a40)}.fmr-d-idea p.fmr-d-idea-source{font-size:13px;color:var(--fm-text-muted,#6b6c70);overflow-wrap:anywhere}" +
3065
+ ".fmr-d-idea-source a{display:inline-flex;align-items:center;gap:5px;font-weight:600;color:#9a3412;text-decoration:underline;text-underline-offset:3px}.fmr-d-idea-source a:hover{color:#7c2d12}" +
3066
+ // a list card: one item per line, each behind a small square in the accent
3067
+ ".fmr-d-list{list-style:none;margin:0;padding:0;display:grid;gap:8px}.fmr-d-list li{position:relative;max-width:72ch;padding-left:22px;font-size:15px;line-height:1.55;color:var(--fm-text-primary,#1c1c1e)}" +
3068
+ ".fmr-d-text .fmr-d-list{display:block;columns:22em 2;column-gap:40px;max-width:1160px}.fmr-d-text .fmr-d-list li{break-inside:avoid;margin:0 0 8px}.fmr-d-text .fmr-d-list li:last-child{margin:0}" +
3069
+ ".fmr-d-list li::before{content:'';position:absolute;left:2px;top:.5em;width:9px;height:9px;box-sizing:border-box;border-radius:2px;border:1.8px solid var(--fm-feature-grape-accent,#8f61e8)}" +
3070
+ "html.fmr-digest table.fmr-table th{font-size:12px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--fm-text-muted,#6b6c70)}" +
3071
+ "html.fmr-digest table.fmr-table td{font-variant-numeric:tabular-nums}" +
3072
+ "html.fmr-digest td.fmr-d-signed.up{color:var(--fm-text-on-success,#1f7735);font-weight:650}html.fmr-digest td.fmr-d-signed.down{color:var(--fm-text-error,#99231d);font-weight:650}" +
3073
+ "@media (max-width:860px){" +
3074
+ "html.fmr-digest #report{padding:12px 12px 28px;gap:12px}" +
3075
+ ".fmr-d-brief{padding:20px 18px}.fmr-d-brief h1{font-size:29px}.fmr-d-lead{font-size:15px}" +
3076
+ ".fmr-d-kpis{grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px}.fmr-d-kpi .fmr-d-value{font-size:22px}.fmr-d-card{padding:16px}" +
3077
+ ".fmr-d-idea{padding:16px}html.fmr-digest .fm-group .fmr-d-idea{margin:4px 0 0}.fmr-d-idea-tag{margin-left:0}" +
3078
+ ".fmr-d-chapter{display:flex;gap:12px;padding-top:18px}.fmr-d-chapter .no{display:none}.fmr-d-chapter .label .n{display:inline;font:800 16px/1 var(--fmr-display)}.fmr-d-chapter h2{font-size:21px}" +
3079
+ ".fmr-d-chapter .stats{flex-basis:100%;margin-left:0;gap:10px 18px;flex-wrap:wrap}.fmr-d-chapter .stat{text-align:left;min-width:0}}" +
3080
+ "@media (max-width:560px){" +
3081
+ ".fmr-d-kpis{grid-template-columns:minmax(0,1fr)}.fmr-d-brief h1{font-size:24px;line-height:1.1}" +
3082
+ // a story's key figures sit two to a row
3083
+ ".fmr-d-chapter .stats{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}" +
3084
+ "html.fmr-digest .fmr-heatmap-odd{visibility:hidden}" +
3085
+ // a phone reads each row as a block: what it compares, the marked column at its right, then every other column by its label
3086
+ "html.fmr-digest .fmr-scroll-x{overflow-x:visible}html.fmr-digest table.fmr-table thead{display:none}" +
3087
+ "html.fmr-digest table.fmr-table,html.fmr-digest table.fmr-table tbody{display:block;width:100%}" +
3088
+ "html.fmr-digest table.fmr-table tr{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 14px;padding:10px 16px;border-top:1px solid var(--fm-border-default,#ebebef)}" +
3089
+ "html.fmr-digest table.fmr-table tr::after{content:'';order:2;flex-basis:100%;height:0}" +
3090
+ "html.fmr-digest table.fmr-table td{display:inline-flex;align-items:baseline;gap:6px;order:3;height:auto;padding:0;border:0;font-size:13px;white-space:normal}" +
3091
+ "html.fmr-digest table.fmr-table td::before{content:attr(data-label);font-size:11.5px;font-weight:400;color:var(--fm-text-muted,#6b6c70)}" +
3092
+ "html.fmr-digest table.fmr-table td:first-child{order:0;flex:1 1 50%;min-width:0;padding:0;font-size:14px}html.fmr-digest table.fmr-table td:first-child::before{content:none}" +
3093
+ "html.fmr-digest table.fmr-table td.fmr-d-signed{order:1;margin-left:auto}}"
3094
+
3095
+ /** A label in at most two lines of at most `chars` characters, broken between words. */
3096
+ function wrapLabel(label, chars) {
3097
+ var lines = []
3098
+ String(label).split(/\s+/).forEach(function (word) {
3099
+ var line = lines[lines.length - 1]
3100
+ if (line !== undefined && (line + " " + word).length <= chars) lines[lines.length - 1] = line + " " + word
3101
+ else lines.push(word)
3102
+ })
3103
+ if (lines.length > 2) lines = [lines[0], lines.slice(1).join(" ")]
3104
+ return lines.map(function (line) { return line.length > chars ? line.slice(0, chars - 1) + "…" : line })
3105
+ }
3106
+
3107
+ function isDigest() {
3108
+ return !!(window.env && window.env.digest)
3109
+ }
3110
+
3111
+ /** The digest's root class, its display face (delivered by the host, never fetched) and its styles, once per frame. */
3112
+ function digestStyles() {
3113
+ if (document.getElementById("fmr-digest-style")) return
3114
+ document.documentElement.classList.add("fmr-digest")
3115
+ var font = envOf("digestFont", "")
3116
+ if (font) {
3117
+ var face = document.createElement("style")
3118
+ face.id = "fmr-digest-font"
3119
+ face.textContent = font
3120
+ document.head.appendChild(face)
3121
+ }
3122
+ var style = document.createElement("style")
3123
+ style.id = "fmr-digest-style"
3124
+ style.textContent = DIGEST_STYLE
3125
+ document.head.appendChild(style)
3126
+ }
3127
+
3128
+ function swatch(kind, label) {
3129
+ var node = el("span")
3130
+ node.appendChild(el("i", "sw " + kind))
3131
+ node.appendChild(document.createTextNode(label))
3132
+ return node
3133
+ }
3134
+
3135
+ /** A canvas component in a digest gets its one-line takeaway under its title. */
3136
+ function addTakeaway(el, config) {
3137
+ if (!config.takeaway) return
3138
+ var head = el.querySelector(".fmr-tile-head")
3139
+ var line = part(document.createElement("p"), "takeaway")
3140
+ line.className = "fmr-digest-takeaway"
3141
+ line.textContent = config.takeaway
3142
+ if (head && head.parentNode) head.parentNode.insertBefore(line, head.nextSibling)
3143
+ else el.insertBefore(line, el.firstChild)
3144
+ }
3145
+
3146
+ /** A digest's money precision for figures read together: whole units once any of them reaches 1,000 or none has cents, else to the cent. */
3147
+ function moneyDigits(values) {
3148
+ var shown = values.filter(function (value) { return value !== null && value !== undefined && value !== "" }).map(num)
3149
+ if (shown.some(function (value) { return Math.abs(value) >= 1000 })) return 0
3150
+ return shown.every(function (value) { return Math.round(value * 100) % 100 === 0 }) ? 0 : 2
3151
+ }
3152
+
3153
+ /** A digest figure: money with its currency label, at the precision of the figures read with it, its sign leading. */
3154
+ function digestValue(format, value, digits) {
3155
+ if (value === null || value === undefined || value === "") return "—"
3156
+ if (format !== "money") return formatValue(format || "amount", value)
3157
+ var currency = envOf("currency", "")
3158
+ var text = intl(digits === undefined ? moneyDigits([value]) : digits).format(Math.abs(num(value)))
3159
+ // a no-break space, so the label never wraps away from its figure
3160
+ return (num(value) < 0 ? "−" : "") + (currency ? currency + "\u00a0" : "") + text
3161
+ }
3162
+
3163
+ function pctChange(now, then) {
3164
+ return then ? ((num(now) - num(then)) / Math.abs(num(then))) * 100 : null
3165
+ }
3166
+
3167
+ /** A change as a chip that says what it compares against, green when it moved the good way. */
3168
+ function digestChip(delta, label, goodWhen) {
3169
+ if (delta === null || !isFinite(delta)) return null
3170
+ var flat = Math.abs(delta) < 0.05
3171
+ var good = (goodWhen || "up") === "up" ? delta > 0 : delta < 0
3172
+ var node = el("span", "fmr-d-chip " + (flat ? "flat" : good ? "up" : "down"), (flat ? "→ " : delta > 0 ? "▲ " : "▼ ") + intl(1).format(Math.abs(delta)) + "% ")
3173
+ node.appendChild(el("small", "", label))
3174
+ return node
3175
+ }
3176
+
3177
+ var SVGNS = "http://www.w3.org/2000/svg"
3178
+ function svgNode(tag, attrs) {
3179
+ var node = document.createElementNS(SVGNS, tag)
3180
+ Object.keys(attrs || {}).forEach(function (key) { node.setAttribute(key, attrs[key]) })
3181
+ return node
3182
+ }
3183
+
3184
+ /** A tile's weeks as a shaded line ending in a dot on this week. */
3185
+ function sparkSvg(values, width, height) {
3186
+ if (values.length < 2) return null
3187
+ var min = Math.min.apply(null, values)
3188
+ var span = Math.max.apply(null, values) - min || 1
3189
+ var x = function (i) { return 2 + (i * (width - 4)) / (values.length - 1) }
3190
+ var y = function (v) { return height - 3 - ((v - min) / span) * (height - 8) }
3191
+ var points = values.map(function (v, i) { return x(i).toFixed(1) + "," + y(v).toFixed(1) })
3192
+ var svg = svgNode("svg", { class: "fmr-d-svg", viewBox: "0 0 " + width + " " + height, width: width, height: height, role: "img" })
3193
+ svg.style.marginTop = "2px"
3194
+ svg.appendChild(svgNode("path", { class: "spark-area", d: "M" + x(0).toFixed(1) + "," + height + " L" + points.join(" L") + " L" + x(values.length - 1).toFixed(1) + "," + height + " Z" }))
3195
+ svg.appendChild(svgNode("polyline", { class: "spark-line", points: points.join(" ") }))
3196
+ var last = values.length - 1
3197
+ svg.appendChild(svgNode("circle", { class: "spark-dot", cx: x(last).toFixed(1), cy: y(values[last]).toFixed(1), r: 2.6 }))
3198
+ return svg
3199
+ }
3200
+
3201
+ /** One figure of one query: a column of its first row. */
3202
+ function figureOf(ref) {
3203
+ if (!ref || !ref.query) return null
3204
+ var rows = FM.rows(ref.query)
3205
+ var raw = rows.length ? rows[0][ref.value] : null
3206
+ return raw === null || raw === undefined || raw === "" ? null : num(raw)
3207
+ }
3208
+
3209
+ function watchQueries(ids, paint) {
3210
+ var seen = {}
3211
+ ids.forEach(function (id) {
3212
+ if (!id || seen[id]) return
3213
+ seen[id] = true
3214
+ FM.onRows(id, paint)
3215
+ })
3216
+ }
3217
+
3218
+ /** Whether a date falls in the report's own week, @from to @to, read as dates in the business's own time zone. */
3219
+ function inReportWeek(raw) {
3220
+ var params = FM.params()
3221
+ var day = /^\d{4}-\d{2}-\d{2}/.exec(String(raw))
3222
+ if (!day || !params["@from"] || !params["@to"]) return false
3223
+ var local = function (iso) {
3224
+ var at = new Date(iso)
3225
+ return isNaN(at) ? "" : new Intl.DateTimeFormat("en-CA", { timeZone: params["@timezone"] || undefined, year: "numeric", month: "2-digit", day: "2-digit" }).format(at)
3226
+ }
3227
+ return day[0] >= local(params["@from"]) && day[0] < local(params["@to"])
3228
+ }
3229
+
3230
+ /** The week in brief: its docket, the headline in the display face and the summary under it. */
3231
+ function renderDigestBrief(root, config, base) {
3232
+ base = base || ""
3233
+ digestStyles()
3234
+ var card = part(el("div", "fmr-d-card fmr-d-brief"), base)
3235
+ var docket = el("div", "fmr-d-docket fmr-d-eyebrow")
3236
+ ;(config.eyebrow || []).forEach(function (piece) { docket.appendChild(el("span", "", piece)) })
3237
+ card.appendChild(docket)
3238
+ var body = el("div", "fmr-d-brief-body" + (config.headline ? " has-head" : ""))
3239
+ if (config.headline) body.appendChild(part(el("h1", "", config.headline), base + "headline"))
3240
+ if (config.lead) body.appendChild(part(el("p", "fmr-d-lead", config.lead), base + "lead"))
3241
+ card.appendChild(body)
3242
+ root.appendChild(card)
3243
+ }
3244
+
3245
+ /** Parts side by side are one row of equal cards; a card whose own content fills under half its row would be mostly empty, so every part of that row then takes the whole row. */
3246
+ function keepRowFilled(section) {
3247
+ var card = section.querySelector(":scope > .fmr-card, :scope > .fmr-d-card")
3248
+ if (!card || typeof ResizeObserver === "undefined") return
3249
+ var due = false
3250
+ // measured a frame later, so moving parts never resizes another inside the observer's loop
3251
+ var watch = new ResizeObserver(function () {
3252
+ if (due) return
3253
+ due = true
3254
+ requestAnimationFrame(function () {
3255
+ due = false
3256
+ var row = Array.prototype.filter.call(section.parentNode ? section.parentNode.children : [], function (other) {
3257
+ return other.offsetParent !== null && other.offsetTop === section.offsetTop
3258
+ })
3259
+ if (row.length < 2) return
3260
+ var own = ownHeight(card)
3261
+ if (!own || section.offsetHeight <= 2 * own) return
3262
+ row.forEach(function (other) { other.classList.add("fmr-d-apart") })
3263
+ watch.disconnect()
3264
+ })
3265
+ })
3266
+ watch.observe(section)
3267
+ }
3268
+
3269
+ /** How tall a card's own content is, however far the row stretches it. */
3270
+ function ownHeight(card) {
3271
+ if (!card.children.length) return 0
3272
+ var bottom = Math.max.apply(null, Array.prototype.map.call(card.children, function (child) { return child.getBoundingClientRect().bottom }))
3273
+ var style = getComputedStyle(card)
3274
+ return bottom - card.getBoundingClientRect().top + parseFloat(style.paddingBottom || "0") + parseFloat(style.borderBottomWidth || "0")
3275
+ }
3276
+
3277
+ /** A row of tiles: each figure with its changes against last week and its usual week, its usual figure, and its weeks as a line. */
3278
+ function renderDigestKpis(root, config, base) {
3279
+ base = base || ""
3280
+ digestStyles()
3281
+ var row = part(el("div", "fmr-d-kpis fmr-fam-grape"), base)
3282
+ root.appendChild(row)
3283
+ var tiles = config.tiles || []
3284
+ var nodes = tiles.map(function (tileConfig, index) {
3285
+ var card = part(el("div", "fmr-d-card fmr-d-kpi"), base + "tiles." + index)
3286
+ card.appendChild(el("div", "fmr-d-eyebrow", tileConfig.label))
3287
+ var node = { value: el("div", "fmr-d-value", "—"), chips: el("div", "fmr-d-chips"), baseline: el("div", "fmr-d-base"), spark: el("div") }
3288
+ node.chips.style.marginTop = "0"
3289
+ ;[node.value, node.chips, node.baseline, node.spark].forEach(function (child) { card.appendChild(child) })
3290
+ row.appendChild(card)
3291
+ return node
3292
+ })
3293
+ function paint() {
3294
+ tiles.forEach(function (tileConfig, index) {
3295
+ var node = nodes[index]
3296
+ var now = figureOf(tileConfig)
3297
+ var last = tileConfig.last ? figureOf({ query: tileConfig.query, value: tileConfig.last.value }) : null
3298
+ var normal = tileConfig.normal ? figureOf({ query: tileConfig.query, value: tileConfig.normal.value }) : null
3299
+ // the figure and its normal read together, at one precision
3300
+ var digits = moneyDigits([now, normal])
3301
+ node.value.textContent = ""
3302
+ if (now !== null && tileConfig.format === "money") {
3303
+ // a loss reads −RM 9,512: the sign leads the label
3304
+ if (now < 0) node.value.appendChild(document.createTextNode("−"))
3305
+ node.value.appendChild(el("small", "", envOf("currency", "")))
3306
+ node.value.appendChild(document.createTextNode(intl(digits).format(Math.abs(now))))
3307
+ } else node.value.textContent = digestValue(tileConfig.format, now)
3308
+ node.chips.textContent = ""
3309
+ ;[
3310
+ last !== null && now !== null ? digestChip(pctChange(now, last), text("digest.vsLastWeek", "vs last week"), tileConfig.deltaGoodWhen) : null,
3311
+ normal !== null && now !== null ? digestChip(pctChange(now, normal), text("digest.vsNormal", "vs normal"), tileConfig.deltaGoodWhen) : null
3312
+ ].forEach(function (chip) { if (chip) node.chips.appendChild(chip) })
3313
+ // the usual figure, then the writer's own caption
3314
+ node.baseline.textContent = [normal !== null ? text("digest.normalIs", "Normal {value}").replace("{value}", digestValue(tileConfig.format, normal, digits)) : "", tileConfig.caption || ""].filter(Boolean).join(" · ")
3315
+ node.spark.textContent = ""
3316
+ if (tileConfig.trend) {
3317
+ var values = FM.rows(tileConfig.trend.query).map(function (one) { return num(one[tileConfig.trend.y]) })
3318
+ var svg = sparkSvg(values, Math.max(120, Math.round(node.spark.clientWidth || 200)), 34)
3319
+ if (svg) node.spark.appendChild(svg)
3320
+ }
3321
+ })
3322
+ }
3323
+ var ids = []
3324
+ tiles.forEach(function (one) { ids.push(one.query, one.trend && one.trend.query) })
3325
+ watchQueries(ids, paint)
3326
+ paint()
3327
+ if (window.ResizeObserver) new window.ResizeObserver(function () { paint() }).observe(row)
3328
+ }
3329
+
3330
+ /** A story opens: its number, the parts of the business it reads, its heading in the display face, and its key figures. */
3331
+ function renderDigestChapter(root, config, base) {
3332
+ base = base || ""
3333
+ digestStyles()
3334
+ var head = part(el("header", "fmr-d-chapter fmr-fam-grape"), base)
3335
+ head.appendChild(el("div", "no", String(config.number)))
3336
+ var middle = el("div", "heading")
3337
+ var label = el("div", "label")
3338
+ label.appendChild(el("b", "n", String(config.number)))
3339
+ if (config.label) {
3340
+ label.appendChild(el("span", "fmr-d-dot"))
3341
+ label.appendChild(document.createTextNode(config.label))
3342
+ }
3343
+ middle.appendChild(label)
3344
+ middle.appendChild(part(el("h2", "", config.title), base + "title"))
3345
+ head.appendChild(middle)
3346
+ var stats = el("div", "stats")
3347
+ head.appendChild(stats)
3348
+ root.appendChild(head)
3349
+ function paint() {
3350
+ stats.textContent = ""
3351
+ // a story's money figures read together, at one precision
3352
+ var digits = moneyDigits((config.stats || []).filter(function (stat) { return stat.format === "money" }).map(figureOf))
3353
+ ;(config.stats || []).forEach(function (stat) {
3354
+ var box = el("div", "stat")
3355
+ box.appendChild(el("b", "", digestValue(stat.format, figureOf(stat), digits)))
3356
+ box.appendChild(el("span", "", stat.label))
3357
+ stats.appendChild(box)
3358
+ })
3359
+ }
3360
+ watchQueries((config.stats || []).map(function (stat) { return stat.query }), paint)
3361
+ paint()
3362
+ }
3363
+
3364
+ /** A block of the report's words: a story's under its chart, or a card under its own heading, its lines a list when asked; each figure the server marked set bold. */
3365
+ function renderDigestText(root, config, base) {
3366
+ base = base || ""
3367
+ digestStyles()
3368
+ var box = part(el("div", config.tone === "card" ? "fmr-d-card fmr-d-text" : "fmr-d-note"), base)
3369
+ if (config.title) box.appendChild(el("h3", "fmr-d-title", config.title))
3370
+ var holder = box.appendChild(config.list ? el("ul", "fmr-d-list") : el("div", config.tone === "card" ? "fmr-d-card-body" : "fmr-d-note-body"))
3371
+ var words = String(config.text || "")
3372
+ var marks = (config.marks || []).slice().sort(function (a, b) { return a[0] - b[0] })
3373
+ // paragraphs, or a list's lines, keep their place in the words, so each mark lands where the server put it
3374
+ var breaks = config.list ? /\s*\n\s*/g : /\n\s*\n/g
3375
+ var spans = []
3376
+ var from = 0
3377
+ var found
3378
+ while ((found = breaks.exec(words))) {
3379
+ spans.push([from, found.index])
3380
+ from = found.index + found[0].length
3381
+ }
3382
+ spans.push([from, words.length])
3383
+ spans.forEach(function (span) {
3384
+ var start = span[0]
3385
+ var end = span[1]
3386
+ while (start < end && /\s/.test(words[start])) start++
3387
+ while (end > start && /\s/.test(words[end - 1])) end--
3388
+ if (start >= end) return
3389
+ var paragraph = el(config.list ? "li" : "p")
3390
+ var at = start
3391
+ marks.forEach(function (mark) {
3392
+ var head = Math.max(mark[0], start)
3393
+ var tail = Math.min(mark[1], end)
3394
+ if (tail <= head || head < at) return
3395
+ if (head > at) paragraph.appendChild(document.createTextNode(words.slice(at, head)))
3396
+ paragraph.appendChild(el("b", "", words.slice(head, tail)))
3397
+ at = tail
3398
+ })
3399
+ if (at < end) paragraph.appendChild(document.createTextNode(words.slice(at, end)))
3400
+ holder.appendChild(paragraph)
3401
+ })
3402
+ root.appendChild(box)
3403
+ }
3404
+
3405
+ function strokeIcon(paths, size) {
3406
+ var icon = svgNode("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true" })
3407
+ paths.forEach(function (d) { icon.appendChild(svgNode("path", { d: d })) })
3408
+ return icon
3409
+ }
3410
+
3411
+ /** An idea for a story: the FeedMe feature it names, its words by paragraph and where the deck shows it, every word the server's. */
3412
+ function renderDigestIdea(root, config, base) {
3413
+ base = base || ""
3414
+ digestStyles()
3415
+ var box = part(el("section", "fmr-d-idea"), base)
3416
+ if (config.eyebrow) box.setAttribute("aria-label", config.eyebrow)
3417
+ var top = box.appendChild(el("div", "fmr-d-idea-top"))
3418
+ top.appendChild(strokeIcon(["M9 18h6", "M10 22h4", "M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V17h6v-.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z"], 18))
3419
+ top.appendChild(el("span", "fmr-d-idea-eyebrow", config.eyebrow || ""))
3420
+ if (config.tag) {
3421
+ var tag = top.appendChild(el("span", "fmr-d-idea-tag"))
3422
+ tag.appendChild(strokeIcon(["M6 11h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2z", "M8 11V7a4 4 0 0 1 8 0v4"], 12))
3423
+ tag.appendChild(document.createTextNode(config.tag))
3424
+ }
3425
+ if (config.title) box.appendChild(el("h3", "", config.title))
3426
+ String(config.text || "").split(/\n\s*\n/).forEach(function (words) {
3427
+ if (words.trim()) box.appendChild(el("p", "", words.trim()))
3428
+ })
3429
+ if (config.source && config.href) {
3430
+ // the frame opens no window itself: the host opens the link
3431
+ var link = box.appendChild(el("p", "fmr-d-idea-source")).appendChild(el("a", "", config.source))
3432
+ link.href = config.href
3433
+ link.target = "_blank"
3434
+ link.rel = "noopener noreferrer"
3435
+ link.appendChild(strokeIcon(["M15 3h6v6", "M10 14 21 3", "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"], 13))
3436
+ } else if (config.source) box.appendChild(el("p", "fmr-d-idea-source", config.source))
3437
+ root.appendChild(box)
3438
+ }
3439
+
2394
3440
  var RENDERERS = {
2395
3441
  "filter-bar": renderFilterBar,
2396
3442
  "kpi-row": renderKpiRow,
@@ -2406,14 +3452,23 @@
2406
3452
  map: renderMap,
2407
3453
  scatter: renderScatter,
2408
3454
  radar: renderRadar,
2409
- flow: renderFlow
3455
+ flow: renderFlow,
3456
+ "digest-brief": renderDigestBrief,
3457
+ "digest-kpis": renderDigestKpis,
3458
+ "digest-chapter": renderDigestChapter,
3459
+ "digest-text": renderDigestText,
3460
+ "digest-idea": renderDigestIdea,
2410
3461
  }
2411
3462
 
2412
3463
  FM.components = {
2413
3464
  render: function (el, section) {
2414
3465
  var renderer = RENDERERS[section.component]
2415
3466
  if (!renderer) throw new Error('unknown component "' + section.component + '"')
2416
- renderer(el, section.config || {}, "")
3467
+ var config = section.config || {}
3468
+ if (isDigest()) digestStyles()
3469
+ renderer(el, config, "")
3470
+ if (isDigest() && String(section.component).indexOf("digest-") !== 0) addTakeaway(el, config)
3471
+ if (isDigest()) keepRowFilled(el)
2417
3472
  }
2418
3473
  }
2419
3474
  })()