diff --git a/ang/crmContactcats.js b/ang/crmContactcats.js index b812780..f1a534b 100644 --- a/ang/crmContactcats.js +++ b/ang/crmContactcats.js @@ -2,220 +2,6 @@ // 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 index cfa6c7c..da07896 100644 --- a/ang/crmContactcats/crmContactCategoryFlows.html +++ b/ang/crmContactcats/crmContactCategoryFlows.html @@ -7,7 +7,6 @@ name="startDate" ng-model="$ctrl.startDate" /> - DAte: {{$ctrl.startDate}}

{{ts('No results')}}

-
+
- - - - - - + + + + + + - - {{p.n}} {{p.previousLabel}} → {{p.newLabel}} + {{p.source.contact_count}} {{p.fromCat.name}} → {{p.toCat.name}} + + +
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._);