Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^6.2.6",
"vite-plugin-commonjs": "^0.10.4",
"vitest": "^3.0.0"
},
"dependencies": {
Expand All @@ -75,6 +76,8 @@
"apexcharts": "^4.7.0",
"bits-ui": "^1.4.8",
"bufferutil": "^4.0.9",
"canvas": "^3.1.2",
"chart.js": "^4.5.0",
"d3": "^7.9.0",
"d3-axis": "^3.0.0",
"d3-scale": "^4.0.2",
Expand All @@ -92,6 +95,7 @@
},
"pnpm": {
"onlyBuiltDependencies": [
"canvas",
"esbuild",
"svelte-preprocess"
]
Expand Down
265 changes: 257 additions & 8 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

Empty file added src/hooks.client.ts
Empty file.
Empty file added src/lib/models/Gateway.ts
Empty file.
1 change: 1 addition & 0 deletions src/lib/pdf/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export interface TableCell {
label: string;
shortLabel?: string;
value?: number | string | Date;
color?: string;
bgColor?: string;
Expand Down
71 changes: 45 additions & 26 deletions src/lib/pdf/pdfDataTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ interface TableConfig {
rowsPerColumn: number;
cellWidth: number;
cellHeight: number;
margin: number;
columnMargin: number;
fontSize: number;
headerHeight: number;
Expand All @@ -19,7 +18,6 @@ const DEFAULT_CONFIG: TableConfig = {
rowsPerColumn: 30,
cellWidth: 100,
cellHeight: 12,
margin: 40,
columnMargin: 10,
fontSize: 7,
headerHeight: 15
Expand All @@ -46,17 +44,22 @@ export function createPDFDataTable({
}): void {
const conf = { ...DEFAULT_CONFIG, ...config };

const { caption, margin, headerHeight, cellWidth, cellHeight, columnsPerPage, columnMargin } =
conf;
const { caption, headerHeight, cellWidth, cellHeight, columnsPerPage, columnMargin } = conf;

const {
top: marginTop,
right: marginRight,
bottom: marginBottom,
left: marginLeft
} = doc.page.margins;

// Calculate how many rows can actually fit on a page
const pageHeight = doc.page.height;
const availableHeight = pageHeight - margin * 2 - headerHeight - 50; // 50 for caption space
const actualRowsPerColumn = Math.floor(availableHeight / cellHeight);
const contentHeight = pageHeight - marginTop - marginBottom;

// Calculate how many columns can actually fit on a page
const pageWidth = doc.page.width;
const availableWidth = pageWidth - margin * 2;
const availableWidth = pageWidth - marginLeft - marginRight;
const columnWidth = [dataHeader.header, ...dataHeader.cells].reduce(
(total, col) => total + (col.width ?? cellWidth),
0
Expand All @@ -66,28 +69,33 @@ export function createPDFDataTable({
const finalColumnsPerPage = Math.min(columnsPerPage, actualColumnsPerPage);

if (caption) {
doc.fillColor('black').fontSize(12).text(caption, margin, doc.y);
doc.fillColor('black').fontSize(12).text(caption, marginLeft, doc.y);
doc.moveDown(0.5);
}

let currentPage = 0;
let dataIndex = 0;
let startY = doc.y;

while (dataIndex < dataRows.length) {
if (currentPage > 0) {
doc.addPage();
startY = margin;
}
// Draw columns for current page
for (let col = 0; col < finalColumnsPerPage && dataIndex < dataRows.length; col++) {
const firstColumn = col % finalColumnsPerPage === 0;

const totalColumns = Math.min(
finalColumnsPerPage,
Math.ceil((dataRows.length - dataIndex) / actualRowsPerColumn)
);
if (firstColumn) {
startY = doc.y;
}

// Draw columns for current page
for (let col = 0; col < totalColumns && dataIndex < dataRows.length; col++) {
const startX = margin + col * (columnWidth + columnMargin);
let availableHeight = pageHeight - startY - marginBottom - headerHeight;

// Insert a new page if we are at the bottom of the current page
if (firstColumn && availableHeight < 200) {
doc.addPage();
startY = marginTop;
availableHeight = contentHeight - headerHeight;
}

const actualRowsPerColumn = Math.floor(availableHeight / cellHeight);
const startX = marginLeft + col * (columnWidth + columnMargin);
const endIndex = Math.min(dataIndex + actualRowsPerColumn, dataRows.length);

drawColumn({
Expand All @@ -99,10 +107,9 @@ export function createPDFDataTable({
startY,
config: conf
});

dataIndex = endIndex;
}

currentPage++;
}
}

Expand Down Expand Up @@ -151,8 +158,8 @@ function drawColumn({
currentY += headerHeight;

// Draw data rows
dataRows.forEach(({ header, cells }, index) => {
const isEvenRow = index % 2 === 0;
dataRows.forEach(({ header, cells }, rowIndex) => {
const isEvenRow = rowIndex % 2 === 0;

// Row background
if (!isEvenRow) {
Expand All @@ -164,7 +171,7 @@ function drawColumn({
// Row border
doc.strokeColor(borderColor).rect(startX, currentY, columnWidth, cellHeight).stroke();

[header, ...cells].forEach(({ label, bgColor }, cellIndex) => {
[header, ...cells].forEach(({ label, shortLabel, bgColor }, cellIndex) => {
const cellX = getCellX(cellIndex);
const { width = cellWidth, fontSize = defaultFontSize } = columns[cellIndex];

Expand All @@ -176,11 +183,23 @@ function drawColumn({
// Cell border
doc.strokeColor(borderColor).rect(cellX, currentY, width, cellHeight).stroke();

let labelText = label;

// If the row is not the first one and a short label is provided, check if we can use it
if (rowIndex > 0 && shortLabel) {
const previousLabel = dataRows[rowIndex - 1]?.header.label;

// If the previous date is the same as the current date, use the short label
if (previousLabel.split(' ')[0] === label.split(' ')[0]) {
labelText = shortLabel;
}
}

// Value text
doc
.fillColor('#000')
.fontSize(fontSize - 1)
.text(label, cellX + 1, currentY + 2, { width: width - 5, align: 'right' });
.text(labelText, cellX + 1, currentY + 2, { width: width - 5, align: 'right' });
});

currentY += cellHeight;
Expand Down
21 changes: 15 additions & 6 deletions src/lib/pdf/pdfFooterPageNumber.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
import PDFDocument from 'pdfkit';

const footerFontSize = 7;
const footerMargin = 20;

export function addFooterPageNumber(
doc: InstanceType<typeof PDFDocument>,
primaryText: string
): void {
// 3) now stamp page numbers on every page
const range = doc.bufferedPageRange(); // { start: 0, count: N }
const total = range.count;
const footerFontSize = 7;
const footerMargin = 20;
const options = {
width: doc.page.width - 20 - 20,
height: footerFontSize + 2,
lineBreak: false
};

for (let i = 0; i < total; i++) {
doc.switchToPage(i);
const text = `${primaryText} | Page ${i + 1} / ${total}`;
const w = doc.widthOfString(text);
const x = doc.page.width / 2 - w / 2; // center the text

const x = 20;
const y = doc.page.height - footerMargin - 5;

doc
.fontSize(footerFontSize)
.fillColor('black')
.text(text, x, y, { align: 'center', lineBreak: false });
.text(primaryText, x, y, { ...options, align: 'left' })
.text(`Page ${i + 1} / ${total}`, x, y, { ...options, align: 'right' });
}

// 4) go back to the last page so that any post‐footer work (if any) ends up there
doc.switchToPage(total - 1);
}
25 changes: 20 additions & 5 deletions src/lib/pdf/pdfLineChart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,23 +94,38 @@ export function createPDFLineChart({
const allValues = dataRows.flatMap((d) =>
d.cells.map(({ value }) => value).filter((value) => typeof value === 'number' && !isNaN(value))
) as number[];

// Handle case where there are no valid numeric values
if (allValues.length === 0) {
console.warn('No valid numeric values found for line chart');
return;
}

const minValue = Math.min(...allValues);
const maxValue = Math.max(...allValues);
const valueRange = maxValue - minValue;
const paddedMin = minValue - valueRange * 0.1;
const paddedMax = maxValue + valueRange * 0.1;

// Handle case where all values are the same (valueRange = 0)
const paddedMin = valueRange === 0 ? minValue - 1 : minValue - valueRange * 0.1;
const paddedMax = valueRange === 0 ? maxValue + 1 : maxValue + valueRange * 0.1;

// Time range
const minTime = Math.min(...timestamps.map((t) => t.getTime()));
const maxTime = Math.max(...timestamps.map((t) => t.getTime()));
const timeRange = maxTime - minTime;

// Scaling functions
const xScale = (timestamp: Date) => {
return chartX + ((timestamp.getTime() - minTime) / (maxTime - minTime)) * chartWidth;
return timeRange === 0
? chartX + chartWidth / 2
: chartX + ((timestamp.getTime() - minTime) / timeRange) * chartWidth;
};

const yScale = (value: number) => {
return chartY + chartHeight - ((value - paddedMin) / (paddedMax - paddedMin)) * chartHeight;
const paddedRange = paddedMax - paddedMin;
return paddedRange === 0
? chartY + chartHeight / 2
: chartY + chartHeight - ((value - paddedMin) / paddedRange) * chartHeight;
};

// Draw title
Expand Down Expand Up @@ -259,7 +274,7 @@ export function createPDFLineChart({
if (conf.yAxisLabel) {
doc
.save()
.rotate(-90, chartX - 60, chartY + chartHeight / 2)
.rotate(-90)
.fontSize(fontSize)
.text(conf.yAxisLabel, chartX - 60, chartY + chartHeight / 2, {
align: 'center'
Expand Down
115 changes: 115 additions & 0 deletions src/lib/pdf/pdfLineChartImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type { ReportAlertPoint } from '$lib/models/Report';
import { createCanvas } from 'canvas';
import {
CategoryScale,
Chart,
LinearScale,
LineController,
LineElement,
PointElement,
type ChartData,
type ChartOptions
} from 'chart.js';
import PDFDocument from 'pdfkit';
import type { TableRow } from '.';

interface ChartConfig {
title?: string;
width: number;
height: number;
options?: ChartOptions;
}

Chart.register([CategoryScale, LineController, LineElement, LinearScale, PointElement]);
Chart.defaults.devicePixelRatio = 3;
Chart.defaults.font.size = 20;

const DEFAULT_CHART_OPTIONS: ChartOptions = {
elements: {
line: {
borderWidth: 4,
tension: 0.2 // Smooth line
},
point: {
radius: 0 // No points on the line
}
}
};

/**
* Creates a line chart image using Chart.js and returns it as a buffer.
* @returns A buffer containing the image data for a line chart.
* @see https://www.chartjs.org/docs/latest/getting-started/using-from-node-js.html
*/
const createImage = ({
data,
options,
width,
height
}: {
data: ChartData;
options?: ChartOptions;
width: number;
height: number;
}): Buffer => {
const canvas = createCanvas(width, height);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chart = new Chart(canvas as any, { type: 'line', data, options });
const buffer = canvas.toBuffer();

chart.destroy();

return buffer;
};

/**
* Creates a line chart in a PDFKit document
* @param {object} params
* @param params.doc - PDFKit document instance
* @param params.dataHeader - Array of column definitions
* @param params.dataRows - Array of data rows, each row is an array of values
* @param params.config - Chart configuration options
*/
export const createPDFLineChartImage = ({
doc,
dataHeader,
dataRows,
config = {}
}: {
doc: InstanceType<typeof PDFDocument>;
dataHeader: TableRow;
dataRows: TableRow[];
alertPoints: ReportAlertPoint[];
config?: Partial<ChartConfig>;
}): void => {
if (!dataRows?.length) {
console.warn('No data provided for line chart');
return;
}

const { left: marinLeft, right: marginRight } = doc.page.margins;
const { title, width = 400, height = 300, options = DEFAULT_CHART_OPTIONS } = config;

const data: ChartData = {
labels: dataRows.map((row) => row.header.shortLabel || row.header.label),
datasets: [
{
label: dataHeader.header.label,
data: dataRows.map((row) => row.cells[0].value as number),
borderColor: dataHeader.cells[0].color || 'blue'
}
]
};

const buffer = createImage({ data, options, width, height });

doc.x = marinLeft;
doc.image(buffer, { width, height });

if (title) {
doc.fontSize(8).text(title, marinLeft, doc.y, {
width: doc.page.width - marinLeft - marginRight,
align: 'center'
});
}
};
Empty file.
Empty file.
Loading