WIP checkin

This commit is contained in:
Rich Lott / Artful Robot 2025-03-26 12:16:48 +00:00
parent 2ae781f6e3
commit f88fb5f10e
3 changed files with 189 additions and 227 deletions

View file

@ -7,7 +7,6 @@
name="startDate"
ng-model="$ctrl.startDate"
/>
DAte: {{$ctrl.startDate}}
</div>
<div crm-ui-field="{name: 'cc.endDate', title: ts('End date')}" >
<input
@ -26,30 +25,31 @@
<div>
<p ng-if="$ctrl.noStats">{{ts('No results')}}</p>
<div class="rfm-sk" >
<div class="contact-cats-sankey" >
<svg viewBox="0 0 {{ $ctrl.sankey.width }} {{ $ctrl.sankey.height }}"
width="{{$ctrl.sankey.width}}"
height="{{$ctrl.sankey.height}}"
style="width: 100%; height: auto;"
version="1.1" xmlns="http://www.w3.org/2000/svg"
>
<defs>
<linearGradient
ng-repeat="p in $ctrl.sankey.rows"
id="{{p.gradient.id}}"
>
<stop offset="0%" stop-color="{{p.gradient.from}}" />
<stop offset="100%" stop-color="{{p.gradient.to}}" />
</linearGradient>
</defs>
<defs>
<linearGradient
ng-repeat="p in $ctrl.sankey.snakes"
id="{{p.gradientId}}"
>
<stop offset="0%" stop-color="{{p.fromCat.color}}" />
<stop offset="100%" stop-color="{{p.toCat.color}}" />
</linearGradient>
</defs>
<path ng-repeat="p in $ctrl.sankey.rows"
<path ng-repeat="p in $ctrl.sankey.snakes"
d="{{p.d}}"
fill="{{p.fill}}"
fill-opacity=0.5
>
<title>{{p.n}} {{p.previousLabel}} → {{p.newLabel}}</title>
<title>{{p.source.contact_count}} {{p.fromCat.name}} → {{p.toCat.name}}</title>
</path>
<!--
<g ng-repeat="label in $ctrl.sankey.labels">
<text x="{{$ctrl.sankey.labelWidth - 4}}"
y="{{label.y}}"
@ -64,6 +64,7 @@
>{{label.label}}</text>
</g>
<!-- ticks on vertical axis -->
<!--
<path ng-repeat="label in $ctrl.sankey.labels"
d="M{{$ctrl.sankey.labelWidth - 4}},{{label.y}} l4,0
M{{$ctrl.sankey.width - $ctrl.sankey.labelWidth}},{{label.y}} l4,0"
@ -73,9 +74,11 @@
class="sk1"
stroke-width=1 stroke='#333'></path>
<!-- vertical axis -->
<!--
<path d="M{{$ctrl.sankey.labelWidth}},0 l0,{{$ctrl.sankey.height}}
M{{$ctrl.sankey.width - $ctrl.sankey.labelWidth}},0 l0,{{$ctrl.sankey.height}}"
stroke='#333'></path>
-->
</svg>
</div>

View file

@ -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._);