From 92db3be27e89ea449ceb580bf906e2ffcec5dd2a Mon Sep 17 00:00:00 2001 From: Rich Lott / Artful Robot Date: Tue, 25 Mar 2025 20:21:32 +0000 Subject: [PATCH] work on visualisation --- CRM/Contactcats/Page/Flows.php | 15 ++ Civi/Api4/Action/ContactCategory/GetFlows.php | 60 +++-- ang/crmContactcats.js | 215 ++++++++++++++++++ .../crmContactCategoryFlows.html | 86 +++++++ ang/crmSearchDisplayContactCat.js | 3 - contactcats.php | 8 + templates/CRM/Contactcats/Page/Flows.tpl | 3 + xml/Menu/contactcats.xml | 6 + 8 files changed, 370 insertions(+), 26 deletions(-) create mode 100644 CRM/Contactcats/Page/Flows.php create mode 100644 ang/crmContactcats/crmContactCategoryFlows.html create mode 100644 templates/CRM/Contactcats/Page/Flows.tpl diff --git a/CRM/Contactcats/Page/Flows.php b/CRM/Contactcats/Page/Flows.php new file mode 100644 index 0000000..bf8a2ef --- /dev/null +++ b/CRM/Contactcats/Page/Flows.php @@ -0,0 +1,15 @@ +assign('currentTime', date('Y-m-d H:i:s')); + Civi::service('angularjs.loader')->addModules('crmContactcats'); + parent::run(); + } + +} diff --git a/Civi/Api4/Action/ContactCategory/GetFlows.php b/Civi/Api4/Action/ContactCategory/GetFlows.php index 2a9d98f..82e88b8 100644 --- a/Civi/Api4/Action/ContactCategory/GetFlows.php +++ b/Civi/Api4/Action/ContactCategory/GetFlows.php @@ -40,20 +40,27 @@ class GetFlows extends \Civi\Api4\Generic\AbstractAction { // occurred *on* the given date. $getValidDate = fn(?string $input) => $input ? (new DateTimeImmutable($input))->modify('+1 day')->format('Ymd'): FALSE; + $endDate = NULL; $startDate = $getValidDate($this->startDate); if (!$startDate) { throw new CRM_Core_Exception(E::ts("Cannot parse given start date.")); } - $endDate = $getValidDate($this->endDate); - if (!$endDate) { - throw new CRM_Core_Exception(E::ts("Cannot parse given end date.")); + if (!empty($this->endDate)) { + $endDate = $getValidDate($this->endDate); + if (!$endDate) { + throw new CRM_Core_Exception(E::ts("Cannot parse given end date.")); + } + if (!($endDate > $startDate)) { + throw new CRM_Core_Exception(E::ts("This version of CiviCRM does not support twisting the space time continuum. End date cannot be before start date.")); + } } - if (!($endDate > $startDate)) { - throw new CRM_Core_Exception(E::ts("This version of CiviCRM does not support twisting the space time continuum. End date cannot be before start date.")); + else if ($startDate > date('Ymd', strtotime('tomorrow'))) { + throw new CRM_Core_Exception(E::ts("This version of CiviCRM does not support predicting the future. Start time cannot be in the future.")); } - $result['windowFunctionsSupported'] = $this->windowFunctionsSupported(); - if ($result['windowFunctionsSupported']) { + // $result['windowFunctionsSupported'] = $this->windowFunctionsSupported(); + // if ($result['windowFunctionsSupported']) { + if ($this->windowFunctionsSupported()) { $this->solveWithWindowFunctions($result, $startDate, $endDate); } else { @@ -98,7 +105,7 @@ class GetFlows extends \Civi\Api4\Generic\AbstractAction { /** * */ - protected function solveWithWindowFunctions(Result $result, string $startDateYmd, string $endDateYmd) { + protected function solveWithWindowFunctions(Result $result, string $startDateYmd, ?string $endDateYmd) { $sql = << [$endDateYmd, 'Int'], - 2 => [$startDateYmd, 'Int'], + 1 => [$startDateYmd, 'Int'], + 2 => [$endDateYmd, 'Int'], ])->fetchAll(); // Don't use exchange array so as not to gazump non-array data(?) foreach ($data as $row) { - $result[] = $row; + $result[] = [ + 'from_category_id' => (int) $row['new_category_id'], + 'to_category_id' => (int) $row['to_category_id'], + 'contact_count' => (int) $row['contact_count'], + ]; } } diff --git a/ang/crmContactcats.js b/ang/crmContactcats.js index b85fbb8..b812780 100644 --- a/ang/crmContactcats.js +++ b/ang/crmContactcats.js @@ -1,6 +1,221 @@ (function(angular, $, _) { // Declare a list of dependencies. angular.module("crmContactcats", CRM.angRequires("crmContactcats")); + + angular.module("crmContactcats").component("crmContactCategoryFlows", { + templateUrl: "~/crmContactcats/crmContactCategoryFlows.html", + bindings: { + // things listed here become properties on the controller using + // values from attributes. @ means a fixed string is passed + // e.g. + // token: '@', + // & is special. In the child, call ctrl.onMyAction with an + // Object whose keys provide parameter names for the code in the parent. + // onMyAction: '&', + // '<' means one-way binding (parent»child) and I think is what you are + // supposed to use for components. + // v: '<' + }, + controller: function($scope, $timeout, crmApi4, crmStatus, $document) { + var ts = ($scope.ts = CRM.ts(null)), + ctrl = this; + + // function prepareSankey(analysis) { + // // analysis is like: + // // { string : { + // // sources: { + // // string : int + // // }, + // // now: int , previous: int + // // }} + // + // // Snakes are vertically positioned cumulatively. + // const snakes = ctrl.catDefs.forEach((cat, i) => { + // + // + // }); + // + // + // // Flatten to single array, then sort. + // let allLabels = new Set(); + // let maxPerLabel = 0; + // let snakes = []; + // console.log('analy', analysis); + // + // Object.entries(analysis).forEach(([newLabel, data]) => { + // console.log({newLabel, data}); + // allLabels.add(newLabel); + // maxPerLabel = Math.max(maxPerLabel, data.now, data.previous); + // Object.entries(data.sources).forEach(([previousLabel, n]) => { + // allLabels.add(previousLabel); + // snakes.push({ previousLabel, n, newLabel }); + // }); + // }); + // // Get sorted list of labels for our axis + // allLabels = Array.from(allLabels); + // allLabels.sort(labelSort); + // console.log(allLabels); + // + // // Sort the snakes + // snakes.sort((a, b) => { + // // We want retentions first. + // if (a.previousLabel === a.newLabel && b.previousLabel !== b.newLabel) return -1; + // if (a.previousLabel !== a.newLabel && b.previousLabel === b.newLabel) return 1; + // // Next we want destination labels + // let c = allLabels.indexOf(b.newLabel) - allLabels.indexOf(a.newLabel); + // if (c !== 0) return c; + // c = allLabels.indexOf(a.previousLabel) - allLabels.indexOf(b.previousLabel); + // if (c !== 0) return c; + // // By size. + // return b.n - a.n; + // }); + // + // const labelWidth = 150, + // width = Math.max(600, document.getElementById('rfm-width').firstElementChild.clientWidth), + // domainWidth = width - 2*labelWidth; + // ctrl.sankey = { + // height: Math.min(600, allLabels.length * 100), /* allow up to 100px per label */ + // width, + // labelWidth, + // rows: snakes + // }; + // + // const heightPerLabel = Math.floor(ctrl.sankey.height / allLabels.length); + // const padding = 24; + // const yScale = (heightPerLabel - padding) / maxPerLabel; + // const labelYs = {}; + // allLabels.forEach((label, i) => { + // const y = i*heightPerLabel; + // labelYs[label] = { initial: y, previous: y, now: y }; + // }); + // ctrl.sankey.labels = allLabels.map(label => ({label: label || '(Uncategorised)', y: labelYs[label].initial})); + // console.log("Sankey", {heightPerLabel, padding, yScale, labelYs, width: ctrl.sankey.width, snakes, allLabels}); + // + // let g=1; // gradient ID + // ctrl.sankey.rows.forEach(row => { + // let yPrevious = labelYs[row.previousLabel].previous, + // h = yScale * row.n, + // yNow = labelYs[row.newLabel].now; + // labelYs[row.previousLabel].previous += h; + // labelYs[row.newLabel].now += h; + // // Create path + // row.d = `M${labelWidth},${yPrevious} l16,0 + // C ${labelWidth + domainWidth/4},${yPrevious} ${labelWidth + domainWidth*3/4},${yNow} ${ctrl.sankey.width - labelWidth -16},${yNow} + // l16,0 + // l0,${h} + // l-16,0 + // C ${labelWidth + domainWidth*3/4},${yNow+h} ${labelWidth + domainWidth/4},${yPrevious+h} ${labelWidth+16},${yPrevious + h} + // l-16,0 z`; + // row.fill = `hsl(${ctrl.getHueFromLabel(row.previousLabel)}, 50%, 50%)`; + // row.fill = `url(#g${g})`; + // row.gradient = {id: 'g' + g++, + // to: `hsl(${ctrl.getHueFromLabel(row.previousLabel)}, 50%, 50%)`, + // from: `hsl(${ctrl.getHueFromLabel(row.newLabel)}, 50%, 50%)`, + // }; + // }); + // + // // console.log("Sankey", {sankey: ctrl.sankey}); + // } + + // this.$onInit gets run after the this controller is called, and after the bindings have been applied. + this.$onInit = async function() { + // Default start date to 1 month ago. + ctrl.loading = true; + ctrl.startDate = new Date(); + ctrl.startDate.setMonth(ctrl.startDate.getMonth() - 1); + ctrl.startDate.setHours(0); + ctrl.startDate.setMinutes(0); + ctrl.startDate.setSeconds(0); + ctrl.startDate = new Date('2025-03-10'); + ctrl.endDate = null; + ctrl.stats = null; + ctrl.catDefs = await crmStatus({ start: ts('Loading...'), success: ''}, + crmApi4("ContactCategoryDefinition", 'get', { orderBy: { presentation_order: 'ASC' }, withLabels: true }) + ); + ctrl.loading = false; + console.log(ctrl.catDefs); + + ctrl.fetchStats = function() { + ctrl.loading = true; + crmApi4('ContactCategory', 'getFlows', { + startDate: ctrl.startDate.toISOString().substring(0, 10), + endDate: ctrl.endDate ? ctrl.endDate.toISOString().substring(0, 10) : '', + }).then(r => { + // Process the results into something useful. + ctrl.loading = false; + console.log("results", r); + + if (!(r.length)) { + ctrl.noStats = true; + ctrl.stats = []; + return; + } + ctrl.noStats = false; + + // Create main analysis structure, indexed by right hand side of snakes. + const analysis = {}, offsetWithinSourcesByCat = {}; + ctrl.catDefs.forEach(cat => { + analysis[cat.id] = { sources: [], now: 0, previous: 0, cat }; + offsetWithinSourcesByCat[cat.id] = 0; + }); + + let totalContacts = 0; + r.forEach(row => { + const {from_category_id, to_category_id, contact_count} = row; + analysis[to_category_id].now += contact_count; + if (!from_category_id) { + // from nowhere: ignore for now. + console.log("Skipping", row); + return; + } + analysis[from_category_id].previous += contact_count; + analysis[to_category_id].sources.push({ cat: ctrl.catDefs[from_category_id], contact_count }); + + totalContacts += analysis[to_category_id].now; + }); + + // allowing 50px per cat + let scale = (ctrl.catDefs.length * 50) / totalContacts; + + // Make sure that our 'from' snake ends are ordered by category presentation_order + // This avoids one categories snakes overlapping its others. + ctrl.catDefs.forEach(cat => { + analysis[cat.id].sources.sort((a, b) => { + return a.cat.presentation_order - b.cat.presentation_order; + }) + }); + + // Make a list of snakes we need to draw. + const snakes = []; + let yTopR = 0; + ctrl.catDefs.forEach(cat => { + let toCat = analysis[cat.id]; + toCat.yTop = yTopR; + toCat.sources.forEach(source => { + snakes.push({ + toTop : yTopR, + toHeight : source.contact_count * scale, + toCat, + fromTopOffset: offsetWithinSourcesByCat[source.cat.id], + fromHeight: source.contact_count * scale, + fromCat: source.cat + }); + offsetWithinSourcesByCat[source.cat.id] += source.contact_count * scale; + yTopR += source.contact_count * scale; + }); + yTopR += 8;// padding + }); + + ctrl.snakes = snakes; + console.log({snakes, catDefs: ctrl.catDefs, analysis}); + }); + } + // do initial fetch. + ctrl.fetchStats(); + }; + }, + }); + angular.module("crmContactcats").component("crmContactCategorySettings", { templateUrl: "~/crmContactcats/crmContactCategorySettings.html", bindings: { diff --git a/ang/crmContactcats/crmContactCategoryFlows.html b/ang/crmContactcats/crmContactCategoryFlows.html new file mode 100644 index 0000000..cfa6c7c --- /dev/null +++ b/ang/crmContactcats/crmContactCategoryFlows.html @@ -0,0 +1,86 @@ +
+
+
+ + DAte: {{$ctrl.startDate}} +
+
+ +
+
+ +
+
+

{{ts('No results')}}

+ +
+ + + + + + + + + + {{p.n}} {{p.previousLabel}} → {{p.newLabel}} + + + {{label.label}} + {{label.label}} + + + + + + + +
+ +
+
+
+ + diff --git a/ang/crmSearchDisplayContactCat.js b/ang/crmSearchDisplayContactCat.js index 34c9e6c..4568e31 100644 --- a/ang/crmSearchDisplayContactCat.js +++ b/ang/crmSearchDisplayContactCat.js @@ -1,11 +1,8 @@ -console.log('crmSearchDisplayContactCat.js loaded'); // see https://docs.civicrm.org/dev/en/latest/searchkit/displays/ (function(angular, $, _) { // Declare a list of dependencies. angular.module('crmSearchDisplayContactCat', CRM.angRequires('crmSearchDisplayContactCat')); - // angular.module('crmContactcats').component(); - console.log('crmSearchDisplayContactCat module registered'); // This is to be the display // NOTE: the component name is {CamelCaseNameFromOptionValue} // Standard seems to be to name crmSearchDisplay{YourName} diff --git a/contactcats.php b/contactcats.php index abcb002..18ad3a2 100644 --- a/contactcats.php +++ b/contactcats.php @@ -45,5 +45,13 @@ function contactcats_civicrm_navigationMenu(&$menu) { 'operator' => 'OR', 'separator' => 0, ]); + _contactcats_civix_insert_navigation_menu($menu, 'Reports', [ + 'label' => E::ts('Contact Category Flows'), + 'name' => 'contact_category_flows', + 'url' => 'civicrm/reports/contact-category-flows', + 'permission' => 'access CiviCRM', + 'operator' => 'OR', + 'separator' => 0, + ]); _contactcats_civix_navigationMenu($menu); } diff --git a/templates/CRM/Contactcats/Page/Flows.tpl b/templates/CRM/Contactcats/Page/Flows.tpl new file mode 100644 index 0000000..f5a48d8 --- /dev/null +++ b/templates/CRM/Contactcats/Page/Flows.tpl @@ -0,0 +1,3 @@ + + + diff --git a/xml/Menu/contactcats.xml b/xml/Menu/contactcats.xml index 9507849..f698ec7 100644 --- a/xml/Menu/contactcats.xml +++ b/xml/Menu/contactcats.xml @@ -6,4 +6,10 @@ Settings access CiviCRM + + civicrm/reports/contact-category-flows + CRM_Contactcats_Page_Flows + Category Flows + access CiviCRM +