File

apps/recallassess/recallassess-api/src/api/integration/bat-chart.service.ts

Index

Properties
Methods

Methods

Private createPostBatChartConfiguration
createPostBatChartConfiguration(skills: Array, chartType: "pre" | "post", options?: literal type)

Create POST-BAT chart configuration

Parameters :
Name Type Optional
skills Array<literal type> No
chartType "pre" | "post" No
options literal type Yes
Returns : ChartConfiguration<polarArea, number[], string>
Private createPreBatChartConfiguration
createPreBatChartConfiguration(skills: Array, options?: literal type)

Create PRE-BAT chart configuration

Parameters :
Name Type Optional
skills Array<literal type> No
options literal type Yes
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 :
Name Type Optional
colorClass string No
Returns : number
getCenterColorHexFromIpq
getCenterColorHexFromIpq(ipq: number)

Inner ring (center circle) hex from IPQ – same bands as legend (Average → gray).

Parameters :
Name Type Optional
ipq number No
Returns : string
getColorHex
getColorHex(colorClass: string)
Parameters :
Name Type Optional
colorClass string No
Returns : string
Async renderPostBatChart
renderPostBatChart(skills: Array, chartType: "pre" | "post", options?: literal type)

Render POST-BAT chart (for pre or post assessment)

Parameters :
Name Type Optional
skills Array<literal type> No
chartType "pre" | "post" No
options literal type Yes
Returns : Promise<string | null>
Async renderPreBatChart
renderPreBatChart(skills: Array, options?: literal type)

Render PRE-BAT chart.

Parameters :
Name Type Optional Description
skills Array<literal type> No
  • Module/skill data for the rings
options literal type Yes
Returns : Promise<string | null>

Properties

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;
  }
}

results matching ""

    No results matching ""