mirror of
https://codeberg.org/artfulrobot/contactcats.git
synced 2025-06-25 12:58:05 +02:00
WIP checkin
This commit is contained in:
parent
2ae781f6e3
commit
f88fb5f10e
3 changed files with 189 additions and 227 deletions
|
@ -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. <crm-reset-password token='foo' />
|
||||
// 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 <newCatID>: {
|
||||
// // sources: {
|
||||
// // string <previousCatId>: int <n>
|
||||
// // },
|
||||
// // now: int <n>, previous: int <n>
|
||||
// // }}
|
||||
//
|
||||
// // 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: {
|
||||
|
|
|
@ -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,7 +25,7 @@
|
|||
<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}}"
|
||||
|
@ -35,21 +34,22 @@
|
|||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
ng-repeat="p in $ctrl.sankey.rows"
|
||||
id="{{p.gradient.id}}"
|
||||
ng-repeat="p in $ctrl.sankey.snakes"
|
||||
id="{{p.gradientId}}"
|
||||
>
|
||||
<stop offset="0%" stop-color="{{p.gradient.from}}" />
|
||||
<stop offset="100%" stop-color="{{p.gradient.to}}" />
|
||||
<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>
|
||||
|
||||
|
|
173
ang/crmContactcats/crmContactCategoryFlows.js
Normal file
173
ang/crmContactcats/crmContactCategoryFlows.js
Normal 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._);
|
Loading…
Add table
Add a link
Reference in a new issue