+
diff --git a/ang/crmContactcats/crmContactCategoryFlows.js b/ang/crmContactcats/crmContactCategoryFlows.js
new file mode 100644
index 0000000..3eae11c
--- /dev/null
+++ b/ang/crmContactcats/crmContactCategoryFlows.js
@@ -0,0 +1,173 @@
+(function(angular, $, _) {
+ angular.module("crmContactcats").component("crmContactCategoryFlows", {
+ templateUrl: "~/crmContactcats/crmContactCategoryFlows.html",
+ controller: function($scope, $timeout, crmApi4, crmStatus, $document) {
+ var ts = ($scope.ts = CRM.ts(null)),
+ ctrl = this;
+
+ const catDefsIndexed = {};
+
+ // Development: set this true to use a small defined fixture instead of real data.
+ const useTestFixtures = false;
+
+ // 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);
+ if (useTestFixtures) {
+ ctrl.startDate = new Date('2025-03-10');
+ }
+ ctrl.endDate = null;
+ ctrl.stats = null;
+ ctrl.sankey = { height: 500, width: 900, labelWidth: 150, snakeWidth: 600, snakes: [] };
+
+ if (useTestFixtures) {
+ ctrl.catDefs = [
+ { id: 100, name: 'Amazing', color:'#4ad926' },
+ { id: 101, name: 'Meh', color:'#d52a80' },
+ ];
+ }
+ else {
+ ctrl.catDefs = await crmStatus({ start: ts('Loading...'), success: ''},
+ crmApi4("ContactCategoryDefinition", 'get', { orderBy: { presentation_order: 'ASC' }, withLabels: true })
+ );
+ }
+ ctrl.loading = false;
+ ctrl.catDefs.forEach(cat => {catDefsIndexed[cat.id] = cat;});
+ console.log(catDefsIndexed);
+
+ ctrl.fetchStats = function() {
+ ctrl.loading = true;
+ let chain;
+ if (useTestFixtures) {
+ chain = Promise.resolve([
+ { from_category_id: 100, to_category_id: 100, contact_count: 10 },
+ { from_category_id: 100, to_category_id: 101, contact_count: 5 },
+ { from_category_id: 101, to_category_id: 101, contact_count: 20 },
+ { from_category_id: 101, to_category_id: 100, contact_count: 2 },
+ ])
+ }
+ else {
+ chain = crmApi4('ContactCategory', 'getFlows', {
+ startDate: ctrl.startDate.toISOString().substring(0, 10),
+ endDate: ctrl.endDate ? ctrl.endDate.toISOString().substring(0, 10) : '',
+ })
+ }
+ chain.then(r => {
+ // Process the results into something useful.
+ ctrl.loading = false;
+ const labelWidth = ctrl.sankey.labelWidth,
+ width = Math.max(600, document.querySelector('.contact-cats-sankey').clientWidth),
+ snakeWidth = width - 2*labelWidth;
+ ctrl.sankey.width = width;
+
+ 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;
+ if (!from_category_id) {
+ // from nowhere: ignore for now.
+ console.log("Skipping", row);
+ return;
+ }
+ analysis[to_category_id].now += contact_count;
+ if (!catDefsIndexed[from_category_id]) {
+ console.log("from_category_id", from_category_id, "not found in defs", catDefsIndexed);
+ }
+ analysis[from_category_id].previous += contact_count;
+ analysis[to_category_id].sources.push({ cat: catDefsIndexed[from_category_id], contact_count });
+
+ totalContacts += analysis[to_category_id].now;
+ });
+
+
+ // allowing 50px per cat
+ let scale = (ctrl.catDefs.length * 50) / totalContacts;
+
+ let previousCatHeight = 0;
+ ctrl.catDefs.forEach(cat => {
+ // Now we know how many contacts per category per side.
+ // Set the from offsets
+ offsetWithinSourcesByCat[cat.id] = previousCatHeight;
+ previousCatHeight += Math.max(1, parseInt(analysis[cat.id].previous * scale)) +8; // padding TODO: standardise
+
+ // Make sure that our 'from' snake ends are ordered by category presentation_order
+ // This avoids one categories snakes overlapping its others.
+ analysis[cat.id].sources.sort((a, b) => {
+ if (!b.cat) {
+ console.log("Sort fail on b", {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 => {
+ const sourceHeight = Math.max(1,parseInt(source.contact_count * scale));
+ const gradientId = 'contactcats-gradient-' + source.cat.id + '-' + cat.id;
+ snakes.push({
+ source,
+ gradientId,
+ toTop : yTopR,
+ toHeight : sourceHeight,
+ toCat: toCat.cat,
+ fromTop: offsetWithinSourcesByCat[source.cat.id],
+ fromHeight: sourceHeight, // from and to heights will be the same!
+ fromCat: source.cat
+ });
+ offsetWithinSourcesByCat[source.cat.id] += sourceHeight;
+ yTopR += sourceHeight;
+ });
+ yTopR += 8;// padding TODO: apply this to the lhs too
+ });
+
+
+
+ // Create the SVG data for the snakes.
+ //c${-snakeWidth/4},0,${-snakeWidth},${-snakeWidth*3/4},0,${-snakeWidth}, ${snake.fromTopOffset + snake.fromHeight - snake.toTop - snake.toHeight}
+ const curve1 = snakeWidth/4, curve2 = snakeWidth*3/4;
+ snakes.forEach(snake => {
+ const dyBottom = snake.fromTop + snake.fromHeight - snake.toTop - snake.toHeight;
+ const dyTop = -snake.fromTop + snake.toTop;
+ snake.d = `M${snakeWidth},${snake.toTop} l0,${snake.toHeight}
+ c${-curve1},0,${-curve2},${dyBottom},${-snakeWidth},${dyBottom}
+ l0,${-snake.fromHeight}
+ c${curve1},0,${curve2},${dyTop},${snakeWidth},${dyTop}
+ z
+ `;
+ snake.fill = `url(#${snake.gradientId})`;
+ });
+
+ ctrl.sankey.snakes = snakes;
+ console.log({snakes, catDefs: catDefsIndexed, analysis});
+ });
+ }
+ // do initial fetch.
+ ctrl.fetchStats();
+ };
+ },
+ });
+})(angular, CRM.$, CRM._);