diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..7367bd8 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,105 @@ +# This is a basic workflow to help you get started with Actions + +name: CI + +# Controls when the workflow will run +on: + # Triggers the workflow on push or pull request events but only for the "main" branch + push: + branches: [ "dev" ] + pull_request: + branches: [ "main" ] + paths: + - 'frontend/**' + - 'backend/**' + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v4 + + # Set up Node.js environment + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '23.6.0' # You can specify the Node.js version you need + + # Runs a single command using the runners shell + - name: CI starting + run: | + echo ⏳ CI testing begin... + + - name: Install Dependencies + run: | + echo ⏳ install frontend dependencies... + cd ./frontend/ + npm install + echo ✅ frontend dependencies installed! + echo ⏳ install backend dependencies... + cd ../backend/ + npm install + echo ✅ backend dependencies installed! + + - name: Building + run: | + echo ⏳ Building... + cd ./frontend/ + npm run build + echo ✅ Built! + + frontend-testing: + + runs-on: ubuntu-latest + + steps: + + - uses: actions/checkout@v4 + + # Deploy Render app + - name: Deploy Application + env: + deploy_url: ${{ secrets.RENDER_DEPLOY_HOOK_URL }} + run: | + echo ⏳ Frontend testing is setting up... + if [[ -z "$deploy_url" ]]; then + echo "❌ Error: deploy_url is not set!" + exit 1 + fi + echo "⏳ Triggering deployment..." + curl -v "$deploy_url" + sleep 60 + echo ✅ Application deployed! + + - name: Run e2e testing + run: | + echo ⏳ frontend e2e testing begin... + cd ./frontend/ + npm i + npm run test:e2e + echo ✅ frontend e2e testing ended! + + backend-testing: + + runs-on: ubuntu-latest + + steps: + + - uses: actions/checkout@v4 + + - name: Run API e2e testing + run: | + echo ⏳ API testing begin... + cd ./backend/ + npm i + npm run test:e2e + echo ✅ backend e2e testing ended! diff --git a/.gitignore b/.gitignore index 860f418..e28c115 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,31 @@ .DS_Store -.vscode/ \ No newline at end of file +.vscode/ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +temp_user_data +package-lock.json +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# API files +parsed_files diff --git a/backend/index.js b/backend/index.js new file mode 100644 index 0000000..2c77ec5 --- /dev/null +++ b/backend/index.js @@ -0,0 +1,240 @@ +import express from 'express'; +import multer from 'multer'; +import { fileURLToPath } from 'url'; +import { dirname, join, extname } from 'path'; +import { promises as fs } from 'fs'; +import cors from 'cors'; + +import { parseSDF } from './src/sdfProcess.js'; +import { parseVerilog } from './src/vProcess.js'; +import { mergeJsonForD3 } from './src/mergeVerilogSdf.js'; + +export const app = express(); +const PORT = 3001; + +// Get absolute path +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Authorize CORS +app.use(cors()); +app.use(express.json()); + +// Configure multer for handling SDF file upload +// Store file in memory, not on disk +const storage = multer.memoryStorage(); + +// Filter to verify file type +const upload = multer({ + storage: storage, + fileFilter: (req, file, cb) => { + const fieldExtensionMap = { + sdfFile: ['.sdf'], + verilogFile: ['.v'] + }; + + // Check if the file field name is in the map + const fileExtension = extname(file.originalname).toLowerCase(); + const allowedExtensions = fieldExtensionMap[file.fieldname]; + + if (allowedExtensions && allowedExtensions.includes(fileExtension)) { + cb(null, true); + } else { + req.fileValidationError = `Invalid file(s) format.`; + cb(null, false); + } + }, +}); + + +// Endpoint for uploading and parsing SDF & Verilog file +app.post('/api/upload', upload.fields([{ name: 'sdfFile' }, { name: 'verilogFile' }]), async (req, res) => { + try { + // verify if files are uploaded + if (req.fileValidationError) { + return res.status(400).send(req.fileValidationError); + } + + const sdfFile = req.files?.['sdfFile']?.[0]; + const verilogFile = req.files?.['verilogFile']?.[0]; + + // Check if files are uploaded + if (!sdfFile || !verilogFile) { + return res.status(400).send('Both SDF and Verilog files must be uploaded.'); + } + + const sdfContent = sdfFile.buffer.toString('utf-8').trim(); + const verilogContent = verilogFile.buffer.toString('utf-8').trim(); + + if (!sdfContent || !verilogContent) { + return res.status(400).send('One or both uploaded files are empty.'); + } + + // Parse SDF and Verilog files + let sdfData, verilogData, commonInstances; + try { + sdfData = parseSDF(sdfContent); + } catch (error) { + return res.status(500).send('Error parsing SDF file.'); + } + + try { + verilogData = parseVerilog(verilogContent); + } catch (error) { + return res.status(500).send('Error parsing Verilog file.'); + } + + try { + commonInstances = mergeJsonForD3(verilogData, sdfData); + } catch (error) { + return res.status(500).send('Error merging files.'); + } + + // Save parsed SDF and Verilog files + try { + const projectName = req.body.projectName; + if (!projectName) { + return res.status(400).send('Project name is required.'); + } + + //try if folder 'parsed_files' exists + try { + await fs.access(join(__dirname, 'parsed_files')); + } catch (error) { + try { + await fs.mkdir(join(__dirname, 'parsed_files')); + } catch (error) { + return res.status(500).send('Error creating directory.'); + } + } + + const projectJSON_Path = join(__dirname, 'parsed_files', `${projectName}.json`); + + //check if files exists + try { + // check if file exists + await fs.access(projectJSON_Path); + return res.status(400).send('The project already exists.'); + + } catch (error) { + try { + await fs.writeFile(join(projectJSON_Path), JSON.stringify(commonInstances, null, 2)); + res.status(200).send('Files uploaded successfully.'); + + } catch (error) { + res.status(500).send('Error saving parsed JSON files.'); + } + } + + } catch (error) { + res.status(500).send('Error saving parsed JSON files.'); + } + + } catch (error) { + res.status(500).send('Unexpected server error.'); + } +}); + +// Endpoint to show a error message if no project name is provided +app.get('/api/map', (req, res) => { + return res.status(400).send('Project name is required.'); +}); + +// Endpoint API for sending parsed SDF file +app.get('/api/map/:projectName', async (req, res) => { + try { + const projectName = req.params.projectName; + if (!projectName) { + return res.status(400).send('Project name is required.'); + } + + // Construct the file path using string concatenation + const jsonFilePath = join(__dirname, 'parsed_files', `${projectName}.json`); + + // Check if the file exists + await fs.access(jsonFilePath); + + // Read the file content + const jsonData = await fs.readFile(jsonFilePath, 'utf-8'); + res.json(JSON.parse(jsonData)); + + } catch (error) { + if (error.code === 'ENOENT') { + // File does not exist + return res.status(400).send('Project not found.'); + } + res.status(500).send('Error reading parsed SDF JSON file.'); + } +}); + +// Endpoint to delete a parsed SDF JSON file +app.delete('/api/delete-project/:projectName', async (req, res) => { + try { + const projectName = req.params.projectName; + + // Validate projectName + if (!projectName || typeof projectName !== 'string') { + return res.status(400).send('Invalid project name.'); + } + + const projectPath = join(__dirname, 'parsed_files', `${projectName}.json`); + + try { + // verify if file exists + await fs.access(projectPath); + } catch (err) { + // file does not exist + return res.status(404).send('Project does not exist.'); + } + + // Delete file + await fs.unlink(projectPath); + res.send('File deleted successfully.'); + + } catch (error) { + res.status(500).send('Error deleting file, please try again later.'); + } +}); + + +// Endpoint to list all SDF files +app.get('/api/list', async (req, res) => { + try { + const directoryPath = join(__dirname, 'parsed_files'); + + // Check if the directory exists + try { + await fs.access(directoryPath); + } catch (error) { + // Create the directory if it doesn't exist + await fs.mkdir(directoryPath, { recursive: true }); + } + + const entries = await fs.readdir(directoryPath, { withFileTypes: true }); + + // Prepare an array to hold file information + const filesInfo = []; + + // Iterate over entries to get file names and creation dates + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith('.json')) { + const filePath = join(directoryPath, entry.name); + const stats = await fs.stat(filePath); + + // Format the date to only include the date part (YYYY-MM-DD) + const createdDate = stats.birthtime.toISOString().split('T')[0]; + + filesInfo.push({ + name: entry.name.replace('.json', ''), + createdDate + }); + } + } + + res.json(filesInfo); + } catch (error) { + res.status(500).send('Error listing files.'); + } +}); + +export const server = app.listen(PORT, () => {}); \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..87572ee --- /dev/null +++ b/backend/package.json @@ -0,0 +1,28 @@ +{ + "name": "backend", + "version": "1.2.2", + "description": "backend for cnes fpga webapp", + "author": "Team 5", + "type": "module", + "main": "index.js", + "scripts": { + "test:e2e": "NODE_OPTIONS=--experimental-vm-modules npx jest --no-cache --detectOpenHandles" + }, + "jest": { + "transform": {}, + "testEnvironment": "node", + "extensionsToTreatAsEsm": [], + "moduleNameMapper": { + "^(\\.{1,2}/.*)\\.js$": "$1" + } + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.21.2", + "multer": "^1.4.5-lts.1" + }, + "devDependencies": { + "jest": "^29.7.0", + "supertest": "^7.0.0" + } +} diff --git a/backend/src/mergeVerilogSdf.js b/backend/src/mergeVerilogSdf.js new file mode 100644 index 0000000..59f22a8 --- /dev/null +++ b/backend/src/mergeVerilogSdf.js @@ -0,0 +1,74 @@ +export const mergeJsonForD3 = (verilogData, sdfData) => { + // Clone Verilog JSON to avoid modifying the original + let finalJSON = JSON.parse(JSON.stringify(verilogData)); + + // Create a map of SDF delays with normalized instance names + let sdfInstancesMap = new Map(); + if (sdfData.instances && Array.isArray(sdfData.instances)) { + sdfData.instances.forEach(inst => { + if (inst && inst.instanceName) { + // Normalize instance name + const normalizedName = inst.instanceName.trim(); + sdfInstancesMap.set(normalizedName, inst); + } + }); + } + + // Iterate over all modules and their instances + Object.values(finalJSON.modules).forEach(module => { + if (module.instances && Array.isArray(module.instances)) { + module.instances.forEach(instance => { + if (!instance.name) { + return; + } + + // Normalize Verilog instance name + const normalizedName = instance.name.trim(); + const sdfInstance = sdfInstancesMap.get(normalizedName); + + if (sdfInstance) { + // Case for DFF - delay is often a simple value + if (sdfInstance.cellType === "DFF") { + // Copy all delays and timing checks + instance.delays = sdfInstance.delays; + instance.timingChecks = sdfInstance.timingChecks; + } + // Case for LUT_K - delays are often an array + else if (sdfInstance.cellType === "LUT_K") { + instance.delays = sdfInstance.delays; + } + // Case for interconnections + else if (instance.type === "fpga_interconnect" && instance.connections) { + instance.connections.forEach(conn => { + // If delays are in an array + if (Array.isArray(sdfInstance.delays)) { + // For interconnections, we generally take the first delay + if (sdfInstance.delays.length > 0) { + conn.delay = sdfInstance.delays[0].delay; + } + } + // If delay is a simple value + else if (typeof sdfInstance.delays === "number") { + conn.delay = sdfInstance.delays; + } + }); + } + } + }); + } + }); + + // Final check + let delaysCount = 0; + Object.values(finalJSON.modules).forEach(module => { + if (module.instances) { + module.instances.forEach(instance => { + if (instance.delays || (instance.connections && instance.connections.some(c => c.delay))) { + delaysCount++; + } + }); + } + }); + + return finalJSON; +}; \ No newline at end of file diff --git a/backend/src/sdfProcess.js b/backend/src/sdfProcess.js new file mode 100644 index 0000000..a34aa92 --- /dev/null +++ b/backend/src/sdfProcess.js @@ -0,0 +1,143 @@ +// import function to clean instance names +import { cleanInstanceName } from './utils.js'; + +export const parseSDF = (sdfContent) => { + + // Extracting basic information + const designMatch = sdfContent.match(/\(DESIGN\s+"([^"]+)"\)/); + const design = designMatch ? designMatch[1] : ""; + + const timescaleMatch = sdfContent.match(/\(TIMESCALE\s+(\d+)\s+(\w+)\)/); + const timescale = timescaleMatch ? parseInt(timescaleMatch[1]) : 1; + const timeUnit = timescaleMatch ? timescaleMatch[2] : "ps"; + + // Extracting cells + const instances = []; + const cellPattern = /\(CELL\s*\(CELLTYPE\s+"([^"]+)"\)\s*\(INSTANCE\s+([^\)]+)\)/g; + + // Collect all matches in an array + const cellMatches = []; + let cellMatch; + while ((cellMatch = cellPattern.exec(sdfContent)) !== null) { + cellMatches.push({ + match: cellMatch, + index: cellMatch.index, + cellType: cellMatch[1], + rawInstanceName: cellMatch[2] + }); + } + + for (let i = 0; i < cellMatches.length; i++) { + const cellType = cellMatches[i].cellType; + const rawInstanceName = cellMatches[i].rawInstanceName; + + // Clean the instance name + const instanceName = cleanInstanceName(rawInstanceName); + + // Determine the end of this cell + let endPos; + if (i < cellMatches.length - 1) { + endPos = cellMatches[i + 1].index; + } else { + endPos = sdfContent.length; + } + + // Current cell content + const startPos = cellMatches[i].match.index + cellMatches[i].match[0].length; + const cellContent = sdfContent.substring(startPos, endPos); + + // Initialize cell data + const cellData = { + instanceName: instanceName, + cellType: cellType + }; + + // For DFF, handle specifically + if (cellType === "DFF") { + // Search for IOPATH with posedge/negedge for DFF + const dffIopathMatch = cellContent.match(/\(IOPATH\s+\(posedge\s+\w+\)\s+\w+\s+\(([^)]+)\)/); + + if (dffIopathMatch) { + // Extract the delay triplet (min:typ:max) + const delayTriplet = dffIopathMatch[1]; + // Take the typical value (middle) for the delay + // Expected format: "min:typ:max" + const delayParts = delayTriplet.split(':'); + let delayValue; + if (delayParts.length >= 2) { + // If it's a triplet, take the typical value (middle) + delayValue = parseInt(delayParts[1]); + } else { + // If it's a single value + delayValue = parseInt(delayParts[0]); + } + cellData.delays = delayValue; + } + + // Search for TIMINGCHECK (SETUP) information + const setupMatch = cellContent.match(/\(SETUP\s+\w+\s+\(posedge\s+\w+\)\s+\(([^)]+)\)\)/); + + if (setupMatch) { + // Extract the triplet for the setup time + const setupTriplet = setupMatch[1]; + // Process the "min:typ:max" triplet + const setupParts = setupTriplet.split(':'); + let setupTime; + if (setupParts.length >= 2) { + // Take the typical value + setupTime = parseFloat(setupParts[1]); + } else { + setupTime = parseFloat(setupParts[0]); + } + cellData.timingChecks = setupTime; + } + } else { + // Standard IOPATH search + const iopathPattern = /\(IOPATH\s+(\S+)\s+(\S+)\s+\(([^:)]+)(?::([^:)]+))?(?::([^)]+))?\)/g; + + let iopathMatch; + let hasDelays = false; + cellData.delays = []; + + while ((iopathMatch = iopathPattern.exec(cellContent)) !== null) { + hasDelays = true; + const fromPin = iopathMatch[1]; + const toPin = iopathMatch[2]; + + // Extract the first delay value or take the typical value if it's a triplet + const delayStr = iopathMatch[3]; + let delayValue; + if (delayStr.includes(':')) { + const delayParts = delayStr.split(':'); + delayValue = delayParts.length > 1 ? parseFloat(delayParts[1]) : parseFloat(delayParts[0]); + } else { + delayValue = parseFloat(delayStr); + } + + cellData.delays.push({ + from: fromPin, + to: toPin, + delay: delayValue + }); + } + + // If no delays found, clean up the structure + if (!hasDelays && "delays" in cellData) { + delete cellData.delays; + } + } + + // Add the cell to the result + instances.push(cellData); + } + + // Building the final result + const result = { + design: design, + timescale: timescale, + timeUnit: timeUnit, + instances: instances + }; + + return result; +} \ No newline at end of file diff --git a/backend/src/utils.js b/backend/src/utils.js new file mode 100644 index 0000000..5674ddb --- /dev/null +++ b/backend/src/utils.js @@ -0,0 +1,14 @@ +// Clean instance name +export const cleanInstanceName = (name) => { + // Trim any leading or trailing spaces + name = name.trim(); + + // Replace escape sequences to match SDF format + name = name.replace(/\\\$/g, '$'); + name = name.replace(/\\\./g, '.'); + name = name.replace(/\\:/g, ':'); + name = name.replace(/\\~/g, '~'); + name = name.replace(/\\\^/g, '^'); + + return name; +} \ No newline at end of file diff --git a/backend/src/vProcess.js b/backend/src/vProcess.js new file mode 100644 index 0000000..9a51853 --- /dev/null +++ b/backend/src/vProcess.js @@ -0,0 +1,173 @@ +// import function to clean instance names +import { cleanInstanceName } from './utils.js'; + +// Helper function to split a connection string into device and IO parts +const splitIntoDeviceAndIO = (connectionString) => { + // This regex pattern looks for strings ending with input_X_X or output_X_X pattern + const match = connectionString.match(/(.+)_(input|output|clock)_(\d+)_(\d+)$/); + + if (match) { + return { + device: match[1], + io: `${match[2]}_${match[3]}_${match[4]}` + }; + } + + // If no pattern is matched, return the original string as device and empty IO + return { + device: connectionString, + io: "" + }; +} + +export const parseVerilog = (verilogText) => { + // Initialize the result dictionary with the required structure + const result = { + "modules": {} + }; + + // Extract module name + const moduleMatch = verilogText.match(/module\s+(\w+)\s*\(/); + const moduleName = moduleMatch[1]; + + result.modules[moduleName] = { + "ports": [], + "instances": [], + "connections": [] + }; + + // Extract ports + const portSectionRegex = /\((.*?)\);/s; + const portSection = verilogText.match(portSectionRegex); + if (portSection) { + const portText = portSection[1]; + + // Extract input ports + const inputPortRegex = /input\s+\\(\w+)\s*,?/g; + let inputPortMatch; + let inputCount = 0; + while ((inputPortMatch = inputPortRegex.exec(portText)) !== null) { + inputCount++; + const cleanName = cleanInstanceName(inputPortMatch[1]); + result.modules[moduleName].ports.push({ + "type": "input", + "name": cleanName + }); + } + + // Extract output ports + const outputPortRegex = /output\s+\\(\w+)\s*,?/g; + let outputPortMatch; + let outputCount = 0; + while ((outputPortMatch = outputPortRegex.exec(portText)) !== null) { + outputCount++; + const cleanName = cleanInstanceName(outputPortMatch[1]); + result.modules[moduleName].ports.push({ + "type": "output", + "name": cleanName + }); + } + } + + // Extract IO assignments (connections between ports and internal signals) + const ioAssignmentRegex = /assign\s+\\([^=]+)\s*=\s*\\([^;]+);/g; + let ioAssignmentMatch; + let assignCount = 0; + while ((ioAssignmentMatch = ioAssignmentRegex.exec(verilogText)) !== null) { + assignCount++; + const fromClean = cleanInstanceName(ioAssignmentMatch[2].trim()); + const toClean = cleanInstanceName(ioAssignmentMatch[1].trim()); + + // Split the from and to into device and IO parts + const fromParts = splitIntoDeviceAndIO(fromClean); + const toParts = splitIntoDeviceAndIO(toClean); + + result.modules[moduleName].connections.push({ + "from": fromClean, + "to": toClean, + "fromDevice": fromParts.device, + "fromIO": fromParts.io, + "toDevice": toParts.device, + "toIO": toParts.io + }); + } + + // Extract interconnects + const interconnectPattern = /fpga_interconnect\s+\\([^(]+)\s*\(\s*\.datain\(\\([^)]+)\),\s*\.dataout\(\\([^)]+)\)\s*\);/g; + let interconnectMatch; + let interconnectCount = 0; + while ((interconnectMatch = interconnectPattern.exec(verilogText)) !== null) { + interconnectCount++; + const instanceName = cleanInstanceName(interconnectMatch[1]); + const datain = cleanInstanceName(interconnectMatch[2]); + const dataout = cleanInstanceName(interconnectMatch[3]); + + // Split the datain and dataout into device and IO parts + const fromParts = splitIntoDeviceAndIO(datain); + const toParts = splitIntoDeviceAndIO(dataout); + + result.modules[moduleName].instances.push({ + "type": "fpga_interconnect", + "name": instanceName, + "connections": [ + { + "fromDevice": fromParts.device, + "fromIO": fromParts.io, + "toDevice": toParts.device, + "toIO": toParts.io + } + ] + }); + } + + // Extract cell instances (like DFF and LUT_K) + const cellPattern = /(\w+)\s+#\(\s*(.*?)\)\s*\\([^(]+)\s*\(\s*(.*?)\);/gs; + let cellMatch; + let dffCount = 0; + let lutCount = 0; + let otherCellCount = 0; + + while ((cellMatch = cellPattern.exec(verilogText)) !== null) { + const cellType = cellMatch[1]; + const paramsText = cellMatch[2]; + const cellName = cleanInstanceName(cellMatch[3]); + + if (cellType === "DFF") { + dffCount++; + + // Add connections if ports were found + const dffInstance = { + "type": cellType, + "name": cellName + }; + + result.modules[moduleName].instances.push(dffInstance); + } + else if (cellType === "LUT_K") { + lutCount++; + + // Parse LUT_K parameters + const kMatch = paramsText.match(/\.K\(([^)]+)\)/); + const maskMatch = paramsText.match(/\.LUT_MASK\(([^)]+)\)/); + + // Add LUT_K instance with parameters and connections + const lutInstance = { + "type": cellType, + "name": cellName + }; + + // Add K and LUT_MASK parameters if found + if (kMatch) { + lutInstance.K = kMatch[1].trim(); + } + if (maskMatch) { + lutInstance.LUT_MASK = maskMatch[1].trim(); + } + result.modules[moduleName].instances.push(lutInstance); + } else { + otherCellCount++; + } + } + + return result; +} \ No newline at end of file diff --git a/backend/tests/index.test.js b/backend/tests/index.test.js new file mode 100644 index 0000000..39f0a16 --- /dev/null +++ b/backend/tests/index.test.js @@ -0,0 +1,306 @@ +import request from 'supertest'; +import { expect, it, jest } from '@jest/globals'; +import { app, server } from '../index.js'; + +// Mock fs.promises methods directly +jest.mock('fs', () => ({ + promises: { + access: jest.fn(), + writeFile: jest.fn(), + readFile: jest.fn(), + readdir: jest.fn(), + unlink: jest.fn(), + }, +})); + +describe('API Endpoints Tests', () => { + afterAll((done) => { + server.close(done); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ! ||--------------------------------------------------------------------------------|| + // ! || In POST /api/upload/ test: || + // ! ||--------------------------------------------------------------------------------|| + + // Normal Conditions Test + it('successfully uploads and parses an SDF file with a right project name', async () => { + const response = await request(app) + .post('/api/upload') + .field('projectName', 'testProject') + .attach('sdfFile', Buffer.from('test'), './testFiles/exampleSDF.sdf') + .attach('verilogFile', Buffer.from('test2'), './testFiles/exampleVerilog.v'); + + expect(response.status).toBe(200); + expect(response.text).toBe('Files uploaded successfully.'); + }); + + // No files uploaded Test + it('fail to uploads an SDF and a Verilog file (no files uploaded)', async () => { + const response = await request(app) + .post('/api/upload') + .field('projectName', 'testProjectFailing'); + + expect(response.status).toBe(400); + expect(response.text).toBe('Both SDF and Verilog files must be uploaded.'); + }); + + // No SDF file uploaded Test + it('fail to uploads only an SDF file (no SDF file uploaded)', async () => { + const response = await request(app) + .post('/api/upload') + .field('projectName', 'testProjectFailing') + .attach('verilogFile', Buffer.from('test2'), './testFiles/exampleVerilog.v'); + + expect(response.status).toBe(400); + expect(response.text).toBe('Both SDF and Verilog files must be uploaded.'); + }); + + // No Verilog file uploaded Test + it('fail to uploads only a Verilog file (no Verilog file uploaded)', async () => { + const response = await request(app) + .post('/api/upload') + .field('projectName', 'testProjectFailing') + .attach('sdfFile', Buffer.from('test'), './testFiles/exampleSDF.sdf'); + + expect(response.status).toBe(400); + expect(response.text).toBe('Both SDF and Verilog files must be uploaded.'); + }); + + // Empty SDF and empty verilog file uploaded Test + it('fail to uploads an empty SDF and an empty Verilog file (no file with content uploaded)', async () => { + const response = await request(app) + .post('/api/upload') + .field('projectName', 'testProjectFailing') + .attach('sdfFile', Buffer.from('test'), './testFiles/emptySDF.sdf') + .attach('verilogFile', Buffer.from('test2'), './testFiles/emptyVerilog.v'); + + expect(response.status).toBe(400); + expect(response.text).toBe('One or both uploaded files are empty.'); + }); + + // Empty SDF file uploaded Test + it('fail to uploads an empty SDF file (no file with content uploaded)', async () => { + const response = await request(app) + .post('/api/upload') + .field('projectName', 'testProjectFailing') + .attach('sdfFile', Buffer.from('test'), './testFiles/emptySDF.sdf') + .attach('verilogFile', Buffer.from('test2'), './testFiles/exampleVerilog.v'); + + expect(response.status).toBe(400); + expect(response.text).toBe('One or both uploaded files are empty.'); + }); + + // Empty Verilog file uploaded Test + it('fail to uploads an empty Verilog file (no file with content uploaded)', async () => { + const response = await request(app) + .post('/api/upload') + .field('projectName', 'testProjectFailing') + .attach('sdfFile', Buffer.from('test'), './testFiles/exampleSDF.sdf') + .attach('verilogFile', Buffer.from('test2'), './testFiles/emptyVerilog.v'); + + expect(response.status).toBe(400); + expect(response.text).toBe('One or both uploaded files are empty.'); + }); + + // No project name given Test + it('fail to uploads files without project name (no project name given)', async () => { + const response = await request(app) + .post('/api/upload') + .attach('sdfFile', Buffer.from('test'), './testFiles/exampleSDF.sdf') + .attach('verilogFile', Buffer.from('test2'), './testFiles/exampleVerilog.v'); + + expect(response.status).toBe(400); + expect(response.text).toBe('Project name is required.'); + }); + + // Project already exist Test + it('fail to uploads files (project already exists)', async () => { + const response = await request(app) + .post('/api/upload') + .field('projectName', 'testProject') + .attach('sdfFile', Buffer.from('test'), './testFiles/exampleSDF.sdf') + .attach('verilogFile', Buffer.from('test2'), './testFiles/exampleVerilog.v'); + + expect(response.status).toBe(400); + expect(response.text).toBe('The project already exists.'); + }); + + // Uploading files that doesn't exist Test + it('fail to uploads an SDF and a Verilog files that doesn\'t exist (file doesn\'t exist)', async () => { + const response = await request(app) + .post('/api/upload') + .field('projectName', 'testProjectFailing') + .attach('sdfFile', Buffer.from('test'), './testFiles/nonExistent.sdf') + .attach('verilogFile', Buffer.from('test'), './testFiles/nonExistent.v'); + + expect(response.status).toBe(404); + expect(response.text).toBe('File not found.'); + }); + + // Uploading a Verilog file that doesn't exist Test + it('fail to uploads an SDF and a Verilog files that doesn\'t exist (file doesn\'t exist)', async () => { + const response = await request(app) + .post('/api/upload') + .field('projectName', 'testProjectFailing') + .attach('sdfFile', Buffer.from('test'), './testFiles/exampleSDF.sdf') + .attach('verilogFile', Buffer.from('test'), './testFiles/nonExistent.v'); + + expect(response.status).toBe(404); + expect(response.text).toBe('File not found.'); + }); + + // Uploading a SDF file that doesn't exist Test + it('fail to uploads an SDF and a Verilog files that doesn\'t exist (file doesn\'t exist)', async () => { + const response = await request(app) + .post('/api/upload') + .field('projectName', 'testProjectFailing') + .attach('sdfFile', Buffer.from('test'), './testFiles/nonExistent.sdf') + .attach('verilogFile', Buffer.from('test'), './testFiles/exampleVerilog.v'); + + expect(response.status).toBe(404); + expect(response.text).toBe('File not found.'); + }); + + // Uploading invalid files format Test + it('fails to uploads an SDF and a Verilog files with the wrong format (invalid file format)', async () => { + const response = await request(app) + .post('/api/upload') + .field('projectName', 'testProjectFailing') + .attach('sdfFile', Buffer.from('test'), './testFiles/invalidSDFFormat.txt') + .attach('verilogFile', Buffer.from('test'), './testFiles/invalidVerilogFormat.txt'); + + expect(response.status).toBe(400); + expect(response.text).toBe('Invalid file(s) format.'); + }); + + // Uploading invalid SDF file format Test + it('fails to uploads an SDF file with the wrong format (invalid file format)', async () => { + const response = await request(app) + .post('/api/upload') + .field('projectName', 'testProjectFailing') + .attach('sdfFile', Buffer.from('test'), './testFiles/invalidSDFFormat.txt') + .attach('verilogFile', Buffer.from('test'), './testFiles/exampleVerilog.v'); + + expect(response.status).toBe(400); + expect(response.text).toBe('Invalid file(s) format.'); + }); + + // Uploading invalid Verilog file format Test + it('fails to uploads a Verilog file with the wrong format (invalid file format)', async () => { + const response = await request(app) + .post('/api/upload') + .field('projectName', 'testProjectFailing') + .attach('sdfFile', Buffer.from('test'), './testFiles/exampleSDF.sdf') + .attach('verilogFile', Buffer.from('test'), './testFiles/invalidVerilogFormat.txt'); + + expect(response.status).toBe(400); + expect(response.text).toBe('Invalid file(s) format.'); + }); + + // Uploading 2 times an SDF file format Test + it('fails to uploads 2 times the same SDF file (uploading 2 times the same file)', async () => { + const response = await request(app) + .post('/api/upload') + .field('projectName', 'testProjectFailing') + .attach('sdfFile', Buffer.from('test'), './testFiles/exampleSDF.sdf') + .attach('verilogFile', Buffer.from('test2'), './testFiles/exampleSDF.sdf'); + + expect(response.status).toBe(400); + expect(response.text).toBe('Invalid file(s) format.'); + }); + + // Uploading 2 times a Verilog file format Test + it('fails to uploads 2 times the same Verilog file (uploading 2 times the same file)', async () => { + const response = await request(app) + .post('/api/upload') + .field('projectName', 'testProjectFailing') + .attach('sdfFile', Buffer.from('test'), './testFiles/exampleVerilog.v') + .attach('verilogFile', Buffer.from('test2'), './testFiles/exampleVerilog.v'); + + expect(response.status).toBe(400); + expect(response.text).toBe('Invalid file(s) format.'); + }); + + // Uploading 2 times a wrong file format Test + it('fails to uploads 2 times the same wrong format file (uploading 2 times the same file)', async () => { + const response = await request(app) + .post('/api/upload') + .field('projectName', 'testProjectFailing') + .attach('sdfFile', Buffer.from('test'), './testFiles/invalidSDFFormat.txt') + .attach('verilogFile', Buffer.from('test2'), './testFiles/invalidSDFFormat.txt'); + + expect(response.status).toBe(400); + expect(response.text).toBe('Invalid file(s) format.'); + }); + + + // ! ||--------------------------------------------------------------------------------|| + // ! || In GET /api/map/:projectName test: || + // ! ||--------------------------------------------------------------------------------|| + + // Normal Conditions Test + it('successfully fetch a project file', async () => { + const response = await request(app).get('/api/map/testProject'); + expect(response.status).toBe(200); + expect(response.headers['content-type']).toMatch(/json/); + }); + + // Project not found Test + it('fail to fetch a project that doesn\'t exist (project not found)', async () => { + const response = await request(app).get('/api/map/nonExistent'); + expect(response.status).toBe(404); + expect(response.text).toBe('Project not found.'); + }); + + // Project name not given Test + it('fail to fetch a project without passing the project\'s name (project name is required)', async () => { + const response = await request(app).get('/api/map/'); + expect(response.status).toBe(400); + expect(response.text).toBe('Project name is required.'); + }); + + // ! ||--------------------------------------------------------------------------------|| + // ! || In GET /api/list test: || + // ! ||--------------------------------------------------------------------------------|| + + // Normal Conditions Test + it('successfully fetch the list of all projects', async () => { + const response = await request(app).get('/api/list'); + expect(response.status).toBe(200); + expect(response.headers['content-type']).toMatch(/json/); + + // Parse and validate the response + const data = JSON.parse(response.text); + + // Type checking + expect(Array.isArray(data)).toBe(true); + data.forEach(item => { + expect(typeof item).toBe('string'); + }); + + // Content validation + expect(data).toEqual(["testProject"]); + }); + + // ! ||--------------------------------------------------------------------------------|| + // ! || In DELETE /api/delete-project/:projectName test: || + // ! ||--------------------------------------------------------------------------------|| + + // Normal Conditions Test + it('successfully deletes the parsed JSON file', async () => { + const response = await request(app).delete('/api/delete-project/testProject'); + expect(response.status).toBe(200); + expect(response.text).toBe('Project deleted successfully.'); + }); + + // Project not found Test + it('fail to delete the parsed JSON file (file not found)', async () => { + const response = await request(app).delete('/api/delete-project/nonExistent'); + expect(response.status).toBe(404); + expect(response.text).toBe('Project does not exist.'); + }); +}); \ No newline at end of file diff --git a/backend/tests/testFiles/emptySDF.sdf b/backend/tests/testFiles/emptySDF.sdf new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/testFiles/emptyVerilog.v b/backend/tests/testFiles/emptyVerilog.v new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/testFiles/exampleSDF.sdf b/backend/tests/testFiles/exampleSDF.sdf new file mode 100644 index 0000000..eb94bc4 --- /dev/null +++ b/backend/tests/testFiles/exampleSDF.sdf @@ -0,0 +1,84 @@ +(DELAYFILE + (SDFVERSION "2.1") + (DESIGN "FF1") + (VENDOR "verilog-to-routing") + (PROGRAM "vpr") + (VERSION "9.0.0-dev+v8.0.0-11943-g8cb20aa52-dirty") + (DIVIDER /) + (TIMESCALE 1 ps) + + (CELL + (CELLTYPE "fpga_interconnect") + (INSTANCE routing_segment_D_output_0_0_to_lut_\$auto\$rtlil\.cc\:2714\:MuxGate\$140_input_0_2) + (DELAY + (ABSOLUTE + (IOPATH datain dataout (235.697:235.697:235.697) (235.697:235.697:235.697)) + ) + ) + ) + + (CELL + (CELLTYPE "fpga_interconnect") + (INSTANCE routing_segment_reset_output_0_0_to_lut_\$auto\$rtlil\.cc\:2714\:MuxGate\$140_input_0_1) + (DELAY + (ABSOLUTE + (IOPATH datain dataout (617.438:617.438:617.438) (617.438:617.438:617.438)) + ) + ) + ) + + (CELL + (CELLTYPE "fpga_interconnect") + (INSTANCE routing_segment_clk_output_0_0_to_latch_Q_clock_0_0) + (DELAY + (ABSOLUTE + (IOPATH datain dataout (10:10:10) (10:10:10)) + ) + ) + ) + + (CELL + (CELLTYPE "fpga_interconnect") + (INSTANCE routing_segment_latch_Q_output_0_0_to_Q_input_0_0) + (DELAY + (ABSOLUTE + (IOPATH datain dataout (1079.77:1079.77:1079.77) (1079.77:1079.77:1079.77)) + ) + ) + ) + + (CELL + (CELLTYPE "fpga_interconnect") + (INSTANCE routing_segment_lut_\$auto\$rtlil\.cc\:2714\:MuxGate\$140_output_0_0_to_latch_Q_input_0_0) + (DELAY + (ABSOLUTE + (IOPATH datain dataout (96:96:96) (96:96:96)) + ) + ) + ) + + (CELL + (CELLTYPE "LUT_K") + (INSTANCE lut_\$auto\$rtlil\.cc\:2714\:MuxGate\$140) + (DELAY + (ABSOLUTE + (IOPATH in[1] out (152:152:152) (152:152:152)) + (IOPATH in[2] out (150:150:150) (150:150:150)) + ) + ) + ) + + (CELL + (CELLTYPE "DFF") + (INSTANCE latch_Q) + (DELAY + (ABSOLUTE + (IOPATH (posedge clock) Q (303:303:303) (303:303:303)) + ) + ) + (TIMINGCHECK + (SETUP D (posedge clock) (-46:-46:-46)) + ) + ) + +) diff --git a/backend/tests/testFiles/exampleVerilog.v b/backend/tests/testFiles/exampleVerilog.v new file mode 100644 index 0000000..24e36b0 --- /dev/null +++ b/backend/tests/testFiles/exampleVerilog.v @@ -0,0 +1,77 @@ +//Verilog generated by VPR 9.0.0-dev+v8.0.0-11943-g8cb20aa52-dirty from post-place-and-route implementation +module FF1 ( + input \D , + input \reset , + input \clk , + output \Q +); + + //Wires + wire \D_output_0_0 ; + wire \reset_output_0_0 ; + wire \clk_output_0_0 ; + wire \latch_Q_output_0_0 ; + wire \lut_$auto$rtlil.cc:2714:MuxGate$140_output_0_0 ; + wire \lut_$auto$rtlil.cc:2714:MuxGate$140_input_0_2 ; + wire \lut_$auto$rtlil.cc:2714:MuxGate$140_input_0_1 ; + wire \latch_Q_clock_0_0 ; + wire \Q_input_0_0 ; + wire \latch_Q_input_0_0 ; + + //IO assignments + assign \Q = \Q_input_0_0 ; + assign \D_output_0_0 = \D ; + assign \reset_output_0_0 = \reset ; + assign \clk_output_0_0 = \clk ; + + //Interconnect + fpga_interconnect \routing_segment_D_output_0_0_to_lut_$auto$rtlil.cc:2714:MuxGate$140_input_0_2 ( + .datain(\D_output_0_0 ), + .dataout(\lut_$auto$rtlil.cc:2714:MuxGate$140_input_0_2 ) + ); + + fpga_interconnect \routing_segment_reset_output_0_0_to_lut_$auto$rtlil.cc:2714:MuxGate$140_input_0_1 ( + .datain(\reset_output_0_0 ), + .dataout(\lut_$auto$rtlil.cc:2714:MuxGate$140_input_0_1 ) + ); + + fpga_interconnect \routing_segment_clk_output_0_0_to_latch_Q_clock_0_0 ( + .datain(\clk_output_0_0 ), + .dataout(\latch_Q_clock_0_0 ) + ); + + fpga_interconnect \routing_segment_latch_Q_output_0_0_to_Q_input_0_0 ( + .datain(\latch_Q_output_0_0 ), + .dataout(\Q_input_0_0 ) + ); + + fpga_interconnect \routing_segment_lut_$auto$rtlil.cc:2714:MuxGate$140_output_0_0_to_latch_Q_input_0_0 ( + .datain(\lut_$auto$rtlil.cc:2714:MuxGate$140_output_0_0 ), + .dataout(\latch_Q_input_0_0 ) + ); + + + //Cell instances + LUT_K #( + .K(5), + .LUT_MASK(32'b00000000000000000000000000010000) + ) \lut_$auto$rtlil.cc:2714:MuxGate$140 ( + .in({ + 1'b0, + 1'b0, + \lut_$auto$rtlil.cc:2714:MuxGate$140_input_0_2 , + \lut_$auto$rtlil.cc:2714:MuxGate$140_input_0_1 , + 1'b0 + }), + .out(\lut_$auto$rtlil.cc:2714:MuxGate$140_output_0_0 ) + ); + + DFF #( + .INITIAL_VALUE(1'b0) + ) \latch_Q ( + .D(\latch_Q_input_0_0 ), + .Q(\latch_Q_output_0_0 ), + .clock(\latch_Q_clock_0_0 ) + ); + +endmodule diff --git a/backend/tests/testFiles/invalidSDFFormat.txt b/backend/tests/testFiles/invalidSDFFormat.txt new file mode 100644 index 0000000..9817cfa --- /dev/null +++ b/backend/tests/testFiles/invalidSDFFormat.txt @@ -0,0 +1 @@ +THIS IS AN INVALID FILE FORMAT MADE FOR TEST PURPOSE! \ No newline at end of file diff --git a/backend/tests/testFiles/invalidVerilogFormat.txt b/backend/tests/testFiles/invalidVerilogFormat.txt new file mode 100644 index 0000000..9817cfa --- /dev/null +++ b/backend/tests/testFiles/invalidVerilogFormat.txt @@ -0,0 +1 @@ +THIS IS AN INVALID FILE FORMAT MADE FOR TEST PURPOSE! \ No newline at end of file diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..9eff286 --- /dev/null +++ b/frontend/.env @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:3001 \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..fd3b758 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,12 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript and enable type-aware lint rules. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..ec2b712 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,33 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' + +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...js.configs.recommended.rules, + ...reactHooks.configs.recommended.rules, + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..6f329bf --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + SPIN - CNES + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..105e143 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,41 @@ +{ + "name": "frontend", + "private": true, + "version": "1.2.2", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview --host", + "test:e2e": "npx tsx --no-warnings node_modules/.bin/mocha --timeout 30000 tests/e2e/**/*.spec.ts" + }, + "dependencies": { + "@tailwindcss/vite": "^4.0.14", + "d3": "^7.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.3.0", + "tailwindcss": "^4.0.14" + }, + "devDependencies": { + "@eslint/js": "^9.21.0", + "@types/chai": "^5.2.0", + "@types/mocha": "^10.0.10", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@types/selenium-webdriver": "^4.1.28", + "@vitejs/plugin-react": "^4.3.4", + "chai": "^5.2.0", + "chromedriver": "^134.0.3", + "eslint": "^9.21.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^15.15.0", + "mocha": "^11.1.0", + "selenium-webdriver": "^4.29.0", + "tsx": "^4.19.3", + "typescript": "^5.8.2", + "vite": "^6.2.0" + } +} diff --git a/frontend/public/images/logo.png b/frontend/public/images/logo.png new file mode 100644 index 0000000..210d4ed Binary files /dev/null and b/frontend/public/images/logo.png differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..36f6f33 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,16 @@ +import { Routes, Route } from "react-router-dom"; +import Home from "./pages/home"; +import Create from "./pages/create"; +import Visualize from "./pages/visualize"; + +function App() { + return ( + + } /> + } /> + } /> + + ); +} + +export default App; diff --git a/frontend/src/config.js b/frontend/src/config.js new file mode 100644 index 0000000..dbdb01d --- /dev/null +++ b/frontend/src/config.js @@ -0,0 +1,11 @@ +//this code is for the prod version + +// const GET_API_URL = () => { +// const protocol = window.location.protocol; // 'http:' ou 'https:' +// const host = window.location.host; // 'localhost:3000' or 'www.example.com' +// return `${protocol}//${host}`; +// }; + +const API_URL = import.meta.env.VITE_API_URL; + +export default API_URL; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..db09dd9 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,79 @@ +@import "tailwindcss"; +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + height: 100%; + margin: 0; + padding: 0; + + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color: #fff; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + + max-width: 100vw; + max-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + cursor: pointer; + transition: border-color 0.25s; +} + +button:hover { + border-color: #646cff; +} + +#root { + width: 100%; + height: 100%; +} + +.full-page{ + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 0; + overflow: auto; +} + +.full-page-container{ + height: 70vh; + width: 90%; + margin: auto; +} + +.full-page-container > svg { + width: 100%; + height: 100%; +} \ No newline at end of file diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..0a7fe63 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")).render( + + + + + +); diff --git a/frontend/src/pages/create.jsx b/frontend/src/pages/create.jsx new file mode 100644 index 0000000..c0181a3 --- /dev/null +++ b/frontend/src/pages/create.jsx @@ -0,0 +1,347 @@ +import { useState, useRef, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import API_URL from "../config"; + +function Create() { + const navigate = useNavigate(); + const fileInputRef = useRef(null); + + // State for existing examples + const [projectExamples, setProjectExamples] = useState([]); + + useEffect(() => { + const fetchData = async () => { + try { + const response = await fetch(`${API_URL}/api/list`); + if (!response.ok) throw new Error("Erreur lors du fetch"); + + const data = await response.json(); + + // adapt the data to our needs + const formattedData = data.map((item) => ({ + name: item.name, + date: item.createdDate, + })); + + setProjectExamples(formattedData); + } catch (error) { + throw new Error("API error :", error); + } + }; + + fetchData(); + }, []); + + // State for the new example + const [exampleName, setExampleName] = useState(""); + const [uploadedVerilogFiles, setUploadedVerilogFiles] = useState([]); + const [isDragActive, setIsDragActive] = useState(false); + + // Drag and drop handling + const handleDrag = (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.type === "dragenter" || e.type === "dragover") { + setIsDragActive(true); + } else if (e.type === "dragleave") { + setIsDragActive(false); + } + }; + + // File drop handling + const handleDrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragActive(false); + + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + handleFiles(e.dataTransfer.files); + } + }; + + // Click handling for the drop zone + const handleClick = () => { + fileInputRef.current.click(); + }; + + // File selection handling via button + const handleChange = (e) => { + e.preventDefault(); + if (e.target.files && e.target.files.length > 0) { + handleFiles(e.target.files); + } + }; + + // File processing + const handleFiles = (files) => { + const fileArray = Array.from(files); + const validFiles = fileArray.filter(file => { + const extension = file.name.split('.').pop().toLowerCase(); + return extension === 'v' || extension === 'sdf'; + }); + + if (validFiles.length > 0) { + setUploadedVerilogFiles([...uploadedVerilogFiles, ...validFiles]); + } else { + alert("Please upload only Verilog (.v) or SDF (.sdf) files"); + } + }; + + // Remove a file from the list + const handleRemoveFile = (index) => { + const newFiles = [...uploadedVerilogFiles]; + newFiles.splice(index, 1); + setUploadedVerilogFiles(newFiles); + }; + + // Create a new example + const handleCreateExample = async () => { + if (!exampleName.trim()) { + alert("Please name your example"); + return; + } + + if (uploadedVerilogFiles.length === 0) { + alert("Please upload at least one file"); + return; + } + + // Check if we have both a Verilog file and an SDF file + const hasVerilog = uploadedVerilogFiles.some(file => file.name.endsWith('.v')); + const hasSDF = uploadedVerilogFiles.some(file => file.name.endsWith('.sdf')); + + if (!hasVerilog || !hasSDF || uploadedVerilogFiles.length > 2) { + alert("Please upload both a Verilog (.v) file and an SDF (.sdf) file"); + return; + } + + // Find the files based on their extensions + const verilogFile = uploadedVerilogFiles.find(file => file.name.endsWith('.v')); + const sdfFile = uploadedVerilogFiles.find(file => file.name.endsWith('.sdf')); + + // Create a FormData object to send the files + const formData = new FormData(); + formData.append('verilogFile', verilogFile); + formData.append('sdfFile', sdfFile); + formData.append('projectName', exampleName); + + try { + const response = await fetch(`${API_URL}/api/upload`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + // Create a new example with the formatted date + const currentDate = new Date(); + const formattedDate = `${currentDate.getFullYear()}-${(currentDate.getMonth() + 1).toString().padStart(2, '0')}-${currentDate.getDate().toString().padStart(2, '0')}`; + + // Create a new example + const newExample = { + name: exampleName, + date: formattedDate, + files: uploadedVerilogFiles + }; + // Add the new example to the state + setProjectExamples([...projectExamples, newExample]); + + // Reset the form + setExampleName(""); + setUploadedVerilogFiles([]); + + } catch (error) { + throw new Error('Error API:', error); + alert("Failed to upload files"); + } + }; + + // Delete an existing example + const handleDeleteExample = async (index) => { + const fileToDelete = projectExamples[index]; // Get the file to delete + + try { + const response = await fetch(`${API_URL}/api/delete-project/${fileToDelete.name}`, { + method: "DELETE", + }); + + if (!response.ok) throw new Error("Erreur lors de la suppression"); + + // update the state + const newFiles = [...projectExamples]; + newFiles.splice(index, 1); + setProjectExamples(newFiles); + + } catch (error) { + throw new Error("Erreur lors de la suppression :", error); + } +}; + + return ( +
+ {/* Header Section */} +
+
navigate("/")} + > + SPIN Logo +

