apps/recallassess/recallassess-api/src/api/integration/bat-chart.service.ts
Properties |
|
Methods |
| Private createPreBatChartConfiguration | |||||||||
createPreBatChartConfiguration(skills: Array
|
|||||||||
|
Create PRE-BAT chart configuration
Parameters :
Returns :
ChartConfiguration<polarArea, number[], string>
|
| getBandOuterValue | ||||||
getBandOuterValue(colorClass: string)
|
||||||
|
Ring outer value: 25-50% Foundation, 50-75% Intermediate, 75-100% Advanced, 100-125% Expert.
Parameters :
Returns :
number
|
| getCenterColorHexFromIpq | ||||||
getCenterColorHexFromIpq(ipq: number)
|
||||||
|
Inner ring (center circle) hex from IPQ – same bands as legend (Average → gray).
Parameters :
Returns :
string
|
| getColorHex | ||||||
getColorHex(colorClass: string)
|
||||||
|
Parameters :
Returns :
string
|
| Async renderPostBatChart | ||||||||||||
renderPostBatChart(skills: Array
|
||||||||||||
|
Render POST-BAT chart (for pre or post assessment)
Parameters :
Returns :
Promise<string | null>
|
| Async renderPreBatChart | ||||||||||||
renderPreBatChart(skills: Array
|
||||||||||||
|
Render PRE-BAT chart.
Parameters :
Returns :
Promise<string | null>
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(BatChartService.name)
|
| Private Readonly skillChartCanvas |
Type : unknown
|
Default value : new ChartJSNodeCanvas({
width: 800,
height: 600,
backgroundColour: "#ffffff",
chartCallback: (ChartJS) => {
ChartJS.register(ArcElement, PolarAreaController, RadialLinearScale, Legend, Title, Tooltip);
// Concentric rings: first 25% = hole, then 25-50% Foundation, 50-75% Intermediate, 75-100% Advanced, 100-125% Expert
const INNER_RING_PX = 30;
ChartJS.register({
id: "offsetInnerRadiusPlugin",
beforeDatasetsDraw(chart) {
try {
const config = chart.config as ChartConfiguration;
if (config.type !== "polarArea") {
return;
}
const meta = chart.getDatasetMeta(0);
if (!meta || !meta.data || meta.data.length === 0) {
return;
}
const arcs = meta.data as Array<{ innerRadius?: number; outerRadius?: number }>;
arcs.forEach((arc) => {
if (typeof arc.outerRadius === "number") {
arc.innerRadius = Math.min(INNER_RING_PX, arc.outerRadius - 1);
}
});
} catch {
// Fail silently so the chart still renders
}
},
});
// Fill 0–25% center hole with IPQ band color and optional center label
ChartJS.register({
id: "fillCenterHolePlugin",
afterDraw(chart) {
try {
const config = chart.config as ChartConfiguration;
if (config.type !== "polarArea") {
return;
}
const plugins = config.options?.plugins as Record<string, unknown> | undefined;
const custom = plugins?.["custom"] as { centerColor?: string; centerLabel?: string } | undefined;
const centerColor = custom?.centerColor;
const centerLabel = custom?.centerLabel;
const ctx = chart.ctx;
const scale = chart.scales["r"] as unknown as { xCenter: number; yCenter: number } | undefined;
if (!scale || scale.xCenter === undefined || scale.yCenter === undefined) {
return;
}
if (centerColor && typeof centerColor === "string") {
ctx.save();
ctx.beginPath();
ctx.arc(scale.xCenter, scale.yCenter, INNER_RING_PX, 0, Math.PI * 2);
ctx.fillStyle = centerColor;
ctx.fill();
ctx.restore();
}
if (centerLabel && typeof centerLabel === "string") {
ctx.save();
ctx.font = "bold 18px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "#ffffff";
ctx.shadowColor = "rgba(255, 255, 255, 0.4)";
ctx.shadowBlur = 1;
ctx.fillText(centerLabel, scale.xCenter, scale.yCenter);
ctx.restore();
}
} catch {
// Fail silently so the chart still renders
}
},
});
},
})
|
import {
BAT_COLOR_HEX,
getColorClassFromScore,
} from "@api/shared/constants/assessment-scores.constants";
import { Injectable, Logger } from "@nestjs/common";
import {
ArcElement,
ChartConfiguration,
Legend,
PolarAreaController,
RadialLinearScale,
Title,
Tooltip,
} from "chart.js";
import { ChartJSNodeCanvas } from "chartjs-node-canvas";
@Injectable()
export class BatChartService {
private readonly logger = new Logger(BatChartService.name);
private readonly skillChartCanvas = new ChartJSNodeCanvas({
width: 800,
height: 600,
backgroundColour: "#ffffff",
chartCallback: (ChartJS) => {
ChartJS.register(ArcElement, PolarAreaController, RadialLinearScale, Legend, Title, Tooltip);
// Concentric rings: first 25% = hole, then 25-50% Foundation, 50-75% Intermediate, 75-100% Advanced, 100-125% Expert
const INNER_RING_PX = 30;
ChartJS.register({
id: "offsetInnerRadiusPlugin",
beforeDatasetsDraw(chart) {
try {
const config = chart.config as ChartConfiguration;
if (config.type !== "polarArea") {
return;
}
const meta = chart.getDatasetMeta(0);
if (!meta || !meta.data || meta.data.length === 0) {
return;
}
const arcs = meta.data as Array<{ innerRadius?: number; outerRadius?: number }>;
arcs.forEach((arc) => {
if (typeof arc.outerRadius === "number") {
arc.innerRadius = Math.min(INNER_RING_PX, arc.outerRadius - 1);
}
});
} catch {
// Fail silently so the chart still renders
}
},
});
// Fill 0–25% center hole with IPQ band color and optional center label
ChartJS.register({
id: "fillCenterHolePlugin",
afterDraw(chart) {
try {
const config = chart.config as ChartConfiguration;
if (config.type !== "polarArea") {
return;
}
const plugins = config.options?.plugins as Record<string, unknown> | undefined;
const custom = plugins?.["custom"] as { centerColor?: string; centerLabel?: string } | undefined;
const centerColor = custom?.centerColor;
const centerLabel = custom?.centerLabel;
const ctx = chart.ctx;
const scale = chart.scales["r"] as unknown as { xCenter: number; yCenter: number } | undefined;
if (!scale || scale.xCenter === undefined || scale.yCenter === undefined) {
return;
}
if (centerColor && typeof centerColor === "string") {
ctx.save();
ctx.beginPath();
ctx.arc(scale.xCenter, scale.yCenter, INNER_RING_PX, 0, Math.PI * 2);
ctx.fillStyle = centerColor;
ctx.fill();
ctx.restore();
}
if (centerLabel && typeof centerLabel === "string") {
ctx.save();
ctx.font = "bold 18px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = "#ffffff";
ctx.shadowColor = "rgba(255, 255, 255, 0.4)";
ctx.shadowBlur = 1;
ctx.fillText(centerLabel, scale.xCenter, scale.yCenter);
ctx.restore();
}
} catch {
// Fail silently so the chart still renders
}
},
});
},
});
/**
* Render PRE-BAT chart.
* @param skills - Module/skill data for the rings
* @param options.centerColor - Hex color for the center circle (inner ring), e.g. from IPQ band
* @param options.centerLabel - Text to draw in center (e.g. IPQ value)
*/
async renderPreBatChart(
skills: Array<{ name: string; percentage: number; colorClass: string; score: number }>,
options?: { centerColor?: string; centerLabel?: string },
): Promise<string | null> {
if (skills.length === 0) {
return null;
}
const configuration = this.createPreBatChartConfiguration(skills, options);
try {
return await this.skillChartCanvas.renderToDataURL(configuration);
} catch (error) {
this.logger.warn("Failed to render PRE-BAT skill chart image for SSR", error);
return null;
}
}
/**
* Render POST-BAT chart (for pre or post assessment)
* @param options.centerColor - Hex for center circle (e.g. from IPQ band)
* @param options.centerLabel - Text in center (e.g. IPQ value)
*/
async renderPostBatChart(
skills: Array<{ name: string; percentage: number; colorClass: string; score: number }>,
chartType: "pre" | "post",
options?: { centerColor?: string; centerLabel?: string },
): Promise<string | null> {
if (skills.length === 0) {
return null;
}
const configuration = this.createPostBatChartConfiguration(skills, chartType, options);
try {
return await this.skillChartCanvas.renderToDataURL(configuration);
} catch (error) {
this.logger.warn(`Failed to render POST-BAT ${chartType} skill chart image for SSR`, error);
return null;
}
}
/**
* Create PRE-BAT chart configuration
* @param options.centerColor - Hex for center circle (inner ring)
* @param options.centerLabel - Text in center (e.g. IPQ value)
*/
private createPreBatChartConfiguration(
skills: Array<{ name: string; percentage: number; colorClass: string; score: number }>,
options?: { centerColor?: string; centerLabel?: string },
): ChartConfiguration<"polarArea", number[], string> {
return {
type: "polarArea",
data: {
labels: skills.map((skill) => skill.name),
datasets: [
{
label: "Course Modules",
// Clamp each segment to a minimum of 10 so it doesn’t collapse at the center
data: skills.map((skill) => this.getBandOuterValue(skill.colorClass)),
backgroundColor: skills.map((skill) => this.getColorHex(skill.colorClass)),
borderColor: "#808080",
borderWidth: 1,
borderDash: [5, 5],
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
top: 2,
right: 2,
bottom: 2,
left: 2,
},
},
scales: {
r: {
beginAtZero: true,
max: 125,
ticks: {
display: false,
},
pointLabels: {
display: true,
centerPointLabels: true,
font: {
size: 12,
weight: "normal",
},
color: "#333333",
padding: 2,
},
grid: {
display: true,
color: "#808080",
lineWidth: 0,
},
},
},
plugins: {
legend: {
display: false,
position: "top",
},
title: {
display: true,
text: "Course Modules Analysis",
font: {
size: 22,
},
padding: {
top: 1,
bottom: 1,
},
},
...((options?.centerColor || options?.centerLabel)
? ({
custom: {
...(options.centerColor && { centerColor: options.centerColor }),
...(options.centerLabel && { centerLabel: options.centerLabel }),
},
} as Record<string, unknown>)
: {}),
},
},
};
}
/**
* Create POST-BAT chart configuration
* @param options.centerColor - Hex for center circle (e.g. from IPQ band)
* @param options.centerLabel - Text in center (e.g. IPQ value)
*/
private createPostBatChartConfiguration(
skills: Array<{ name: string; percentage: number; colorClass: string; score: number }>,
chartType: "pre" | "post",
options?: { centerColor?: string; centerLabel?: string },
): ChartConfiguration<"polarArea", number[], string> {
return {
type: "polarArea",
data: {
labels: skills.map((skill) => skill.name),
datasets: [
{
label: "Course Modules",
// Clamp each segment to a minimum of 10 so it doesn’t collapse at the center
data: skills.map((skill) => this.getBandOuterValue(skill.colorClass)),
backgroundColor: skills.map((skill) => this.getColorHex(skill.colorClass)),
borderColor: "#808080",
borderWidth: 1,
borderDash: [5, 5],
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
top: 1,
right: 1,
bottom: 1,
left: 1,
},
},
scales: {
r: {
beginAtZero: true,
max: 125,
ticks: {
display: false,
},
pointLabels: {
display: true,
centerPointLabels: true,
font: {
size: 12,
weight: "normal",
},
color: "#333333",
padding: 1,
},
grid: {
display: true,
color: "#808080",
lineWidth: 0,
},
},
},
plugins: {
legend: {
display: false,
position: "top",
},
title: {
display: true,
text: chartType === "pre" ? "Pre-BAT Course Modules Analysis" : "Post-BAT Course Modules Analysis",
font: {
size: 22,
},
padding: {
top: 1,
bottom: 1,
},
},
...((options?.centerColor || options?.centerLabel)
? ({
custom: {
...(options.centerColor && { centerColor: options.centerColor }),
...(options.centerLabel && { centerLabel: options.centerLabel }),
},
} as Record<string, unknown>)
: {}),
},
},
};
}
getColorHex(colorClass: string): string {
return BAT_COLOR_HEX[colorClass as keyof typeof BAT_COLOR_HEX] || BAT_COLOR_HEX.gray;
}
/** Inner ring (center circle) hex from IPQ – same bands as legend (Average → gray). */
getCenterColorHexFromIpq(ipq: number): string {
const cls = getColorClassFromScore(ipq);
const hexMap: Record<string, string> = {
...BAT_COLOR_HEX,
"light-green": BAT_COLOR_HEX.gray,
green: BAT_COLOR_HEX["light-green"],
};
return hexMap[cls] ?? BAT_COLOR_HEX.gray;
}
/** Ring outer value: 25-50% Foundation, 50-75% Intermediate, 75-100% Advanced, 100-125% Expert. */
getBandOuterValue(colorClass: string): number {
const band: Record<string, number> = {
red: 50,
orange: 75,
"light-green": 100,
green: 100,
"dark-green": 125,
gray: 75,
};
return band[colorClass] ?? 75;
}
}