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