@feedmepos/mf-report 5.28.5 → 5.29.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-Bc8Mp5vR.js → App-CuBsRwfx.js} +4 -4
- package/dist/{DateRangeSelect-cbSEHMzW.js → DateRangeSelect-CzjlwjuC.js} +1 -1
- package/dist/{Default.vue_vue_type_script_setup_true_lang-BpJFA_ET.js → Default.vue_vue_type_script_setup_true_lang-Di9_F5Yk.js} +1 -1
- package/dist/{DynamicWidget.vue_vue_type_script_setup_true_lang-Dx-cxJP6.js → DynamicWidget.vue_vue_type_script_setup_true_lang-C73R6Fb9.js} +5 -5
- package/dist/{GlobalFilterSelect.vue_vue_type_script_setup_true_lang-Cxrti7_L.js → GlobalFilterSelect.vue_vue_type_script_setup_true_lang-tLRFdLNs.js} +9 -10
- package/dist/{InsightView-Bo0Fb5QG.js → InsightView-BcnmLQ8c.js} +5 -5
- package/dist/{Integration-B4tC2MlL.js → Integration-D4VFT1M8.js} +3 -3
- package/dist/{Integrations-RB8iuvdJ.js → Integrations-v8oF1TL7.js} +2 -2
- package/dist/{Layout-DBCPtUH7.js → Layout-Ds8RSWmi.js} +1 -1
- package/dist/{LocationSelect.vue_vue_type_script_setup_true_lang-w2H9qOgJ.js → LocationSelect.vue_vue_type_script_setup_true_lang-Dk6u8nUH.js} +1638 -1634
- package/dist/{MenuTab.vue_vue_type_script_setup_true_lang-BLVGUSWf.js → MenuTab.vue_vue_type_script_setup_true_lang-BWt7pqFb.js} +5 -5
- package/dist/{NavigationTab.vue_vue_type_script_setup_true_lang-N31ee-oZ.js → NavigationTab.vue_vue_type_script_setup_true_lang-CU00GINo.js} +3853 -3823
- package/dist/OverviewView-1v80g6KX.js +81 -0
- package/dist/{Report-Di6GyP1r.js → Report-DcFB4gM6.js} +3 -3
- package/dist/ReportCanvasView-BQO6Mywc.js +5190 -0
- package/dist/{ReportEditor-BfNmiEVn.js → ReportEditor-Bgcisf1s.js} +7 -7
- package/dist/{ReportView-JWJ-6FCu.js → ReportView-Q_juD8Kr.js} +6 -6
- package/dist/{ReportViewRoute-BMjjJqN2.js → ReportViewRoute-DwStGqyz.js} +2 -2
- package/dist/{Setting-D13NIemB.js → Setting-C9rlM23O.js} +3 -3
- package/dist/{Template-D16bqzrx.js → Template-BZd0NB5w.js} +3 -3
- package/dist/{TileEditor-1Sn9ZSb2.js → TileEditor-B2F9r3RS.js} +6 -6
- package/dist/{TilePageStack.vue_vue_type_script_setup_true_lang-BOXsV7-r.js → TilePageStack.vue_vue_type_script_setup_true_lang-BmbrJAph.js} +2 -2
- package/dist/{TransitionFade-DwJkmMTb.js → TransitionFade-Dkeo9qr0.js} +5 -5
- package/dist/{app-CVBllEjW.js → app-CoQ_GRKo.js} +1819 -1326
- package/dist/app.js +1 -1
- package/dist/assets/{formatChartData.worker-DZp5dSDQ.js → formatChartData.worker-D7sGqQhX.js} +34 -34
- package/dist/assets/{processTableData.worker-Kyuhw4lS.js → processTableData.worker-CltxwQxU.js} +34 -34
- package/dist/{report-Cn4Ik1zm.js → report-Dau_so1j.js} +1 -1
- package/dist/report-canvas-libs/components-1.js +1042 -0
- package/dist/report-canvas-libs/fm-ui-1.js +599 -180
- package/dist/style.css +1 -1
- package/dist/{useRestaurantPermission-C8bLPfk8.js → useRestaurantPermission-CPoHZTxx.js} +1 -1
- package/package.json +2 -1
- package/dist/OverviewView-WKe6Zxe1.js +0 -61
- package/dist/ReportCanvasView-Dg2ItHBO.js +0 -1336
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FM.components — declarative section renderers, delivered as the `components@1` lib.
|
|
3
|
+
* A component section is config rendered by this tested code; the model writes no JS.
|
|
4
|
+
* Renderers subscribe ONLY synchronously during mount (the frame attributes errors and
|
|
5
|
+
* rows callbacks to the section it is currently mounting) and treat config as data:
|
|
6
|
+
* unknown keys are ignored, the server already validated shape at write time.
|
|
7
|
+
*/
|
|
8
|
+
;(function () {
|
|
9
|
+
"use strict"
|
|
10
|
+
|
|
11
|
+
var FM = (window.FM = window.FM || {})
|
|
12
|
+
if (FM.components) return
|
|
13
|
+
|
|
14
|
+
// Colours come from the Vue-v2 Rebus tokens the frame embeds; Chart.js needs them resolved,
|
|
15
|
+
// so they are read at paint time. The literal is only the fallback for a frame without the sheet.
|
|
16
|
+
function token(name, fallback) {
|
|
17
|
+
var value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
|
18
|
+
return value || fallback
|
|
19
|
+
}
|
|
20
|
+
function px(name, fallback) {
|
|
21
|
+
var value = parseFloat(token(name, ""))
|
|
22
|
+
return isFinite(value) ? value : fallback
|
|
23
|
+
}
|
|
24
|
+
// chart families per the chart-design guide: lines step through blossom, bars take grape
|
|
25
|
+
var LINE_STEPS = [
|
|
26
|
+
["--fm-feature-blossom-fg", "#8c146a"],
|
|
27
|
+
["--fm-feature-blossom-accent", "#ffc4c3"],
|
|
28
|
+
["--fm-feature-grape-accent", "#8f61e8"],
|
|
29
|
+
["--fm-feature-aqua-accent", "#3ab0ff"]
|
|
30
|
+
]
|
|
31
|
+
function lineColor(index) {
|
|
32
|
+
var step = LINE_STEPS[index % LINE_STEPS.length]
|
|
33
|
+
return token(step[0], step[1])
|
|
34
|
+
}
|
|
35
|
+
function barColor() {
|
|
36
|
+
return token("--fm-feature-grape-fg", "#512191")
|
|
37
|
+
}
|
|
38
|
+
function compareColor() {
|
|
39
|
+
return token("--fm-text-disabled", "#b2b3b6")
|
|
40
|
+
}
|
|
41
|
+
function axis() {
|
|
42
|
+
return {
|
|
43
|
+
border: { display: false },
|
|
44
|
+
ticks: { color: token("--fm-text-muted", "#6b6c70"), font: { size: px("--fm-font-size-14", 14) } }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function gridColor() {
|
|
48
|
+
return token("--fm-border-default", "#f0f0f0")
|
|
49
|
+
}
|
|
50
|
+
function chartOpts() {
|
|
51
|
+
return {
|
|
52
|
+
responsive: true,
|
|
53
|
+
maintainAspectRatio: false,
|
|
54
|
+
animation: false,
|
|
55
|
+
elements: { bar: { borderRadius: px("--fm-radius-xs", 4) } },
|
|
56
|
+
plugins: {
|
|
57
|
+
legend: { display: false },
|
|
58
|
+
tooltip: {
|
|
59
|
+
backgroundColor: token("--fm-bg-inverse", "#1c1c1e"),
|
|
60
|
+
titleColor: token("--fm-text-inverse", "#fff"),
|
|
61
|
+
bodyColor: token("--fm-text-inverse", "#fff"),
|
|
62
|
+
cornerRadius: px("--fm-radius-sm", 8),
|
|
63
|
+
padding: 10,
|
|
64
|
+
boxWidth: 8,
|
|
65
|
+
boxHeight: 8,
|
|
66
|
+
borderWidth: 0
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- formatting: every value leaves PG as a string; env is read at call time
|
|
73
|
+
|
|
74
|
+
function num(value) {
|
|
75
|
+
var parsed = Number(value)
|
|
76
|
+
return isFinite(parsed) ? parsed : 0
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function intl(digits) {
|
|
80
|
+
return new Intl.NumberFormat((window.env && window.env.locale) || "en-MY", {
|
|
81
|
+
minimumFractionDigits: digits,
|
|
82
|
+
maximumFractionDigits: digits
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatValue(format, value) {
|
|
87
|
+
if (value === null || value === undefined || value === "") return "—"
|
|
88
|
+
switch (format) {
|
|
89
|
+
case "money":
|
|
90
|
+
return intl(2).format(num(value))
|
|
91
|
+
case "amount":
|
|
92
|
+
return intl(2).format(num(value))
|
|
93
|
+
case "count":
|
|
94
|
+
return intl(0).format(num(value))
|
|
95
|
+
case "percent":
|
|
96
|
+
return intl(1).format(num(value)) + "%"
|
|
97
|
+
case "ratio":
|
|
98
|
+
return intl(1).format(num(value) * 100) + "%"
|
|
99
|
+
case "datetime":
|
|
100
|
+
return new Date(value).toLocaleString((window.env && window.env.locale) || "en-MY", {
|
|
101
|
+
dateStyle: "medium",
|
|
102
|
+
timeStyle: "short"
|
|
103
|
+
})
|
|
104
|
+
default:
|
|
105
|
+
return String(value)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Axis ticks: RM 0, RM 2k, RM 10k — abbreviated, per the chart guide. */
|
|
110
|
+
function abbrev(format) {
|
|
111
|
+
return function (value) {
|
|
112
|
+
var v = num(value)
|
|
113
|
+
var currency = format === "money" ? ((window.env && window.env.currency) || "") + " " : ""
|
|
114
|
+
if (Math.abs(v) >= 1000000) return currency + (v / 1000000).toFixed(1).replace(/\.0$/, "") + "m"
|
|
115
|
+
if (Math.abs(v) >= 1000) return currency + (v / 1000).toFixed(1).replace(/\.0$/, "") + "k"
|
|
116
|
+
// a tiny domain otherwise rounds every tick to the same label
|
|
117
|
+
return currency + intl(Math.abs(v) > 0 && Math.abs(v) < 10 ? 2 : 0).format(v)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Marks a node as the config it renders, so a comment on it can name the config path. */
|
|
122
|
+
function part(node, path) {
|
|
123
|
+
if (path) node.setAttribute("data-fm-part", path)
|
|
124
|
+
return node
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function el(tag, className, text) {
|
|
128
|
+
var node = document.createElement(tag)
|
|
129
|
+
if (className) node.className = className
|
|
130
|
+
if (text !== undefined && text !== null) node.textContent = String(text)
|
|
131
|
+
return node
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function tile(root, title, base) {
|
|
135
|
+
var card = el("div", "fmr-card fmr-tile")
|
|
136
|
+
if (title) {
|
|
137
|
+
var head = el("div", "fmr-tile-head")
|
|
138
|
+
head.appendChild(part(el("h2", "fmr-tile-title", title), base + "title"))
|
|
139
|
+
card.appendChild(head)
|
|
140
|
+
}
|
|
141
|
+
root.appendChild(card)
|
|
142
|
+
return card
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function emptyNote(config) {
|
|
146
|
+
return el("p", "fmr-note", config.empty || "No data in this period.")
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** '$row.col' / '$x' references resolve against the interaction; literals pass through. */
|
|
150
|
+
function resolveSet(setConfig, row, x) {
|
|
151
|
+
var values = {}
|
|
152
|
+
Object.keys(setConfig || {}).forEach(function (param) {
|
|
153
|
+
var value = setConfig[param]
|
|
154
|
+
if (typeof value === "string" && value.indexOf("$row.") === 0) {
|
|
155
|
+
values[param] = row ? row[value.slice(5)] : null
|
|
156
|
+
} else if (value === "$x") {
|
|
157
|
+
values[param] = x
|
|
158
|
+
} else {
|
|
159
|
+
values[param] = value
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
return values
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- controls: one shared implementation, at every placement
|
|
166
|
+
|
|
167
|
+
var LOCATION_CONTEXT = "locations"
|
|
168
|
+
|
|
169
|
+
function paramType(name) {
|
|
170
|
+
var meta = FM.paramMeta && FM.paramMeta(name)
|
|
171
|
+
return meta ? meta.type : undefined
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function declaredOptions(name) {
|
|
175
|
+
var meta = FM.paramMeta && FM.paramMeta(name)
|
|
176
|
+
return meta && meta.options && meta.options.length ? meta.options : null
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A list declared with an empty default means every value, so ticking nothing is not "None". */
|
|
180
|
+
function emptyMeansAll(name) {
|
|
181
|
+
var meta = FM.paramMeta && FM.paramMeta(name)
|
|
182
|
+
return !!(meta && Array.isArray(meta.default) && !meta.default.length)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function sameValue(a, b) {
|
|
186
|
+
return JSON.stringify(a === undefined ? null : a) === JSON.stringify(b === undefined ? null : b)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Segmented only for a small static scalar domain: an array or a source that refreshes would
|
|
191
|
+
* otherwise change interaction style under the merchant's hand.
|
|
192
|
+
*/
|
|
193
|
+
function presentationOf(control, count, multiple, dynamic) {
|
|
194
|
+
if (control.kind) return control.kind
|
|
195
|
+
var wanted = control.presentation || "auto"
|
|
196
|
+
if (wanted !== "auto") return wanted
|
|
197
|
+
return !multiple && !dynamic && count > 1 && count <= 5 ? "segmented" : "dropdown"
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** A date control commits both endpoints in one write, so no query sees half a range. */
|
|
201
|
+
function mountDate(mount, control) {
|
|
202
|
+
var single = !control.toParam
|
|
203
|
+
function reading(values) {
|
|
204
|
+
return {
|
|
205
|
+
from: values[control.param],
|
|
206
|
+
to: control.toParam ? values[control.toParam] : values[control.param]
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
var handle = FM.ui.dateRange(mount, {
|
|
210
|
+
single: single,
|
|
211
|
+
value: reading(FM.params()),
|
|
212
|
+
onChange: function (range) {
|
|
213
|
+
var patch = {}
|
|
214
|
+
patch[control.param] = range.from
|
|
215
|
+
if (control.toParam) patch[control.toParam] = range.to
|
|
216
|
+
FM.setParams(patch)
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
FM.onParams(single ? [control.param] : [control.param, control.toParam], function (values) {
|
|
220
|
+
handle.setValue(reading(values))
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function mountSelection(mount, note, control) {
|
|
225
|
+
var name = control.param
|
|
226
|
+
var type = paramType(name)
|
|
227
|
+
var multiple = type === "string[]" || type === "number[]"
|
|
228
|
+
var source = control.optionsFrom
|
|
229
|
+
var outlets = !!(source && source.context === LOCATION_CONTEXT)
|
|
230
|
+
var optionQuery = source && source.query ? source.query : null
|
|
231
|
+
var dynamic = outlets || !!optionQuery
|
|
232
|
+
|
|
233
|
+
function fromSource() {
|
|
234
|
+
if (outlets) {
|
|
235
|
+
return (window.env.locations || []).map(function (location) {
|
|
236
|
+
return { label: location.name || location.id, value: location.id }
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
var labelColumn = source.label || source.value
|
|
240
|
+
var seen = Object.create(null)
|
|
241
|
+
var out = []
|
|
242
|
+
FM.rows(optionQuery).forEach(function (row) {
|
|
243
|
+
var value = row[source.value]
|
|
244
|
+
if (value === null || value === undefined) return
|
|
245
|
+
var key = String(value)
|
|
246
|
+
if (seen[key]) return
|
|
247
|
+
seen[key] = 1
|
|
248
|
+
var text = row[labelColumn]
|
|
249
|
+
out.push({ label: String(text === null || text === undefined ? value : text), value: value })
|
|
250
|
+
})
|
|
251
|
+
return out
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** One source, and a declared domain is the ceiling a dynamic one may offer within. */
|
|
255
|
+
function options() {
|
|
256
|
+
if (control.options) return control.options
|
|
257
|
+
if (!dynamic) return declaredOptions(name) || []
|
|
258
|
+
var offered = fromSource()
|
|
259
|
+
var declared = declaredOptions(name)
|
|
260
|
+
if (!declared) return offered
|
|
261
|
+
var allowed = Object.create(null)
|
|
262
|
+
declared.forEach(function (option) {
|
|
263
|
+
allowed[String(option.value)] = 1
|
|
264
|
+
})
|
|
265
|
+
return offered.filter(function (option) {
|
|
266
|
+
return allowed[String(option.value)]
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function write(value) {
|
|
271
|
+
var patch = {}
|
|
272
|
+
patch[name] = value
|
|
273
|
+
FM.setParams(patch)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
var items = options()
|
|
277
|
+
var presentation = presentationOf(control, items.length, multiple, dynamic)
|
|
278
|
+
var current = FM.params()[name]
|
|
279
|
+
var handle
|
|
280
|
+
if (presentation === "segmented") {
|
|
281
|
+
handle = FM.ui.segmented(mount, { options: items, value: current, onChange: write })
|
|
282
|
+
} else {
|
|
283
|
+
handle = FM.ui.dropdown(mount, {
|
|
284
|
+
options: items,
|
|
285
|
+
multiple: multiple,
|
|
286
|
+
value: current,
|
|
287
|
+
placeholder: multiple && emptyMeansAll(name) ? "All" : null,
|
|
288
|
+
icon: outlets ? "store" : null,
|
|
289
|
+
all: outlets
|
|
290
|
+
? {
|
|
291
|
+
label: control.label ? "All " + control.label.toLowerCase() : "All",
|
|
292
|
+
active: FM.paramIntent(name) === "all",
|
|
293
|
+
onSelect: function () {
|
|
294
|
+
var patch = {}
|
|
295
|
+
var intent = {}
|
|
296
|
+
patch[name] = options().map(function (option) {
|
|
297
|
+
return option.value
|
|
298
|
+
})
|
|
299
|
+
intent[name] = "all"
|
|
300
|
+
FM.setParams(patch, { intent: intent })
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
: null,
|
|
304
|
+
onChange: write
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** A value the refreshed source no longer offers is said, never silently rewritten. */
|
|
309
|
+
function checkMissing(value) {
|
|
310
|
+
if (!items.length || value === null || value === undefined) return false
|
|
311
|
+
var offered = Object.create(null)
|
|
312
|
+
items.forEach(function (option) {
|
|
313
|
+
offered[String(option.value)] = 1
|
|
314
|
+
})
|
|
315
|
+
var picked = Array.isArray(value) ? value : [value]
|
|
316
|
+
return picked.some(function (one) {
|
|
317
|
+
return !offered[String(one)]
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function say(text) {
|
|
322
|
+
note.hidden = !text
|
|
323
|
+
note.textContent = text || ""
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function repaint(values) {
|
|
327
|
+
var value = values[name]
|
|
328
|
+
if (!sameValue(handle.getValue(), value)) handle.setValue(value)
|
|
329
|
+
if (handle.setAll) handle.setAll(FM.paramIntent(name) === "all")
|
|
330
|
+
if (FM.paramIntent(name) === "all") return say("")
|
|
331
|
+
say(checkMissing(value) ? "The saved selection is no longer offered." : "")
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
FM.onParams([name], function (values) {
|
|
335
|
+
// the outlet list resolves after the first paint, so the options are re-read here too
|
|
336
|
+
if (outlets) {
|
|
337
|
+
items = options()
|
|
338
|
+
handle.setOptions(items)
|
|
339
|
+
}
|
|
340
|
+
repaint(values)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
if (optionQuery) {
|
|
344
|
+
FM.onRows(optionQuery, function () {
|
|
345
|
+
var status = FM.status(optionQuery)
|
|
346
|
+
items = options()
|
|
347
|
+
handle.setOptions(items)
|
|
348
|
+
// only its own source disables it: a chart refreshing must never take the filter away
|
|
349
|
+
if (handle.setDisabled) handle.setDisabled(status === "loading" && !items.length)
|
|
350
|
+
if (status === "error") return say("Options unavailable.")
|
|
351
|
+
repaint(FM.params())
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Controls are canvas elements: a standalone bar, or a row below a widget's title. */
|
|
357
|
+
function renderControls(root, controls, base) {
|
|
358
|
+
if (!controls || !controls.length) return
|
|
359
|
+
if (!FM.ui) throw new Error("fm-ui@1 lib missing")
|
|
360
|
+
var row = el("div", "fm-controls")
|
|
361
|
+
root.appendChild(row)
|
|
362
|
+
controls.forEach(function (control, index) {
|
|
363
|
+
var slot = part(el("div", "fm-control"), base + "controls." + index)
|
|
364
|
+
if (control.label)
|
|
365
|
+
slot.appendChild(el("span", "fm-control-label fm-text-body-sm-500", control.label))
|
|
366
|
+
var mount = el("span")
|
|
367
|
+
slot.appendChild(mount)
|
|
368
|
+
var note = el("span", "fm-control-note fm-text-body-sm-400")
|
|
369
|
+
note.hidden = true
|
|
370
|
+
slot.appendChild(note)
|
|
371
|
+
row.appendChild(slot)
|
|
372
|
+
if (control.type === "date") mountDate(mount, control)
|
|
373
|
+
else mountSelection(mount, note, control)
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// --- filter-bar: a standalone grouping container for controls
|
|
378
|
+
|
|
379
|
+
function renderFilterBar(root, config, base) {
|
|
380
|
+
renderControls(root, config.controls, base || "")
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// --- kpi-row: tiles with an optional previous-period chip
|
|
384
|
+
|
|
385
|
+
function renderKpiRow(root, config, base) {
|
|
386
|
+
base = base || ""
|
|
387
|
+
renderControls(root, config.controls, base)
|
|
388
|
+
var row = el("div", "fmr-kpis")
|
|
389
|
+
root.appendChild(row)
|
|
390
|
+
var tiles = config.tiles || []
|
|
391
|
+
var nodes = tiles.map(function (tileConfig, index) {
|
|
392
|
+
var card = part(el("div", "fmr-card fmr-kpi"), base + "tiles." + index)
|
|
393
|
+
card.appendChild(el("div", "fmr-kpi-label", tileConfig.label))
|
|
394
|
+
var value = el("div", "fmr-kpi-value", "—")
|
|
395
|
+
card.appendChild(value)
|
|
396
|
+
// the comparison gets its own line: on the value line it has only the room the
|
|
397
|
+
// number leaves, which at four money tiles across is none
|
|
398
|
+
var delta = el("div", "fmr-kpi-delta")
|
|
399
|
+
card.appendChild(delta)
|
|
400
|
+
if (tileConfig.caption) card.appendChild(el("div", "fmr-kpi-note", tileConfig.caption))
|
|
401
|
+
row.appendChild(card)
|
|
402
|
+
return { value: value, delta: delta }
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
function paint() {
|
|
406
|
+
tiles.forEach(function (tileConfig, index) {
|
|
407
|
+
var rows = FM.rows(tileConfig.query)
|
|
408
|
+
var target = nodes[index].value
|
|
409
|
+
var deltaNode = nodes[index].delta
|
|
410
|
+
target.textContent = ""
|
|
411
|
+
deltaNode.textContent = ""
|
|
412
|
+
var raw = rows.length ? rows[0][tileConfig.value] : null
|
|
413
|
+
if (tileConfig.format === "money" && raw !== null && raw !== undefined) {
|
|
414
|
+
target.appendChild(el("span", "fmr-kpi-cur", (window.env && window.env.currency) || ""))
|
|
415
|
+
target.appendChild(document.createTextNode(formatValue("amount", raw)))
|
|
416
|
+
} else {
|
|
417
|
+
target.appendChild(document.createTextNode(formatValue(tileConfig.format || "amount", raw)))
|
|
418
|
+
}
|
|
419
|
+
if (tileConfig.compareQuery && raw !== null && raw !== undefined) {
|
|
420
|
+
var prevRows = FM.rows(tileConfig.compareQuery)
|
|
421
|
+
var prev = prevRows.length ? num(prevRows[0][tileConfig.compareValue || tileConfig.value]) : null
|
|
422
|
+
if (prev !== null && prev !== 0) {
|
|
423
|
+
var delta = ((num(raw) - prev) / Math.abs(prev)) * 100
|
|
424
|
+
var goodWhen = tileConfig.deltaGoodWhen || "up"
|
|
425
|
+
var good = goodWhen === "up" ? delta >= 0 : delta <= 0
|
|
426
|
+
var chipClass = Math.abs(delta) < 0.05 ? "flat" : good ? "up" : "down"
|
|
427
|
+
var arrow = Math.abs(delta) < 0.05 ? "→" : delta >= 0 ? "↗" : "↘"
|
|
428
|
+
deltaNode.appendChild(
|
|
429
|
+
el("span", "fmr-trend " + chipClass, arrow + " " + intl(1).format(Math.abs(delta)) + "%")
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
var seen = {}
|
|
437
|
+
tiles.forEach(function (tileConfig) {
|
|
438
|
+
;[tileConfig.query, tileConfig.compareQuery].forEach(function (queryId) {
|
|
439
|
+
if (!queryId || seen[queryId]) return
|
|
440
|
+
seen[queryId] = true
|
|
441
|
+
FM.onRows(queryId, paint)
|
|
442
|
+
})
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// --- charts
|
|
447
|
+
|
|
448
|
+
function mountChart(body, build) {
|
|
449
|
+
var wrap = el("div", "fmr-chart")
|
|
450
|
+
var canvas = document.createElement("canvas")
|
|
451
|
+
wrap.appendChild(canvas)
|
|
452
|
+
body.appendChild(wrap)
|
|
453
|
+
var chart = null
|
|
454
|
+
return function paint(data, note) {
|
|
455
|
+
if (!window.Chart) {
|
|
456
|
+
body.textContent = ""
|
|
457
|
+
body.appendChild(el("p", "fmr-note error", "chart library unavailable"))
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
if (note) {
|
|
461
|
+
wrap.hidden = true
|
|
462
|
+
if (!body.__note) {
|
|
463
|
+
body.__note = note
|
|
464
|
+
body.appendChild(note)
|
|
465
|
+
}
|
|
466
|
+
} else {
|
|
467
|
+
wrap.hidden = false
|
|
468
|
+
if (body.__note) {
|
|
469
|
+
body.removeChild(body.__note)
|
|
470
|
+
body.__note = null
|
|
471
|
+
}
|
|
472
|
+
if (chart) {
|
|
473
|
+
chart.data = data
|
|
474
|
+
chart.update()
|
|
475
|
+
} else {
|
|
476
|
+
chart = new window.Chart(canvas, build(data))
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function renderTimeseries(root, config, base) {
|
|
483
|
+
base = base || ""
|
|
484
|
+
var card = tile(root, config.title, base)
|
|
485
|
+
renderControls(card, config.controls, base)
|
|
486
|
+
var body = part(el("div", "fmr-tile-body"), base + "chart")
|
|
487
|
+
card.appendChild(body)
|
|
488
|
+
var compareQuery = config.compare && (config.compare.query || config.query)
|
|
489
|
+
var paint = mountChart(body, function (data) {
|
|
490
|
+
var ax = axis()
|
|
491
|
+
return {
|
|
492
|
+
type: "line",
|
|
493
|
+
data: data,
|
|
494
|
+
options: Object.assign({}, chartOpts(), {
|
|
495
|
+
scales: {
|
|
496
|
+
x: Object.assign({}, ax, { grid: { display: false } }),
|
|
497
|
+
y: Object.assign({}, ax, {
|
|
498
|
+
// a value axis cropped above zero exaggerates every wiggle into a peak
|
|
499
|
+
beginAtZero: true,
|
|
500
|
+
grid: { color: gridColor() },
|
|
501
|
+
ticks: Object.assign({}, ax.ticks, { callback: abbrev(config.format) })
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
})
|
|
507
|
+
|
|
508
|
+
function refresh() {
|
|
509
|
+
var rows = FM.rows(config.query)
|
|
510
|
+
if (!rows.length && FM.status(config.query) !== "loading") {
|
|
511
|
+
paint(null, emptyNote(config))
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
var labels = rows.map(function (row) {
|
|
515
|
+
var raw = row[config.x]
|
|
516
|
+
if (config.xType === "day") {
|
|
517
|
+
var day = new Date(raw)
|
|
518
|
+
if (!isNaN(day)) {
|
|
519
|
+
return day.toLocaleDateString((window.env && window.env.locale) || "en-MY", {
|
|
520
|
+
month: "short",
|
|
521
|
+
day: "numeric"
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return String(raw)
|
|
526
|
+
})
|
|
527
|
+
// a mixed series is drawn as its own family: bars take grape, lines step through blossom.
|
|
528
|
+
// chart.js draws the order-sorted metasets backwards, so the lower order lands on top —
|
|
529
|
+
// the line has to stay readable where it crosses its own bars.
|
|
530
|
+
var lineIndex = 0
|
|
531
|
+
// few enough days to read one by one: draw the points, or the line is a bare triangle
|
|
532
|
+
var marker = rows.length <= 14 ? 3 : 0
|
|
533
|
+
var datasets = (config.series || []).map(function (series) {
|
|
534
|
+
var data = rows.map(function (row) { return num(row[series.y]) })
|
|
535
|
+
if (series.kind === "bar") {
|
|
536
|
+
return {
|
|
537
|
+
label: series.label,
|
|
538
|
+
type: "bar",
|
|
539
|
+
data: data,
|
|
540
|
+
backgroundColor: barColor(),
|
|
541
|
+
borderWidth: 0,
|
|
542
|
+
maxBarThickness: 20,
|
|
543
|
+
order: 2
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
var color = lineColor(lineIndex++)
|
|
547
|
+
return {
|
|
548
|
+
label: series.label,
|
|
549
|
+
type: "line",
|
|
550
|
+
data: data,
|
|
551
|
+
borderColor: color,
|
|
552
|
+
backgroundColor: color,
|
|
553
|
+
borderWidth: 2,
|
|
554
|
+
tension: 0,
|
|
555
|
+
pointRadius: marker,
|
|
556
|
+
pointHoverRadius: 4,
|
|
557
|
+
fill: false,
|
|
558
|
+
order: 1
|
|
559
|
+
}
|
|
560
|
+
})
|
|
561
|
+
if (config.compare) {
|
|
562
|
+
var compareRows = FM.rows(compareQuery)
|
|
563
|
+
datasets.push({
|
|
564
|
+
label: config.compare.label || "Previous period",
|
|
565
|
+
type: "line",
|
|
566
|
+
// aligned by row index: both windows are the same length by construction
|
|
567
|
+
data: rows.map(function (row, index) {
|
|
568
|
+
var match = compareRows[index]
|
|
569
|
+
return match ? num(match[config.compare.y]) : null
|
|
570
|
+
}),
|
|
571
|
+
borderColor: compareColor(),
|
|
572
|
+
borderDash: [4, 4],
|
|
573
|
+
borderWidth: 2,
|
|
574
|
+
tension: 0,
|
|
575
|
+
pointRadius: marker,
|
|
576
|
+
pointHoverRadius: 4,
|
|
577
|
+
fill: false,
|
|
578
|
+
order: 3
|
|
579
|
+
})
|
|
580
|
+
}
|
|
581
|
+
paint({ labels: labels, datasets: datasets }, null)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
FM.onRows(config.query, refresh)
|
|
585
|
+
if (compareQuery && compareQuery !== config.query) FM.onRows(compareQuery, refresh)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function renderBar(root, config, base) {
|
|
589
|
+
base = base || ""
|
|
590
|
+
var card = tile(root, config.title, base)
|
|
591
|
+
renderControls(card, config.controls, base)
|
|
592
|
+
var body = part(el("div", "fmr-tile-body"), base + "chart")
|
|
593
|
+
card.appendChild(body)
|
|
594
|
+
var current = []
|
|
595
|
+
var total = 0
|
|
596
|
+
// shareDenominator only reads .share, so config doubles as a column
|
|
597
|
+
var denominator = shareDenominator(config)
|
|
598
|
+
var paint = mountChart(body, function (data) {
|
|
599
|
+
var opts = chartOpts()
|
|
600
|
+
var ax = axis()
|
|
601
|
+
return {
|
|
602
|
+
type: "bar",
|
|
603
|
+
data: data,
|
|
604
|
+
options: Object.assign({}, opts, {
|
|
605
|
+
onClick: config.clickSet
|
|
606
|
+
? function (event, elements) {
|
|
607
|
+
if (!elements || !elements.length) return
|
|
608
|
+
var index = elements[0].index
|
|
609
|
+
var row = current[index]
|
|
610
|
+
FM.setParams(resolveSet(config.clickSet, row, row ? row[config.x] : null))
|
|
611
|
+
}
|
|
612
|
+
: undefined,
|
|
613
|
+
plugins: Object.assign({}, opts.plugins, {
|
|
614
|
+
tooltip: config.share
|
|
615
|
+
? Object.assign({}, opts.plugins.tooltip, {
|
|
616
|
+
callbacks: {
|
|
617
|
+
label: function (context) {
|
|
618
|
+
var amount = context.formattedValue
|
|
619
|
+
if (!total) return amount
|
|
620
|
+
var row = current[context.dataIndex]
|
|
621
|
+
var pct = intl(1).format((num(row ? row[config.y] : 0) / total) * 100)
|
|
622
|
+
return amount + " · " + pct + "% of total"
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
})
|
|
626
|
+
: opts.plugins.tooltip
|
|
627
|
+
}),
|
|
628
|
+
scales: {
|
|
629
|
+
x: Object.assign({}, ax, { grid: { display: false } }),
|
|
630
|
+
y: Object.assign({}, ax, {
|
|
631
|
+
// a value axis cropped above zero exaggerates every wiggle into a peak
|
|
632
|
+
beginAtZero: true,
|
|
633
|
+
grid: { color: gridColor() },
|
|
634
|
+
ticks: Object.assign({}, ax.ticks, { callback: abbrev(config.format) })
|
|
635
|
+
})
|
|
636
|
+
}
|
|
637
|
+
})
|
|
638
|
+
}
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
function refresh() {
|
|
642
|
+
var rows = FM.rows(config.query)
|
|
643
|
+
current = rows
|
|
644
|
+
if (config.share) {
|
|
645
|
+
if (denominator) {
|
|
646
|
+
var denominatorRows = FM.rows(denominator.query)
|
|
647
|
+
total = denominatorRows.length ? num(denominatorRows[0][denominator.value]) : 0
|
|
648
|
+
} else {
|
|
649
|
+
total = rows.reduce(function (sum, row) { return sum + num(row[config.y]) }, 0)
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (!rows.length && FM.status(config.query) !== "loading") {
|
|
653
|
+
paint(null, emptyNote(config))
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
paint(
|
|
657
|
+
{
|
|
658
|
+
labels: rows.map(function (row) {
|
|
659
|
+
var label = String(row[config.x])
|
|
660
|
+
if (!config.share || !total) return label
|
|
661
|
+
return label + " · " + intl(1).format((num(row[config.y]) / total) * 100) + "%"
|
|
662
|
+
}),
|
|
663
|
+
datasets: [
|
|
664
|
+
{
|
|
665
|
+
data: rows.map(function (row) { return num(row[config.y]) }),
|
|
666
|
+
backgroundColor: barColor(),
|
|
667
|
+
maxBarThickness: 20
|
|
668
|
+
}
|
|
669
|
+
]
|
|
670
|
+
},
|
|
671
|
+
null
|
|
672
|
+
)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
FM.onRows(config.query, refresh)
|
|
676
|
+
if (denominator && denominator.query !== config.query) FM.onRows(denominator.query, refresh)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// --- table: FM.ui.table plus header sorting and a client-side share column
|
|
680
|
+
|
|
681
|
+
function shareDenominator(column) {
|
|
682
|
+
var denominator = column.share && column.share.denominator
|
|
683
|
+
return denominator && typeof denominator === "object" ? denominator : null
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function renderTable(root, config, base) {
|
|
687
|
+
base = base || ""
|
|
688
|
+
var card = tile(root, config.title, base)
|
|
689
|
+
renderControls(card, config.controls, base)
|
|
690
|
+
if (config.caption)
|
|
691
|
+
card.appendChild(part(el("p", "fmr-tile-note", config.caption), base + "caption"))
|
|
692
|
+
var mount = part(el("div"), base + "table")
|
|
693
|
+
card.appendChild(mount)
|
|
694
|
+
|
|
695
|
+
var sortKey = config.sort ? config.sort.key : null
|
|
696
|
+
var sortDir = config.sort ? config.sort.dir : "desc"
|
|
697
|
+
var shareTotals = {}
|
|
698
|
+
|
|
699
|
+
var columns = []
|
|
700
|
+
;(config.columns || []).forEach(function (column, index) {
|
|
701
|
+
var format = column.format
|
|
702
|
+
columns.push({
|
|
703
|
+
part: base + "columns." + index,
|
|
704
|
+
key: column.key,
|
|
705
|
+
label: column.label || column.key,
|
|
706
|
+
align: column.align || (format && format !== "text" && format !== "datetime" && format !== "image" ? "right" : "left"),
|
|
707
|
+
dim: column.dim,
|
|
708
|
+
image: column.image,
|
|
709
|
+
format: format === "image" ? "image" : function (value) { return formatValue(format, value) }
|
|
710
|
+
})
|
|
711
|
+
if (column.share) {
|
|
712
|
+
columns.push({
|
|
713
|
+
part: base + "columns." + index + ".share",
|
|
714
|
+
key: column.key,
|
|
715
|
+
label: "Share",
|
|
716
|
+
align: "right",
|
|
717
|
+
dim: true,
|
|
718
|
+
format: function (value, row) {
|
|
719
|
+
var total = shareTotals[column.key]
|
|
720
|
+
var denominator = shareDenominator(column)
|
|
721
|
+
if (denominator && denominator.key) total = total ? total[String(row[denominator.key])] : 0
|
|
722
|
+
return total ? intl(1).format((num(value) / total) * 100) + "%" : "—"
|
|
723
|
+
}
|
|
724
|
+
})
|
|
725
|
+
}
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
var control = FM.ui.table(mount, {
|
|
729
|
+
columns: columns,
|
|
730
|
+
rows: [],
|
|
731
|
+
pageSize: config.pageSize === undefined ? 20 : config.pageSize,
|
|
732
|
+
empty: config.empty || "No rows in this period.",
|
|
733
|
+
onRowClick: config.rowClickSet
|
|
734
|
+
? function (row) { FM.setParams(resolveSet(config.rowClickSet, row, null)) }
|
|
735
|
+
: undefined
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
function ordered() {
|
|
739
|
+
var rows = FM.rows(config.query).slice()
|
|
740
|
+
;(config.columns || []).forEach(function (column) {
|
|
741
|
+
if (!column.share) return
|
|
742
|
+
var denominator = shareDenominator(column)
|
|
743
|
+
if (denominator && denominator.key) {
|
|
744
|
+
var byKey = {}
|
|
745
|
+
FM.rows(denominator.query).forEach(function (row) {
|
|
746
|
+
byKey[String(row[denominator.key])] = num(row[denominator.value])
|
|
747
|
+
})
|
|
748
|
+
shareTotals[column.key] = byKey
|
|
749
|
+
} else if (denominator) {
|
|
750
|
+
var denominatorRows = FM.rows(denominator.query)
|
|
751
|
+
shareTotals[column.key] = denominatorRows.length
|
|
752
|
+
? num(denominatorRows[0][denominator.value])
|
|
753
|
+
: 0
|
|
754
|
+
} else {
|
|
755
|
+
shareTotals[column.key] = rows.reduce(function (sum, row) { return sum + num(row[column.key]) }, 0)
|
|
756
|
+
}
|
|
757
|
+
})
|
|
758
|
+
if (!sortKey) return rows
|
|
759
|
+
rows.sort(function (a, b) {
|
|
760
|
+
var left = a[sortKey]
|
|
761
|
+
var right = b[sortKey]
|
|
762
|
+
var ln = Number(left)
|
|
763
|
+
var rn = Number(right)
|
|
764
|
+
var compared =
|
|
765
|
+
isFinite(ln) && isFinite(rn) ? ln - rn : String(left ?? "").localeCompare(String(right ?? ""))
|
|
766
|
+
return sortDir === "asc" ? compared : -compared
|
|
767
|
+
})
|
|
768
|
+
return rows
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// header click cycles the sort; thead persists across setRows, so bind once
|
|
772
|
+
var headers = control.element.querySelectorAll("th")
|
|
773
|
+
Array.prototype.forEach.call(headers, function (th, index) {
|
|
774
|
+
var column = columns[index]
|
|
775
|
+
if (!column) return
|
|
776
|
+
th.style.cursor = "pointer"
|
|
777
|
+
th.addEventListener("click", function () {
|
|
778
|
+
if (sortKey === column.key) sortDir = sortDir === "desc" ? "asc" : "desc"
|
|
779
|
+
else {
|
|
780
|
+
sortKey = column.key
|
|
781
|
+
sortDir = "desc"
|
|
782
|
+
}
|
|
783
|
+
Array.prototype.forEach.call(headers, function (other) { other.removeAttribute("aria-sort") })
|
|
784
|
+
th.setAttribute("aria-sort", sortDir === "asc" ? "ascending" : "descending")
|
|
785
|
+
control.setRows(ordered())
|
|
786
|
+
})
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
var subscribed = {}
|
|
790
|
+
function subscribe(queryId) {
|
|
791
|
+
if (!queryId || subscribed[queryId]) return
|
|
792
|
+
subscribed[queryId] = true
|
|
793
|
+
FM.onRows(queryId, function () { control.setRows(ordered()) })
|
|
794
|
+
}
|
|
795
|
+
subscribe(config.query)
|
|
796
|
+
;(config.columns || []).forEach(function (column) {
|
|
797
|
+
var denominator = shareDenominator(column)
|
|
798
|
+
if (denominator) subscribe(denominator.query)
|
|
799
|
+
})
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// --- detail-panel: a dialog over the report while its param is set; closing writes null back
|
|
803
|
+
|
|
804
|
+
function renderDetailPanel(root, config, base) {
|
|
805
|
+
base = base || ""
|
|
806
|
+
var host = el("div")
|
|
807
|
+
root.appendChild(host)
|
|
808
|
+
var panel = FM.ui.dialog(host, {
|
|
809
|
+
title: config.title || "",
|
|
810
|
+
subtitle: config.title || "",
|
|
811
|
+
onClose: function () {
|
|
812
|
+
// an external clear already closed it; writing null again would be a second params push
|
|
813
|
+
if (!FM.params()[config.showWhen]) return
|
|
814
|
+
var patch = {}
|
|
815
|
+
patch[config.showWhen] = null
|
|
816
|
+
FM.setParams(patch)
|
|
817
|
+
}
|
|
818
|
+
})
|
|
819
|
+
part(panel.element.querySelector(".fm-dlg-title"), base + "title")
|
|
820
|
+
var body = el("div")
|
|
821
|
+
panel.body.appendChild(body)
|
|
822
|
+
|
|
823
|
+
var renderers = { timeseries: renderTimeseries, table: renderTable, bar: renderBar }
|
|
824
|
+
var mounted = false
|
|
825
|
+
|
|
826
|
+
// titleLookup swaps a raw drill id for the matching row's text column
|
|
827
|
+
function titleValue(value) {
|
|
828
|
+
var lookup = config.titleLookup
|
|
829
|
+
if (lookup) {
|
|
830
|
+
var rows = FM.rows(lookup.query)
|
|
831
|
+
for (var i = 0; i < rows.length; i++) {
|
|
832
|
+
if (String(rows[i][lookup.value]) === String(value)) {
|
|
833
|
+
var text = rows[i][lookup.text]
|
|
834
|
+
if (text !== null && text !== undefined && text !== "") return String(text)
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return String(value)
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function sync() {
|
|
842
|
+
var value = FM.params()[config.showWhen]
|
|
843
|
+
var open = value !== null && value !== undefined && value !== ""
|
|
844
|
+
if (!open) {
|
|
845
|
+
panel.close()
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
// the entity the viewer clicked is the headline; the panel's own title says what it shows
|
|
849
|
+
panel.setTitle(titleValue(value))
|
|
850
|
+
panel.setSubtitle(config.title || "")
|
|
851
|
+
if (!mounted) {
|
|
852
|
+
mounted = true
|
|
853
|
+
renderers[config.body.component](body, config.body.config, base + "body.config.")
|
|
854
|
+
}
|
|
855
|
+
panel.open()
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// the body's own subscription re-fires on every params push, which drives visibility;
|
|
859
|
+
// mounting the body up front would subscribe outside the frame's mount attribution
|
|
860
|
+
var probe = config.body && config.body.config ? config.body.config.query : null
|
|
861
|
+
if (probe) {
|
|
862
|
+
mounted = true
|
|
863
|
+
renderers[config.body.component](body, config.body.config, base + "body.config.")
|
|
864
|
+
FM.onRows(probe, sync)
|
|
865
|
+
}
|
|
866
|
+
if (config.titleLookup) FM.onRows(config.titleLookup.query, sync)
|
|
867
|
+
sync()
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// --- heatmap: day-of-week × hour grid on one hue; grey where no row; its own legend
|
|
871
|
+
var HEATMAP_DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
|
872
|
+
var HEAT_BANDS = [0.14, 0.32, 0.54, 0.78, 1]
|
|
873
|
+
var NO_DATA = "var(--fm-border-default, #f0f0f0)"
|
|
874
|
+
function rgba(hex, alpha) {
|
|
875
|
+
var h = hex.replace("#", "")
|
|
876
|
+
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]
|
|
877
|
+
var n = parseInt(h, 16)
|
|
878
|
+
if (!isFinite(n)) return hex
|
|
879
|
+
return "rgba(" + ((n >> 16) & 255) + ", " + ((n >> 8) & 255) + ", " + (n & 255) + ", " + alpha + ")"
|
|
880
|
+
}
|
|
881
|
+
function heatBand(t) {
|
|
882
|
+
var clamped = Math.max(0, Math.min(1, t))
|
|
883
|
+
return HEAT_BANDS[Math.min(HEAT_BANDS.length - 1, Math.floor(clamped * HEAT_BANDS.length))]
|
|
884
|
+
}
|
|
885
|
+
function heatShade(t) {
|
|
886
|
+
return rgba(token("--fm-action-primary-bg", "#ff7823"), heatBand(t))
|
|
887
|
+
}
|
|
888
|
+
function heatLabel(text, align) {
|
|
889
|
+
var node = el("div", "fmr-heatmap-label", text)
|
|
890
|
+
node.style.fontSize = "12px"
|
|
891
|
+
node.style.color = "var(--fm-text-muted, #6b6c70)"
|
|
892
|
+
node.style.textAlign = align
|
|
893
|
+
node.style.padding = "0 4px"
|
|
894
|
+
return node
|
|
895
|
+
}
|
|
896
|
+
function heatSwatch(background) {
|
|
897
|
+
var node = el("span", "fmr-heatmap-swatch")
|
|
898
|
+
node.style.display = "inline-block"
|
|
899
|
+
node.style.width = "14px"
|
|
900
|
+
node.style.height = "14px"
|
|
901
|
+
node.style.borderRadius = "var(--fm-radius-xs, 4px)"
|
|
902
|
+
node.style.background = background
|
|
903
|
+
return node
|
|
904
|
+
}
|
|
905
|
+
function heatLegend() {
|
|
906
|
+
var row = el("div", "fmr-heatmap-legend")
|
|
907
|
+
row.style.display = "flex"
|
|
908
|
+
row.style.alignItems = "center"
|
|
909
|
+
row.style.gap = "6px"
|
|
910
|
+
row.style.marginTop = "10px"
|
|
911
|
+
row.style.fontSize = "12px"
|
|
912
|
+
row.style.color = "var(--fm-text-muted, #6b6c70)"
|
|
913
|
+
row.appendChild(el("span", null, "Quieter"))
|
|
914
|
+
HEAT_BANDS.forEach(function (alpha) { row.appendChild(heatSwatch(rgba(token("--fm-action-primary-bg", "#ff7823"), alpha))) })
|
|
915
|
+
row.appendChild(el("span", null, "Busier"))
|
|
916
|
+
var gap = el("span")
|
|
917
|
+
gap.style.width = "12px"
|
|
918
|
+
row.appendChild(gap)
|
|
919
|
+
row.appendChild(heatSwatch(NO_DATA))
|
|
920
|
+
row.appendChild(el("span", null, "No data"))
|
|
921
|
+
return row
|
|
922
|
+
}
|
|
923
|
+
function renderHeatmap(root, config, base) {
|
|
924
|
+
base = base || ""
|
|
925
|
+
var card = tile(root, config.title, base)
|
|
926
|
+
renderControls(card, config.controls, base)
|
|
927
|
+
var body = part(el("div", "fmr-tile-body"), base + "chart")
|
|
928
|
+
card.appendChild(body)
|
|
929
|
+
var days = config.weekStart === "sunday" ? [0, 1, 2, 3, 4, 5, 6] : [1, 2, 3, 4, 5, 6, 0]
|
|
930
|
+
var format = config.format || "amount"
|
|
931
|
+
function refresh() {
|
|
932
|
+
body.innerHTML = ""
|
|
933
|
+
var rows = FM.rows(config.query)
|
|
934
|
+
if (!rows.length && FM.status(config.query) !== "loading") {
|
|
935
|
+
body.appendChild(emptyNote(config))
|
|
936
|
+
return
|
|
937
|
+
}
|
|
938
|
+
var cells = {}
|
|
939
|
+
var totals = {}
|
|
940
|
+
var max = 0
|
|
941
|
+
rows.forEach(function (row) {
|
|
942
|
+
var d = ((num(row[config.y]) % 7) + 7) % 7
|
|
943
|
+
var key = d + ":" + num(row[config.x])
|
|
944
|
+
cells[key] = (cells[key] || 0) + num(row[config.value])
|
|
945
|
+
totals[d] = (totals[d] || 0) + num(row[config.value])
|
|
946
|
+
if (cells[key] > max) max = cells[key]
|
|
947
|
+
})
|
|
948
|
+
var grid = el("div", "fmr-heatmap")
|
|
949
|
+
grid.style.display = "grid"
|
|
950
|
+
grid.style.gridTemplateColumns = "44px repeat(24, minmax(0, 1fr))" + (config.rowTotals ? " 88px" : "")
|
|
951
|
+
grid.style.gap = "2px"
|
|
952
|
+
grid.style.alignItems = "center"
|
|
953
|
+
grid.appendChild(el("div"))
|
|
954
|
+
for (var h = 0; h < 24; h++) grid.appendChild(heatLabel(String(h), "center"))
|
|
955
|
+
if (config.rowTotals) grid.appendChild(heatLabel("Total", "right"))
|
|
956
|
+
days.forEach(function (d) {
|
|
957
|
+
grid.appendChild(heatLabel(HEATMAP_DAYS[d], "left"))
|
|
958
|
+
for (var hh = 0; hh < 24; hh++) {
|
|
959
|
+
var value = cells[d + ":" + hh]
|
|
960
|
+
var cell = el("div", "fmr-heatmap-cell")
|
|
961
|
+
cell.style.minHeight = "26px"
|
|
962
|
+
cell.style.borderRadius = "var(--fm-radius-xs, 4px)"
|
|
963
|
+
if (value === undefined) {
|
|
964
|
+
cell.className += " fmr-heatmap-cell-empty"
|
|
965
|
+
cell.style.background = NO_DATA
|
|
966
|
+
cell.title = HEATMAP_DAYS[d] + " " + hh + ":00 — no data"
|
|
967
|
+
} else {
|
|
968
|
+
cell.style.background = heatShade(max ? value / max : 0)
|
|
969
|
+
cell.title = HEATMAP_DAYS[d] + " " + hh + ":00 — " + formatValue(format, value)
|
|
970
|
+
}
|
|
971
|
+
grid.appendChild(cell)
|
|
972
|
+
}
|
|
973
|
+
if (config.rowTotals) grid.appendChild(heatLabel(formatValue(format, totals[d] || 0), "right"))
|
|
974
|
+
})
|
|
975
|
+
body.appendChild(grid)
|
|
976
|
+
body.appendChild(heatLegend())
|
|
977
|
+
}
|
|
978
|
+
FM.onRows(config.query, refresh)
|
|
979
|
+
refresh()
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* The head of a page the viewer navigated into. It names the entity the page is about and
|
|
984
|
+
* carries the one click back — the page's own panels are the sections sharing its showWhen.
|
|
985
|
+
*/
|
|
986
|
+
function renderPageHeader(root, config, base) {
|
|
987
|
+
base = base || ""
|
|
988
|
+
var head = el("div", "fmr-page-head")
|
|
989
|
+
var back = part(el("button", "fmr-back", config.backLabel || "Back"), base + "back")
|
|
990
|
+
back.type = "button"
|
|
991
|
+
back.addEventListener("click", function () { FM.setParams(config.back) })
|
|
992
|
+
var title = part(el("h2", "fmr-tile-title", config.title || ""), base + "title")
|
|
993
|
+
head.appendChild(back)
|
|
994
|
+
head.appendChild(title)
|
|
995
|
+
root.appendChild(head)
|
|
996
|
+
|
|
997
|
+
// titleLookup swaps a raw drill id for the matching row's text column
|
|
998
|
+
function titleValue(value) {
|
|
999
|
+
var lookup = config.titleLookup
|
|
1000
|
+
if (lookup) {
|
|
1001
|
+
var rows = FM.rows(lookup.query)
|
|
1002
|
+
for (var i = 0; i < rows.length; i++) {
|
|
1003
|
+
if (String(rows[i][lookup.value]) === String(value)) {
|
|
1004
|
+
var text = rows[i][lookup.text]
|
|
1005
|
+
if (text !== null && text !== undefined && text !== "") return String(text)
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
return String(value)
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function sync() {
|
|
1013
|
+
var value = FM.params()[config.showWhen]
|
|
1014
|
+
if (value === null || value === undefined || value === "") return
|
|
1015
|
+
var shown = titleValue(value)
|
|
1016
|
+
title.textContent = config.title ? config.title + " — " + shown : shown
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
FM.onParams([config.showWhen], sync)
|
|
1020
|
+
if (config.titleLookup) FM.onRows(config.titleLookup.query, sync)
|
|
1021
|
+
sync()
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
var RENDERERS = {
|
|
1025
|
+
"filter-bar": renderFilterBar,
|
|
1026
|
+
"kpi-row": renderKpiRow,
|
|
1027
|
+
timeseries: renderTimeseries,
|
|
1028
|
+
bar: renderBar,
|
|
1029
|
+
table: renderTable,
|
|
1030
|
+
heatmap: renderHeatmap,
|
|
1031
|
+
"detail-panel": renderDetailPanel,
|
|
1032
|
+
"page-header": renderPageHeader
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
FM.components = {
|
|
1036
|
+
render: function (el, section) {
|
|
1037
|
+
var renderer = RENDERERS[section.component]
|
|
1038
|
+
if (!renderer) throw new Error('unknown component "' + section.component + '"')
|
|
1039
|
+
renderer(el, section.config || {}, "")
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
})()
|