SPIN

+
+

Signal Propagation Inspector

+
+ + {/* Main Content */} +
+ {/* Create Section */} +
+

+ Welcome to the Creation Interface +

+ +
+

Add Example

+ +
+ {/* Drag & drop zone */} +
+ + + + +

+ Drag & drop your Verilog (.v) and SDF (.sdf) files here
+ or click to select files +

+
+ + {/* Input Name */} + setExampleName(e.target.value)} + className="border p-3 rounded-lg w-full focus:outline-none focus:ring-2 focus:ring-purple-500" + /> + + {/* Uploaded Files */} +
+ {uploadedVerilogFiles.length > 0 ? ( + uploadedVerilogFiles.map((file, index) => ( +
+ + {file.name} ({(file.size / 1024).toFixed(2)} KB) + + +
+ )) + ) : ( +

No files uploaded yet

+ )} +
+
+ +
+ +
+
+
+ + {/* Existing Examples */} +
+

+ Examples Already Created +

+ +
+ {projectExamples.length > 0 ? ( + + + + + + + + + + {projectExamples.map((ex, index) => ( + + + + + + ))} + +
NameDateAction
{ex.name}{ex.date} +
+ +
+
+ ) : ( +

No examples created yet

+ )} +
+ +
+ +
+
+
+ + {/* Footer */} + +
+ ); +} + +export default Create; \ No newline at end of file diff --git a/frontend/src/pages/home.jsx b/frontend/src/pages/home.jsx new file mode 100644 index 0000000..f912877 --- /dev/null +++ b/frontend/src/pages/home.jsx @@ -0,0 +1,73 @@ +import { useNavigate } from "react-router-dom"; + +function Home() { + const navigate = useNavigate(); + + return ( +
+ {/* Header Section */} +
+
navigate("/")} + > + SPIN Logo +

SPIN

+
+

Signal Propagation Inspector

+
+ + {/* Main Content */} +
+

+ Welcome to the FPGA Educational Simulator +

+ + + {/* Project Description */} +
+

+ SPIN is an interactive FPGA visualization tool designed to help students and engineers understand signal propagation within field-programmable gate arrays. Explore real-time signal flows, create custom designs, and learn the fundamentals of digital circuit design in an intuitive environment. +

+
+ +
+

Get Started

+ +
+ + +
+
+

Visualize existing FPGA designs or create your own from scratch

+
+
+
+ + {/* Footer */} + +
+ ); +} + +export default Home; \ No newline at end of file diff --git a/frontend/src/pages/visualizationEngine.js b/frontend/src/pages/visualizationEngine.js new file mode 100644 index 0000000..2b11a62 --- /dev/null +++ b/frontend/src/pages/visualizationEngine.js @@ -0,0 +1,817 @@ +import * as d3 from "d3"; + +// Constants for visualization +const COLORS = { + INPUT_PORT: "#6ab04c", // Green for input ports + OUTPUT_PORT: "#eb4d4b", // Red for output ports + LUT: "#f0932b", // Orange for LUTs + DFF: "#686de0", // Purple for DFFs + INTERCONNECT: "#95a5a6", // Grey for inactive interconnects + ACTIVE_PATH: "url(#active-gradient)", // Gradient for active paths + PORT_STROKE: "#14002b", // Dark border for ports + COMPONENT_STROKE: "#14002b", // Dark border for components + TEXT: "#14002b", // Text color +}; + +const SIZES = { + PORT_RADIUS: 5, + LUT_WIDTH: 60, + LUT_HEIGHT: 40, + DFF_WIDTH: 60, + DFF_HEIGHT: 30, + FONT_SIZE: 10, +}; + +// Initialize SVG and D3 visualization +export function setupVisualization(data, containerRef, svgRef, zoomLevel, showLabels) { + if (!data || !containerRef.current) return; + + // Clear previous visualization + d3.select(containerRef.current).selectAll("svg").remove(); + + // Extract module data - for now we'll just use the first module in the data + const moduleName = Object.keys(data.modules)[0]; + const moduleData = data.modules[moduleName]; + + // Create new SVG + const containerWidth = containerRef.current.clientWidth; + const containerHeight = containerRef.current.clientHeight; + + const svg = d3.select(containerRef.current) + .append("svg") + .attr("width", containerWidth) + .attr("height", containerHeight) + .attr("viewBox", [0, 0, containerWidth, containerHeight]); + + // Store reference to svg + svgRef.current = svg.node(); + + // Create a gradient for active paths + const defs = svg.append("defs"); + const gradient = defs.append("linearGradient") + .attr("id", "active-gradient") + .attr("gradientUnits", "userSpaceOnUse") + .attr("x1", "0%") + .attr("y1", "0%") + .attr("x2", "100%") + .attr("y2", "0%"); + + gradient.append("stop") + .attr("offset", "0%") + .attr("stop-color", "#0984e3"); + + gradient.append("stop") + .attr("offset", "100%") + .attr("stop-color", "#2ecc71"); + + // Create main group for panning and zooming + const g = svg.append("g") + .attr("transform", `translate(${containerWidth / 2}, ${containerHeight / 2}) scale(${zoomLevel})`); + + // Setup zoom behavior + const zoom = d3.zoom() + .scaleExtent([0.1, 10]) + .on("zoom", (event) => { + g.attr("transform", event.transform); + }); + + svg.call(zoom); + + // If a specific zoomLevel was provided, apply it + if (zoomLevel !== 1) { + svg.call(zoom.transform, d3.zoomIdentity.translate(containerWidth / 2, containerHeight / 2).scale(zoomLevel)); + } + + // Organize components for layout + const { components, graph } = organizeComponents(moduleData); + + // Draw connections (first, so they're behind components) + drawConnections(g, components, graph); + + // Draw components + drawComponents(g, components, showLabels); +} + +// Organize components for visualization layout +function organizeComponents(moduleData) { + // Extract ports, LUTs, DFFs, and interconnects + const ports = moduleData.ports.map(port => ({ + id: port.name, + type: port.type === "input" ? "INPUT_PORT" : "OUTPUT_PORT", + name: port.name, + connections: [] + })); + + // Create mapping of all component instances + const components = {}; + + // Add ports to components + ports.forEach(port => { + components[port.id] = port; + }); + + // Add other component instances (LUTs, DFFs, etc.) + moduleData.instances.forEach(instance => { + if (instance.type === "fpga_interconnect") { + // Handle interconnects differently as they're edges in our graph + return; + } + + components[instance.name] = { + id: instance.name, + type: instance.type === "LUT_K" ? "LUT" : + instance.type === "DFF" ? "DFF" : "OTHER", + name: instance.name, + connections: [], + data: instance + }; + }); + + // Build connection graph + const graph = { + nodes: Object.values(components), + edges: [] + }; + + // Process interconnects to build edges + moduleData.instances + .filter(instance => instance.type === "fpga_interconnect") + .forEach(interconnect => { + interconnect.connections.forEach(conn => { + // Get source and target nodes + const fromDevice = conn.fromDevice; + const fromIO = conn.fromIO; + const toDevice = conn.toDevice; + const toIO = conn.toIO; + const delay = conn.delay || 0; + + // Create a unique identifier for this connection + const edgeId = `${fromDevice}_${fromIO}_to_${toDevice}_${toIO}`; + + // Add edge to graph + graph.edges.push({ + id: edgeId, + source: fromDevice, + sourceIO: fromIO, + target: toDevice, + targetIO: toIO, + delay: delay, + name: interconnect.name + }); + + // Add connection references to components + if (components[fromDevice]) { + components[fromDevice].connections.push({ + type: "output", + io: fromIO, + edge: edgeId + }); + } + + if (components[toDevice]) { + components[toDevice].connections.push({ + type: "input", + io: toIO, + edge: edgeId + }); + } + }); + }); + + // Calculate positions for components using a force-directed layout + const componentSpacing = 100; + const layers = calculateLayers(graph); + + // Position nodes based on layers + let maxLayerSize = 0; + Object.values(layers).forEach(layer => { + maxLayerSize = Math.max(maxLayerSize, layer.length); + }); + + const layerKeys = Object.keys(layers).sort((a, b) => parseInt(a) - parseInt(b)); + const numLayers = layerKeys.length; + + const layerSpacing = componentSpacing * 2; + const totalWidth = numLayers * layerSpacing; + const startX = -totalWidth / 2; + + // Position nodes in each layer + layerKeys.forEach((layerIndex, i) => { + const layer = layers[layerIndex]; + const layerHeight = layer.length * componentSpacing; + const startY = -layerHeight / 2; + + layer.forEach((nodeId, j) => { + if (components[nodeId]) { + const node = components[nodeId]; + node.x = startX + i * layerSpacing; + node.y = startY + j * componentSpacing; + + // Special positioning for input and output ports + if (node.type === "INPUT_PORT") { + node.x = startX - componentSpacing; + } else if (node.type === "OUTPUT_PORT") { + node.x = startX + totalWidth + componentSpacing; + } + } + }); + }); + + return { components, graph }; +} + +// Calculate layers for components based on connections (for layout purposes) +function calculateLayers(graph) { + const layers = {}; + const visited = new Set(); + const inputPorts = graph.nodes.filter(node => node.type === "INPUT_PORT").map(node => node.id); + const outputPorts = graph.nodes.filter(node => node.type === "OUTPUT_PORT").map(node => node.id); + + // Add input ports to layer 0 + layers[0] = inputPorts; + inputPorts.forEach(id => visited.add(id)); + + // Calculate other layers using breadth-first search + let currentLayer = 0; + let shouldContinue = true; + + while (shouldContinue) { + const nextLayer = []; + + if (!layers[currentLayer]) { + break; + } + + layers[currentLayer].forEach(nodeId => { + const outEdges = graph.edges.filter(edge => edge.source === nodeId); + + outEdges.forEach(edge => { + const targetId = edge.target; + + // Skip output ports - they'll be positioned separately + if (outputPorts.includes(targetId)) { + if (!layers[999]) { + layers[999] = []; + } + if (!layers[999].includes(targetId)) { + layers[999].push(targetId); + } + visited.add(targetId); + return; + } + + if (!visited.has(targetId)) { + nextLayer.push(targetId); + visited.add(targetId); + } + }); + }); + + if (nextLayer.length > 0) { + currentLayer++; + layers[currentLayer] = nextLayer; + } else { + shouldContinue = false; + } + } + + // Handle nodes that weren't reached + const unreachedNodes = graph.nodes + .filter(node => !visited.has(node.id) && !inputPorts.includes(node.id) && !outputPorts.includes(node.id)) + .map(node => node.id); + + if (unreachedNodes.length > 0) { + const nextLayer = currentLayer + 1; + layers[nextLayer] = unreachedNodes; + } + + return layers; +} + +// Draw connections between components +function drawConnections(g, components, graph) { + const connectionGroup = g.append("g") + .attr("class", "connections"); + + graph.edges.forEach(edge => { + const sourceNode = components[edge.source]; + const targetNode = components[edge.target]; + + if (!sourceNode || !targetNode) return; + + // Calculate source and target points + const source = calculateIOPoint(sourceNode, edge.sourceIO, "output"); + const target = calculateIOPoint(targetNode, edge.targetIO, "input"); + + // Draw connection path + const pathGenerator = d3.line() + .curve(d3.curveLinear) + .x(d => d.x) + .y(d => d.y); + + // Create control points for curved paths + const controlPointDistance = 30; + const dx = target.x - source.x; + const pathPoints = [ + source, + { + x: source.x + Math.max(controlPointDistance, dx / 3), + y: source.y + }, + { + x: target.x - Math.max(controlPointDistance, dx / 3), + y: target.y + }, + target + ]; + + connectionGroup.append("path") + .attr("d", pathGenerator(pathPoints)) + .attr("fill", "none") + .attr("stroke", COLORS.INTERCONNECT) + .attr("stroke-width", 2) + .attr("class", "connection-path") + .attr("id", `path-${edge.id}`) + .attr("data-delay", edge.delay || 0) + .attr("data-source", edge.source) + .attr("data-target", edge.target); + + // Add source IO port indicator + connectionGroup.append("circle") + .attr("cx", source.x) + .attr("cy", source.y) + .attr("r", SIZES.PORT_RADIUS) + .attr("fill", COLORS.INPUT_PORT) + .attr("stroke", COLORS.PORT_STROKE) + .attr("class", `io-port source-port port-${edge.source}-${edge.sourceIO}`) + .attr("data-component", edge.source) + .attr("data-io", edge.sourceIO); + + // Add target IO port indicator + connectionGroup.append("circle") + .attr("cx", target.x) + .attr("cy", target.y) + .attr("r", SIZES.PORT_RADIUS) + .attr("fill", COLORS.OUTPUT_PORT) + .attr("stroke", COLORS.PORT_STROKE) + .attr("class", `io-port target-port port-${edge.target}-${edge.targetIO}`) + .attr("data-component", edge.target) + .attr("data-io", edge.targetIO); + }); +} + +// Calculate IO point positions for components +function calculateIOPoint(node, ioName, ioType) { + if (!node) return { x: 0, y: 0 }; + + const isInput = ioType === "input"; + + // Identify clock and non-clock IOs + const isClockIO = ioName.toLowerCase().includes('clock') || ioName.toLowerCase().includes('clk'); + + // Separate connections + const allConnections = node.connections.filter(conn => conn.type === ioType); + const clockConnections = allConnections.filter(conn => + conn.io.toLowerCase().includes('clock') || conn.io.toLowerCase().includes('clk') + ); + const nonClockConnections = allConnections.filter(conn => + !conn.io.toLowerCase().includes('clock') && !conn.io.toLowerCase().includes('clk') + ); + + // Determine the specific index and total count for the current IO + let ioIndex, totalIOCount; + if (isClockIO) { + ioIndex = clockConnections.findIndex(conn => conn.io === ioName); + totalIOCount = clockConnections.length; + } else { + ioIndex = nonClockConnections.findIndex(conn => conn.io === ioName); + totalIOCount = nonClockConnections.length; + } + + // Default positions + let x = node.x; + let y = node.y; + + // Adjust position based on node type and IO type + switch (node.type) { + case "INPUT_PORT": + x = node.x + SIZES.PORT_RADIUS * 2; + if (allConnections.length > 1) { + const verticalSpacing = SIZES.PORT_RADIUS * 4; + y = 40 + node.y + (ioIndex - (totalIOCount - 1) / 2) * verticalSpacing; + } + break; + + case "OUTPUT_PORT": + x = node.x - SIZES.PORT_RADIUS * 2; + if (allConnections.length > 1) { + const verticalSpacing = SIZES.PORT_RADIUS * 4; + y = node.y + (ioIndex - (totalIOCount - 1) / 2) * verticalSpacing; + } + break; + + case "LUT": + if (isInput) { + x = node.x - SIZES.LUT_WIDTH / 2; + + // Separate clock and non-clock inputs + if (isClockIO) { + // Push clock inputs to the far left + x -= SIZES.PORT_RADIUS * 2; + } + + if (totalIOCount > 1) { + const componentHeight = SIZES.LUT_HEIGHT; + const step = componentHeight / (totalIOCount + 1); + y = node.y - componentHeight / 2 + (ioIndex + 1) * step; + } + } else { + x = node.x + SIZES.LUT_WIDTH / 2; + } + break; + + case "DFF": + if (isInput) { + x = node.x - SIZES.DFF_WIDTH / 2; + + // Separate clock and non-clock inputs + if (isClockIO) { + // Push clock inputs further to the left + y = node.y + 18; + + //Push clock inputs to the middle of the DFF + x = (node.x - SIZES.DFF_WIDTH / 2) + SIZES.DFF_WIDTH / 2; + } + + if (totalIOCount > 1) { + const componentHeight = SIZES.DFF_HEIGHT; + const step = componentHeight / (totalIOCount + 1); + y = node.y - componentHeight / 2 + (ioIndex + 1) * step; + } + } else { + x = node.x + SIZES.DFF_WIDTH / 2; + } + break; + + default: + if (isInput) { + x = node.x - 20; + if (totalIOCount > 1) { + const verticalSpacing = 15; + y = node.y + (ioIndex - (totalIOCount - 1) / 2) * verticalSpacing; + } + } else { + x = node.x + 20; + if (totalIOCount > 1) { + const verticalSpacing = 15; + y = node.y + (ioIndex - (totalIOCount - 1) / 2) * verticalSpacing; + } + } + } + + return { x, y }; +} + +// Draw components (LUTs, DFFs, etc.) +function drawComponents(g, components, showLabels) { + const componentsGroup = g.append("g") + .attr("class", "components"); + + Object.values(components).forEach(node => { + let shape; + + switch (node.type) { + case "INPUT_PORT": + // Draw input port as a circle + shape = componentsGroup.append("circle") + .attr("cx", node.x) + .attr("cy", node.y) + .attr("r", SIZES.PORT_RADIUS * 2) + .attr("fill", COLORS.INPUT_PORT) + .attr("stroke", COLORS.PORT_STROKE) + .attr("stroke-width", 1.5) + .attr("class", `component component-${node.id} port-component`); + break; + + case "OUTPUT_PORT": + // Draw output port as a circle + shape = componentsGroup.append("circle") + .attr("cx", node.x) + .attr("cy", node.y) + .attr("r", SIZES.PORT_RADIUS * 2) + .attr("fill", COLORS.OUTPUT_PORT) + .attr("stroke", COLORS.PORT_STROKE) + .attr("stroke-width", 1.5) + .attr("class", `component component-${node.id} port-component`); + break; + + case "LUT": + // Draw LUT as a rectangle + shape = componentsGroup.append("rect") + .attr("x", node.x - SIZES.LUT_WIDTH / 2) + .attr("y", node.y - SIZES.LUT_HEIGHT / 2) + .attr("width", SIZES.LUT_WIDTH) + .attr("height", SIZES.LUT_HEIGHT) + .attr("fill", COLORS.LUT) + .attr("stroke", COLORS.COMPONENT_STROKE) + .attr("stroke-width", 1.5) + .attr("rx", 3) + .attr("ry", 3) + .attr("class", `component component-${node.id} lut-component`); + break; + + case "DFF": + // Draw DFF as a rectangle with different dimensions + shape = componentsGroup.append("rect") + .attr("x", node.x - SIZES.DFF_WIDTH / 2) + .attr("y", node.y - SIZES.DFF_HEIGHT / 2) + .attr("width", SIZES.DFF_WIDTH) + .attr("height", SIZES.DFF_HEIGHT) + .attr("fill", COLORS.DFF) + .attr("stroke", COLORS.COMPONENT_STROKE) + .attr("stroke-width", 1.5) + .attr("rx", 3) + .attr("ry", 3) + .attr("class", `component component-${node.id} dff-component`); + break; + + default: + // Draw other components as simple circles + shape = componentsGroup.append("circle") + .attr("cx", node.x) + .attr("cy", node.y) + .attr("r", 15) + .attr("fill", "#dfe6e9") + .attr("stroke", COLORS.COMPONENT_STROKE) + .attr("stroke-width", 1.5) + .attr("class", `component component-${node.id} other-component`); + } + + // Add component label + let labelX = node.x; + let labelY = node.y; + + // Adjust label position based on component type + if (node.type === "LUT" || node.type === "DFF") { + labelY = node.y + 5; + } else if (node.type === "INPUT_PORT" || node.type === "OUTPUT_PORT") { + const labelOffset = 15; + labelY = node.y - labelOffset; + } + + componentsGroup.append("text") + .attr("x", labelX) + .attr("y", labelY) + .attr("text-anchor", "middle") + .attr("dominant-baseline", "middle") + .attr("font-size", SIZES.FONT_SIZE) + .attr("fill", COLORS.TEXT) + .attr("class", `component-label label-${node.id}`) + .text(getShortName(node.name)); + + // Add IO port labels if enabled + if (showLabels) { + node.connections.forEach(conn => { + const ioPoint = calculateIOPoint(node, conn.io, conn.type); + + componentsGroup.append("text") + .attr("x", ioPoint.x + (conn.type === "input" ? -10 : 10)) + .attr("y", ioPoint.y - 8) + .attr("text-anchor", conn.type === "input" ? "end" : "start") + .attr("font-size", SIZES.FONT_SIZE - 2) + .attr("fill", COLORS.TEXT) + .attr("class", `io-label label-${node.id}-${conn.io}`) + .text(getIOName(conn.io)); + }); + } + }); +} + +// Helper function to shorten component names for display +function getShortName(name) { + // Standardize LUT and FF names + const lutMatch = name.match(/lut_?(\d+)?/i); + if (lutMatch) { + return 'LUT'; + } + + const dffMatch = name.match(/(?:dff|latch)_?(\w+)?/i); + if (dffMatch) { + return 'DFF'; + } + + // Simplify routing segments + if (name.startsWith("routing_segment_")) { + return "Route"; + } + + return name; +} + +// Helper function to format IO names +function getIOName(ioName) { + if (ioName.includes("input_")) { + return "in" + ioName.replace("input_", ""); + } else if (ioName.includes("output_")) { + return "out" + ioName.replace("output_", ""); + } else if (ioName.includes("clock_")) { + return "clk"; + } + return ioName; +} + +// Update active paths based on simulation time with flowing animation effect +export function updateActivePaths(data, svgRef, simulationTime) { + if (!data || !svgRef.current) return []; + + const moduleName = Object.keys(data.modules)[0]; + const moduleData = data.modules[moduleName]; + + const activeClockPaths = []; + const activeSignalPaths = []; + + // Animation parameters for signal propagation + const SIGNAL_ANIM_DURATION = 10; // in ps, for the signal to travel along a path + + // Iterate through interconnect instances + moduleData.instances + .filter(instance => instance.type === "fpga_interconnect") + .forEach(interconnect => { + interconnect.connections.forEach(conn => { + const pathId = `path-${conn.fromDevice}_${conn.fromIO}_to_${conn.toDevice}_${conn.toIO}`; + const isClockPath = conn.fromIO.toLowerCase().includes('clock') || + conn.toIO.toLowerCase().includes('clock'); + const delay = conn.delay || 0; + + // Animation ends at the delay time + const animationStartTime = delay - SIGNAL_ANIM_DURATION; + + // Check if the simulation time is within the animation window or after it + if (simulationTime >= animationStartTime) { + if (isClockPath) { + // Clock paths blink at 100 MHz frequency + const cycleTime = 10; // nanoseconds + const isActiveInCycle = Math.floor(simulationTime / cycleTime) % 2 === 0; + + if (isActiveInCycle && simulationTime >= delay) { + activeClockPaths.push(pathId); + } + } else { + // Calculate animation progress + let progress; + if (simulationTime >= delay) { + // After delay, the progress is complete + progress = 1; + } else { + // During animation window, calculate progress + progress = (simulationTime - animationStartTime) / SIGNAL_ANIM_DURATION; + } + + activeSignalPaths.push({ + id: pathId, + progress: progress + }); + } + } + }); + }); + + // Update path visualization + if (svgRef.current) { + const svg = d3.select(svgRef.current); + + // Update clock paths (blinking) + svg.selectAll(".connection-path") + .each(function() { + const path = d3.select(this); + const pathId = path.attr("id"); + const isClockPath = activeClockPaths.includes(pathId); + const signalInfo = activeSignalPaths.find(p => p.id === pathId); + + // Reset any existing gradients + path.attr("stroke-dasharray", null) + .attr("stroke-dashoffset", null); + + if (isClockPath) { + // Clock paths blink + path.attr("stroke", COLORS.ACTIVE_PATH) + .attr("stroke-width", 3); + } else if (signalInfo) { + // For non-clock signal paths, create flowing effect + const pathLength = this.getTotalLength(); + + if (signalInfo.progress < 1) { + // During animation: show partial path with gradient + path.attr("stroke", COLORS.ACTIVE_PATH) + .attr("stroke-width", 3) + .attr("stroke-dasharray", pathLength) + .attr("stroke-dashoffset", pathLength * (1 - signalInfo.progress)); + } else { + // After animation completes: show full path + path.attr("stroke", COLORS.ACTIVE_PATH) + .attr("stroke-width", 3); + } + } else { + // Inactive paths + path.attr("stroke", COLORS.INTERCONNECT) + .attr("stroke-width", 2); + } + }); + + // Update port indicators to light up when the signal reaches them + svg.selectAll(".io-port") + .each(function() { + const port = d3.select(this); + const componentId = port.attr("data-component"); + const ioName = port.attr("data-io"); + const isTargetPort = port.classed("target-port"); + + // Find if any connected paths are active and have reached this port + let isActive = false; + + if (isTargetPort) { + // For target ports, check if any incoming signals have reached + const incomingPaths = activeSignalPaths.filter(p => + p.id.includes(`_to_${componentId}_${ioName}`) && p.progress === 1 + ); + isActive = incomingPaths.length > 0; + } else { + // For source ports, check if any outgoing signals have started + const outgoingPaths = activeSignalPaths.filter(p => + p.id.includes(`${componentId}_${ioName}_to_`) && p.progress > 0 + ); + isActive = outgoingPaths.length > 0; + } + + // Update port appearance based on activity + if (isActive) { + port.attr("r", SIZES.PORT_RADIUS * 1.2) + .attr("fill", isTargetPort ? COLORS.OUTPUT_PORT : COLORS.INPUT_PORT) + .attr("opacity", 1); + } else { + port.attr("r", SIZES.PORT_RADIUS) + .attr("opacity", 0.8); + } + }); + + // Highlight components that are receiving signals + svg.selectAll(".component") + .each(function() { + const component = d3.select(this); + const componentClass = component.attr("class"); + const componentId = componentClass.match(/component-([^\s]+)/)?.[1]; + + if (!componentId) return; + + // Check if any signals have fully reached this component + const incomingPaths = activeSignalPaths.filter(p => + p.id.includes(`_to_${componentId}_`) && p.progress === 1 + ); + + const isActive = incomingPaths.length > 0; + + // Add a subtle pulse effect when component is active + if (isActive) { + // Get current fill color + const currentFill = component.attr("fill"); + + // Create a subtle pulsing effect + if (!component.classed("active-pulse")) { + component.classed("active-pulse", true) + .attr("original-fill", currentFill); + + // Create subtle pulse effect using CSS animation + const id = "pulse-" + Math.random().toString(36).substr(2, 9); + component.attr("filter", `url(#${id})`); + + // Create filter for glow effect + const defs = svg.select("defs"); + const filter = defs.append("filter") + .attr("id", id) + .attr("x", "-50%") + .attr("y", "-50%") + .attr("width", "200%") + .attr("height", "200%"); + + filter.append("feGaussianBlur") + .attr("stdDeviation", "2") + .attr("result", "blur"); + + filter.append("feComposite") + .attr("in", "SourceGraphic") + .attr("in2", "blur") + .attr("operator", "over"); + } + } else { + // Remove pulse effect + if (component.classed("active-pulse")) { + component.classed("active-pulse", false) + .attr("filter", null); + } + } + }); + } + + // Return all active path IDs + return [...activeClockPaths, ...activeSignalPaths.map(p => p.id)]; +} \ No newline at end of file diff --git a/frontend/src/pages/visualize.jsx b/frontend/src/pages/visualize.jsx new file mode 100644 index 0000000..4af98b8 --- /dev/null +++ b/frontend/src/pages/visualize.jsx @@ -0,0 +1,396 @@ +import { useState, useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; +import { setupVisualization, updateActivePaths } from "./visualizationEngine"; +import API_URL from "../config"; + +function Visualize() { + const navigate = useNavigate(); + const [selectedExample, setSelectedExample] = useState(""); + const [isPlaying, setIsPlaying] = useState(false); + const [zoomLevel, setZoomLevel] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [speed, setSpeed] = useState(0.1); + const [data, setData] = useState(null); + const [currentStep, setCurrentStep] = useState(0); + const [activeSignalPaths, setActiveSignalPaths] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isLabelsVisible, setIsLabelsVisible] = useState(false); + const [isFullPage, setIsFullPage] = useState(false); + + + const containerRef = useRef(null); + const svgRef = useRef(null); + const animationRef = useRef(null); + const simulationTimeRef = useRef(0); + const zoomBehaviorRef = useRef(null); + + const [projectExamples, setProjectExamples] = useState([]); + + useEffect(() => { + // Function to fetch data from the API + const fetchData = async () => { + try { + const response = await fetch(`${API_URL}/api/list`); + if (!response.ok) { + throw new Error('Network response was not ok'); + } + const data = await response.json(); + + // Map the data to the desired format + const updatedExamples = data.map(item => ({ + name: item.name, + path: `${API_URL}/api/map/${item.name}` + })); + + // Update the state with the fetched data + setProjectExamples(updatedExamples); + } catch (error) { + throw new Error('Error fetching data:', error); + } + }; + + // Call the function to fetch data + fetchData(); + }, []); + + // Load data when example changes + useEffect(() => { + if (!selectedExample) { + setData(null); + return; + } + + setIsLoading(true); + const selectedPath = projectExamples.find(ex => ex.name === selectedExample)?.path; + + // In a real implementation, this would fetch from the server + fetch(selectedPath) + .then(response => response.json()) + .then(jsonData => { + setData(jsonData); + setCurrentStep(0); + setActiveSignalPaths([]); + setupVisualization(jsonData, containerRef, svgRef, zoomLevel, isLabelsVisible); + + // Store reference to the zoom behavior if it's created during setup + if (svgRef.current && svgRef.current.__zoom) { + zoomBehaviorRef.current = svgRef.current.__zoom; + } + + setIsLoading(false); + }) + .catch(error => { + setIsLoading(false); + throw new Error("Error loading data:", error); + }); + }, [selectedExample]); + + // Update when zoom level or label visibility changes + useEffect(() => { + if (data) { + setupVisualization(data, containerRef, svgRef, zoomLevel, isLabelsVisible); + + // Update zoom behavior reference + if (svgRef.current && svgRef.current.__zoom) { + zoomBehaviorRef.current = svgRef.current.__zoom; + } + } + }, [zoomLevel, isLabelsVisible]); + + // Animation functions + useEffect(() => { + if (isPlaying && data) { + // Start animation + let lastTimestamp = 0; + const animate = (timestamp) => { + if (!lastTimestamp) lastTimestamp = timestamp; + const deltaTime = timestamp - lastTimestamp; + + // Update simulation time based on speed + simulationTimeRef.current += deltaTime * speed; + + // Determine which paths should be active based on simulation time + const newActivePaths = updateActivePaths(data, svgRef, simulationTimeRef.current); + setActiveSignalPaths(newActivePaths || []); + + lastTimestamp = timestamp; + animationRef.current = requestAnimationFrame(animate); + }; + + animationRef.current = requestAnimationFrame(animate); + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + }; + } + }, [isPlaying, data, speed]); + + // Handle step button click - scaled by speed value + const handleNextStep = () => { + if (!data) return; + + // Calculate step size based on current speed setting + // For 0.001, make 1 step unit + // For 0.01, make 10 step units + // For 0.1, make 100 step units + // For 1, make 1000 step units + let stepSize = 1; + if (speed === 0.001) stepSize = 1; + else if (speed === 0.01) stepSize = 10; + else if (speed === 0.1) stepSize = 100; + else if (speed === 1) stepSize = 1000; + + // Increment simulation time by the calculated step size + simulationTimeRef.current += stepSize; + const newActivePaths = updateActivePaths(data, svgRef, simulationTimeRef.current); + setActiveSignalPaths(newActivePaths || []); + }; + + + const handleNextBack = () => { + if (!data) return; + + // Calculate step size based on current speed setting + // For 0.001, make 1 step unit + // For 0.01, make 10 step units + // For 0.1, make 100 step units + // For 1, make 1000 step units + let stepSize = -1; + if (speed === 0.001) stepSize = -1; + else if (speed === 0.01) stepSize = -10; + else if (speed === 0.1) stepSize = -100; + else if (speed === 1) stepSize = -1000; + + // Increment simulation time by the calculated step size + simulationTimeRef.current += stepSize; + const newActivePaths = updateActivePaths(data, svgRef, simulationTimeRef.current); + setActiveSignalPaths(newActivePaths || []); + }; + + // Handle reset view - complete reset of both pan and zoom + const handleResetView = () => { + // Reset zoom level to default + setZoomLevel(0.4); + + // Reset position to center + setPosition({ x: 0, y: 0 }); + + // If SVG is set up, attempt to reset visualization + if (containerRef.current && svgRef.current) { + try { + // Rerun setup visualization with default zoom + setupVisualization( + data, + containerRef, + svgRef, + 1, // Reset zoom level to 1 + isLabelsVisible + ); + } catch (e) { + throw new Error("Error resetting view:", e); + } + } + }; + + // Toggle IO labels visibility + const handleToggleLabels = () => { + setIsLabelsVisible(prev => !prev); + }; + + // Handle play/pause controls + const handleTogglePlayback = () => { + setIsPlaying(prev => !prev); + }; + + // Reset simulation + const handleResetSimulation = () => { + simulationTimeRef.current = 0; + setActiveSignalPaths([]); + const newActivePaths = updateActivePaths(data, svgRef, simulationTimeRef.current); + setActiveSignalPaths(newActivePaths || []); + }; + + const handleToggleFullPage = () => { + setIsFullPage(prev => !prev); + }; + + return ( +
+ {/* Header Section */} +
+
navigate("/")} + > + SPIN Logo +

