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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ If the dev server appears suspended (e.g. you see `zsh: suspended npm run dev`),
Recommended: Vercel (works well with Next.js). Create a GitHub repo and connect it to Vercel. Default build command `npm run build` and output directory are handled by Next.js.

**Important for Vercel deployment**:
- The PDF export feature requires Puppeteer and a serverless-compatible Chrome binary. This is handled automatically by `@sparticuz/chromium-min`.
- The PDF export feature requires Puppeteer and a serverless-compatible Chrome binary. This is handled automatically by `@sparticuz/chromium`.
- The `vercel.json` file configures increased memory and timeout for the PDF generation endpoint.
- **Hobby Plan Limitation**: Vercel's Hobby (free) plan has a 1024MB default memory limit with a maximum of 2048MB for serverless functions. The configuration uses 1800MB to stay within this limit. If deployments silently fail from GitHub, check that the memory allocation in `vercel.json` is ≤2048MB.
- Pro plans support up to 3008MB which may improve performance for larger visualizations.
Expand All @@ -53,7 +53,7 @@ Key source files
- `src/app/page.tsx` — main page that mounts the visualization component.
- `src/app/HomeClient.tsx` — client-side wrapper for the visualization with program selector.
- `src/components/TimelineVisualization.tsx` — the D3 + React visualization (~2400 lines). This file draws the SVG, course bars with visual connectors for consecutive periods, prerequisite arrows, exam/re-exam markers, and handles SVG/PDF export.
- `src/app/api/export-pdf/route.ts` — API endpoint for server-side PDF generation using Puppeteer and `@sparticuz/chromium-min`.
- `src/app/api/export-pdf/route.ts` — API endpoint for server-side PDF generation using Puppeteer and `@sparticuz/chromium`.
- `src/types/course.ts` — TypeScript types (Course, Period, etc.) and the exported `academicPeriods` (loaded from JSON).
- `src/types/cosmetics.ts` — TypeScript types for program-specific visual customizations (colors, positions).

Expand Down Expand Up @@ -86,7 +86,7 @@ Exam/re-exam markers

**Export Functionality**:
- **SVG Export**: Downloads the visualization as a vector SVG file with embedded fonts (Figtree from Google Fonts).
- **PDF Export**: Server-side PDF generation using Puppeteer with Chrome for perfect font rendering and vector graphics. Configured for Vercel deployment with `@sparticuz/chromium-min`.
- **PDF Export**: Server-side PDF generation using Puppeteer with Chrome for perfect font rendering and vector graphics. Configured for Vercel deployment with `@sparticuz/chromium`.

**Tooltip Information**: Hover over courses to see total credits and per-period credit breakdown.

Expand All @@ -95,7 +95,7 @@ Troubleshooting
- Port 3000 already in use: find and kill the process `lsof -iTCP:3000 -sTCP:LISTEN -n -P` then `kill <PID>`.
- Suspended dev job (Ctrl+Z): resume with `fg` or start a background server with `nohup` as shown above.
- Type errors: run `npx tsc --noEmit` to see TypeScript diagnostics.
- PDF export not working on Vercel: Ensure `vercel.json` is deployed with the project and `@sparticuz/chromium-min` is in dependencies.
- PDF export not working on Vercel: Ensure `vercel.json` is deployed with the project and `@sparticuz/chromium` is in dependencies.

## Dependencies

Expand All @@ -104,7 +104,7 @@ Key production dependencies:
- `react` (19.x) — UI library
- `d3` (7.9.x) — Visualization and data manipulation
- `puppeteer-core` (23.x) — Headless browser control for PDF generation
- `@sparticuz/chromium-min` (141.x) — Serverless-compatible Chrome binary for Vercel
- `@sparticuz/chromium` (141.x) — Serverless-compatible Chrome binary for Vercel

Development dependencies include TypeScript, ESLint, and Tailwind CSS.

Expand Down
99 changes: 66 additions & 33 deletions src/app/api/export-pdf/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,62 +5,95 @@ import chromium from '@sparticuz/chromium';
export const runtime = 'nodejs';
export const maxDuration = 60; // Increase timeout for PDF generation

// Maximum accepted HTML payload size (10 MB). Larger payloads are rejected
// before launching Chrome to prevent memory exhaustion or slow serverless runs.
const MAX_HTML_BYTES = 10 * 1024 * 1024;

// Ordered list of candidate Chrome/Chromium paths for local development.
// Checked in sequence; the first one that satisfies Puppeteer is used.
const LOCAL_CHROME_PATHS = [
'/usr/bin/google-chrome',
'/usr/bin/chromium-browser',
'/usr/bin/chromium',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
];

