mirror of
https://codeberg.org/artfulrobot/contactcats.git
synced 2025-06-25 13:08:04 +02:00
209 lines
8.3 KiB
JavaScript
209 lines
8.3 KiB
JavaScript
(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 = {
|
|
// <to-CatID>: {
|
|
// {
|
|
// cat: <the cat def object>, of the 'to' cat.
|
|
// sources: [
|
|
// {
|
|
// cat: <the cat def object>, of the 'from' cat.
|
|
// contact_count: <int>, the number of contacts moved from here.
|
|
// },
|
|
// ...
|
|
// ],
|
|
// now: <int>, How many contacts end up in this cat.
|
|
// previous: <int>, How many contacts started in this cat.
|
|
// y: <int>,
|
|
// }
|
|
// }
|
|
// }
|
|
//
|
|
// 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._);
|