From 92d1cbc7566004aec9845e7936bad17db6279a9e Mon Sep 17 00:00:00 2001 From: Sanan507 <227714367+Sanan507@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:45:54 +0000 Subject: [PATCH] feat(backend/frontend): implement SSE real-time streaming for algorithm simulations - Add `StreamingService` to manage Server-Sent Events (SSE) emitting for Sorting, Searching, and Pathfinding - Use a gracefully shutdown cached thread pool in `StreamingService` to compute frames asynchronously - Create `StreamController` to expose GET endpoints returning `SseEmitter` - Add `sseClient.ts` to manage EventSource connections in the frontend - Update `SortingPage`, `SearchingPage`, and `PathfindingPage` to receive real-time streams instead of waiting for the full simulation payload --- .../controller/StreamController.java | 43 ++ .../visualizer/dto/LaneFrameEvent.java | 3 + .../visualizer/service/StreamingService.java | 470 ++++++++++++++++++ frontend/src/pages/PathfindingPage.tsx | 86 +++- frontend/src/pages/SearchingPage.tsx | 69 ++- frontend/src/pages/SortingPage.tsx | 70 ++- frontend/src/services/sseClient.ts | 64 +++ 7 files changed, 748 insertions(+), 57 deletions(-) create mode 100644 backend/src/main/java/com/algorithmrace/visualizer/controller/StreamController.java create mode 100644 backend/src/main/java/com/algorithmrace/visualizer/dto/LaneFrameEvent.java create mode 100644 backend/src/main/java/com/algorithmrace/visualizer/service/StreamingService.java create mode 100644 frontend/src/services/sseClient.ts diff --git a/backend/src/main/java/com/algorithmrace/visualizer/controller/StreamController.java b/backend/src/main/java/com/algorithmrace/visualizer/controller/StreamController.java new file mode 100644 index 0000000..46fdaa4 --- /dev/null +++ b/backend/src/main/java/com/algorithmrace/visualizer/controller/StreamController.java @@ -0,0 +1,43 @@ +package com.algorithmrace.visualizer.controller; + +import com.algorithmrace.visualizer.dto.PathfindingSimulationRequest; +import com.algorithmrace.visualizer.dto.SearchingSimulationRequest; +import com.algorithmrace.visualizer.dto.SortingSimulationRequest; +import com.algorithmrace.visualizer.service.StreamingService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@RestController +@RequestMapping("/api/simulations/stream") +@Tag(name = "Stream", description = "Endpoints for streaming algorithm simulations via SSE") +public class StreamController { + + private final StreamingService streamingService; + + public StreamController(StreamingService streamingService) { + this.streamingService = streamingService; + } + + @Operation(summary = "Stream sorting simulation") + @GetMapping("/sorting") + public SseEmitter streamSorting(@ModelAttribute SortingSimulationRequest request) { + return streamingService.streamSorting(request); + } + + @Operation(summary = "Stream searching simulation") + @GetMapping("/searching") + public SseEmitter streamSearching(@ModelAttribute SearchingSimulationRequest request) { + return streamingService.streamSearching(request); + } + + @Operation(summary = "Stream pathfinding simulation") + @GetMapping("/pathfinding") + public SseEmitter streamPathfinding(@ModelAttribute PathfindingSimulationRequest request) { + return streamingService.streamPathfinding(request); + } +} diff --git a/backend/src/main/java/com/algorithmrace/visualizer/dto/LaneFrameEvent.java b/backend/src/main/java/com/algorithmrace/visualizer/dto/LaneFrameEvent.java new file mode 100644 index 0000000..3111ede --- /dev/null +++ b/backend/src/main/java/com/algorithmrace/visualizer/dto/LaneFrameEvent.java @@ -0,0 +1,3 @@ +package com.algorithmrace.visualizer.dto; + +public record LaneFrameEvent(String laneName, SimulationFrame frame) {} diff --git a/backend/src/main/java/com/algorithmrace/visualizer/service/StreamingService.java b/backend/src/main/java/com/algorithmrace/visualizer/service/StreamingService.java new file mode 100644 index 0000000..32a7017 --- /dev/null +++ b/backend/src/main/java/com/algorithmrace/visualizer/service/StreamingService.java @@ -0,0 +1,470 @@ +package com.algorithmrace.visualizer.service; + +import com.algorithmrace.visualizer.algorithms.pathfinding.CellState; +import com.algorithmrace.visualizer.algorithms.pathfinding.GridCell; +import com.algorithmrace.visualizer.algorithms.pathfinding.PathfindingFactory; +import com.algorithmrace.visualizer.algorithms.pathfinding.PathfindingModel; +import com.algorithmrace.visualizer.algorithms.searching.SearchModel; +import com.algorithmrace.visualizer.algorithms.searching.SearchingAlgorithmFactory; +import com.algorithmrace.visualizer.algorithms.sorting.SortingAlgorithmFactory; +import com.algorithmrace.visualizer.dto.LaneFrameEvent; +import com.algorithmrace.visualizer.dto.LaneStats; +import com.algorithmrace.visualizer.dto.PathfindingSimulationRequest; +import com.algorithmrace.visualizer.dto.PointDto; +import com.algorithmrace.visualizer.dto.RaceLaneResponse; +import com.algorithmrace.visualizer.dto.RaceResponse; +import com.algorithmrace.visualizer.dto.SearchingSimulationRequest; +import com.algorithmrace.visualizer.dto.SimulationFrame; +import com.algorithmrace.visualizer.dto.SortingSimulationRequest; +import com.algorithmrace.visualizer.model.AlgorithmModel; +import com.algorithmrace.visualizer.utils.ArrayGenerator; +import com.algorithmrace.visualizer.utils.ComplexityCatalog; +import com.algorithmrace.visualizer.utils.MazeGenerator; +import jakarta.annotation.PreDestroy; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Service +public class StreamingService { + private static final int MAX_FRAMES = 5000; + private static final int MAX_ARRAY_SIZE = 100; + private static final int MAX_LANES = 6; + private static final int MAX_GRID_ROWS = 40; + private static final int MAX_GRID_COLS = 60; + + private static final int SORT_FRAME_MS = 16; + private static final int SEARCH_FRAME_MS = 18; + private static final int PATH_FRAME_MS = 20; + + private final ExecutorService executor = Executors.newCachedThreadPool(); + + @PreDestroy + public void shutdown() { + executor.shutdown(); + } + + public SseEmitter streamSorting(SortingSimulationRequest request) { + SseEmitter emitter = new SseEmitter(0L); // Infinite timeout + executor.execute( + () -> { + try { + int[] dataset = resolveSortingDataset(request); + List algos = sanitizeAlgorithms(request.algorithms()); + RaceResponse initialResponse = + new RaceResponse("sorting", dataset, null, null, null, new ArrayList<>(), null); + emitter.send(SseEmitter.event().name("START").data(initialResponse)); + + List lanes = new ArrayList<>(); + for (String algo : algos) { + lanes.add(streamSortingLane(algo, dataset.clone(), emitter)); + } + + RaceResponse finalResponse = + new RaceResponse( + "sorting", dataset, null, null, null, new ArrayList<>(), winner(lanes)); + emitter.send(SseEmitter.event().name("END").data(finalResponse)); + emitter.complete(); + } catch (Exception e) { + emitter.completeWithError(e); + } + }); + return emitter; + } + + public SseEmitter streamSearching(SearchingSimulationRequest request) { + SseEmitter emitter = new SseEmitter(0L); + executor.execute( + () -> { + try { + int[] dataset = resolveSearchingDataset(request); + if (dataset.length == 0) { + throw new IllegalArgumentException("Dataset must contain at least one element."); + } + int target = + request.target() != null + ? request.target() + : dataset[ThreadLocalRandom.current().nextInt(dataset.length)]; + + List algos = sanitizeAlgorithms(request.algorithms()); + RaceResponse initialResponse = + new RaceResponse("searching", dataset, target, null, null, new ArrayList<>(), null); + emitter.send(SseEmitter.event().name("START").data(initialResponse)); + + List lanes = new ArrayList<>(); + for (String algo : algos) { + lanes.add(streamSearchingLane(algo, dataset.clone(), target, emitter)); + } + + RaceResponse finalResponse = + new RaceResponse( + "searching", dataset, target, null, null, new ArrayList<>(), winner(lanes)); + emitter.send(SseEmitter.event().name("END").data(finalResponse)); + emitter.complete(); + } catch (Exception e) { + emitter.completeWithError(e); + } + }); + return emitter; + } + + public SseEmitter streamPathfinding(PathfindingSimulationRequest request) { + SseEmitter emitter = new SseEmitter(0L); + executor.execute( + () -> { + try { + int rows = + Math.min(MAX_GRID_ROWS, Math.max(5, request.rows() <= 0 ? 18 : request.rows())); + int cols = + Math.min(MAX_GRID_COLS, Math.max(5, request.cols() <= 0 ? 28 : request.cols())); + + int startRow = + request.startRow() != null + ? Math.min(rows - 1, Math.max(0, request.startRow())) + : 2; + int startCol = + request.startCol() != null + ? Math.min(cols - 1, Math.max(0, request.startCol())) + : 2; + int endRow = + request.endRow() != null + ? Math.min(rows - 1, Math.max(0, request.endRow())) + : rows - 3; + int endCol = + request.endCol() != null + ? Math.min(cols - 1, Math.max(0, request.endCol())) + : cols - 3; + + boolean[][] walls; + if (request.walls() != null) { + walls = sanitizeWalls(request.walls(), rows, cols); + } else { + walls = + MazeGenerator.generate( + rows, + cols, + startRow, + startCol, + endRow, + endCol, + MazeGenerator.fromName(request.mazeType())); + } + + int[][] weights; + if (request.weights() != null) { + weights = sanitizeWeights(request.weights(), rows, cols); + } else { + weights = + MazeGenerator.generateWeights( + rows, cols, MazeGenerator.fromName(request.mazeType())); + } + + List algos = sanitizeAlgorithms(request.algorithms()); + RaceResponse initialResponse = + new RaceResponse( + "pathfinding", null, null, walls, weights, new ArrayList<>(), null); + emitter.send(SseEmitter.event().name("START").data(initialResponse)); + + List lanes = new ArrayList<>(); + for (String algo : algos) { + lanes.add( + streamPathLane( + algo, rows, cols, startRow, startCol, endRow, endCol, walls, weights, + emitter)); + } + + RaceResponse finalResponse = + new RaceResponse( + "pathfinding", null, null, walls, weights, new ArrayList<>(), winner(lanes)); + emitter.send(SseEmitter.event().name("END").data(finalResponse)); + emitter.complete(); + } catch (Exception e) { + emitter.completeWithError(e); + } + }); + return emitter; + } + + private RaceLaneResponse streamSortingLane(String name, int[] dataset, SseEmitter emitter) + throws IOException { + AlgorithmModel model = SortingAlgorithmFactory.create(name); + model.resetState(dataset); + + List frames = new ArrayList<>(); + SimulationFrame initialFrame = sortFrame(0, model); + frames.add(initialFrame); + emitter.send(SseEmitter.event().name("FRAME").data(new LaneFrameEvent(name, initialFrame))); + + int frame = 1; + while (!model.isDone() && frame < MAX_FRAMES) { + model.step(); + model.setTimeMs((long) frame * SORT_FRAME_MS); + SimulationFrame sFrame = sortFrame(frame, model); + frames.add(sFrame); + emitter.send(SseEmitter.event().name("FRAME").data(new LaneFrameEvent(name, sFrame))); + frame++; + } + + LaneStats stats = + new LaneStats(model.getComparisons(), model.getSwaps(), 0, model.getTimeMs(), false, null); + return new RaceLaneResponse( + name, model.getComplexity(), ComplexityCatalog.get(name), frames, stats); + } + + private RaceLaneResponse streamSearchingLane( + String name, int[] dataset, int target, SseEmitter emitter) throws IOException { + SearchModel model = SearchingAlgorithmFactory.create(name); + model.resetState(dataset); + model.setTarget(target); + + List frames = new ArrayList<>(); + SimulationFrame initialFrame = searchFrame(0, model); + frames.add(initialFrame); + emitter.send(SseEmitter.event().name("FRAME").data(new LaneFrameEvent(name, initialFrame))); + + int frame = 1; + while (!model.isDone() && frame < MAX_FRAMES) { + model.step(); + model.setTimeMs((long) frame * SEARCH_FRAME_MS); + SimulationFrame sFrame = searchFrame(frame, model); + frames.add(sFrame); + emitter.send(SseEmitter.event().name("FRAME").data(new LaneFrameEvent(name, sFrame))); + frame++; + } + + LaneStats stats = + new LaneStats( + model.getComparisons(), + 0, + 0, + model.getTimeMs(), + model.getFoundIndex() >= 0, + model.getFoundIndex()); + return new RaceLaneResponse( + name, model.getComplexity(), ComplexityCatalog.get(name), frames, stats); + } + + private RaceLaneResponse streamPathLane( + String name, + int rows, + int cols, + int startRow, + int startCol, + int endRow, + int endCol, + boolean[][] walls, + int[][] weights, + SseEmitter emitter) + throws IOException { + + PathfindingModel model = PathfindingFactory.create(name); + model.initGrid(rows, cols); + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + if (r < walls.length && c < walls[r].length && walls[r][c]) { + model.getGrid()[r][c].state = CellState.WALL; + } + if (weights != null && r < weights.length && c < weights[r].length) { + model.getGrid()[r][c].weight = Math.max(1, weights[r][c]); + } + } + } + model.start = model.getGrid()[startRow][startCol]; + model.end = model.getGrid()[endRow][endCol]; + model.start.state = CellState.START; + model.end.state = CellState.END; + model.reset(); + + List frames = new ArrayList<>(); + SimulationFrame initialFrame = pathFrame(0, model, 0); + frames.add(initialFrame); + emitter.send(SseEmitter.event().name("FRAME").data(new LaneFrameEvent(name, initialFrame))); + + int frame = 1; + while (!model.isDone() && frame < MAX_FRAMES) { + model.step(); + if (model.isPathFound()) { + markPath(model); + } + SimulationFrame sFrame = pathFrame(frame, model, (long) frame * PATH_FRAME_MS); + frames.add(sFrame); + emitter.send(SseEmitter.event().name("FRAME").data(new LaneFrameEvent(name, sFrame))); + frame++; + } + + LaneStats stats = + new LaneStats( + 0, + 0, + model.getSteps(), + (long) Math.max(0, frame - 1) * PATH_FRAME_MS, + model.isPathFound(), + null); + return new RaceLaneResponse( + name, ComplexityCatalog.get(name).average(), ComplexityCatalog.get(name), frames, stats); + } + + // --- Helper Methods (Copied/Adapted from SimulationService) --- + + private int[][] sanitizeWeights(int[][] clientWeights, int rows, int cols) { + int[][] safe = new int[rows][cols]; + for (int r = 0; r < rows; r++) { + for (int c = 0; c < cols; c++) { + safe[r][c] = 1; + } + } + if (clientWeights == null) return safe; + int srcRows = Math.min(clientWeights.length, rows); + for (int r = 0; r < srcRows; r++) { + if (clientWeights[r] != null) { + int srcCols = Math.min(clientWeights[r].length, cols); + for (int c = 0; c < srcCols; c++) { + safe[r][c] = Math.max(1, clientWeights[r][c]); + } + } + } + return safe; + } + + private boolean[][] sanitizeWalls(boolean[][] clientWalls, int rows, int cols) { + boolean[][] safe = new boolean[rows][cols]; + if (clientWalls == null) return safe; + int srcRows = Math.min(clientWalls.length, rows); + for (int r = 0; r < srcRows; r++) { + if (clientWalls[r] != null) { + int srcCols = Math.min(clientWalls[r].length, cols); + System.arraycopy(clientWalls[r], 0, safe[r], 0, srcCols); + } + } + return safe; + } + + private List sanitizeAlgorithms(List inputAlgos) { + if (inputAlgos == null || inputAlgos.isEmpty()) { + return List.of(); + } + return inputAlgos.stream() + .filter(name -> name != null && !name.isBlank()) + .limit(MAX_LANES) + .toList(); + } + + private int[] resolveSortingDataset(SortingSimulationRequest request) { + if (request.customArray() != null && !request.customArray().isEmpty()) { + return request.customArray().stream() + .limit(MAX_ARRAY_SIZE) + .mapToInt(Integer::intValue) + .toArray(); + } + int size = Math.min(MAX_ARRAY_SIZE, Math.max(2, request.size() <= 0 ? 30 : request.size())); + return ArrayGenerator.generate(size, ArrayGenerator.fromLabel(request.datasetType())); + } + + private int[] resolveSearchingDataset(SearchingSimulationRequest request) { + if (request.dataset() != null && !request.dataset().isEmpty()) { + return request.dataset().stream().limit(MAX_ARRAY_SIZE).mapToInt(Integer::intValue).toArray(); + } + int size = Math.min(MAX_ARRAY_SIZE, Math.max(2, request.size() <= 0 ? 42 : request.size())); + return ArrayGenerator.generate(size, ArrayGenerator.ArrayType.RANDOM); + } + + private SimulationFrame sortFrame(int frame, AlgorithmModel model) { + return new SimulationFrame( + frame, + model.getArray().clone(), + model.getHighlight().clone(), + model.getSortedBoundary(), + model.getPivotIndex(), + model.getMergeRegionStart(), + model.getMergeRegionEnd(), + model.getHeapBoundary(), + model.getComparisons(), + model.getSwaps(), + model.getTimeMs(), + model.isDone(), + model.getStatus(), + null, + new int[0], + null, + List.of(), + 0, + false); + } + + private SimulationFrame searchFrame(int frame, SearchModel model) { + return new SimulationFrame( + frame, + model.getArray().clone(), + model.getHighlight().clone(), + model.getSortedBoundary(), + model.getPivotIndex(), + model.getMergeRegionStart(), + model.getMergeRegionEnd(), + model.getHeapBoundary(), + model.getComparisons(), + model.getSwaps(), + model.getTimeMs(), + model.isDone(), + model.getStatus(), + model.getFoundIndex(), + model.getSearchPath().clone(), + null, + List.of(), + 0, + false); + } + + private SimulationFrame pathFrame(int frame, PathfindingModel model, long timeMs) { + return new SimulationFrame( + frame, + new int[0], + new int[0], + 0, + -1, + -1, + -1, + -1, + 0, + 0, + timeMs, + model.isDone(), + model.isDone() ? "Done" : "Running", + null, + new int[0], + gridState(model.getGrid()), + model.getPath().stream().map(cell -> new PointDto(cell.row, cell.col)).toList(), + model.getSteps(), + model.isPathFound()); + } + + private String[][] gridState(GridCell[][] grid) { + String[][] states = new String[grid.length][grid[0].length]; + for (int r = 0; r < grid.length; r++) { + for (int c = 0; c < grid[r].length; c++) { + states[r][c] = grid[r][c].state.name(); + } + } + return states; + } + + private void markPath(PathfindingModel model) { + for (GridCell cell : model.getPath()) { + if (cell.state != CellState.START && cell.state != CellState.END) { + cell.state = CellState.PATH; + } + } + } + + private String winner(List lanes) { + return lanes.stream() + .min(Comparator.comparingLong(lane -> lane.stats().timeMs())) + .map(RaceLaneResponse::algorithm) + .orElse(null); + } +} diff --git a/frontend/src/pages/PathfindingPage.tsx b/frontend/src/pages/PathfindingPage.tsx index 3cba2b4..592dc72 100644 --- a/frontend/src/pages/PathfindingPage.tsx +++ b/frontend/src/pages/PathfindingPage.tsx @@ -11,6 +11,7 @@ import { useAudio } from '../context/AudioContext'; import { usePlayback } from '../hooks/usePlayback'; import type { CatalogResponse, RaceResponse, SimulationFrame } from '../models/types'; import { api } from '../services/api'; +import { createSimulationStream } from '../services/sseClient'; import { StepExplanationCard } from '../components/StepExplanationCard'; import { Share2, RefreshCw, Sparkles, Palette } from 'lucide-react'; import { getUrlParams } from '../utils/urlParams'; @@ -105,10 +106,10 @@ export function PathfindingPage({ catalog }: { catalog: CatalogResponse }) { if (sendWalls) setWalls(sendWalls); if (sendWeights) setWeights(sendWeights); - const wasCompletedOrAdvanced = playback.frameIndex > 0; + try { - const data = await api.pathfinding({ + const params = { algorithms: useAlgos, rows: 18, cols: 28, @@ -119,32 +120,65 @@ export function PathfindingPage({ catalog }: { catalog: CatalogResponse }) { startCol: useStart[1], endRow: useEnd[0], endCol: useEnd[1] - }); + }; - if (fetchId !== latestFetchIdRef.current) return; - - setResponse(data); - const resolvedWalls = data.walls ?? sendWalls ?? Array.from({ length: 18 }, () => Array(28).fill(false)); - const resolvedWeights = data.weights ?? sendWeights ?? Array.from({ length: 18 }, () => Array(28).fill(1)); - setWalls(resolvedWalls); - setWeights(resolvedWeights); - currentWallsRef.current = resolvedWalls; - currentWeightsRef.current = resolvedWeights; - - setHasFreshDataset(true); - playback.reset(); - if (autoplay) { - play('start'); - playback.setPlaying(true); - setHasFreshDataset(false); - } else if (wasCompletedOrAdvanced) { - setTimeout(() => { - if (data.lanes && data.lanes[0]?.frames) { - const maxF = Math.max(...data.lanes.map((l) => l.frames.length)) - 1; - playback.seek(Math.max(0, maxF)); + const cancelStream = createSimulationStream('/api/simulations/stream/pathfinding', params, + (startData: any) => { + if (fetchId !== latestFetchIdRef.current) { + cancelStream(); + return; } - }, 10); - } + setResponse(startData); + const resolvedWalls = startData.walls ?? sendWalls ?? Array.from({ length: 18 }, () => Array(28).fill(false)); + const resolvedWeights = startData.weights ?? sendWeights ?? Array.from({ length: 18 }, () => Array(28).fill(1)); + setWalls(resolvedWalls); + setWeights(resolvedWeights); + currentWallsRef.current = resolvedWalls; + currentWeightsRef.current = resolvedWeights; + + setHasFreshDataset(true); + playback.reset(); + if (autoplay) { + play('start'); + playback.setPlaying(true); + setHasFreshDataset(false); + } + }, + (frameEvent: any) => { + if (fetchId !== latestFetchIdRef.current) { + cancelStream(); + return; + } + setResponse((prev) => { + if (!prev) return prev; + const newLanes = prev.lanes.map(lane => { + if (lane.name === frameEvent.laneName) { + return { ...lane, frames: [...lane.frames, frameEvent.frame] }; + } + return lane; + }); + if (!newLanes.find(l => l.name === frameEvent.laneName)) { + newLanes.push({ + name: frameEvent.laneName, + complexity: '', + complexityInfo: {} as any, + stats: { comparisons: 0, swaps: 0, steps: 0, timeMs: 0, found: false, foundIndex: null }, + frames: [frameEvent.frame] + }); + } + return { ...prev, lanes: newLanes }; + }); + }, + (endData: any) => { + if (fetchId !== latestFetchIdRef.current) return; + setResponse(prev => prev ? { ...prev, winner: endData.winner } : endData); + }, + (err: any) => { + console.error('SSE Error', err); + } + ); + + } finally { if (fetchId === latestFetchIdRef.current) { setLoading(false); diff --git a/frontend/src/pages/SearchingPage.tsx b/frontend/src/pages/SearchingPage.tsx index a5f7e90..3ab7013 100644 --- a/frontend/src/pages/SearchingPage.tsx +++ b/frontend/src/pages/SearchingPage.tsx @@ -11,6 +11,7 @@ import { useAudio } from '../context/AudioContext'; import { usePlayback } from '../hooks/usePlayback'; import type { CatalogResponse, RaceLaneResponse, RaceResponse, SimulationFrame } from '../models/types'; import { api } from '../services/api'; +import { createSimulationStream } from '../services/sseClient'; import { parseCustomArrayInput } from '../utils/arrayParser'; import { StepExplanationCard } from '../components/StepExplanationCard'; import { CustomDatasetModal } from '../components/CustomDatasetModal'; @@ -201,24 +202,62 @@ export function SearchingPage({ catalog }: { catalog: CatalogResponse }) { const useSize = customSize ?? (isCustomMode && useDataset ? Math.max(1, useDataset.length) : size); try { - const data = await api.searching({ + const params = { algorithms: useAlgos, size: useSize, target: useTarget, dataset: useDataset ?? undefined, - }); - - if (requestId !== requestIdRef.current) return; - - setDataset(data.dataset); - setResponse(data); - setHasFreshDataset(true); - playback.reset(); - if (autoplay) { - play('start'); - playback.setPlaying(true); - setHasFreshDataset(false); - } + }; + + const cancelStream = createSimulationStream('/api/simulations/stream/searching', params, + (startData: any) => { + if (requestId !== requestIdRef.current) { + cancelStream(); + return; + } + setDataset(startData.dataset); + setResponse(startData); + setHasFreshDataset(true); + playback.reset(); + if (autoplay) { + play('start'); + playback.setPlaying(true); + setHasFreshDataset(false); + } + }, + (frameEvent: any) => { + if (requestId !== requestIdRef.current) { + cancelStream(); + return; + } + setResponse((prev) => { + if (!prev) return prev; + const newLanes = prev.lanes.map(lane => { + if (lane.name === frameEvent.laneName) { + return { ...lane, frames: [...lane.frames, frameEvent.frame] }; + } + return lane; + }); + if (!newLanes.find(l => l.name === frameEvent.laneName)) { + newLanes.push({ + name: frameEvent.laneName, + complexity: '', + complexityInfo: {} as any, + stats: { comparisons: 0, swaps: 0, steps: 0, timeMs: 0, found: false, foundIndex: null }, + frames: [frameEvent.frame] + }); + } + return { ...prev, lanes: newLanes }; + }); + }, + (endData: any) => { + if (requestId !== requestIdRef.current) return; + setResponse(prev => prev ? { ...prev, winner: endData.winner } : endData); + }, + (err: any) => { + console.error('SSE Error', err); + } + ); } finally { if (requestId === requestIdRef.current) { setLoading(false); @@ -704,4 +743,4 @@ export function SearchingPage({ catalog }: { catalog: CatalogResponse }) { /> ); -} +} \ No newline at end of file diff --git a/frontend/src/pages/SortingPage.tsx b/frontend/src/pages/SortingPage.tsx index 6796ff1..83d96be 100644 --- a/frontend/src/pages/SortingPage.tsx +++ b/frontend/src/pages/SortingPage.tsx @@ -11,6 +11,7 @@ import { useAudio } from '../context/AudioContext'; import { usePlayback } from '../hooks/usePlayback'; import type { CatalogResponse, RaceLaneResponse, RaceResponse, SimulationFrame } from '../models/types'; import { api } from '../services/api'; +import { createSimulationStream } from '../services/sseClient'; import { parseCustomArrayInput } from '../utils/arrayParser'; import { StepExplanationCard } from '../components/StepExplanationCard'; import { CustomDatasetModal } from '../components/CustomDatasetModal'; @@ -213,27 +214,64 @@ export function SortingPage({ catalog }: { catalog: CatalogResponse }) { const useSize = customParams?.sz ?? (useType === 'Custom' && sendCustomArray ? Math.max(1, sendCustomArray.length) : size); try { - const body = { + const params = { algorithms: useAlgos, datasetType: useType, size: useSize, customArray: sendCustomArray, }; - const data = await api.sorting(body); - if (requestId !== requestIdRef.current) return; - - setResponse(data); - if (data.dataset) { - setDataset(data.dataset); - } - setHasFreshDataset(true); - playback.reset(); - if (autoplay) { - play('start'); - playback.setPlaying(true); - setHasFreshDataset(false); - } + const cancelStream = createSimulationStream('/api/simulations/stream/sorting', params, + (startData: any) => { + if (requestId !== requestIdRef.current) { + cancelStream(); + return; + } + setResponse(startData); + if (startData.dataset) { + setDataset(startData.dataset); + } + setHasFreshDataset(true); + playback.reset(); + if (autoplay) { + play('start'); + playback.setPlaying(true); + setHasFreshDataset(false); + } + }, + (frameEvent: any) => { + if (requestId !== requestIdRef.current) { + cancelStream(); + return; + } + setResponse((prev) => { + if (!prev) return prev; + const newLanes = prev.lanes.map(lane => { + if (lane.name === frameEvent.laneName) { + return { ...lane, frames: [...lane.frames, frameEvent.frame] }; + } + return lane; + }); + if (!newLanes.find(l => l.name === frameEvent.laneName)) { + newLanes.push({ + name: frameEvent.laneName, + complexity: '', + complexityInfo: {} as any, + stats: { comparisons: 0, swaps: 0, steps: 0, timeMs: 0, found: false, foundIndex: null }, + frames: [frameEvent.frame] + }); + } + return { ...prev, lanes: newLanes }; + }); + }, + (endData: any) => { + if (requestId !== requestIdRef.current) return; + setResponse(prev => prev ? { ...prev, winner: endData.winner } : endData); + }, + (err: any) => { + console.error('SSE Error', err); + } + ); } finally { if (requestId === requestIdRef.current) { setLoading(false); @@ -666,4 +704,4 @@ export function SortingPage({ catalog }: { catalog: CatalogResponse }) { /> ); -} +} \ No newline at end of file diff --git a/frontend/src/services/sseClient.ts b/frontend/src/services/sseClient.ts new file mode 100644 index 0000000..932299d --- /dev/null +++ b/frontend/src/services/sseClient.ts @@ -0,0 +1,64 @@ +import type { RaceResponse, SimulationFrame } from '../models/types'; + +export type LaneFrameEvent = { + laneName: string; + frame: SimulationFrame; +}; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'; + +export function createSimulationStream( + endpoint: string, + params: Record, + onStart: (initialResponse: RaceResponse) => void, + onFrame: (event: LaneFrameEvent) => void, + onEnd: (finalResponse: RaceResponse) => void, + onError: (error: Event) => void +): () => void { + const finalParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + if (key === 'walls' || key === 'weights') continue; + if (Array.isArray(value)) { + value.forEach((v) => finalParams.append(key, v.toString())); + } else { + finalParams.append(key, value.toString()); + } + } + + if (params.walls) { + params.walls.forEach((row: boolean[]) => { + finalParams.append('walls', row.join(',')); + }); + } + if (params.weights) { + params.weights.forEach((row: number[]) => { + finalParams.append('weights', row.join(',')); + }); + } + + const url = `${API_BASE_URL}${endpoint}?${finalParams.toString()}`; + const eventSource = new EventSource(url); + + eventSource.addEventListener('START', (event: MessageEvent) => { + onStart(JSON.parse(event.data) as RaceResponse); + }); + + eventSource.addEventListener('FRAME', (event: MessageEvent) => { + onFrame(JSON.parse(event.data) as LaneFrameEvent); + }); + + eventSource.addEventListener('END', (event: MessageEvent) => { + onEnd(JSON.parse(event.data) as RaceResponse); + eventSource.close(); + }); + + eventSource.onerror = (error) => { + onError(error); + eventSource.close(); + }; + + return () => { + eventSource.close(); + }; +}