SPIN

+
+

Signal Propagation Inspector

+
+ + {/* Main Content */} +
+

+ Welcome To The Visualization +

+ + {/* Dropdown Selection */} +
+ +
+ + {/* Visualization Section */} +
+

+ Propagation Signals Interface +

+ + {/* Legend */} +
+
+
+ Input Ports +
+
+
+ Output Ports +
+
+
+ LUTs +
+
+
+ DFFs +
+
+
+ Active Signals +
+
+ + {/* D3.js Visualization Container */} +
+ {!selectedExample ? ( +

Select an example to visualize

+ ) : isLoading ? ( +

Loading visualization for {selectedExample}...

+ ) : null} +
+ + {/* Simulation Info */} +
+

Simulation Time: {Math.floor(simulationTimeRef.current)} picoseconds

+

Active Paths: {activeSignalPaths.length}

+
+ + {/* Playback Controls */} +
+ + + + + + {/* Speed Control with ultra-slow options */} +
+ Speed: + +
+
+ + {/* Navigation Controls - kept reset view and labels */} +
+ + + + +
+
+
+ + {/* Footer */} + +
+ ); +} + +export default Visualize; \ No newline at end of file diff --git a/frontend/tests/e2e/app.spec.ts b/frontend/tests/e2e/app.spec.ts new file mode 100644 index 0000000..cc181da --- /dev/null +++ b/frontend/tests/e2e/app.spec.ts @@ -0,0 +1,130 @@ +import { Builder } from 'selenium-webdriver'; +import { Options } from 'selenium-webdriver/chrome'; // Import Chrome Options +import { expect } from 'chai'; + +describe('Main Page Tests', () => { + let driver: any; + + before(async () => { + const chromeOptions = new Options(); + + driver = await new Builder() + .forBrowser('chrome') + .build(); + + await driver.get('https://two024-2025-project-4-web-fpga-team-5.onrender.com'); + }); + + after(async () => { + if (driver) { // Check if driver is defined + await driver.quit(); + } + }); + + it('should display correct page title', async () => { + const title = await driver.getTitle(); + expect(title).to.include('SPIN - CNES'); + }); + + it('should display correct heading 1', async () => { + const heading = await driver.findElement({ tagName: 'h1' }).getText(); + expect(heading).to.equal('SPIN'); + }); + + it('should display correct heading 2', async () => { + const heading = await driver.findElement({ tagName: 'h2' }).getText(); + expect(heading).to.equal('Signal Propagation Inspector'); + }); + + it('should display correct heading 3', async () => { + const heading = await driver.findElement({ tagName: 'h3' }).getText(); + expect(heading).to.equal('Welcome to the FPGA Educational Simulator'); + }); + + it('should display correct paragraph', async () => { + const paragraph = await driver.findElement({ tagName: 'p' }).getText(); + expect(paragraph).to.include('Get Started'); + }); + + it('should display correct buttons', async () => { + const buttons = await driver.findElements({ tagName: 'button' }); + expect(buttons).to.have.lengthOf(2); + + const button1Text = await buttons[0].getText(); + const button2Text = await buttons[1].getText(); + + expect(button1Text).to.equal('Visualize'); + expect(button2Text).to.equal('Create'); + }); +}); + +describe('Visualization Page Tests', () => { + let driver: any; + + before(async () => { + const chromeOptions = new Options(); + + driver = await new Builder() + .forBrowser('chrome') + .build(); + + await driver.get('https://two024-2025-project-4-web-fpga-team-5.onrender.com/visualize'); + }); + + after(async () => { + if (driver) { // Check if driver is defined + await driver.quit(); + } + }); + + it('should display correct heading 1', async () => { + const heading = await driver.findElement({ tagName: 'h1' }).getText(); + expect(heading).to.equal('SPIN'); + }); + + it('should display correct heading 2', async () => { + const heading = await driver.findElement({ tagName: 'h2' }).getText(); + expect(heading).to.equal('Signal Propagation Inspector'); + }); + + it('should display correct heading 3', async () => { + const heading = await driver.findElement({ tagName: 'h3' }).getText(); + expect(heading).to.equal('Welcome To The Visualization'); + }); +}); + + +describe('Creation Page Tests', () => { + let driver: any; + + before(async () => { + const chromeOptions = new Options(); + + driver = await new Builder() + .forBrowser('chrome') + .build(); + + await driver.get('https://two024-2025-project-4-web-fpga-team-5.onrender.com/create'); + }); + + after(async () => { + if (driver) { // Check if driver is defined + await driver.quit(); + } + }); + + it('should display correct heading 1', async () => { + const heading = await driver.findElement({ tagName: 'h1' }).getText(); + expect(heading).to.equal('SPIN'); + }); + + it('should display correct heading 2', async () => { + const heading = await driver.findElement({ tagName: 'h2' }).getText(); + expect(heading).to.equal('Signal Propagation Inspector'); + }); + + it('should display correct heading 3', async () => { + const heading = await driver.findElement({ tagName: 'h3' }).getText(); + expect(heading).to.equal('Welcome to the Creation Interface'); + }); +}); \ No newline at end of file diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..7fcb9fd --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + react(), + tailwindcss(), + ], + preview: { + allowedHosts: ['two024-2025-project-4-web-fpga-team-5.onrender.com'], + } +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..d8d9f78 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "spin", + "version": "1.2.2", + "description": "App for visualizing FPGA designs", + "main": "index.js", + "scripts": { + "install": "npm run install:backend && npm run install:frontend", + "install:backend": "cd backend && npm install", + "install:frontend": "cd frontend && npm install", + "start:backend": "cd backend && node index.js", + "start:frontend": "cd frontend && npm run dev", + "main": "concurrently \"npm run start:backend\" \"npm run start:frontend\" \"npm run open:browser\"", + "open:browser": "wait-on http://localhost:5173 && open-cli http://localhost:5173" + }, + "keywords": [ + "verilog", + "sdf", + "electronics", + "visualization" + ], + "author": "", + "devDependencies": { + "concurrently": "^8.2.2", + "open-cli": "^7.2.0", + "wait-on": "^7.2.0" + } +} \ No newline at end of file