diff --git a/Lara-JS/package.json b/Lara-JS/package.json index 2aebbd405..9747886d4 100644 --- a/Lara-JS/package.json +++ b/Lara-JS/package.json @@ -21,7 +21,7 @@ "tsconfig.json" ], "scripts": { - "build": "node scripts/copy-folder.js -i src-api/libs -o api/libs && npx tsc -b src-api src-code", + "build": "node scripts/copy-folder.js -i src-api/libs -o api/libs && node scripts/copy-folder.js -i src-api/visualization/public -o api/visualization/public && npx tsc -b src-api src-code", "build:api": "npx tsc -b src-api", "build:code": "npx tsc -b src-code", "build:watch": "npm run build -- --watch", @@ -58,16 +58,20 @@ "dependencies": { "chokidar": "^3.6.0", "debug": "^4.3.5", + "express": "^4.19.2", "java": "^0.14.0", "supports-color": "^9.4.0", + "ws": "^8.17.1", "yargs": "^17.7.2" }, "devDependencies": { "@jest/globals": "^29.7.0", "@types/debug": "^4.1.12", + "@types/express": "^4.17.21", "@types/java": "^0.9.5", "@types/jest": "^29.5.12", "@types/node": "^20.14.10", + "@types/ws": "^8.5.11", "@types/yargs": "^17.0.32", "typescript-eslint": "^8.0.0-alpha.44", "cross-env": "^7.0.3", diff --git a/Lara-JS/src-api/visualization/AstConverterUtils.ts b/Lara-JS/src-api/visualization/AstConverterUtils.ts new file mode 100644 index 000000000..7eb2872b1 --- /dev/null +++ b/Lara-JS/src-api/visualization/AstConverterUtils.ts @@ -0,0 +1,64 @@ +/** + * @file AstConverterUtils.ts + * @brief Utility functions for the specializations of GenericAstConverter. + */ + +/** + * @brief Adds indentation to the given code, with the exception of the first line. + * + * @param code Code + * @param indentation The indentation to use + * @returns The indented code + */ +const addIdentation = (code: string, indentation: number): string => { + return code.split('\n').map((line, i) => i > 0 ? ' '.repeat(indentation) + line : line).join('\n'); +}; + + +/** + * @brief Escapes the HTML special characters in the given text. + * + * @param text Text to escape + * @returns The escaped text + */ +const escapeHtml = (text: string): string => { + const specialCharMap: { [char: string]: string } = { + '&': '&', + '<': '<', + '>': '>', + }; + + return text.replace(/[&<>]/g, (match) => specialCharMap[match]); +}; + +/** + * @brief Returns the opening and closing span tags with the given attributes. + * + * @param attrs Attributes to add to the span tag + * @returns An array with the opening and closing span tags + */ +const getSpanTags = (...attrs: string[]): string[] => { + return [``, '']; +}; + +/** + * @brief Returns the span tags for the code of the given node. + * + * @param nodeId Node ID + * @returns An array with the opening and closing span tags + */ +const getNodeCodeTags = (nodeId: string): string[] => { + return getSpanTags('class="node-code"', `data-node-id="${nodeId}"`); +}; + +/** + * @brief Returns the span tags for syntax highlighting of code of the given type.. + * + * @param type Code type + * @returns An array with the opening and closing span tags + */ +const getSyntaxHighlightTags = (type: 'comment' | 'keyword' | 'literal' | 'string' | 'type'): string[] => { + return getSpanTags(`class="${type}"`); +}; + +export { addIdentation, escapeHtml, getSpanTags, getNodeCodeTags, getSyntaxHighlightTags }; \ No newline at end of file diff --git a/Lara-JS/src-api/visualization/GenericAstConverter.ts b/Lara-JS/src-api/visualization/GenericAstConverter.ts new file mode 100644 index 000000000..19916036e --- /dev/null +++ b/Lara-JS/src-api/visualization/GenericAstConverter.ts @@ -0,0 +1,41 @@ +import { LaraJoinPoint } from "../LaraJoinPoint.js"; +import ToolJoinPoint from "./public/js/ToolJoinPoint.js"; + +/** + * @brief Object type for storing the code of each file + */ +export type FilesCode = { + [filepath: string]: string; +}; + +/** + * @brief Interface for the AST converter. + * @details This interface includes all the compiler specific operations that + * are required by the LARA visualization tool to work. + */ +export default interface GenericAstConverter { + /** + * @brief Updates/Rebuilds the AST on the compiler. + */ + updateAst(): void; + + /** + * @brief Converts the compiler AST joinpoints to ToolJoinPoint. + * + * @param root Root of the AST + * @returns The AST converted to ToolJoinPoint + */ + getToolAst(root: LaraJoinPoint): ToolJoinPoint; + + /** + * @brief Converts the compiled code to HTML code. + * @details This method should perform the mapping of each AST joinpoint to + * their respective code, using HTML tags, and optionally its syntax + * highlighting. See the getNodeCodeTags and getSyntaxHighlightTags methods + * in AstConverterUtils.js for more information. + * + * @param root Root of the AST + * @returns The HTML code + */ + getPrettyHtmlCode(root: LaraJoinPoint): FilesCode; +} \ No newline at end of file diff --git a/Lara-JS/src-api/visualization/GenericVisualizationTool.ts b/Lara-JS/src-api/visualization/GenericVisualizationTool.ts new file mode 100644 index 000000000..6ed093774 --- /dev/null +++ b/Lara-JS/src-api/visualization/GenericVisualizationTool.ts @@ -0,0 +1,218 @@ +import express from 'express'; +import http from 'http'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import WebSocket, { WebSocketServer } from 'ws'; +import { AddressInfo } from 'net'; + +import { LaraJoinPoint } from '../LaraJoinPoint.js'; +import JoinPoints from '../weaver/JoinPoints.js'; +import GenericAstConverter, { FilesCode } from './GenericAstConverter.js'; +import ToolJoinPoint from './public/js/ToolJoinPoint.js'; + +/** + * @brief Abstract class for a the LARA visualization tool. + * @details To use this class in a compiler, this class must be extended and + * the getAstConverter method must be implemented, returning the compiler + * specialization of the GenericAstConverter class. + */ +export default abstract class GenericVisualizationTool { + #hostname: string | undefined; + #port: number | undefined; + #wss: WebSocketServer | undefined; + #serverClosed: boolean = false; + #toolAst: ToolJoinPoint | undefined; + #prettyHtmlCode: FilesCode | undefined; + + /** + * @brief True whether the visualization tool is launched, and false otherwise + */ + public get isLaunched(): boolean { + return this.#wss !== undefined && this.#serverClosed === false; + } + + /** + * @brief Hostname to which the visualization tool is listening to. + */ + public get hostname(): string | undefined { + return this.#hostname; + } + + /** + * @brief Port to which the visualization tool is listening to. + */ + public get port(): number | undefined { + return this.#port; + } + + /** + * @brief URL to which the visualization tool is listening to. + */ + public get url(): string | undefined { + return this.#hostname && this.#port ? `http://${this.#hostname}:${this.#port}` : undefined; + } + + /** + * @brief Updates the stored tool AST and code with the info retrieved from the astConverter. + * + * @param astRoot The root of the wanted AST + */ + private updateAstAndCode(astRoot: LaraJoinPoint): void { + const astConverter = this.getAstConverter(); + astConverter.updateAst(); + + this.#toolAst = astConverter.getToolAst(astRoot); + this.#prettyHtmlCode = astConverter.getPrettyHtmlCode(astRoot); + } + + /** + * @brief WebSocket server error handler. + * + * @param error The error that occurred + */ + private onWssError(error: NodeJS.ErrnoException): void { + switch (error.code) { + case 'EADDRINUSE': + console.error(`[server]: Port ${this.#port} is already in use`); + break; + + case 'EACCES': + console.error(`[server]: Permission denied to use port ${this.#port}`); + break; + + default: + console.error(`[server]: Unknown error occurred: ${error.message}`); + break; + }; + + this.#wss!.close(); + } + + /** + * @brief Launches the visualization tool. + * + * @param hostname The hostname to listen to + * @param port The port to listen to + */ + private async launch(hostname: string, port: number): Promise { + const app = express(); + const server = http.createServer(app); + this.#wss = new WebSocketServer({ server: server }); + + const filename = fileURLToPath(import.meta.url); + const dirname = path.dirname(filename); + app.use(express.static(path.join(dirname, 'public'))); + + this.#wss.on('connection', ws => this.updateClient(ws)); + this.#wss.on('close', () => { this.#serverClosed = true; }); + this.#wss.on('error', error => this.onWssError(error)); + + return new Promise(res => { + server.listen(port, hostname, () => { + const addressInfo = server.address() as AddressInfo; + this.#hostname = addressInfo.address; + this.#port = addressInfo.port; + this.#serverClosed = false; + + res(); + }); + }); + } + + /** + * @brief Sends a message to a specific client + * + * @param ws Client WebSocket + * @param data Data to be sent + */ + private sendToClient(ws: WebSocket, data: any): void { + ws.send(JSON.stringify(data)); + } + + /** + * @brief Sends a message to all the clients + * + * @param data Message data + */ + private sendToAllClients(data: any): void { + this.#wss!.clients.forEach(ws => this.sendToClient(ws, data)); + } + + /** + * @brief Updates the client with the current tool AST and code. + * + * @param ws Client WebSocket + */ + private updateClient(ws: WebSocket): void { + this.sendToClient(ws, { + message: 'update', + ast: this.#toolAst!.toJson(), + code: this.#prettyHtmlCode!, + }); + } + + /** + * @brief Updates all the clients with the current tool AST and code. + */ + private updateAllClients(): void { + this.#wss!.clients.forEach(ws => this.updateClient(ws)); + } + + /** + * @brief Waits for the tool to be ready to receive the AST and code. + */ + private async waitForTool(): Promise { + return new Promise(res => { + let placeClientOnWait: (ws: WebSocket) => void; + + const waitOnMessage = (message: string) => { + const data = JSON.parse(message); + if (data.message === 'continue') { + this.#wss!.clients.forEach(ws => { + this.#wss!.off('connection', placeClientOnWait); + ws.off('message', waitOnMessage); + }); + + this.sendToAllClients({ message: 'continue' }); + res(); + } + } + + placeClientOnWait = (ws: WebSocket) => { + ws.on('message', waitOnMessage); + this.sendToClient(ws, { message: 'wait' }); + } + + this.#wss!.clients.forEach(placeClientOnWait); + this.#wss!.on('connection', placeClientOnWait); + }); + } + + /** + * @brief Visualizes the given AST. + * @details This function launches the visualization tool, if it is not + * already launched, and updates the web interface with the AST and code, + * otherwise. This can involve the recompilation of the code. + * + * @param astRoot Root of the AST to be visualized + * @param port The port to listen to + * @param hostname The hostname to listen to + */ + public async visualize(astRoot: LaraJoinPoint = JoinPoints.root(), port: number = 3000, hostname: string = '127.0.0.1'): Promise { + this.updateAstAndCode(astRoot!); + + if (!this.isLaunched) { + await this.launch(hostname, port); + } else { + this.updateAllClients(); + } + + console.log(`\nVisualization tool is running at ${this.url}\n`); + await this.waitForTool(); + } + + /** + * @brief Returns the compiler AST converter. + */ + protected abstract getAstConverter(): GenericAstConverter; +}; \ No newline at end of file diff --git a/Lara-JS/src-api/visualization/README.md b/Lara-JS/src-api/visualization/README.md new file mode 100644 index 000000000..55da94b03 --- /dev/null +++ b/Lara-JS/src-api/visualization/README.md @@ -0,0 +1,36 @@ +# LARA Visualization Tool + +Web tool for visualization and analysis of the AST and its source code. + +## Integration + +Internally, the tool follows a system for interaction with the compiler, to be able to apply code linkage and correction, among others, while still being independent of the specific compiler. This system is an implementation of the Factory Method pattern, and the integration with Clava is illustrated in the following diagram: + +![Compiler Abstracted System](./compiler-abstracted-system.svg) + +To integrate the tool in another compiler: + +1. Implement the `GenericAstConverter` interface, with its functions properly implemented. More information can be found in their documentation. +2. Create a class that extends `GenericVisualizationTool`, and override `getAstConverter` so that it returns an instance of the class declared in the previous step. +3. Use an instance of the previous class as the entry point of the visualization tool API, for the compiler in question. + +## Usage + +After integration, and being `VisualizationTool` the extended derived class of `GenericVisualizationTool`, to launch or update the visualization tool, execute the following statement: + +```js +await VisualizationTool.visualize(); +``` + +Once ready, Clava will provide the URL that should be opened in the browser to access the web interface. The function can also change the AST root and URL domain and port. + +Other properties will allow the user to know other important information from the server: + +```js +VisualizationTool.isLaunched; // true if the server is running +VisualizationTool.url; // URL where the server is running +VisualizationTool.port; // port to which the server is listening +VisualizationTool.hostname; // hostname to which the server is listening +``` + +For more details, refer to the `GenericVisualizationTool` documentation. \ No newline at end of file diff --git a/Lara-JS/src-api/visualization/capstone-project-report.pdf b/Lara-JS/src-api/visualization/capstone-project-report.pdf new file mode 100644 index 000000000..f204be5fa Binary files /dev/null and b/Lara-JS/src-api/visualization/capstone-project-report.pdf differ diff --git a/Lara-JS/src-api/visualization/compiler-abstracted-system.svg b/Lara-JS/src-api/visualization/compiler-abstracted-system.svg new file mode 100644 index 000000000..caaf5f448 --- /dev/null +++ b/Lara-JS/src-api/visualization/compiler-abstracted-system.svg @@ -0,0 +1,4 @@ + + + +
Compiler-Abstracted System Class Diagram
GenericVisualizationTool
+ visualize(): Promise<void>
+ get isLaunched(): bool
+ get hostname(): string | undefined
+ get port(): number | undefined
+ get url(): string | undefined
# getAstConverter(): GenericAstConverter
«interface»
GenericAstConverter
+ updateAst(): void
+ getToolAst(root: LaraJoinPoint): ToolJoinPoint
+ getPrettyHtmlCode(root: LaraJoinPoint): FilesCode
VisualizationTool
- astConverter: ClavaAstConverter
# getAstConverter(): GenericAstConverter
ClavaAstConverter
1
1
LARA
Clava
return this.astConverter;
\ No newline at end of file diff --git a/Lara-JS/src-api/visualization/public/css/color-scheme.css b/Lara-JS/src-api/visualization/public/css/color-scheme.css new file mode 100644 index 000000000..5ed4761f1 --- /dev/null +++ b/Lara-JS/src-api/visualization/public/css/color-scheme.css @@ -0,0 +1,78 @@ +/* Generic colors */ + +:root { + --white: #fff; + --translucid-white: #fff6; + --lighter-gray: #e4e4e4; + --light-gray: #d4d4d4; + --gray: #a2a2a2; + --dark-gray: #747474; + --darker-gray: #313131; + --black: #161616; + --translucid-black: #16161666; + --light-blue: #19d8ff; + --strong-translucid-light-blue: #19d8ff66; + --weak-translucid-light-blue: #19d8ff33; + --gray-blue: #004483; + --strong-translucid-gray-blue: #004483bf; + --weak-translucid-gray-blue: #0044837f; + --blue: #0000ff; + --violet: #dbb8fe; + --purple: #7d00ae; +} + +/* Light color scheme */ + +:root { + --bg-color: var(--white); + --translucid-bg-color: var(--translucid-white); + --text-color: var(--darker-gray); + --header-color: var(--lighter-gray); + --border-color: var(--gray); + --icon-color: var(--darker-gray); + --disabled-icon-color: var(--gray); + + --button-bg-color: var(--white); + --button-hover-bg-color: var(--lighter-gray); + --button-disabled-bg-color: var(--lighter-gray); + + --tab-bg-color: var(--white); + --tab-hover-bg-color: var(--lighter-gray); + --tab-active-bg-color: var(--light-gray); + + --highlight-color: var(--light-blue); + --secondary-highlight-color: var(--strong-translucid-light-blue); + --tertiary-highlight-color: var(--weak-translucid-light-blue); + + --line-num-color: var(--dark-gray); + --secondary-code-bg-color: var(--lighter-gray); +} + +/* Dark color scheme */ + +@media (prefers-color-scheme: dark) { + :root { + --bg-color: var(--black); + --translucid-bg-color: var(--translucid-black); + --text-color: var(--white); + --header-color: var(--darker-gray); + --border-color: var(--lighter-gray); + --icon-color: var(--white); + --disabled-icon-color: var(--gray); + + --button-bg-color: var(--black); + --button-hover-bg-color: var(--darker-gray); + --button-disabled-bg-color: var(--dark-gray); + + --tab-bg-color: var(--black); + --tab-hover-bg-color: var(--darker-gray); + --tab-active-bg-color: var(--dark-gray); + + --highlight-color: var(--gray-blue); + --secondary-highlight-color: var(--strong-translucid-gray-blue); + --tertiary-highlight-color: var(--weak-translucid-gray-blue); + + --line-num-color: var(--gray); + --secondary-code-bg-color: var(--darker-gray); + } +} \ No newline at end of file diff --git a/Lara-JS/src-api/visualization/public/css/imports.css b/Lara-JS/src-api/visualization/public/css/imports.css new file mode 100644 index 000000000..9b68a9c8c --- /dev/null +++ b/Lara-JS/src-api/visualization/public/css/imports.css @@ -0,0 +1,13 @@ +/* Fonts */ +@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap'); + +/* Material Icons */ +@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200'); +.material-symbols-outlined { + font-variation-settings: + 'FILL' 0, + 'wght' 400, + 'GRAD' 0, + 'opsz' 24 +} diff --git a/Lara-JS/src-api/visualization/public/css/styles.css b/Lara-JS/src-api/visualization/public/css/styles.css new file mode 100644 index 000000000..65c9aa66d --- /dev/null +++ b/Lara-JS/src-api/visualization/public/css/styles.css @@ -0,0 +1,352 @@ +:root { + --ast-container-width: 35vw; +} + +body { + height: 100vh; + margin: 0; + + font-family: 'Roboto', sans-serif; + font-weight: 500; + color: var(--text-color); + + display: grid; + grid-template-rows: 4em calc(100vh - 4em); /* To force the containers to not span beyond the window size */ +} + +.icon { + color: var(--icon-color); +} + +button { + height: 2.5em; + padding: 0 1.25em; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--button-bg-color); + color: var(--text-color); + + font-size: 1em; + font-weight: 500; + font-family: inherit; + + cursor: pointer; + + transition: background-color 0.1s linear, color 0.1s linear; +} + +button .icon { + color: var(--icon-color); +} + +button:hover { + background-color: var(--button-hover-bg-color); +} + +button:disabled { + background-color: var(--button-disabled-bg-color); + cursor: not-allowed; +} + + +header { + padding: 0.5em; + background-color: var(--header-color); + + display: flex; + align-items: center; + justify-content: left; + gap: 0.5em; +} + +header .specs-logo { + height: 100%; +} + +header h1 { + font-size: 1.5em; +} + + +main { + --code-container-width: calc(100% - var(--ast-container-width) - 1em); + + width: 100vw; + box-sizing: border-box; + padding: 1em 2em 2em; + background-color: var(--bg-color); + + display: grid; + grid-template-columns: var(--ast-container-width) 1em var(--code-container-width); + grid-template-rows: 3.5em 1fr; + grid-template-areas: "continue-button . file-tabs" + "ast resizer code"; +} + + +#continue-button { + width: fit-content; + + grid-area: continue-button; + + display: flex; + justify-content: center; + align-items: center; + gap: 0.5em; +} + +#ast-container { + grid-area: ast; +} + +#code-container { + grid-area: code; +} + + +.container { + box-sizing: border-box; + padding: 0.25em 0.5em; + margin: 0; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--bg-color); + + overflow: auto; +} + + +#ast-container { + line-height: 1.25em; + + display: flex; + flex-direction: column; + + user-select: none; + -webkit-user-select: none; +} + +.ast-node { + width: min-content; + + display: flex; + justify-content: left; + align-items: center; + gap: 0.25em +} + +.ast-node button { + height: 1.5em; + padding: 0; + background-color: var(--bg-color); + border: none; + border-radius: 50%; +} + +.ast-node button:disabled { + cursor: default; +} + +.ast-node button:disabled .icon { + color: var(--disabled-icon-color); +} + +.ast-node-dropdown { + padding-left: 2em; +} + + +#code-container { + z-index: 1; + + display: grid; + grid-template-columns: auto 1fr; + gap: 1em; + + user-select: none; + -webkit-user-select: none; +} + +#code-container .lines, #code-container pre code { + font-family: "JetBrains Mono", monospace; + font-weight: 400; +} + +#code-container pre { + margin: 0; +} + +#code-container .lines { + color: var(--line-num-color); +} + +#code-container code { + display: none; +} + +#code-container code.active { + display: block; +} + + +#node-info-container { + width: fit-content; + max-width: 40vw; + height: fit-content; + max-height: 40vh; + padding-inline: 0.5em; + + font-weight: 400; + + position: absolute; + right: 2.5em; + bottom: 2.5em; + z-index: 2; + + display: none; + + overflow: auto; +} + +#node-info-container p { + margin: 0; +} + +#node-info-container p > span:first-of-type, #node-info-container .alert { + font-weight: bold; +} + +#node-info-container pre { + border-radius: 4px; + background-color: var(--secondary-code-bg-color); +} + +#resizer { + width: 1em; + height: 1.2em; + + grid-area: resizer; + justify-self: center; + align-self: center; + + display: flex; + justify-content: center; +} + +#resizer > div { + width: 0.2em; + border-inline: 2px solid var(--border-color); +} + + +#file-tabs { + box-sizing: border-box; + height: 3.5em; + width: fit-content; + max-width: 100%; + padding: 0.125em; + + border-width: 1px 1px 0; + border-style: solid; + border-color: var(--border-color); + border-radius: 4px 4px 0 0; + + grid-area: file-tabs; + position: relative; + bottom: calc(-1em + 1px); +} + +#file-tabs > div { + height: 100%; + display: flex; + align-items: start; + justify-content: left; + gap: 0.125em; + + overflow-x: auto; + overflow-y: hidden; +} + +#file-tabs:not(:has(*)) { + display: none; +} + +.file-tab { + box-sizing: border-box; + height: 2.25em; + padding: 0.625em 1em; + border: none; + border-radius: 2px; + background-color: var(--tab-bg-color); + + font-weight: 500; + + display: inline-block; + + user-select: none; + -webkit-user-select: none; + + transition: background-color 0.1s linear; +} + +.file-tab:hover { + background-color: var(--tab-hover-bg-color); +} + +.file-tab.active { + background-color: var(--tab-active-bg-color); +} + +.file-tabs-arrow { + padding: 0.625em; + background-color: var(--translucid-bg-color); + border: none; + + position: absolute; + top: 0; +} + +.file-tabs-arrow:hover { + background-color: var(--translucid-bg-color); +} + +#file-tabs-arrow-left { + left: 0; +} + +#file-tabs-arrow-right { + right: 0; +} + +.file-tabs-arrow:disabled { + display: none; +} + + +.loading { + color: var(--text-color); + margin: auto; + + font-family: 'Roboto', sans-serif; + font-size: 1.5em; + + display: block; + + animation: loading-animation 1.5s ease-in-out infinite; +} + +#code-container > .loading { + grid-column-end: span 2; +} + +@keyframes loading-animation { + 0% { + opacity: 0.6; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.6; + } +} \ No newline at end of file diff --git a/Lara-JS/src-api/visualization/public/css/syntax-highlighting.css b/Lara-JS/src-api/visualization/public/css/syntax-highlighting.css new file mode 100644 index 000000000..d52cf1bdd --- /dev/null +++ b/Lara-JS/src-api/visualization/public/css/syntax-highlighting.css @@ -0,0 +1,27 @@ +/* Syntax highlighting color scheme (using base colors in color-scheme.css) */ + +:root { + --comment: var(--dark-gray); + --keyword: var(--purple); + --type: var(--purple); + --string: var(--blue); + --literal: var(--blue); +} + +@media (prefers-color-scheme: dark) { + :root { + --comment: var(--gray); + --keyword: var(--violet); + --type: var(--violet); + --string: var(--light-blue); + --literal: var(--light-blue); + } +} + +/* Syntax highlighting classes */ + +.comment { color: var(--comment); } +.keyword { color: var(--keyword); } +.type { color: var(--type); } +.string { color: var(--string); } +.literal { color: var(--literal); } diff --git a/Lara-JS/src-api/visualization/public/favicon.ico b/Lara-JS/src-api/visualization/public/favicon.ico new file mode 100644 index 000000000..23007c7f3 Binary files /dev/null and b/Lara-JS/src-api/visualization/public/favicon.ico differ diff --git a/Lara-JS/src-api/visualization/public/img/specs-logo.png b/Lara-JS/src-api/visualization/public/img/specs-logo.png new file mode 100644 index 000000000..23d45e3c4 Binary files /dev/null and b/Lara-JS/src-api/visualization/public/img/specs-logo.png differ diff --git a/Lara-JS/src-api/visualization/public/index.html b/Lara-JS/src-api/visualization/public/index.html new file mode 100644 index 000000000..1283556a1 --- /dev/null +++ b/Lara-JS/src-api/visualization/public/index.html @@ -0,0 +1,36 @@ + + + + + + + + + + + LARA Visualization Tool + + + +
+ +

LARA Visualization Tool

+
+
+ +
+
+
Loading...
+
+
+
+
+
Loading...
+
+
+
+ + \ No newline at end of file diff --git a/Lara-JS/src-api/visualization/public/js/ToolJoinPoint.ts b/Lara-JS/src-api/visualization/public/js/ToolJoinPoint.ts new file mode 100644 index 000000000..4a65db582 --- /dev/null +++ b/Lara-JS/src-api/visualization/public/js/ToolJoinPoint.ts @@ -0,0 +1,103 @@ +export type JoinPointInfo = { [attribute: string]: string }; + +export default class ToolJoinPoint { + #id: string; + #type: string; + #code: string | undefined; + #filepath: string | undefined; + #info: JoinPointInfo; + #children: ToolJoinPoint[]; + + constructor(id: string, type: string, code: string | undefined, filepath: string | undefined, info: JoinPointInfo, children: ToolJoinPoint[]) { + this.#id = id; + this.#type = type; + this.#filepath = filepath; + this.#code = code; + this.#info = info; + this.#children = children; + } + + /** + * @brief Returns the join point ID. + */ + get id(): string { + return this.#id; + } + + /** + * @brief Returns the type of join point. + */ + get type(): string { + return this.#type; + } + + /** + * @brief Returns the code of the join point. + */ + get code(): string | undefined { + return this.#code; + } + + /** + * @brief Returns the filepath of the file this join point belongs to. + */ + get filepath(): string | undefined { + return this.#filepath; + } + + /** + * @brief Returns extra information about the join point. + */ + get info(): JoinPointInfo { + return this.#info; + } + + /** + * @brief Returns the children of the join point. + */ + get children(): ToolJoinPoint[] { + return this.#children + } + + /** + * @brief Creates a new ToolJoinPoint object from a JSON object. + */ + public static fromJson(json: any): ToolJoinPoint { + return new ToolJoinPoint( + json.id, + json.type, + json.code, + json.filepath, + json.info, + json.children.map((child: any) => ToolJoinPoint.fromJson(child)) + ); + } + + /** + * @brief Converts the ToolJoinPoint object to a JSON object. + */ + public toJson(): any { + return { + id: this.#id, + type: this.#type, + code: this.#code, + filepath: this.#filepath, + info: this.#info, + children: this.#children.map((child) => child.toJson()), + }; + } + + /** + * @brief Clones the join point. + */ + public clone(): ToolJoinPoint { + return new ToolJoinPoint( + this.#id, + this.#type, + this.#code, + this.#filepath, + this.#info, + this.#children.map((child) => child.clone()) + ); + } +}; \ No newline at end of file diff --git a/Lara-JS/src-api/visualization/public/js/ast-import.ts b/Lara-JS/src-api/visualization/public/js/ast-import.ts new file mode 100644 index 000000000..e27cf2fd3 --- /dev/null +++ b/Lara-JS/src-api/visualization/public/js/ast-import.ts @@ -0,0 +1,100 @@ +/** + * @file ast-import.ts + * @brief Functions for importing the AST and code to the visualization. + */ + +import { countChar } from './utils.js'; +import JoinPoint from './ToolJoinPoint.js'; +import { createCodeElement, createCodeLines, createCodeWrapper, createNodeDropdown, createNodeDropdownButton, createNodeElement, getActiveCodeElement, getAstContainer, getCodeContainer, getCodeLines, getMainCodeWrapper } from './components.js'; + +/** + * @brief Updates the line numbering of the code container. + */ +const updateLines = (): void => { + const codeLines = getCodeLines(); + const codeWrapper = getMainCodeWrapper(); + if (!codeLines || !codeWrapper) + throw new Error('Code container not initialized'); + + const codeElement = getActiveCodeElement(); + const code = codeElement?.textContent ?? ''; + + const numLines = countChar(code, '\n') + 1; + const newCodeLines = createCodeLines(numLines); + codeLines.replaceWith(newCodeLines); +}; + +/** + * @brief Initializes the code container, by adding the code lines and the code wrapper. + */ +const initCodeContainer = (): void => { + const codeContainer = getCodeContainer(); + codeContainer.innerHTML = ''; + + const codeLines = createCodeLines(0); + const codeWrapper = createCodeWrapper(); + + codeContainer.append(codeLines, codeWrapper); +}; + +/** + * @brief Adds a new hidden code element, with the given code, to the code container. + * + * @param code Code of the element + * @param filepath Path of the file + */ +const addFileCode = (code: string, filepath: string): void => { + const codeWrapper = getMainCodeWrapper(); + if (!codeWrapper) + throw new Error('Code container not initialized'); + + const codeElement = createCodeElement(code); + codeElement.dataset.filepath = filepath; + codeWrapper.appendChild(codeElement); +}; + +/** + * @brief Converts the AST to node HTML elements and their respective dropdowns. + * + * @param root Root of the AST + * @returns The resulting node HTML elements + */ +const toNodeElements = (root: JoinPoint): DocumentFragment => { + const fragment = new DocumentFragment(); + + if (root.children.length > 0) { + const dropdown = createNodeDropdown(root.id); + + for (const node of root.children) { + const descendantNodeElements = toNodeElements(node); + dropdown.appendChild(descendantNodeElements); + } + + const dropdownButton = createNodeDropdownButton(dropdown); + const nodeElement = createNodeElement(root.id, root.type, dropdownButton); + + fragment.append(nodeElement, dropdown); + } else { + const dropdownButton = createNodeDropdownButton(); + const nodeElement = createNodeElement(root.id, root.type, dropdownButton); + + fragment.appendChild(nodeElement); + } + + return fragment; +}; + +/** + * @brief Imports the AST to the AST container. + * + * @param astRoot Root of the AST + */ +const importAst = (astRoot: JoinPoint): void => { + const astContainer = getAstContainer(); + + const astFragment = toNodeElements(astRoot); + astContainer.innerHTML = ''; + astContainer.appendChild(astFragment); +}; + +export { importAst, initCodeContainer, addFileCode, updateLines }; diff --git a/Lara-JS/src-api/visualization/public/js/communication.ts b/Lara-JS/src-api/visualization/public/js/communication.ts new file mode 100644 index 000000000..e2d162d5f --- /dev/null +++ b/Lara-JS/src-api/visualization/public/js/communication.ts @@ -0,0 +1,88 @@ +/** + * @file communication.ts + * @brief Functions for communication with the server. + */ + +import { importAst, initCodeContainer } from "./ast-import.js"; +import { getContinueButton } from "./components.js"; +import { addFile, clearFiles, selectFile } from "./files.js"; +import { addHighlighingEventListeners } from "./visualization.js"; + +/** + * @brief WebSocket message handler for the 'update' message. + * @details When executed, this function updates the code container and the AST + * with the new data. + * + * @param data Message data + */ +const onUpdate = (data: any): void => { + const buttonDisabled = getContinueButton().disabled; + + getContinueButton().disabled = true; + + initCodeContainer(); + clearFiles(); + for (const [filename, filecode] of Object.entries(data.code)) + addFile(filename, filecode as string); + + importAst(data.ast); + addHighlighingEventListeners(data.ast); + + selectFile(Object.keys(data.code)[0]); + + getContinueButton().disabled = buttonDisabled; +}; + +const webSocketOnMessage = (message: MessageEvent): void => { + const continueButton = getContinueButton(); + const data = parseMessage(message); + + switch (data.message) { + case 'update': + onUpdate(data); + break; + + case 'wait': + continueButton.disabled = false; + break; + + case 'continue': + continueButton.disabled = true; + break; + } +}; + +/** + * @brief Creates a WebSocket connection to the server, with the message event + * listener. + * + * @returns WebSocket object + */ +const getWebSocket = (): WebSocket => { + const url = '/'; + const ws = new WebSocket(url); + ws.addEventListener('message', webSocketOnMessage); + return ws; +}; + +const sendData = (ws: WebSocket, data: any): void => { + ws.send(JSON.stringify(data)); +}; + +const parseMessage = (message: MessageEvent): any => { + return JSON.parse(message.data); +}; + +const continueButtonOnClick = (ws: WebSocket): void => { + const continueButton = getContinueButton(); + continueButton.disabled = true; + sendData(ws, { message: 'continue' }); +}; + +export { + getWebSocket, + sendData, + parseMessage, + webSocketOnMessage, + continueButtonOnClick, +}; diff --git a/Lara-JS/src-api/visualization/public/js/components.ts b/Lara-JS/src-api/visualization/public/js/components.ts new file mode 100644 index 000000000..d3f66da75 --- /dev/null +++ b/Lara-JS/src-api/visualization/public/js/components.ts @@ -0,0 +1,295 @@ +const getAstContainer = (() => { + const astContainer = document.querySelector('#ast-container'); + if (!astContainer) { + throw new Error('Could not find AST container'); + } + return (): HTMLDivElement => astContainer; +})(); + +const getCodeContainer = (() => { + const codeContainer = document.querySelector('#code-container'); + if (!codeContainer) { + throw new Error('Could not find code container'); + } + return (): HTMLDivElement => codeContainer; +})(); + +const getNodeInfoContainer = (() => { + const nodeInfoContainer = document.querySelector('#node-info-container'); + if (!nodeInfoContainer) { + throw new Error('Could not find node info container') + } + return (): HTMLDivElement => nodeInfoContainer; +})(); + +const getContinueButton = (() => { + const continueButton = document.querySelector('#continue-button'); + if (!continueButton) { + throw new Error('Could not find continue button'); + } + return (): HTMLButtonElement => continueButton; +})(); + +const getResizer = (() => { + const resizer = document.querySelector('#resizer'); + if (!resizer) { + throw new Error('Could not find resizer'); + } + return (): HTMLDivElement => resizer; +})(); + +const getFileTabs = (() => { + const fileTabs = document.querySelector('#file-tabs'); + if (!fileTabs) { + throw new Error('Could not find file tabs'); + } + return (): HTMLDivElement => fileTabs; +})(); + + +const getNodeElement = (nodeId: string): HTMLSpanElement | null => { + return document.querySelector(`.ast-node[data-node-id="${nodeId}"]`); +}; + +const getNodeText = (nodeId: string): HTMLSpanElement | null => { + const nodeElement = getNodeElement(nodeId); + return nodeElement?.querySelector('.node-text') ?? null; +}; + +const getFirstNodeCodeElement = (nodeId: string): HTMLSpanElement | null => { + return document.querySelector(`.node-code[data-node-id="${nodeId}"]`); +}; + +const getNodeCodeElements = (nodeId: string): HTMLSpanElement[] => { + return Array.from(document.querySelectorAll(`.node-code[data-node-id="${nodeId}"]`)); +}; + +const getHighlightableElements = (nodeId: string): HTMLElement[] => { + const nodeText = getNodeText(nodeId); + if (!nodeText) + return []; + + const nodeCodeElements = getNodeCodeElements(nodeId); + return [nodeText, ...nodeCodeElements]; +}; + + +const getMainCodeWrapper = (): HTMLPreElement | null => { + return getCodeContainer().querySelector('.code-wrapper'); +}; + +const getCodeLines = (): HTMLPreElement | null => { + return getCodeContainer().querySelector('.lines'); +}; + +const getActiveCodeElement = (): HTMLElement | null => { + return getMainCodeWrapper()?.querySelector('code.active') ?? null; +}; + +const getFileCodeElement = (filename: string): HTMLElement | null => { + return getCodeContainer().querySelector(`code[data-filepath="${filename}"]`); +}; + + +const getFileTabsInternalDiv = (): HTMLDivElement | null => { + return getFileTabs().querySelector('div'); +} + +const getFileTab = (filepath: string): HTMLButtonElement | null => { + return getFileTabs().querySelector(`.file-tab[data-filepath="${filepath}"]`); +}; + +const getActiveFileTab = (): HTMLButtonElement | null => { + return getFileTabs().querySelector('.file-tab.active'); +}; + +const getFileTabsArrow = (direction: 'left' | 'right'): HTMLButtonElement | null => { + return document.querySelector(`#file-tabs-arrow-${direction}`); +} + + +const createIcon = (name: string): HTMLElement => { + const icon = document.createElement('span'); + icon.classList.add('icon', 'material-symbols-outlined'); + icon.textContent = name; + + return icon; +}; + + +const createNodeDropdown = (nodeId: string): HTMLDivElement => { + const dropdown = document.createElement('div'); + dropdown.classList.add('ast-node-dropdown'); + dropdown.dataset.nodeId = nodeId; + + return dropdown; +}; + +const createDropdownButtonOnClick = (dropdown: HTMLElement) => { + let nodeCollapsed = false; + return (event: Event): void => { + nodeCollapsed = !nodeCollapsed; + dropdown.style.display = nodeCollapsed ? 'none' : 'block'; + + const dropdownButton = event.currentTarget as HTMLElement; + const chevronIcon = dropdownButton.children[0] as HTMLElement; + chevronIcon.textContent = nodeCollapsed ? 'keyboard_arrow_right' : 'keyboard_arrow_down'; + + event.stopPropagation(); + }; +}; + +const createNodeDropdownButton = (dropdown?: HTMLElement): HTMLButtonElement => { + const dropdownButton = document.createElement('button'); + + const arrowIcon = createIcon('keyboard_arrow_down'); + dropdownButton.appendChild(arrowIcon); + + if (dropdown) { + dropdownButton.addEventListener('click', createDropdownButtonOnClick(dropdown)); + } else { + dropdownButton.disabled = true; + } + + return dropdownButton; +}; + +const createNodeElement = (nodeId: string, text: string, dropdownButton: HTMLElement): HTMLSpanElement => { + const nodeElement = document.createElement('span'); // TODO: Convert to div + nodeElement.classList.add('ast-node'); + + nodeElement.dataset.nodeId = nodeId; + + const nodeText = document.createElement('span'); + nodeText.classList.add('node-text'); + nodeText.textContent = text; + + nodeElement.appendChild(dropdownButton); + nodeElement.appendChild(nodeText); + + return nodeElement; +}; + + +const createCodeElement = (code: string = ''): HTMLElement => { + const codeElement = document.createElement('code'); + codeElement.innerHTML = code; + return codeElement; +}; + +const createCodeLines = (numLines: number): HTMLPreElement => { + const codeLines = document.createElement('pre'); + codeLines.classList.add('lines'); + codeLines.textContent = Array.from({ length: numLines }, (_, i) => i + 1).join('\n'); + return codeLines; +}; + +const createCodeWrapper = (): HTMLPreElement => { + const codeWrapper = document.createElement('pre'); + codeWrapper.classList.add('code-wrapper'); + return codeWrapper; +}; + +const createNodeInfoLine = (name: string, value: string): HTMLParagraphElement => { + const attributeName = document.createElement('span'); + attributeName.textContent = name + ': '; + + const attributeValue = document.createElement('span'); + attributeValue.textContent = value; + + const line = document.createElement('p'); + line.append(attributeName, attributeValue); + return line; +}; + +const createNodeInfoAlert = (alert: string): HTMLParagraphElement => { + const codeAlert = document.createElement('p'); + codeAlert.classList.add('alert'); + codeAlert.textContent = alert; + return codeAlert; +}; + +const createFileTab = (filepath: string): HTMLButtonElement => { + const fileTab = document.createElement('button'); + fileTab.classList.add('file-tab'); + fileTab.dataset.filepath = filepath; + + fileTab.title = filepath; + fileTab.textContent = filepath !== '' ? filepath.slice(filepath.lastIndexOf('/') + 1) : ''; + + return fileTab; +}; + +const fileTabsArrowOnClick = (event: Event, direction: 'left' | 'right') => { + const fileTabsInternalDiv = getFileTabsInternalDiv()!; + const activeTabIndex = Array.from(fileTabsInternalDiv.children).findIndex(tab => tab.classList.contains('active')); + + if (direction === 'left' && activeTabIndex > 0) { + (fileTabsInternalDiv.children[activeTabIndex - 1] as HTMLButtonElement).click(); + } else if (direction === 'right' && activeTabIndex < fileTabsInternalDiv.children.length - 1) { + (fileTabsInternalDiv.children[activeTabIndex + 1] as HTMLButtonElement).click(); + } + + event.stopPropagation(); +}; + +const createFileTabsArrow = (direction: 'left' | 'right'): HTMLButtonElement => { + const arrow = document.createElement('button'); + arrow.classList.add('file-tabs-arrow'); + arrow.id = `file-tabs-arrow-${direction}`; + + arrow.appendChild(createIcon(`keyboard_arrow_${direction}`)); + arrow.addEventListener('click', event => fileTabsArrowOnClick(event, direction)); + return arrow; +} + +/** + * @brief Updates the file tabs arrows, enabling or disabling them based on the + * number of tabs and selected tab. + */ +const updateFileTabsArrows = (): void => { + const fileTabs = getFileTabs(); + const fileTabsInternalDiv = getFileTabsInternalDiv()!; + const activeFileTab = getActiveFileTab(); + + const fileTabsLeftArrow = getFileTabsArrow('left')!; + const fileTabsRightArrow = getFileTabsArrow('right')!; + + const fileTabsOverflow = fileTabs.scrollWidth < fileTabsInternalDiv.scrollWidth; + fileTabsLeftArrow.disabled = !fileTabsOverflow || fileTabsInternalDiv.children[0] === activeFileTab; + fileTabsRightArrow.disabled = !fileTabsOverflow || fileTabsInternalDiv.children[fileTabsInternalDiv.children.length - 1] === activeFileTab; +} + +export { + getAstContainer, + getCodeContainer, + getNodeInfoContainer, + getContinueButton, + getResizer, + getFileTabs, + getNodeElement, + getNodeText, + getFirstNodeCodeElement, + getNodeCodeElements, + getHighlightableElements, + getMainCodeWrapper, + getCodeLines, + getActiveCodeElement, + getFileCodeElement, + getFileTab, + getFileTabsInternalDiv, + getActiveFileTab, + getFileTabsArrow, + createIcon, + createNodeDropdown, + createNodeDropdownButton, + createNodeElement, + createNodeInfoLine, + createNodeInfoAlert, + createCodeLines, + createCodeElement, + createCodeWrapper, + createFileTab, + createFileTabsArrow, + updateFileTabsArrows, +}; \ No newline at end of file diff --git a/Lara-JS/src-api/visualization/public/js/files.ts b/Lara-JS/src-api/visualization/public/js/files.ts new file mode 100644 index 000000000..d5901e91e --- /dev/null +++ b/Lara-JS/src-api/visualization/public/js/files.ts @@ -0,0 +1,80 @@ +/** + * @file files.ts + * @brief Functions for handling files in the visualization. + */ + +import { addFileCode, updateLines } from "./ast-import.js"; +import { createFileTab, createFileTabsArrow, getActiveCodeElement, getActiveFileTab, getFileCodeElement, getFileTab, getFileTabsInternalDiv, getFileTabs, getMainCodeWrapper, updateFileTabsArrows } from "./components.js"; + +let selectedFilepath: string | null = null; + +/** + * @brief Adds a new file, with the respective file tab and (hidden) code, to the visualization. + * + * @param path Path of the file + * @param code File code + */ +const addFile = (path: string, code: string): void => { + addFileCode(code, path); + + const fileTab = createFileTab(path); + fileTab.addEventListener('click', () => selectFile(path)); + + const fileTabs = getFileTabs(); + const fileTabsInternalDiv = getFileTabsInternalDiv()!; + if (fileTabsInternalDiv.children.length === 0) { + const fileTabsLeftArrow = createFileTabsArrow('left'); + const fileTabsRightArrow = createFileTabsArrow('right'); + fileTabs.append(fileTabsLeftArrow, fileTabsRightArrow); + } + + fileTabsInternalDiv.appendChild(fileTab); +}; + +/** + * @brief Clears all files from the code container. + */ +const clearFiles = (): void => { + const codeWrapper = getMainCodeWrapper(); + if (!codeWrapper) + throw new Error('Code container not initialized'); + + const fileTabs = getFileTabs(); + fileTabs.innerHTML = ''; + fileTabs.appendChild(document.createElement('div')); + codeWrapper.innerHTML = ''; + + selectedFilepath = null; +}; + +/** + * @brief Selects a file, by making its code visible in the code container. + * @param filepath + */ +const selectFile = (filepath: string): void => { + const fileTab = getFileTab(filepath); + if (!fileTab) + throw new Error(`File "${filepath}" not found`); + + if (filepath !== selectedFilepath) { + const activeFileTab = getActiveFileTab(); + if (activeFileTab) + activeFileTab.classList.remove('active'); + fileTab.classList.add('active'); + + const activeCode = getActiveCodeElement(); + if (activeCode) + activeCode.classList.remove('active'); + + const fileCodeElement = getFileCodeElement(filepath)!; + fileCodeElement.classList.add('active'); + updateLines(); + + fileTab.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + updateFileTabsArrows(); + + selectedFilepath = filepath; + } +}; + +export { addFile, clearFiles, selectFile }; diff --git a/Lara-JS/src-api/visualization/public/js/main.ts b/Lara-JS/src-api/visualization/public/js/main.ts new file mode 100644 index 000000000..b955b8ac1 --- /dev/null +++ b/Lara-JS/src-api/visualization/public/js/main.ts @@ -0,0 +1,21 @@ +import { continueButtonOnClick, getWebSocket } from "./communication.js"; +import { getContinueButton } from "./components.js"; +import { addResizerEventListeners } from "./visualization.js"; + +const setupEventListeners = (ws: WebSocket): void => { + const continueButton = getContinueButton(); + continueButton.addEventListener('click', () => continueButtonOnClick(ws)); + + addResizerEventListeners(); +} + +(() => { + let ws: WebSocket; + const setupWebSocket = () => { + ws = getWebSocket(); + ws.addEventListener('close', () => setTimeout(setupWebSocket, 1000)); + }; + setupWebSocket(); + + setupEventListeners(ws!); +})(); diff --git a/Lara-JS/src-api/visualization/public/js/utils.ts b/Lara-JS/src-api/visualization/public/js/utils.ts new file mode 100644 index 000000000..20ed67024 --- /dev/null +++ b/Lara-JS/src-api/visualization/public/js/utils.ts @@ -0,0 +1,22 @@ +/** + * @file utils.ts + * @brief Utility functions for the visualization tool. + */ + +/** + * @brief Counts the number of occurrences of a character in a string. + * + * @param str String to search + * @param char Target character + * @returns Number of occurrences of char in str + */ +const countChar = (str: string, char: string): number => { + let count = 0; + for (const c of str) { + if (c === char) + count++; + } + return count; +}; + +export { countChar }; \ No newline at end of file diff --git a/Lara-JS/src-api/visualization/public/js/visualization.ts b/Lara-JS/src-api/visualization/public/js/visualization.ts new file mode 100644 index 000000000..0a1d8ad80 --- /dev/null +++ b/Lara-JS/src-api/visualization/public/js/visualization.ts @@ -0,0 +1,235 @@ +/** + * @file visualization.ts + * @brief Functions for handling the visualization behavior and events. + */ + +import { createCodeElement, createCodeWrapper, createNodeInfoAlert, createNodeInfoLine, getAstContainer, getCodeContainer, getContinueButton, getFileTabsInternalDiv, getFirstNodeCodeElement, getHighlightableElements, getNodeElement, getNodeInfoContainer, getNodeText, getResizer, updateFileTabsArrows } from "./components.js"; +import { selectFile } from "./files.js"; +import JoinPoint from "./ToolJoinPoint.js"; + +/** + * @brief Highlights the node with the given id. + * + * @param nodeId Node id + * @param strong If the highlight should use a strong color + */ +const highlightNode = (nodeId: string, strong: boolean): void => { + const nodeElement = getNodeElement(nodeId); + if (!nodeElement) { + console.warn(`There is no node with id ${nodeId}`); + return; + } + + const highlightableElements = getHighlightableElements(nodeId); + highlightableElements.forEach(elem => elem.style.backgroundColor = strong ? 'var(--highlight-color)' : 'var(--secondary-highlight-color)'); + + let parentNode = nodeElement.parentElement?.previousSibling; + while (parentNode instanceof HTMLElement && parentNode.classList.contains('ast-node')) { + const parentNodeId = parentNode.dataset.nodeId! + const parentNodeText = getNodeText(parentNodeId)!; + parentNodeText.style.backgroundColor = strong ? 'var(--secondary-highlight-color)' : 'var(--tertiary-highlight-color)'; + + parentNode = parentNode.parentElement?.previousSibling; + } +}; + +/** + * @brief Unhighlights the node with the given id. + * + * @param nodeId Node id + */ +const unhighlightNode = (nodeId: string): void => { + const nodeElement = getNodeElement(nodeId); + if (!nodeElement) { + console.warn(`There is no node with id ${nodeId}`); + return; + } + + const highlightableElements = getHighlightableElements(nodeId)!; + highlightableElements.forEach(elem => elem.style.backgroundColor = ''); + + let parentNode = nodeElement.parentElement?.previousSibling; + while (parentNode instanceof HTMLElement && parentNode.classList.contains('ast-node')) { + const parentNodeId = parentNode.dataset.nodeId! + const parentNodeText = getNodeText(parentNodeId)!; + parentNodeText.style.backgroundColor = ''; + + parentNode = parentNode.parentElement?.previousSibling; + } +}; + +/** + * @brief Shows the node info container with the given node information. + * + * @param node The target node + */ +const showNodeInfo = (node: JoinPoint): void => { + const nodeInfoContainer = getNodeInfoContainer(); + nodeInfoContainer.style.display = 'block'; + nodeInfoContainer.innerHTML = '' + + for (const [name, value] of Object.entries(node.info)) { + const line = createNodeInfoLine(name, value); + nodeInfoContainer.appendChild(line); + } + + const hasCode = getFirstNodeCodeElement(node.id) !== null; + if (!hasCode) { + if (node.code) { + const alert = createNodeInfoAlert('Node code not found:'); + const codeElement = createCodeElement(node.code); + const codeWrapper = createCodeWrapper(); + codeWrapper.appendChild(codeElement); + nodeInfoContainer.append(alert, codeWrapper); + } else { + const alert = createNodeInfoAlert('Node does not have code!'); + nodeInfoContainer.appendChild(alert); + } + } +}; + +/** + * @brief Hides the node information container. + */ +const hideNodeInfo = (): void => { + const nodeInfoContainer = getNodeInfoContainer(); + nodeInfoContainer.style.display = 'none'; + nodeInfoContainer.innerHTML = ''; +}; + +const scrollIntoViewIfNeeded = (element: HTMLElement, parent: HTMLElement): void => { + const rect = element.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); + + if (rect.bottom < parentRect.top || rect.top > parentRect.bottom) { + const scrollPos = rect.height <= parentRect.height + ? (rect.top + rect.bottom - parentRect.top - parentRect.bottom) / 2 + : rect.top - parentRect.top; + + parent.scrollBy({ top: scrollPos, left: 0, behavior: 'smooth' }); + } +}; + + +let selectedNodeId: string | null = null; + +const highlightableOnMouseOver = (node: JoinPoint, event: Event): void => { + highlightNode(node.id, false); + if (selectedNodeId !== null) + highlightNode(selectedNodeId, true); + event.stopPropagation(); +}; + +const highlightableOnMouseOut = (node: JoinPoint, event: Event): void => { + unhighlightNode(node.id); + if (selectedNodeId !== null) + highlightNode(selectedNodeId, true); + event.stopPropagation(); +}; + +const highlightableOnClick = (node: JoinPoint, event: Event): void => { + event.stopPropagation(); + + if (selectedNodeId !== null) { + unhighlightNode(selectedNodeId); + if (selectedNodeId === node.id) { + selectedNodeId = null; + hideNodeInfo(); + return; + } + } + + selectedNodeId = node.id; + highlightNode(node.id, true); + if (node.filepath) + selectFile(node.filepath); + + + const nodeElement = getNodeElement(node.id)!; + const astContainer = getAstContainer(); + scrollIntoViewIfNeeded(nodeElement, astContainer); + + const firstNodeCodeBlock = getFirstNodeCodeElement(node.id); + if (firstNodeCodeBlock) { + const codeContainer = getCodeContainer(); + scrollIntoViewIfNeeded(firstNodeCodeBlock!, codeContainer); + } + + showNodeInfo(node); +}; + +/** + * @brief Adds event listeners to all the highlightable elements relative to + * the nodes in the given AST. + * + * @param root Root of the AST + */ +const addHighlighingEventListeners = (root: JoinPoint): void => { + const addListeners = (node: JoinPoint) => { + const highlightableElements = getHighlightableElements(node.id); + + for (const element of highlightableElements) { + element.addEventListener('mouseover', event => highlightableOnMouseOver(node, event)); + element.addEventListener('mouseout', event => highlightableOnMouseOut(node, event)); + + element.tabIndex = 0; + element.addEventListener('click', event => highlightableOnClick(node, event)); + + // For keyboard accessibility + element.addEventListener('keydown', event => { + if (event.key === 'Enter') { + element.click(); + } + event.stopPropagation(); + }); + } + + node.children.forEach(child => addListeners(child)); + } + + selectedNodeId = null; // To prevent invalid references + addListeners(root); +}; + +/** + * @brief Adds event listeners to the resizer element. + */ +const addResizerEventListeners = (): void => { + const resizer = getResizer(); + const astContainer = getAstContainer(); + const codeContainer = getCodeContainer(); + const continueButton = getContinueButton(); + + let drag = false; + let width = astContainer.offsetWidth; + + const rootStyle = document.documentElement.style; + + resizer.addEventListener('mousedown', () => { + drag = true; + }); + + document.addEventListener('mouseup', () => { + drag = false; + }); + + document.addEventListener('mousemove', event => { + if (drag) { + const astLeft = astContainer.getBoundingClientRect().left; + const minWidth = continueButton.offsetWidth; + const maxWidth = codeContainer.getBoundingClientRect().right - astLeft - 160; + + width = event.x - astLeft; + if (width < minWidth) + width = minWidth; + else if (width > maxWidth) + width = maxWidth; + rootStyle.setProperty('--ast-container-width', `${width}px`); + + if (getFileTabsInternalDiv()) + updateFileTabsArrows(); + } + }); +}; + +export { addHighlighingEventListeners, addResizerEventListeners }; diff --git a/LaraApi/src-java/pt/up/fe/specs/lara/LaraApiJsResource.java b/LaraApi/src-java/pt/up/fe/specs/lara/LaraApiJsResource.java index a382ca4e5..167eb26b0 100644 --- a/LaraApi/src-java/pt/up/fe/specs/lara/LaraApiJsResource.java +++ b/LaraApi/src-java/pt/up/fe/specs/lara/LaraApiJsResource.java @@ -105,6 +105,17 @@ public enum LaraApiJsResource implements LaraResourceProvider { TIMEUNITS_JS("lara/util/TimeUnits.js"), TUPLEID_JS("lara/util/TupleId.js"), CYTOSCAPE_3_26_0_JS("libs/cytoscape-3.26.0.js"), + ASTCONVERTERUTILS_JS("visualization/AstConverterUtils.js"), + GENERICASTCONVERTER_JS("visualization/GenericAstConverter.js"), + GENERICVISUALIZATIONTOOL_JS("visualization/GenericVisualizationTool.js"), + TOOLJOINPOINT_JS("visualization/public/js/ToolJoinPoint.js"), + AST_IMPORT_JS("visualization/public/js/ast-import.js"), + COMMUNICATION_JS("visualization/public/js/communication.js"), + COMPONENTS_JS("visualization/public/js/components.js"), + FILES_JS("visualization/public/js/files.js"), + MAIN_JS("visualization/public/js/main.js"), + UTILS_JS("visualization/public/js/utils.js"), + VISUALIZATION_JS("visualization/public/js/visualization.js"), AST_JS("weaver/Ast.js"), JOINPOINTS_JS("weaver/JoinPoints.js"), QUERY_JS("weaver/Query.js"), diff --git a/LaraApi/src-lara/visualization/AstConverterUtils.js b/LaraApi/src-lara/visualization/AstConverterUtils.js new file mode 100644 index 000000000..3749ee94f --- /dev/null +++ b/LaraApi/src-lara/visualization/AstConverterUtils.js @@ -0,0 +1,57 @@ +/** + * @file AstConverterUtils.ts + * @brief Utility functions for the specializations of GenericAstConverter. + */ +/** + * @brief Adds indentation to the given code, with the exception of the first line. + * + * @param code Code + * @param indentation The indentation to use + * @returns The indented code + */ +const addIdentation = (code, indentation) => { + return code.split('\n').map((line, i) => i > 0 ? ' '.repeat(indentation) + line : line).join('\n'); +}; +/** + * @brief Escapes the HTML special characters in the given text. + * + * @param text Text to escape + * @returns The escaped text + */ +const escapeHtml = (text) => { + const specialCharMap = { + '&': '&', + '<': '<', + '>': '>', + }; + return text.replace(/[&<>]/g, (match) => specialCharMap[match]); +}; +/** + * @brief Returns the opening and closing span tags with the given attributes. + * + * @param attrs Attributes to add to the span tag + * @returns An array with the opening and closing span tags + */ +const getSpanTags = (...attrs) => { + return [``, '']; +}; +/** + * @brief Returns the span tags for the code of the given node. + * + * @param nodeId Node ID + * @returns An array with the opening and closing span tags + */ +const getNodeCodeTags = (nodeId) => { + return getSpanTags('class="node-code"', `data-node-id="${nodeId}"`); +}; +/** + * @brief Returns the span tags for syntax highlighting of code of the given type.. + * + * @param type Code type + * @returns An array with the opening and closing span tags + */ +const getSyntaxHighlightTags = (type) => { + return getSpanTags(`class="${type}"`); +}; +export { addIdentation, escapeHtml, getSpanTags, getNodeCodeTags, getSyntaxHighlightTags }; +//# sourceMappingURL=AstConverterUtils.js.map \ No newline at end of file diff --git a/LaraApi/src-lara/visualization/GenericAstConverter.js b/LaraApi/src-lara/visualization/GenericAstConverter.js new file mode 100644 index 000000000..9619bd83e --- /dev/null +++ b/LaraApi/src-lara/visualization/GenericAstConverter.js @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=GenericAstConverter.js.map \ No newline at end of file diff --git a/LaraApi/src-lara/visualization/GenericAstSerializer.js b/LaraApi/src-lara/visualization/GenericAstSerializer.js new file mode 100644 index 000000000..ceab74e66 --- /dev/null +++ b/LaraApi/src-lara/visualization/GenericAstSerializer.js @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=GenericAstSerializer.js.map \ No newline at end of file diff --git a/LaraApi/src-lara/visualization/GenericJoinPointConverter.js b/LaraApi/src-lara/visualization/GenericJoinPointConverter.js new file mode 100644 index 000000000..9ea593153 --- /dev/null +++ b/LaraApi/src-lara/visualization/GenericJoinPointConverter.js @@ -0,0 +1,2 @@ +export {}; +//# sourceMappingURL=GenericJoinPointConverter.js.map \ No newline at end of file diff --git a/LaraApi/src-lara/visualization/GenericVisualizationTool.js b/LaraApi/src-lara/visualization/GenericVisualizationTool.js new file mode 100644 index 000000000..443da27ce --- /dev/null +++ b/LaraApi/src-lara/visualization/GenericVisualizationTool.js @@ -0,0 +1,184 @@ +import express from 'express'; +import http from 'http'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { WebSocketServer } from 'ws'; +import JoinPoints from '../weaver/JoinPoints.js'; +/** + * @brief Abstract class for a the LARA visualization tool. + * @details To use this class in a compiler, this class must be extended and + * the getAstConverter method must be implemented, returning the compiler + * specialization of the GenericAstConverter class. + */ +export default class GenericVisualizationTool { + #hostname; + #port; + #wss; + #serverClosed = false; + #toolAst; + #prettyHtmlCode; + /** + * @brief True whether the visualization tool is launched, and false otherwise + */ + get isLaunched() { + return this.#wss !== undefined && this.#serverClosed === false; + } + /** + * @brief Hostname to which the visualization tool is listening to. + */ + get hostname() { + return this.#hostname; + } + /** + * @brief Port to which the visualization tool is listening to. + */ + get port() { + return this.#port; + } + /** + * @brief URL to which the visualization tool is listening to. + */ + get url() { + return this.#hostname && this.#port ? `http://${this.#hostname}:${this.#port}` : undefined; + } + /** + * @brief Updates the stored tool AST and code with the info retrieved from the astConverter. + * + * @param astRoot The root of the wanted AST + */ + updateAstAndCode(astRoot) { + const astConverter = this.getAstConverter(); + astConverter.updateAst(); + this.#toolAst = astConverter.getToolAst(astRoot); + this.#prettyHtmlCode = astConverter.getPrettyHtmlCode(astRoot); + } + /** + * @brief WebSocket server error handler. + * + * @param error The error that occurred + */ + onWssError(error) { + switch (error.code) { + case 'EADDRINUSE': + console.error(`[server]: Port ${this.#port} is already in use`); + break; + case 'EACCES': + console.error(`[server]: Permission denied to use port ${this.#port}`); + break; + default: + console.error(`[server]: Unknown error occurred: ${error.message}`); + break; + } + ; + this.#wss.close(); + } + /** + * @brief Launches the visualization tool. + * + * @param hostname The hostname to listen to + * @param port The port to listen to + */ + async launch(hostname, port) { + const app = express(); + const server = http.createServer(app); + this.#wss = new WebSocketServer({ server: server }); + const filename = fileURLToPath(import.meta.url); + const dirname = path.dirname(filename); + app.use(express.static(path.join(dirname, 'public'))); + this.#wss.on('connection', ws => this.updateClient(ws)); + this.#wss.on('close', () => { this.#serverClosed = true; }); + this.#wss.on('error', error => this.onWssError(error)); + return new Promise(res => { + server.listen(port, hostname, () => { + const addressInfo = server.address(); + this.#hostname = addressInfo.address; + this.#port = addressInfo.port; + this.#serverClosed = false; + res(); + }); + }); + } + /** + * @brief Sends a message to a specific client + * + * @param ws Client WebSocket + * @param data Data to be sent + */ + sendToClient(ws, data) { + ws.send(JSON.stringify(data)); + } + /** + * @brief Sends a message to all the clients + * + * @param data Message data + */ + sendToAllClients(data) { + this.#wss.clients.forEach(ws => this.sendToClient(ws, data)); + } + /** + * @brief Updates the client with the current tool AST and code. + * + * @param ws Client WebSocket + */ + updateClient(ws) { + this.sendToClient(ws, { + message: 'update', + ast: this.#toolAst.toJson(), + code: this.#prettyHtmlCode, + }); + } + /** + * @brief Updates all the clients with the current tool AST and code. + */ + updateAllClients() { + this.#wss.clients.forEach(ws => this.updateClient(ws)); + } + /** + * @brief Waits for the tool to be ready to receive the AST and code. + */ + async waitForTool() { + return new Promise(res => { + let placeClientOnWait; + const waitOnMessage = (message) => { + const data = JSON.parse(message); + if (data.message === 'continue') { + this.#wss.clients.forEach(ws => { + this.#wss.off('connection', placeClientOnWait); + ws.off('message', waitOnMessage); + }); + this.sendToAllClients({ message: 'continue' }); + res(); + } + }; + placeClientOnWait = (ws) => { + ws.on('message', waitOnMessage); + this.sendToClient(ws, { message: 'wait' }); + }; + this.#wss.clients.forEach(placeClientOnWait); + this.#wss.on('connection', placeClientOnWait); + }); + } + /** + * @brief Visualizes the given AST. + * @details This function launches the visualization tool, if it is not + * already launched, and updates the web interface with the AST and code, + * otherwise. This can involve the recompilation of the code. + * + * @param astRoot Root of the AST to be visualized + * @param port The port to listen to + * @param hostname The hostname to listen to + */ + async visualize(astRoot = JoinPoints.root(), port = 3000, hostname = '127.0.0.1') { + this.updateAstAndCode(astRoot); + if (!this.isLaunched) { + await this.launch(hostname, port); + } + else { + this.updateAllClients(); + } + console.log(`\nVisualization tool is running at ${this.url}\n`); + await this.waitForTool(); + } +} +; +//# sourceMappingURL=GenericVisualizationTool.js.map \ No newline at end of file diff --git a/LaraApi/src-lara/visualization/VisualizationTool.js b/LaraApi/src-lara/visualization/VisualizationTool.js new file mode 100644 index 000000000..0610ce4a2 --- /dev/null +++ b/LaraApi/src-lara/visualization/VisualizationTool.js @@ -0,0 +1,129 @@ +import express from 'express'; +import http from 'http'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { WebSocketServer } from 'ws'; +import { wrapJoinPoint } from '../LaraJoinPoint.js'; +import JoinPoints from '../weaver/JoinPoints.js'; +export default class VisualizationTool { + static hostname; + static port; + static wss; + static serverClosed = false; + static isLaunched() { + return this.wss !== undefined && this.serverClosed === false; + } + static getHostname() { + return this.hostname; + } + static getPort() { + return this.port; + } + static onWssError(error) { + switch (error.code) { + case 'EADDRINUSE': + console.error(`[server]: Port ${this.port} is already in use`); + break; + case 'EACCES': + console.error(`[server]: Permission denied to use port ${this.port}`); + break; + default: + console.error(`[server]: Unknown error occurred: ${error.message}`); + break; + } + ; + this.wss.close(); + } + static async launch(hostname = '127.0.0.1', port) { + if (this.isLaunched()) { + console.warn(`Visualization tool is already running at http://${this.hostname}:${this.port}`); + return; + } + const app = express(); + const server = http.createServer(app); + this.wss = new WebSocketServer({ server: server }); + const filename = fileURLToPath(import.meta.url); + const dirname = path.dirname(filename); + app.use(express.static(path.join(dirname, 'public'))); + this.wss.on('connection', (ws) => { + console.log('[server]: Client connected'); + ws.on('message', (message) => { + console.log(`[server]: Received message => ${message}`); + }); + ws.addEventListener('close', () => { + console.log('[server]: Client disconnected'); + }); + }); // TODO: Remove this + this.wss.on('connection', ws => this.updateClient(ws)); + this.wss.on('close', () => { + this.serverClosed = true; + }); + this.wss.on('error', error => this.onWssError(error)); + return new Promise(res => { + server.listen(port ?? 0, hostname, () => { + const addressInfo = server.address(); + this.hostname = addressInfo.address; + this.port = addressInfo.port; + this.serverClosed = false; + console.log(`\nVisualization tool is running at http://${this.hostname}:${this.port}\n`); + // child.exec(`xdg-open http://${this.host}:${this.port}`); + // TODO: See if opening automatically is a good idea + res(); + }); + }); + } + static sendToClient(ws, data) { + ws.send(JSON.stringify(data)); + } + static sendToAllClients(data) { + this.wss.clients.forEach(ws => this.sendToClient(ws, data)); + } + static verifyToolIsRunning() { + if (!this.isLaunched()) { + throw Error('Visualization tool is not running'); + } + } + static async waitForTool() { + this.verifyToolIsRunning(); + return new Promise(res => { + let placeClientOnWait; + const waitOnMessage = (message) => { + const data = JSON.parse(message); + if (data.message === 'continue') { + this.wss.clients.forEach(ws => { + this.wss.off('connection', placeClientOnWait); + ws.off('message', waitOnMessage); + }); + this.sendToAllClients({ message: 'continue' }); + res(); + } + }; + placeClientOnWait = (ws) => { + ws.on('message', waitOnMessage); + this.sendToClient(ws, { message: 'wait' }); + }; + this.wss.clients.forEach(placeClientOnWait); + this.wss.on('connection', placeClientOnWait); + }); + } + static toToolJpJson(jp) { + return { + id: wrapJoinPoint(jp._javaObject.getAstId()), + type: wrapJoinPoint(jp._javaObject.getJoinPointType()), + code: wrapJoinPoint(jp._javaObject.getCode()), + children: jp.children + .slice() + .sort((a, b) => wrapJoinPoint(a._javaObject.getLocation()).localeCompare(wrapJoinPoint(b._javaObject.getLocation()), 'en', { numeric: true })) // TODO: Perform sorting on frontend + .map(child => this.toToolJpJson(child)) + }; + } + static updateClient(ws) { + wrapJoinPoint(JoinPoints.root()._javaObject.rebuild()); + this.sendToClient(ws, { message: 'update', ast: this.toToolJpJson(JoinPoints.root()) }); + } + static update() { + this.verifyToolIsRunning(); + this.wss.clients.forEach(ws => this.updateClient(ws)); + } +} +//# sourceMappingURL=VisualizationTool.js.map \ No newline at end of file diff --git a/LaraApi/src-lara/visualization/VisualizationToolUpdate.js b/LaraApi/src-lara/visualization/VisualizationToolUpdate.js new file mode 100644 index 000000000..18ef9be4f --- /dev/null +++ b/LaraApi/src-lara/visualization/VisualizationToolUpdate.js @@ -0,0 +1,9 @@ +import JoinPoints from "../weaver/JoinPoints.js"; +export default class VisualizationToolUpdate { + static updateVisualizationTool() { + const socket = new WebSocket("ws://127.0.0.1:3000"); + console.log(JSON.stringify(JoinPoints.root().dump)); + } +} +; +//# sourceMappingURL=VisualizationToolUpdate.js.map \ No newline at end of file diff --git a/LaraApi/src-lara/visualization/public/js/ToolJoinPoint.js b/LaraApi/src-lara/visualization/public/js/ToolJoinPoint.js new file mode 100644 index 000000000..6a61fd55c --- /dev/null +++ b/LaraApi/src-lara/visualization/public/js/ToolJoinPoint.js @@ -0,0 +1,79 @@ +export default class ToolJoinPoint { + #id; + #type; + #code; + #filepath; + #info; + #children; + constructor(id, type, code, filepath, info, children) { + this.#id = id; + this.#type = type; + this.#filepath = filepath; + this.#code = code; + this.#info = info; + this.#children = children; + } + /** + * @brief Returns the join point ID. + */ + get id() { + return this.#id; + } + /** + * @brief Returns the type of join point. + */ + get type() { + return this.#type; + } + /** + * @brief Returns the code of the join point. + */ + get code() { + return this.#code; + } + /** + * @brief Returns the filepath of the file this join point belongs to. + */ + get filepath() { + return this.#filepath; + } + /** + * @brief Returns extra information about the join point. + */ + get info() { + return this.#info; + } + /** + * @brief Returns the children of the join point. + */ + get children() { + return this.#children; + } + /** + * @brief Creates a new ToolJoinPoint object from a JSON object. + */ + static fromJson(json) { + return new ToolJoinPoint(json.id, json.type, json.code, json.filepath, json.info, json.children.map((child) => ToolJoinPoint.fromJson(child))); + } + /** + * @brief Converts the ToolJoinPoint object to a JSON object. + */ + toJson() { + return { + id: this.#id, + type: this.#type, + code: this.#code, + filepath: this.#filepath, + info: this.#info, + children: this.#children.map((child) => child.toJson()), + }; + } + /** + * @brief Clones the join point. + */ + clone() { + return new ToolJoinPoint(this.#id, this.#type, this.#code, this.#filepath, this.#info, this.#children.map((child) => child.clone())); + } +} +; +//# sourceMappingURL=ToolJoinPoint.js.map \ No newline at end of file diff --git a/LaraApi/src-lara/visualization/public/js/ast-import.js b/LaraApi/src-lara/visualization/public/js/ast-import.js new file mode 100644 index 000000000..c63620586 --- /dev/null +++ b/LaraApi/src-lara/visualization/public/js/ast-import.js @@ -0,0 +1,82 @@ +/** + * @file ast-import.ts + * @brief Functions for importing the AST and code to the visualization. + */ +import { countChar } from './utils.js'; +import { createCodeElement, createCodeLines, createCodeWrapper, createNodeDropdown, createNodeDropdownButton, createNodeElement, getActiveCodeElement, getAstContainer, getCodeContainer, getCodeLines, getMainCodeWrapper } from './components.js'; +/** + * @brief Updates the line numbering of the code container. + */ +const updateLines = () => { + const codeLines = getCodeLines(); + const codeWrapper = getMainCodeWrapper(); + if (!codeLines || !codeWrapper) + throw new Error('Code container not initialized'); + const codeElement = getActiveCodeElement(); + const code = codeElement?.textContent ?? ''; + const numLines = countChar(code, '\n') + 1; + const newCodeLines = createCodeLines(numLines); + codeLines.replaceWith(newCodeLines); +}; +/** + * @brief Initializes the code container, by adding the code lines and the code wrapper. + */ +const initCodeContainer = () => { + const codeContainer = getCodeContainer(); + codeContainer.innerHTML = ''; + const codeLines = createCodeLines(0); + const codeWrapper = createCodeWrapper(); + codeContainer.append(codeLines, codeWrapper); +}; +/** + * @brief Adds a new hidden code element, with the given code, to the code container. + * + * @param code Code of the element + * @param filepath Path of the file + */ +const addFileCode = (code, filepath) => { + const codeWrapper = getMainCodeWrapper(); + if (!codeWrapper) + throw new Error('Code container not initialized'); + const codeElement = createCodeElement(code); + codeElement.dataset.filepath = filepath; + codeWrapper.appendChild(codeElement); +}; +/** + * @brief Converts the AST to node HTML elements and their respective dropdowns. + * + * @param root Root of the AST + * @returns The resulting node HTML elements + */ +const toNodeElements = (root) => { + const fragment = new DocumentFragment(); + if (root.children.length > 0) { + const dropdown = createNodeDropdown(root.id); + for (const node of root.children) { + const descendantNodeElements = toNodeElements(node); + dropdown.appendChild(descendantNodeElements); + } + const dropdownButton = createNodeDropdownButton(dropdown); + const nodeElement = createNodeElement(root.id, root.type, dropdownButton); + fragment.append(nodeElement, dropdown); + } + else { + const dropdownButton = createNodeDropdownButton(); + const nodeElement = createNodeElement(root.id, root.type, dropdownButton); + fragment.appendChild(nodeElement); + } + return fragment; +}; +/** + * @brief Imports the AST to the AST container. + * + * @param astRoot Root of the AST + */ +const importAst = (astRoot) => { + const astContainer = getAstContainer(); + const astFragment = toNodeElements(astRoot); + astContainer.innerHTML = ''; + astContainer.appendChild(astFragment); +}; +export { importAst, initCodeContainer, addFileCode, updateLines }; +//# sourceMappingURL=ast-import.js.map \ No newline at end of file diff --git a/LaraApi/src-lara/visualization/public/js/communication.js b/LaraApi/src-lara/visualization/public/js/communication.js new file mode 100644 index 000000000..2e2ed58c8 --- /dev/null +++ b/LaraApi/src-lara/visualization/public/js/communication.js @@ -0,0 +1,67 @@ +/** + * @file communication.ts + * @brief Functions for communication with the server. + */ +import { importAst, initCodeContainer } from "./ast-import.js"; +import { getContinueButton } from "./components.js"; +import { addFile, clearFiles, selectFile } from "./files.js"; +import { addHighlighingEventListeners } from "./visualization.js"; +/** + * @brief WebSocket message handler for the 'update' message. + * @details When executed, this function updates the code container and the AST + * with the new data. + * + * @param data Message data + */ +const onUpdate = (data) => { + const buttonDisabled = getContinueButton().disabled; + getContinueButton().disabled = true; + initCodeContainer(); + clearFiles(); + for (const [filename, filecode] of Object.entries(data.code)) + addFile(filename, filecode); + importAst(data.ast); + addHighlighingEventListeners(data.ast); + selectFile(Object.keys(data.code)[0]); + getContinueButton().disabled = buttonDisabled; +}; +const webSocketOnMessage = (message) => { + const continueButton = getContinueButton(); + const data = parseMessage(message); + switch (data.message) { + case 'update': + onUpdate(data); + break; + case 'wait': + continueButton.disabled = false; + break; + case 'continue': + continueButton.disabled = true; + break; + } +}; +/** + * @brief Creates a WebSocket connection to the server, with the message event + * listener. + * + * @returns WebSocket object + */ +const getWebSocket = () => { + const url = '/'; + const ws = new WebSocket(url); + ws.addEventListener('message', webSocketOnMessage); + return ws; +}; +const sendData = (ws, data) => { + ws.send(JSON.stringify(data)); +}; +const parseMessage = (message) => { + return JSON.parse(message.data); +}; +const continueButtonOnClick = (ws) => { + const continueButton = getContinueButton(); + continueButton.disabled = true; + sendData(ws, { message: 'continue' }); +}; +export { getWebSocket, sendData, parseMessage, webSocketOnMessage, continueButtonOnClick, }; +//# sourceMappingURL=communication.js.map \ No newline at end of file diff --git a/LaraApi/src-lara/visualization/public/js/components.js b/LaraApi/src-lara/visualization/public/js/components.js new file mode 100644 index 000000000..16fb916da --- /dev/null +++ b/LaraApi/src-lara/visualization/public/js/components.js @@ -0,0 +1,206 @@ +const getAstContainer = (() => { + const astContainer = document.querySelector('#ast-container'); + if (!astContainer) { + throw new Error('Could not find AST container'); + } + return () => astContainer; +})(); +const getCodeContainer = (() => { + const codeContainer = document.querySelector('#code-container'); + if (!codeContainer) { + throw new Error('Could not find code container'); + } + return () => codeContainer; +})(); +const getNodeInfoContainer = (() => { + const nodeInfoContainer = document.querySelector('#node-info-container'); + if (!nodeInfoContainer) { + throw new Error('Could not find node info container'); + } + return () => nodeInfoContainer; +})(); +const getContinueButton = (() => { + const continueButton = document.querySelector('#continue-button'); + if (!continueButton) { + throw new Error('Could not find continue button'); + } + return () => continueButton; +})(); +const getResizer = (() => { + const resizer = document.querySelector('#resizer'); + if (!resizer) { + throw new Error('Could not find resizer'); + } + return () => resizer; +})(); +const getFileTabs = (() => { + const fileTabs = document.querySelector('#file-tabs'); + if (!fileTabs) { + throw new Error('Could not find file tabs'); + } + return () => fileTabs; +})(); +const getNodeElement = (nodeId) => { + return document.querySelector(`.ast-node[data-node-id="${nodeId}"]`); +}; +const getNodeText = (nodeId) => { + const nodeElement = getNodeElement(nodeId); + return nodeElement?.querySelector('.node-text') ?? null; +}; +const getFirstNodeCodeElement = (nodeId) => { + return document.querySelector(`.node-code[data-node-id="${nodeId}"]`); +}; +const getNodeCodeElements = (nodeId) => { + return Array.from(document.querySelectorAll(`.node-code[data-node-id="${nodeId}"]`)); +}; +const getHighlightableElements = (nodeId) => { + const nodeText = getNodeText(nodeId); + if (!nodeText) + return []; + const nodeCodeElements = getNodeCodeElements(nodeId); + return [nodeText, ...nodeCodeElements]; +}; +const getMainCodeWrapper = () => { + return getCodeContainer().querySelector('.code-wrapper'); +}; +const getCodeLines = () => { + return getCodeContainer().querySelector('.lines'); +}; +const getActiveCodeElement = () => { + return getMainCodeWrapper()?.querySelector('code.active') ?? null; +}; +const getFileCodeElement = (filename) => { + return getCodeContainer().querySelector(`code[data-filepath="${filename}"]`); +}; +const getFileTabsInternalDiv = () => { + return getFileTabs().querySelector('div'); +}; +const getFileTab = (filepath) => { + return getFileTabs().querySelector(`.file-tab[data-filepath="${filepath}"]`); +}; +const getActiveFileTab = () => { + return getFileTabs().querySelector('.file-tab.active'); +}; +const getFileTabsArrow = (direction) => { + return document.querySelector(`#file-tabs-arrow-${direction}`); +}; +const createIcon = (name) => { + const icon = document.createElement('span'); + icon.classList.add('icon', 'material-symbols-outlined'); + icon.textContent = name; + return icon; +}; +const createNodeDropdown = (nodeId) => { + const dropdown = document.createElement('div'); + dropdown.classList.add('ast-node-dropdown'); + dropdown.dataset.nodeId = nodeId; + return dropdown; +}; +const createDropdownButtonOnClick = (dropdown) => { + let nodeCollapsed = false; + return (event) => { + nodeCollapsed = !nodeCollapsed; + dropdown.style.display = nodeCollapsed ? 'none' : 'block'; + const dropdownButton = event.currentTarget; + const chevronIcon = dropdownButton.children[0]; + chevronIcon.textContent = nodeCollapsed ? 'keyboard_arrow_right' : 'keyboard_arrow_down'; + event.stopPropagation(); + }; +}; +const createNodeDropdownButton = (dropdown) => { + const dropdownButton = document.createElement('button'); + const arrowIcon = createIcon('keyboard_arrow_down'); + dropdownButton.appendChild(arrowIcon); + if (dropdown) { + dropdownButton.addEventListener('click', createDropdownButtonOnClick(dropdown)); + } + else { + dropdownButton.disabled = true; + } + return dropdownButton; +}; +const createNodeElement = (nodeId, text, dropdownButton) => { + const nodeElement = document.createElement('span'); // TODO: Convert to div + nodeElement.classList.add('ast-node'); + nodeElement.dataset.nodeId = nodeId; + const nodeText = document.createElement('span'); + nodeText.classList.add('node-text'); + nodeText.textContent = text; + nodeElement.appendChild(dropdownButton); + nodeElement.appendChild(nodeText); + return nodeElement; +}; +const createCodeElement = (code = '') => { + const codeElement = document.createElement('code'); + codeElement.innerHTML = code; + return codeElement; +}; +const createCodeLines = (numLines) => { + const codeLines = document.createElement('pre'); + codeLines.classList.add('lines'); + codeLines.textContent = Array.from({ length: numLines }, (_, i) => i + 1).join('\n'); + return codeLines; +}; +const createCodeWrapper = () => { + const codeWrapper = document.createElement('pre'); + codeWrapper.classList.add('code-wrapper'); + return codeWrapper; +}; +const createNodeInfoLine = (name, value) => { + const attributeName = document.createElement('span'); + attributeName.textContent = name + ': '; + const attributeValue = document.createElement('span'); + attributeValue.textContent = value; + const line = document.createElement('p'); + line.append(attributeName, attributeValue); + return line; +}; +const createNodeInfoAlert = (alert) => { + const codeAlert = document.createElement('p'); + codeAlert.classList.add('alert'); + codeAlert.textContent = alert; + return codeAlert; +}; +const createFileTab = (filepath) => { + const fileTab = document.createElement('button'); + fileTab.classList.add('file-tab'); + fileTab.dataset.filepath = filepath; + fileTab.title = filepath; + fileTab.textContent = filepath !== '' ? filepath.slice(filepath.lastIndexOf('/') + 1) : ''; + return fileTab; +}; +const fileTabsArrowOnClick = (event, direction) => { + const fileTabsInternalDiv = getFileTabsInternalDiv(); + const activeTabIndex = Array.from(fileTabsInternalDiv.children).findIndex(tab => tab.classList.contains('active')); + if (direction === 'left' && activeTabIndex > 0) { + fileTabsInternalDiv.children[activeTabIndex - 1].click(); + } + else if (direction === 'right' && activeTabIndex < fileTabsInternalDiv.children.length - 1) { + fileTabsInternalDiv.children[activeTabIndex + 1].click(); + } + event.stopPropagation(); +}; +const createFileTabsArrow = (direction) => { + const arrow = document.createElement('button'); + arrow.classList.add('file-tabs-arrow'); + arrow.id = `file-tabs-arrow-${direction}`; + arrow.appendChild(createIcon(`keyboard_arrow_${direction}`)); + arrow.addEventListener('click', event => fileTabsArrowOnClick(event, direction)); + return arrow; +}; +/** + * @brief Updates the file tabs arrows, enabling or disabling them based on the + * number of tabs and selected tab. + */ +const updateFileTabsArrows = () => { + const fileTabs = getFileTabs(); + const fileTabsInternalDiv = getFileTabsInternalDiv(); + const activeFileTab = getActiveFileTab(); + const fileTabsLeftArrow = getFileTabsArrow('left'); + const fileTabsRightArrow = getFileTabsArrow('right'); + const fileTabsOverflow = fileTabs.scrollWidth < fileTabsInternalDiv.scrollWidth; + fileTabsLeftArrow.disabled = !fileTabsOverflow || fileTabsInternalDiv.children[0] === activeFileTab; + fileTabsRightArrow.disabled = !fileTabsOverflow || fileTabsInternalDiv.children[fileTabsInternalDiv.children.length - 1] === activeFileTab; +}; +export { getAstContainer, getCodeContainer, getNodeInfoContainer, getContinueButton, getResizer, getFileTabs, getNodeElement, getNodeText, getFirstNodeCodeElement, getNodeCodeElements, getHighlightableElements, getMainCodeWrapper, getCodeLines, getActiveCodeElement, getFileCodeElement, getFileTab, getFileTabsInternalDiv, getActiveFileTab, getFileTabsArrow, createIcon, createNodeDropdown, createNodeDropdownButton, createNodeElement, createNodeInfoLine, createNodeInfoAlert, createCodeLines, createCodeElement, createCodeWrapper, createFileTab, createFileTabsArrow, updateFileTabsArrows, }; +//# sourceMappingURL=components.js.map \ No newline at end of file diff --git a/LaraApi/src-lara/visualization/public/js/files.js b/LaraApi/src-lara/visualization/public/js/files.js new file mode 100644 index 000000000..90610de92 --- /dev/null +++ b/LaraApi/src-lara/visualization/public/js/files.js @@ -0,0 +1,65 @@ +/** + * @file files.ts + * @brief Functions for handling files in the visualization. + */ +import { addFileCode, updateLines } from "./ast-import.js"; +import { createFileTab, createFileTabsArrow, getActiveCodeElement, getActiveFileTab, getFileCodeElement, getFileTab, getFileTabsInternalDiv, getFileTabs, getMainCodeWrapper, updateFileTabsArrows } from "./components.js"; +let selectedFilepath = null; +/** + * @brief Adds a new file, with the respective file tab and (hidden) code, to the visualization. + * + * @param path Path of the file + * @param code File code + */ +const addFile = (path, code) => { + addFileCode(code, path); + const fileTab = createFileTab(path); + fileTab.addEventListener('click', () => selectFile(path)); + const fileTabs = getFileTabs(); + const fileTabsInternalDiv = getFileTabsInternalDiv(); + if (fileTabsInternalDiv.children.length === 0) { + const fileTabsLeftArrow = createFileTabsArrow('left'); + const fileTabsRightArrow = createFileTabsArrow('right'); + fileTabs.append(fileTabsLeftArrow, fileTabsRightArrow); + } + fileTabsInternalDiv.appendChild(fileTab); +}; +/** + * @brief Clears all files from the code container. + */ +const clearFiles = () => { + const codeWrapper = getMainCodeWrapper(); + if (!codeWrapper) + throw new Error('Code container not initialized'); + const fileTabs = getFileTabs(); + fileTabs.innerHTML = ''; + fileTabs.appendChild(document.createElement('div')); + codeWrapper.innerHTML = ''; + selectedFilepath = null; +}; +/** + * @brief Selects a file, by making its code visible in the code container. + * @param filepath + */ +const selectFile = (filepath) => { + const fileTab = getFileTab(filepath); + if (!fileTab) + throw new Error(`File "${filepath}" not found`); + if (filepath !== selectedFilepath) { + const activeFileTab = getActiveFileTab(); + if (activeFileTab) + activeFileTab.classList.remove('active'); + fileTab.classList.add('active'); + const activeCode = getActiveCodeElement(); + if (activeCode) + activeCode.classList.remove('active'); + const fileCodeElement = getFileCodeElement(filepath); + fileCodeElement.classList.add('active'); + updateLines(); + fileTab.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + updateFileTabsArrows(); + selectedFilepath = filepath; + } +}; +export { addFile, clearFiles, selectFile }; +//# sourceMappingURL=files.js.map \ No newline at end of file diff --git a/LaraApi/src-lara/visualization/public/js/main.js b/LaraApi/src-lara/visualization/public/js/main.js new file mode 100644 index 000000000..5c4aa5909 --- /dev/null +++ b/LaraApi/src-lara/visualization/public/js/main.js @@ -0,0 +1,18 @@ +import { continueButtonOnClick, getWebSocket } from "./communication.js"; +import { getContinueButton } from "./components.js"; +import { addResizerEventListeners } from "./visualization.js"; +const setupEventListeners = (ws) => { + const continueButton = getContinueButton(); + continueButton.addEventListener('click', () => continueButtonOnClick(ws)); + addResizerEventListeners(); +}; +(() => { + let ws; + const setupWebSocket = () => { + ws = getWebSocket(); + ws.addEventListener('close', () => setTimeout(setupWebSocket, 1000)); + }; + setupWebSocket(); + setupEventListeners(ws); +})(); +//# sourceMappingURL=main.js.map \ No newline at end of file diff --git a/LaraApi/src-lara/visualization/public/js/utils.js b/LaraApi/src-lara/visualization/public/js/utils.js new file mode 100644 index 000000000..921ecd51d --- /dev/null +++ b/LaraApi/src-lara/visualization/public/js/utils.js @@ -0,0 +1,21 @@ +/** + * @file utils.ts + * @brief Utility functions for the visualization tool. + */ +/** + * @brief Counts the number of occurrences of a character in a string. + * + * @param str String to search + * @param char Target character + * @returns Number of occurrences of char in str + */ +const countChar = (str, char) => { + let count = 0; + for (const c of str) { + if (c === char) + count++; + } + return count; +}; +export { countChar }; +//# sourceMappingURL=utils.js.map \ No newline at end of file diff --git a/LaraApi/src-lara/visualization/public/js/visualization.js b/LaraApi/src-lara/visualization/public/js/visualization.js new file mode 100644 index 000000000..90c9ce295 --- /dev/null +++ b/LaraApi/src-lara/visualization/public/js/visualization.js @@ -0,0 +1,194 @@ +/** + * @file visualization.ts + * @brief Functions for handling the visualization behavior and events. + */ +import { createCodeElement, createCodeWrapper, createNodeInfoAlert, createNodeInfoLine, getAstContainer, getCodeContainer, getContinueButton, getFileTabsInternalDiv, getFirstNodeCodeElement, getHighlightableElements, getNodeElement, getNodeInfoContainer, getNodeText, getResizer, updateFileTabsArrows } from "./components.js"; +import { selectFile } from "./files.js"; +/** + * @brief Highlights the node with the given id. + * + * @param nodeId Node id + * @param strong If the highlight should use a strong color + */ +const highlightNode = (nodeId, strong) => { + const nodeElement = getNodeElement(nodeId); + if (!nodeElement) { + console.warn(`There is no node with id ${nodeId}`); + return; + } + const highlightableElements = getHighlightableElements(nodeId); + highlightableElements.forEach(elem => elem.style.backgroundColor = strong ? 'var(--highlight-color)' : 'var(--secondary-highlight-color)'); + let parentNode = nodeElement.parentElement?.previousSibling; + while (parentNode instanceof HTMLElement && parentNode.classList.contains('ast-node')) { + const parentNodeId = parentNode.dataset.nodeId; + const parentNodeText = getNodeText(parentNodeId); + parentNodeText.style.backgroundColor = strong ? 'var(--secondary-highlight-color)' : 'var(--tertiary-highlight-color)'; + parentNode = parentNode.parentElement?.previousSibling; + } +}; +/** + * @brief Unhighlights the node with the given id. + * + * @param nodeId Node id + */ +const unhighlightNode = (nodeId) => { + const nodeElement = getNodeElement(nodeId); + if (!nodeElement) { + console.warn(`There is no node with id ${nodeId}`); + return; + } + const highlightableElements = getHighlightableElements(nodeId); + highlightableElements.forEach(elem => elem.style.backgroundColor = ''); + let parentNode = nodeElement.parentElement?.previousSibling; + while (parentNode instanceof HTMLElement && parentNode.classList.contains('ast-node')) { + const parentNodeId = parentNode.dataset.nodeId; + const parentNodeText = getNodeText(parentNodeId); + parentNodeText.style.backgroundColor = ''; + parentNode = parentNode.parentElement?.previousSibling; + } +}; +/** + * @brief Shows the node info container with the given node information. + * + * @param node The target node + */ +const showNodeInfo = (node) => { + const nodeInfoContainer = getNodeInfoContainer(); + nodeInfoContainer.style.display = 'block'; + nodeInfoContainer.innerHTML = ''; + for (const [name, value] of Object.entries(node.info)) { + const line = createNodeInfoLine(name, value); + nodeInfoContainer.appendChild(line); + } + const hasCode = getFirstNodeCodeElement(node.id) !== null; + if (!hasCode) { + if (node.code) { + const alert = createNodeInfoAlert('Node code not found:'); + const codeElement = createCodeElement(node.code); + const codeWrapper = createCodeWrapper(); + codeWrapper.appendChild(codeElement); + nodeInfoContainer.append(alert, codeWrapper); + } + else { + const alert = createNodeInfoAlert('Node does not have code!'); + nodeInfoContainer.appendChild(alert); + } + } +}; +/** + * @brief Hides the node information container. + */ +const hideNodeInfo = () => { + const nodeInfoContainer = getNodeInfoContainer(); + nodeInfoContainer.style.display = 'none'; + nodeInfoContainer.innerHTML = ''; +}; +const scrollIntoViewIfNeeded = (element, parent) => { + const rect = element.getBoundingClientRect(); + const parentRect = parent.getBoundingClientRect(); + if (rect.bottom < parentRect.top || rect.top > parentRect.bottom) { + const scrollPos = rect.height <= parentRect.height + ? (rect.top + rect.bottom - parentRect.top - parentRect.bottom) / 2 + : rect.top - parentRect.top; + parent.scrollBy({ top: scrollPos, left: 0, behavior: 'smooth' }); + } +}; +let selectedNodeId = null; +const highlightableOnMouseOver = (node, event) => { + highlightNode(node.id, false); + if (selectedNodeId !== null) + highlightNode(selectedNodeId, true); + event.stopPropagation(); +}; +const highlightableOnMouseOut = (node, event) => { + unhighlightNode(node.id); + if (selectedNodeId !== null) + highlightNode(selectedNodeId, true); + event.stopPropagation(); +}; +const highlightableOnClick = (node, event) => { + event.stopPropagation(); + if (selectedNodeId !== null) { + unhighlightNode(selectedNodeId); + if (selectedNodeId === node.id) { + selectedNodeId = null; + hideNodeInfo(); + return; + } + } + selectedNodeId = node.id; + highlightNode(node.id, true); + if (node.filepath) + selectFile(node.filepath); + const nodeElement = getNodeElement(node.id); + const astContainer = getAstContainer(); + scrollIntoViewIfNeeded(nodeElement, astContainer); + const firstNodeCodeBlock = getFirstNodeCodeElement(node.id); + if (firstNodeCodeBlock) { + const codeContainer = getCodeContainer(); + scrollIntoViewIfNeeded(firstNodeCodeBlock, codeContainer); + } + showNodeInfo(node); +}; +/** + * @brief Adds event listeners to all the highlightable elements relative to + * the nodes in the given AST. + * + * @param root Root of the AST + */ +const addHighlighingEventListeners = (root) => { + const addListeners = (node) => { + const highlightableElements = getHighlightableElements(node.id); + for (const element of highlightableElements) { + element.addEventListener('mouseover', event => highlightableOnMouseOver(node, event)); + element.addEventListener('mouseout', event => highlightableOnMouseOut(node, event)); + element.tabIndex = 0; + element.addEventListener('click', event => highlightableOnClick(node, event)); + // For keyboard accessibility + element.addEventListener('keydown', event => { + if (event.key === 'Enter') { + element.click(); + } + event.stopPropagation(); + }); + } + node.children.forEach(child => addListeners(child)); + }; + selectedNodeId = null; // To prevent invalid references + addListeners(root); +}; +/** + * @brief Adds event listeners to the resizer element. + */ +const addResizerEventListeners = () => { + const resizer = getResizer(); + const astContainer = getAstContainer(); + const codeContainer = getCodeContainer(); + const continueButton = getContinueButton(); + let drag = false; + let width = astContainer.offsetWidth; + const rootStyle = document.documentElement.style; + resizer.addEventListener('mousedown', () => { + drag = true; + }); + document.addEventListener('mouseup', () => { + drag = false; + }); + document.addEventListener('mousemove', event => { + if (drag) { + const astLeft = astContainer.getBoundingClientRect().left; + const minWidth = continueButton.offsetWidth; + const maxWidth = codeContainer.getBoundingClientRect().right - astLeft - 160; + width = event.x - astLeft; + if (width < minWidth) + width = minWidth; + else if (width > maxWidth) + width = maxWidth; + rootStyle.setProperty('--ast-container-width', `${width}px`); + if (getFileTabsInternalDiv()) + updateFileTabsArrows(); + } + }); +}; +export { addHighlighingEventListeners, addResizerEventListeners }; +//# sourceMappingURL=visualization.js.map \ No newline at end of file diff --git a/LaraApi/src-lara/visualization/tool-launcher.js b/LaraApi/src-lara/visualization/tool-launcher.js new file mode 100644 index 000000000..eab1f7705 --- /dev/null +++ b/LaraApi/src-lara/visualization/tool-launcher.js @@ -0,0 +1,27 @@ +import express from 'express'; +import http from 'http'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { WebSocketServer } from 'ws'; +const launchVisualizationTool = (domain, port) => { + const app = express(); + const server = http.createServer(app); + const wss = new WebSocketServer({ server: server }); + const filename = fileURLToPath(import.meta.url); + const dirname = path.dirname(filename); + app.use(express.static(path.join(dirname, 'public'))); + server.listen(port, () => { + console.log(`[server]: Server is running at http://${domain}:${port}`); + }); + wss.on('connection', (ws) => { + console.log('[server]: Client connected'); + ws.on('message', (message) => { + console.log(`[server]: Received message => ${message}`); + }); + ws.on('close', () => { + console.log('[server]: Client disconnected'); + }); + }); +}; +launchVisualizationTool('localhost', 3000); +//# sourceMappingURL=tool-launcher.js.map \ No newline at end of file