diff --git a/.gitignore b/.gitignore index 1b68e4d..2fc9aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,4 @@ yarn-error.log* tsconfig.tsbuildinfo # env files (copy .env.example) -.env +.env \ No newline at end of file diff --git a/Graph.md b/Graph.md new file mode 100644 index 0000000..dff6465 --- /dev/null +++ b/Graph.md @@ -0,0 +1,220 @@ +# Graph Module Documentation + +This module provides a visual editor for Graphs built with **Cytoscape.js** and **Paper.js**. It bridges a **Python-style backend** (deeply nested objects) and a **TypeScript/Zod frontend** (restricted to 2-level JSON nesting via pipe-delimited strings). + +--- + +## 1. The Core Data Structures + +### Frontend Schema (`Graph`) + +The full graph type used internally by the editor. Defined as a Zod schema in `type.ts`. + +```typescript +type Graph = { + nodes: Array<{ + id: string; + label?: string; + x?: number; + y?: number; + metadata: Record; + }>; + edges: Array<{ + source: string; + target: string; + weight?: number; // currently unused + label?: string; + id?: string; + metadata: Record; + }>; + directed: boolean; + weighted: boolean; // currently unused + multigraph: boolean; // currently unused + metadata: Record; +} +``` + +### Flattened Schema (`SimpleGraph`) + +To satisfy the `jsonNestedSchema` (2-level nesting limit, Shimmy communication issues), nodes and edges are serialised as pipe-delimited strings. + +```typescript +type SimpleGraph = { + nodes: string[]; // Format: "id|label|x|y" + edges: string[]; // Format: "source|target|weight|label" + directed: boolean; + weighted: boolean; + multigraph: boolean; + evaluation_type: string[]; +} +``` + +### Config Schema (`GraphConfig`) + +Teacher-configured parameters stored separately in `config`, **not** in the answer. `evaluation_type` is a plain string (the backend Pydantic `EvaluationParams` expects a string, not an array). + +```typescript +type GraphConfig = { + directed: boolean; + weighted: boolean; + multigraph: boolean; + evaluation_type: string; // e.g. 'connectivity', 'isomorphism', ... +} +``` + +### Answer Schema (`GraphAnswer`) + +Topology-only answer — config flags live in `GraphConfig`, not here. + +```typescript +type GraphAnswer = { + nodes: string[]; // pipe-delimited node strings + edges: string[]; // pipe-delimited edge strings +} +``` + +### Validation & Feedback Types + +```typescript +enum CheckPhase { Idle = 'idle', Evaluated = 'evaluated' } + +interface ValidationError { + type: 'error' | 'warning' + message: string + field?: string +} + +interface GraphFeedback { + valid: boolean + errors: ValidationError[] + phase: CheckPhase +} +``` + +--- + +## 2. File Structure + +``` +src/types/Graph/ + Graph.component.tsx # GraphEditor — Cytoscape + Paper.js visual editor (panel inlined) + Graph.component.styles.ts # makeStyles (emotion/tss-react) styles for GraphEditor + index.tsx # GraphResponseAreaTub, WizardPanel, InputComponent + type.ts # All Zod schemas, types, and conversion utilities + components/ + ConfigPanel.tsx # Teacher config UI (graph type, evaluation type) + ConfigPanel.styles.ts # makeStyles styles for ConfigPanel +``` + +> **Note:** Styles use `makeStyles` from `@styles` (tss-react/emotion) rather than CSS modules. This is required because the project builds as an IIFE library — CSS modules are extracted into a separate file that may not be loaded by the consuming app, while emotion injects styles at runtime inside the JS bundle. + +--- + +## 3. Key Components + +### `Graph.component.tsx` — `GraphEditor` + +The primary visual editor. + +- **Rendering**: Uses **Cytoscape.js** for graph rendering and interaction. +- **Draw Mode**: Uses **Paper.js** (overlaid canvas) for freehand drawing: + - **Draw a circle** → creates a new node at the circle's centre. + - **Draw a line between nodes** → creates a new edge between the two closest nodes. + - **Click two nodes** (while in draw mode) → creates an edge between them. +- **Selection**: Clicking a node or edge selects it; its properties appear in the inlined **Item Properties** side panel. +- **Sync**: Every mutation (add/delete/edit) calls `syncToGraph()`, which reads Cytoscape state and fires `onChange(graph)`. + +### `components/ConfigPanel.tsx` + +Teacher-facing configuration panel (rendered in `WizardComponent` only). + +- Toggle **Directed / Undirected**. +- Select an **Evaluation Type** (e.g. `isomorphism`, `connectivity`, `tree`, ...). +- Accepts an optional `AnswerPanel?: React.ReactNode` prop, rendered below the evaluation-type selector when `isomorphism` is selected. +- Styles are extracted to `ConfigPanel.styles.ts`. + +### Item Properties Panel (inlined in `Graph.component.tsx`) + +Left side panel rendered directly inside `GraphEditor` for editing selected elements: + +- **Add Node** button. +- **Fit to Screen** button. +- **Draw Edge** toggle (activates draw mode). +- Edit **Display Name** of a selected node. +- Edit **Edge Label** of a selected edge. +- **Delete** button for nodes and edges. + +--- + +## 4. Transformation Logic + +Conversion utilities in `type.ts` handle the boundary between the rich editor format and the flattened wire format. + +| Function | Source | Target | +|---|---|---| +| `toSimpleGraph(graph, evaluationType?)` | `Graph` | `SimpleGraph` | +| `fromSimpleGraph(simple)` | `SimpleGraph` | `Graph` | +| `graphAnswerToSimple(answer, config)` | `GraphAnswer` + `GraphConfig` | `SimpleGraph` | +| `simpleToAnswer(simple)` | `SimpleGraph` | `GraphAnswer` | + +--- + +## 5. Usage in the Pipeline + +1. **Load**: Data arrives from the backend as a flattened answer object (nodes + edges + config flags merged together). +2. **Convert**: `fromSimpleGraph(graphAnswerToSimple(answer, config))` reconstructs the rich `Graph` for the editor. +3. **Edit**: The user interacts with `GraphEditor`. Internal state stays in the rich `Graph` format. +4. **Save**: On change, `simpleToAnswer(toSimpleGraph(graph))` flattens the topology back. Config flags are merged into the answer object before sending to the backend: + ```typescript + const flatAnswer = { + ...answer, + directed: config.directed, + weighted: config.weighted, + multigraph: config.multigraph, + evaluation_type: config.evaluation_type, + } + ``` + +### Legacy migration + +`extractConfig` and `extractAnswer` in `GraphResponseAreaTub` handle two legacy cases: +- `evaluation_type` stored as `string[]` (old format) — first element is taken. +- Config flags flattened directly into the answer object (old format) — flags are read from there and a proper `GraphConfig` is reconstructed. + +--- + +## 6. Important Implementation Notes + +- **Node IDs**: Auto-generated as `n0`, `n1`, `n2`, ... The counter tracks the highest existing ID to avoid duplicates on reload. +- **Edge IDs**: Generated as `` `e-${source}-${target}-${Date.now()}` `` to guarantee uniqueness, including in multigraphs. +- **Config is flattened into answer**: The backend reads `directed`, `weighted`, `multigraph`, and `evaluation_type` from the answer object, not a separate config field. This is handled in `WizardPanel.onChange`. +- **Stable sub-components**: `WizardPanel` is defined outside the `GraphResponseAreaTub` class to prevent React from treating it as a new component type on re-render, which would unmount/remount `GraphEditor` and destroy Cytoscape state. +- **Cytoscape vs Paper.js layering**: Paper.js canvas sits on top of Cytoscape (`zIndex: 10`) with `pointerEvents: none` when not in draw mode, and `pointerEvents: auto` + `cursor: crosshair` when draw mode is active. +- **Arrow direction**: The Cytoscape edge style `target-arrow-shape` is reactively updated whenever `graph.directed` changes. +- **Isomorphism mode**: When `evaluation_type === 'isomorphism'`, `WizardPanel` renders a second `GraphEditor` for the teacher to define the reference graph. +- **Initial emit**: `WizardPanel` emits `onChange` on mount so config is always persisted to the DB even if the teacher never interacts with it. + +--- + +## 7. Supported Evaluation Types + +``` +isomorphism, connectivity, bipartite, cycle_detection, +graph_coloring, planarity, tree, forest, dag, eulerian, +semi_eulerian, regular, complete, degree_sequence, +subgraph, hamiltonian_path, hamiltonian_cycle, clique_number +``` + +--- + +## 8. Dev Notice + +Run `yarn dev` to start (runs `build:watch` + `preview` in parallel). + +Note: for dev mode only, there is an extra config in `vite.config.ts`: + +```ts +root: 'dev', // for dev only +``` + +Remember to remove it before going to production. \ No newline at end of file diff --git a/README.md b/README.md index 35d5e5a..754832c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Develop local TypeScript code to create custom response area types, and see them previewed live in the main application. When they're ready, provide your code to the Lambda Feedback team, who will consider including it in the main application after (human) review. +## Graph Response Area + +Please refer to [Graph.md](./Graph.md) + ## Overview To create a new response area type, you'll need to: diff --git a/package.json b/package.json index b7a8bf5..856999d 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build": "tsc -b && vite build", - "build:watch": "vite build --watch", + "build:watch": "node --max-old-space-size=4096 --no-opt ./node_modules/.bin/vite build --watch", "format": "prettier --check \"**/*.{js,ts,tsx}\"", "format:fix": "prettier --write \"**/*.{js,ts,tsx}\"", "lint": "eslint .", @@ -14,7 +14,8 @@ "ci": "run-p format lint typecheck", "fix": "run-s format:fix lint:fix", "preview": "vite preview", - "dev": "run-p build:watch preview" + "dev": "run-p build:watch preview", + "dev:fsa": "vite" }, "dependencies": { "@date-io/date-fns": "^2.13.2", @@ -35,6 +36,7 @@ "@nivo/core": "^0.88.0", "@nivo/line": "^0.88.0", "@nivo/pie": "^0.88.0", + "cytoscape": "^3.33.1", "axios": "^1.7.2", "date-fns": "^2.28.0", "firebase": "^11.0.1", @@ -46,6 +48,8 @@ "markdown-to-jsx": "^7.1.7", "monaco-editor": "^0.50.0", "next": "^14.2.4", + "paper": "^0.12.18", + "reactflow": "^11.11.4", "notistack": "^2.0.3", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/sandbox-component.tsx b/src/sandbox-component.tsx index ab01af5..2c2e134 100644 --- a/src/sandbox-component.tsx +++ b/src/sandbox-component.tsx @@ -6,7 +6,7 @@ function ResponseAreaInputWrapper({ children }: { children: React.ReactNode }) { } // wrap the components with the necessary providers; only in the sandbox -class WrappedSandboxResponseAreaTub extends SandboxResponseAreaTub { +class WrappedSandboxResponseAreaTub extends GraphResponseAreaTub { constructor() { super() diff --git a/src/types/Code/Code.component.tsx b/src/types/Code/Code.component.tsx index 0024960..295b6f5 100644 --- a/src/types/Code/Code.component.tsx +++ b/src/types/Code/Code.component.tsx @@ -37,7 +37,6 @@ export const CodeInput: React.FC = ({ loading={} language={config.language} className={classes.codearea} - beforeMount={console.log} /> ) } diff --git a/src/types/Graph/Graph.component.styles.ts b/src/types/Graph/Graph.component.styles.ts new file mode 100644 index 0000000..2f70654 --- /dev/null +++ b/src/types/Graph/Graph.component.styles.ts @@ -0,0 +1,114 @@ +import { makeStyles } from "@styles"; + +export const useLocalStyles = makeStyles()((theme) => ({ + container: { + width: '100%', + height: 600, + display: 'flex', + border: '1px solid #ddd', + fontFamily: 'sans-serif', + position: 'relative', + }, + + panel: { + width: 280, + padding: theme.spacing(2), + borderRight: '1px solid #ddd', + backgroundColor: '#fafafa', + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + overflowY: 'auto' + }, + + floatingConfig: { + position: 'absolute', + right: 12, + bottom: 12, + width: 320, + maxHeight: 420, + backgroundColor: '#fafafa', + border: '1px solid #ddd', + borderRadius: 6, + boxShadow: '0 4px 12px rgba(0,0,0,0.15)', + display: 'flex', + flexDirection: 'column', + zIndex: 10, + }, + + configHeader: { + padding: theme.spacing(1), + fontWeight: 600, + borderBottom: '1px solid #eee', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + cursor: 'pointer', + }, + + configBody: { + padding: theme.spacing(1.5), + overflowY: 'auto', + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1.5), + }, + + panelTitle: { + fontWeight: 600, + fontSize: 16, + borderBottom: '1px solid #eee', + paddingBottom: theme.spacing(1), + }, + + field: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + }, + + inputField: { + padding: '6px 8px', + border: '1px solid #ccc', + borderRadius: 4, + }, + + checkboxRow: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + }, + + addButton: { + padding: '6px 10px', + backgroundColor: '#fff', + border: '1px solid #ccc', + borderRadius: 4, + cursor: 'pointer', + }, + + deleteButton: { + padding: '6px', + backgroundColor: '#fff1f0', + color: '#cf1322', + border: '1px solid #ffa39e', + borderRadius: 4, + cursor: 'pointer', + fontWeight: 600, + }, + + cyWrapper: { + flexGrow: 1, + position: 'relative', + }, + + drawCanvas: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + pointerEvents: 'none', + zIndex: 10, + }, +})) \ No newline at end of file diff --git a/src/types/Graph/Graph.component.tsx b/src/types/Graph/Graph.component.tsx new file mode 100644 index 0000000..183770c --- /dev/null +++ b/src/types/Graph/Graph.component.tsx @@ -0,0 +1,677 @@ +import cytoscape, { Core, NodeSingular, EdgeSingular } from 'cytoscape' +import paper from 'paper' +import React, { useEffect, useRef, useState, useCallback } from 'react' + +import { Graph, Node, Edge } from './type' +import { useLocalStyles } from './Graph.component.styles' + +const CIRCLE_MIN_DIAMETER = 20 +const CIRCLE_MIN_STROKE_RATIO = 0.3 // min stroke length as a fraction of circumference +const CIRCLE_MAX_DISTANCE_RATIO = 0.5 // max start-to-end gap as a fraction of diameter +const NODE_SNAP_THRESHOLD = 75 // max pixel distance to snap a drawn line to a node + +/* ----------------------------- Graph Editor ----------------------------- */ +interface GraphEditorProps { + graph: Graph + onChange: (graph: Graph) => void +} + +export const GraphEditor: React.FC = ({ + graph, + onChange, +}) => { + const { classes } = useLocalStyles() + + const cyRef = useRef(null) + const containerRef = useRef(null) + const drawCanvasRef = useRef(null) + + const [drawMode, setDrawMode] = useState(false) + const [selectedNodeId, setSelectedNodeId] = useState(null) + const [selectedEdgeId, setSelectedEdgeId] = useState(null) + const [fromNode, setFromNode] = useState(null) + const [nodeCounter, setNodeCounter] = useState(graph.nodes.length) + const [isDrawing, setIsDrawing] = useState(false) + + // Drawing state + const pathRef = useRef(null) + const startPointRef = useRef(null) + const paperProjectRef = useRef(null) + + /* -------------------- Initialize Cytoscape (once) -------------------- */ + useEffect(() => { + if (!containerRef.current || cyRef.current) return + + const cy: Core = cytoscape({ + container: containerRef.current, + layout: { name: 'preset' }, + style: [ + { + selector: 'node', + style: { + label: 'data(displayLabel)', + 'text-valign': 'center', + 'text-halign': 'center', + width: 50, + height: 50, + 'background-color': '#fff', + 'border-width': 1, + 'border-color': '#555', + 'font-size': '14px' + } + }, + { + selector: 'node:selected', + style: { + 'background-color': '#e3f2fd', + 'border-width': 3, + 'border-color': '#1976d2' + } + }, + { + selector: 'edge', + style: { + label: 'data(label)', + 'curve-style': 'bezier', + 'target-arrow-shape': graph.directed ? 'triangle' : 'none', + 'line-color': '#555', + 'target-arrow-color': '#555', + 'font-size': '12px' + } + }, + { + selector: 'edge:selected', + style: { + 'line-color': '#1976d2', + 'target-arrow-color': '#1976d2', + 'width': 3 + } + }, + { + selector: '.edge-source', + style: { + 'background-color': '#d4edda', + 'border-color': '#155724' + } + } + ], + }) + + cyRef.current = cy + + + return () => { + cy.destroy() + cyRef.current = null + } + }, []) + + /* -------------------- Update arrow style when directed flag changes -------------------- */ + useEffect(() => { + const cy = cyRef.current + if (!cy) return + cy.style() + .selector('edge') + .style({ 'target-arrow-shape': graph.directed ? 'triangle' : 'none' }) + .update() + }, [graph.directed]) + + /* -------------------- Update Node Counter -------------------- */ + useEffect(() => { + // Find the highest node number to avoid duplicate IDs + let maxNum = 0 + graph.nodes.forEach(node => { + const match = node.id.match(/^n(\d+)$/) + if (match && match[1]) { + const num = parseInt(match[1], 10) + if (num > maxNum) maxNum = num + } + }) + setNodeCounter(maxNum + 1) + }, [graph.nodes]) + + /* -------------------- Update Cytoscape from Graph -------------------- */ + useEffect(() => { + const cy = cyRef.current + if (!cy) return + + // Get existing elements + const existingNodeIds = new Set(cy.nodes().map(n => n.id())) + const existingEdgeIds = new Set(cy.edges().map(e => e.id())) + + // Add/Update nodes + graph.nodes.forEach((n: Node) => { + if (existingNodeIds.has(n.id)) { + // Update existing node + const node = cy.getElementById(n.id) + node.data('displayLabel', n.label ?? n.id) + node.position({ x: n.x ?? node.position().x, y: n.y ?? node.position().y }) + } else { + // Add new node + cy.add({ + group: 'nodes', + data: { id: n.id, displayLabel: n.label ?? n.id }, + position: { x: n.x ?? 100, y: n.y ?? 100 } + }) + } + }) + + // Remove nodes not in graph + const graphNodeIds = new Set(graph.nodes.map(n => n.id)) + cy.nodes().forEach(node => { + if (!graphNodeIds.has(node.id())) { + node.remove() + } + }) + + // Add/Update edges + graph.edges.forEach((e: Edge) => { + const edgeId = e.id ?? `e-${e.source}-${e.target}` + if (existingEdgeIds.has(edgeId)) { + // Update existing edge + const edge = cy.getElementById(edgeId) + edge.data('label', e.label ?? '') + } else { + // Add new edge + cy.add({ + group: 'edges', + data: { + id: edgeId, + source: e.source, + target: e.target, + label: e.label ?? '' + } + }) + } + }) + + // Remove edges not in graph + const graphEdgeIds = new Set(graph.edges.map(e => e.id ?? `e-${e.source}-${e.target}`)) + cy.edges().forEach(edge => { + if (!graphEdgeIds.has(edge.id())) { + edge.remove() + } + }) + }, [graph]) + + /* -------------------- Sync to Graph -------------------- */ + const syncToGraph = useCallback((): void => { + const cy = cyRef.current + if (!cy) return + + const nodes: Node[] = cy.nodes().map((n) => ({ + id: n.id(), + label: n.data('displayLabel') as string, + x: n.position().x, + y: n.position().y, + metadata: {}, + })) + + const edges: Edge[] = cy.edges().map((e) => ({ + id: e.id(), + source: e.source().id(), + target: e.target().id(), + label: (e.data('label') as string) ?? '', + metadata: {}, + weight: 0 + })) + + onChange({ ...graph, nodes, edges }) + }, [graph, onChange]) + + /* -------------------- Add Node -------------------- */ + const addNode = useCallback((): void => { + const cy = cyRef.current + if (!cy) return + + const id = `n${nodeCounter}` + cy.add({ + group: 'nodes', + data: { id, displayLabel: id }, + position: { + x: 100 + Math.random() * 300, + y: 100 + Math.random() * 300 + } + }) + setNodeCounter(nodeCounter + 1) + syncToGraph() + }, [nodeCounter, syncToGraph]) + + const addNodeWithPos = useCallback((x: number, y: number): void => { + const cy = cyRef.current + if (!cy) return + + const id = `n${nodeCounter}` + cy.add({ + group: 'nodes', + data: { id, displayLabel: id }, + position: { x, y } + }) + setNodeCounter(nodeCounter + 1) + syncToGraph() + }, [nodeCounter, syncToGraph]) + + /* -------------------- Paper.js Setup -------------------- */ + useEffect(() => { + const canvas = drawCanvasRef.current + if (!canvas || paperProjectRef.current) return + + // Create a new Paper.js project for this instance + const paperProject = new paper.Project(canvas) + paperProjectRef.current = paperProject + + + // Setup canvas size + const updateCanvasSize = () => { + const container = containerRef.current + if (!container || !canvas) return + + const { width, height } = container.getBoundingClientRect() + + // Set canvas dimensions + canvas.width = width + canvas.height = height + + // Update Paper.js view + if (paperProjectRef.current) { + paperProjectRef.current.view.viewSize = new paper.Size(width, height) + paperProjectRef.current.view.update() + } + } + + updateCanvasSize() + + // Handle resizing + const resizeObserver = new ResizeObserver(updateCanvasSize) + if (containerRef.current) { + resizeObserver.observe(containerRef.current) + } + + return () => { + resizeObserver.disconnect() + if (paperProjectRef.current) { + paperProjectRef.current.remove() + paperProjectRef.current = null + } + } + }, []) + + /* -------------------- Drawing Handlers -------------------- */ + const handlePointerDown = useCallback((e: React.PointerEvent) => { + if (!drawMode || !paperProjectRef.current) return + + // Activate this project before drawing + paperProjectRef.current.activate() + + e.preventDefault() + e.stopPropagation() + + const rect = drawCanvasRef.current?.getBoundingClientRect() + if (!rect || !drawCanvasRef.current) return + + const x = e.clientX - rect.left + const y = e.clientY - rect.top + + // Clear any existing paths from previous drawings + if (pathRef.current) { + pathRef.current.remove() + } + + // Create new path + pathRef.current = new paper.Path() + pathRef.current.strokeColor = new paper.Color('red') + pathRef.current.strokeWidth = 3 + + startPointRef.current = new paper.Point(x, y) + pathRef.current.add(startPointRef.current) + + setIsDrawing(true) + }, [drawMode]) + + const handlePointerMove = useCallback((e: React.PointerEvent) => { + if (!drawMode || !isDrawing || !pathRef.current) return + + e.preventDefault() + e.stopPropagation() + + const rect = drawCanvasRef.current?.getBoundingClientRect() + if (!rect) return + + const x = e.clientX - rect.left + const y = e.clientY - rect.top + + pathRef.current.add(new paper.Point(x, y)) + }, [drawMode, isDrawing]) + + const handlePointerUp = useCallback((e: React.PointerEvent) => { + if (!drawMode || !isDrawing || !pathRef.current || !startPointRef.current) { + setIsDrawing(false) + return + } + + e.preventDefault() + e.stopPropagation() + + const rect = drawCanvasRef.current?.getBoundingClientRect() + if (!rect) return + + const endX = e.clientX - rect.left + const endY = e.clientY - rect.top + const endPoint = new paper.Point(endX, endY) + + // Analyze the drawing + const bounds = pathRef.current.bounds + const dx = bounds.width + const dy = bounds.height + const diameter = Math.max(dx, dy) + const strokeLength = pathRef.current.length + const distance = startPointRef.current.getDistance(endPoint) + + // Circle detection - relaxed criteria + const circumference = Math.PI * diameter + const isCircle = diameter > CIRCLE_MIN_DIAMETER && + strokeLength > circumference * CIRCLE_MIN_STROKE_RATIO && + distance < diameter * CIRCLE_MAX_DISTANCE_RATIO + + if (isCircle) { + // Add node at circle center + addNodeWithPos(bounds.center.x, bounds.center.y) + } else { + // Find closest nodes to start and end + const cy = cyRef.current + if (cy) { + const findClosestNode = ( + pointX: number, + pointY: number + ): NodeSingular | null => { + let minDist = Infinity + let closestNode: NodeSingular | null = null + + cy.nodes().forEach((node) => { + // Use rendered position (viewport coordinates) instead of graph position + const pos = node.renderedPosition() + const dist = Math.hypot(pos.x - pointX, pos.y - pointY) + if (dist < minDist) { + minDist = dist + closestNode = node + } + }) + + return minDist < NODE_SNAP_THRESHOLD ? closestNode : null + } + + const startNode = findClosestNode(startPointRef.current.x, startPointRef.current.y) + const endNode = findClosestNode(endPoint.x, endPoint.y) + + + if (startNode !== null && endNode !== null && startNode.id() !== endNode.id()) { + cy.add({ + group: 'edges', + data: { + id: `e-${startNode.id()}-${endNode.id()}-${Date.now()}`, + source: startNode.id(), + target: endNode.id(), + label: '', + }, + }) + syncToGraph() + } + } + } + + // Don't remove the path immediately - keep it visible for a moment + // setTimeout(() => { + if (pathRef.current) { + pathRef.current.remove() + pathRef.current = null + } + startPointRef.current = null + setIsDrawing(false) + // }, 500) // Keep stroke visible for 500ms + }, [drawMode, isDrawing, addNodeWithPos, syncToGraph]) + + const handlePointerLeave = useCallback(() => { + if (pathRef.current) { + pathRef.current.remove() + pathRef.current = null + } + startPointRef.current = null + setIsDrawing(false) + }, []) + + /* -------------------- Cytoscape Selection Handlers -------------------- */ + useEffect(() => { + const cy = cyRef.current + if (!cy) return + + const tapNode = (e: cytoscape.EventObject) => { + const node = e.target as NodeSingular + + if (drawMode) { + if (!fromNode) { + setFromNode(node.id()) + node.addClass('edge-source') + } else { + cy.add({ + group: 'edges', + data: { + id: `e-${fromNode}-${node.id()}-${Date.now()}`, + source: fromNode, + target: node.id(), + label: '' + } + }) + cy.nodes().removeClass('edge-source') + setDrawMode(false) + setFromNode(null) + syncToGraph() + } + return + } + + setSelectedNodeId(node.id()) + setSelectedEdgeId(null) + } + + const tapEdge = (e: cytoscape.EventObject) => { + const edge = e.target as EdgeSingular + setSelectedEdgeId(edge.id()) + setSelectedNodeId(null) + } + + const tapBlank = (e: cytoscape.EventObject) => { + // Only clear selection if we clicked the background (not a node or edge) + if (e.target === cy) { + setSelectedNodeId(null) + setSelectedEdgeId(null) + } + } + + cy.on('tap', 'node', tapNode) + cy.on('tap', 'edge', tapEdge) + cy.on('tap', tapBlank) + + return () => { + cy.off('tap', 'node', tapNode) + cy.off('tap', 'edge', tapEdge) + cy.off('tap', tapBlank) + } + }, [drawMode, fromNode, syncToGraph]) + + /* -------------------- Render -------------------- */ + // Get fresh node/edge references from IDs + const selectedNode = selectedNodeId && cyRef.current ? cyRef.current.$id(selectedNodeId) : null + const selectedEdge = selectedEdgeId && cyRef.current ? cyRef.current.$id(selectedEdgeId) : null + + return ( +
+ {/* -------------------- Item Properties Panel -------------------- */} +
+
Item Properties
+ + + + + + + + {drawMode && ( +
+ Draw Mode Active:
+ • Draw a circle to create a node
+ • Draw a line between nodes to create an edge
+ • Or click nodes to connect them +
+ )} + + {selectedNode && selectedNode.length > 0 ? ( + <> +
+ Selected Node: {selectedNode.id()} +
+
+ + { + selectedNode.data('displayLabel', e.target.value); + syncToGraph() + }} + /> +
+ + + ) : selectedNodeId ? ( +
+ Node "{selectedNodeId}" not found in graph +
+ ) : null} + + {!selectedNodeId && !selectedEdgeId && ( +
+ Click a node or edge to select it +
+ )} + + {selectedEdge && selectedEdge.length > 0 ? ( + <> +
+ Selected Edge +
+
+ + { + selectedEdge.data('label', e.target.value); + syncToGraph() + }} + /> +
+ + + ) : null} + +
+ + {/* -------------------- Cytoscape + Paper Canvas -------------------- */} +
+
+ +
+
+ ) +} \ No newline at end of file diff --git a/src/types/Graph/components/ConfigPanel.styles.ts b/src/types/Graph/components/ConfigPanel.styles.ts new file mode 100644 index 0000000..2bbafa0 --- /dev/null +++ b/src/types/Graph/components/ConfigPanel.styles.ts @@ -0,0 +1,63 @@ +import { makeStyles } from '@styles' + +export const useConfigPanelStyles = makeStyles()(() => ({ + container: { + display: 'flex', + flexDirection: 'column', + gap: '20px', + maxWidth: 400, + }, + + sectionHeading: { + marginBottom: 8, + fontWeight: 600, + fontSize: 18, + }, + + radioGroupRow: { + display: 'flex', + gap: '12px', + }, + + radioGroupColumn: { + display: 'flex', + flexDirection: 'column', + gap: '8px', + }, + + radioLabel: { + display: 'flex', + alignItems: 'center', + padding: '10px 20px', + cursor: 'pointer', + border: '1px solid #d9d9d9', + background: '#fff', + borderRadius: '8px', + fontWeight: 400, + color: '#333', + transition: 'all 0.2s', + boxShadow: 'none', + }, + + radioLabelActive: { + border: '2px solid #0057b8', + background: '#cce6ff', + fontWeight: 700, + color: '#0057b8', + boxShadow: '0 0 8px #0057b833', + }, + + radioInput: { + accentColor: '#0057b8', + marginRight: 10, + }, + + radioInputWide: { + accentColor: '#0057b8', + marginRight: 16, + }, + + radioOptionText: { + fontSize: 15, + }, +})) diff --git a/src/types/Graph/components/ConfigPanel.tsx b/src/types/Graph/components/ConfigPanel.tsx new file mode 100644 index 0000000..e5dd619 --- /dev/null +++ b/src/types/Graph/components/ConfigPanel.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { GraphConfig } from '../type'; +import { useConfigPanelStyles } from './ConfigPanel.styles'; + +const evaluationOptions = [ + 'isomorphism', + 'connectivity', + 'bipartite', + 'cycle_detection', + 'graph_coloring', + 'planarity', + 'tree', + 'forest', + 'dag', + 'eulerian', + 'semi_eulerian', + 'regular', + 'complete', + 'degree_sequence', + 'subgraph', + 'hamiltonian_path', + 'hamiltonian_cycle', + 'clique_number' +]; + +interface ConfigPanelProps { + config: GraphConfig; + onChange: (config: GraphConfig) => void; + AnswerPanel?: React.ReactNode; +} + +export const ConfigPanel: React.FC = ({ config, onChange, AnswerPanel }) => { + const { classes, cx } = useConfigPanelStyles() + const [selectedType, setSelectedType] = React.useState(config.evaluation_type ?? '') + const [directed, setDirected] = React.useState(config.directed ?? false) + + const updateConfig = (updates: Partial) => { + onChange({ ...config, ...updates }); + }; + + const handleTypeChange = (type: string) => { + setSelectedType(type); + updateConfig({ evaluation_type: type }); + }; + + const handleDirectedToggle = (val: boolean) => { + setDirected(val); + updateConfig({ directed: val }); + }; + + return ( +
+ + {/* ---- Directed / Undirected toggle ---- */} +
+

Graph Type

+
+ {([false, true] as const).map(val => ( + + ))} +
+
+ + {/* ---- Evaluation type selector ---- */} +
+

Evaluation Type

+
+ {evaluationOptions.map(type => ( + + ))} +
+
+ + {selectedType === 'isomorphism' && AnswerPanel} + +
+ ); +}; diff --git a/src/types/Graph/index.tsx b/src/types/Graph/index.tsx new file mode 100644 index 0000000..fedbc0c --- /dev/null +++ b/src/types/Graph/index.tsx @@ -0,0 +1,249 @@ +import React, { useState, useEffect } from 'react' +import { + BaseResponseAreaProps, + BaseResponseAreaWizardProps, +} from '../base-props.type' +import { ResponseAreaTub } from '../response-area-tub' + +import { ConfigPanel } from './components/ConfigPanel' +import { GraphEditor } from './Graph.component' +import { + Graph, + GraphConfig, + GraphConfigSchema, + GraphAnswer, + GraphAnswerSchema, + toSimpleGraph, + fromSimpleGraph, + graphAnswerToSimple, + simpleToAnswer, +} from './type' + +const DEFAULT_CONFIG: GraphConfig = { + directed: false, + weighted: false, + multigraph: false, + evaluation_type: '', +} + +const DEFAULT_ANSWER: GraphAnswer = { + nodes: [], + edges: [], +} + +export class GraphResponseAreaTub extends ResponseAreaTub { + public readonly responseType = 'HANDDRAWNGRAPH' + public readonly displayWideInput = true + + protected answerSchema = GraphAnswerSchema + protected configSchema = GraphConfigSchema + + protected answer: GraphAnswer = { ...DEFAULT_ANSWER } + protected config: GraphConfig = { ...DEFAULT_CONFIG } + + public readonly delegateFeedback = true + + constructor() { + super() + } + + initWithDefault = () => { + this.config = { ...DEFAULT_CONFIG } + this.answer = { ...DEFAULT_ANSWER } + } + + initWithConfig = () => { + // Called by the parent app when initialising with config only (student view) + // config is already extracted via extractConfig — nothing extra needed + } + + // Override extractConfig to handle missing/invalid config gracefully + protected extractConfig = (provided: any): void => { + if (!provided || typeof provided !== 'object') { + this.config = { directed: false, weighted: false, multigraph: false, evaluation_type: '' } + return + } + + const parsedConfig = this.configSchema?.safeParse(provided) + if (!parsedConfig || !parsedConfig.success) { + // Legacy migration: config was a SimpleGraph — extract just the flags + // evaluation_type may be a legacy string[] — take first element + const legacyEval = provided.evaluation_type + this.config = { + directed: provided.directed ?? false, + weighted: provided.weighted ?? false, + multigraph: provided.multigraph ?? false, + evaluation_type: Array.isArray(legacyEval) + ? (legacyEval[0] ?? '') + : (legacyEval ?? ''), + } + return + } + + this.config = parsedConfig.data + } + + // Override extractAnswer — answer may be flattened (nodes + edges + config flags) + protected extractAnswer = (provided: any): void => { + if (!provided || typeof provided !== 'object') return + + if (Array.isArray(provided.nodes) && Array.isArray(provided.edges)) { + this.answer = { nodes: provided.nodes, edges: provided.edges } + // Always read config flags from the flattened answer + const legacyEval = provided.evaluation_type + this.config = { + directed: provided.directed ?? false, + weighted: provided.weighted ?? false, + multigraph: provided.multigraph ?? false, + evaluation_type: Array.isArray(legacyEval) + ? (legacyEval[0] ?? '') + : (legacyEval ?? ''), + } + } + } + + /* -------------------- Custom Check -------------------- */ + customCheck = (): boolean => { + return !!(this.answer && this.answer.nodes.length > 0) + } + + /* -------------------- Input -------------------- */ + InputComponent = (props: BaseResponseAreaProps) => { + const isTeacherPreview = props.isTeacherMode && props.hasPreview + + // Student always starts with an empty graph — never pre-filled from the answer + const [studentAnswer, setStudentAnswer] = useState({ nodes: [], edges: [] }) + + // Resolve config — read from flattened answer first, then props.config, then tub state + const resolvedConfig: GraphConfig = (() => { + // Config flags may be flattened into the answer + const ans = props.answer as any + if (ans && (typeof ans.directed !== 'undefined' || typeof ans.evaluation_type !== 'undefined')) { + const eval_ = ans.evaluation_type + return { + directed: ans.directed ?? false, + weighted: ans.weighted ?? false, + multigraph: ans.multigraph ?? false, + evaluation_type: Array.isArray(eval_) ? (eval_[0] ?? '') : (eval_ ?? ''), + } + } + if (props.config) { + const parsed = GraphConfigSchema.safeParse(props.config) + if (parsed.success) return parsed.data + const leg = props.config as any + return { + directed: leg.directed ?? false, + weighted: leg.weighted ?? false, + multigraph: leg.multigraph ?? false, + evaluation_type: leg.evaluation_type ?? '', + } + } + return this.config + })() + + // Config is read-only here — set exclusively via WizardComponent. + // InputComponent only carries answer changes via props.handleChange. + const graph: Graph = fromSimpleGraph(graphAnswerToSimple(studentAnswer, resolvedConfig)) + return ( + { + const newAnswer = simpleToAnswer(toSimpleGraph(val)) + setStudentAnswer(newAnswer) + this.answer = newAnswer + props.handleChange(newAnswer) + }} + /> + ) + } + + /* -------------------- Wizard -------------------- */ + WizardComponent = (props: BaseResponseAreaWizardProps) => { + return ( + { + this.config = config + this.answer = answer + // Flatten config fields into the answer — backend reads from answer, not config + const flatAnswer = { + ...answer, + directed: config.directed, + weighted: config.weighted, + multigraph: config.multigraph, + evaluation_type: config.evaluation_type, + } + props.handleChange({ + responseType: this.responseType, + answer: flatAnswer, + }) + }} + /> + ) + } +} + +/* ================================================================ + Stable sub-components — defined outside the class so React never + treats them as new component types on re-render, which would + unmount/remount GraphEditor and lose all Cytoscape canvas state. +================================================================ */ + +interface WizardPanelProps { + initialConfig: GraphConfig + initialAnswer: GraphAnswer + onChange: (config: GraphConfig, answer: GraphAnswer) => void +} + +const WizardPanel: React.FC = ({ + initialConfig, + initialAnswer, + onChange, +}) => { + const [config, setConfig] = useState(initialConfig) + const [answer, setAnswer] = useState(initialAnswer) + + // Emit initial state on mount so config is always persisted to DB, + // even if the teacher never interacts with the config panel. + useEffect(() => { + onChange(initialConfig, initialAnswer) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleConfigChange = (updatedConfig: GraphConfig) => { + setConfig(updatedConfig) + onChange(updatedConfig, answer) + } + + const handleAnswerChange = (val: Graph) => { + // evaluation_type is now a plain string; wrap for toSimpleGraph which expects string[] + const newAnswer = simpleToAnswer(toSimpleGraph(val, config.evaluation_type ? [config.evaluation_type] : [])) + setAnswer(newAnswer) + onChange(config, newAnswer) + } + + const isIsomorphism = config.evaluation_type?.includes('isomorphism') + const graph: Graph = fromSimpleGraph(graphAnswerToSimple(answer, config)) + + return ( +
+ + {isIsomorphism && ( +
+

Reference Graph (for Isomorphism)

+

Draw the graph the student's answer will be compared against.

+ +
+ )} +
+ ) +} diff --git a/src/types/Graph/type.ts b/src/types/Graph/type.ts new file mode 100644 index 0000000..831f6b2 --- /dev/null +++ b/src/types/Graph/type.ts @@ -0,0 +1,182 @@ +import { z } from "zod"; + +// ----------------------------- +// Node Schema +// ----------------------------- +export const NodeSchema = z.object({ + id: z.string(), + label: z.string().optional(), + x: z.number().optional(), + y: z.number().optional(), + metadata: z.record(z.any()).optional().default({}), +}); + +export type Node = z.infer + +// ----------------------------- +// Edge Schema +// ----------------------------- +export const EdgeSchema = z.object({ + source: z.string(), + target: z.string(), + weight: z.number().optional().default(1), + label: z.string().optional(), + id: z.string().optional(), + metadata: z.record(z.any()).optional().default({}), +}); + +export type Edge = z.infer + +// ----------------------------- +// Graph Schema +// ----------------------------- +export const GraphSchema = z.object({ + nodes: z.array(NodeSchema), + edges: z.array(EdgeSchema).default([]), + directed: z.boolean().default(false), + weighted: z.boolean().default(false), + multigraph: z.boolean().default(false), + metadata: z.record(z.any()).optional().default({}), +}); + +export type Graph = z.infer + +// ----------------------------- +// Simplified Graph: for internal editor use (topology + flags merged) +// ----------------------------- +export const SimpleGraphSchema = z.object({ + // Nodes as pipe-delimited strings: "id|label|x|y" + nodes: z.array(z.string()), + // Edges as pipe-delimited strings: "source|target|weight|label" + edges: z.array(z.string()), + directed: z.boolean().default(false), + weighted: z.boolean().default(false), + multigraph: z.boolean().default(false), + evaluation_type: z.array(z.string()).default([]), +}); + +export type SimpleGraph = z.infer + +// ----------------------------- +// GraphConfig: teacher-configured params (stored in config, NOT in answer) +// directed, weighted, multigraph, evaluation_type live here +// ----------------------------- +export const GraphConfigSchema = z.object({ + directed: z.boolean().default(false), + weighted: z.boolean().default(false), + multigraph: z.boolean().default(false), + // Plain string — backend Pydantic EvaluationParams expects e.g. 'connectivity', not ['connectivity'] + evaluation_type: z.string().default(''), +}); + +export type GraphConfig = z.infer + +// ----------------------------- +// GraphAnswer: student/teacher answer — topology only (nodes + edges) +// flags (directed, weighted, etc.) come from GraphConfig, not stored here +// ----------------------------- +export const GraphAnswerSchema = z.object({ + nodes: z.array(z.string()), + edges: z.array(z.string()), +}); + +export type GraphAnswer = z.infer + +// Helper functions to convert between Graph and SimpleGraph +export function toSimpleGraph(graph: Graph, evaluationType?: string[]): SimpleGraph { + return { + nodes: graph.nodes.map(n => + `${n.id}|${n.label || ''}|${n.x || 0}|${n.y || 0}` + ), + edges: graph.edges.map(e => + `${e.source}|${e.target}|${e.weight || 1}|${e.label || ''}` + ), + directed: graph.directed, + weighted: graph.weighted, + multigraph: graph.multigraph, + evaluation_type: evaluationType ?? [], + }; +} + +// Merge a GraphAnswer (topology) with a GraphConfig (flags) into a SimpleGraph for the editor +export function graphAnswerToSimple(answer: GraphAnswer, config: GraphConfig): SimpleGraph { + return { + nodes: answer.nodes, + edges: answer.edges, + directed: config.directed, + weighted: config.weighted, + multigraph: config.multigraph, + // Wrap the string into an array for internal SimpleGraph usage + evaluation_type: config.evaluation_type ? [config.evaluation_type] : [], + }; +} + +// Extract only topology from a SimpleGraph (strips config flags) +export function simpleToAnswer(simple: SimpleGraph): GraphAnswer { + return { + nodes: simple.nodes, + edges: simple.edges, + }; +} + +export function fromSimpleGraph(simple: SimpleGraph): Graph { + return { + nodes: simple.nodes.map(str => { + const [id = '', label = '', xStr = '0', yStr = '0'] = str.split('|'); + return { + id, + label: label || undefined, + x: parseFloat(xStr) || 0, + y: parseFloat(yStr) || 0, + metadata: {}, + }; + }), + edges: simple.edges.map(str => { + const [source = '', target = '', weightStr = '1', label = ''] = str.split('|'); + return { + source, + target, + weight: parseFloat(weightStr) || 1, + label: label || undefined, + metadata: {}, + }; + }), + directed: simple.directed, + weighted: simple.weighted, + multigraph: simple.multigraph, + metadata: {}, + // evaluation_type lives on SimpleGraph only, not on the rich Graph type + }; +} + +// ----------------------------- +// Validation & Feedback Types +// ----------------------------- +export enum CheckPhase { + Idle = 'idle', + Evaluated = 'evaluated', +} + +export interface ValidationError { + type: 'error' | 'warning' + message: string + field?: string +} + +export interface GraphFeedback { + valid: boolean + errors: ValidationError[] + phase: CheckPhase +} + +export const GraphFeedbackSchema = z.object({ + valid: z.boolean(), + errors: z.array( + z.object({ + type: z.enum(['error', 'warning']), + message: z.string(), + field: z.string().optional(), + }) + ), + phase: z.nativeEnum(CheckPhase), +}) diff --git a/src/types/Sandbox/.gitkeep b/src/types/Sandbox/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/types/Sandbox/SandboxInput.component.tsx b/src/types/Sandbox/SandboxInput.component.tsx deleted file mode 100644 index 7a1e88f..0000000 --- a/src/types/Sandbox/SandboxInput.component.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useCallback } from 'react' - -import { BaseResponseAreaProps } from '../base-props.type' - -type TextInputProps = Omit & { - handleChange: (val: string) => void - answer?: string -} - -// Stateless SandboxInput Response Area -export const SandboxInput: React.FC = ({ - handleChange, - handleSubmit, - answer, -}) => { - const submitOnEnter: React.KeyboardEventHandler = - useCallback( - event => { - if (event.key !== 'Enter' || event.shiftKey || !handleSubmit) return - event.preventDefault() - return handleSubmit() - }, - [handleSubmit], - ) - - return ( -