(function(angular, $, _) { angular.module("crmContactcats").component("crmContactCategoryFlows", { templateUrl: "~/crmContactcats/crmContactCategoryFlows.html", controller: function($scope, $timeout, crmApi4, crmStatus, $document) { const catHeightMin = 50, catHeightGap = 8; 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 = true; let allFlowsData; ctrl.redrawSankey = function() { let flowsData = ctrl.changesOnly ? allFlowsData.filter(({from_category_id, to_category_id}) => from_category_id != to_category_id) : allFlowsData; 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 (!(flowsData.length)) { ctrl.noStats = true; ctrl.stats = []; return; } ctrl.noStats = false; // Create main analysis structure, indexed by right hand side of snakes. // analysis = { // : { // { // cat: , of the 'to' cat. // sources: [ // { // cat: , of the 'from' cat. // contact_count: , the number of contacts moved from here. // }, // ... // ], // now: , How many contacts end up in this cat. // previous: , How many contacts started in this cat. // y: , // } // } // } // // offsetWithinSourcesByCat tracks, per source, the place the // next snake's left-top should be, relative to the top for that category. const analysis = {}, analysisArray = [], offsetWithinSourcesByCat = {}; ctrl.catDefs.forEach(cat => { analysis[cat.id] = { sources: [], now: 0, previous: 0, cat, y:0 }; // This is just to make looping in order easier. analysisArray.push(analysis[cat.id]); }); let totalContacts = 0; flowsData.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 += contact_count; }); // Allow a cat height to grow from its min to 3x min. let maxCatHeight = 3 * catHeightMin; let maxContactsAtOneCat = Math.max(... analysisArray.map(cat => Math.max(cat.now, cat.previous))); let scale = maxCatHeight / maxContactsAtOneCat; console.log({maxCatHeight, maxContactsAtOneCat,scale}); // Assign y-offsets for each category. // We start with a minimum so as to accommodate labels, but it may need to grow // to accommodate the total contacts in either end. let accumulatedY = 0; analysisArray.forEach(toAna => { toAna.y = accumulatedY; let thisCatHeight = Math.ceil(Math.max(catHeightMin, scale * Math.max(toAna.now, toAna.previous))); accumulatedY += thisCatHeight + catHeightGap; console.log({toCat: toAna.cat.label, toCatY: toAna.y, accumulatedY, thisCatHeight}); // Intialise source offsets for this category. offsetWithinSourcesByCat[toAna.cat.id] = 0; // Make sure that our 'from' snake ends are ordered by category presentation_order // This avoids one categories snakes overlapping its others. toAna.sources.sort((a, b) => { return a.cat.presentation_order - b.cat.presentation_order; }) }); ctrl.sankey.height = Math.ceil(accumulatedY); // Make a list of snakes we need to draw. const snakes = []; analysisArray.forEach(toAna => { let offsetWithinTo = 0; toAna.sources.forEach(source => { const sourceHeight = source.contact_count * scale; const gradientId = 'contactcats-gradient-' + source.cat.id + '-' + toAna.cat.id; snakes.push({ source, gradientId, toTop : toAna.y + offsetWithinTo, height : Math.max(1, Math.round(sourceHeight)), toCat: toAna.cat, fromTop: analysis[source.cat.id].y + offsetWithinSourcesByCat[source.cat.id], fromCat: source.cat }); offsetWithinTo += sourceHeight; offsetWithinSourcesByCat[source.cat.id] += sourceHeight; }); }); // Create the SVG data for the snakes. const curve1 = snakeWidth/4, curve2 = snakeWidth*3/4; snakes.forEach(snake => { const dy = snake.fromTop - snake.toTop; snake.d = [ // Move to top right of snake `M${snakeWidth},${snake.toTop}`, // Line down height of snake on right. `l0,${snake.height}`, // Curve to left bottom of snake. `c${-curve1},0,${-curve2},${dy},${-snakeWidth},${dy}`, // Up on left side `l0,${-snake.height}`, // Curve back to start. `c${curve1},0,${curve2},${-dy},${snakeWidth},${-dy}`, // Close shape. `z`, ].join(' '); snake.fill = `url(#${snake.gradientId})`; }); ctrl.sankey.snakes = snakes; console.log({snakes, catDefs: catDefsIndexed, analysis}); } // 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.changesOnly = false; 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.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, label: 'Amazing', color:'#ffcc00' }, { id: 101, label: 'Meh', color:'#ff6f00' }, ]; } 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;}); 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 => { allFlowsData = r; ctrl.redrawSankey(); }); } // do initial fetch. ctrl.fetchStats(); }; }, }); })(angular, CRM.$, CRM._);