@feedmepos/mf-report 5.31.3-beta.0 → 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.
- package/dist/App-o4z-EAO1.js +339 -0
- package/dist/{DateRangeSelect-AJd7gYva.js → DateRangeSelect-DO3T_rQs.js} +18 -18
- package/dist/{Default.vue_vue_type_script_setup_true_lang-CIVlgjSX.js → Default.vue_vue_type_script_setup_true_lang-CW_VllVL.js} +1 -1
- package/dist/DigestListView-DG-vojKl.js +637 -0
- package/dist/DigestReaderView-bRfEYtZA.js +507 -0
- package/dist/{DynamicWidget.vue_vue_type_script_setup_true_lang-BKHx9p3R.js → DynamicWidget.vue_vue_type_script_setup_true_lang-Bfmd5eMI.js} +173 -173
- package/dist/{GlobalFilterSelect.vue_vue_type_script_setup_true_lang-qQi-7kuX.js → GlobalFilterSelect.vue_vue_type_script_setup_true_lang-vy0FZKv0.js} +154 -154
- package/dist/{InsightView-noFPHcRY.js → InsightView-sLKiFc_-.js} +16 -16
- package/dist/{Integration-UwZP3vYt.js → Integration-CZn5gvF8.js} +3 -3
- package/dist/{Integrations-BMhJuw20.js → Integrations-I0rHAfPo.js} +2 -2
- package/dist/{Layout-CofH0rzo.js → Layout-DPEfiH3f.js} +1 -1
- package/dist/{LocationSelect.vue_vue_type_script_setup_true_lang-BsdJx7gt.js → LocationSelect.vue_vue_type_script_setup_true_lang-DFQ943gj.js} +2 -2
- package/dist/{MenuTab.vue_vue_type_script_setup_true_lang-BMJHW2E2.js → MenuTab.vue_vue_type_script_setup_true_lang-BjJcvN_z.js} +9 -9
- package/dist/{NavigationTab.vue_vue_type_script_setup_true_lang-b6hgoVnw.js → NavigationTab.vue_vue_type_script_setup_true_lang-zUF-G98m.js} +6 -6
- package/dist/{OverviewView-V-KcUYld.js → OverviewView-CJS82eII.js} +5 -5
- package/dist/{Report-Cxfxa7EK.js → Report-DDYgnr3v.js} +30 -30
- package/dist/ReportCanvasView-m_qW7QgV.js +966 -0
- package/dist/{ReportEditor-syPuiUDA.js → ReportEditor-CrMd2q2a.js} +7 -7
- package/dist/{ReportView-Dqc89voW.js → ReportView-CKLzDw5j.js} +6 -6
- package/dist/{ReportViewRoute-BE4Olg2t.js → ReportViewRoute-BrLi7iXr.js} +2 -2
- package/dist/{Setting-CSZG2_pc.js → Setting-BUmSYXCO.js} +3 -3
- package/dist/{Template-DE8y5xA7.js → Template-CljYVpCi.js} +18 -18
- package/dist/{TileEditor-DCLCIQqG.js → TileEditor-3fV8xOVc.js} +6 -6
- package/dist/{TilePageStack.vue_vue_type_script_setup_true_lang-BKM30woJ.js → TilePageStack.vue_vue_type_script_setup_true_lang-Biwl30EU.js} +2 -2
- package/dist/{TransitionFade-DSKakW7L.js → TransitionFade-BUSdiKFH.js} +5 -5
- package/dist/{app-BxEbZEnS.js → app-BXVd3ce-.js} +3354 -2967
- package/dist/app.js +4 -4
- package/dist/assets/{formatChartData.worker-R9EGYsk6.js → formatChartData.worker-BprlZnJs.js} +80 -58
- package/dist/assets/{processTableData.worker-CxpDa0tB.js → processTableData.worker-D5GSFMnH.js} +83 -61
- package/dist/{currency-lmnrd1Gz.js → currency-C6h0cwip.js} +1 -1
- package/dist/{index-Co3j0l3d.js → index-BoeJLw2U.js} +1279 -1244
- package/dist/{report-Dsp2krIZ.js → report-DG-20Pq8.js} +18 -18
- package/dist/report-canvas-libs/components-1.js +1126 -71
- package/dist/report-canvas-libs/geo-outlines-1.js +1 -1
- package/dist/style.css +1 -1
- package/dist/useDigestFont-Gf1l5qYq.js +205 -0
- package/dist/{ReportCanvasView-DDNkVmlA.js → useReportCanvasPanel-CquUHqIK.js} +2933 -3477
- package/dist/{useRestaurantPermission-D8xwhBHn.js → useRestaurantPermission-BNu9cN13.js} +1 -1
- package/package.json +1 -1
- package/dist/App-j08Hk0tX.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
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
660
|
-
var
|
|
926
|
+
if (days) {
|
|
927
|
+
var at = plainDay(raw) || raw
|
|
928
|
+
var day = new Date(at)
|
|
661
929
|
if (!isNaN(day)) {
|
|
662
|
-
|
|
930
|
+
var date = day.toLocaleDateString(locale(), {
|
|
663
931
|
month: "short",
|
|
664
932
|
day: "numeric",
|
|
665
|
-
timeZone: zoneFor(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1299
|
+
var built = {
|
|
855
1300
|
type: "bar",
|
|
856
1301
|
data: data,
|
|
857
1302
|
options: Object.assign({}, opts, {
|
|
858
|
-
indexAxis:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
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) {
|
|
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 +
|
|
1963
|
+
cell.title = where + cellFigure(value)
|
|
1445
1964
|
}
|
|
1446
1965
|
grid.appendChild(cell)
|
|
1447
1966
|
})
|
|
1448
|
-
if (config.rowTotals) grid.appendChild(heatLabel(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1759
|
-
return token(
|
|
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
|
-
|
|
1832
|
-
|
|
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
|
|
1839
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})()
|