export async function POST(req: NextRequest) {
// Enforce payload size limit before parsing JSON.
const contentLength = Number(req.headers.get('content-length') ?? '0');
if (contentLength > MAX_HTML_BYTES) {
return new Response('Payload too large', { status: 413 });
}

let html: string;
try {
// Get the HTML content from the request body
const { html } = await req.json();

if (!html) {
return new Response('Missing HTML content', { status: 400 });
}

// Detect if running on Vercel or locally
const isVercel = !!process.env.VERCEL;

let executablePath: string;

if (isVercel) {
// For Vercel, use @sparticuz/chromium which bundles the binary
executablePath = await chromium.executablePath();
const body = await req.json();
html = body?.html ?? '';
} catch {
return new Response('Invalid JSON body', { status: 400 });
}

if (!html) {
return new Response('Missing HTML content', { status: 400 });
}

if (Buffer.byteLength(html, 'utf8') > MAX_HTML_BYTES) {
return new Response('Payload too large', { status: 413 });
}

// Detect if running on Vercel or locally
const isVercel = !!process.env.VERCEL;

let executablePath: string;
if (isVercel) {
// Use @sparticuz/chromium which bundles the binary for serverless environments.
executablePath = await chromium.executablePath();
} else {
// Prefer an explicit override, then fall back through common install paths.
if (process.env.PUPPETEER_EXECUTABLE_PATH) {
executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
} else {
// For local development, use local Chrome
executablePath = process.env.PUPPETEER_EXECUTABLE_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const { existsSync } = await import('fs');
executablePath = LOCAL_CHROME_PATHS.find(existsSync) ?? LOCAL_CHROME_PATHS[LOCAL_CHROME_PATHS.length - 1];
}

// Launch headless browser
const browser = await puppeteer.launch({
args: isVercel
? chromium.args
}

let browser;
try {
browser = await puppeteer.launch({
args: isVercel
? chromium.args
: ['--no-sandbox', '--disable-setuid-sandbox'],
executablePath,
headless: true,
headless: true,
});

const page = await browser.newPage();

// Set the HTML content
await page.setContent(html, {
waitUntil: 'networkidle0' // Wait for fonts and resources to load
waitUntil: 'networkidle0', // Wait for fonts and resources to load
});

// Generate PDF with proper settings
const pdfBuffer = await page.pdf({
printBackground: true,
preferCSSPageSize: true,
format: undefined // Use the size defined in the HTML/CSS
format: undefined, // Use the size defined in the HTML/CSS
});

await browser.close();

return new Response(Buffer.from(pdfBuffer), {
status: 200,
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="program-visualization.pdf"'
}
'Content-Disposition': 'attachment; filename="program-visualization.pdf"',
},
});
} catch (e) {
console.error('PDF export failed', e);
return new Response(`Failed to generate PDF: ${e}`, { status: 500 });
return new Response('Failed to generate PDF. Check server logs for details.', { status: 500 });
} finally {
// Always close the browser to avoid leaking processes, even on error.
await browser?.close();
}
}
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const figtree = Figtree({

export const metadata: Metadata = {
title: "KTH - Visualisering av utbildningsprogram",
description: "Generated by create next app",
description: "Interactive timeline visualization of KTH degree programs, showing courses, study periods, exam dates, and prerequisites.",
};

export default function RootLayout({
Expand Down
127 changes: 0 additions & 127 deletions src/components/OptionGroupModal.tsx

This file was deleted.

10 changes: 5 additions & 5 deletions src/components/TimelineVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2165,7 +2165,6 @@ const TimelineVisualization = forwardRef(function TimelineVisualization({ course

// Vertical segments: group by y overlap (not used yet, but structure is ready)
const vSegs = segsByDir.vertical || [];
console.log(`Gap ${gapType}: ${vSegs.length} vertical segments from arrows:`, vSegs.map(s => s.arrowId));
const vGroups: OverlapGroup[] = [];
const sortedV = [...vSegs].sort((a, b) => a.y1 - b.y1);
let vCurrentGroup: ArrowSegment[] = [];
Expand All @@ -2181,7 +2180,6 @@ const TimelineVisualization = forwardRef(function TimelineVisualization({ course
}
});
if (vCurrentGroup.length > 0) vGroups.push(vCurrentGroup);
console.log(`Gap ${gapType}: ${vGroups.length} vertical overlap groups`);

overlapGroupsByGap[gapType] = { horizontal: hGroups, vertical: vGroups };
});
Expand Down Expand Up @@ -2305,9 +2303,6 @@ const TimelineVisualization = forwardRef(function TimelineVisualization({ course
const vLaneIdxStart = segmentLanes[`${arrow.prCode}->${arrow.targetCourse.code}`]?.[`vertical-${vGapTypeStart}-start`] ?? 0;
const vLaneIdxEnd = segmentLanes[`${arrow.prCode}->${arrow.targetCourse.code}`]?.[`vertical-${vGapTypeEnd}-end`] ?? 0;

const arrowId = `${arrow.prCode}->${arrow.targetCourse.code}`;
console.log(`Arrow ${arrowId}: isImmediatelyAfter=${isImmediatelyAfter}, hLaneIdx=${hLaneIdx}, vLaneStart=${vLaneIdxStart}, vLaneEnd=${vLaneIdxEnd}`);

// Compute the main routing points (including endpoints)
const points: [number, number][] = [];
const startX = arrow.from.xEnd;
Expand Down Expand Up @@ -2505,6 +2500,11 @@ const TimelineVisualization = forwardRef(function TimelineVisualization({ course
.style('display', layers.courseBars ? '' : 'none')
.style('pointer-events', layers.courseBars ? 'auto' : 'none');

// Course labels (follow bar visibility)
container.selectAll('.course-label')
.interrupt()
.style('display', layers.courseBars ? '' : 'none');

// Course bar borders
container.selectAll('.course-bar-border')
.interrupt()
Expand Down
Loading