From dde3c8ec96fea11b838df206dfce9748a61bafc5 Mon Sep 17 00:00:00 2001 From: Rich Lott / Artful Robot Date: Mon, 31 Mar 2025 11:04:31 +0100 Subject: [PATCH] Improve vertical layout sankey --- ang/crmContactcats/crmContactCategoryFlows.js | 111 +++++++++++------- 1 file changed, 69 insertions(+), 42 deletions(-) diff --git a/ang/crmContactcats/crmContactCategoryFlows.js b/ang/crmContactcats/crmContactCategoryFlows.js index 4e5d26c..69870a8 100644 --- a/ang/crmContactcats/crmContactCategoryFlows.js +++ b/ang/crmContactcats/crmContactCategoryFlows.js @@ -2,13 +2,14 @@ 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 = false; + const useTestFixtures = true; let allFlowsData; @@ -17,7 +18,6 @@ ? allFlowsData.filter(({from_category_id, to_category_id}) => from_category_id != to_category_id) : allFlowsData; - // Process the results into something useful. ctrl.loading = false; const labelWidth = ctrl.sankey.labelWidth, width = Math.max(600, document.querySelector('.contact-cats-sankey').clientWidth), @@ -32,12 +32,32 @@ ctrl.noStats = false; // Create main analysis structure, indexed by right hand side of snakes. - const analysis = {}, offsetWithinSourcesByCat = {}; + // 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 }; - offsetWithinSourcesByCat[cat.id] = 0; + 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; @@ -56,62 +76,70 @@ totalContacts += contact_count; }); - // allowing 50px per cat - let scale = (ctrl.sankey.height - (ctrl.catDefs.length * 8)) / 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 += analysis[cat.id].previous * scale +8; // padding TODO: standardise + // 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. - analysis[cat.id].sources.sort((a, b) => { + 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 = []; - let yTopR = 0; - ctrl.catDefs.forEach(cat => { - let toCat = analysis[cat.id]; - toCat.yTop = yTopR; - toCat.sources.forEach(source => { - const sourceHeight = parseInt(source.contact_count * scale); - const gradientId = 'contactcats-gradient-' + source.cat.id + '-' + cat.id; + 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 : yTopR, - toHeight : Math.max(1,sourceHeight), - toCat: toCat.cat, - fromTop: offsetWithinSourcesByCat[source.cat.id], - fromHeight: Math.max(1,sourceHeight), // from and to heights will be the same! + 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; - 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 - `; + 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})`; }); @@ -148,7 +176,6 @@ crmApi4("ContactCategoryDefinition", 'get', { orderBy: { presentation_order: 'ASC' }, withLabels: true }) ); } - ctrl.sankey.height = ctrl.catDefs.length * 50; ctrl.loading = false; ctrl.catDefs.forEach(cat => {catDefsIndexed[cat.id] = cat;});