From 5cf9b7d5109100592078de469a5b10d35a5a7c6c Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 09:28:37 +0200 Subject: [PATCH 01/11] feat: Implement annotation rendering and management functionalities --- .../annotation}/__tests__/annotationRenderer.test.ts | 0 .../annotation}/annotationDisplayUtils.ts | 0 .../{annotations => workspace/annotation}/annotationDrawer.ts | 0 .../{annotations => workspace/annotation}/annotationManager.ts | 0 .../{annotations => workspace/annotation}/annotationRenderer.ts | 0 frontend/src/core/{annotations => workspace/annotation}/index.ts | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename frontend/src/core/{annotations => workspace/annotation}/__tests__/annotationRenderer.test.ts (100%) rename frontend/src/core/{annotations => workspace/annotation}/annotationDisplayUtils.ts (100%) rename frontend/src/core/{annotations => workspace/annotation}/annotationDrawer.ts (100%) rename frontend/src/core/{annotations => workspace/annotation}/annotationManager.ts (100%) rename frontend/src/core/{annotations => workspace/annotation}/annotationRenderer.ts (100%) rename frontend/src/core/{annotations => workspace/annotation}/index.ts (100%) diff --git a/frontend/src/core/annotations/__tests__/annotationRenderer.test.ts b/frontend/src/core/workspace/annotation/__tests__/annotationRenderer.test.ts similarity index 100% rename from frontend/src/core/annotations/__tests__/annotationRenderer.test.ts rename to frontend/src/core/workspace/annotation/__tests__/annotationRenderer.test.ts diff --git a/frontend/src/core/annotations/annotationDisplayUtils.ts b/frontend/src/core/workspace/annotation/annotationDisplayUtils.ts similarity index 100% rename from frontend/src/core/annotations/annotationDisplayUtils.ts rename to frontend/src/core/workspace/annotation/annotationDisplayUtils.ts diff --git a/frontend/src/core/annotations/annotationDrawer.ts b/frontend/src/core/workspace/annotation/annotationDrawer.ts similarity index 100% rename from frontend/src/core/annotations/annotationDrawer.ts rename to frontend/src/core/workspace/annotation/annotationDrawer.ts diff --git a/frontend/src/core/annotations/annotationManager.ts b/frontend/src/core/workspace/annotation/annotationManager.ts similarity index 100% rename from frontend/src/core/annotations/annotationManager.ts rename to frontend/src/core/workspace/annotation/annotationManager.ts diff --git a/frontend/src/core/annotations/annotationRenderer.ts b/frontend/src/core/workspace/annotation/annotationRenderer.ts similarity index 100% rename from frontend/src/core/annotations/annotationRenderer.ts rename to frontend/src/core/workspace/annotation/annotationRenderer.ts diff --git a/frontend/src/core/annotations/index.ts b/frontend/src/core/workspace/annotation/index.ts similarity index 100% rename from frontend/src/core/annotations/index.ts rename to frontend/src/core/workspace/annotation/index.ts From 9879906929cf13518a26eabdbbcf2905d0e2517e Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 09:28:53 +0200 Subject: [PATCH 02/11] feat: Implement TaskManager class for task operations and navigation --- .../src/core/workspace/task/taskManager.ts | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 frontend/src/core/workspace/task/taskManager.ts diff --git a/frontend/src/core/workspace/task/taskManager.ts b/frontend/src/core/workspace/task/taskManager.ts new file mode 100644 index 00000000..fd580f6c --- /dev/null +++ b/frontend/src/core/workspace/task/taskManager.ts @@ -0,0 +1,346 @@ +import { AppLogger } from '@/core/logger/logger'; +import { taskService } from '@/services/project'; +import type { Task } from '@/services/project/task/task.types'; +import { TaskStatus } from '@/services/project/task/task.types'; +import type { TaskNavigationContext } from '@/stores/workspaceStore.types'; + +const logger = AppLogger.createServiceLogger('TaskManager'); + +/** + * Parameters for task operations + */ +export interface TaskOperationParams { + projectId: number; + taskId: number; + workflowStageId?: number; +} + +/** + * Result of task operations + */ +export type TaskOperationResult = { + success: true; + data: T; +} | { + success: false; + error: string; +}; + +/** + * Navigation result with task information + */ +export interface TaskNavigationResult { + taskId: number | null; + assetId: number | null; + hasNext: boolean; + hasPrevious: boolean; + currentPosition: number; + totalTasks: number; + message?: string | null; +} + +/** + * Task completion pipeline result + */ +export interface TaskPipelineResult { + isSuccess: boolean; + updatedTask?: Task; + errorMessage?: string; +} + +/** + * TaskManager handles all task-related operations outside of Pinia store reactivity. + * This includes task loading, status changes, navigation, and pipeline operations. + */ +export class TaskManager { + /** + * Load a specific task by ID + */ + async loadTask(projectId: number, taskId: number): Promise> { + try { + logger.info(`Loading task ${taskId} for project ${projectId}`); + const task = await taskService.getTaskById(projectId, taskId); + return { success: true, data: task }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to load task'; + logger.error(`Failed to load task ${taskId}:`, error); + return { success: false, error: errorMessage }; + } + } + + /** + * Load tasks for a specific asset + */ + async loadTasksForAsset(projectId: number, assetId: number): Promise> { + try { + logger.info(`Loading tasks for asset ${assetId} in project ${projectId}`); + const tasks = await taskService.getTasksForAsset(projectId, assetId); + return { success: true, data: tasks }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to load tasks for asset'; + logger.error(`Failed to load tasks for asset ${assetId}:`, error); + return { success: false, error: errorMessage }; + } + } + + /** + * Load assigned tasks for a specific workflow stage + */ + async loadMyTasksForStage(projectId: number, workflowStageId: number): Promise> { + try { + logger.info(`Loading my tasks for stage ${workflowStageId} in project ${projectId}`); + const assignedTasks = await taskService.getMyTasksForStage(projectId, workflowStageId); + return { success: true, data: assignedTasks.tasks }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to load assigned tasks'; + logger.error(`Failed to load assigned tasks for stage ${workflowStageId}:`, error); + return { success: false, error: errorMessage }; + } + } + + /** + * Get navigation context for a task + */ + async getNavigationContext( + projectId: number, + taskId: number, + workflowStageId: number + ): Promise> { + try { + logger.info(`Loading navigation context for task ${taskId} in stage ${workflowStageId}`); + const navigationContext = await taskService.getNavigationContext( + projectId, + taskId, + workflowStageId + ); + + const context: TaskNavigationContext = { + hasNext: navigationContext.hasNext, + hasPrevious: navigationContext.hasPrevious, + currentPosition: navigationContext.currentPosition, + totalTasks: navigationContext.totalTasks, + message: navigationContext.message + }; + + return { success: true, data: context }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to load navigation context'; + logger.warn('Failed to load navigation context:', error); + return { success: false, error: errorMessage }; + } + } + + /** + * Navigate to the previous task + */ + async navigateToPreviousTask( + projectId: number, + taskId: number, + workflowStageId: number + ): Promise> { + try { + logger.info(`Navigating to previous task from ${taskId} in stage ${workflowStageId}`); + const navigation = await taskService.getPreviousTask(projectId, taskId, workflowStageId); + + const result: TaskNavigationResult = { + taskId: navigation.taskId || null, + assetId: navigation.assetId || null, + hasNext: navigation.hasNext, + hasPrevious: navigation.hasPrevious, + currentPosition: navigation.currentPosition, + totalTasks: navigation.totalTasks, + message: navigation.message + }; + + return { success: true, data: result }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to navigate to previous task'; + logger.error('Failed to navigate to previous task:', error); + return { success: false, error: errorMessage }; + } + } + + /** + * Navigate to the next task + */ + async navigateToNextTask( + projectId: number, + taskId: number, + workflowStageId: number + ): Promise> { + try { + logger.info(`Navigating to next task from ${taskId} in stage ${workflowStageId}`); + const navigation = await taskService.getNextTask(projectId, taskId, workflowStageId); + + const result: TaskNavigationResult = { + taskId: navigation.taskId || null, + assetId: navigation.assetId || null, + hasNext: navigation.hasNext, + hasPrevious: navigation.hasPrevious, + currentPosition: navigation.currentPosition, + totalTasks: navigation.totalTasks, + message: navigation.message + }; + + return { success: true, data: result }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to navigate to next task'; + logger.error('Failed to navigate to next task:', error); + return { success: false, error: errorMessage }; + } + } + + /** + * Change task status using the TaskService (ensures lock is acquired first) + */ + async changeTaskStatus( + projectId: number, + taskId: number, + targetStatus: TaskStatus + ): Promise> { + try { + logger.info(`Changing task ${taskId} status to ${targetStatus}`); + + // First, ensure we have a lock on the task by fetching it with autoAssign=true + logger.debug(`Acquiring lock for task ${taskId} before status change`); + await taskService.getTaskById(projectId, taskId, true); + + // Now change the status + const updatedTask = await taskService.changeTaskStatus(projectId, taskId, { targetStatus }); + + if (!updatedTask) { + return { success: false, error: 'Failed to change task status' }; + } + + return { success: true, data: updatedTask }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to change task status'; + logger.error(`Failed to change task ${taskId} status to ${targetStatus}:`, error); + return { success: false, error: errorMessage }; + } + } + + /** + * Complete task using the pipeline system + */ + async completeTaskPipeline(projectId: number, taskId: number): Promise> { + try { + logger.info(`Completing task ${taskId} via pipeline`); + const result = await taskService.completeTaskPipeline(projectId, taskId); + return { success: true, data: result }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to complete task via pipeline'; + logger.error(`Failed to complete task ${taskId} via pipeline:`, error); + return { success: false, error: errorMessage }; + } + } + + /** + * Veto/return task for rework using the pipeline system + */ + async vetoTaskPipeline( + projectId: number, + taskId: number, + reason?: string + ): Promise> { + try { + logger.info(`Vetoing task ${taskId} via pipeline`); + const result = await taskService.vetoTaskPipeline(projectId, taskId, { + reason: reason || 'Task returned for rework' + }); + return { success: true, data: result }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to veto task via pipeline'; + logger.error(`Failed to veto task ${taskId} via pipeline:`, error); + return { success: false, error: errorMessage }; + } + } + + /** + * Suspend task + */ + async suspendTask(projectId: number, taskId: number): Promise> { + return this.changeTaskStatus(projectId, taskId, TaskStatus.SUSPENDED); + } + + /** + * Defer task + */ + async deferTask(projectId: number, taskId: number): Promise> { + return this.changeTaskStatus(projectId, taskId, TaskStatus.DEFERRED); + } + + /** + * Assign task to current user and start it + */ + async assignAndStartTask(projectId: number, taskId: number): Promise> { + try { + logger.info(`Assigning and starting task ${taskId}`); + + // First assign the task to current user + await taskService.assignTaskToCurrentUser(projectId, taskId); + + // Then change status to IN_PROGRESS using our wrapper method (which ensures lock) + const result = await this.changeTaskStatus(projectId, taskId, TaskStatus.IN_PROGRESS); + + if (!result.success) { + return { success: false, error: result.error }; + } + + return { success: true, data: undefined }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to assign and start task'; + logger.error(`Failed to assign and start task ${taskId}:`, error); + return { success: false, error: errorMessage }; + } + } + + /** + * Update working time for a task + */ + async updateWorkingTime(projectId: number, taskId: number, workingTimeMs: number): Promise> { + try { + logger.info(`Updating working time for task ${taskId} to ${workingTimeMs}ms`); + const updatedTask = await taskService.updateWorkingTime(projectId, taskId, workingTimeMs); + return { success: true, data: updatedTask }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to update working time'; + logger.error(`Failed to update working time for task ${taskId}:`, error); + return { success: false, error: errorMessage }; + } + } + + /** + * Check if a task can be opened by the current user + */ + canOpenTask(task: Task): boolean { + // Simple check - most tasks can be opened, except archived + return task.status !== TaskStatus.ARCHIVED; + } + + /** + * Check if a task is completed + */ + isTaskCompleted(task: Task | null): boolean { + if (!task) return false; + + // Task is NOT considered completed (i.e., should be editable) if it has CHANGES_REQUIRED status + if (task.status === TaskStatus.CHANGES_REQUIRED) { + return false; + } + + // Otherwise, check if task is actually completed + return task.status === TaskStatus.COMPLETED; + } + + /** + * Check if time should be tracked for a task + */ + shouldTrackTime(task: Task | null): boolean { + // Only track time for active, non-completed tasks + return !!(task && !task.completedAt && !task.archivedAt); + } +} + +// Export singleton instance +export const taskManager = new TaskManager(); \ No newline at end of file From 98585d64027c4425305bf96fb43f951196dbec58 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 09:28:57 +0200 Subject: [PATCH 03/11] feat: Implement WorkspaceLoader for orchestrating workspace data loading --- .../core/workspace/loader/workspaceLoader.ts | 355 ++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 frontend/src/core/workspace/loader/workspaceLoader.ts diff --git a/frontend/src/core/workspace/loader/workspaceLoader.ts b/frontend/src/core/workspace/loader/workspaceLoader.ts new file mode 100644 index 00000000..a428a804 --- /dev/null +++ b/frontend/src/core/workspace/loader/workspaceLoader.ts @@ -0,0 +1,355 @@ +import { AppLogger } from '@/core/logger/logger'; +import { AssetManager } from '@/core/workspace'; +import { taskManager } from '../task/taskManager'; +import { annotationDataManager } from '../data/annotationDataManager'; +import { + labelSchemeService, + labelService, + workflowService, + workflowStageService +} from '@/services/project'; + +import type { Asset } from '@/core/asset/asset.types'; +import type { ImageDimensions } from "@/core/asset/asset.types"; +import type { Task } from '@/services/project/task/task.types'; +import type { Annotation } from '@/core/workspace/annotation.types'; +import type { LabelScheme } from '@/services/project/labelScheme/label.types'; +import type { WorkflowStageType } from '@/services/project/workflow/workflowStage.types'; +import type { TaskNavigationContext } from '@/stores/workspaceStore.types'; + +const logger = AppLogger.createServiceLogger('WorkspaceLoader'); + +// Core managers +const assetManager = new AssetManager(); + +/** + * Parameters for loading workspace data + */ +export interface WorkspaceLoadParams { + projectId: string; + assetId: string; + taskId?: string; +} + +/** + * Asset loading result + */ +export interface AssetLoadResult { + asset: Asset; + imageUrl: string | null; + naturalDimensions: ImageDimensions | null; +} + +/** + * Task loading result + */ +export interface TaskLoadResult { + currentTask: Task | null; + availableTasks: Task[]; + workflowStageType: WorkflowStageType | null; + navigationContext: TaskNavigationContext | null; +} + +/** + * Label scheme loading result + */ +export interface LabelSchemeLoadResult { + labelScheme: LabelScheme | null; + availableSchemes: LabelScheme[]; +} + +/** + * Complete workspace loading result + */ +export interface WorkspaceLoadResult { + success: boolean; + error?: string; + asset?: AssetLoadResult; + annotations?: Annotation[]; + tasks?: TaskLoadResult; + labelScheme?: LabelSchemeLoadResult; +} + +/** + * WorkspaceLoader orchestrates the loading of all workspace-related data. + * It coordinates asset loading, annotation fetching, task management, and label scheme loading. + */ +export class WorkspaceLoader { + /** + * Load asset data using AssetManager + */ + async loadAssetData(projectId: string, assetId: string): Promise { + try { + logger.info(`Loading asset ${assetId} for project ${projectId}`); + + // Use AssetManager to load the asset + const assetResult = await assetManager.loadAsset(projectId, assetId); + + if (!assetResult.success) { + throw new Error(assetResult.error); + } + + logger.info(`Successfully loaded asset data:`, assetResult.asset); + return { + asset: assetResult.asset, + imageUrl: assetResult.imageUrl, + naturalDimensions: assetResult.naturalDimensions + }; + } catch (error) { + logger.error(`Failed to load asset ${assetId}:`, error); + return null; + } + } + + /** + * Load annotations for the specified asset + */ + async loadAnnotationsForAsset(projectId: number, assetId: number): Promise { + try { + logger.info(`Loading annotations for asset ${assetId}`); + const result = await annotationDataManager.loadAnnotationsForAsset(projectId, assetId); + + if (!result.success) { + logger.error("Failed to fetch annotations:", result.error); + return []; + } + + logger.info(`Loaded ${result.data.length} annotations for asset ${assetId}`); + return result.data; + } catch (error) { + logger.error("Failed to fetch annotations:", error); + return []; + } + } + + /** + * Load tasks for the specified asset and determine current task + */ + async loadTasksForAsset( + projectId: number, + assetId: number, + initialTaskId?: number + ): Promise { + const result: TaskLoadResult = { + currentTask: null, + availableTasks: [], + workflowStageType: null, + navigationContext: null + }; + + try { + let currentTask: Task | null = null; + let workflowStageId: number | null = null; + + // Load specific task if provided + if (initialTaskId) { + try { + const taskResult = await taskManager.loadTask(projectId, initialTaskId); + if (taskResult.success) { + currentTask = taskResult.data; + workflowStageId = currentTask.workflowStageId; + logger.info(`Loaded specific task ${initialTaskId} for stage ${workflowStageId}`); + } else { + logger.warn(`Failed to load task ${initialTaskId}, falling back to asset tasks:`, taskResult.error); + } + } catch (taskByIdError) { + logger.warn(`Failed to load task ${initialTaskId}, falling back to asset tasks:`, taskByIdError); + } + } + + // If no current task yet, find task(s) for this asset + if (!currentTask) { + const assetTasksResult = await taskManager.loadTasksForAsset(projectId, assetId); + if (assetTasksResult.success && assetTasksResult.data.length > 0) { + currentTask = assetTasksResult.data[0]; + workflowStageId = currentTask?.workflowStageId || null; + logger.info(`Found asset task ${currentTask.id} for asset ${assetId} in stage ${workflowStageId}`); + } + } + + if (currentTask && workflowStageId) { + // Load user's assigned tasks for this workflow stage + const assignedTasksResult = await taskManager.loadMyTasksForStage(projectId, workflowStageId); + let tasksForNavigation = assignedTasksResult.success ? assignedTasksResult.data : []; + + // If current task is not in the assigned tasks list, add it + const currentTaskInList = tasksForNavigation.find(task => task.id === currentTask.id); + if (!currentTaskInList) { + tasksForNavigation = [...tasksForNavigation, currentTask]; + logger.info(`Added current task ${currentTask.id} to navigation context`); + } + + result.currentTask = currentTask; + result.availableTasks = tasksForNavigation; + + // Load navigation context + const navigationResult = await taskManager.getNavigationContext( + projectId, + currentTask.id, + currentTask.workflowStageId + ); + + if (navigationResult.success) { + result.navigationContext = navigationResult.data; + logger.info(`Loaded navigation context: ${navigationResult.data.currentPosition}/${navigationResult.data.totalTasks}`); + } + + // Load workflow stage type + try { + const stageData = await workflowStageService.getWorkflowStageById( + projectId, + currentTask.workflowId, + workflowStageId + ); + result.workflowStageType = stageData.stageType || null; + logger.info(`Loaded workflow stage type: ${result.workflowStageType} for stage ${workflowStageId}`); + } catch (stageError) { + logger.warn('Failed to fetch workflow stage type:', stageError); + } + + logger.info(`Loaded ${result.availableTasks.length} tasks for stage ${workflowStageId}`); + } else { + logger.warn(`No tasks found for asset ${assetId}`); + } + } catch (taskError) { + logger.error("Failed to fetch tasks:", taskError); + } + + return result; + } + + /** + * Load label scheme for the current workflow + */ + async loadLabelScheme( + projectId: number, + currentTask: Task | null + ): Promise { + const result: LabelSchemeLoadResult = { + labelScheme: null, + availableSchemes: [] + }; + + try { + if (currentTask && currentTask.workflowId) { + // Get workflow to access its assigned label scheme + const workflow = await workflowService.getWorkflowById(projectId, currentTask.workflowId); + + if (workflow.labelSchemeId) { + // Get the specific label scheme assigned to this workflow + const labelScheme = await labelSchemeService.getLabelSchemeById(projectId, workflow.labelSchemeId); + + // Fetch labels for the scheme + try { + const labelsResponse = await labelService.getLabelsForScheme(projectId, labelScheme.labelSchemeId); + labelScheme.labels = labelsResponse.data; + + // Set this as the only available scheme (no switching allowed) + result.availableSchemes = [labelScheme]; + result.labelScheme = labelScheme; + logger.info(`Loaded workflow label scheme: ${labelScheme.name} with ${labelsResponse.data.length} labels`); + } catch (labelsError) { + logger.error("Failed to fetch labels for workflow scheme:", labelsError); + result.availableSchemes = [labelScheme]; + result.labelScheme = labelScheme; // Set scheme even without labels + } + } else { + logger.warn(`Workflow ${currentTask.workflowId} has no assigned label scheme`); + } + } else { + logger.warn('No current task with workflow information available for label scheme loading'); + } + } catch (labelSchemeError) { + logger.error("Failed to fetch workflow label scheme:", labelSchemeError); + } + + return result; + } + + /** + * Load all workspace data for the given parameters + */ + async loadWorkspaceData(params: WorkspaceLoadParams): Promise { + const { projectId, assetId, taskId } = params; + const numericProjectId = parseInt(projectId, 10); + const numericAssetId = parseInt(assetId, 10); + const numericTaskId = taskId ? parseInt(taskId, 10) : undefined; + + logger.info(`Loading workspace data for project ${projectId}, asset ${assetId}, task ${taskId || 'auto'}`); + + try { + // Load asset data + const assetData = await this.loadAssetData(projectId, assetId); + if (!assetData) { + return { success: false, error: 'Failed to load asset data' }; + } + + // Load annotations for this asset + const annotations = await this.loadAnnotationsForAsset(numericProjectId, numericAssetId); + + // Load tasks for this asset + const tasksData = await this.loadTasksForAsset(numericProjectId, numericAssetId, numericTaskId); + + // Load label scheme from the workflow + const labelSchemeData = await this.loadLabelScheme(numericProjectId, tasksData.currentTask); + + logger.info(`Successfully loaded all workspace data for asset ${assetId}`); + return { + success: true, + asset: assetData, + annotations, + tasks: tasksData, + labelScheme: labelSchemeData + }; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to load workspace data'; + logger.error(`Failed to load workspace data for asset ${assetId}:`, error); + return { success: false, error: errorMessage }; + } + } + + /** + * Reset workspace state (helper for clearing data) + */ + getInitialWorkspaceState() { + return { + asset: { + currentProjectId: null, + currentAssetId: null, + currentAssetData: null, + currentImageUrl: null, + imageNaturalDimensions: null, + canvasDisplayDimensions: null + }, + task: { + currentTaskId: null, + currentTaskData: null, + currentWorkflowStageType: null, + availableTasks: [], + initialTaskId: null + }, + annotation: { + annotations: [], + currentLabelId: null, + currentLabelScheme: null, + availableLabelSchemes: [] + }, + ui: { + viewOffset: { x: 0, y: 0 }, + zoomLevel: 1.0, + isLoading: false, + error: null + }, + timer: { + elapsedTimeDisplay: "00:00:00", + timerIntervalId: null, + lastSavedWorkingTime: 0 + }, + navigationContext: null + }; + } +} + +// Export singleton instance +export const workspaceLoader = new WorkspaceLoader(); \ No newline at end of file From 9efe6adb428ceca9e0ee0a6c79f1ccbb88c39d06 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 09:29:01 +0200 Subject: [PATCH 04/11] feat: Implement AnnotationDataManager for managing annotation CRUD operations --- .../workspace/data/annotationDataManager.ts | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 frontend/src/core/workspace/data/annotationDataManager.ts diff --git a/frontend/src/core/workspace/data/annotationDataManager.ts b/frontend/src/core/workspace/data/annotationDataManager.ts new file mode 100644 index 00000000..4e0e9525 --- /dev/null +++ b/frontend/src/core/workspace/data/annotationDataManager.ts @@ -0,0 +1,219 @@ +import { AppLogger } from '@/core/logger/logger'; +import { annotationService } from '@/services/project'; +import type { Annotation, CreateAnnotationDto } from '@/core/workspace/annotation.types'; + +const logger = AppLogger.createServiceLogger('AnnotationDataManager'); + +/** + * Result of annotation operations + */ +export type AnnotationOperationResult = { + success: true; + data: T; +} | { + success: false; + error: string; +}; + +/** + * AnnotationDataManager handles CRUD operations for annotations. + * This is separate from the AnnotationManager in /core/annotations/ which handles interaction and drawing. + */ +export class AnnotationDataManager { + /** + * Load all annotations for a specific asset + */ + async loadAnnotationsForAsset(projectId: number, assetId: number): Promise> { + try { + logger.info(`Loading annotations for asset ${assetId} in project ${projectId}`); + const response = await annotationService.getAnnotationsForAsset(projectId, assetId); + return { success: true, data: response.data }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to load annotations'; + logger.error(`Failed to load annotations for asset ${assetId}:`, error); + return { success: false, error: errorMessage }; + } + } + + /** + * Create a new annotation + */ + async createAnnotation(projectId: number, annotation: Annotation): Promise> { + try { + logger.info(`Creating annotation for asset ${annotation.assetId}`); + + // Convert Annotation to CreateAnnotationDto + const createDto: CreateAnnotationDto = { + annotationType: annotation.annotationType, + data: annotation.data || (annotation.coordinates ? JSON.stringify(annotation.coordinates) : '{}'), + taskId: annotation.taskId, + assetId: annotation.assetId, + labelId: annotation.labelId, + isPrediction: annotation.isPrediction || false, + confidenceScore: annotation.confidenceScore, + isGroundTruth: annotation.isGroundTruth, + version: annotation.version || 1, + notes: annotation.notes, + annotatorEmail: annotation.annotatorEmail, + parentAnnotationId: annotation.parentAnnotationId + }; + + // Save annotation to backend + const savedAnnotation = await annotationService.createAnnotation(projectId, createDto); + + logger.info(`Successfully created annotation with ID: ${savedAnnotation.annotationId}`); + return { success: true, data: savedAnnotation }; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to create annotation'; + logger.error("Failed to create annotation:", error); + return { success: false, error: errorMessage }; + } + } + + /** + * Update an existing annotation + */ + async updateAnnotation( + projectId: number, + annotationId: number, + updates: Partial + ): Promise> { + try { + logger.info(`Updating annotation ${annotationId}`); + + // Prepare update payload for backend + const updatePayload: any = {}; + if (updates.annotationType) updatePayload.annotationType = updates.annotationType; + if (updates.coordinates) updatePayload.data = JSON.stringify(updates.coordinates); + if (updates.isPrediction !== undefined) updatePayload.isPrediction = updates.isPrediction; + if (updates.confidenceScore !== undefined) updatePayload.confidenceScore = updates.confidenceScore; + if (updates.isGroundTruth !== undefined) updatePayload.isGroundTruth = updates.isGroundTruth; + if (updates.notes !== undefined) updatePayload.notes = updates.notes; + if (updates.labelId !== undefined) updatePayload.labelId = updates.labelId; + + // Update annotation on backend + const updatedAnnotation = await annotationService.updateAnnotation(projectId, annotationId, updatePayload); + + logger.info(`Successfully updated annotation with ID: ${annotationId}`); + return { success: true, data: updatedAnnotation }; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to update annotation'; + logger.error(`Failed to update annotation ${annotationId}:`, error); + return { success: false, error: errorMessage }; + } + } + + /** + * Delete an annotation + */ + async deleteAnnotation(projectId: number, annotationId: number): Promise> { + try { + logger.info(`Deleting annotation ${annotationId}`); + + // Delete annotation on backend + await annotationService.deleteAnnotation(projectId, annotationId); + + logger.info(`Successfully deleted annotation with ID: ${annotationId}`); + return { success: true, data: undefined }; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to delete annotation'; + logger.error(`Failed to delete annotation ${annotationId}:`, error); + return { success: false, error: errorMessage }; + } + } + + /** + * Find annotation in a list by client ID + */ + findAnnotationByClientId(annotations: Annotation[], clientId: string): number { + return annotations.findIndex((a: Annotation) => a.clientId === clientId); + } + + /** + * Find annotation in a list by coordinates and properties (fallback matching) + */ + findAnnotationByProperties( + annotations: Annotation[], + targetAnnotation: Annotation + ): number { + return annotations.findIndex((a: Annotation) => + !a.annotationId && // Only match unsaved annotations + a.assetId === targetAnnotation.assetId && + a.labelId === targetAnnotation.labelId && + a.annotationType === targetAnnotation.annotationType && + JSON.stringify(a.coordinates) === JSON.stringify(targetAnnotation.coordinates) + ); + } + + /** + * Update annotation in list with preserved client ID + */ + updateAnnotationInList( + annotations: Annotation[], + index: number, + savedAnnotation: Annotation, + originalClientId?: string + ): Annotation[] { + const updatedAnnotations = [...annotations]; + updatedAnnotations[index] = { + ...savedAnnotation, + clientId: originalClientId || updatedAnnotations[index].clientId + }; + return updatedAnnotations; + } + + /** + * Remove annotation from list by client ID + */ + removeAnnotationByClientId(annotations: Annotation[], clientId: string): Annotation[] { + return annotations.filter((a: Annotation) => a.clientId !== clientId); + } + + /** + * Remove annotation from list by annotation ID + */ + removeAnnotationById(annotations: Annotation[], annotationId: number): Annotation[] { + return annotations.filter((a: Annotation) => a.annotationId !== annotationId); + } + + /** + * Add annotation to list + */ + addAnnotationToList(annotations: Annotation[], annotation: Annotation): Annotation[] { + return [...annotations, annotation]; + } + + /** + * Check if annotations exist for asset (used for task completion validation) + */ + hasAnnotationsForAsset(annotations: Annotation[], assetId: number): boolean { + return annotations.some((annotation: Annotation) => annotation.assetId === assetId); + } + + /** + * Check if annotations exist for task (used for task completion validation) + */ + hasAnnotationsForTask(annotations: Annotation[], taskId: number): boolean { + return annotations.some((annotation: Annotation) => annotation.taskId === taskId); + } + + /** + * Get annotations for a specific asset + */ + getAnnotationsForAsset(annotations: Annotation[], assetId: number): Annotation[] { + return annotations.filter((annotation: Annotation) => annotation.assetId === assetId); + } + + /** + * Get annotations for a specific task + */ + getAnnotationsForTask(annotations: Annotation[], taskId: number): Annotation[] { + return annotations.filter((annotation: Annotation) => annotation.taskId === taskId); + } +} + +// Export singleton instance +export const annotationDataManager = new AnnotationDataManager(); \ No newline at end of file From 171490a8ba01611061423aeeb786e1a089f7f166 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 09:29:10 +0200 Subject: [PATCH 05/11] refactor: Remove unused cursor style calculation and update import paths for annotation drawer and renderer --- .../src/core/workspace/interaction/cursors.ts | 29 ------------------- .../src/core/workspace/interaction/index.ts | 1 - .../toolHandlers/boundingBoxToolHandler.ts | 4 +-- .../toolHandlers/lineToolHandler.ts | 4 +-- .../toolHandlers/polygonToolHandler.ts | 4 +-- .../toolHandlers/polylineToolHandler.ts | 4 +-- 6 files changed, 8 insertions(+), 38 deletions(-) delete mode 100644 frontend/src/core/workspace/interaction/cursors.ts diff --git a/frontend/src/core/workspace/interaction/cursors.ts b/frontend/src/core/workspace/interaction/cursors.ts deleted file mode 100644 index ed56def6..00000000 --- a/frontend/src/core/workspace/interaction/cursors.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ToolName } from "@/core/workspace/tools.types"; - -/** - * Calculate the appropriate cursor style based on current tool and interaction state - */ -export const calculateCanvasCursorStyle = ( - activeTool: ToolName, - isDraggingHandle: boolean = false, - isPanning: boolean = false, - hoveredHandleIndex: number = -1, - hoveredAnnotationId: number | null = null -): string => { - switch (activeTool) { - case ToolName.CURSOR: - if (isDraggingHandle) return 'grabbing'; - if (isPanning) return 'grabbing'; - if (hoveredHandleIndex >= 0) return 'grab'; - if (hoveredAnnotationId !== null) return 'pointer'; - return 'default'; - case ToolName.POINT: - case ToolName.LINE: - case ToolName.POLYLINE: - case ToolName.BOUNDING_BOX: - case ToolName.POLYGON: - return 'crosshair'; - default: - return 'default'; - } -}; \ No newline at end of file diff --git a/frontend/src/core/workspace/interaction/index.ts b/frontend/src/core/workspace/interaction/index.ts index cfc595c2..6afe6dfb 100644 --- a/frontend/src/core/workspace/interaction/index.ts +++ b/frontend/src/core/workspace/interaction/index.ts @@ -1,4 +1,3 @@ -export { calculateCanvasCursorStyle } from './cursors'; export { type ToolHandler } from './toolHandlers/toolHandler'; export { BoundingBoxToolHandler } from './toolHandlers/boundingBoxToolHandler'; export { LineToolHandler } from './toolHandlers/lineToolHandler'; diff --git a/frontend/src/core/workspace/interaction/toolHandlers/boundingBoxToolHandler.ts b/frontend/src/core/workspace/interaction/toolHandlers/boundingBoxToolHandler.ts index 3029569c..c367b657 100644 --- a/frontend/src/core/workspace/interaction/toolHandlers/boundingBoxToolHandler.ts +++ b/frontend/src/core/workspace/interaction/toolHandlers/boundingBoxToolHandler.ts @@ -4,8 +4,8 @@ import { AnnotationType } from '@/core/workspace/annotation.types'; import type { ToolHandler } from './toolHandler'; import type { useWorkspaceStore } from '@/stores/workspaceStore'; import { StoreError, ToolError } from '@/core/errors/errors'; -import { drawBoundingBox } from '@/core/annotations/annotationDrawer'; -import { calculateRenderSizes } from '@/core/annotations/annotationRenderer'; +import { drawBoundingBox } from '@/core/workspace/annotation/annotationDrawer'; +import { calculateRenderSizes } from '@/core/workspace/annotation/annotationRenderer'; import { clampPointToImageBounds } from '@/core/workspace/geometry/geometry'; import type { Point } from '@/core/geometry/geometry.types'; diff --git a/frontend/src/core/workspace/interaction/toolHandlers/lineToolHandler.ts b/frontend/src/core/workspace/interaction/toolHandlers/lineToolHandler.ts index fa210110..ef9e3571 100644 --- a/frontend/src/core/workspace/interaction/toolHandlers/lineToolHandler.ts +++ b/frontend/src/core/workspace/interaction/toolHandlers/lineToolHandler.ts @@ -4,8 +4,8 @@ import { AnnotationType } from '@/core/workspace/annotation.types'; import type { ToolHandler } from './toolHandler'; import type { useWorkspaceStore } from '@/stores/workspaceStore'; import { StoreError, ToolError } from '@/core/errors/errors'; -import { drawLine } from '@/core/annotations/annotationDrawer'; -import { calculateRenderSizes } from '@/core/annotations/annotationRenderer'; +import { drawLine } from '@/core/workspace/annotation/annotationDrawer'; +import { calculateRenderSizes } from '@/core/workspace/annotation/annotationRenderer'; import { clampPointToImageBounds } from '@/core/workspace/geometry/geometry'; import type { Point } from '@/core/geometry/geometry.types'; diff --git a/frontend/src/core/workspace/interaction/toolHandlers/polygonToolHandler.ts b/frontend/src/core/workspace/interaction/toolHandlers/polygonToolHandler.ts index 8c16a4cc..287eb26a 100644 --- a/frontend/src/core/workspace/interaction/toolHandlers/polygonToolHandler.ts +++ b/frontend/src/core/workspace/interaction/toolHandlers/polygonToolHandler.ts @@ -4,8 +4,8 @@ import { AnnotationType } from '@/core/workspace/annotation.types'; import type { ToolHandler } from './toolHandler'; import type { useWorkspaceStore } from '@/stores/workspaceStore'; import { StoreError, ToolError } from '@/core/errors/errors'; -import { drawPoint } from '@/core/annotations/annotationDrawer'; -import { calculateRenderSizes } from '@/core/annotations/annotationRenderer'; +import { drawPoint } from '@/core/workspace/annotation/annotationDrawer'; +import { calculateRenderSizes } from '@/core/workspace/annotation/annotationRenderer'; import { clampPointToImageBounds } from '@/core/workspace/geometry/geometry'; import type { Point } from '@/core/geometry/geometry.types'; diff --git a/frontend/src/core/workspace/interaction/toolHandlers/polylineToolHandler.ts b/frontend/src/core/workspace/interaction/toolHandlers/polylineToolHandler.ts index 4e923c85..a152cec1 100644 --- a/frontend/src/core/workspace/interaction/toolHandlers/polylineToolHandler.ts +++ b/frontend/src/core/workspace/interaction/toolHandlers/polylineToolHandler.ts @@ -4,8 +4,8 @@ import { AnnotationType } from '@/core/workspace/annotation.types'; import type { ToolHandler } from './toolHandler'; import type { useWorkspaceStore } from '@/stores/workspaceStore'; import { StoreError, ToolError } from '@/core/errors/errors'; -import { drawPolyline, drawPoint } from '@/core/annotations/annotationDrawer'; -import { calculateRenderSizes } from '@/core/annotations/annotationRenderer'; +import { drawPolyline, drawPoint } from '@/core/workspace/annotation/annotationDrawer'; +import { calculateRenderSizes } from '@/core/workspace/annotation/annotationRenderer'; import { clampPointToImageBounds } from '@/core/workspace/geometry/geometry'; import type { Point } from '@/core/geometry/geometry.types'; From 3c79f975603f5d6a9093079480e546adbafa4c54 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 09:29:15 +0200 Subject: [PATCH 06/11] feat: Add CursorManager for centralized cursor style management --- .../src/core/workspace/ui/cursorManager.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 frontend/src/core/workspace/ui/cursorManager.ts diff --git a/frontend/src/core/workspace/ui/cursorManager.ts b/frontend/src/core/workspace/ui/cursorManager.ts new file mode 100644 index 00000000..3168b40b --- /dev/null +++ b/frontend/src/core/workspace/ui/cursorManager.ts @@ -0,0 +1,89 @@ +import { ToolName } from "@/core/workspace/tools.types"; + +/** + * CursorManager handles cursor style calculations based on tool state and interaction context. + * This centralizes cursor logic that was previously scattered across components. + */ +export class CursorManager { + /** + * Calculate the appropriate cursor style based on current tool and interaction state + */ + calculateCanvasCursorStyle( + activeTool: ToolName, + isDraggingHandle: boolean = false, + isPanning: boolean = false, + hoveredHandleIndex: number = -1, + hoveredAnnotationId: number | null = null + ): string { + switch (activeTool) { + case ToolName.CURSOR: + if (isDraggingHandle) return 'grabbing'; + if (isPanning) return 'grabbing'; + if (hoveredHandleIndex >= 0) return 'grab'; + if (hoveredAnnotationId !== null) return 'pointer'; + return 'default'; + + case ToolName.POINT: + case ToolName.LINE: + case ToolName.POLYLINE: + case ToolName.BOUNDING_BOX: + case ToolName.POLYGON: + return 'crosshair'; + + default: + return 'default'; + } + } + + /** + * Get cursor style for a specific tool (without interaction context) + */ + getToolCursor(toolName: ToolName): string { + return this.calculateCanvasCursorStyle(toolName); + } + + /** + * Check if cursor should change on hover for given tool + */ + shouldShowHoverCursor(toolName: ToolName): boolean { + return toolName === ToolName.CURSOR; + } + + /** + * Get cursor for dragging operations + */ + getDragCursor(): string { + return 'grabbing'; + } + + /** + * Get cursor for hover operations + */ + getHoverCursor(): string { + return 'grab'; + } + + /** + * Get default cursor + */ + getDefaultCursor(): string { + return 'default'; + } + + /** + * Get crosshair cursor for drawing tools + */ + getCrosshairCursor(): string { + return 'crosshair'; + } + + /** + * Get pointer cursor for clickable elements + */ + getPointerCursor(): string { + return 'pointer'; + } +} + +// Export singleton instance +export const cursorManager = new CursorManager(); \ No newline at end of file From 76790d6b4eed82c28e13181cdab5fd099ef7a048 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 09:29:22 +0200 Subject: [PATCH 07/11] feat: Add ToolManager for tool selection and validation with cursor management --- frontend/src/core/workspace/ui/toolManager.ts | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 frontend/src/core/workspace/ui/toolManager.ts diff --git a/frontend/src/core/workspace/ui/toolManager.ts b/frontend/src/core/workspace/ui/toolManager.ts new file mode 100644 index 00000000..7deaabca --- /dev/null +++ b/frontend/src/core/workspace/ui/toolManager.ts @@ -0,0 +1,121 @@ +import { faArrowPointer, faDotCircle, faMinus, faWaveSquare, faSquare, faDrawPolygon } from '@fortawesome/free-solid-svg-icons'; +import { ToolName, type Tool } from "@/core/workspace/tools.types"; +import { cursorManager } from './cursorManager'; + +/** + * ToolManager handles tool selection and validation. + * This is a utility class that doesn't require reactivity. + */ +export class ToolManager { + private readonly availableTools: Tool[] = [ + { id: ToolName.CURSOR, name: 'Cursor', iconDefinition: faArrowPointer }, + { id: ToolName.POINT, name: 'Point', iconDefinition: faDotCircle }, + { id: ToolName.LINE, name: 'Line', iconDefinition: faMinus }, + { id: ToolName.BOUNDING_BOX, name: 'Bounding Box', iconDefinition: faSquare }, + { id: ToolName.POLYLINE, name: 'Polyline', iconDefinition: faWaveSquare }, + { id: ToolName.POLYGON, name: 'Polygon', iconDefinition: faDrawPolygon }, + ]; + + /** + * Get all available tools + */ + getAvailableTools(): Tool[] { + return [...this.availableTools]; + } + + /** + * Get tool details by ID + */ + getToolById(toolId: ToolName): Tool | undefined { + return this.availableTools.find((tool: Tool) => tool.id === toolId); + } + + /** + * Validate if a tool exists + */ + isValidTool(toolId: ToolName): boolean { + return this.availableTools.some((tool: Tool) => tool.id === toolId); + } + + /** + * Get default tool + */ + getDefaultTool(): ToolName { + return ToolName.CURSOR; + } + + /** + * Get tool name by ID + */ + getToolName(toolId: ToolName): string { + const tool = this.getToolById(toolId); + return tool?.name || 'Unknown Tool'; + } + + /** + * Check if tool is annotation tool (not cursor) + */ + isAnnotationTool(toolId: ToolName): boolean { + return toolId !== ToolName.CURSOR; + } + + /** + * Get annotation tools only + */ + getAnnotationTools(): Tool[] { + return this.availableTools.filter(tool => this.isAnnotationTool(tool.id)); + } + + /** + * Get UI tools only (cursor) + */ + getUITools(): Tool[] { + return this.availableTools.filter(tool => !this.isAnnotationTool(tool.id)); + } + + /** + * Validate and set active tool (returns valid tool or default) + */ + validateAndSetTool(requestedTool: ToolName): ToolName { + if (this.isValidTool(requestedTool)) { + return requestedTool; + } + return this.getDefaultTool(); + } + + /** + * Get cursor style for the given tool + */ + getToolCursor(toolName: ToolName): string { + return cursorManager.getToolCursor(toolName); + } + + /** + * Calculate cursor style based on tool and interaction state + */ + calculateCursorStyle( + activeTool: ToolName, + isDraggingHandle?: boolean, + isPanning?: boolean, + hoveredHandleIndex?: number, + hoveredAnnotationId?: number | null + ): string { + return cursorManager.calculateCanvasCursorStyle( + activeTool, + isDraggingHandle, + isPanning, + hoveredHandleIndex, + hoveredAnnotationId + ); + } + + /** + * Check if tool should show hover cursor + */ + shouldShowHoverCursor(toolName: ToolName): boolean { + return cursorManager.shouldShowHoverCursor(toolName); + } +} + +// Export singleton instance +export const toolManager = new ToolManager(); \ No newline at end of file From 2d267683f86f18e20fe2ce8f867713f53d9be24d Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 09:29:26 +0200 Subject: [PATCH 08/11] feat: Implement ViewportManager for managing viewport operations including zoom and pan --- .../src/core/workspace/ui/viewportManager.ts | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 frontend/src/core/workspace/ui/viewportManager.ts diff --git a/frontend/src/core/workspace/ui/viewportManager.ts b/frontend/src/core/workspace/ui/viewportManager.ts new file mode 100644 index 00000000..74d54ee1 --- /dev/null +++ b/frontend/src/core/workspace/ui/viewportManager.ts @@ -0,0 +1,234 @@ +import type { Point } from "@/core/geometry/geometry.types"; +import type { ImageDimensions } from "@/core/asset/asset.types"; + +/** + * Viewport configuration constants + */ +export const VIEWPORT_CONFIG = { + MIN_ZOOM: 0.1, + MAX_ZOOM: 10.0, + ZOOM_SENSITIVITY: 0.005, + DEFAULT_ZOOM: 1.0, + DEFAULT_OFFSET: { x: 0, y: 0 } +} as const; + +/** + * Viewport state interface + */ +export interface ViewportState { + viewOffset: Point; + zoomLevel: number; +} + +/** + * Zoom configuration + */ +export interface ZoomConfig { + minZoom: number; + maxZoom: number; + zoomSensitivity: number; +} + +/** + * ViewportManager handles all viewport-related operations including zoom and pan. + * This is a utility class that doesn't require reactivity. + */ +export class ViewportManager { + private readonly config = VIEWPORT_CONFIG; + + /** + * Get zoom configuration + */ + getZoomConfig(): ZoomConfig { + return { + minZoom: this.config.MIN_ZOOM, + maxZoom: this.config.MAX_ZOOM, + zoomSensitivity: this.config.ZOOM_SENSITIVITY, + }; + } + + /** + * Get initial viewport state + */ + getInitialViewportState(): ViewportState { + return { + viewOffset: { ...this.config.DEFAULT_OFFSET }, + zoomLevel: this.config.DEFAULT_ZOOM + }; + } + + /** + * Clamp zoom level to valid range + */ + clampZoom(level: number): number { + return Math.max(this.config.MIN_ZOOM, Math.min(level, this.config.MAX_ZOOM)); + } + + /** + * Validate and clamp viewport offset + */ + clampOffset(offset: Point): Point { + // TODO: For now, we don't clamp the offset to allow free panning + // This could be extended to clamp to image boundaries if needed + return { ...offset }; + } + + /** + * Calculate zoom level for fit-to-screen + */ + calculateFitToScreenZoom( + imageNaturalDimensions: ImageDimensions, + canvasDisplayDimensions: ImageDimensions + ): number { + if (!imageNaturalDimensions || !canvasDisplayDimensions) { + return this.config.DEFAULT_ZOOM; + } + + const scaleX = canvasDisplayDimensions.width / imageNaturalDimensions.width; + const scaleY = canvasDisplayDimensions.height / imageNaturalDimensions.height; + + // Use the smaller scale to ensure the entire image fits + const fitZoom = Math.min(scaleX, scaleY); + + return this.clampZoom(fitZoom); + } + + /** + * Calculate image aspect ratio + */ + calculateImageAspectRatio(imageNaturalDimensions: ImageDimensions | null): number | null { + if (!imageNaturalDimensions) { + return null; + } + return imageNaturalDimensions.width / imageNaturalDimensions.height; + } + + /** + * Apply zoom delta while preserving center point + */ + applyZoomDelta( + currentZoom: number, + zoomDelta: number, + center?: Point + ): { zoomLevel: number; viewOffset?: Point } { + const newZoom = this.clampZoom(currentZoom + zoomDelta); + + // If no center point provided, just return the new zoom level + if (!center) { + return { zoomLevel: newZoom }; + } + + // Calculate offset adjustment to maintain center point + // TODO: This would require current viewOffset and canvas dimensions to be fully implemented + // For now, return just the zoom level + return { zoomLevel: newZoom }; + } + + /** + * Reset viewport to initial state + */ + resetViewport(): ViewportState { + return this.getInitialViewportState(); + } + + /** + * Center image in viewport + */ + centerImage( + imageNaturalDimensions: ImageDimensions, + canvasDisplayDimensions: ImageDimensions, + currentZoom: number + ): Point { + if (!imageNaturalDimensions || !canvasDisplayDimensions) { + return { ...this.config.DEFAULT_OFFSET }; + } + + // Calculate the scaled image dimensions + const scaledImageWidth = imageNaturalDimensions.width * currentZoom; + const scaledImageHeight = imageNaturalDimensions.height * currentZoom; + + // Calculate centering offset + const offsetX = (canvasDisplayDimensions.width - scaledImageWidth) / 2; + const offsetY = (canvasDisplayDimensions.height - scaledImageHeight) / 2; + + return this.clampOffset({ x: offsetX, y: offsetY }); + } + + /** + * Apply pan delta to current offset + */ + applyPanDelta(currentOffset: Point, deltaX: number, deltaY: number): Point { + return this.clampOffset({ + x: currentOffset.x + deltaX, + y: currentOffset.y + deltaY + }); + } + + /** + * Convert screen coordinates to image coordinates + */ + screenToImageCoordinates( + screenPoint: Point, + viewOffset: Point, + zoomLevel: number + ): Point { + return { + x: (screenPoint.x - viewOffset.x) / zoomLevel, + y: (screenPoint.y - viewOffset.y) / zoomLevel + }; + } + + /** + * Convert image coordinates to screen coordinates + */ + imageToScreenCoordinates( + imagePoint: Point, + viewOffset: Point, + zoomLevel: number + ): Point { + return { + x: imagePoint.x * zoomLevel + viewOffset.x, + y: imagePoint.y * zoomLevel + viewOffset.y + }; + } + + /** + * Check if viewport state is valid + */ + isValidViewportState(state: Partial): boolean { + if (state.zoomLevel !== undefined) { + if (state.zoomLevel < this.config.MIN_ZOOM || state.zoomLevel > this.config.MAX_ZOOM) { + return false; + } + } + + // TODO: ViewOffset is always valid for now + return true; + } + + /** + * Get viewport bounds in image coordinates + */ + getViewportBounds( + viewOffset: Point, + zoomLevel: number, + canvasDisplayDimensions: ImageDimensions + ): { topLeft: Point; bottomRight: Point } { + const topLeft = this.screenToImageCoordinates( + { x: 0, y: 0 }, + viewOffset, + zoomLevel + ); + + const bottomRight = this.screenToImageCoordinates( + { x: canvasDisplayDimensions.width, y: canvasDisplayDimensions.height }, + viewOffset, + zoomLevel + ); + + return { topLeft, bottomRight }; + } +} + +// Export singleton instance +export const viewportManager = new ViewportManager(); \ No newline at end of file From 3a219ef64947ad8ae1d2642d474f8b208ab8a35b Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 09:29:32 +0200 Subject: [PATCH 09/11] fix: Correct export path for annotation and add ui exports for tool, viewport, and cursor managers --- frontend/src/core/workspace/index.ts | 5 +++-- frontend/src/core/workspace/ui/index.ts | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 frontend/src/core/workspace/ui/index.ts diff --git a/frontend/src/core/workspace/index.ts b/frontend/src/core/workspace/index.ts index 0fcdaf9c..0e22f94e 100644 --- a/frontend/src/core/workspace/index.ts +++ b/frontend/src/core/workspace/index.ts @@ -1,6 +1,7 @@ // Annotations -export * from '../annotations'; +export * from './annotation'; export * from '../asset'; export * from './geometry'; export * from './interaction'; -export * from './task'; \ No newline at end of file +export * from './task'; +export * from './ui'; \ No newline at end of file diff --git a/frontend/src/core/workspace/ui/index.ts b/frontend/src/core/workspace/ui/index.ts new file mode 100644 index 00000000..f06a8d20 --- /dev/null +++ b/frontend/src/core/workspace/ui/index.ts @@ -0,0 +1,3 @@ +export { toolManager } from './toolManager'; +export { viewportManager } from './viewportManager'; +export { cursorManager } from './cursorManager'; \ No newline at end of file From d0171cd1410079b961d726584bffce2c3b64fda7 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 09:29:39 +0200 Subject: [PATCH 10/11] refactor: Remove legacy availableTasks from WorkspaceState interface --- frontend/src/stores/workspaceStore.ts | 913 +++++++++----------- frontend/src/stores/workspaceStore.types.ts | 1 - 2 files changed, 403 insertions(+), 511 deletions(-) diff --git a/frontend/src/stores/workspaceStore.ts b/frontend/src/stores/workspaceStore.ts index 62e895f6..090ea017 100644 --- a/frontend/src/stores/workspaceStore.ts +++ b/frontend/src/stores/workspaceStore.ts @@ -1,22 +1,17 @@ import { defineStore } from "pinia"; -import { faArrowPointer, faDotCircle, faMinus, faWaveSquare, faSquare, faDrawPolygon } from '@fortawesome/free-solid-svg-icons'; import type { ImageDimensions } from "@/core/asset/asset.types"; import type { WorkspaceState, TaskNavigationContext } from "./workspaceStore.types"; import type { Point } from "@/core/geometry/geometry.types"; import { ToolName, type Tool } from "@/core/workspace/tools.types"; -import type { Annotation, CreateAnnotationDto } from '@/core/workspace/annotation.types'; +import type { Annotation } from '@/core/workspace/annotation.types'; import type { LabelScheme, Label } from '@/services/project/labelScheme/label.types'; -import { AssetManager } from '@/core/workspace'; import { TimeTracker } from '@/core/timeTracking'; -import { - annotationService, - labelSchemeService, - labelService, - taskService, - workflowService, - workflowStageService -} from '@/services/project'; -import type { Asset } from '@/core/asset/asset.types'; +import { workspaceLoader } from '@/core/workspace/loader/workspaceLoader'; +import { taskManager } from '@/core/workspace/task/taskManager'; +import { annotationDataManager } from '@/core/workspace/data/annotationDataManager'; +import { viewportManager } from '@/core/workspace/ui/viewportManager'; +import { toolManager } from '@/core/workspace/ui/toolManager'; +import { taskService } from '@/services/project'; import type { Task } from '@/services/project/task/task.types'; import { TaskStatus } from '@/services/project/task/task.types'; import { AppLogger } from '@/core/logger/logger'; @@ -24,15 +19,10 @@ import { WorkflowStageType } from '@/services/project/workflow/workflowStage.typ const logger = AppLogger.createServiceLogger('WorkspaceStore'); -const MIN_ZOOM = 0.1; -const MAX_ZOOM = 10.0; -const ZOOM_SENSITIVITY = 0.005; // Core managers -const assetManager = new AssetManager(); const timeTracker = new TimeTracker(); -// TODO: Refactor the store export const useWorkspaceStore = defineStore("workspace", { state: (): WorkspaceState => ({ @@ -48,14 +38,7 @@ export const useWorkspaceStore = defineStore("workspace", { viewOffset: { x: 0, y: 0 }, zoomLevel: 1.0, activeTool: ToolName.CURSOR, - availableTools: [ - { id: ToolName.CURSOR, name: 'Cursor', iconDefinition: faArrowPointer }, - { id: ToolName.POINT, name: 'Point', iconDefinition: faDotCircle }, - { id: ToolName.LINE, name: 'Line', iconDefinition: faMinus }, - { id: ToolName.BOUNDING_BOX, name: 'Bounding Box', iconDefinition: faSquare }, - { id: ToolName.POLYLINE, name: 'Polyline', iconDefinition: faWaveSquare }, - { id: ToolName.POLYGON, name: 'Polygon', iconDefinition: faDrawPolygon }, - ], + availableTools: toolManager.getAvailableTools(), annotations: [] as Annotation[], currentLabelId: null as number | null, currentLabelScheme: null as LabelScheme | null, @@ -63,7 +46,6 @@ export const useWorkspaceStore = defineStore("workspace", { currentTaskId: null as number | null, currentTaskData: null as Task | null, currentWorkflowStageType: null as WorkflowStageType | null, - availableTasks: [] as Task[], initialTaskId: null as number | null, // Store the initial task ID from URL query navigationContext: null as TaskNavigationContext | null, isLoading: false, @@ -72,30 +54,13 @@ export const useWorkspaceStore = defineStore("workspace", { getters: { getCurrentImageAspectRatio(): number | null { - if (this.imageNaturalDimensions) { - return ( - this.imageNaturalDimensions.width / - this.imageNaturalDimensions.height - ); - } - return null; - }, - getZoomConfig: () => ({ - minZoom: MIN_ZOOM, - maxZoom: MAX_ZOOM, - zoomSensitivity: ZOOM_SENSITIVITY, - }), - getActiveToolDetails(): Tool | undefined { - return this.availableTools.find((tool: Tool) => tool.id === this.activeTool); - }, - getAnnotations(state): Annotation[] { - return state.annotations; + return viewportManager.calculateImageAspectRatio(this.imageNaturalDimensions); }, - getSelectedLabelId(state): number | null { - return state.currentLabelId; + getZoomConfig() { + return viewportManager.getZoomConfig(); }, - getCurrentLabelScheme(state): LabelScheme | null { - return state.currentLabelScheme; + getActiveToolDetails(): Tool | undefined { + return toolManager.getToolById(this.activeTool); }, getLabelById(state): (labelId: number) => Label | undefined { return (labelId: number) => { @@ -105,7 +70,14 @@ export const useWorkspaceStore = defineStore("workspace", { return state.currentLabelScheme.labels.find((label: Label) => label.labelId === labelId); }; }, - getCurrentAsset(state): Asset | null { + // Simple state getters - for backward compatibility with components + getAnnotations(state): Annotation[] { + return state.annotations; + }, + getSelectedLabelId(state): number | null { + return state.currentLabelId; + }, + getCurrentAsset(state) { return state.currentAssetData; }, getLoadingState(state): boolean { @@ -114,23 +86,14 @@ export const useWorkspaceStore = defineStore("workspace", { getError(state): string | null { return state.error; }, - getAvailableLabels(state): Label[] { - return state.currentLabelScheme?.labels || []; - }, - getAvailableLabelSchemes(state): LabelScheme[] { - return state.availableLabelSchemes; - }, getCurrentTask(state): Task | null { return state.currentTaskData; }, - getAvailableTasks(state): Task[] { - return state.availableTasks; + getCurrentLabelScheme(state): LabelScheme | null { + return state.currentLabelScheme; }, - getCurrentTaskIndex(state): number { - if (!state.currentTaskData || state.availableTasks.length === 0) { - return -1; - } - return state.availableTasks.findIndex((task: Task) => task.id === state.currentTaskData?.id); + getAvailableLabels(state): Label[] { + return state.currentLabelScheme?.labels || []; }, getTaskNavigationInfo(): { current: number; total: number } { // Simple navigation info from server context @@ -144,15 +107,7 @@ export const useWorkspaceStore = defineStore("workspace", { return { current: 0, total: 0 }; }, isTaskCompleted(state): boolean { - if (!state.currentTaskData) return false; - - // Task is NOT considered completed (i.e., should be editable) if it has CHANGES_REQUIRED status - if (state.currentTaskData.status === TaskStatus.CHANGES_REQUIRED) { - return false; - } - - // Otherwise, check if task is actually completed - return state.currentTaskData.status === TaskStatus.COMPLETED; + return taskManager.isTaskCompleted(state.currentTaskData); }, isAnnotationEditingDisabled(): boolean { // Annotation editing is disabled when task is completed (preview mode) @@ -179,17 +134,10 @@ export const useWorkspaceStore = defineStore("workspace", { if (state.currentWorkflowStageType === WorkflowStageType.REVISION || state.currentWorkflowStageType === WorkflowStageType.COMPLETION) { // In revision/completion stages, check if there are annotations for this asset - // (created in previous annotation stages) - const assetAnnotations = state.annotations.filter((annotation: Annotation) => - annotation.assetId === state.currentAssetData?.id - ); - return assetAnnotations.length > 0; + return annotationDataManager.hasAnnotationsForAsset(state.annotations, state.currentAssetData.id); } else { // In annotation stages, check annotations for this specific task - const taskAnnotations = state.annotations.filter((annotation: Annotation) => - annotation.taskId === state.currentTaskData?.id - ); - return taskAnnotations.length > 0; + return annotationDataManager.hasAnnotationsForTask(state.annotations, state.currentTaskData.id); } }, canVetoCurrentTask(state): boolean { @@ -211,205 +159,189 @@ export const useWorkspaceStore = defineStore("workspace", { }, actions: { - async loadAsset(projectId: string, assetId: string, taskId?: string) { + /** + * Initialize workspace with clean state + */ + initializeWorkspaceState(projectId: string, assetId: string, taskId?: string) { this.currentProjectId = projectId; this.currentAssetId = assetId; this.isLoading = true; this.error = null; + + // Reset UI state to defaults + const initialViewport = viewportManager.getInitialViewportState(); + this.viewOffset = initialViewport.viewOffset; + this.zoomLevel = initialViewport.zoomLevel; + + // Clear data state + this.annotations = []; + this.currentLabelId = null; + this.currentTaskId = null; + this.currentTaskData = null; + this.currentWorkflowStageType = null; + this.initialTaskId = taskId ? parseInt(taskId, 10) : null; + this.navigationContext = null; + + // Clear asset state + this.currentAssetData = null; + this.currentImageUrl = null; + this.imageNaturalDimensions = null; + this.currentLabelScheme = null; + this.availableLabelSchemes = []; + }, + /** + * Load asset data using WorkspaceLoader + */ + async loadAssetData(projectId: string, assetId: string): Promise { try { - logger.info(`Loading asset ${assetId} for project ${projectId}`); - - // Use AssetManager to load the asset - const assetResult = await assetManager.loadAsset(projectId, assetId); + const assetData = await workspaceLoader.loadAssetData(projectId, assetId); - if (!assetResult.success) { - throw new Error(assetResult.error); + if (!assetData) { + this.error = 'Failed to load asset data'; + return false; } - // Set asset data from AssetManager result - this.currentAssetData = assetResult.asset; - this.currentImageUrl = assetResult.imageUrl; - this.imageNaturalDimensions = assetResult.naturalDimensions; - - logger.info(`Loaded asset data via AssetManager:`, assetResult.asset); - - // Convert IDs for other service calls (AssetManager already validated these) - const numericProjectId = parseInt(projectId, 10); - const numericAssetId = parseInt(assetId, 10); - - // Reset other workspace state - this.viewOffset = { x: 0, y: 0 }; - this.zoomLevel = 1; - this.annotations = []; - this.currentLabelId = null; - this.currentTaskId = null; - this.currentTaskData = null; - this.currentWorkflowStageType = null; - this.availableTasks = []; - this.initialTaskId = taskId ? parseInt(taskId, 10) : null; - - // Fetch annotations for this asset - try { - const fetchedAnnotations = await annotationService.getAnnotationsForAsset(numericProjectId, numericAssetId); - this.setAnnotations(fetchedAnnotations.data); - logger.info(`Loaded ${fetchedAnnotations.data.length} annotations for asset ${assetId}`); - } catch (annotationError) { - logger.error("Failed to fetch annotations:", annotationError); - this.setAnnotations([]); // Clear annotations on error - } + // Set asset data + this.currentAssetData = assetData.asset; + this.currentImageUrl = assetData.imageUrl; + this.imageNaturalDimensions = assetData.naturalDimensions; - // Fetch tasks - either by specific task ID or find task for this asset - try { - let currentTask: Task | null = null; - let workflowStageId: number | null = null; - - if (this.initialTaskId) { - // If we have a specific task ID (from URL query), load that task first - try { - currentTask = await taskService.getTaskById(numericProjectId, this.initialTaskId); - workflowStageId = currentTask.workflowStageId; - logger.info(`Loaded specific task ${this.initialTaskId} for stage ${workflowStageId}`); - } catch (taskByIdError) { - logger.warn(`Failed to load task ${this.initialTaskId}, falling back to asset tasks:`, taskByIdError); - } - } + logger.info(`Successfully loaded asset data for ${assetId}`); + return true; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to load asset data'; + logger.error(`Failed to load asset data for ${assetId}:`, error); + this.error = errorMessage; + return false; + } + }, - // If we don't have a current task yet, find task(s) for this asset - if (!currentTask) { - const assetTasks = await taskService.getTasksForAsset(numericProjectId, numericAssetId); - if (assetTasks.length > 0) { - currentTask = assetTasks[0]; - workflowStageId = currentTask?.workflowStageId || null; - logger.info(`Found asset task ${currentTask.id} for asset ${assetId} in stage ${workflowStageId}`); - } - } + /** + * Load annotations for current asset + */ + async loadAnnotations(): Promise { + if (!this.currentProjectId || !this.currentAssetId) return; + + const numericProjectId = parseInt(this.currentProjectId, 10); + const numericAssetId = parseInt(this.currentAssetId, 10); - if (currentTask && workflowStageId) { - // Load user's assigned tasks for this workflow stage to enable proper navigation - const assignedTasks = await taskService.getMyTasksForStage(numericProjectId, workflowStageId); - let tasksForNavigation = assignedTasks.tasks; - - // If current task is not in the assigned tasks list (e.g., completed task viewed in preview mode), - // add it to the list so the UI shows correct context, but navigation will still skip completed tasks - const currentTaskInList = tasksForNavigation.find(task => task.id === currentTask.id); - if (!currentTaskInList) { - tasksForNavigation = [...tasksForNavigation, currentTask]; - logger.info(`Added current task ${currentTask.id} to navigation context (likely viewing completed task in preview mode)`); - } - - this.availableTasks = tasksForNavigation; - this.currentTaskData = currentTask; - this.currentTaskId = currentTask.id; - - // Load navigation context from server - try { - const navigationContext = await taskService.getNavigationContext( - numericProjectId, - currentTask.id, - currentTask.workflowStageId - ); - this.navigationContext = { - hasNext: navigationContext.hasNext, - hasPrevious: navigationContext.hasPrevious, - currentPosition: navigationContext.currentPosition, - totalTasks: navigationContext.totalTasks, - message: navigationContext.message - }; - logger.info(`Loaded navigation context: ${navigationContext.currentPosition}/${navigationContext.totalTasks}, hasNext=${navigationContext.hasNext}, hasPrevious=${navigationContext.hasPrevious}`); - } catch (navigationError) { - logger.warn('Failed to load navigation context:', navigationError); - this.navigationContext = null; - } + const annotations = await workspaceLoader.loadAnnotationsForAsset(numericProjectId, numericAssetId); + this.setAnnotations(annotations); + logger.info(`Loaded ${annotations.length} annotations for asset ${this.currentAssetId}`); + }, - // Fetch current workflow stage type for completion logic - try { - const stageData = await workflowStageService.getWorkflowStageById( - numericProjectId, - currentTask.workflowId, - workflowStageId - ); - this.currentWorkflowStageType = stageData.stageType || null; - logger.info(`Loaded workflow stage type: ${this.currentWorkflowStageType} for stage ${workflowStageId}`); - } catch (stageError) { - logger.warn('Failed to fetch workflow stage type:', stageError); - this.currentWorkflowStageType = null; - } + /** + * Load tasks and navigation context for current asset + */ + async loadTasks(): Promise { + if (!this.currentProjectId || !this.currentAssetId) return; + + const numericProjectId = parseInt(this.currentProjectId, 10); + const numericAssetId = parseInt(this.currentAssetId, 10); - const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === this.currentTaskData?.id); - logger.info(`Loaded ${this.availableTasks.length} tasks for stage ${workflowStageId}, current task is at index ${taskIndex}`); - } else { - logger.warn(`No tasks found for asset ${assetId}`); - } - } catch (taskError) { - logger.error("Failed to fetch tasks:", taskError); - // Don't fail the entire load process if tasks fail - } + const tasksData = await workspaceLoader.loadTasksForAsset( + numericProjectId, + numericAssetId, + this.initialTaskId || undefined + ); - // Fetch label scheme from the workflow - try { - if (this.currentTaskData && this.currentTaskData.workflowId) { - // Get workflow to access its assigned label scheme - const workflow = await workflowService.getWorkflowById(numericProjectId, this.currentTaskData.workflowId); - - if (workflow.labelSchemeId) { - // Get the specific label scheme assigned to this workflow - const labelScheme = await labelSchemeService.getLabelSchemeById(numericProjectId, workflow.labelSchemeId); - - // Fetch labels for the scheme - try { - const labelsResponse = await labelService.getLabelsForScheme(numericProjectId, labelScheme.labelSchemeId); - labelScheme.labels = labelsResponse.data; - - // Set this as the only available scheme (no switching allowed) - this.availableLabelSchemes = [labelScheme]; - this.setCurrentLabelScheme(labelScheme); - logger.info(`Loaded workflow label scheme: ${labelScheme.name} with ${labelsResponse.data.length} labels`); - } catch (labelsError) { - logger.error("Failed to fetch labels for workflow scheme:", labelsError); - this.availableLabelSchemes = [labelScheme]; - this.setCurrentLabelScheme(labelScheme); // Set scheme even without labels - } - } else { - logger.warn(`Workflow ${this.currentTaskData.workflowId} has no assigned label scheme`); - this.availableLabelSchemes = []; - this.setCurrentLabelScheme(null); - } - } else { - logger.warn('No current task with workflow information available for label scheme loading'); - this.availableLabelSchemes = []; - this.setCurrentLabelScheme(null); - } - } catch (labelSchemeError) { - logger.error("Failed to fetch workflow label scheme:", labelSchemeError); - this.availableLabelSchemes = []; - this.setCurrentLabelScheme(null); + // Update task state + this.currentTaskData = tasksData.currentTask; + this.currentTaskId = tasksData.currentTask?.id || null; + this.currentWorkflowStageType = tasksData.workflowStageType; + this.navigationContext = tasksData.navigationContext; + + if (tasksData.currentTask) { + logger.info(`Loaded task ${tasksData.currentTask.id}`); + } + }, + + /** + * Load label scheme for current workflow + */ + async loadLabelScheme(): Promise { + if (!this.currentProjectId || !this.currentTaskData) return; + + const numericProjectId = parseInt(this.currentProjectId, 10); + + const labelSchemeData = await workspaceLoader.loadLabelScheme(numericProjectId, this.currentTaskData); + + // Update label scheme state + this.currentLabelScheme = labelSchemeData.labelScheme; + this.availableLabelSchemes = labelSchemeData.availableSchemes; + + if (labelSchemeData.labelScheme) { + logger.info(`Loaded label scheme: ${labelSchemeData.labelScheme.name}`); + } + }, + + /** + * Complete workspace initialization + */ + finalizeWorkspaceLoad(): void { + // Start timer and set cursor tool + this.startTimer(); + this.setActiveTool(toolManager.getDefaultTool()); + this.isLoading = false; + }, + + /** + * Handle workspace load error + */ + handleWorkspaceLoadError(error: unknown, assetId: string): void { + this.isLoading = false; + this.error = error instanceof Error ? error.message : 'Failed to load asset'; + logger.error(`Failed to load asset ${assetId}:`, error); + + // Reset state on error + this.currentAssetData = null; + this.currentImageUrl = null; + this.imageNaturalDimensions = null; + this.annotations = []; + this.currentLabelScheme = null; + this.availableLabelSchemes = []; + this.currentLabelId = null; + this.currentTaskId = null; + this.currentTaskData = null; + this.currentWorkflowStageType = null; + this.initialTaskId = null; + this.navigationContext = null; + }, + + /** + * Main entry point for loading workspace data + * Orchestrates the loading of asset, annotations, tasks, and labels + */ + async loadAsset(projectId: string, assetId: string, taskId?: string) { + logger.info(`Loading asset ${assetId} for project ${projectId}`); + + try { + // Step 1: Initialize clean state + this.initializeWorkspaceState(projectId, assetId, taskId); + + // Step 2: Load asset data + const assetLoaded = await this.loadAssetData(projectId, assetId); + if (!assetLoaded) { + this.handleWorkspaceLoadError(new Error('Failed to load asset data'), assetId); + return; } - // Start timer and set cursor tool - this.startTimer(); - this.setActiveTool(ToolName.CURSOR); + // Step 3: Load all workspace data in parallel + await Promise.allSettled([ + this.loadAnnotations(), + this.loadTasks() + ]); + + // Step 4: Load label scheme (depends on tasks being loaded first) + await this.loadLabelScheme(); + + // Step 5: Finalize workspace + this.finalizeWorkspaceLoad(); - this.isLoading = false; logger.info(`Successfully loaded asset ${assetId} for project ${projectId}`); } catch (error) { - this.isLoading = false; - this.error = error instanceof Error ? error.message : 'Failed to load asset'; - logger.error(`Failed to load asset ${assetId} for project ${projectId}:`, error); - - // Reset state on error - this.currentAssetData = null; - this.currentImageUrl = null; - this.imageNaturalDimensions = null; - this.annotations = []; - this.currentLabelScheme = null; - this.availableLabelSchemes = []; - this.currentLabelId = null; - this.currentTaskId = null; - this.currentTaskData = null; - this.currentWorkflowStageType = null; - this.availableTasks = []; - this.initialTaskId = null; + this.handleWorkspaceLoadError(error, assetId); } }, @@ -429,56 +361,58 @@ export const useWorkspaceStore = defineStore("workspace", { * Adds a new annotation to the current list and saves it to the backend. */ async addAnnotation(annotation: Annotation) { + if (!this.currentProjectId) { + this.error = 'No current project'; + return; + } + // Add annotation to the store immediately for optimistic updates - this.annotations.push(annotation); + this.annotations = annotationDataManager.addAnnotationToList(this.annotations, annotation); try { - // Convert Annotation to CreateAnnotationDto - const createDto: CreateAnnotationDto = { - annotationType: annotation.annotationType, - data: annotation.data || (annotation.coordinates ? JSON.stringify(annotation.coordinates) : '{}'), - taskId: annotation.taskId, - assetId: annotation.assetId, - labelId: annotation.labelId, - isPrediction: annotation.isPrediction || false, - confidenceScore: annotation.confidenceScore, - isGroundTruth: annotation.isGroundTruth, - version: annotation.version || 1, - notes: annotation.notes, - annotatorEmail: annotation.annotatorEmail, - parentAnnotationId: annotation.parentAnnotationId - }; + // Save annotation to backend using annotationDataManager + const result = await annotationDataManager.createAnnotation( + parseInt(this.currentProjectId), + annotation + ); - // Save annotation to backend - const savedAnnotation = await annotationService.createAnnotation(parseInt(this.currentProjectId!), createDto); - + if (!result.success) { + throw new Error(result.error); + } + + const savedAnnotation = result.data; - // Update the annotation in the store with the saved data (including ID) - // First try to find by clientId - let index = this.annotations.findIndex((a: Annotation) => a.clientId === annotation.clientId); + // Update the annotation in the store with the saved data + let index = annotationDataManager.findAnnotationByClientId(this.annotations, annotation.clientId!); if (index !== -1) { - // Preserve the clientId when updating - this.annotations[index] = { ...savedAnnotation, clientId: annotation.clientId }; + // Update by clientId + this.annotations = annotationDataManager.updateAnnotationInList( + this.annotations, + index, + savedAnnotation, + annotation.clientId + ); logger.debug(`Updated annotation by clientId: ${annotation.clientId}`); } else { - // If clientId doesn't match, find by coordinates and other properties - index = this.annotations.findIndex((a: Annotation) => - !a.annotationId && // Only match unsaved annotations - a.assetId === savedAnnotation.assetId && - a.labelId === savedAnnotation.labelId && - a.annotationType === savedAnnotation.annotationType && - JSON.stringify(a.coordinates) === JSON.stringify(savedAnnotation.coordinates) - ); + // Try finding by properties as fallback + index = annotationDataManager.findAnnotationByProperties(this.annotations, annotation); if (index !== -1) { - // Preserve the original clientId - this.annotations[index] = { ...savedAnnotation, clientId: this.annotations[index].clientId }; - logger.debug(`Updated annotation by coordinates match`); + this.annotations = annotationDataManager.updateAnnotationInList( + this.annotations, + index, + savedAnnotation, + this.annotations[index].clientId + ); + logger.debug(`Updated annotation by properties match`); } else { logger.warn(`Could not find matching annotation to update after save`); // Add as new annotation if we can't find a match - this.annotations.push({ ...savedAnnotation, clientId: annotation.clientId }); + this.annotations = annotationDataManager.addAnnotationToList( + this.annotations, + { ...savedAnnotation, clientId: annotation.clientId } + ); } } @@ -488,7 +422,10 @@ export const useWorkspaceStore = defineStore("workspace", { logger.error("Failed to save annotation:", error); // Remove the annotation from the store on failure - this.annotations = this.annotations.filter((a: Annotation) => a.clientId !== annotation.clientId); + this.annotations = annotationDataManager.removeAnnotationByClientId( + this.annotations, + annotation.clientId! + ); // Set error state this.error = error instanceof Error ? error.message : 'Failed to save annotation'; @@ -499,6 +436,11 @@ export const useWorkspaceStore = defineStore("workspace", { * Updates an existing annotation */ async updateAnnotation(annotationId: number, updates: Partial) { + if (!this.currentProjectId) { + this.error = 'No current project'; + return; + } + const index = this.annotations.findIndex((a: Annotation) => a.annotationId === annotationId); if (index === -1) { logger.error(`Annotation with ID ${annotationId} not found in store`); @@ -512,22 +454,19 @@ export const useWorkspaceStore = defineStore("workspace", { this.annotations[index] = { ...this.annotations[index], ...updates }; try { - // Prepare update payload for backend - const updatePayload: any = {}; - if (updates.annotationType) updatePayload.annotationType = updates.annotationType; - if (updates.coordinates) updatePayload.data = JSON.stringify(updates.coordinates); - if (updates.isPrediction !== undefined) updatePayload.isPrediction = updates.isPrediction; - if (updates.confidenceScore !== undefined) updatePayload.confidenceScore = updates.confidenceScore; - if (updates.isGroundTruth !== undefined) updatePayload.isGroundTruth = updates.isGroundTruth; - if (updates.notes !== undefined) updatePayload.notes = updates.notes; - if (updates.labelId !== undefined) updatePayload.labelId = updates.labelId; - - // Update annotation on backend - const updatedAnnotation = await annotationService.updateAnnotation(parseInt(this.currentProjectId!), annotationId, updatePayload); - + // Update annotation on backend using annotationDataManager + const result = await annotationDataManager.updateAnnotation( + parseInt(this.currentProjectId), + annotationId, + updates + ); + + if (!result.success) { + throw new Error(result.error); + } // Update the annotation in the store with the response from backend - this.annotations[index] = updatedAnnotation; + this.annotations[index] = result.data; logger.info(`Successfully updated annotation with ID: ${annotationId}`); @@ -546,6 +485,11 @@ export const useWorkspaceStore = defineStore("workspace", { * Deletes an annotation */ async deleteAnnotation(annotationId: number) { + if (!this.currentProjectId) { + this.error = 'No current project'; + return; + } + const index = this.annotations.findIndex((a: Annotation) => a.annotationId === annotationId); if (index === -1) { logger.error(`Annotation with ID ${annotationId} not found in store`); @@ -556,12 +500,18 @@ export const useWorkspaceStore = defineStore("workspace", { const deletedAnnotation = this.annotations[index]; // Optimistically remove the annotation from the store - this.annotations.splice(index, 1); + this.annotations = annotationDataManager.removeAnnotationById(this.annotations, annotationId); try { - // Delete annotation on backend - await annotationService.deleteAnnotation(parseInt(this.currentProjectId!), annotationId); - + // Delete annotation on backend using annotationDataManager + const result = await annotationDataManager.deleteAnnotation( + parseInt(this.currentProjectId), + annotationId + ); + + if (!result.success) { + throw new Error(result.error); + } logger.info(`Successfully deleted annotation with ID: ${annotationId}`); @@ -680,18 +630,15 @@ export const useWorkspaceStore = defineStore("workspace", { }, setViewOffset(offset: Point) { - this.viewOffset = offset; + this.viewOffset = viewportManager.clampOffset(offset); }, setZoomLevel(level: number) { - this.zoomLevel = Math.max(MIN_ZOOM, Math.min(level, MAX_ZOOM)); + this.zoomLevel = viewportManager.clampZoom(level); }, setActiveTool(toolId: ToolName) { - const toolExists = this.availableTools.some((tool: Tool) => tool.id === toolId); - if (toolExists) { - this.activeTool = toolId; - } + this.activeTool = toolManager.validateAndSetTool(toolId); }, clearError() { @@ -724,10 +671,7 @@ export const useWorkspaceStore = defineStore("workspace", { _shouldTrackTime(): boolean { - // Only track time for active, non-completed tasks - return !!(this.currentTaskData && - !this.currentTaskData.completedAt && - !this.currentTaskData.archivedAt); + return taskManager.shouldTrackTime(this.currentTaskData); }, @@ -794,8 +738,7 @@ export const useWorkspaceStore = defineStore("workspace", { * Check if a task can be opened by the current user */ async canOpenTask(task: Task): Promise { - // Simple check - most tasks can be opened, except archived - return task.status !== TaskStatus.ARCHIVED; + return taskManager.canOpenTask(task); }, /** @@ -807,37 +750,39 @@ export const useWorkspaceStore = defineStore("workspace", { return null; } - try { - const navigation = await taskService.getPreviousTask( - parseInt(this.currentProjectId), - this.currentTaskData.id, - this.currentTaskData.workflowStageId - ); + const result = await taskManager.navigateToPreviousTask( + parseInt(this.currentProjectId), + this.currentTaskData.id, + this.currentTaskData.workflowStageId + ); - if (navigation.taskId && navigation.assetId) { - logger.info('Successfully navigated to previous task:', navigation); - // Update navigation context - this.navigationContext = { - hasNext: navigation.hasNext, - hasPrevious: navigation.hasPrevious, - currentPosition: navigation.currentPosition, - totalTasks: navigation.totalTasks, - message: navigation.message - }; - - return { - projectId: this.currentProjectId, - assetId: navigation.assetId.toString(), - taskId: navigation.taskId.toString() - }; - } - - logger.info('No previous task available'); - return null; - } catch (error) { - logger.error('Failed to navigate to previous task:', error); + if (!result.success) { + logger.error('Failed to navigate to previous task:', result.error); return null; } + + const navigation = result.data; + + if (navigation.taskId && navigation.assetId) { + logger.info('Successfully navigated to previous task:', navigation); + // Update navigation context + this.navigationContext = { + hasNext: navigation.hasNext, + hasPrevious: navigation.hasPrevious, + currentPosition: navigation.currentPosition, + totalTasks: navigation.totalTasks, + message: navigation.message + }; + + return { + projectId: this.currentProjectId, + assetId: navigation.assetId.toString(), + taskId: navigation.taskId.toString() + }; + } + + logger.info('No previous task available'); + return null; }, /** @@ -849,37 +794,39 @@ export const useWorkspaceStore = defineStore("workspace", { return null; } - try { - const navigation = await taskService.getNextTask( - parseInt(this.currentProjectId), - this.currentTaskData.id, - this.currentTaskData.workflowStageId - ); - - if (navigation.taskId && navigation.assetId) { - logger.info('Successfully navigated to next task:', navigation); - // Update navigation context - this.navigationContext = { - hasNext: navigation.hasNext, - hasPrevious: navigation.hasPrevious, - currentPosition: navigation.currentPosition, - totalTasks: navigation.totalTasks, - message: navigation.message - }; - - return { - projectId: this.currentProjectId, - assetId: navigation.assetId.toString(), - taskId: navigation.taskId.toString() - }; - } + const result = await taskManager.navigateToNextTask( + parseInt(this.currentProjectId), + this.currentTaskData.id, + this.currentTaskData.workflowStageId + ); - logger.info('No next task available'); - return null; - } catch (error) { - logger.error('Failed to navigate to next task:', error); + if (!result.success) { + logger.error('Failed to navigate to next task:', result.error); return null; } + + const navigation = result.data; + + if (navigation.taskId && navigation.assetId) { + logger.info('Successfully navigated to next task:', navigation); + // Update navigation context + this.navigationContext = { + hasNext: navigation.hasNext, + hasPrevious: navigation.hasPrevious, + currentPosition: navigation.currentPosition, + totalTasks: navigation.totalTasks, + message: navigation.message + }; + + return { + projectId: this.currentProjectId, + assetId: navigation.assetId.toString(), + taskId: navigation.taskId.toString() + }; + } + + logger.info('No next task available'); + return null; }, /** @@ -891,34 +838,29 @@ export const useWorkspaceStore = defineStore("workspace", { return false; } - try { - const numericProjectId = parseInt(this.currentProjectId); - - // Use TaskService pipeline to complete the task - const result = await taskService.completeTaskPipeline(numericProjectId, this.currentTaskData.id); - - if (!result.isSuccess) { - this.error = result.errorMessage || 'Failed to complete task'; - return false; - } - - // Update current task data - if (result.updatedTask) { - this.currentTaskData = result.updatedTask; - - // Update the task in the available tasks list - const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.updatedTask!.id); - if (taskIndex !== -1) { - this.availableTasks[taskIndex] = result.updatedTask; - } - } + const result = await taskManager.completeTaskPipeline( + parseInt(this.currentProjectId), + this.currentTaskData.id + ); - return true; - } catch (error) { - logger.error('Failed to complete task:', error); - this.error = error instanceof Error ? error.message : 'Failed to complete task'; + if (!result.success) { + this.error = result.error; + return false; + } + + const pipelineResult = result.data; + + if (!pipelineResult.isSuccess) { + this.error = pipelineResult.errorMessage || 'Failed to complete task'; return false; } + + // Update current task data + if (pipelineResult.updatedTask) { + this.currentTaskData = pipelineResult.updatedTask; + } + + return true; }, /** @@ -930,39 +872,23 @@ export const useWorkspaceStore = defineStore("workspace", { return false; } - try { - const numericProjectId = parseInt(this.currentProjectId); + const numericProjectId = parseInt(this.currentProjectId); - // Update working time before suspending - await taskService.updateWorkingTime(numericProjectId, this.currentTaskData.id, this.lastSavedWorkingTime); - - // Use TaskService to suspend the task - const result = await taskService.changeTaskStatus( - numericProjectId, - this.currentTaskData.id, - { targetStatus: TaskStatus.SUSPENDED } - ); - - if (!result) { - this.error = 'Failed to suspend task'; - return false; - } - - // Update current task data - this.currentTaskData = result; - - // Update the task in the available tasks list - const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.id); - if (taskIndex !== -1) { - this.availableTasks[taskIndex] = result; - } - - return true; - } catch (error) { - logger.error('Failed to suspend task:', error); - this.error = error instanceof Error ? error.message : 'Failed to suspend task'; + // Update working time before suspending + await taskManager.updateWorkingTime(numericProjectId, this.currentTaskData.id, this.lastSavedWorkingTime); + + // Suspend the task using taskManager + const result = await taskManager.suspendTask(numericProjectId, this.currentTaskData.id); + + if (!result.success) { + this.error = result.error; return false; } + + // Update current task data + this.currentTaskData = result.data; + + return true; }, /** @@ -974,39 +900,23 @@ export const useWorkspaceStore = defineStore("workspace", { return false; } - try { - const numericProjectId = parseInt(this.currentProjectId); - - // Update working time before deferring - await taskService.updateWorkingTime(numericProjectId, this.currentTaskData.id, this.lastSavedWorkingTime); - - // Use TaskService to defer the task - const result = await taskService.changeTaskStatus( - numericProjectId, - this.currentTaskData.id, - { targetStatus: TaskStatus.DEFERRED } - ); - - if (!result) { - this.error = 'Failed to defer task'; - return false; - } - - // Update current task data - this.currentTaskData = result; - - // Update the task in the available tasks list - const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.id); - if (taskIndex !== -1) { - this.availableTasks[taskIndex] = result; - } - - return true; - } catch (error) { - logger.error('Failed to defer task:', error); - this.error = error instanceof Error ? error.message : 'Failed to defer task'; + const numericProjectId = parseInt(this.currentProjectId); + + // Update working time before deferring + await taskManager.updateWorkingTime(numericProjectId, this.currentTaskData.id, this.lastSavedWorkingTime); + + // Defer the task using taskManager + const result = await taskManager.deferTask(numericProjectId, this.currentTaskData.id); + + if (!result.success) { + this.error = result.error; return false; } + + // Update current task data + this.currentTaskData = result.data; + + return true; }, /** @@ -1018,38 +928,30 @@ export const useWorkspaceStore = defineStore("workspace", { return false; } - try { - const numericProjectId = parseInt(this.currentProjectId); - - // Use TaskService pipeline to veto the task - const result = await taskService.vetoTaskPipeline( - numericProjectId, - this.currentTaskData.id, - { reason: reason || 'Task returned for rework' } - ); - - if (!result.isSuccess) { - this.error = result.errorMessage || 'Failed to return task for rework'; - return false; - } - - // Update current task data - if (result.updatedTask) { - this.currentTaskData = result.updatedTask; - - // Update the task in the available tasks list - const taskIndex = this.availableTasks.findIndex((task: Task) => task.id === result.updatedTask!.id); - if (taskIndex !== -1) { - this.availableTasks[taskIndex] = result.updatedTask; - } - } + const result = await taskManager.vetoTaskPipeline( + parseInt(this.currentProjectId), + this.currentTaskData.id, + reason + ); + + if (!result.success) { + this.error = result.error; + return false; + } - return true; - } catch (error) { - logger.error('Failed to return task for rework:', error); - this.error = error instanceof Error ? error.message : 'Failed to return task for rework'; + const pipelineResult = result.data; + + if (!pipelineResult.isSuccess) { + this.error = pipelineResult.errorMessage || 'Failed to return task for rework'; return false; } + + // Update current task data + if (pipelineResult.updatedTask) { + this.currentTaskData = pipelineResult.updatedTask; + } + + return true; }, /** @@ -1065,19 +967,10 @@ export const useWorkspaceStore = defineStore("workspace", { * This is used when auto-transitioning to the next task after completion */ async assignAndStartNextTask(projectId: number, taskId: number): Promise { - try { - // First assign the task to current user - await taskService.assignTaskToCurrentUser(projectId, taskId); - - // Then change status to IN_PROGRESS if not already - await taskService.changeTaskStatus(projectId, taskId, { - targetStatus: TaskStatus.IN_PROGRESS - }); - - logger.info(`Successfully assigned and started task ${taskId} for project ${projectId}`); - } catch (error) { - logger.error(`Failed to assign and start task ${taskId}:`, error); - throw error; + const result = await taskManager.assignAndStartTask(projectId, taskId); + + if (!result.success) { + throw new Error(result.error); } }, diff --git a/frontend/src/stores/workspaceStore.types.ts b/frontend/src/stores/workspaceStore.types.ts index 10479c33..275861f3 100644 --- a/frontend/src/stores/workspaceStore.types.ts +++ b/frontend/src/stores/workspaceStore.types.ts @@ -54,7 +54,6 @@ export interface WorkspaceState { currentTaskId: number | null; currentTaskData: Task | null; currentWorkflowStageType: WorkflowStageType | null; - availableTasks: Task[]; // Legacy - will be removed initialTaskId: number | null; // Simple navigation context From bee8dfe2dc136b85f8f2725ea096b76a72939b59 Mon Sep 17 00:00:00 2001 From: Cemonix Date: Sun, 7 Sep 2025 09:29:54 +0200 Subject: [PATCH 11/11] feat: Enhance task completion flow by saving working time and ensuring proper navigation to the next task --- .../annotationWorkspace/AnnotationCanvas.vue | 6 +++--- .../src/components/project/settings/MembersSection.vue | 1 - frontend/src/views/AnnotationWorkspace.vue | 10 ++++++++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/annotationWorkspace/AnnotationCanvas.vue b/frontend/src/components/annotationWorkspace/AnnotationCanvas.vue index 244f6293..6d5a1c07 100644 --- a/frontend/src/components/annotationWorkspace/AnnotationCanvas.vue +++ b/frontend/src/components/annotationWorkspace/AnnotationCanvas.vue @@ -36,11 +36,11 @@ import { mouseToImageCoordinates, calculateZoomFromWheel, calculateZoomViewOffset, - calculateCanvasCursorStyle, renderAnnotation, type AnnotationRenderContext, getAnnotationDisplayColor, - separateAnnotationsBySelection + separateAnnotationsBySelection, + cursorManager } from "@/core/workspace"; import {StoreError, ToolError} from "@/core/errors/errors"; import AlertModal from "../common/modal/AlertModal.vue"; @@ -87,7 +87,7 @@ const annotationsToRender = computed(() => workspaceStore.getAnnotations); const isAnnotationEditingDisabled = computed(() => workspaceStore.isAnnotationEditingDisabled); const canvasCursorStyle = computed(() => { - return calculateCanvasCursorStyle( + return cursorManager.calculateCanvasCursorStyle( activeTool.value, isDraggingHandle.value, isPanning.value, diff --git a/frontend/src/components/project/settings/MembersSection.vue b/frontend/src/components/project/settings/MembersSection.vue index cfbf1212..3b769751 100644 --- a/frontend/src/components/project/settings/MembersSection.vue +++ b/frontend/src/components/project/settings/MembersSection.vue @@ -165,7 +165,6 @@