From a39f305fe4bfaa3f452983a709ed30ec91516c84 Mon Sep 17 00:00:00 2001 From: everythingfades Date: Fri, 16 Jan 2026 01:00:00 +0000 Subject: [PATCH 01/29] feat: first FSA draft with Reactflow, the wizard definitely need some fix, I dont think it should be the same with Input --- FSA.md | 97 +++++++++ README.md | 4 + dev/index.html | 14 ++ dev/main.tsx | 43 ++++ package.json | 10 +- src/types/FSA/FSA.backend.ts | 54 +++++ src/types/FSA/FSA.component.tsx | 135 +++++++++++++ src/types/FSA/index.tsx | 38 ++++ src/types/FSA/type.ts | 24 +++ src/types/index.ts | 4 + vite.config.ts | 1 + yarn.lock | 336 +++++++++++++++++++++++++++++++- 12 files changed, 752 insertions(+), 8 deletions(-) create mode 100644 FSA.md create mode 100644 dev/index.html create mode 100644 dev/main.tsx create mode 100644 src/types/FSA/FSA.backend.ts create mode 100644 src/types/FSA/FSA.component.tsx create mode 100644 src/types/FSA/index.tsx create mode 100644 src/types/FSA/type.ts diff --git a/FSA.md b/FSA.md new file mode 100644 index 0000000..5a76502 --- /dev/null +++ b/FSA.md @@ -0,0 +1,97 @@ +# FSA Module Documentation + +This module provides a visual editor for Finite State Automata (FSA) built with **React Flow**. It is designed to bridge the gap between a **Python-style backend** (deeply nested objects) and a **TypeScript/Zod frontend** (restricted to 2-level JSON nesting). + +## 1. The Core Data Structures + +### Frontend Schema (`FSA`) + +To satisfy the `jsonNestedSchema` (which permits only 2 levels of nesting), we use a "flattened" string format for transitions. + +```typescript +// level 1: Object properties +// level 2: String arrays +export interface FSA { + states: string[]; + alphabet: string[]; + transitions: string[]; // Format: "from_state|symbol|to_state" + initial_state: string; + accept_states: string[]; +} + +``` + +### Backend Schema (`BackendFSA`) + +The Python backend uses standard object-oriented nesting for transitions. + +```typescript +export interface BackendFSA { + states: string[]; + alphabet: string[]; + transitions: Array<{ + from_state: string; + to_state: string; + symbol: string; + }>; + initial_state: string; + accept_states: string[]; +} + +``` + +--- + +## 2. Key Components + +### `FSAInput.component.tsx` + +The primary visual editor. + +* **State Management**: Uses `useNodesState` and `useEdgesState` from React Flow. +* **Syncing**: Every change (adding a node, connecting an edge, deleting) triggers a `syncChanges` function that converts the visual graph back into the flattened `FSA` interface. +* **User Interactions**: +* **Add State**: Prompt-based creation of new nodes. +* **Connections**: Dragging from one node to another prompts for a transition symbol (defaults to `ε`). +* **Deletion**: Selecting a node/edge and pressing **Backspace** or **Delete** removes the element and cleans up orphaned transitions. + + + +### `FSAResponseAreaTub.ts` + +The controller class that integrates the editor into the application wizard. + +* **Resilience**: Uses `defaultFSA` to prevent `undefined` errors. +* **Validation**: Uses `fsaAnswerSchema.safeParse()` to guard against corrupted data. + +--- + +## 3. Transformation Logic (`FSAConverter`) + +Since the frontend and backend see the data differently, the `FSAConverter` utility is used at the network boundary. + +| Method | Source | Target | Reason | +| --- | --- | --- | --- | +| `toFrontend()` | `BackendFSA` | `FSA` | Unpacks objects into `"q0 | +| `toBackend()` | `FSA` | `BackendFSA` | Packs strings back into objects for the Python service logic. | + +--- + +## 4. Usage in the Pipeline + +1. **Load**: Data is fetched from the backend (`BackendFSA`). +2. **Convert**: `FSAConverter.toFrontend()` is called. +3. **Edit**: The user interacts with `FSAInput`. The `answer` state stays in the flattened `FSA` format. +4. [TODO] **Save**: On `onSubmit` or `onChange`, `FSAConverter.toBackend()` is called to transform the data back to the format the server expects. + +--- + +## 5. Important Implementation Notes + +* **Unique Identifiers**: Edge IDs in React Flow are generated as ``e-${from}-${symbol}-${to}``. If the automaton is Non-Deterministic (NFA), ensure symbol uniqueness or add a UUID to the ID string. +* **Visual Cues**: +* **Initial State**: Nodes matching `initial_state` are colored with a light teal background. +* **Accept States**: Nodes in `accept_states` are rendered with a double-border (4px double). + + +* **Alphabet Consistency**: The `alphabet` array is automatically derived from the unique labels present in the transitions during the sync process. \ No newline at end of file diff --git a/README.md b/README.md index 35d5e5a..aab700e 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. +## FSA Response Section + +please refer to [FSA.md](./FSA.md) + ## Overview To create a new response area type, you'll need to: diff --git a/dev/index.html b/dev/index.html new file mode 100644 index 0000000..0894690 --- /dev/null +++ b/dev/index.html @@ -0,0 +1,14 @@ + + + + + + Library Dev Sandbox + + +
+ + + + + diff --git a/dev/main.tsx b/dev/main.tsx new file mode 100644 index 0000000..0e76b9b --- /dev/null +++ b/dev/main.tsx @@ -0,0 +1,43 @@ +import { IModularResponseSchema } from '@modules/shared/schemas/question-form.schema' +import React, { useState } from 'react' +import ReactDOM from 'react-dom/client' + +import { FSAResponseAreaTub } from '../src/types/FSA' + +const tub = new FSAResponseAreaTub() + +function Sandbox() { + const [answer, setAnswer] = + useState(null) + + const [allowSave, setAllowSave] = useState(true) + + return ( + <> +

Input

+ console.log('submit')} + handleDraftSave={() => console.log('draft save')} + displayMode="normal" + hasPreview + /> + +
+ +

Wizard

+ console.log('wizard change', val)} + setAllowSave={setAllowSave} + /> + + ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/package.json b/package.json index 66e29ee..1698e24 100644 --- a/package.json +++ b/package.json @@ -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", @@ -43,9 +44,10 @@ "next": "^14.2.4", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-katex": "^3.0.1", "react-hook-form": "^7.31.2", + "react-katex": "^3.0.1", "react-query": "^3.39.0", + "reactflow": "^11.11.4", "tldraw": "^3.13.1", "tss-react": "^3.7.0", "zod": "^3.14.4" @@ -66,11 +68,11 @@ "@types/shuffle-seed": "^1.1.0", "@vitejs/plugin-react": "^4.5.2", "eslint": "^9.3.0", - "eslint-plugin-react": "^7.34.2", - "eslint-plugin-react-hooks": "^4.6.2", "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-typescript": "^3.6.3", "eslint-plugin-import": "^2.31.0", + "eslint-plugin-react": "^7.34.2", + "eslint-plugin-react-hooks": "^4.6.2", "globals": "^16.2.0", "npm-run-all": "^4.1.5", "prettier": "^3.3.3", diff --git a/src/types/FSA/FSA.backend.ts b/src/types/FSA/FSA.backend.ts new file mode 100644 index 0000000..fd5a07b --- /dev/null +++ b/src/types/FSA/FSA.backend.ts @@ -0,0 +1,54 @@ +// FSA.converter.ts +// this is for the inconsistency between the backend pydantic models and the frontend compromsise +import { FSA } from './type'; + +/** + * Backend representation of an FSA transition + */ +export interface BackendTransition { + from_state: string; + to_state: string; + symbol: string; +} + +/** + * Backend representation of the full FSA + */ +export interface BackendFSA { + states: string[]; + alphabet: string[]; + transitions: BackendTransition[]; + initial_state: string; + accept_states: string[]; +} + +export const FSAConverter = { + /** + * Converts frontend FSA (flat transitions) to Backend FSA (object transitions) + */ + toBackend(frontendFsa: FSA): BackendFSA { + return { + ...frontendFsa, + transitions: frontendFsa.transitions.map((tStr) => { + const [from, symbol, to] = tStr.split('|'); + return { + from_state: from || '', + to_state: to || '', + symbol: symbol || '', + }; + }), + }; + }, + + /** + * Converts Backend FSA (object transitions) to frontend FSA (flat transitions) + */ + toFrontend(backendFsa: BackendFSA): FSA { + return { + ...backendFsa, + transitions: backendFsa.transitions.map( + (t) => `${t.from_state}|${t.symbol}|${t.to_state}` + ), + }; + }, +}; \ No newline at end of file diff --git a/src/types/FSA/FSA.component.tsx b/src/types/FSA/FSA.component.tsx new file mode 100644 index 0000000..fc03e35 --- /dev/null +++ b/src/types/FSA/FSA.component.tsx @@ -0,0 +1,135 @@ +import React, { useCallback, useMemo } from 'react'; +import ReactFlow, { + addEdge, + Background, + Controls, + Connection, + useNodesState, + useEdgesState, + MarkerType, + Edge, + Node, + OnNodesDelete +} from 'reactflow'; + +import 'reactflow/dist/style.css'; +import { FSA } from './type'; + +interface FSAInputProps { + answer: FSA; + onChange: (val: FSA) => void; +} + +export const FSAInput: React.FC = ({ answer, onChange }) => { + // 1. Unpack flattened strings into React Flow Edges + // we could use the convertor, but lets just keep this here + const initialEdges: Edge[] = useMemo(() => { + return (answer.transitions || []).reduce((acc: Edge[], tStr: string) => { + const [from, symbol, to] = tStr.split('|'); + if (from && symbol && to) { + acc.push({ + id: `e-${from}-${to}-${symbol}`, + source: from, + target: to, + label: symbol, + markerEnd: { type: MarkerType.ArrowClosed }, + }); + } + return acc; + }, []); + }, [answer.transitions]); + + const initialNodes: Node[] = useMemo(() => + answer.states.map((s, i) => ({ + id: s, + data: { label: s }, + position: { x: i * 150, y: 100 }, + style: { + border: answer.accept_states.includes(s) ? '4px double #333' : '1px solid #777', + background: s === answer.initial_state ? '#e6fffa' : '#fff', + borderRadius: '50%', width: 50, height: 50, + display: 'flex', alignItems: 'center', justifyContent: 'center' + } + })), [answer]); + + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + // Sync helper to convert current Flow state back to FSA format + const syncChanges = useCallback((currentNodes: Node[], currentEdges: Edge[]) => { + const updatedFSA: FSA = { + ...answer, + states: currentNodes.map(n => n.id), + transitions: currentEdges.map(e => `${e.source}|${e.label}|${e.target}`), + alphabet: Array.from(new Set(currentEdges.map(e => String(e.label)))) + }; + onChange(updatedFSA); + }, [answer, onChange]); + + // Handle Adding Nodes + const addState = useCallback(() => { + const id = prompt("Enter state name (e.g. q2):"); + if (!id || nodes.find(n => n.id === id)) return; + + const newNode: Node = { + id, + data: { label: id }, + position: { x: Math.random() * 400, y: Math.random() * 400 }, + style: { border: '1px solid #777', borderRadius: '50%', width: 50, height: 50 } + }; + + const updatedNodes = [...nodes, newNode]; + setNodes(updatedNodes); + syncChanges(updatedNodes, edges); + }, [nodes, edges, setNodes, syncChanges]); + + // Handle Deleting Nodes (triggered by Backspace/Delete key by default in React Flow) + const onNodesDelete: OnNodesDelete = useCallback((deletedNodes) => { + const deletedIds = new Set(deletedNodes.map(n => n.id)); + const remainingNodes = nodes.filter(n => !deletedIds.has(n.id)); + // React Flow handles edge cleanup internally in the 'edges' state, + // but we need to ensure our sync uses the filtered edges. + const remainingEdges = edges.filter(e => !deletedIds.has(e.source) && !deletedIds.has(e.target)); + + syncChanges(remainingNodes, remainingEdges); + }, [nodes, edges, syncChanges]); + + const onConnect = useCallback((params: Connection) => { + const symbol = prompt("Transition symbol:") || 'ε'; + const newEdge = { ...params, label: symbol, markerEnd: { type: MarkerType.ArrowClosed } }; + + setEdges((eds) => { + const updatedEdges = addEdge(newEdge, eds); + syncChanges(nodes, updatedEdges); + return updatedEdges; + }); + }, [nodes, syncChanges, setEdges]); + + return ( +
+
+ + + Select a node/edge and press Backspace to delete. + +
+ +
+ + + + +
+
+ ); +}; \ No newline at end of file diff --git a/src/types/FSA/index.tsx b/src/types/FSA/index.tsx new file mode 100644 index 0000000..d7c4172 --- /dev/null +++ b/src/types/FSA/index.tsx @@ -0,0 +1,38 @@ +// FSAResponseArea.tub.ts +import { BaseResponseAreaProps, BaseResponseAreaWizardProps } from '../base-props.type'; +import { ResponseAreaTub } from '../response-area-tub'; + +import { FSAInput } from './FSA.component'; +import { fsaAnswerSchema, FSA, defaultFSA } from './type'; + +export class FSAResponseAreaTub extends ResponseAreaTub { + public readonly responseType = 'FSA'; + public readonly displayWideInput = true; + protected answerSchema = fsaAnswerSchema; + protected answer: FSA = defaultFSA; + + InputComponent = (props: BaseResponseAreaProps) => { + const parsedAnswer = this.answerSchema.safeParse(props.answer); + return ( + props.handleChange(val)} + /> + ); + } + + WizardComponent = (props: BaseResponseAreaWizardProps) => { + return ( + { + props.handleChange({ + responseType: this.responseType, + answer, + }); + }} + /> + ); + } +} \ No newline at end of file diff --git a/src/types/FSA/type.ts b/src/types/FSA/type.ts new file mode 100644 index 0000000..2801b2f --- /dev/null +++ b/src/types/FSA/type.ts @@ -0,0 +1,24 @@ +// this is kind of the compromise for the zod restricts IModularResponseSchema and the backend python schema cannot match that +// see file externals/modules/shared/schemas/question-form.schema.ts for details +// since that is a external module, we should not edit that file + +import { z } from 'zod'; + +export const fsaAnswerSchema = z.object({ + states: z.array(z.string()), + alphabet: z.array(z.string()), + // Flattened: Array of "from|symbol|to" strings + transitions: z.array(z.string()), + initial_state: z.string(), + accept_states: z.array(z.string()), +}); + +export type FSA = z.infer; + +export const defaultFSA: FSA = { + states: ['q0'], + alphabet: [], + transitions: [], + initial_state: 'q0', + accept_states: [] +}; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index daeacc0..63e11e1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { CodeResponseAreaTub } from './Code' import { EssayResponseAreaTub } from './Essay' +import { FSAResponseAreaTub } from './FSA' import { MatrixResponseAreaTub } from './Matrix' import { MultipleChoiceResponseAreaTub } from './MultipleChoice' import { NumberResponseAreaTub } from './NumberInput' @@ -25,6 +26,7 @@ export const supportedResponseTypes = [ 'ESSAY', 'CODE', 'MILKDOWN', + 'FSA' ] if (typeof window !== 'undefined') { @@ -98,6 +100,8 @@ const createReponseAreaTub = (type: string): ResponseAreaTub => { return new EssayResponseAreaTub() case 'CODE': return new CodeResponseAreaTub() + case 'FSA': + return new FSAResponseAreaTub() case 'VOID': return new VoidResponseAreaTub() default: diff --git a/vite.config.ts b/vite.config.ts index 2b0af76..9b983a3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,7 @@ import { defineConfig } from 'vite' export default defineConfig({ plugins: [react()], + root: 'dev', // for dev only define: { 'process.env': JSON.stringify({ NODE_ENV: 'production' }), }, diff --git a/yarn.lock b/yarn.lock index 8ca7d57..ea1535e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1873,6 +1873,72 @@ "@react-spring/shared" "~9.7.5" "@react-spring/types" "~9.7.5" +"@reactflow/background@11.3.14": + version "11.3.14" + resolved "https://registry.npmmirror.com/@reactflow/background/-/background-11.3.14.tgz#778ca30174f3de77fc321459ab3789e66e71a699" + integrity sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA== + dependencies: + "@reactflow/core" "11.11.4" + classcat "^5.0.3" + zustand "^4.4.1" + +"@reactflow/controls@11.2.14": + version "11.2.14" + resolved "https://registry.npmmirror.com/@reactflow/controls/-/controls-11.2.14.tgz#508ed2c40d23341b3b0919dd11e76fd49cf850c7" + integrity sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw== + dependencies: + "@reactflow/core" "11.11.4" + classcat "^5.0.3" + zustand "^4.4.1" + +"@reactflow/core@11.11.4": + version "11.11.4" + resolved "https://registry.npmmirror.com/@reactflow/core/-/core-11.11.4.tgz#89bd86d1862aa1416f3f49926cede7e8c2aab6a7" + integrity sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q== + dependencies: + "@types/d3" "^7.4.0" + "@types/d3-drag" "^3.0.1" + "@types/d3-selection" "^3.0.3" + "@types/d3-zoom" "^3.0.1" + classcat "^5.0.3" + d3-drag "^3.0.0" + d3-selection "^3.0.0" + d3-zoom "^3.0.0" + zustand "^4.4.1" + +"@reactflow/minimap@11.7.14": + version "11.7.14" + resolved "https://registry.npmmirror.com/@reactflow/minimap/-/minimap-11.7.14.tgz#298d7a63cb1da06b2518c99744f716560c88ca73" + integrity sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ== + dependencies: + "@reactflow/core" "11.11.4" + "@types/d3-selection" "^3.0.3" + "@types/d3-zoom" "^3.0.1" + classcat "^5.0.3" + d3-selection "^3.0.0" + d3-zoom "^3.0.0" + zustand "^4.4.1" + +"@reactflow/node-resizer@2.2.14": + version "2.2.14" + resolved "https://registry.npmmirror.com/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz#1810c0ce51aeb936f179466a6660d1e02c7a77a8" + integrity sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA== + dependencies: + "@reactflow/core" "11.11.4" + classcat "^5.0.4" + d3-drag "^3.0.0" + d3-selection "^3.0.0" + zustand "^4.4.1" + +"@reactflow/node-toolbar@1.3.14": + version "1.3.14" + resolved "https://registry.npmmirror.com/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz#c6ffc76f82acacdce654f2160dc9852162d6e7c9" + integrity sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ== + dependencies: + "@reactflow/core" "11.11.4" + classcat "^5.0.3" + zustand "^4.4.1" + "@remirror/core-constants@3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@remirror/core-constants/-/core-constants-3.0.0.tgz#96fdb89d25c62e7b6a5d08caf0ce5114370e3b8f" @@ -2307,38 +2373,155 @@ resolved "https://registry.yarnpkg.com/@types/core-js/-/core-js-2.5.8.tgz#d5c6ec44f2f3328653dce385ae586bd8261f8e85" integrity sha512-VgnAj6tIAhJhZdJ8/IpxdatM8G4OD3VWGlp6xIxUGENZlpbob9Ty4VVdC1FIEp0aK6DBscDDjyzy5FB60TuNqg== -"@types/d3-color@^3.0.0": +"@types/d3-array@*": + version "3.2.2" + resolved "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz#e02151464d02d4a1b44646d0fcdb93faf88fde8c" + integrity sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw== + +"@types/d3-axis@*": + version "3.0.6" + resolved "https://registry.npmmirror.com/@types/d3-axis/-/d3-axis-3.0.6.tgz#e760e5765b8188b1defa32bc8bb6062f81e4c795" + integrity sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-brush@*": + version "3.0.6" + resolved "https://registry.npmmirror.com/@types/d3-brush/-/d3-brush-3.0.6.tgz#c2f4362b045d472e1b186cdbec329ba52bdaee6c" + integrity sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-chord@*": + version "3.0.6" + resolved "https://registry.npmmirror.com/@types/d3-chord/-/d3-chord-3.0.6.tgz#1706ca40cf7ea59a0add8f4456efff8f8775793d" + integrity sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg== + +"@types/d3-color@*", "@types/d3-color@^3.0.0": version "3.1.3" resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== -"@types/d3-delaunay@^6.0.4": +"@types/d3-contour@*": + version "3.0.6" + resolved "https://registry.npmmirror.com/@types/d3-contour/-/d3-contour-3.0.6.tgz#9ada3fa9c4d00e3a5093fed0356c7ab929604231" + integrity sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg== + dependencies: + "@types/d3-array" "*" + "@types/geojson" "*" + +"@types/d3-delaunay@*", "@types/d3-delaunay@^6.0.4": version "6.0.4" resolved "https://registry.yarnpkg.com/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz#185c1a80cc807fdda2a3fe960f7c11c4a27952e1" integrity sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw== +"@types/d3-dispatch@*": + version "3.0.7" + resolved "https://registry.npmmirror.com/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz#ef004d8a128046cfce434d17182f834e44ef95b2" + integrity sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA== + +"@types/d3-drag@*", "@types/d3-drag@^3.0.1": + version "3.0.7" + resolved "https://registry.npmmirror.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02" + integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-dsv@*": + version "3.0.7" + resolved "https://registry.npmmirror.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz#0a351f996dc99b37f4fa58b492c2d1c04e3dac17" + integrity sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g== + +"@types/d3-ease@*": + version "3.0.2" + resolved "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b" + integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA== + +"@types/d3-fetch@*": + version "3.0.7" + resolved "https://registry.npmmirror.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz#c04a2b4f23181aa376f30af0283dbc7b3b569980" + integrity sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA== + dependencies: + "@types/d3-dsv" "*" + +"@types/d3-force@*": + version "3.0.10" + resolved "https://registry.npmmirror.com/@types/d3-force/-/d3-force-3.0.10.tgz#6dc8fc6e1f35704f3b057090beeeb7ac674bff1a" + integrity sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw== + +"@types/d3-format@*": + version "3.0.4" + resolved "https://registry.npmmirror.com/@types/d3-format/-/d3-format-3.0.4.tgz#b1e4465644ddb3fdf3a263febb240a6cd616de90" + integrity sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g== + "@types/d3-format@^1.4.1": version "1.4.5" resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-1.4.5.tgz#6392303c2ca3c287c3a1a2046455cd0a0bd50bbe" integrity sha512-mLxrC1MSWupOSncXN/HOlWUAAIffAEBaI4+PKy2uMPsKe4FNZlk7qrbTjmzJXITQQqBHivaks4Td18azgqnotA== +"@types/d3-geo@*": + version "3.1.0" + resolved "https://registry.npmmirror.com/@types/d3-geo/-/d3-geo-3.1.0.tgz#b9e56a079449174f0a2c8684a9a4df3f60522440" + integrity sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ== + dependencies: + "@types/geojson" "*" + +"@types/d3-hierarchy@*": + version "3.1.7" + resolved "https://registry.npmmirror.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz#6023fb3b2d463229f2d680f9ac4b47466f71f17b" + integrity sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg== + +"@types/d3-interpolate@*": + version "3.0.4" + resolved "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" + integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== + dependencies: + "@types/d3-color" "*" + "@types/d3-path@*": version "3.1.1" resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.1.tgz#f632b380c3aca1dba8e34aa049bcd6a4af23df8a" integrity sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg== -"@types/d3-scale-chromatic@^3.0.0": +"@types/d3-polygon@*": + version "3.0.2" + resolved "https://registry.npmmirror.com/@types/d3-polygon/-/d3-polygon-3.0.2.tgz#dfae54a6d35d19e76ac9565bcb32a8e54693189c" + integrity sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA== + +"@types/d3-quadtree@*": + version "3.0.6" + resolved "https://registry.npmmirror.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz#d4740b0fe35b1c58b66e1488f4e7ed02952f570f" + integrity sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg== + +"@types/d3-random@*": + version "3.0.3" + resolved "https://registry.npmmirror.com/@types/d3-random/-/d3-random-3.0.3.tgz#ed995c71ecb15e0cd31e22d9d5d23942e3300cfb" + integrity sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ== + +"@types/d3-scale-chromatic@*", "@types/d3-scale-chromatic@^3.0.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#dc6d4f9a98376f18ea50bad6c39537f1b5463c39" integrity sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ== -"@types/d3-scale@^4.0.8": +"@types/d3-scale@*", "@types/d3-scale@^4.0.8": version "4.0.9" resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb" integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw== dependencies: "@types/d3-time" "*" +"@types/d3-selection@*", "@types/d3-selection@^3.0.3": + version "3.0.11" + resolved "https://registry.npmmirror.com/@types/d3-selection/-/d3-selection-3.0.11.tgz#bd7a45fc0a8c3167a631675e61bc2ca2b058d4a3" + integrity sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w== + +"@types/d3-shape@*": + version "3.1.8" + resolved "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3" + integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w== + dependencies: + "@types/d3-path" "*" + "@types/d3-shape@^3.1.6": version "3.1.7" resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.7.tgz#2b7b423dc2dfe69c8c93596e673e37443348c555" @@ -2346,6 +2529,11 @@ dependencies: "@types/d3-path" "*" +"@types/d3-time-format@*": + version "4.0.3" + resolved "https://registry.npmmirror.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz#d6bc1e6b6a7db69cccfbbdd4c34b70632d9e9db2" + integrity sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg== + "@types/d3-time-format@^2.3.1": version "2.3.4" resolved "https://registry.yarnpkg.com/@types/d3-time-format/-/d3-time-format-2.3.4.tgz#544af5184df8b3fc4d9b42b14058789acee2905e" @@ -2366,6 +2554,62 @@ resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-1.1.4.tgz#20da4b75c537a940e7319b75717c67a2e499515a" integrity sha512-JIvy2HjRInE+TXOmIGN5LCmeO0hkFZx5f9FZ7kiN+D+YTcc8pptsiLiuHsvwxwC7VVKmJ2ExHUgNlAiV7vQM9g== +"@types/d3-timer@*": + version "3.0.2" + resolved "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" + integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== + +"@types/d3-transition@*": + version "3.0.9" + resolved "https://registry.npmmirror.com/@types/d3-transition/-/d3-transition-3.0.9.tgz#1136bc57e9ddb3c390dccc9b5ff3b7d2b8d94706" + integrity sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-zoom@*", "@types/d3-zoom@^3.0.1": + version "3.0.8" + resolved "https://registry.npmmirror.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b" + integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw== + dependencies: + "@types/d3-interpolate" "*" + "@types/d3-selection" "*" + +"@types/d3@^7.4.0": + version "7.4.3" + resolved "https://registry.npmmirror.com/@types/d3/-/d3-7.4.3.tgz#d4550a85d08f4978faf0a4c36b848c61eaac07e2" + integrity sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww== + dependencies: + "@types/d3-array" "*" + "@types/d3-axis" "*" + "@types/d3-brush" "*" + "@types/d3-chord" "*" + "@types/d3-color" "*" + "@types/d3-contour" "*" + "@types/d3-delaunay" "*" + "@types/d3-dispatch" "*" + "@types/d3-drag" "*" + "@types/d3-dsv" "*" + "@types/d3-ease" "*" + "@types/d3-fetch" "*" + "@types/d3-force" "*" + "@types/d3-format" "*" + "@types/d3-geo" "*" + "@types/d3-hierarchy" "*" + "@types/d3-interpolate" "*" + "@types/d3-path" "*" + "@types/d3-polygon" "*" + "@types/d3-quadtree" "*" + "@types/d3-random" "*" + "@types/d3-scale" "*" + "@types/d3-scale-chromatic" "*" + "@types/d3-selection" "*" + "@types/d3-shape" "*" + "@types/d3-time" "*" + "@types/d3-time-format" "*" + "@types/d3-timer" "*" + "@types/d3-transition" "*" + "@types/d3-zoom" "*" + "@types/eslint@*": version "9.6.1" resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-9.6.1.tgz#d5795ad732ce81715f27f75da913004a56751584" @@ -2391,6 +2635,11 @@ resolved "https://registry.yarnpkg.com/@types/format-util/-/format-util-1.0.4.tgz#c4e3b556735149fdf047898a5b9c04650491509b" integrity sha512-xrCYOdHh5zA3LUrn6CvspYwlzSWxPso11Lx32WnAG6KvLCRecKZ/Rh21PLXUkzUFsQmrGcx/traJAFjR6dVS5Q== +"@types/geojson@*": + version "7946.0.16" + resolved "https://registry.npmmirror.com/@types/geojson/-/geojson-7946.0.16.tgz#8ebe53d69efada7044454e3305c19017d97ced2a" + integrity sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg== + "@types/json-schema@*", "@types/json-schema@^7.0.15": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" @@ -3120,6 +3369,11 @@ chance@^1.1.12: resolved "https://registry.yarnpkg.com/chance/-/chance-1.1.13.tgz#d4ecfd20c5e6799aaf5c2270d7653b32385cd6e3" integrity sha512-V6lQCljcLznE7tUYUM9EOAnnKXbctE6j/rdQkYOHIWbfGQbrzTsAXNW9CdU5XCo4ArXQCj/rb6HgxPlmGJcaUg== +classcat@^5.0.3, classcat@^5.0.4: + version "5.0.5" + resolved "https://registry.npmmirror.com/classcat/-/classcat-5.0.5.tgz#8c209f359a93ac302404a10161b501eba9c09c77" + integrity sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w== + classnames@^2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" @@ -3279,6 +3533,24 @@ d3-delaunay@^6.0.4: dependencies: delaunator "5" +"d3-dispatch@1 - 3": + version "3.0.1" + resolved "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e" + integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg== + +"d3-drag@2 - 3", d3-drag@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba" + integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg== + dependencies: + d3-dispatch "1 - 3" + d3-selection "3" + +"d3-ease@1 - 3": + version "3.0.1" + resolved "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" + integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== + "d3-format@1 - 3": version "3.1.0" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641" @@ -3320,6 +3592,11 @@ d3-scale@^4.0.2: d3-time "2.1.1 - 3" d3-time-format "2 - 4" +"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" + integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== + d3-shape@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5" @@ -3360,6 +3637,33 @@ d3-time@^1.0.11: resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1" integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA== +"d3-timer@1 - 3": + version "3.0.1" + resolved "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" + integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== + +"d3-transition@2 - 3": + version "3.0.1" + resolved "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f" + integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w== + dependencies: + d3-color "1 - 3" + d3-dispatch "1 - 3" + d3-ease "1 - 3" + d3-interpolate "1 - 3" + d3-timer "1 - 3" + +d3-zoom@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3" + integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw== + dependencies: + d3-dispatch "1 - 3" + d3-drag "2 - 3" + d3-interpolate "1 - 3" + d3-selection "2 - 3" + d3-transition "2 - 3" + data-view-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" @@ -5649,6 +5953,18 @@ react@^18.3.1: dependencies: loose-envify "^1.1.0" +reactflow@^11.11.4: + version "11.11.4" + resolved "https://registry.npmmirror.com/reactflow/-/reactflow-11.11.4.tgz#e3593e313420542caed81aecbd73fb9bc6576653" + integrity sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og== + dependencies: + "@reactflow/background" "11.3.14" + "@reactflow/controls" "11.2.14" + "@reactflow/core" "11.11.4" + "@reactflow/minimap" "11.7.14" + "@reactflow/node-resizer" "2.2.14" + "@reactflow/node-toolbar" "1.3.14" + read-pkg@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389" @@ -6516,6 +6832,11 @@ use-sync-external-store@^1, use-sync-external-store@^1.0.0, use-sync-external-st resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0" integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A== +use-sync-external-store@^1.2.2: + version "1.6.0" + resolved "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== + util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -6688,3 +7009,10 @@ zod@^3.14.4: version "3.25.76" resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== + +zustand@^4.4.1: + version "4.5.7" + resolved "https://registry.npmmirror.com/zustand/-/zustand-4.5.7.tgz#7d6bb2026a142415dd8be8891d7870e6dbe65f55" + integrity sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw== + dependencies: + use-sync-external-store "^1.2.2" From b9e9a3086e19ec0a5548252ebd579e62b943862f Mon Sep 17 00:00:00 2001 From: everythingfades Date: Fri, 16 Jan 2026 01:08:45 +0000 Subject: [PATCH 02/29] fix: dev notice in FSA.md --- FSA.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/FSA.md b/FSA.md index 5a76502..929f1eb 100644 --- a/FSA.md +++ b/FSA.md @@ -94,4 +94,20 @@ Since the frontend and backend see the data differently, the `FSAConverter` util * **Accept States**: Nodes in `accept_states` are rendered with a double-border (4px double). -* **Alphabet Consistency**: The `alphabet` array is automatically derived from the unique labels present in the transitions during the sync process. \ No newline at end of file +* **Alphabet Consistency**: The `alphabet` array is automatically derived from the unique labels present in the transitions during the sync process. + +## 6. Dev Notice: + +There is a temporary folder `/dev`, all the development stuff should be tested there + +run `yarn vite` or `yarn dev:fsa` to run + +also take notice in order for yarn to be configured correctly, there is a extra config + +```json +root: 'dev', // for dev only +``` + +in the vite.config.ts + +remember to remove it when we get to production \ No newline at end of file From 675d61227ba17a94428e44da62ad1ab0fd3f88fe Mon Sep 17 00:00:00 2001 From: everythingfades Date: Fri, 16 Jan 2026 23:49:06 +0000 Subject: [PATCH 03/29] fix: updated UI display, removed prompting, add information panel, need to add more info for teacher mode and find ways to test backend connect, schema may need fix as JSON.stringfy can walk pass the zod schema check --- dev/main.tsx | 20 +- src/sandbox-component.tsx | 5 +- src/types/FSA/FSA.component.tsx | 394 +++++++++++++++++++++++++------- src/types/FSA/index.tsx | 42 +++- vite.config.ts | 2 +- 5 files changed, 356 insertions(+), 107 deletions(-) diff --git a/dev/main.tsx b/dev/main.tsx index 0e76b9b..eec2322 100644 --- a/dev/main.tsx +++ b/dev/main.tsx @@ -3,35 +3,29 @@ import React, { useState } from 'react' import ReactDOM from 'react-dom/client' import { FSAResponseAreaTub } from '../src/types/FSA' +import { FSAInput } from '../src/types/FSA/FSA.component' +import { defaultFSA, FSA } from '../src/types/FSA/type' + const tub = new FSAResponseAreaTub() function Sandbox() { const [answer, setAnswer] = - useState(null) + useState(defaultFSA) - const [allowSave, setAllowSave] = useState(true) + const [, setAllowSave] = useState(true) return ( <>

Input

- console.log('submit')} - handleDraftSave={() => console.log('draft save')} - displayMode="normal" - hasPreview + onChange={(val) => console.log("wizard change", val)} />

Wizard

- console.log('wizard change', val)} - setAllowSave={setAllowSave} - /> ) } diff --git a/src/sandbox-component.tsx b/src/sandbox-component.tsx index ec553ef..098da57 100644 --- a/src/sandbox-component.tsx +++ b/src/sandbox-component.tsx @@ -1,13 +1,14 @@ import { ThemeProvider } from '@styles/minimal/theme-provider' -import { SandboxResponseAreaTub } from './types/Sandbox' +import { FSAResponseAreaTub } from './types/FSA' +// import { SandboxResponseAreaTub } from './types/Sandbox' function ResponseAreaInputWrapper({ children }: { children: React.ReactNode }) { return {children} } // wrap the components with the necessary providers; only in the sandbox -class WrappedSandboxResponseAreaTub extends SandboxResponseAreaTub { +class WrappedSandboxResponseAreaTub extends FSAResponseAreaTub { constructor() { super() diff --git a/src/types/FSA/FSA.component.tsx b/src/types/FSA/FSA.component.tsx index fc03e35..284df6c 100644 --- a/src/types/FSA/FSA.component.tsx +++ b/src/types/FSA/FSA.component.tsx @@ -1,34 +1,154 @@ -import React, { useCallback, useMemo } from 'react'; -import ReactFlow, { - addEdge, - Background, - Controls, - Connection, - useNodesState, +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import ReactFlow, { + addEdge, + Background, + Controls, + Connection, + useNodesState, useEdgesState, MarkerType, Edge, Node, - OnNodesDelete } from 'reactflow'; import 'reactflow/dist/style.css'; import { FSA } from './type'; +import { makeStyles } from '@styles'; + +/* -------------------- styles -------------------- */ + +const useLocalStyles = makeStyles()((theme) => ({ + container: { + width: '100%', + height: 600, + display: 'flex', + border: '1px solid #ddd', + fontFamily: 'sans-serif', + }, + panel: { + width: 280, + borderRight: '1px solid #ddd', + padding: theme.spacing(2), + backgroundColor: '#fafafa', + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + }, + 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), + cursor: 'pointer', + padding: '4px 0', + }, + deleteButton: { + marginTop: theme.spacing(2), + padding: '8px', + backgroundColor: '#fff1f0', + color: '#cf1322', + border: '1px solid #ffa39e', + borderRadius: 4, + cursor: 'pointer', + fontWeight: 600, + '&:hover': { + backgroundColor: '#ffa39e', + color: '#fff', + }, + }, + flowWrapper: { + flexGrow: 1, + display: 'flex', + flexDirection: 'column', + }, + toolbar: { + padding: theme.spacing(1), + borderBottom: '1px solid #eee', + backgroundColor: '#f9f9f9', + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1.5), + }, + addButton: { + padding: '4px 12px', + cursor: 'pointer', + backgroundColor: '#fff', + border: '1px solid #ccc', + borderRadius: 4, + }, + node: { + border: '1px solid #777', + borderRadius: '50%', + width: 50, + height: 50, + backgroundColor: '#fff', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 'bold', + }, + initialNode: { + backgroundColor: '#e6fffa', + borderWidth: 2, + borderColor: '#38b2ac', + }, + acceptNode: { + boxShadow: '0 0 0 4px #fff, 0 0 0 6px #333', + }, +})); + +/* -------------------- component -------------------- */ + interface FSAInputProps { answer: FSA; onChange: (val: FSA) => void; + isTeacherMode?: boolean; } -export const FSAInput: React.FC = ({ answer, onChange }) => { - // 1. Unpack flattened strings into React Flow Edges - // we could use the convertor, but lets just keep this here +export const FSAInput: React.FC = ({ + answer, + onChange, + isTeacherMode, +}) => { + const { classes, cx } = useLocalStyles(); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); + + useEffect(() => { + // import the css, since the import 'reactflow/dist/style.css'; dont seems to work + // the css is on the cdn anyway + const linkId = 'react-flow-css'; + if (!document.getElementById(linkId)) { + const link = document.createElement('link'); + link.id = linkId; link.rel = 'stylesheet'; + link.href = 'https://cdn.jsdelivr.net/npm/reactflow@11.10.4/dist/style.css'; + // Use a CDN fallback + document.head.appendChild(link); + } + }, []); + const initialEdges: Edge[] = useMemo(() => { return (answer.transitions || []).reduce((acc: Edge[], tStr: string) => { const [from, symbol, to] = tStr.split('|'); if (from && symbol && to) { acc.push({ - id: `e-${from}-${to}-${symbol}`, + id: `e-${from}-${to}-${symbol}-${Date.now()}`, source: from, target: to, label: symbol, @@ -39,92 +159,198 @@ export const FSAInput: React.FC = ({ answer, onChange }) => { }, []); }, [answer.transitions]); - const initialNodes: Node[] = useMemo(() => - answer.states.map((s, i) => ({ - id: s, - data: { label: s }, - position: { x: i * 150, y: 100 }, - style: { - border: answer.accept_states.includes(s) ? '4px double #333' : '1px solid #777', - background: s === answer.initial_state ? '#e6fffa' : '#fff', - borderRadius: '50%', width: 50, height: 50, - display: 'flex', alignItems: 'center', justifyContent: 'center' - } - })), [answer]); + const initialNodes: Node[] = useMemo( + () => + answer.states.map((s, i) => ({ + id: s, + data: { label: s }, + position: { x: i * 120 + 50, y: 150 }, + className: cx( + classes.node, + s === answer.initial_state && classes.initialNode, + answer.accept_states.includes(s) && classes.acceptNode, + ), + })), + [answer.states, answer.initial_state, answer.accept_states, classes, cx], + ); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - // Sync helper to convert current Flow state back to FSA format - const syncChanges = useCallback((currentNodes: Node[], currentEdges: Edge[]) => { - const updatedFSA: FSA = { - ...answer, - states: currentNodes.map(n => n.id), - transitions: currentEdges.map(e => `${e.source}|${e.label}|${e.target}`), - alphabet: Array.from(new Set(currentEdges.map(e => String(e.label)))) - }; - onChange(updatedFSA); - }, [answer, onChange]); - - // Handle Adding Nodes - const addState = useCallback(() => { - const id = prompt("Enter state name (e.g. q2):"); - if (!id || nodes.find(n => n.id === id)) return; - - const newNode: Node = { - id, - data: { label: id }, - position: { x: Math.random() * 400, y: Math.random() * 400 }, - style: { border: '1px solid #777', borderRadius: '50%', width: 50, height: 50 } - }; - - const updatedNodes = [...nodes, newNode]; - setNodes(updatedNodes); - syncChanges(updatedNodes, edges); - }, [nodes, edges, setNodes, syncChanges]); - - // Handle Deleting Nodes (triggered by Backspace/Delete key by default in React Flow) - const onNodesDelete: OnNodesDelete = useCallback((deletedNodes) => { - const deletedIds = new Set(deletedNodes.map(n => n.id)); - const remainingNodes = nodes.filter(n => !deletedIds.has(n.id)); - // React Flow handles edge cleanup internally in the 'edges' state, - // but we need to ensure our sync uses the filtered edges. - const remainingEdges = edges.filter(e => !deletedIds.has(e.source) && !deletedIds.has(e.target)); - + const selectedNode = useMemo(() => nodes.find((n) => n.id === selectedNodeId), [nodes, selectedNodeId]); + const selectedEdge = useMemo(() => edges.find((e) => e.id === selectedEdgeId), [edges, selectedEdgeId]); + + const syncChanges = useCallback( + (currentNodes: Node[], currentEdges: Edge[]) => { + onChange({ + ...answer, + states: currentNodes.map((n) => n.id), + transitions: currentEdges.map((e) => `${e.source}|${e.label || 'ε'}|${e.target}`), + alphabet: Array.from(new Set(currentEdges.map((e) => String(e.label || 'ε')))).filter(s => s !== 'ε'), + }); + }, + [answer, onChange], + ); + + const deleteSelectedNode = () => { + if (!selectedNodeId) return; + const remainingNodes = nodes.filter((n) => n.id !== selectedNodeId); + const remainingEdges = edges.filter((e) => e.source !== selectedNodeId && e.target !== selectedNodeId); + setNodes(remainingNodes); + setEdges(remainingEdges); syncChanges(remainingNodes, remainingEdges); - }, [nodes, edges, syncChanges]); - - const onConnect = useCallback((params: Connection) => { - const symbol = prompt("Transition symbol:") || 'ε'; - const newEdge = { ...params, label: symbol, markerEnd: { type: MarkerType.ArrowClosed } }; - - setEdges((eds) => { - const updatedEdges = addEdge(newEdge, eds); - syncChanges(nodes, updatedEdges); - return updatedEdges; - }); - }, [nodes, syncChanges, setEdges]); + setSelectedNodeId(null); + }; + + const deleteSelectedEdge = () => { + if (!selectedEdgeId) return; + const remainingEdges = edges.filter((e) => e.id !== selectedEdgeId); + setEdges(remainingEdges); + syncChanges(nodes, remainingEdges); + setSelectedEdgeId(null); + }; + + const onConnect = useCallback( + (params: Connection) => { + if (!params.source || !params.target) return; + const symbol = `tran-${Date.now()}` + const newEdge: Edge = { + ...params, + id: `edge-${Date.now()}`, + source: params.source, + target: params.target, + label: symbol, + markerEnd: { type: MarkerType.ArrowClosed }, + }; + setEdges((eds) => { + const updated = addEdge(newEdge, eds); + syncChanges(nodes, updated); + return updated; + }); + }, + [nodes, syncChanges, setEdges], + ); + + // Prevent backspace in input from deleting node/edge in React Flow + const stopPropagation = (e: React.KeyboardEvent) => e.stopPropagation(); return ( -
-
- - - Select a node/edge and press Backspace to delete. - +
+
+
Item Properties
+ + {selectedNode && ( + <> +
+ + { + const newId = e.target.value.trim(); + if (!newId || nodes.some(n => n.id === newId)) return; + const oldId = selectedNode.id; + const updatedNodes = nodes.map(n => n.id === oldId ? { ...n, id: newId, data: { label: newId } } : n); + const updatedEdges = edges.map(e => ({ + ...e, + source: e.source === oldId ? newId : e.source, + target: e.target === oldId ? newId : e.target + })); + setNodes(updatedNodes); + setEdges(updatedEdges); + setSelectedNodeId(newId); + syncChanges(updatedNodes, updatedEdges); + }} + /> +
+
+ { + if (e.target.checked) { + // Only one initial state allowed + onChange({ + ...answer, + initial_state: selectedNode.id, + }); + } else { + if (answer.initial_state === selectedNode.id){ + onChange({ + ...answer, + initial_state: '', + }); + } + } + }} + /> + +
+ +
+ { + const isChecked = e.target.checked; + + onChange({ + ...answer, + accept_states: isChecked + ? [...answer.accept_states, selectedNode.id] + : answer.accept_states.filter( + (s) => s !== selectedNode.id, + ), + }); + }} + /> + +
+ + + )} + + {selectedEdge && ( + <> +
+ + { + const updatedEdges = edges.map(ed => ed.id === selectedEdgeId ? { ...ed, label: e.target.value } : ed); + setEdges(updatedEdges); + syncChanges(nodes, updatedEdges); + }} + /> +
+ + + )} + + {!selectedNode && !selectedEdge &&
Select an element to edit
}
- -
+ +
+
+ +
{ setSelectedNodeId(n.id); setSelectedEdgeId(null); }} + onEdgeClick={(_, e) => { setSelectedEdgeId(e.id); setSelectedNodeId(null); }} + deleteKeyCode={null} // Disables keyboard delete to prevent conflicts > diff --git a/src/types/FSA/index.tsx b/src/types/FSA/index.tsx index d7c4172..cac9160 100644 --- a/src/types/FSA/index.tsx +++ b/src/types/FSA/index.tsx @@ -11,22 +11,50 @@ export class FSAResponseAreaTub extends ResponseAreaTub { protected answerSchema = fsaAnswerSchema; protected answer: FSA = defaultFSA; + initWithConfig = (config: any) => { + this.config = config // config is not used for now + this.answer = defaultFSA + } + + customCheck = () => {} // will set this up later + InputComponent = (props: BaseResponseAreaProps) => { - const parsedAnswer = this.answerSchema.safeParse(props.answer); + // Always derive a local FSA value + console.log('FSA InputComponent props.answer:', props.answer, typeof props.answer); + const fsaAnswer: FSA = (() => { + if (!props.answer) return defaultFSA; + if (typeof props.answer === 'string') { + try { + return JSON.parse(props.answer); + } catch { + return defaultFSA; + } + } + + return props.answer as FSA; + })(); + return ( - props.handleChange(val)} - /> + <> +

Input Component

+ { + props.handleChange(JSON.stringify(answer)); + }} + /> + ); - } + }; WizardComponent = (props: BaseResponseAreaWizardProps) => { return ( { + this.answer = answer props.handleChange({ responseType: this.responseType, answer, diff --git a/vite.config.ts b/vite.config.ts index 9b983a3..b385a8c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from 'vite' export default defineConfig({ plugins: [react()], - root: 'dev', // for dev only + // root: 'dev', // for dev only define: { 'process.env': JSON.stringify({ NODE_ENV: 'production' }), }, From da333ba284255bc4b00b2f740b738503337b4d0d Mon Sep 17 00:00:00 2001 From: everythingfades Date: Sun, 25 Jan 2026 18:34:56 +0000 Subject: [PATCH 04/29] refactor: Reactflow is not so suitable for maths, changing to cytoscape instead --- package.json | 1 + src/types/FSA/FSA.component.tsx | 476 ++++++++++++++++---------------- src/types/FSA/index.tsx | 2 + src/types/FSA/type.ts | 4 +- yarn.lock | 5 + 5 files changed, 244 insertions(+), 244 deletions(-) diff --git a/package.json b/package.json index 1698e24..c64c1c2 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@nivo/core": "^0.88.0", "@nivo/line": "^0.88.0", "@nivo/pie": "^0.88.0", + "cytoscape": "^3.33.1", "date-fns": "^2.28.0", "framer-motion": "^11.2.10", "katex": "^0.16.2", diff --git a/src/types/FSA/FSA.component.tsx b/src/types/FSA/FSA.component.tsx index 284df6c..0ec2d10 100644 --- a/src/types/FSA/FSA.component.tsx +++ b/src/types/FSA/FSA.component.tsx @@ -1,23 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import ReactFlow, { - addEdge, - Background, - Controls, - Connection, - useNodesState, - useEdgesState, - MarkerType, - Edge, - Node, -} from 'reactflow'; - -import 'reactflow/dist/style.css'; -import { FSA } from './type'; - import { makeStyles } from '@styles'; +import cytoscape, { Core, NodeSingular, EdgeSingular } from 'cytoscape'; +import React, { useEffect, useRef, useState } from 'react'; -/* -------------------- styles -------------------- */ +import { FSA } from './type'; +/* -------------------- styles -------------------- */ const useLocalStyles = makeStyles()((theme) => ({ container: { width: '100%', @@ -55,235 +42,259 @@ const useLocalStyles = makeStyles()((theme) => ({ display: 'flex', alignItems: 'center', gap: theme.spacing(1), + }, + addButton: { + padding: '6px 10px', cursor: 'pointer', - padding: '4px 0', + backgroundColor: '#fff', + border: '1px solid #ccc', + borderRadius: 4, }, deleteButton: { - marginTop: theme.spacing(2), - padding: '8px', + padding: '6px', backgroundColor: '#fff1f0', color: '#cf1322', border: '1px solid #ffa39e', borderRadius: 4, cursor: 'pointer', fontWeight: 600, - '&:hover': { - backgroundColor: '#ffa39e', - color: '#fff', - }, }, - flowWrapper: { + cyWrapper: { flexGrow: 1, - display: 'flex', - flexDirection: 'column', - }, - toolbar: { - padding: theme.spacing(1), - borderBottom: '1px solid #eee', - backgroundColor: '#f9f9f9', - display: 'flex', - alignItems: 'center', - gap: theme.spacing(1.5), - }, - addButton: { - padding: '4px 12px', - cursor: 'pointer', - backgroundColor: '#fff', - border: '1px solid #ccc', - borderRadius: 4, - }, - node: { - border: '1px solid #777', - borderRadius: '50%', - width: 50, - height: 50, - backgroundColor: '#fff', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontWeight: 'bold', - }, - initialNode: { - backgroundColor: '#e6fffa', - borderWidth: 2, - borderColor: '#38b2ac', - }, - acceptNode: { - boxShadow: '0 0 0 4px #fff, 0 0 0 6px #333', }, })); /* -------------------- component -------------------- */ - interface FSAInputProps { answer: FSA; onChange: (val: FSA) => void; - isTeacherMode?: boolean; } -export const FSAInput: React.FC = ({ - answer, - onChange, - isTeacherMode, -}) => { - const { classes, cx } = useLocalStyles(); - const [selectedNodeId, setSelectedNodeId] = useState(null); - const [selectedEdgeId, setSelectedEdgeId] = useState(null); - - useEffect(() => { - // import the css, since the import 'reactflow/dist/style.css'; dont seems to work - // the css is on the cdn anyway - const linkId = 'react-flow-css'; - if (!document.getElementById(linkId)) { - const link = document.createElement('link'); - link.id = linkId; link.rel = 'stylesheet'; - link.href = 'https://cdn.jsdelivr.net/npm/reactflow@11.10.4/dist/style.css'; - // Use a CDN fallback - document.head.appendChild(link); - } +export const FSAInput: React.FC = ({ answer, onChange }) => { + const { classes } = useLocalStyles(); + const containerRef = useRef(null); + const cyRef = useRef(null); + + // State for UI and logic + const [drawMode, setDrawMode] = useState(false); + const [fromNodeId, setFromNodeId] = useState(null); + const [toNodeId, setToNodeId] = useState(null); + const [selectedNode, setSelectedNode] = useState(null); + const [selectedEdge, setSelectedEdge] = useState(null); + + /* -------------------- initialize cytoscape -------------------- */ + useEffect(() => { + if (!containerRef.current) return; + + const cy = 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', + }, + }, + { + selector: 'edge', + style: { + label: 'data(label)', + 'curve-style': 'bezier', + 'target-arrow-shape': 'triangle', + 'line-color': '#555', + 'target-arrow-color': '#555', + }, + }, + { + selector: '.edge-source', + style: { + 'border-color': '#1890ff', + 'border-width': 3, + }, + }, + { + selector: '.edge-target', + style: { + 'border-color': '#52c41a', + 'border-width': 3, + }, + }, + ], + }); + + cyRef.current = cy; + + return () => cy.destroy(); }, []); - const initialEdges: Edge[] = useMemo(() => { - return (answer.transitions || []).reduce((acc: Edge[], tStr: string) => { - const [from, symbol, to] = tStr.split('|'); - if (from && symbol && to) { - acc.push({ - id: `e-${from}-${to}-${symbol}-${Date.now()}`, - source: from, - target: to, - label: symbol, - markerEnd: { type: MarkerType.ArrowClosed }, - }); + /* -------------------- attach handlers -------------------- */ + useEffect(() => { + const cy = cyRef.current; + if (!cy) return; + + const handleNodeTap = (e: cytoscape.EventObject) => { + const node = e.target as NodeSingular; + + if (drawMode) { + if (!fromNodeId) { + setFromNodeId(node.id()); + node.addClass('edge-source'); + } else if (!toNodeId) { + setToNodeId(node.id()); + node.addClass('edge-target'); + } + return; } - return acc; - }, []); - }, [answer.transitions]); - - const initialNodes: Node[] = useMemo( - () => - answer.states.map((s, i) => ({ - id: s, - data: { label: s }, - position: { x: i * 120 + 50, y: 150 }, - className: cx( - classes.node, - s === answer.initial_state && classes.initialNode, - answer.accept_states.includes(s) && classes.acceptNode, - ), - })), - [answer.states, answer.initial_state, answer.accept_states, classes, cx], - ); - const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - - const selectedNode = useMemo(() => nodes.find((n) => n.id === selectedNodeId), [nodes, selectedNodeId]); - const selectedEdge = useMemo(() => edges.find((e) => e.id === selectedEdgeId), [edges, selectedEdgeId]); - - const syncChanges = useCallback( - (currentNodes: Node[], currentEdges: Edge[]) => { - onChange({ - ...answer, - states: currentNodes.map((n) => n.id), - transitions: currentEdges.map((e) => `${e.source}|${e.label || 'ε'}|${e.target}`), - alphabet: Array.from(new Set(currentEdges.map((e) => String(e.label || 'ε')))).filter(s => s !== 'ε'), - }); - }, - [answer, onChange], - ); + setSelectedNode(node); + setSelectedEdge(null); + }; - const deleteSelectedNode = () => { - if (!selectedNodeId) return; - const remainingNodes = nodes.filter((n) => n.id !== selectedNodeId); - const remainingEdges = edges.filter((e) => e.source !== selectedNodeId && e.target !== selectedNodeId); - setNodes(remainingNodes); - setEdges(remainingEdges); - syncChanges(remainingNodes, remainingEdges); - setSelectedNodeId(null); - }; + const handleEdgeTap = (e: cytoscape.EventObject) => { + if (drawMode) return; + setSelectedEdge(e.target as EdgeSingular); + setSelectedNode(null); + }; + + cy.on('tap', 'node', handleNodeTap); + cy.on('tap', 'edge', handleEdgeTap); + + return () => { + cy.off('tap', 'node', handleNodeTap); + cy.off('tap', 'edge', handleEdgeTap); + }; + }, [drawMode, fromNodeId, toNodeId]); + + /* -------------------- draw transition effect -------------------- */ + useEffect(() => { + const cy = cyRef.current; + if (!drawMode || !fromNodeId || !toNodeId || !cy) return; + + cy.add({ + group: 'edges', + data: { + id: `e-${fromNodeId}-${toNodeId}-${Date.now()}`, + source: fromNodeId, + target: toNodeId, + label: 'a', + }, + }); + + cy.nodes().removeClass('edge-source edge-target'); + + setDrawMode(false); + setFromNodeId(null); + setToNodeId(null); + + syncToAnswer(); + }, [drawMode, fromNodeId, toNodeId]); + + /* -------------------- helpers -------------------- */ + const syncToAnswer = () => { + const cy = cyRef.current; + if (!cy) return; - const deleteSelectedEdge = () => { - if (!selectedEdgeId) return; - const remainingEdges = edges.filter((e) => e.id !== selectedEdgeId); - setEdges(remainingEdges); - syncChanges(nodes, remainingEdges); - setSelectedEdgeId(null); + const states = cy.nodes().map((n) => n.id()); + const transitions = cy + .edges() + .map((e) => `${e.source().id()}|${e.data('label') || 'ε'}|${e.target().id()}`); + + onChange({ + ...answer, + states, + transitions, + alphabet: Array.from( + new Set(transitions.map((t) => t.split('|')[1]).filter(s => s !== undefined)), + ), + }); }; - const onConnect = useCallback( - (params: Connection) => { - if (!params.source || !params.target) return; - const symbol = `tran-${Date.now()}` - const newEdge: Edge = { - ...params, - id: `edge-${Date.now()}`, - source: params.source, - target: params.target, - label: symbol, - markerEnd: { type: MarkerType.ArrowClosed }, - }; - setEdges((eds) => { - const updated = addEdge(newEdge, eds); - syncChanges(nodes, updated); - return updated; - }); - }, - [nodes, syncChanges, setEdges], - ); + const addState = () => { + const cy = cyRef.current; + if (!cy) return; - // Prevent backspace in input from deleting node/edge in React Flow - const stopPropagation = (e: React.KeyboardEvent) => e.stopPropagation(); + const id = `q${cy.nodes().length}`; + cy.add({ + group: 'nodes', + data: { id, label: id, displayLabel: id }, + position: { x: 100 + Math.random() * 300, y: 100 + Math.random() * 300 }, + }); + syncToAnswer(); + }; + + const deleteSelected = () => { + selectedNode?.remove(); + selectedEdge?.remove(); + setSelectedNode(null); + setSelectedEdge(null); + syncToAnswer(); + }; + + /* -------------------- render -------------------- */ return (
+
Controls
+ + + + + + {drawMode && ( + <> +
From Node: {fromNodeId}
+
To Node: {toNodeId}
+ + )} +
Item Properties
+ {/* Node Properties */} {selectedNode && ( <>
- + { - const newId = e.target.value.trim(); - if (!newId || nodes.some(n => n.id === newId)) return; - const oldId = selectedNode.id; - const updatedNodes = nodes.map(n => n.id === oldId ? { ...n, id: newId, data: { label: newId } } : n); - const updatedEdges = edges.map(e => ({ - ...e, - source: e.source === oldId ? newId : e.source, - target: e.target === oldId ? newId : e.target - })); - setNodes(updatedNodes); - setEdges(updatedEdges); - setSelectedNodeId(newId); - syncChanges(updatedNodes, updatedEdges); + selectedNode.data('displayLabel', e.target.value); }} />
+
{ - if (e.target.checked) { - // Only one initial state allowed - onChange({ - ...answer, - initial_state: selectedNode.id, - }); - } else { - if (answer.initial_state === selectedNode.id){ - onChange({ - ...answer, - initial_state: '', - }); - } - } - }} + checked={answer.initial_state === selectedNode.id()} + onChange={(e) => + onChange({ + ...answer, + initial_state: e.target.checked ? selectedNode.id() : '', + }) + } />
@@ -291,71 +302,52 @@ export const FSAInput: React.FC = ({
{ - const isChecked = e.target.checked; - + checked={answer.accept_states.includes(selectedNode.id())} + onChange={(e) => onChange({ ...answer, - accept_states: isChecked - ? [...answer.accept_states, selectedNode.id] - : answer.accept_states.filter( - (s) => s !== selectedNode.id, - ), - }); - }} + accept_states: e.target.checked + ? [...answer.accept_states, selectedNode.id()] + : answer.accept_states.filter((s) => s !== selectedNode.id()), + }) + } />
- + + )} + {/* Edge Properties */} {selectedEdge && ( <>
{ - const updatedEdges = edges.map(ed => ed.id === selectedEdgeId ? { ...ed, label: e.target.value } : ed); - setEdges(updatedEdges); - syncChanges(nodes, updatedEdges); + selectedEdge.data('label', e.target.value); + syncToAnswer(); }} />
- + + )} - {!selectedNode && !selectedEdge &&
Select an element to edit
} + {!selectedNode && !selectedEdge && ( +
Select an element to edit
+ )}
-
-
- -
- { setSelectedNodeId(n.id); setSelectedEdgeId(null); }} - onEdgeClick={(_, e) => { setSelectedEdgeId(e.id); setSelectedNodeId(null); }} - deleteKeyCode={null} // Disables keyboard delete to prevent conflicts - > - - - -
+
); -}; \ No newline at end of file +}; diff --git a/src/types/FSA/index.tsx b/src/types/FSA/index.tsx index cac9160..998a922 100644 --- a/src/types/FSA/index.tsx +++ b/src/types/FSA/index.tsx @@ -1,4 +1,6 @@ // FSAResponseArea.tub.ts +import z from 'zod'; + import { BaseResponseAreaProps, BaseResponseAreaWizardProps } from '../base-props.type'; import { ResponseAreaTub } from '../response-area-tub'; diff --git a/src/types/FSA/type.ts b/src/types/FSA/type.ts index 2801b2f..a8794c9 100644 --- a/src/types/FSA/type.ts +++ b/src/types/FSA/type.ts @@ -16,9 +16,9 @@ export const fsaAnswerSchema = z.object({ export type FSA = z.infer; export const defaultFSA: FSA = { - states: ['q0'], + states: [], alphabet: [], transitions: [], - initial_state: 'q0', + initial_state: '', accept_states: [] }; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index ea1535e..62bcefd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3507,6 +3507,11 @@ csstype@^3.0.2, csstype@^3.1.3: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== +cytoscape@^3.33.1: + version "3.33.1" + resolved "https://registry.npmmirror.com/cytoscape/-/cytoscape-3.33.1.tgz#449e05d104b760af2912ab76482d24c01cdd4c97" + integrity sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ== + d3-array@2: version "2.12.1" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-2.12.1.tgz#e20b41aafcdffdf5d50928004ececf815a465e81" From 21e9a1163d8597345ed7e057d439931adddb452c Mon Sep 17 00:00:00 2001 From: everythingfades Date: Tue, 27 Jan 2026 23:40:44 +0000 Subject: [PATCH 05/29] fix: align with the backend, can pass basic tests --- src/types/FSA/FSA.component.tsx | 413 +++++------------- src/types/FSA/components/ConfigPanel.tsx | 75 ++++ .../FSA/components/ItemPropertiesPanel.tsx | 159 +++++++ src/types/FSA/index.tsx | 106 ++--- src/types/FSA/styles.ts | 102 +++++ src/types/FSA/type.ts | 39 +- 6 files changed, 551 insertions(+), 343 deletions(-) create mode 100644 src/types/FSA/components/ConfigPanel.tsx create mode 100644 src/types/FSA/components/ItemPropertiesPanel.tsx create mode 100644 src/types/FSA/styles.ts diff --git a/src/types/FSA/FSA.component.tsx b/src/types/FSA/FSA.component.tsx index 0ec2d10..6e523a6 100644 --- a/src/types/FSA/FSA.component.tsx +++ b/src/types/FSA/FSA.component.tsx @@ -1,92 +1,38 @@ -import { makeStyles } from '@styles'; -import cytoscape, { Core, NodeSingular, EdgeSingular } from 'cytoscape'; -import React, { useEffect, useRef, useState } from 'react'; +import { makeStyles } from '@styles' +import cytoscape, { Core, NodeSingular, EdgeSingular } from 'cytoscape' +import React, { useEffect, useRef, useState } from 'react' -import { FSA } from './type'; +import ConfigPanel from './components/ConfigPanel' +import ItemPropertiesPanel from './components/ItemPropertiesPanel' +import { useLocalStyles } from './styles' +import { DEFAULT_FSA_CONFIG, FSA, FSAConfig } from './type' -/* -------------------- styles -------------------- */ -const useLocalStyles = makeStyles()((theme) => ({ - container: { - width: '100%', - height: 600, - display: 'flex', - border: '1px solid #ddd', - fontFamily: 'sans-serif', - }, - panel: { - width: 280, - borderRight: '1px solid #ddd', - padding: theme.spacing(2), - backgroundColor: '#fafafa', - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(2), - }, - 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', - cursor: 'pointer', - backgroundColor: '#fff', - border: '1px solid #ccc', - borderRadius: 4, - }, - deleteButton: { - padding: '6px', - backgroundColor: '#fff1f0', - color: '#cf1322', - border: '1px solid #ffa39e', - borderRadius: 4, - cursor: 'pointer', - fontWeight: 600, - }, - cyWrapper: { - flexGrow: 1, - }, -})); - -/* -------------------- component -------------------- */ interface FSAInputProps { - answer: FSA; - onChange: (val: FSA) => void; + answer: FSA + handleChange: (fsa: FSA) => void } -export const FSAInput: React.FC = ({ answer, onChange }) => { - const { classes } = useLocalStyles(); - const containerRef = useRef(null); - const cyRef = useRef(null); +export const FSAInput: React.FC = ({ + answer, + handleChange, +}) => { + const { classes } = useLocalStyles() + + const cyRef = useRef(null) + const containerRef = useRef(null) - // State for UI and logic - const [drawMode, setDrawMode] = useState(false); - const [fromNodeId, setFromNodeId] = useState(null); - const [toNodeId, setToNodeId] = useState(null); - const [selectedNode, setSelectedNode] = useState(null); - const [selectedEdge, setSelectedEdge] = useState(null); + const [selectedNode, setSelectedNode] = useState(null) + const [selectedEdge, setSelectedEdge] = useState(null) + const [drawMode, setDrawMode] = useState(false) + const [fromNode, setFromNode] = useState(null) + const [config, setConfig] = useState(DEFAULT_FSA_CONFIG) + const [configOpen, setConfigOpen] = useState(true) - /* -------------------- initialize cytoscape -------------------- */ + /* -------------------- init cytoscape -------------------- */ useEffect(() => { - if (!containerRef.current) return; + if (!containerRef.current) return - const cy = cytoscape({ + const cy: Core = cytoscape({ container: containerRef.current, layout: { name: 'preset' }, style: [ @@ -111,243 +57,126 @@ export const FSAInput: React.FC = ({ answer, onChange }) => { 'target-arrow-shape': 'triangle', 'line-color': '#555', 'target-arrow-color': '#555', - }, - }, - { - selector: '.edge-source', - style: { - 'border-color': '#1890ff', - 'border-width': 3, - }, - }, - { - selector: '.edge-target', - style: { - 'border-color': '#52c41a', - 'border-width': 3, + 'text-background-color': '#fff', + 'text-background-opacity': 1, + 'text-background-padding': '3px', }, }, ], - }); + }) - cyRef.current = cy; + cyRef.current = cy + return () => cy.destroy() + }, []) - return () => cy.destroy(); - }, []); - - /* -------------------- attach handlers -------------------- */ + /* -------------------- node/edge handlers -------------------- */ useEffect(() => { - const cy = cyRef.current; - if (!cy) return; - - const handleNodeTap = (e: cytoscape.EventObject) => { - const node = e.target as NodeSingular; + const cy = cyRef.current + if (!cy) return + const tapNode = (e: cytoscape.EventObject): void => { + const node = e.target as NodeSingular if (drawMode) { - if (!fromNodeId) { - setFromNodeId(node.id()); - node.addClass('edge-source'); - } else if (!toNodeId) { - setToNodeId(node.id()); - node.addClass('edge-target'); + 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: config.epsilon_symbol, + }, + }) + cy.nodes().removeClass('edge-source') + setDrawMode(false) + setFromNode(null) + syncToBackend() } - return; + return } - setSelectedNode(node); - setSelectedEdge(null); - }; + setSelectedNode(node) + setSelectedEdge(null) + } - const handleEdgeTap = (e: cytoscape.EventObject) => { - if (drawMode) return; - setSelectedEdge(e.target as EdgeSingular); - setSelectedNode(null); - }; + const tapEdge = (e: cytoscape.EventObject): void => { + setSelectedEdge(e.target as EdgeSingular) + setSelectedNode(null) + } - cy.on('tap', 'node', handleNodeTap); - cy.on('tap', 'edge', handleEdgeTap); + cy.on('tap', 'node', tapNode) + cy.on('tap', 'edge', tapEdge) return () => { - cy.off('tap', 'node', handleNodeTap); - cy.off('tap', 'edge', handleEdgeTap); - }; - }, [drawMode, fromNodeId, toNodeId]); - - /* -------------------- draw transition effect -------------------- */ - useEffect(() => { - const cy = cyRef.current; - if (!drawMode || !fromNodeId || !toNodeId || !cy) return; - - cy.add({ - group: 'edges', - data: { - id: `e-${fromNodeId}-${toNodeId}-${Date.now()}`, - source: fromNodeId, - target: toNodeId, - label: 'a', - }, - }); - - cy.nodes().removeClass('edge-source edge-target'); - - setDrawMode(false); - setFromNodeId(null); - setToNodeId(null); - - syncToAnswer(); - }, [drawMode, fromNodeId, toNodeId]); - - /* -------------------- helpers -------------------- */ - const syncToAnswer = () => { - const cy = cyRef.current; - if (!cy) return; - - const states = cy.nodes().map((n) => n.id()); - const transitions = cy - .edges() - .map((e) => `${e.source().id()}|${e.data('label') || 'ε'}|${e.target().id()}`); - - onChange({ - ...answer, - states, - transitions, - alphabet: Array.from( - new Set(transitions.map((t) => t.split('|')[1]).filter(s => s !== undefined)), + cy.off('tap', 'node', tapNode) + cy.off('tap', 'edge', tapEdge) + } + }, [drawMode, fromNode, config.epsilon_symbol]) + + /* -------------------- sync to backend -------------------- */ + const syncToBackend = (): void => { + const cy = cyRef.current + if (!cy) return + + const fsa: FSA = { + states: cy.nodes().map((n) => n.id()), + transitions: cy.edges().map( + (e) => + `${e.source().id()}|${e.data('label') || config.epsilon_symbol}|${e.target().id()}`, ), - }); - }; + initial_state: answer.initial_state, + accept_states: answer.accept_states, + alphabet: Array.from(new Set(cy.edges().map((e) => String(e.data('label'))))), + } - const addState = () => { - const cy = cyRef.current; - if (!cy) return; + handleChange(fsa) // Only FSA, not config + } - const id = `q${cy.nodes().length}`; + /* -------------------- add state -------------------- */ + const addState = (): void => { + const cy = cyRef.current + if (!cy) return + + const id = `q${cy.nodes().length}` cy.add({ group: 'nodes', - data: { id, label: id, displayLabel: id }, + data: { id, displayLabel: id }, position: { x: 100 + Math.random() * 300, y: 100 + Math.random() * 300 }, - }); - - syncToAnswer(); - }; + }) - const deleteSelected = () => { - selectedNode?.remove(); - selectedEdge?.remove(); - setSelectedNode(null); - setSelectedEdge(null); - syncToAnswer(); - }; + syncToBackend() + } - /* -------------------- render -------------------- */ return (
-
-
Controls
- - - - - - {drawMode && ( - <> -
From Node: {fromNodeId}
-
To Node: {toNodeId}
- - )} - -
Item Properties
- - {/* Node Properties */} - {selectedNode && ( - <> -
- - { - selectedNode.data('displayLabel', e.target.value); - }} - /> -
- -
- - onChange({ - ...answer, - initial_state: e.target.checked ? selectedNode.id() : '', - }) - } - /> - -
- -
- - onChange({ - ...answer, - accept_states: e.target.checked - ? [...answer.accept_states, selectedNode.id()] - : answer.accept_states.filter((s) => s !== selectedNode.id()), - }) - } - /> - -
- - - - )} - - {/* Edge Properties */} - {selectedEdge && ( - <> -
- - { - selectedEdge.data('label', e.target.value); - syncToAnswer(); - }} - /> -
- - - - )} - - {!selectedNode && !selectedEdge && ( -
Select an element to edit
- )} -
+
+ +
- ); -}; + ) +} diff --git a/src/types/FSA/components/ConfigPanel.tsx b/src/types/FSA/components/ConfigPanel.tsx new file mode 100644 index 0000000..870db01 --- /dev/null +++ b/src/types/FSA/components/ConfigPanel.tsx @@ -0,0 +1,75 @@ +import React from 'react' + +interface EvaluationConfigPanelProps> { + config: T + setConfig: React.Dispatch> + configOpen: boolean + setConfigOpen: React.Dispatch> + classes: Record +} + +export default function EvaluationConfigPanel>({ + config, + setConfig, + configOpen, + setConfigOpen, + classes, +}: EvaluationConfigPanelProps) { + return ( +
+
setConfigOpen((o) => !o)} + > + Evaluation Config + {configOpen ? '▾' : '▸'} +
+ + {configOpen && ( +
+ {Object.entries(config).map(([key, value]) => ( +
+ + + {typeof value === 'boolean' ? ( + + setConfig((prev) => ({ + ...prev, + [key]: e.target.checked, + })) + } + /> + ) : typeof value === 'number' ? ( + + setConfig((prev) => ({ + ...prev, + [key]: Number(e.target.value), + })) + } + /> + ) : ( + + setConfig((prev) => ({ + ...prev, + [key]: e.target.value, + })) + } + /> + )} +
+ ))} +
+ )} +
+ ) +} diff --git a/src/types/FSA/components/ItemPropertiesPanel.tsx b/src/types/FSA/components/ItemPropertiesPanel.tsx new file mode 100644 index 0000000..d321471 --- /dev/null +++ b/src/types/FSA/components/ItemPropertiesPanel.tsx @@ -0,0 +1,159 @@ +import type { Core, NodeSingular, EdgeSingular } from 'cytoscape' +import React from 'react' + +import { FSA } from '../type' + +interface ItemPropertiesPanelProps { + cyRef: React.MutableRefObject + classes: Record + + addState: () => void + + drawMode: boolean + setDrawMode: React.Dispatch> + setFromNode: (id: string | null) => void + + selectedNode: NodeSingular | null + setSelectedNode: (n: NodeSingular | null) => void + + selectedEdge: EdgeSingular | null + setSelectedEdge: (e: EdgeSingular | null) => void + + answer: FSA + handleChange: (fsa: FSA) => void + + syncToBackend: () => void +} + +export default function ItemPropertiesPanel({ + cyRef, + classes, + addState, + drawMode, + setDrawMode, + setFromNode, + selectedNode, + setSelectedNode, + selectedEdge, + setSelectedEdge, + answer, + handleChange, + syncToBackend, +}: ItemPropertiesPanelProps): JSX.Element { + return ( +
+
Item Properties
+ + {/* -------------------- Actions -------------------- */} + + + + + + + {/* -------------------- Node Properties -------------------- */} + {selectedNode && ( + <> +
+ + { + selectedNode.data('displayLabel', e.target.value) + // syncToBackend() + }} + /> +
+ + {/* Initial State (unique) */} +
+ { + handleChange({ + ...answer, + initial_state: e.target.checked ? selectedNode.id() : answer.initial_state, + }) + // syncToBackend() + }} + /> + +
+ + {/* Accepting State (multiple allowed) */} +
+ { + handleChange({ + ...answer, + accept_states: e.target.checked + ? [...answer.accept_states, selectedNode.id()] + : answer.accept_states.filter( + (id) => id !== selectedNode.id(), + ), + }) + // syncToBackend() + }} + /> + +
+ + )} + + {/* -------------------- Edge Properties -------------------- */} + {selectedEdge && ( +
+ + { + selectedEdge.data('label', e.target.value) + syncToBackend() + }} + /> +
+ )} + + {/* -------------------- Delete -------------------- */} + {(selectedNode || selectedEdge) && ( + + )} +
+ ) +} diff --git a/src/types/FSA/index.tsx b/src/types/FSA/index.tsx index 998a922..7e7e837 100644 --- a/src/types/FSA/index.tsx +++ b/src/types/FSA/index.tsx @@ -1,68 +1,74 @@ -// FSAResponseArea.tub.ts -import z from 'zod'; +import { z } from 'zod' -import { BaseResponseAreaProps, BaseResponseAreaWizardProps } from '../base-props.type'; -import { ResponseAreaTub } from '../response-area-tub'; +import { + BaseResponseAreaProps, + BaseResponseAreaWizardProps, +} from '../base-props.type' +import { ResponseAreaTub } from '../response-area-tub' -import { FSAInput } from './FSA.component'; -import { fsaAnswerSchema, FSA, defaultFSA } from './type'; +import { FSAInput } from './FSA.component' +import { fsaAnswerSchema, FSA, defaultFSA, DEFAULT_FSA_CONFIG, FSAConfig } from './type' export class FSAResponseAreaTub extends ResponseAreaTub { - public readonly responseType = 'FSA'; - public readonly displayWideInput = true; - protected answerSchema = fsaAnswerSchema; - protected answer: FSA = defaultFSA; + public readonly responseType = 'FSA' + public readonly displayWideInput: boolean = true - initWithConfig = (config: any) => { - this.config = config // config is not used for now - this.answer = defaultFSA - } + protected answerSchema = fsaAnswerSchema + protected answer: FSA = defaultFSA // Never undefined now + protected config: FSAConfig = DEFAULT_FSA_CONFIG + private debug = '' + initWithConfig = (config: any) => { + this.config = { + ...DEFAULT_FSA_CONFIG, + ...config, // not too sure about this, maybe the opposite so the default config is overwritten? + } + } + customCheck = () => {} // will set this up later - InputComponent = (props: BaseResponseAreaProps) => { - // Always derive a local FSA value - console.log('FSA InputComponent props.answer:', props.answer, typeof props.answer); - const fsaAnswer: FSA = (() => { - if (!props.answer) return defaultFSA; - if (typeof props.answer === 'string') { - try { - return JSON.parse(props.answer); - } catch { - return defaultFSA; - } - } + /* -------------------- Input -------------------- */ + public InputComponent = (props: BaseResponseAreaProps): JSX.Element => { + // Always ensure a valid FSA is passed + const parsedAnswer = this.answerSchema.safeParse(props.answer) + const validAnswer: FSA = parsedAnswer.success ? parsedAnswer.data : defaultFSA - return props.answer as FSA; - })(); + return ( + <> +

{this.debug}

+ { + this.debug=JSON.stringify(val) + props.handleChange(val) + }} + /> + + ) + } + /* -------------------- Wizard -------------------- */ + public WizardComponent = ( + props: BaseResponseAreaWizardProps, + ): JSX.Element => { return ( <> -

Input Component

+

answer: {JSON.stringify(this.answer)} config: {JSON.stringify(this.config)}

{ - props.handleChange(JSON.stringify(answer)); + answer={this.answer} // Guaranteed defined + handleChange={(val: FSA): void => { + this.answer = val + console.log('Wizard val:', val) + props.handleChange({ + responseType: this.responseType, + answer: val, + config: this.config as unknown as Record + }) }} /> - ); - }; - - WizardComponent = (props: BaseResponseAreaWizardProps) => { - return ( - { - this.answer = answer - props.handleChange({ - responseType: this.responseType, - answer, - }); - }} - /> - ); + ) } -} \ No newline at end of file +} diff --git a/src/types/FSA/styles.ts b/src/types/FSA/styles.ts new file mode 100644 index 0000000..3ae419f --- /dev/null +++ b/src/types/FSA/styles.ts @@ -0,0 +1,102 @@ +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), + }, + + 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, + }, +})) \ No newline at end of file diff --git a/src/types/FSA/type.ts b/src/types/FSA/type.ts index a8794c9..122174c 100644 --- a/src/types/FSA/type.ts +++ b/src/types/FSA/type.ts @@ -21,4 +21,41 @@ export const defaultFSA: FSA = { transitions: [], initial_state: '', accept_states: [] -}; \ No newline at end of file +}; + +export const fsaConfigSchema = z.object({ + evaluation_mode: z.enum(['strict', 'lenient', 'partial']).optional(), + expected_type: z.enum(['DFA', 'NFA', 'any']).optional(), + feedback_verbosity: z.enum(['minimal', 'standard', 'detailed']).optional(), + + check_minimality: z.boolean().optional(), + check_completeness: z.boolean().optional(), + + highlight_errors: z.boolean().optional(), + show_counterexample: z.boolean().optional(), + + max_test_length: z.number().int().positive().optional(), + + is_dev: z.boolean().optional(), + + epsilon_symbol: z.string(), +}) + +export type FSAConfig = z.infer + +export const DEFAULT_FSA_CONFIG: FSAConfig = { + evaluation_mode: "lenient", + expected_type: "any", + feedback_verbosity: "standard", + + check_minimality: false, + check_completeness: false, + + highlight_errors: true, + show_counterexample: true, + + max_test_length: 10, + + is_dev: false, + epsilon_symbol: "epsilon" +} From 3e813d71a5cc427cf0b0e131550a57954f42eee4 Mon Sep 17 00:00:00 2001 From: everythingfades Date: Wed, 28 Jan 2026 17:09:41 +0000 Subject: [PATCH 06/29] feat: added feedback --- src/types/FSA/FSA.component.tsx | 5 +- src/types/FSA/components/FSAFeedbackPanel.tsx | 226 ++++++++++++++++++ .../FSA/components/ItemPropertiesPanel.tsx | 9 +- src/types/FSA/index.tsx | 61 +++-- src/types/FSA/type.ts | 137 +++++++++++ 5 files changed, 419 insertions(+), 19 deletions(-) create mode 100644 src/types/FSA/components/FSAFeedbackPanel.tsx diff --git a/src/types/FSA/FSA.component.tsx b/src/types/FSA/FSA.component.tsx index 6e523a6..de58b19 100644 --- a/src/types/FSA/FSA.component.tsx +++ b/src/types/FSA/FSA.component.tsx @@ -5,16 +5,18 @@ import React, { useEffect, useRef, useState } from 'react' import ConfigPanel from './components/ConfigPanel' import ItemPropertiesPanel from './components/ItemPropertiesPanel' import { useLocalStyles } from './styles' -import { DEFAULT_FSA_CONFIG, FSA, FSAConfig } from './type' +import { DEFAULT_FSA_CONFIG, FSA, FSAConfig, FSAFeedback } from './type' interface FSAInputProps { answer: FSA handleChange: (fsa: FSA) => void + feedback: FSAFeedback | null } export const FSAInput: React.FC = ({ answer, handleChange, + feedback }) => { const { classes } = useLocalStyles() @@ -166,6 +168,7 @@ export const FSAInput: React.FC = ({ syncToBackend={syncToBackend} handleChange={handleChange} answer={answer} + feedback={feedback} />
diff --git a/src/types/FSA/components/FSAFeedbackPanel.tsx b/src/types/FSA/components/FSAFeedbackPanel.tsx new file mode 100644 index 0000000..d7ecc5d --- /dev/null +++ b/src/types/FSA/components/FSAFeedbackPanel.tsx @@ -0,0 +1,226 @@ +import React, { useMemo } from 'react' + +import { FSAFeedbackSchema, type FSAFeedback } from '../type' + +interface FSAFeedbackPanelProps { + feedback: FSAFeedback | null +} + +export function FSAFeedbackPanel({ feedback }: FSAFeedbackPanelProps) { + console.log(feedback) + const parsed = useMemo(() => FSAFeedbackSchema.safeParse(feedback), [feedback]) + if (!feedback || !parsed.success) { + console.log(parsed.error?.message) + return ( +
+ No feedback yet {parsed.error?.message} +
+ ) + } + + const safeFeedback = parsed.data + + return ( +
+ {/* ================= Summary ================= */} + {safeFeedback.summary && ( +
+ {safeFeedback.summary} +
+ )} + + {/* ================= Errors ================= */} + {safeFeedback.errors.length > 0 && ( + + )} + + {/* ================= Warnings ================= */} + {safeFeedback.warnings.length > 0 && ( + + )} + + {/* ================= Structural Info ================= */} + {safeFeedback.structural && ( +
+ + + + + + {safeFeedback.structural.unreachable_states.length > 0 && ( + + )} + + {safeFeedback.structural.dead_states.length > 0 && ( + + )} +
+ )} + + {/* ================= Language ================= */} + {safeFeedback.language && ( +
+ + + {!safeFeedback.language.are_equivalent && safeFeedback.language.counterexample && ( + + )} +
+ )} + + {/* ================= Test Results ================= */} + {safeFeedback.test_results.length > 0 && ( +
+ {safeFeedback.test_results.map((t, i) => ( +
+ {JSON.stringify(t.input)} + {t.passed ? '✓' : '✗'} +
+ ))} +
+ )} + + {/* ================= Hints ================= */} + {safeFeedback.hints.length > 0 && ( +
+
    + {safeFeedback.hints.map((h, i) => ( +
  • {h}
  • + ))} +
+
+ )} +
+ ) +} + +/* =========================== + Helper components +=========================== */ + +function FeedbackSection({ + title, + items, + accent, +}: { + title: string + items: any[] + accent: string +}) { + return ( +
+
+ {items.map((e, i) => ( +
+
{e.message}
+ +
+ {e.code} · {e.severity} +
+ + {e.suggestion && ( +
+ 💡 {e.suggestion} +
+ )} +
+ ))} +
+
+ ) +} + + +function Section({ + title, + children, +}: { + title: string + children: React.ReactNode +}) { + return ( +
+
+ {title} +
+ {children} +
+ ) +} + +function KV({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label}: + {value} +
+ ) +} + +function bool(v: boolean) { + return v ? 'Yes' : 'No' +} diff --git a/src/types/FSA/components/ItemPropertiesPanel.tsx b/src/types/FSA/components/ItemPropertiesPanel.tsx index d321471..bb23291 100644 --- a/src/types/FSA/components/ItemPropertiesPanel.tsx +++ b/src/types/FSA/components/ItemPropertiesPanel.tsx @@ -1,7 +1,9 @@ import type { Core, NodeSingular, EdgeSingular } from 'cytoscape' import React from 'react' -import { FSA } from '../type' +import { FSA, FSAFeedback } from '../type' + +import { FSAFeedbackPanel } from './FSAFeedbackPanel' interface ItemPropertiesPanelProps { cyRef: React.MutableRefObject @@ -23,6 +25,7 @@ interface ItemPropertiesPanelProps { handleChange: (fsa: FSA) => void syncToBackend: () => void + feedback: FSAFeedback | null } export default function ItemPropertiesPanel({ @@ -39,6 +42,7 @@ export default function ItemPropertiesPanel({ answer, handleChange, syncToBackend, + feedback }: ItemPropertiesPanelProps): JSX.Element { return (
@@ -154,6 +158,9 @@ export default function ItemPropertiesPanel({ Delete Selected )} +
) } diff --git a/src/types/FSA/index.tsx b/src/types/FSA/index.tsx index 7e7e837..d6a7314 100644 --- a/src/types/FSA/index.tsx +++ b/src/types/FSA/index.tsx @@ -1,4 +1,6 @@ -import { z } from 'zod' +// import React, { useMemo } from 'react' +// import { z } from 'zod' + import { BaseResponseAreaProps, @@ -7,7 +9,7 @@ import { import { ResponseAreaTub } from '../response-area-tub' import { FSAInput } from './FSA.component' -import { fsaAnswerSchema, FSA, defaultFSA, DEFAULT_FSA_CONFIG, FSAConfig } from './type' +import { fsaAnswerSchema, FSA, defaultFSA, DEFAULT_FSA_CONFIG, FSAConfig, FSAFeedback } from './type' export class FSAResponseAreaTub extends ResponseAreaTub { public readonly responseType = 'FSA' @@ -16,7 +18,10 @@ export class FSAResponseAreaTub extends ResponseAreaTub { protected answerSchema = fsaAnswerSchema protected answer: FSA = defaultFSA // Never undefined now protected config: FSAConfig = DEFAULT_FSA_CONFIG - private debug = '' + // private feedback: FSAFeedback | null = null + + public readonly delegateFeedback = false // we want to manage our own feedback + public readonly delegateLivePreview = false // we want live previews initWithConfig = (config: any) => { this.config = { @@ -28,25 +33,46 @@ export class FSAResponseAreaTub extends ResponseAreaTub { customCheck = () => {} // will set this up later /* -------------------- Input -------------------- */ - public InputComponent = (props: BaseResponseAreaProps): JSX.Element => { - // Always ensure a valid FSA is passed - const parsedAnswer = this.answerSchema.safeParse(props.answer) - const validAnswer: FSA = parsedAnswer.success ? parsedAnswer.data : defaultFSA - return ( - <> -

{this.debug}

+public InputComponent = (props: BaseResponseAreaProps): JSX.Element => { + // Ensure a valid FSA answer + const validAnswer: FSA = this.answerSchema.safeParse(props.answer).success + ? this.answerSchema.safeParse(props.answer).data ?? defaultFSA + : defaultFSA + + // // Parse feedback safely and memoize so it updates when props.feedback changes + // const feedback: FSAFeedback | null = useMemo(() => { + // if (!props.feedback?.feedback) return null + // try { + // return JSON.parse(props.feedback.feedback) + // } catch { + // return null + // } + // }, [props.feedback?.feedback]) + + return ( + <> +

feedback: {props.feedback?.feedback}

{ + const raw = props.feedback?.feedback + if (!raw) return {} + try { + // split by
and take the second part, trim whitespace + const jsonPart = raw.split('
')[1]?.trim() ?? '{}' + return JSON.parse(jsonPart) + } catch { + return {} // fallback to empty object if parsing fails + } + })()} answer={validAnswer} - handleChange={(val: FSA): void => { - this.debug=JSON.stringify(val) - props.handleChange(val) - }} + handleChange={(val: FSA) => props.handleChange(val)} /> - - ) - } + + ) +} + /* -------------------- Wizard -------------------- */ public WizardComponent = ( @@ -57,6 +83,7 @@ export class FSAResponseAreaTub extends ResponseAreaTub {

answer: {JSON.stringify(this.answer)} config: {JSON.stringify(this.config)}

{ this.answer = val diff --git a/src/types/FSA/type.ts b/src/types/FSA/type.ts index 122174c..516ed54 100644 --- a/src/types/FSA/type.ts +++ b/src/types/FSA/type.ts @@ -59,3 +59,140 @@ export const DEFAULT_FSA_CONFIG: FSAConfig = { is_dev: false, epsilon_symbol: "epsilon" } + +/* =========================== + Error codes +=========================== */ + +export const ErrorCodeSchema = z.enum([ + "INVALID_STATE", + "INVALID_INITIAL", + "INVALID_ACCEPT", + "INVALID_SYMBOL", + + "INVALID_TRANSITION_SOURCE", + "INVALID_TRANSITION_DEST", + "INVALID_TRANSITION_SYMBOL", + "MISSING_TRANSITION", + "DUPLICATE_TRANSITION", + + "UNREACHABLE_STATE", + "DEAD_STATE", + + "WRONG_AUTOMATON_TYPE", + "NOT_DETERMINISTIC", + "NOT_COMPLETE", + "NOT_MINIMAL", + + "LANGUAGE_MISMATCH", + "TEST_CASE_FAILED", + + "EMPTY_STATES", + "EMPTY_ALPHABET", + "EVALUATION_ERROR", +]); + +/* =========================== + Element highlighting +=========================== */ + +export const ElementHighlightTypeSchema = z.enum([ + "state", + "transition", + "initial_state", + "accept_state", + "alphabet_symbol", +]); +export const ElementHighlightSchema = z.object({ + type: ElementHighlightTypeSchema, + state_id: z.string().nullable().optional(), + from_state: z.string().nullable().optional(), + to_state: z.string().nullable().optional(), + symbol: z.string().nullable().optional(), +}); + + +/* =========================== + Validation errors +=========================== */ + +export const ValidationSeveritySchema = z.enum([ + "error", + "warning", + "info", +]); + +export const ValidationErrorSchema = z.object({ + message: z.string(), + code: ErrorCodeSchema, + severity: ValidationSeveritySchema.default("error"), + highlight: ElementHighlightSchema.nullable().optional(), + suggestion: z.string().optional(), +}); + +/* =========================== + Test results +=========================== */ + +export const TestResultSchema = z.object({ + input: z.string(), + expected: z.boolean(), + actual: z.boolean(), + passed: z.boolean(), + trace: z.array(z.string()).optional(), +}); + +/* =========================== + Structural analysis +=========================== */ + +export const StructuralInfoSchema = z.object({ + is_deterministic: z.boolean(), + is_complete: z.boolean(), + + num_states: z.number().int().min(0), + num_transitions: z.number().int().min(0), + + unreachable_states: z.array(z.string()).default([]), + dead_states: z.array(z.string()).default([]), +}); + +/* =========================== + Language comparison +=========================== */ + +export const CounterexampleTypeSchema = z.enum([ + "should_accept", + "should_reject", +]); + +export const LanguageComparisonSchema = z.object({ + are_equivalent: z.boolean(), + counterexample: z.string().nullable().optional(), + counterexample_type: CounterexampleTypeSchema.nullable().optional(), +}); + +/* =========================== + Top-level feedback +=========================== */ + +export const FSAFeedbackSchema = z.object({ + summary: z.string().default(""), + + errors: z.array(ValidationErrorSchema).default([]), + warnings: z.array(ValidationErrorSchema).default([]), + + structural: StructuralInfoSchema.optional(), + language: LanguageComparisonSchema.optional(), + + test_results: z.array(TestResultSchema).default([]), + hints: z.array(z.string()).default([]), +}); + +export type ErrorCode = z.infer; +export type ElementHighlight = z.infer; +export type ValidationError = z.infer; +export type TestResult = z.infer; +export type StructuralInfo = z.infer; +export type LanguageComparison = z.infer; +export type FSAFeedback = z.infer; From 5456800a3b9f4ef2bd22360d1b6c0be9a2b04b59 Mon Sep 17 00:00:00 2001 From: everythingfades Date: Wed, 28 Jan 2026 17:14:09 +0000 Subject: [PATCH 07/29] fix: remove debugging logs --- src/types/FSA/components/FSAFeedbackPanel.tsx | 1 - src/types/FSA/index.tsx | 66 +++++++++---------- 2 files changed, 30 insertions(+), 37 deletions(-) diff --git a/src/types/FSA/components/FSAFeedbackPanel.tsx b/src/types/FSA/components/FSAFeedbackPanel.tsx index d7ecc5d..4e89fe9 100644 --- a/src/types/FSA/components/FSAFeedbackPanel.tsx +++ b/src/types/FSA/components/FSAFeedbackPanel.tsx @@ -7,7 +7,6 @@ interface FSAFeedbackPanelProps { } export function FSAFeedbackPanel({ feedback }: FSAFeedbackPanelProps) { - console.log(feedback) const parsed = useMemo(() => FSAFeedbackSchema.safeParse(feedback), [feedback]) if (!feedback || !parsed.success) { console.log(parsed.error?.message) diff --git a/src/types/FSA/index.tsx b/src/types/FSA/index.tsx index d6a7314..7ec599f 100644 --- a/src/types/FSA/index.tsx +++ b/src/types/FSA/index.tsx @@ -51,25 +51,22 @@ public InputComponent = (props: BaseResponseAreaProps): JSX.Element => { // }, [props.feedback?.feedback]) return ( - <> -

feedback: {props.feedback?.feedback}

- { - const raw = props.feedback?.feedback - if (!raw) return {} - try { - // split by
and take the second part, trim whitespace - const jsonPart = raw.split('
')[1]?.trim() ?? '{}' - return JSON.parse(jsonPart) - } catch { - return {} // fallback to empty object if parsing fails - } - })()} - answer={validAnswer} - handleChange={(val: FSA) => props.handleChange(val)} - /> - + { + const raw = props.feedback?.feedback + if (!raw) return {} + try { + // split by
and take the second part, trim whitespace + const jsonPart = raw.split('
')[1]?.trim() ?? '{}' + return JSON.parse(jsonPart) + } catch { + return {} // fallback to empty object if parsing fails + } + })()} + answer={validAnswer} + handleChange={(val: FSA) => props.handleChange(val)} + /> ) } @@ -79,23 +76,20 @@ public InputComponent = (props: BaseResponseAreaProps): JSX.Element => { props: BaseResponseAreaWizardProps, ): JSX.Element => { return ( - <> -

answer: {JSON.stringify(this.answer)} config: {JSON.stringify(this.config)}

- { - this.answer = val - console.log('Wizard val:', val) - props.handleChange({ - responseType: this.responseType, - answer: val, - config: this.config as unknown as Record - }) - }} - /> - + { + this.answer = val + console.log('Wizard val:', val) + props.handleChange({ + responseType: this.responseType, + answer: val, + config: this.config as unknown as Record + }) + }} + /> ) } } From 822f87e0815157e95b7254af8dc1d2dc32fef2f7 Mon Sep 17 00:00:00 2001 From: Johnny Wan <2695191695@qq.com> Date: Fri, 30 Jan 2026 04:38:42 +0000 Subject: [PATCH 08/29] feat: implement GraphQL data fetching and add live preview functionality in FSA component --- .gitignore | 2 +- externals/api/fetcher.ts | 61 ++- src/types/FSA/FSA.component.tsx | 462 +++++++++++++++++- .../FSA/components/ItemPropertiesPanel.tsx | 26 +- src/types/FSA/index.tsx | 55 +-- 5 files changed, 568 insertions(+), 38 deletions(-) diff --git a/.gitignore b/.gitignore index 1b68e4d..a0919bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # dependencies /node_modules - +/client-web-main # production /build /dist diff --git a/externals/api/fetcher.ts b/externals/api/fetcher.ts index 4fa1f95..b0f1f8f 100644 --- a/externals/api/fetcher.ts +++ b/externals/api/fetcher.ts @@ -1,7 +1,62 @@ +type GraphqlResponse = { + data: TData | null + errors?: Array<{ message?: string }> | null +} + +function getGraphqlUrl(): string { + // 1) Explicit override (handy in sandbox contexts) + const override = (globalThis as any).__LF_GRAPHQL_URL + if (typeof override === 'string' && override.length > 0) return override + + // 2) Common paths (works when frontend and API share origin/proxy) + // Prefer /graphql (client-web-main default), but also try /api/graphql (common convention) + return new URL('/graphql', globalThis.location?.origin ?? 'http://localhost') + .toString() +} + export const fetchData = ( - _query: string, - _variables?: TVariables, + query: string, + variables?: TVariables, _options?: unknown, ): (() => Promise) => { - return null as any + return async () => { + const url = getGraphqlUrl() + + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + // In the real app, auth is usually cookie-based or handled by the host page. + // Include credentials so the sandbox can reuse the host session. + credentials: 'include', + body: JSON.stringify({ + query, + variables, + }), + }) + + if (!res.ok) { + throw new Error( + `GraphQL request failed (${res.status} ${res.statusText})`, + ) + } + + const json = (await res.json()) as GraphqlResponse + + if (json.errors && json.errors.length > 0) { + const msg = + json.errors + .map(e => e?.message) + .filter(Boolean) + .join('\n') || 'GraphQL error' + throw new Error(msg) + } + + if (json.data === null) { + throw new Error('GraphQL: no data returned') + } + + return json.data + } } diff --git a/src/types/FSA/FSA.component.tsx b/src/types/FSA/FSA.component.tsx index de58b19..a1e5518 100644 --- a/src/types/FSA/FSA.component.tsx +++ b/src/types/FSA/FSA.component.tsx @@ -1,22 +1,37 @@ -import { makeStyles } from '@styles' import cytoscape, { Core, NodeSingular, EdgeSingular } from 'cytoscape' -import React, { useEffect, useRef, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import ConfigPanel from './components/ConfigPanel' import ItemPropertiesPanel from './components/ItemPropertiesPanel' import { useLocalStyles } from './styles' -import { DEFAULT_FSA_CONFIG, FSA, FSAConfig, FSAFeedback } from './type' +import { useSubmitResponsePreviewMutation } from '@api/graphql' + +import { + DEFAULT_FSA_CONFIG, + FSA, + FSAConfig, + FSAFeedback, + FSAFeedbackSchema, + ValidationError, + ValidationErrorSchema, +} from './type' interface FSAInputProps { answer: FSA handleChange: (fsa: FSA) => void feedback: FSAFeedback | null + hasPreview?: boolean + responseAreaId?: string + universalResponseAreaId?: string } export const FSAInput: React.FC = ({ answer, handleChange, - feedback + feedback, + hasPreview, + responseAreaId, + universalResponseAreaId, }) => { const { classes } = useLocalStyles() @@ -30,6 +45,90 @@ export const FSAInput: React.FC = ({ const [config, setConfig] = useState(DEFAULT_FSA_CONFIG) const [configOpen, setConfigOpen] = useState(true) + const [previewIsLoading, setPreviewIsLoading] = useState(false) + const [previewText, setPreviewText] = useState(null) + const [previewFeedback, setPreviewFeedback] = useState(null) + + type PreviewSympy = Record + + const localPreview = useMemo(() => { + // Always compute a fast local preview as a fallback. + return validateFsaPreview(answer, config) + }, [answer, config]) + + /* -------------------- debounce preview call -------------------- */ + useEffect(() => { + // If live preview is off, show normal (check) feedback if any. + if (!hasPreview) { + setPreviewIsLoading(false) + setPreviewText(null) + setPreviewFeedback(feedback) + return + } + + // If we can't call the backend preview, fall back to local validation. + if (!responseAreaId || !universalResponseAreaId) { + setPreviewIsLoading(false) + setPreviewText(null) + setPreviewFeedback(localPreview) + return + } + + const requireDeterministic = config.expected_type === 'DFA' + const showWarnings = true + + const timeout = setTimeout(async () => { + setPreviewIsLoading(true) + try { + const data = await useSubmitResponsePreviewMutation.fetcher({ + submission: answer, + additionalParams: { + require_deterministic: requireDeterministic, + show_warnings: showWarnings, + }, + responseAreaId, + universalResponseAreaId, + })() + + const res = data.submitResponsePreview + + // Best-effort mapping: the backend returns { preview: { latex, feedback, sympy } } + const previewObj = res.preview + if (previewObj && typeof previewObj === 'object') { + const maybeFeedback = (previewObj as any).feedback + const maybeSympy = (previewObj as any).sympy as PreviewSympy | undefined + + setPreviewText(typeof maybeFeedback === 'string' ? maybeFeedback : null) + setPreviewFeedback( + maybeSympy ? sympyToFsaFeedback(maybeSympy, answer) : localPreview, + ) + } else { + // No structured preview; show server string feedback if present + setPreviewText(res.feedback ?? null) + setPreviewFeedback(localPreview) + } + } catch { + // If network preview fails (common in sandbox contexts), still give the student useful local feedback. + setPreviewText(null) + setPreviewFeedback(localPreview) + } finally { + setPreviewIsLoading(false) + } + }, 500) + + return () => { + clearTimeout(timeout) + } + }, [ + answer, + config.expected_type, + feedback, + hasPreview, + localPreview, + responseAreaId, + universalResponseAreaId, + ]) + /* -------------------- init cytoscape -------------------- */ useEffect(() => { if (!containerRef.current) return @@ -64,6 +163,26 @@ export const FSAInput: React.FC = ({ 'text-background-padding': '3px', }, }, + { + selector: '.preview-error', + style: { + 'border-width': 4, + 'border-color': '#d32f2f', + 'line-color': '#d32f2f', + 'target-arrow-color': '#d32f2f', + 'background-color': '#ffebee', + }, + }, + { + selector: '.preview-warning', + style: { + 'border-width': 4, + 'border-color': '#ed6c02', + 'line-color': '#ed6c02', + 'target-arrow-color': '#ed6c02', + 'background-color': '#fff3e0', + }, + }, ], }) @@ -71,6 +190,24 @@ export const FSAInput: React.FC = ({ return () => cy.destroy() }, []) + /* -------------------- apply preview highlights -------------------- */ + useEffect(() => { + const cy = cyRef.current + if (!cy) return + + cy.elements().removeClass('preview-error preview-warning') + + const fb = previewFeedback + if (!fb) return + + for (const err of fb.errors) { + applyHighlight(cy, err, 'preview-error') + } + for (const warn of fb.warnings) { + applyHighlight(cy, warn, 'preview-warning') + } + }, [previewFeedback]) + /* -------------------- node/edge handlers -------------------- */ useEffect(() => { const cy = cyRef.current @@ -168,7 +305,9 @@ export const FSAInput: React.FC = ({ syncToBackend={syncToBackend} handleChange={handleChange} answer={answer} - feedback={feedback} + feedback={previewFeedback} + previewText={previewText} + previewIsLoading={previewIsLoading} />
@@ -183,3 +322,316 @@ export const FSAInput: React.FC = ({
) } + +function parseTransition(t: string): { from: string; symbol: string; to: string } { + const [from = '', symbol = '', to = ''] = t.split('|') + return { from, symbol, to } +} + +function sympyToFsaFeedback(sympy: Record, answer: FSA): FSAFeedback { + const errorsRaw = Array.isArray(sympy.errors) ? sympy.errors : [] + const warningsRaw = Array.isArray(sympy.warnings) ? sympy.warnings : [] + + const errors: ValidationError[] = [] + const warnings: ValidationError[] = [] + + for (const e of errorsRaw) { + const parsed = ValidationErrorSchema.safeParse(e) + if (parsed.success) errors.push(parsed.data) + } + for (const w of warningsRaw) { + const parsed = ValidationErrorSchema.safeParse(w) + if (parsed.success) warnings.push(parsed.data) + } + + const numStates = readNumber(sympy.num_states, answer.states.length) + const numTransitions = readNumber(sympy.num_transitions, answer.transitions.length) + + const isDet = readBoolean(sympy.is_deterministic, false) + const isComplete = readBoolean(sympy.is_complete, false) + const unreachable = readStringArray(sympy.unreachable_states) + const dead = readStringArray(sympy.dead_states) + + const stateWord = numStates === 1 ? 'state' : 'states' + const transWord = numTransitions === 1 ? 'transition' : 'transitions' + const summary = `${isDet ? 'DFA' : 'NFA'} with ${numStates} ${stateWord} and ${numTransitions} ${transWord}` + + const candidate: FSAFeedback = { + summary, + errors, + warnings, + structural: { + is_deterministic: isDet, + is_complete: isComplete, + num_states: numStates, + num_transitions: numTransitions, + unreachable_states: unreachable, + dead_states: dead, + }, + test_results: [], + hints: [], + } + + const parsed = FSAFeedbackSchema.safeParse(candidate) + return parsed.success ? parsed.data : candidate +} + +function readBoolean(v: unknown, fallback: boolean): boolean { + return typeof v === 'boolean' ? v : fallback +} + +function readNumber(v: unknown, fallback: number): number { + return typeof v === 'number' && Number.isFinite(v) ? v : fallback +} + +function readStringArray(v: unknown): string[] { + if (!Array.isArray(v)) return [] + return v.filter(x => typeof x === 'string') as string[] +} + +function validateFsaPreview(answer: FSA, config: FSAConfig): FSAFeedback { + const errors: ValidationError[] = [] + const warnings: ValidationError[] = [] + + const states = new Set(answer.states) + const alphabet = new Set(answer.alphabet) + + const transitions = answer.transitions.map(parseTransition) + + // Structural errors + if (answer.states.length === 0) { + errors.push({ + message: 'No states defined.', + code: 'EMPTY_STATES', + severity: 'error', + highlight: null, + }) + } + + if (!answer.initial_state || !states.has(answer.initial_state)) { + errors.push({ + message: 'Initial state is missing or not in the set of states.', + code: 'INVALID_INITIAL', + severity: 'error', + highlight: answer.initial_state + ? { type: 'initial_state', state_id: answer.initial_state } + : { type: 'initial_state', state_id: null }, + }) + } + + for (const acc of answer.accept_states) { + if (!states.has(acc)) { + errors.push({ + message: `Accept state '${acc}' is not in the set of states.`, + code: 'INVALID_ACCEPT', + severity: 'error', + highlight: { type: 'accept_state', state_id: acc }, + }) + } + } + + for (const tr of transitions) { + if (!states.has(tr.from)) { + errors.push({ + message: `Transition source state '${tr.from}' is not a valid state.`, + code: 'INVALID_TRANSITION_SOURCE', + severity: 'error', + highlight: { type: 'transition', from_state: tr.from, to_state: tr.to, symbol: tr.symbol }, + }) + } + if (!states.has(tr.to)) { + errors.push({ + message: `Transition destination state '${tr.to}' is not a valid state.`, + code: 'INVALID_TRANSITION_DEST', + severity: 'error', + highlight: { type: 'transition', from_state: tr.from, to_state: tr.to, symbol: tr.symbol }, + }) + } + if (!tr.symbol) { + errors.push({ + message: `A transition is missing its symbol.`, + code: 'INVALID_TRANSITION_SYMBOL', + severity: 'error', + highlight: { type: 'transition', from_state: tr.from, to_state: tr.to, symbol: null }, + }) + } else if (alphabet.size > 0 && !alphabet.has(tr.symbol)) { + errors.push({ + message: `Transition symbol '${tr.symbol}' is not in the alphabet.`, + code: 'INVALID_SYMBOL', + severity: 'error', + highlight: { type: 'transition', from_state: tr.from, to_state: tr.to, symbol: tr.symbol }, + }) + } + } + + const requireDeterministic = config.expected_type === 'DFA' + + const keyToTargets = new Map>() + let hasEpsilon = false + for (const tr of transitions) { + if (tr.symbol === config.epsilon_symbol) hasEpsilon = true + const key = `${tr.from}||${tr.symbol}` + const set = keyToTargets.get(key) ?? new Set() + set.add(tr.to) + keyToTargets.set(key, set) + } + + let isDeterministic = true + for (const [key, tos] of keyToTargets.entries()) { + if (tos.size > 1) { + isDeterministic = false + const [from, symbol] = key.split('||') + errors.push({ + message: `Non-determinism detected: multiple transitions from '${from}' on symbol '${symbol}'.`, + code: 'NOT_DETERMINISTIC', + severity: requireDeterministic ? 'error' : 'warning', + highlight: { type: 'state', state_id: from }, + }) + } + } + + if (hasEpsilon) { + isDeterministic = false + if (requireDeterministic) { + errors.push({ + message: `Epsilon transitions are not allowed in a DFA (epsilon = '${config.epsilon_symbol}').`, + code: 'NOT_DETERMINISTIC', + severity: 'error', + highlight: null, + }) + } + } + + // Reachability + const reachable = new Set() + if (answer.initial_state && states.has(answer.initial_state)) { + const stack = [answer.initial_state] + while (stack.length) { + const s = stack.pop()! + if (reachable.has(s)) continue + reachable.add(s) + for (const tr of transitions) { + if (tr.from === s && states.has(tr.to) && !reachable.has(tr.to)) { + stack.push(tr.to) + } + } + } + } + + const unreachableStates = answer.states.filter(s => !reachable.has(s)) + if (unreachableStates.length > 0) { + for (const s of unreachableStates) { + warnings.push({ + message: `State '${s}' is unreachable from the initial state.`, + code: 'UNREACHABLE_STATE', + severity: 'warning', + highlight: { type: 'state', state_id: s }, + }) + } + } + + // Dead states: cannot reach any accept state + const reverse = new Map>() + for (const tr of transitions) { + const set = reverse.get(tr.to) ?? new Set() + set.add(tr.from) + reverse.set(tr.to, set) + } + + const canReachAccept = new Set() + const queue: string[] = [] + for (const a of answer.accept_states) { + if (states.has(a)) { + canReachAccept.add(a) + queue.push(a) + } + } + while (queue.length) { + const cur = queue.shift()! + const prevs = reverse.get(cur) + if (!prevs) continue + for (const p of prevs) { + if (!canReachAccept.has(p)) { + canReachAccept.add(p) + queue.push(p) + } + } + } + + const deadStates = answer.states.filter(s => states.has(s) && !canReachAccept.has(s)) + if (deadStates.length > 0) { + for (const s of deadStates) { + warnings.push({ + message: `State '${s}' is a dead state (cannot reach an accept state).`, + code: 'DEAD_STATE', + severity: 'warning', + highlight: { type: 'state', state_id: s }, + }) + } + } + + // Completeness (only meaningful for DFA) + let isComplete = false + if (isDeterministic && alphabet.size > 0 && !alphabet.has(config.epsilon_symbol)) { + isComplete = true + for (const s of answer.states) { + for (const sym of alphabet) { + const key = `${s}||${sym}` + if (!keyToTargets.get(key) || (keyToTargets.get(key)?.size ?? 0) !== 1) { + isComplete = false + if (requireDeterministic) { + warnings.push({ + message: `Missing transition from '${s}' on symbol '${sym}'.`, + code: 'MISSING_TRANSITION', + severity: 'warning', + highlight: { type: 'state', state_id: s }, + }) + } + } + } + } + } + + const numStates = answer.states.length + const numTransitions = answer.transitions.length + const stateWord = numStates === 1 ? 'state' : 'states' + const transWord = numTransitions === 1 ? 'transition' : 'transitions' + const summary = `${isDeterministic ? 'DFA' : 'NFA'} with ${numStates} ${stateWord} and ${numTransitions} ${transWord}` + + return { + summary, + errors, + warnings, + structural: { + is_deterministic: isDeterministic, + is_complete: isComplete, + num_states: numStates, + num_transitions: numTransitions, + unreachable_states: unreachableStates, + dead_states: deadStates, + }, + test_results: [], + hints: [], + } +} + +function applyHighlight(cy: Core, err: ValidationError, className: string) { + const h = err.highlight + if (!h) return + + if ((h.type === 'state' || h.type === 'initial_state' || h.type === 'accept_state') && h.state_id) { + cy.getElementById(h.state_id).addClass(className) + return + } + + if (h.type === 'transition' && h.from_state && h.to_state) { + const candidates = cy.edges().filter(e => { + const fromOk = e.source().id() === h.from_state + const toOk = e.target().id() === h.to_state + const label = String(e.data('label') ?? '') + const symOk = h.symbol ? label === h.symbol : true + return fromOk && toOk && symOk + }) + candidates.addClass(className) + } +} diff --git a/src/types/FSA/components/ItemPropertiesPanel.tsx b/src/types/FSA/components/ItemPropertiesPanel.tsx index bb23291..0fefd1a 100644 --- a/src/types/FSA/components/ItemPropertiesPanel.tsx +++ b/src/types/FSA/components/ItemPropertiesPanel.tsx @@ -26,6 +26,8 @@ interface ItemPropertiesPanelProps { syncToBackend: () => void feedback: FSAFeedback | null + previewText?: string | null + previewIsLoading?: boolean } export default function ItemPropertiesPanel({ @@ -42,7 +44,9 @@ export default function ItemPropertiesPanel({ answer, handleChange, syncToBackend, - feedback + feedback, + previewText, + previewIsLoading, }: ItemPropertiesPanelProps): JSX.Element { return (
@@ -158,6 +162,26 @@ export default function ItemPropertiesPanel({ Delete Selected )} + {previewIsLoading ? ( +
Previewing…
+ ) : null} + {previewText ? ( +
+ {previewText} +
+ ) : null} diff --git a/src/types/FSA/index.tsx b/src/types/FSA/index.tsx index 7ec599f..b398f56 100644 --- a/src/types/FSA/index.tsx +++ b/src/types/FSA/index.tsx @@ -9,7 +9,14 @@ import { import { ResponseAreaTub } from '../response-area-tub' import { FSAInput } from './FSA.component' -import { fsaAnswerSchema, FSA, defaultFSA, DEFAULT_FSA_CONFIG, FSAConfig, FSAFeedback } from './type' +import { + DEFAULT_FSA_CONFIG, + FSA, + FSAConfig, + FSAFeedbackSchema, + defaultFSA, + fsaAnswerSchema, +} from './type' export class FSAResponseAreaTub extends ResponseAreaTub { public readonly responseType = 'FSA' @@ -36,36 +43,30 @@ export class FSAResponseAreaTub extends ResponseAreaTub { public InputComponent = (props: BaseResponseAreaProps): JSX.Element => { // Ensure a valid FSA answer - const validAnswer: FSA = this.answerSchema.safeParse(props.answer).success - ? this.answerSchema.safeParse(props.answer).data ?? defaultFSA - : defaultFSA + const parsedAnswer = this.answerSchema.safeParse(props.answer) + const validAnswer: FSA = parsedAnswer.success ? parsedAnswer.data : defaultFSA - // // Parse feedback safely and memoize so it updates when props.feedback changes - // const feedback: FSAFeedback | null = useMemo(() => { - // if (!props.feedback?.feedback) return null - // try { - // return JSON.parse(props.feedback.feedback) - // } catch { - // return null - // } - // }, [props.feedback?.feedback]) + const checkFeedback = (() => { + const raw = props.feedback?.feedback + if (!raw) return null + try { + // legacy format sometimes embeds JSON after a
+ const jsonPart = raw.split('
')[1]?.trim() ?? raw.trim() + const parsed = FSAFeedbackSchema.safeParse(JSON.parse(jsonPart)) + return parsed.success ? parsed.data : null + } catch { + return null + } + })() return ( { - const raw = props.feedback?.feedback - if (!raw) return {} - try { - // split by
and take the second part, trim whitespace - const jsonPart = raw.split('
')[1]?.trim() ?? '{}' - return JSON.parse(jsonPart) - } catch { - return {} // fallback to empty object if parsing fails - } - })()} answer={validAnswer} handleChange={(val: FSA) => props.handleChange(val)} + feedback={checkFeedback} + hasPreview={props.hasPreview} + responseAreaId={props.responseAreaId} + universalResponseAreaId={props.universalResponseAreaId} /> ) } @@ -77,12 +78,10 @@ public InputComponent = (props: BaseResponseAreaProps): JSX.Element => { ): JSX.Element => { return ( { this.answer = val - console.log('Wizard val:', val) props.handleChange({ responseType: this.responseType, answer: val, From 8b60ada913a42db49a490baa093841c05bdbf415 Mon Sep 17 00:00:00 2001 From: everythingfades Date: Fri, 30 Jan 2026 12:25:44 +0000 Subject: [PATCH 09/29] fix: style modification --- src/types/FSA/FSA.component.tsx | 129 ++++++++++++++++++++++++++++++-- src/types/FSA/index.tsx | 4 +- src/types/FSA/styles.ts | 1 + 3 files changed, 126 insertions(+), 8 deletions(-) diff --git a/src/types/FSA/FSA.component.tsx b/src/types/FSA/FSA.component.tsx index de58b19..4e744ad 100644 --- a/src/types/FSA/FSA.component.tsx +++ b/src/types/FSA/FSA.component.tsx @@ -16,7 +16,7 @@ interface FSAInputProps { export const FSAInput: React.FC = ({ answer, handleChange, - feedback + feedback, }) => { const { classes } = useLocalStyles() @@ -38,6 +38,7 @@ export const FSAInput: React.FC = ({ container: containerRef.current, layout: { name: 'preset' }, style: [ + // ---------------- Nodes ---------------- { selector: 'node', style: { @@ -51,6 +52,33 @@ export const FSAInput: React.FC = ({ 'border-color': '#555', }, }, + + { + selector: 'node.initial', + style: { + 'border-width': 3, + 'border-color': '#1976d2', + }, + }, + + { + selector: 'node.accept', + style: { + 'border-style': 'double', + 'border-width': 4, + }, + }, + + { + selector: 'node.error-highlight', + style: { + 'background-color': '#ffebee', + 'border-color': '#d32f2f', + 'border-width': 4, + }, + }, + + // ---------------- Edges ---------------- { selector: 'edge', style: { @@ -64,6 +92,16 @@ export const FSAInput: React.FC = ({ 'text-background-padding': '3px', }, }, + + { + selector: 'edge.error-highlight', + style: { + 'line-color': '#d32f2f', + 'target-arrow-color': '#d32f2f', + 'line-style': 'dashed', + width: 3, + }, + }, ], }) @@ -78,6 +116,7 @@ export const FSAInput: React.FC = ({ const tapNode = (e: cytoscape.EventObject): void => { const node = e.target as NodeSingular + if (drawMode) { if (!fromNode) { setFromNode(node.id()) @@ -124,17 +163,19 @@ export const FSAInput: React.FC = ({ if (!cy) return const fsa: FSA = { - states: cy.nodes().map((n) => n.id()), - transitions: cy.edges().map( + states: cy.nodes()?.map((n) => n.id()), + transitions: cy.edges()?.map( (e) => `${e.source().id()}|${e.data('label') || config.epsilon_symbol}|${e.target().id()}`, ), initial_state: answer.initial_state, accept_states: answer.accept_states, - alphabet: Array.from(new Set(cy.edges().map((e) => String(e.data('label'))))), + alphabet: Array.from( + new Set(cy.edges().map((e) => String(e.data('label')))), + ), } - handleChange(fsa) // Only FSA, not config + handleChange(fsa) } /* -------------------- add state -------------------- */ @@ -146,12 +187,88 @@ export const FSAInput: React.FC = ({ cy.add({ group: 'nodes', data: { id, displayLabel: id }, - position: { x: 100 + Math.random() * 300, y: 100 + Math.random() * 300 }, + position: { + x: 100 + Math.random() * 300, + y: 100 + Math.random() * 300, + }, }) syncToBackend() } + /* -------------------- apply initial / accept styling -------------------- */ + useEffect(() => { + const cy = cyRef.current + if (!cy) return + + cy.nodes().removeClass('initial accept') + + if (answer.initial_state) { + cy.$id(answer.initial_state).addClass('initial') + } + + for (const id of answer.accept_states) { + cy.$id(id).addClass('accept') + } + }, [answer.initial_state, answer.accept_states]) + + /* -------------------- apply feedback highlights -------------------- */ + useEffect(() => { + const cy = cyRef.current + if (!cy) return + + cy.nodes().removeClass('error-highlight') + cy.edges().removeClass('error-highlight') + + if (!feedback || !feedback.errors) return + + const highlights = feedback.errors + .map((e) => e.highlight) + .filter(Boolean) + + for (const h of highlights) { + if (!h) continue + + switch (h.type) { + case 'state': + case 'initial_state': + case 'accept_state': { + if (h.state_id) { + cy.$id(h.state_id).addClass('error-highlight') + } + break + } + + case 'transition': { + cy.edges() + .filter((e) => { + const fromOk = h.from_state + ? e.source().id() === h.from_state + : true + const toOk = h.to_state + ? e.target().id() === h.to_state + : true + const symOk = h.symbol + ? e.data('label') === h.symbol + : true + return fromOk && toOk && symOk + }) + .addClass('error-highlight') + break + } + + case 'alphabet_symbol': { + if (h.symbol) { + cy.edges() + .filter((e) => e.data('label') === h.symbol) + .addClass('error-highlight') + } + break + } + } + } + }, [feedback]) + return (
{ this.config = { @@ -30,7 +30,7 @@ export class FSAResponseAreaTub extends ResponseAreaTub { } } - customCheck = () => {} // will set this up later + // customCheck = () => {} // will set this up later /* -------------------- Input -------------------- */ diff --git a/src/types/FSA/styles.ts b/src/types/FSA/styles.ts index 3ae419f..8ed068e 100644 --- a/src/types/FSA/styles.ts +++ b/src/types/FSA/styles.ts @@ -18,6 +18,7 @@ export const useLocalStyles = makeStyles()((theme) => ({ display: 'flex', flexDirection: 'column', gap: theme.spacing(2), + overflowY: 'auto' }, floatingConfig: { From c88db1b971c0eff49a95e7ca47b31a747f70dd65 Mon Sep 17 00:00:00 2001 From: everythingfades Date: Fri, 30 Jan 2026 18:55:45 +0000 Subject: [PATCH 10/29] revert: revert back to before preview --- .gitignore | 2 +- externals/api/fetcher.ts | 61 +- src/types/FSA/FSA.component.tsx | 589 +----------------- .../FSA/components/ItemPropertiesPanel.tsx | 26 +- src/types/FSA/index.tsx | 59 +- src/types/FSA/styles.ts | 1 - 6 files changed, 45 insertions(+), 693 deletions(-) diff --git a/.gitignore b/.gitignore index a0919bd..1b68e4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # dependencies /node_modules -/client-web-main + # production /build /dist diff --git a/externals/api/fetcher.ts b/externals/api/fetcher.ts index b0f1f8f..4fa1f95 100644 --- a/externals/api/fetcher.ts +++ b/externals/api/fetcher.ts @@ -1,62 +1,7 @@ -type GraphqlResponse = { - data: TData | null - errors?: Array<{ message?: string }> | null -} - -function getGraphqlUrl(): string { - // 1) Explicit override (handy in sandbox contexts) - const override = (globalThis as any).__LF_GRAPHQL_URL - if (typeof override === 'string' && override.length > 0) return override - - // 2) Common paths (works when frontend and API share origin/proxy) - // Prefer /graphql (client-web-main default), but also try /api/graphql (common convention) - return new URL('/graphql', globalThis.location?.origin ?? 'http://localhost') - .toString() -} - export const fetchData = ( - query: string, - variables?: TVariables, + _query: string, + _variables?: TVariables, _options?: unknown, ): (() => Promise) => { - return async () => { - const url = getGraphqlUrl() - - const res = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - // In the real app, auth is usually cookie-based or handled by the host page. - // Include credentials so the sandbox can reuse the host session. - credentials: 'include', - body: JSON.stringify({ - query, - variables, - }), - }) - - if (!res.ok) { - throw new Error( - `GraphQL request failed (${res.status} ${res.statusText})`, - ) - } - - const json = (await res.json()) as GraphqlResponse - - if (json.errors && json.errors.length > 0) { - const msg = - json.errors - .map(e => e?.message) - .filter(Boolean) - .join('\n') || 'GraphQL error' - throw new Error(msg) - } - - if (json.data === null) { - throw new Error('GraphQL: no data returned') - } - - return json.data - } + return null as any } diff --git a/src/types/FSA/FSA.component.tsx b/src/types/FSA/FSA.component.tsx index de76e14..de58b19 100644 --- a/src/types/FSA/FSA.component.tsx +++ b/src/types/FSA/FSA.component.tsx @@ -1,36 +1,22 @@ -import { useSubmitResponsePreviewMutation } from '@api/graphql' +import { makeStyles } from '@styles' import cytoscape, { Core, NodeSingular, EdgeSingular } from 'cytoscape' -import React, { useEffect, useMemo, useRef, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import ConfigPanel from './components/ConfigPanel' import ItemPropertiesPanel from './components/ItemPropertiesPanel' import { useLocalStyles } from './styles' -import { - DEFAULT_FSA_CONFIG, - FSA, - FSAConfig, - FSAFeedback, - FSAFeedbackSchema, - ValidationError, - ValidationErrorSchema, -} from './type' +import { DEFAULT_FSA_CONFIG, FSA, FSAConfig, FSAFeedback } from './type' interface FSAInputProps { answer: FSA handleChange: (fsa: FSA) => void feedback: FSAFeedback | null - hasPreview?: boolean - responseAreaId?: string - universalResponseAreaId?: string } export const FSAInput: React.FC = ({ answer, handleChange, - feedback, - hasPreview, - responseAreaId, - universalResponseAreaId, + feedback }) => { const { classes } = useLocalStyles() @@ -44,90 +30,6 @@ export const FSAInput: React.FC = ({ const [config, setConfig] = useState(DEFAULT_FSA_CONFIG) const [configOpen, setConfigOpen] = useState(true) - const [previewIsLoading, setPreviewIsLoading] = useState(false) - const [previewText, setPreviewText] = useState(null) - const [previewFeedback, setPreviewFeedback] = useState(null) - - type PreviewSympy = Record - - const localPreview = useMemo(() => { - // Always compute a fast local preview as a fallback. - return validateFsaPreview(answer, config) - }, [answer, config]) - - /* -------------------- debounce preview call -------------------- */ - useEffect(() => { - // If live preview is off, show normal (check) feedback if any. - if (!hasPreview) { - setPreviewIsLoading(false) - setPreviewText(null) - setPreviewFeedback(feedback) - return - } - - // If we can't call the backend preview, fall back to local validation. - if (!responseAreaId || !universalResponseAreaId) { - setPreviewIsLoading(false) - setPreviewText(null) - setPreviewFeedback(localPreview) - return - } - - const requireDeterministic = config.expected_type === 'DFA' - const showWarnings = true - - const timeout = setTimeout(async () => { - setPreviewIsLoading(true) - try { - const data = await useSubmitResponsePreviewMutation.fetcher({ - submission: answer, - additionalParams: { - require_deterministic: requireDeterministic, - show_warnings: showWarnings, - }, - responseAreaId, - universalResponseAreaId, - })() - - const res = data.submitResponsePreview - - // Best-effort mapping: the backend returns { preview: { latex, feedback, sympy } } - const previewObj = res.preview - if (previewObj && typeof previewObj === 'object') { - const maybeFeedback = (previewObj as any).feedback - const maybeSympy = (previewObj as any).sympy as PreviewSympy | undefined - - setPreviewText(typeof maybeFeedback === 'string' ? maybeFeedback : null) - setPreviewFeedback( - maybeSympy ? sympyToFsaFeedback(maybeSympy, answer) : localPreview, - ) - } else { - // No structured preview; show server string feedback if present - setPreviewText(res.feedback ?? null) - setPreviewFeedback(localPreview) - } - } catch { - // If network preview fails (common in sandbox contexts), still give the student useful local feedback. - setPreviewText(null) - setPreviewFeedback(localPreview) - } finally { - setPreviewIsLoading(false) - } - }, 500) - - return () => { - clearTimeout(timeout) - } - }, [ - answer, - config.expected_type, - feedback, - hasPreview, - localPreview, - responseAreaId, - universalResponseAreaId, - ]) - /* -------------------- init cytoscape -------------------- */ useEffect(() => { if (!containerRef.current) return @@ -136,7 +38,6 @@ export const FSAInput: React.FC = ({ container: containerRef.current, layout: { name: 'preset' }, style: [ - // ---------------- Nodes ---------------- { selector: 'node', style: { @@ -150,33 +51,6 @@ export const FSAInput: React.FC = ({ 'border-color': '#555', }, }, - - { - selector: 'node.initial', - style: { - 'border-width': 3, - 'border-color': '#1976d2', - }, - }, - - { - selector: 'node.accept', - style: { - 'border-style': 'double', - 'border-width': 4, - }, - }, - - { - selector: 'node.error-highlight', - style: { - 'background-color': '#ffebee', - 'border-color': '#d32f2f', - 'border-width': 4, - }, - }, - - // ---------------- Edges ---------------- { selector: 'edge', style: { @@ -190,37 +64,6 @@ export const FSAInput: React.FC = ({ 'text-background-padding': '3px', }, }, - { - selector: '.preview-error', - style: { - 'border-width': 4, - 'border-color': '#d32f2f', - 'line-color': '#d32f2f', - 'target-arrow-color': '#d32f2f', - 'background-color': '#ffebee', - width: 3, - }, - }, - { - selector: '.preview-warning', - style: { - 'border-width': 4, - 'border-color': '#ed6c02', - 'line-color': '#ed6c02', - 'target-arrow-color': '#ed6c02', - 'background-color': '#fff3e0', - }, - }, - - { - selector: 'edge.error-highlight', - style: { - 'line-color': '#d32f2f', - 'target-arrow-color': '#d32f2f', - 'line-style': 'dashed', - width: 3, - }, - }, ], }) @@ -228,24 +71,6 @@ export const FSAInput: React.FC = ({ return () => cy.destroy() }, []) - /* -------------------- apply preview highlights -------------------- */ - useEffect(() => { - const cy = cyRef.current - if (!cy) return - - cy.elements().removeClass('preview-error preview-warning') - - const fb = previewFeedback - if (!fb) return - - for (const err of fb.errors) { - applyHighlight(cy, err, 'preview-error') - } - for (const warn of fb.warnings) { - applyHighlight(cy, warn, 'preview-warning') - } - }, [previewFeedback]) - /* -------------------- node/edge handlers -------------------- */ useEffect(() => { const cy = cyRef.current @@ -253,7 +78,6 @@ export const FSAInput: React.FC = ({ const tapNode = (e: cytoscape.EventObject): void => { const node = e.target as NodeSingular - if (drawMode) { if (!fromNode) { setFromNode(node.id()) @@ -300,19 +124,17 @@ export const FSAInput: React.FC = ({ if (!cy) return const fsa: FSA = { - states: cy.nodes()?.map((n) => n.id()), - transitions: cy.edges()?.map( + states: cy.nodes().map((n) => n.id()), + transitions: cy.edges().map( (e) => `${e.source().id()}|${e.data('label') || config.epsilon_symbol}|${e.target().id()}`, ), initial_state: answer.initial_state, accept_states: answer.accept_states, - alphabet: Array.from( - new Set(cy.edges().map((e) => String(e.data('label')))), - ), + alphabet: Array.from(new Set(cy.edges().map((e) => String(e.data('label'))))), } - handleChange(fsa) + handleChange(fsa) // Only FSA, not config } /* -------------------- add state -------------------- */ @@ -324,88 +146,12 @@ export const FSAInput: React.FC = ({ cy.add({ group: 'nodes', data: { id, displayLabel: id }, - position: { - x: 100 + Math.random() * 300, - y: 100 + Math.random() * 300, - }, + position: { x: 100 + Math.random() * 300, y: 100 + Math.random() * 300 }, }) syncToBackend() } - /* -------------------- apply initial / accept styling -------------------- */ - useEffect(() => { - const cy = cyRef.current - if (!cy) return - - cy.nodes().removeClass('initial accept') - - if (answer.initial_state) { - cy.$id(answer.initial_state).addClass('initial') - } - - for (const id of answer.accept_states) { - cy.$id(id).addClass('accept') - } - }, [answer.initial_state, answer.accept_states]) - - /* -------------------- apply feedback highlights -------------------- */ - useEffect(() => { - const cy = cyRef.current - if (!cy) return - - cy.nodes().removeClass('error-highlight') - cy.edges().removeClass('error-highlight') - - if (!feedback || !feedback.errors) return - - const highlights = feedback.errors - .map((e) => e.highlight) - .filter(Boolean) - - for (const h of highlights) { - if (!h) continue - - switch (h.type) { - case 'state': - case 'initial_state': - case 'accept_state': { - if (h.state_id) { - cy.$id(h.state_id).addClass('error-highlight') - } - break - } - - case 'transition': { - cy.edges() - .filter((e) => { - const fromOk = h.from_state - ? e.source().id() === h.from_state - : true - const toOk = h.to_state - ? e.target().id() === h.to_state - : true - const symOk = h.symbol - ? e.data('label') === h.symbol - : true - return fromOk && toOk && symOk - }) - .addClass('error-highlight') - break - } - - case 'alphabet_symbol': { - if (h.symbol) { - cy.edges() - .filter((e) => e.data('label') === h.symbol) - .addClass('error-highlight') - } - break - } - } - } - }, [feedback]) - return (
= ({ syncToBackend={syncToBackend} handleChange={handleChange} answer={answer} - feedback={previewFeedback} - previewText={previewText} - previewIsLoading={previewIsLoading} + feedback={feedback} />
@@ -439,316 +183,3 @@ export const FSAInput: React.FC = ({
) } - -function parseTransition(t: string): { from: string; symbol: string; to: string } { - const [from = '', symbol = '', to = ''] = t.split('|') - return { from, symbol, to } -} - -function sympyToFsaFeedback(sympy: Record, answer: FSA): FSAFeedback { - const errorsRaw = Array.isArray(sympy.errors) ? sympy.errors : [] - const warningsRaw = Array.isArray(sympy.warnings) ? sympy.warnings : [] - - const errors: ValidationError[] = [] - const warnings: ValidationError[] = [] - - for (const e of errorsRaw) { - const parsed = ValidationErrorSchema.safeParse(e) - if (parsed.success) errors.push(parsed.data) - } - for (const w of warningsRaw) { - const parsed = ValidationErrorSchema.safeParse(w) - if (parsed.success) warnings.push(parsed.data) - } - - const numStates = readNumber(sympy.num_states, answer.states.length) - const numTransitions = readNumber(sympy.num_transitions, answer.transitions.length) - - const isDet = readBoolean(sympy.is_deterministic, false) - const isComplete = readBoolean(sympy.is_complete, false) - const unreachable = readStringArray(sympy.unreachable_states) - const dead = readStringArray(sympy.dead_states) - - const stateWord = numStates === 1 ? 'state' : 'states' - const transWord = numTransitions === 1 ? 'transition' : 'transitions' - const summary = `${isDet ? 'DFA' : 'NFA'} with ${numStates} ${stateWord} and ${numTransitions} ${transWord}` - - const candidate: FSAFeedback = { - summary, - errors, - warnings, - structural: { - is_deterministic: isDet, - is_complete: isComplete, - num_states: numStates, - num_transitions: numTransitions, - unreachable_states: unreachable, - dead_states: dead, - }, - test_results: [], - hints: [], - } - - const parsed = FSAFeedbackSchema.safeParse(candidate) - return parsed.success ? parsed.data : candidate -} - -function readBoolean(v: unknown, fallback: boolean): boolean { - return typeof v === 'boolean' ? v : fallback -} - -function readNumber(v: unknown, fallback: number): number { - return typeof v === 'number' && Number.isFinite(v) ? v : fallback -} - -function readStringArray(v: unknown): string[] { - if (!Array.isArray(v)) return [] - return v.filter(x => typeof x === 'string') as string[] -} - -function validateFsaPreview(answer: FSA, config: FSAConfig): FSAFeedback { - const errors: ValidationError[] = [] - const warnings: ValidationError[] = [] - - const states = new Set(answer.states) - const alphabet = new Set(answer.alphabet) - - const transitions = answer.transitions.map(parseTransition) - - // Structural errors - if (answer.states.length === 0) { - errors.push({ - message: 'No states defined.', - code: 'EMPTY_STATES', - severity: 'error', - highlight: null, - }) - } - - if (!answer.initial_state || !states.has(answer.initial_state)) { - errors.push({ - message: 'Initial state is missing or not in the set of states.', - code: 'INVALID_INITIAL', - severity: 'error', - highlight: answer.initial_state - ? { type: 'initial_state', state_id: answer.initial_state } - : { type: 'initial_state', state_id: null }, - }) - } - - for (const acc of answer.accept_states) { - if (!states.has(acc)) { - errors.push({ - message: `Accept state '${acc}' is not in the set of states.`, - code: 'INVALID_ACCEPT', - severity: 'error', - highlight: { type: 'accept_state', state_id: acc }, - }) - } - } - - for (const tr of transitions) { - if (!states.has(tr.from)) { - errors.push({ - message: `Transition source state '${tr.from}' is not a valid state.`, - code: 'INVALID_TRANSITION_SOURCE', - severity: 'error', - highlight: { type: 'transition', from_state: tr.from, to_state: tr.to, symbol: tr.symbol }, - }) - } - if (!states.has(tr.to)) { - errors.push({ - message: `Transition destination state '${tr.to}' is not a valid state.`, - code: 'INVALID_TRANSITION_DEST', - severity: 'error', - highlight: { type: 'transition', from_state: tr.from, to_state: tr.to, symbol: tr.symbol }, - }) - } - if (!tr.symbol) { - errors.push({ - message: `A transition is missing its symbol.`, - code: 'INVALID_TRANSITION_SYMBOL', - severity: 'error', - highlight: { type: 'transition', from_state: tr.from, to_state: tr.to, symbol: null }, - }) - } else if (alphabet.size > 0 && !alphabet.has(tr.symbol)) { - errors.push({ - message: `Transition symbol '${tr.symbol}' is not in the alphabet.`, - code: 'INVALID_SYMBOL', - severity: 'error', - highlight: { type: 'transition', from_state: tr.from, to_state: tr.to, symbol: tr.symbol }, - }) - } - } - - const requireDeterministic = config.expected_type === 'DFA' - - const keyToTargets = new Map>() - let hasEpsilon = false - for (const tr of transitions) { - if (tr.symbol === config.epsilon_symbol) hasEpsilon = true - const key = `${tr.from}||${tr.symbol}` - const set = keyToTargets.get(key) ?? new Set() - set.add(tr.to) - keyToTargets.set(key, set) - } - - let isDeterministic = true - for (const [key, tos] of keyToTargets.entries()) { - if (tos.size > 1) { - isDeterministic = false - const [from, symbol] = key.split('||') - errors.push({ - message: `Non-determinism detected: multiple transitions from '${from}' on symbol '${symbol}'.`, - code: 'NOT_DETERMINISTIC', - severity: requireDeterministic ? 'error' : 'warning', - highlight: { type: 'state', state_id: from }, - }) - } - } - - if (hasEpsilon) { - isDeterministic = false - if (requireDeterministic) { - errors.push({ - message: `Epsilon transitions are not allowed in a DFA (epsilon = '${config.epsilon_symbol}').`, - code: 'NOT_DETERMINISTIC', - severity: 'error', - highlight: null, - }) - } - } - - // Reachability - const reachable = new Set() - if (answer.initial_state && states.has(answer.initial_state)) { - const stack = [answer.initial_state] - while (stack.length) { - const s = stack.pop()! - if (reachable.has(s)) continue - reachable.add(s) - for (const tr of transitions) { - if (tr.from === s && states.has(tr.to) && !reachable.has(tr.to)) { - stack.push(tr.to) - } - } - } - } - - const unreachableStates = answer.states.filter(s => !reachable.has(s)) - if (unreachableStates.length > 0) { - for (const s of unreachableStates) { - warnings.push({ - message: `State '${s}' is unreachable from the initial state.`, - code: 'UNREACHABLE_STATE', - severity: 'warning', - highlight: { type: 'state', state_id: s }, - }) - } - } - - // Dead states: cannot reach any accept state - const reverse = new Map>() - for (const tr of transitions) { - const set = reverse.get(tr.to) ?? new Set() - set.add(tr.from) - reverse.set(tr.to, set) - } - - const canReachAccept = new Set() - const queue: string[] = [] - for (const a of answer.accept_states) { - if (states.has(a)) { - canReachAccept.add(a) - queue.push(a) - } - } - while (queue.length) { - const cur = queue.shift()! - const prevs = reverse.get(cur) - if (!prevs) continue - for (const p of prevs) { - if (!canReachAccept.has(p)) { - canReachAccept.add(p) - queue.push(p) - } - } - } - - const deadStates = answer.states.filter(s => states.has(s) && !canReachAccept.has(s)) - if (deadStates.length > 0) { - for (const s of deadStates) { - warnings.push({ - message: `State '${s}' is a dead state (cannot reach an accept state).`, - code: 'DEAD_STATE', - severity: 'warning', - highlight: { type: 'state', state_id: s }, - }) - } - } - - // Completeness (only meaningful for DFA) - let isComplete = false - if (isDeterministic && alphabet.size > 0 && !alphabet.has(config.epsilon_symbol)) { - isComplete = true - for (const s of answer.states) { - for (const sym of alphabet) { - const key = `${s}||${sym}` - if (!keyToTargets.get(key) || (keyToTargets.get(key)?.size ?? 0) !== 1) { - isComplete = false - if (requireDeterministic) { - warnings.push({ - message: `Missing transition from '${s}' on symbol '${sym}'.`, - code: 'MISSING_TRANSITION', - severity: 'warning', - highlight: { type: 'state', state_id: s }, - }) - } - } - } - } - } - - const numStates = answer.states.length - const numTransitions = answer.transitions.length - const stateWord = numStates === 1 ? 'state' : 'states' - const transWord = numTransitions === 1 ? 'transition' : 'transitions' - const summary = `${isDeterministic ? 'DFA' : 'NFA'} with ${numStates} ${stateWord} and ${numTransitions} ${transWord}` - - return { - summary, - errors, - warnings, - structural: { - is_deterministic: isDeterministic, - is_complete: isComplete, - num_states: numStates, - num_transitions: numTransitions, - unreachable_states: unreachableStates, - dead_states: deadStates, - }, - test_results: [], - hints: [], - } -} - -function applyHighlight(cy: Core, err: ValidationError, className: string) { - const h = err.highlight - if (!h) return - - if ((h.type === 'state' || h.type === 'initial_state' || h.type === 'accept_state') && h.state_id) { - cy.getElementById(h.state_id).addClass(className) - return - } - - if (h.type === 'transition' && h.from_state && h.to_state) { - const candidates = cy.edges().filter(e => { - const fromOk = e.source().id() === h.from_state - const toOk = e.target().id() === h.to_state - const label = String(e.data('label') ?? '') - const symOk = h.symbol ? label === h.symbol : true - return fromOk && toOk && symOk - }) - candidates.addClass(className) - } -} diff --git a/src/types/FSA/components/ItemPropertiesPanel.tsx b/src/types/FSA/components/ItemPropertiesPanel.tsx index 0fefd1a..bb23291 100644 --- a/src/types/FSA/components/ItemPropertiesPanel.tsx +++ b/src/types/FSA/components/ItemPropertiesPanel.tsx @@ -26,8 +26,6 @@ interface ItemPropertiesPanelProps { syncToBackend: () => void feedback: FSAFeedback | null - previewText?: string | null - previewIsLoading?: boolean } export default function ItemPropertiesPanel({ @@ -44,9 +42,7 @@ export default function ItemPropertiesPanel({ answer, handleChange, syncToBackend, - feedback, - previewText, - previewIsLoading, + feedback }: ItemPropertiesPanelProps): JSX.Element { return (
@@ -162,26 +158,6 @@ export default function ItemPropertiesPanel({ Delete Selected )} - {previewIsLoading ? ( -
Previewing…
- ) : null} - {previewText ? ( -
- {previewText} -
- ) : null} diff --git a/src/types/FSA/index.tsx b/src/types/FSA/index.tsx index 05f3102..7ec599f 100644 --- a/src/types/FSA/index.tsx +++ b/src/types/FSA/index.tsx @@ -9,14 +9,7 @@ import { import { ResponseAreaTub } from '../response-area-tub' import { FSAInput } from './FSA.component' -import { - DEFAULT_FSA_CONFIG, - FSA, - FSAConfig, - FSAFeedbackSchema, - defaultFSA, - fsaAnswerSchema, -} from './type' +import { fsaAnswerSchema, FSA, defaultFSA, DEFAULT_FSA_CONFIG, FSAConfig, FSAFeedback } from './type' export class FSAResponseAreaTub extends ResponseAreaTub { public readonly responseType = 'FSA' @@ -28,7 +21,7 @@ export class FSAResponseAreaTub extends ResponseAreaTub { // private feedback: FSAFeedback | null = null public readonly delegateFeedback = false // we want to manage our own feedback - public readonly delegateLivePreview = true// we want live previews + public readonly delegateLivePreview = false // we want live previews initWithConfig = (config: any) => { this.config = { @@ -37,36 +30,42 @@ export class FSAResponseAreaTub extends ResponseAreaTub { } } - // customCheck = () => {} // will set this up later + customCheck = () => {} // will set this up later /* -------------------- Input -------------------- */ public InputComponent = (props: BaseResponseAreaProps): JSX.Element => { // Ensure a valid FSA answer - const parsedAnswer = this.answerSchema.safeParse(props.answer) - const validAnswer: FSA = parsedAnswer.success ? parsedAnswer.data : defaultFSA + const validAnswer: FSA = this.answerSchema.safeParse(props.answer).success + ? this.answerSchema.safeParse(props.answer).data ?? defaultFSA + : defaultFSA - const checkFeedback = (() => { - const raw = props.feedback?.feedback - if (!raw) return null - try { - // legacy format sometimes embeds JSON after a
- const jsonPart = raw.split('
')[1]?.trim() ?? raw.trim() - const parsed = FSAFeedbackSchema.safeParse(JSON.parse(jsonPart)) - return parsed.success ? parsed.data : null - } catch { - return null - } - })() + // // Parse feedback safely and memoize so it updates when props.feedback changes + // const feedback: FSAFeedback | null = useMemo(() => { + // if (!props.feedback?.feedback) return null + // try { + // return JSON.parse(props.feedback.feedback) + // } catch { + // return null + // } + // }, [props.feedback?.feedback]) return ( { + const raw = props.feedback?.feedback + if (!raw) return {} + try { + // split by
and take the second part, trim whitespace + const jsonPart = raw.split('
')[1]?.trim() ?? '{}' + return JSON.parse(jsonPart) + } catch { + return {} // fallback to empty object if parsing fails + } + })()} answer={validAnswer} handleChange={(val: FSA) => props.handleChange(val)} - feedback={checkFeedback} - hasPreview={props.hasPreview} - responseAreaId={props.responseAreaId} - universalResponseAreaId={props.universalResponseAreaId} /> ) } @@ -78,10 +77,12 @@ public InputComponent = (props: BaseResponseAreaProps): JSX.Element => { ): JSX.Element => { return ( { this.answer = val + console.log('Wizard val:', val) props.handleChange({ responseType: this.responseType, answer: val, diff --git a/src/types/FSA/styles.ts b/src/types/FSA/styles.ts index 8ed068e..3ae419f 100644 --- a/src/types/FSA/styles.ts +++ b/src/types/FSA/styles.ts @@ -18,7 +18,6 @@ export const useLocalStyles = makeStyles()((theme) => ({ display: 'flex', flexDirection: 'column', gap: theme.spacing(2), - overflowY: 'auto' }, floatingConfig: { From 1370a0dea641407593b708eb9685f56b36976572 Mon Sep 17 00:00:00 2001 From: everythingfades Date: Fri, 30 Jan 2026 19:04:19 +0000 Subject: [PATCH 11/29] fix: styles forgetten by the revert --- src/types/FSA/FSA.component.tsx | 129 ++++++++++++++++++++++++++++++-- src/types/FSA/index.tsx | 4 +- src/types/FSA/styles.ts | 1 + 3 files changed, 126 insertions(+), 8 deletions(-) diff --git a/src/types/FSA/FSA.component.tsx b/src/types/FSA/FSA.component.tsx index de58b19..4e744ad 100644 --- a/src/types/FSA/FSA.component.tsx +++ b/src/types/FSA/FSA.component.tsx @@ -16,7 +16,7 @@ interface FSAInputProps { export const FSAInput: React.FC = ({ answer, handleChange, - feedback + feedback, }) => { const { classes } = useLocalStyles() @@ -38,6 +38,7 @@ export const FSAInput: React.FC = ({ container: containerRef.current, layout: { name: 'preset' }, style: [ + // ---------------- Nodes ---------------- { selector: 'node', style: { @@ -51,6 +52,33 @@ export const FSAInput: React.FC = ({ 'border-color': '#555', }, }, + + { + selector: 'node.initial', + style: { + 'border-width': 3, + 'border-color': '#1976d2', + }, + }, + + { + selector: 'node.accept', + style: { + 'border-style': 'double', + 'border-width': 4, + }, + }, + + { + selector: 'node.error-highlight', + style: { + 'background-color': '#ffebee', + 'border-color': '#d32f2f', + 'border-width': 4, + }, + }, + + // ---------------- Edges ---------------- { selector: 'edge', style: { @@ -64,6 +92,16 @@ export const FSAInput: React.FC = ({ 'text-background-padding': '3px', }, }, + + { + selector: 'edge.error-highlight', + style: { + 'line-color': '#d32f2f', + 'target-arrow-color': '#d32f2f', + 'line-style': 'dashed', + width: 3, + }, + }, ], }) @@ -78,6 +116,7 @@ export const FSAInput: React.FC = ({ const tapNode = (e: cytoscape.EventObject): void => { const node = e.target as NodeSingular + if (drawMode) { if (!fromNode) { setFromNode(node.id()) @@ -124,17 +163,19 @@ export const FSAInput: React.FC = ({ if (!cy) return const fsa: FSA = { - states: cy.nodes().map((n) => n.id()), - transitions: cy.edges().map( + states: cy.nodes()?.map((n) => n.id()), + transitions: cy.edges()?.map( (e) => `${e.source().id()}|${e.data('label') || config.epsilon_symbol}|${e.target().id()}`, ), initial_state: answer.initial_state, accept_states: answer.accept_states, - alphabet: Array.from(new Set(cy.edges().map((e) => String(e.data('label'))))), + alphabet: Array.from( + new Set(cy.edges().map((e) => String(e.data('label')))), + ), } - handleChange(fsa) // Only FSA, not config + handleChange(fsa) } /* -------------------- add state -------------------- */ @@ -146,12 +187,88 @@ export const FSAInput: React.FC = ({ cy.add({ group: 'nodes', data: { id, displayLabel: id }, - position: { x: 100 + Math.random() * 300, y: 100 + Math.random() * 300 }, + position: { + x: 100 + Math.random() * 300, + y: 100 + Math.random() * 300, + }, }) syncToBackend() } + /* -------------------- apply initial / accept styling -------------------- */ + useEffect(() => { + const cy = cyRef.current + if (!cy) return + + cy.nodes().removeClass('initial accept') + + if (answer.initial_state) { + cy.$id(answer.initial_state).addClass('initial') + } + + for (const id of answer.accept_states) { + cy.$id(id).addClass('accept') + } + }, [answer.initial_state, answer.accept_states]) + + /* -------------------- apply feedback highlights -------------------- */ + useEffect(() => { + const cy = cyRef.current + if (!cy) return + + cy.nodes().removeClass('error-highlight') + cy.edges().removeClass('error-highlight') + + if (!feedback || !feedback.errors) return + + const highlights = feedback.errors + .map((e) => e.highlight) + .filter(Boolean) + + for (const h of highlights) { + if (!h) continue + + switch (h.type) { + case 'state': + case 'initial_state': + case 'accept_state': { + if (h.state_id) { + cy.$id(h.state_id).addClass('error-highlight') + } + break + } + + case 'transition': { + cy.edges() + .filter((e) => { + const fromOk = h.from_state + ? e.source().id() === h.from_state + : true + const toOk = h.to_state + ? e.target().id() === h.to_state + : true + const symOk = h.symbol + ? e.data('label') === h.symbol + : true + return fromOk && toOk && symOk + }) + .addClass('error-highlight') + break + } + + case 'alphabet_symbol': { + if (h.symbol) { + cy.edges() + .filter((e) => e.data('label') === h.symbol) + .addClass('error-highlight') + } + break + } + } + } + }, [feedback]) + return (
{ this.config = { @@ -30,7 +30,7 @@ export class FSAResponseAreaTub extends ResponseAreaTub { } } - customCheck = () => {} // will set this up later + // customCheck = () => {} // will set this up later /* -------------------- Input -------------------- */ diff --git a/src/types/FSA/styles.ts b/src/types/FSA/styles.ts index 3ae419f..8ed068e 100644 --- a/src/types/FSA/styles.ts +++ b/src/types/FSA/styles.ts @@ -18,6 +18,7 @@ export const useLocalStyles = makeStyles()((theme) => ({ display: 'flex', flexDirection: 'column', gap: theme.spacing(2), + overflowY: 'auto' }, floatingConfig: { From 04a1e4dcd56716adf3d54aabeec156a72fe7c772 Mon Sep 17 00:00:00 2001 From: everythingfades Date: Sun, 1 Feb 2026 02:43:20 +0000 Subject: [PATCH 12/29] feat: should be everything --- src/types/FSA/FSA.backend.ts | 54 --- src/types/FSA/FSA.component.tsx | 19 +- src/types/FSA/components/ConfigPanel.tsx | 32 +- src/types/FSA/components/FSAFeedbackPanel.tsx | 55 ++- .../FSA/components/ItemPropertiesPanel.tsx | 10 +- src/types/FSA/index.tsx | 136 ++++--- src/types/FSA/type.ts | 27 +- src/types/FSA/utils.ts | 13 + src/types/FSA/validateFSA.ts | 333 ++++++++++++++++++ 9 files changed, 529 insertions(+), 150 deletions(-) delete mode 100644 src/types/FSA/FSA.backend.ts create mode 100644 src/types/FSA/utils.ts create mode 100644 src/types/FSA/validateFSA.ts diff --git a/src/types/FSA/FSA.backend.ts b/src/types/FSA/FSA.backend.ts deleted file mode 100644 index fd5a07b..0000000 --- a/src/types/FSA/FSA.backend.ts +++ /dev/null @@ -1,54 +0,0 @@ -// FSA.converter.ts -// this is for the inconsistency between the backend pydantic models and the frontend compromsise -import { FSA } from './type'; - -/** - * Backend representation of an FSA transition - */ -export interface BackendTransition { - from_state: string; - to_state: string; - symbol: string; -} - -/** - * Backend representation of the full FSA - */ -export interface BackendFSA { - states: string[]; - alphabet: string[]; - transitions: BackendTransition[]; - initial_state: string; - accept_states: string[]; -} - -export const FSAConverter = { - /** - * Converts frontend FSA (flat transitions) to Backend FSA (object transitions) - */ - toBackend(frontendFsa: FSA): BackendFSA { - return { - ...frontendFsa, - transitions: frontendFsa.transitions.map((tStr) => { - const [from, symbol, to] = tStr.split('|'); - return { - from_state: from || '', - to_state: to || '', - symbol: symbol || '', - }; - }), - }; - }, - - /** - * Converts Backend FSA (object transitions) to frontend FSA (flat transitions) - */ - toFrontend(backendFsa: BackendFSA): FSA { - return { - ...backendFsa, - transitions: backendFsa.transitions.map( - (t) => `${t.from_state}|${t.symbol}|${t.to_state}` - ), - }; - }, -}; \ No newline at end of file diff --git a/src/types/FSA/FSA.component.tsx b/src/types/FSA/FSA.component.tsx index 4e744ad..08335bd 100644 --- a/src/types/FSA/FSA.component.tsx +++ b/src/types/FSA/FSA.component.tsx @@ -5,18 +5,22 @@ import React, { useEffect, useRef, useState } from 'react' import ConfigPanel from './components/ConfigPanel' import ItemPropertiesPanel from './components/ItemPropertiesPanel' import { useLocalStyles } from './styles' -import { DEFAULT_FSA_CONFIG, FSA, FSAConfig, FSAFeedback } from './type' +import { CheckPhase, DEFAULT_FSA_CONFIG, FSA, FSAConfig, FSAFeedback } from './type' interface FSAInputProps { answer: FSA handleChange: (fsa: FSA) => void feedback: FSAFeedback | null + previewFeedback: FSAFeedback | null + phase: CheckPhase } export const FSAInput: React.FC = ({ answer, handleChange, feedback, + previewFeedback, + phase }) => { const { classes } = useLocalStyles() @@ -173,6 +177,7 @@ export const FSAInput: React.FC = ({ alphabet: Array.from( new Set(cy.edges().map((e) => String(e.data('label')))), ), + config: JSON.stringify(config) } handleChange(fsa) @@ -286,13 +291,23 @@ export const FSAInput: React.FC = ({ handleChange={handleChange} answer={answer} feedback={feedback} + previewFeedback={previewFeedback} + phase={phase} />
{ + const fsa: FSA = { + ...answer, + config: JSON.stringify(val) + } + + handleChange(fsa) + setConfig(val) + }} configOpen={configOpen} setConfigOpen={setConfigOpen} classes={classes} diff --git a/src/types/FSA/components/ConfigPanel.tsx b/src/types/FSA/components/ConfigPanel.tsx index 870db01..240ee63 100644 --- a/src/types/FSA/components/ConfigPanel.tsx +++ b/src/types/FSA/components/ConfigPanel.tsx @@ -1,20 +1,22 @@ import React from 'react' -interface EvaluationConfigPanelProps> { - config: T - setConfig: React.Dispatch> +import { FSAConfig } from '../type' + +interface EvaluationConfigPanelProps { + config: FSAConfig + setConfig: (config: FSAConfig) => void configOpen: boolean setConfigOpen: React.Dispatch> classes: Record } -export default function EvaluationConfigPanel>({ +export default function EvaluationConfigPanel({ config, setConfig, configOpen, setConfigOpen, - classes, -}: EvaluationConfigPanelProps) { + classes +}: EvaluationConfigPanelProps) { return (
>({ type="checkbox" checked={value} onChange={(e) => - setConfig((prev) => ({ - ...prev, + setConfig({ + ...config, [key]: e.target.checked, - })) + }) } /> ) : typeof value === 'number' ? ( @@ -48,10 +50,10 @@ export default function EvaluationConfigPanel>({ className={classes.inputField} value={value} onChange={(e) => - setConfig((prev) => ({ - ...prev, + setConfig({ + ...config, [key]: Number(e.target.value), - })) + }) } /> ) : ( @@ -59,10 +61,10 @@ export default function EvaluationConfigPanel>({ className={classes.inputField} value={String(value)} onChange={(e) => - setConfig((prev) => ({ - ...prev, + setConfig({ + ...config, [key]: e.target.value, - })) + }) } /> )} diff --git a/src/types/FSA/components/FSAFeedbackPanel.tsx b/src/types/FSA/components/FSAFeedbackPanel.tsx index 4e89fe9..0280090 100644 --- a/src/types/FSA/components/FSAFeedbackPanel.tsx +++ b/src/types/FSA/components/FSAFeedbackPanel.tsx @@ -1,18 +1,32 @@ import React, { useMemo } from 'react' -import { FSAFeedbackSchema, type FSAFeedback } from '../type' +import { + CheckPhase, + FSAFeedbackSchema, + type FSAFeedback, +} from '../type' interface FSAFeedbackPanelProps { feedback: FSAFeedback | null + phase: CheckPhase } -export function FSAFeedbackPanel({ feedback }: FSAFeedbackPanelProps) { - const parsed = useMemo(() => FSAFeedbackSchema.safeParse(feedback), [feedback]) +export function FSAFeedbackPanel({ + feedback, + phase, +}: FSAFeedbackPanelProps) { + + const parsed = useMemo( + () => FSAFeedbackSchema.safeParse(feedback), + [feedback], + ) + if (!feedback || !parsed.success) { - console.log(parsed.error?.message) return (
- No feedback yet {parsed.error?.message} + {phase === CheckPhase.PreviewError + ? 'Preview errors found' + : 'No feedback yet'}
) } @@ -21,6 +35,18 @@ export function FSAFeedbackPanel({ feedback }: FSAFeedbackPanelProps) { return (
+
+ { + phase == CheckPhase.PreviewError ? "Errors in Preview" : "Errors in Evaluation" + } +
{/* ================= Summary ================= */} {safeFeedback.summary && (
0 && ( @@ -85,12 +115,13 @@ export function FSAFeedbackPanel({ feedback }: FSAFeedbackPanelProps) { value={bool(safeFeedback.language.are_equivalent)} /> - {!safeFeedback.language.are_equivalent && safeFeedback.language.counterexample && ( - - )} + {!safeFeedback.language.are_equivalent && + safeFeedback.language.counterexample && ( + + )} )} diff --git a/src/types/FSA/components/ItemPropertiesPanel.tsx b/src/types/FSA/components/ItemPropertiesPanel.tsx index bb23291..9bd3fdf 100644 --- a/src/types/FSA/components/ItemPropertiesPanel.tsx +++ b/src/types/FSA/components/ItemPropertiesPanel.tsx @@ -1,7 +1,7 @@ import type { Core, NodeSingular, EdgeSingular } from 'cytoscape' import React from 'react' -import { FSA, FSAFeedback } from '../type' +import { CheckPhase, FSA, FSAFeedback } from '../type' import { FSAFeedbackPanel } from './FSAFeedbackPanel' @@ -26,6 +26,8 @@ interface ItemPropertiesPanelProps { syncToBackend: () => void feedback: FSAFeedback | null + previewFeedback: FSAFeedback | null + phase: CheckPhase } export default function ItemPropertiesPanel({ @@ -42,7 +44,9 @@ export default function ItemPropertiesPanel({ answer, handleChange, syncToBackend, - feedback + feedback, + previewFeedback, + phase }: ItemPropertiesPanelProps): JSX.Element { return (
@@ -160,6 +164,8 @@ export default function ItemPropertiesPanel({ )}
) diff --git a/src/types/FSA/index.tsx b/src/types/FSA/index.tsx index c2c2439..d30d776 100644 --- a/src/types/FSA/index.tsx +++ b/src/types/FSA/index.tsx @@ -1,5 +1,5 @@ // import React, { useMemo } from 'react' -// import { z } from 'zod' +import { z } from 'zod' import { @@ -9,84 +9,108 @@ import { import { ResponseAreaTub } from '../response-area-tub' import { FSAInput } from './FSA.component' -import { fsaAnswerSchema, FSA, defaultFSA, DEFAULT_FSA_CONFIG, FSAConfig, FSAFeedback } from './type' +import { fsaAnswerSchema, FSA, defaultFSA, DEFAULT_FSA_CONFIG, FSAConfig, FSAFeedback, ValidationError, CheckPhase, FSAFeedbackSchema, fsaConfigSchema } from './type' +import { validateFSA } from './validateFSA' export class FSAResponseAreaTub extends ResponseAreaTub { public readonly responseType = 'FSA' - public readonly displayWideInput: boolean = true + public readonly displayWideInput = true protected answerSchema = fsaAnswerSchema - protected answer: FSA = defaultFSA // Never undefined now - protected config: FSAConfig = DEFAULT_FSA_CONFIG - // private feedback: FSAFeedback | null = null + protected answer: FSA = defaultFSA - public readonly delegateFeedback = false // we want to manage our own feedback - public readonly delegateLivePreview = true// we want live previews + private previewFeedback: FSAFeedback | null = null + private phase: CheckPhase = CheckPhase.Idle + private response: FSA | null = null - initWithConfig = (config: any) => { - this.config = { - ...DEFAULT_FSA_CONFIG, - ...config, // not too sure about this, maybe the opposite so the default config is overwritten? - } - } - - // customCheck = () => {} // will set this up later + public readonly delegateFeedback = false + public readonly delegateLivePreview = true + + initWithConfig = () => {} + + /* -------------------- Custom Check -------------------- */ + + customCheck = () => { + // Block submission if preview validation fails + if (this.previewFeedback) { + throw new Error('preview failed') + } + + // Preview passed — ensure it's cleared + this.previewFeedback = null + // this.phase = CheckPhase.Idle + } /* -------------------- Input -------------------- */ -public InputComponent = (props: BaseResponseAreaProps): JSX.Element => { - // Ensure a valid FSA answer - const validAnswer: FSA = this.answerSchema.safeParse(props.answer).success - ? this.answerSchema.safeParse(props.answer).data ?? defaultFSA - : defaultFSA - - // // Parse feedback safely and memoize so it updates when props.feedback changes - // const feedback: FSAFeedback | null = useMemo(() => { - // if (!props.feedback?.feedback) return null - // try { - // return JSON.parse(props.feedback.feedback) - // } catch { - // return null - // } - // }, [props.feedback?.feedback]) - - return ( - { - const raw = props.feedback?.feedback - if (!raw) return {} - try { - // split by
and take the second part, trim whitespace - const jsonPart = raw.split('
')[1]?.trim() ?? '{}' - return JSON.parse(jsonPart) - } catch { - return {} // fallback to empty object if parsing fails - } - })()} - answer={validAnswer} - handleChange={(val: FSA) => props.handleChange(val)} - /> - ) -} + public InputComponent = (props: BaseResponseAreaProps): JSX.Element => { + // Ensure a valid FSA answer + const parsed = this.answerSchema.safeParse(props.answer) + const validAnswer = parsed.success ? parsed.data : defaultFSA + + this.response = validAnswer + + /* ---------- Extract submitted feedback ---------- */ + + const submittedFeedback: FSAFeedback | null = (() => { + const raw = props.feedback?.feedback + if (!raw) return null + + try { + const jsonPart = raw.split('
')[1]?.trim() + if (!jsonPart) return null + return JSON.parse(jsonPart) + } catch { + return null + } + })() + /* ---------- Effective feedback ---------- */ + + const effectiveFeedback = + this.previewFeedback ?? submittedFeedback + + return ( + { + props.handleChange(val) + + const preview = validateFSA(val) + + if (preview.errors.length > 0) { + this.previewFeedback = preview + this.phase = CheckPhase.PreviewError + } else { + this.previewFeedback = null // 🔥 THIS IS THE KEY + this.phase = CheckPhase.Idle + } + }} + /> + ) + } /* -------------------- Wizard -------------------- */ + public WizardComponent = ( props: BaseResponseAreaWizardProps, ): JSX.Element => { return ( { + feedback={null} + answer={this.answer} + phase={CheckPhase.Evaluated} + previewFeedback={null} + handleChange={(val: FSA) => { this.answer = val - console.log('Wizard val:', val) props.handleChange({ responseType: this.responseType, answer: val, - config: this.config as unknown as Record }) }} /> diff --git a/src/types/FSA/type.ts b/src/types/FSA/type.ts index 516ed54..aad2a8a 100644 --- a/src/types/FSA/type.ts +++ b/src/types/FSA/type.ts @@ -11,18 +11,11 @@ export const fsaAnswerSchema = z.object({ transitions: z.array(z.string()), initial_state: z.string(), accept_states: z.array(z.string()), + config: z.string() }); export type FSA = z.infer; -export const defaultFSA: FSA = { - states: [], - alphabet: [], - transitions: [], - initial_state: '', - accept_states: [] -}; - export const fsaConfigSchema = z.object({ evaluation_mode: z.enum(['strict', 'lenient', 'partial']).optional(), expected_type: z.enum(['DFA', 'NFA', 'any']).optional(), @@ -127,7 +120,7 @@ export const ValidationErrorSchema = z.object({ code: ErrorCodeSchema, severity: ValidationSeveritySchema.default("error"), highlight: ElementHighlightSchema.nullable().optional(), - suggestion: z.string().optional(), + suggestion: z.string().nullable().optional(), }); /* =========================== @@ -196,3 +189,19 @@ export type TestResult = z.infer; export type StructuralInfo = z.infer; export type LanguageComparison = z.infer; export type FSAFeedback = z.infer; + +export enum CheckPhase { + Idle = 'IDLE', + PreviewError = 'PREVIEW_ERROR', + Evaluating = 'EVALUATING', // we never have access to the api call, so this is useless + Evaluated = 'EVALUATED', +} + +export const defaultFSA: FSA = { + states: [], + alphabet: [], + transitions: [], + initial_state: '', + accept_states: [], + config: JSON.stringify(DEFAULT_FSA_CONFIG) +}; \ No newline at end of file diff --git a/src/types/FSA/utils.ts b/src/types/FSA/utils.ts new file mode 100644 index 0000000..5654792 --- /dev/null +++ b/src/types/FSA/utils.ts @@ -0,0 +1,13 @@ +import { CheckPhase, FSAFeedback } from "./type" + +export function mergeFeedback( + feedback: FSAFeedback | null, + previewFeedback: FSAFeedback | null, + phase: CheckPhase, +): FSAFeedback | null { + if (phase === CheckPhase.PreviewError) { + return previewFeedback + } + + return feedback +} \ No newline at end of file diff --git a/src/types/FSA/validateFSA.ts b/src/types/FSA/validateFSA.ts new file mode 100644 index 0000000..01a4520 --- /dev/null +++ b/src/types/FSA/validateFSA.ts @@ -0,0 +1,333 @@ +import type { FSA, FSAFeedback } from "./type"; +import { ValidationError } from "./type"; + +/* =========================== + Internal helper types +=========================== */ + +type MutableFeedback = Pick; + +type OutgoingMap = Map>; + +/* =========================== + Basic validation checks +=========================== */ + +const checkStates = ( + states: string[], + feedback: MutableFeedback +): boolean => { + if (states.length === 0) { + feedback.errors.push({ + message: "The automaton has no states.", + code: "EMPTY_STATES", + severity: "error", + }); + return false; + } + return true; +}; + +const checkAlphabet = ( + alphabet: string[], + feedback: MutableFeedback +): boolean => { + if (alphabet.length === 0) { + feedback.errors.push({ + message: "The automaton has no alphabet symbols.", + code: "EMPTY_ALPHABET", + severity: "error", + }); + return false; + } + return true; +}; + +const checkInitialState = ( + initial_state: string, + states: string[], + feedback: MutableFeedback +): boolean => { + if (!states.includes(initial_state)) { + feedback.errors.push({ + message: `Initial state "${initial_state}" is not a valid state.`, + code: "INVALID_INITIAL", + severity: "error", + highlight: { + type: "initial_state", + state_id: initial_state, + }, + }); + return false; + } + return true; +}; + +const checkAcceptStates = ( + accept_states: string[], + states: string[], + feedback: MutableFeedback +): boolean => { + let ok = true; + + for (const s of accept_states) { + if (!states.includes(s)) { + ok = false; + feedback.errors.push({ + message: `Accept state "${s}" is not a valid state.`, + code: "INVALID_ACCEPT", + severity: "error", + highlight: { + type: "accept_state", + state_id: s, + }, + }); + } + } + + return ok; +}; + +const checkTransitions = ( + transitions: string[], + states: string[], + alphabet: string[], + feedback: MutableFeedback +): OutgoingMap => { + const outgoing: OutgoingMap = new Map(); + + for (const t of transitions) { + const parts = t.split("|"); + + // Cannot safely highlight: transition not parseable + if (parts.length !== 3) { + feedback.errors.push({ + message: `Invalid transition format "${t}".`, + code: "INVALID_TRANSITION_SYMBOL", + severity: "error", + }); + continue; + } + + const [from, symbol, to] = parts; + + // Still not safely highlightable + if (!from || !symbol || !to) { + feedback.errors.push({ + message: `Transition unrecognisable "${t}".`, + code: "INVALID_SYMBOL", + severity: "error", + } as ValidationError); + continue; + } + + if (!states.includes(from)) { + feedback.errors.push({ + message: `Transition source "${from}" is invalid.`, + code: "INVALID_TRANSITION_SOURCE", + severity: "error", + highlight: { + type: "transition", + from_state: from, + to_state: to, + symbol, + }, + }); + } + + if (!states.includes(to)) { + feedback.errors.push({ + message: `Transition destination "${to}" is invalid.`, + code: "INVALID_TRANSITION_DEST", + severity: "error", + highlight: { + type: "transition", + from_state: from, + to_state: to, + symbol, + }, + }); + } + + if (!alphabet.includes(symbol)) { + feedback.errors.push({ + message: `Transition symbol "${symbol}" is invalid.`, + code: "INVALID_TRANSITION_SYMBOL", + severity: "error", + highlight: { + type: "alphabet_symbol", + symbol, + }, + }); + } + + if (!outgoing.has(from)) outgoing.set(from, []); + outgoing.get(from)!.push({ symbol, to }); + } + + return outgoing; +}; + +/* =========================== + Structural computations +=========================== */ + +const computeDeterminism = (outgoing: OutgoingMap): boolean => { + for (const edges of outgoing.values()) { + const seen = new Set(); + for (const { symbol } of edges) { + if (seen.has(symbol)) return false; + seen.add(symbol); + } + } + return true; +}; + +const computeCompleteness = ( + states: string[], + alphabet: string[], + outgoing: OutgoingMap +): boolean => { + for (const state of states) { + const edges = outgoing.get(state) ?? []; + const covered = new Set(edges.map(e => e.symbol)); + + for (const sym of alphabet) { + if (!covered.has(sym)) return false; + } + } + return true; +}; + +const computeReachableStates = ( + initial_state: string, + states: string[], + outgoing: OutgoingMap +): Set => { + const reachable = new Set(); + const stack: string[] = []; + + if (states.includes(initial_state)) { + reachable.add(initial_state); + stack.push(initial_state); + } + + while (stack.length) { + const s = stack.pop()!; + for (const { to } of outgoing.get(s) ?? []) { + if (!reachable.has(to)) { + reachable.add(to); + stack.push(to); + } + } + } + + return reachable; +}; + +const computeDeadStates = ( + states: string[], + accept_states: string[], + outgoing: OutgoingMap +): string[] => { + const reverse = new Map(); + + for (const [from, edges] of outgoing) { + for (const { to } of edges) { + if (!reverse.has(to)) reverse.set(to, []); + reverse.get(to)!.push(from); + } + } + + const canReachAccept = new Set(accept_states); + const queue = [...accept_states]; + + while (queue.length) { + const s = queue.shift()!; + for (const p of reverse.get(s) ?? []) { + if (!canReachAccept.has(p)) { + canReachAccept.add(p); + queue.push(p); + } + } + } + + return states.filter( + s => !canReachAccept.has(s) && !accept_states.includes(s) + ); +}; + +/* =========================== + Public API +=========================== */ + +export const validateFSA = (fsa: FSA | null): FSAFeedback => { + const feedback: FSAFeedback = { + summary: "", + errors: [], + warnings: [], + test_results: [], + hints: [], + }; + + if (!fsa) { + feedback.errors.push({ + message: "No automaton provided.", + code: "EVALUATION_ERROR", + severity: "error", + }); + return feedback; + } + + const { states, alphabet, transitions, initial_state, accept_states } = fsa; + + const statesOk = checkStates(states, feedback); + const alphabetOk = checkAlphabet(alphabet, feedback); + const initialOk = checkInitialState(initial_state, states, feedback); + const acceptOk = checkAcceptStates(accept_states, states, feedback); + + const outgoing = checkTransitions( + transitions, + states, + alphabet, + feedback + ); + + const is_deterministic = computeDeterminism(outgoing); + const is_complete = computeCompleteness(states, alphabet, outgoing); + + const reachable = computeReachableStates( + initial_state, + states, + outgoing + ); + const unreachable_states = states.filter(s => !reachable.has(s)); + + const dead_states = computeDeadStates( + states, + accept_states, + outgoing + ); + + feedback.structural = { + is_deterministic, + is_complete, + num_states: states.length, + num_transitions: transitions.length, + unreachable_states, + dead_states, + }; + + const isValid = + statesOk && + alphabetOk && + initialOk && + acceptOk && + feedback.errors.length === 0; + + feedback.summary = isValid + ? "The automaton is a valid finite-state automaton." + : "The automaton is not a valid finite-state automaton."; + + return feedback; +}; From 7a15110ba68398739a5a8ec6a3fe7b284f380cc0 Mon Sep 17 00:00:00 2001 From: everythingfades Date: Mon, 23 Feb 2026 00:16:15 +0000 Subject: [PATCH 13/29] feat: epsilon --- dev/index.html | 14 -- dev/main.tsx | 37 ---- src/types/FSA/FSA.component.tsx | 90 ++++++--- src/types/FSA/components/ConfigPanel.tsx | 174 ++++++++++++------ src/types/FSA/components/FSAFeedbackPanel.tsx | 13 +- .../FSA/components/ItemPropertiesPanel.tsx | 13 +- src/types/FSA/index.tsx | 22 ++- src/types/FSA/styles.ts | 125 +++++++++---- src/types/FSA/type.ts | 9 +- 9 files changed, 298 insertions(+), 199 deletions(-) delete mode 100644 dev/index.html delete mode 100644 dev/main.tsx diff --git a/dev/index.html b/dev/index.html deleted file mode 100644 index 0894690..0000000 --- a/dev/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - Library Dev Sandbox - - -
- - - - - diff --git a/dev/main.tsx b/dev/main.tsx deleted file mode 100644 index eec2322..0000000 --- a/dev/main.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { IModularResponseSchema } from '@modules/shared/schemas/question-form.schema' -import React, { useState } from 'react' -import ReactDOM from 'react-dom/client' - -import { FSAResponseAreaTub } from '../src/types/FSA' -import { FSAInput } from '../src/types/FSA/FSA.component' -import { defaultFSA, FSA } from '../src/types/FSA/type' - - -const tub = new FSAResponseAreaTub() - -function Sandbox() { - const [answer, setAnswer] = - useState(defaultFSA) - - const [, setAllowSave] = useState(true) - - return ( - <> -

Input

- console.log("wizard change", val)} - /> - -
- -

Wizard

- - ) -} - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - -) diff --git a/src/types/FSA/FSA.component.tsx b/src/types/FSA/FSA.component.tsx index 08335bd..5ed16f6 100644 --- a/src/types/FSA/FSA.component.tsx +++ b/src/types/FSA/FSA.component.tsx @@ -1,4 +1,4 @@ -import { makeStyles } from '@styles' +// import { makeStyles } from '@styles' import cytoscape, { Core, NodeSingular, EdgeSingular } from 'cytoscape' import React, { useEffect, useRef, useState } from 'react' @@ -11,16 +11,17 @@ interface FSAInputProps { answer: FSA handleChange: (fsa: FSA) => void feedback: FSAFeedback | null - previewFeedback: FSAFeedback | null phase: CheckPhase + isTeacherMode: boolean } + export const FSAInput: React.FC = ({ answer, handleChange, feedback, - previewFeedback, - phase + phase, + isTeacherMode }) => { const { classes } = useLocalStyles() @@ -106,6 +107,16 @@ export const FSAInput: React.FC = ({ width: 3, }, }, + { + selector: 'edge.epsilon', + style: { + 'line-style': 'dashed', + 'line-color': '#6a1b9a', + 'target-arrow-color': '#6a1b9a', + width: 3, + 'font-style': 'italic', + }, + }, ], }) @@ -132,7 +143,7 @@ export const FSAInput: React.FC = ({ id: `e-${fromNode}-${node.id()}-${Date.now()}`, source: fromNode, target: node.id(), - label: config.epsilon_symbol, + label: 'edge' }, }) cy.nodes().removeClass('edge-source') @@ -159,7 +170,7 @@ export const FSAInput: React.FC = ({ cy.off('tap', 'node', tapNode) cy.off('tap', 'edge', tapEdge) } - }, [drawMode, fromNode, config.epsilon_symbol]) + }, [drawMode, fromNode]) /* -------------------- sync to backend -------------------- */ const syncToBackend = (): void => { @@ -170,7 +181,7 @@ export const FSAInput: React.FC = ({ states: cy.nodes()?.map((n) => n.id()), transitions: cy.edges()?.map( (e) => - `${e.source().id()}|${e.data('label') || config.epsilon_symbol}|${e.target().id()}`, + `${e.source().id()}|${e.data('label') || ' '}|${e.target().id()}`, ), initial_state: answer.initial_state, accept_states: answer.accept_states, @@ -274,8 +285,10 @@ export const FSAInput: React.FC = ({ } }, [feedback]) + return (
+ {/* Left Panel */} = ({ handleChange={handleChange} answer={answer} feedback={feedback} - previewFeedback={previewFeedback} phase={phase} /> -
- - { - const fsa: FSA = { - ...answer, - config: JSON.stringify(val) - } - - handleChange(fsa) - setConfig(val) - }} - configOpen={configOpen} - setConfigOpen={setConfigOpen} - classes={classes} - /> + {/* Canvas Area */} +
+ {isTeacherMode && ( +
+ + Teacher Mode + + + +
+ )} + +
+
+ + {/* Side Modal */} + {isTeacherMode && configOpen && ( + <> +
setConfigOpen(false)} + /> + + { + const fsa: FSA = { + ...answer, + config: JSON.stringify(val), + } + handleChange(fsa) + setConfig(val) + }} + onClose={() => setConfigOpen(false)} + classes={classes} + /> + + )}
) -} +} \ No newline at end of file diff --git a/src/types/FSA/components/ConfigPanel.tsx b/src/types/FSA/components/ConfigPanel.tsx index 240ee63..243ee25 100644 --- a/src/types/FSA/components/ConfigPanel.tsx +++ b/src/types/FSA/components/ConfigPanel.tsx @@ -1,77 +1,133 @@ import React from 'react' +import { z } from 'zod' -import { FSAConfig } from '../type' +import { fsaConfigSchema } from '../type' -interface EvaluationConfigPanelProps { +type FSAConfig = z.infer + +interface ConfigPanelProps { config: FSAConfig setConfig: (config: FSAConfig) => void - configOpen: boolean - setConfigOpen: React.Dispatch> + onClose: () => void classes: Record } -export default function EvaluationConfigPanel({ +export default function ConfigPanel({ config, setConfig, - configOpen, - setConfigOpen, - classes -}: EvaluationConfigPanelProps) { + onClose, + classes, +}: ConfigPanelProps) { return ( -
-
setConfigOpen((o) => !o)} - > - Evaluation Config - {configOpen ? '▾' : '▸'} +
+
+ Evaluation Settings + + ✕ +
- {configOpen && ( -
- {Object.entries(config).map(([key, value]) => ( -
- +
+ {/* evaluation_mode */} +
+ + +
+ + {/* expected_type */} +
+ + +
+ + {/* feedback_verbosity */} +
+ + +
+ + {/* check_minimality */} +
+ +
+ + {/* check_completeness */} +
+ +
- {typeof value === 'boolean' ? ( - - setConfig({ - ...config, - [key]: e.target.checked, - }) - } - /> - ) : typeof value === 'number' ? ( - - setConfig({ - ...config, - [key]: Number(e.target.value), - }) - } - /> - ) : ( - - setConfig({ - ...config, - [key]: e.target.value, - }) - } - /> - )} -
- ))} + {/* highlight_errors */} +
+
- )} + + +
) } diff --git a/src/types/FSA/components/FSAFeedbackPanel.tsx b/src/types/FSA/components/FSAFeedbackPanel.tsx index 0280090..d9be12f 100644 --- a/src/types/FSA/components/FSAFeedbackPanel.tsx +++ b/src/types/FSA/components/FSAFeedbackPanel.tsx @@ -1,13 +1,12 @@ -import React, { useMemo } from 'react' +import React from 'react' import { CheckPhase, - FSAFeedbackSchema, type FSAFeedback, } from '../type' interface FSAFeedbackPanelProps { - feedback: FSAFeedback | null + feedback: FSAFeedback | null phase: CheckPhase } @@ -16,12 +15,8 @@ export function FSAFeedbackPanel({ phase, }: FSAFeedbackPanelProps) { - const parsed = useMemo( - () => FSAFeedbackSchema.safeParse(feedback), - [feedback], - ) - if (!feedback || !parsed.success) { + if (!feedback) { return (
{phase === CheckPhase.PreviewError @@ -31,7 +26,7 @@ export function FSAFeedbackPanel({ ) } - const safeFeedback = parsed.data + const safeFeedback = feedback return (
diff --git a/src/types/FSA/components/ItemPropertiesPanel.tsx b/src/types/FSA/components/ItemPropertiesPanel.tsx index 9bd3fdf..2efb648 100644 --- a/src/types/FSA/components/ItemPropertiesPanel.tsx +++ b/src/types/FSA/components/ItemPropertiesPanel.tsx @@ -26,7 +26,6 @@ interface ItemPropertiesPanelProps { syncToBackend: () => void feedback: FSAFeedback | null - previewFeedback: FSAFeedback | null phase: CheckPhase } @@ -45,7 +44,6 @@ export default function ItemPropertiesPanel({ handleChange, syncToBackend, feedback, - previewFeedback, phase }: ItemPropertiesPanelProps): JSX.Element { return ( @@ -140,7 +138,16 @@ export default function ItemPropertiesPanel({ className={classes.inputField} value={selectedEdge.data('label') ?? ''} onChange={(e) => { - selectedEdge.data('label', e.target.value) + const value = e.target.value.trim() + + selectedEdge.data('label', value) + + if (value === 'ε' || value.toLowerCase() === 'epsilon' || value === '') { + selectedEdge.addClass('epsilon') + } else { + selectedEdge.removeClass('epsilon') + } + syncToBackend() }} /> diff --git a/src/types/FSA/index.tsx b/src/types/FSA/index.tsx index d30d776..8289321 100644 --- a/src/types/FSA/index.tsx +++ b/src/types/FSA/index.tsx @@ -1,5 +1,5 @@ // import React, { useMemo } from 'react' -import { z } from 'zod' +// import { z } from 'zod' import { @@ -9,7 +9,7 @@ import { import { ResponseAreaTub } from '../response-area-tub' import { FSAInput } from './FSA.component' -import { fsaAnswerSchema, FSA, defaultFSA, DEFAULT_FSA_CONFIG, FSAConfig, FSAFeedback, ValidationError, CheckPhase, FSAFeedbackSchema, fsaConfigSchema } from './type' +import { fsaAnswerSchema, FSA, defaultFSA, FSAFeedback, CheckPhase } from './type' import { validateFSA } from './validateFSA' export class FSAResponseAreaTub extends ResponseAreaTub { @@ -21,7 +21,7 @@ export class FSAResponseAreaTub extends ResponseAreaTub { private previewFeedback: FSAFeedback | null = null private phase: CheckPhase = CheckPhase.Idle - private response: FSA | null = null + // private response: FSA | null = null public readonly delegateFeedback = false public readonly delegateLivePreview = true @@ -48,34 +48,39 @@ export class FSAResponseAreaTub extends ResponseAreaTub { const parsed = this.answerSchema.safeParse(props.answer) const validAnswer = parsed.success ? parsed.data : defaultFSA - this.response = validAnswer + // this.response = validAnswer /* ---------- Extract submitted feedback ---------- */ const submittedFeedback: FSAFeedback | null = (() => { + console.log("raw:", props) const raw = props.feedback?.feedback if (!raw) return null try { - const jsonPart = raw.split('
')[1]?.trim() + const jsonPart = raw.split('
')[1]?.trim() if (!jsonPart) return null return JSON.parse(jsonPart) - } catch { + } catch (e) { + + console.error('Failed to parse feedback JSON:', e) return null } })() + console.log("submitted:", submittedFeedback) /* ---------- Effective feedback ---------- */ const effectiveFeedback = this.previewFeedback ?? submittedFeedback + console.log(submittedFeedback, this.previewFeedback, effectiveFeedback) + return ( { props.handleChange(val) @@ -90,6 +95,7 @@ export class FSAResponseAreaTub extends ResponseAreaTub { this.phase = CheckPhase.Idle } }} + isTeacherMode={false} /> ) } @@ -105,7 +111,6 @@ export class FSAResponseAreaTub extends ResponseAreaTub { feedback={null} answer={this.answer} phase={CheckPhase.Evaluated} - previewFeedback={null} handleChange={(val: FSA) => { this.answer = val props.handleChange({ @@ -113,6 +118,7 @@ export class FSAResponseAreaTub extends ResponseAreaTub { answer: val, }) }} + isTeacherMode={true} /> ) } diff --git a/src/types/FSA/styles.ts b/src/types/FSA/styles.ts index 8ed068e..84b9b17 100644 --- a/src/types/FSA/styles.ts +++ b/src/types/FSA/styles.ts @@ -1,65 +1,123 @@ import { makeStyles } from "@styles"; export const useLocalStyles = makeStyles()((theme) => ({ + /* ---------------- Root Container ---------------- */ + container: { width: '100%', height: 600, - display: 'flex', border: '1px solid #ddd', fontFamily: 'sans-serif', + display: 'grid', + gridTemplateColumns: '280px 1fr', position: 'relative', + backgroundColor: '#fff', }, + /* ---------------- Left Panel ---------------- */ + panel: { - width: 280, padding: theme.spacing(2), borderRight: '1px solid #ddd', backgroundColor: '#fafafa', display: 'flex', flexDirection: 'column', gap: theme.spacing(2), - overflowY: 'auto' + overflowY: 'auto', + }, + + panelTitle: { + fontWeight: 600, + fontSize: 16, + borderBottom: '1px solid #eee', + paddingBottom: theme.spacing(1), + }, + + /* ---------------- Canvas Area ---------------- */ + + canvasArea: { + position: 'relative', + display: 'flex', + flexDirection: 'column', + }, + + canvasHeader: { + height: 48, + borderBottom: '1px solid #eee', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: `0 ${theme.spacing(2)}px`, + backgroundColor: '#fcfcff', + }, + + headerTitle: { + fontWeight: 600, + }, + + configButton: { + padding: '6px 10px', + borderRadius: 4, + border: '1px solid #d0d7ff', + backgroundColor: '#f5f7ff', + cursor: 'pointer', + fontWeight: 500, }, - floatingConfig: { + cyWrapper: { + flexGrow: 1, + }, + + /* ---------------- Overlay ---------------- */ + + overlayBackdrop: { + position: 'absolute', + inset: 0, + backgroundColor: 'rgba(0,0,0,0.3)', + zIndex: 10, + }, + + /* ---------------- Side Modal ---------------- */ + + sideModal: { position: 'absolute', - right: 12, - bottom: 12, - width: 320, - maxHeight: 420, + top: 0, + right: 0, + height: '100%', + width: 380, backgroundColor: '#fafafa', - border: '1px solid #ddd', - borderRadius: 6, - boxShadow: '0 4px 12px rgba(0,0,0,0.15)', + borderLeft: '1px solid #ddd', + boxShadow: '-4px 0 12px rgba(0,0,0,0.15)', display: 'flex', flexDirection: 'column', - zIndex: 10, + zIndex: 11, }, - configHeader: { - padding: theme.spacing(1), + sideModalHeader: { + padding: theme.spacing(2), fontWeight: 600, + fontSize: 16, borderBottom: '1px solid #eee', display: 'flex', justifyContent: 'space-between', alignItems: 'center', + backgroundColor: '#f5f7ff', + }, + + closeButton: { cursor: 'pointer', + fontWeight: 600, }, - configBody: { - padding: theme.spacing(1.5), + sideModalBody: { + padding: theme.spacing(2), overflowY: 'auto', display: 'flex', flexDirection: 'column', - gap: theme.spacing(1.5), + gap: theme.spacing(2), }, - panelTitle: { - fontWeight: 600, - fontSize: 16, - borderBottom: '1px solid #eee', - paddingBottom: theme.spacing(1), - }, + /* ---------------- Form Controls ---------------- */ field: { display: 'flex', @@ -86,18 +144,15 @@ export const useLocalStyles = makeStyles()((theme) => ({ borderRadius: 4, cursor: 'pointer', }, - - deleteButton: { - padding: '6px', - backgroundColor: '#fff1f0', - color: '#cf1322', - border: '1px solid #ffa39e', + epsilonButton: { + marginTop: 6, + padding: '4px 8px', borderRadius: 4, - cursor: 'pointer', + border: '1px solid #6a1b9a', + backgroundColor: '#f3e5f5', + color: '#6a1b9a', fontWeight: 600, + cursor: 'pointer', + width: 'fit-content', }, - - cyWrapper: { - flexGrow: 1, - }, -})) \ No newline at end of file +})); diff --git a/src/types/FSA/type.ts b/src/types/FSA/type.ts index aad2a8a..53353a2 100644 --- a/src/types/FSA/type.ts +++ b/src/types/FSA/type.ts @@ -28,10 +28,6 @@ export const fsaConfigSchema = z.object({ show_counterexample: z.boolean().optional(), max_test_length: z.number().int().positive().optional(), - - is_dev: z.boolean().optional(), - - epsilon_symbol: z.string(), }) export type FSAConfig = z.infer @@ -47,10 +43,7 @@ export const DEFAULT_FSA_CONFIG: FSAConfig = { highlight_errors: true, show_counterexample: true, - max_test_length: 10, - - is_dev: false, - epsilon_symbol: "epsilon" + max_test_length: 10 } /* =========================== From b0750d214ffb520e1dea6ec89b4c5aba69ec4692 Mon Sep 17 00:00:00 2001 From: everythingfades Date: Thu, 26 Feb 2026 02:50:00 +0000 Subject: [PATCH 14/29] sync: sync existing content to fork --- src/types/FSA/type.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/types/FSA/type.ts b/src/types/FSA/type.ts index 53353a2..bd44a01 100644 --- a/src/types/FSA/type.ts +++ b/src/types/FSA/type.ts @@ -25,9 +25,6 @@ export const fsaConfigSchema = z.object({ check_completeness: z.boolean().optional(), highlight_errors: z.boolean().optional(), - show_counterexample: z.boolean().optional(), - - max_test_length: z.number().int().positive().optional(), }) export type FSAConfig = z.infer @@ -40,10 +37,7 @@ export const DEFAULT_FSA_CONFIG: FSAConfig = { check_minimality: false, check_completeness: false, - highlight_errors: true, - show_counterexample: true, - - max_test_length: 10 + highlight_errors: true } /* =========================== From f54e32074a5cf2f9fc77e85e5e183002652ac98a Mon Sep 17 00:00:00 2001 From: everythingfades Date: Mon, 2 Mar 2026 13:53:51 +0000 Subject: [PATCH 15/29] feat: add paper.js hand drawing and remove some redundant UI --- package.json | 1 + src/types/FSA/FSA.component.tsx | 539 ++++++++++++------ src/types/FSA/components/FSAFeedbackPanel.tsx | 29 +- .../FSA/components/ItemPropertiesPanel.tsx | 30 +- yarn.lock | 5 + 5 files changed, 410 insertions(+), 194 deletions(-) diff --git a/package.json b/package.json index c64c1c2..dda9aa2 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "markdown-to-jsx": "^7.1.7", "monaco-editor": "^0.50.0", "next": "^14.2.4", + "paper": "^0.12.18", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.31.2", diff --git a/src/types/FSA/FSA.component.tsx b/src/types/FSA/FSA.component.tsx index 5ed16f6..517604f 100644 --- a/src/types/FSA/FSA.component.tsx +++ b/src/types/FSA/FSA.component.tsx @@ -1,11 +1,17 @@ -// import { makeStyles } from '@styles' import cytoscape, { Core, NodeSingular, EdgeSingular } from 'cytoscape' +import paper from 'paper' import React, { useEffect, useRef, useState } from 'react' import ConfigPanel from './components/ConfigPanel' import ItemPropertiesPanel from './components/ItemPropertiesPanel' import { useLocalStyles } from './styles' -import { CheckPhase, DEFAULT_FSA_CONFIG, FSA, FSAConfig, FSAFeedback } from './type' +import { + CheckPhase, + DEFAULT_FSA_CONFIG, + FSA, + FSAConfig, + FSAFeedback, +} from './type' interface FSAInputProps { answer: FSA @@ -15,25 +21,37 @@ interface FSAInputProps { isTeacherMode: boolean } - export const FSAInput: React.FC = ({ answer, handleChange, feedback, phase, - isTeacherMode + isTeacherMode, }) => { const { classes } = useLocalStyles() const cyRef = useRef(null) const containerRef = useRef(null) - const [selectedNode, setSelectedNode] = useState(null) - const [selectedEdge, setSelectedEdge] = useState(null) + // Paper refs + const drawCanvasRef = useRef(null) + const paperProjectRef = useRef(null) + const pathRef = useRef(null) + const startPointRef = useRef(null) + + const [selectedNode, setSelectedNode] = + useState(null) + const [selectedEdge, setSelectedEdge] = + useState(null) + const [drawMode, setDrawMode] = useState(false) const [fromNode, setFromNode] = useState(null) - const [config, setConfig] = useState(DEFAULT_FSA_CONFIG) - const [configOpen, setConfigOpen] = useState(true) + const [isDrawing, setIsDrawing] = useState(false) + + const [config, setConfig] = + useState(DEFAULT_FSA_CONFIG) + const [configOpen, setConfigOpen] = + useState(true) /* -------------------- init cytoscape -------------------- */ useEffect(() => { @@ -43,7 +61,6 @@ export const FSAInput: React.FC = ({ container: containerRef.current, layout: { name: 'preset' }, style: [ - // ---------------- Nodes ---------------- { selector: 'node', style: { @@ -57,7 +74,6 @@ export const FSAInput: React.FC = ({ 'border-color': '#555', }, }, - { selector: 'node.initial', style: { @@ -65,7 +81,6 @@ export const FSAInput: React.FC = ({ 'border-color': '#1976d2', }, }, - { selector: 'node.accept', style: { @@ -73,7 +88,6 @@ export const FSAInput: React.FC = ({ 'border-width': 4, }, }, - { selector: 'node.error-highlight', style: { @@ -82,8 +96,6 @@ export const FSAInput: React.FC = ({ 'border-width': 4, }, }, - - // ---------------- Edges ---------------- { selector: 'edge', style: { @@ -97,7 +109,6 @@ export const FSAInput: React.FC = ({ 'text-background-padding': '3px', }, }, - { selector: 'edge.error-highlight', style: { @@ -107,15 +118,16 @@ export const FSAInput: React.FC = ({ width: 3, }, }, - { - selector: 'edge.epsilon', - style: { - 'line-style': 'dashed', - 'line-color': '#6a1b9a', - 'target-arrow-color': '#6a1b9a', - width: 3, - 'font-style': 'italic', - }, + { + selector: 'edge.epsilon', + style: { + 'line-style': 'dashed', + 'line-color': '#6a1b9a', + 'target-arrow-color': '#6a1b9a', + width: 3, + 'font-style': + 'italic', + }, }, ], }) @@ -124,82 +136,273 @@ export const FSAInput: React.FC = ({ return () => cy.destroy() }, []) - /* -------------------- node/edge handlers -------------------- */ + /* -------------------- node/edge handlers -------------------- */ + useEffect(() => { + const cy = cyRef.current + if (!cy) return + const tapNode = (e: cytoscape.EventObject): void => { + 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: 'edge' + }, + }) + cy.nodes().removeClass('edge-source') + setDrawMode(false) + setFromNode(null) + syncToBackend() + } + return + } + setSelectedNode(node) + setSelectedEdge(null) + } + const tapEdge = (e: cytoscape.EventObject): void => { + setSelectedEdge(e.target as EdgeSingular) + setSelectedNode(null) + } + cy.on('tap', 'node', tapNode) + cy.on('tap', 'edge', tapEdge) + return () => { + cy.off('tap', 'node', tapNode) + cy.off('tap', 'edge', tapEdge) + } + }, [drawMode, fromNode]) + + /* -------------------- Paper setup -------------------- */ useEffect(() => { + const canvas = drawCanvasRef.current + if (!canvas || paperProjectRef.current) return + + const project = new paper.Project(canvas) + paperProjectRef.current = project + + const updateSize = () => { + if (!containerRef.current || !canvas) return + const { width, height } = + containerRef.current.getBoundingClientRect() + + canvas.width = width + canvas.height = height + project.view.viewSize = new paper.Size(width, height) + project.view.update() + } + + updateSize() + const resizeObserver = new ResizeObserver(updateSize) + if (containerRef.current) + resizeObserver.observe(containerRef.current) + + return () => { + resizeObserver.disconnect() + project.remove() + paperProjectRef.current = null + } + }, []) + + /* -------------------- Drawing Handlers -------------------- */ + + const handlePointerDown = ( + e: React.PointerEvent, + ) => { + if (!drawMode || !paperProjectRef.current) return + + paperProjectRef.current.activate() + + const rect = + drawCanvasRef.current?.getBoundingClientRect() + if (!rect) return + + const x = e.clientX - rect.left + const y = e.clientY - rect.top + + if (pathRef.current) pathRef.current.remove() + + pathRef.current = new paper.Path() + pathRef.current.strokeColor = new paper.Color('#d32f2f') + pathRef.current.strokeWidth = 3 + + startPointRef.current = new paper.Point(x, y) + pathRef.current.add(startPointRef.current) + + setIsDrawing(true) + } + + const handlePointerMove = ( + e: React.PointerEvent, + ) => { + if (!drawMode || !isDrawing || !pathRef.current) + return + + const rect = + drawCanvasRef.current?.getBoundingClientRect() + if (!rect) return + + pathRef.current.add( + new paper.Point( + e.clientX - rect.left, + e.clientY - rect.top, + ), + ) + } + + const handlePointerUp = ( + e: React.PointerEvent, + ) => { + if ( + !drawMode || + !isDrawing || + !pathRef.current || + !startPointRef.current + ) { + setIsDrawing(false) + return + } + + const rect = + drawCanvasRef.current?.getBoundingClientRect() + if (!rect) return + + const endPoint = new paper.Point( + e.clientX - rect.left, + e.clientY - rect.top, + ) + + const bounds = pathRef.current.bounds + const diameter = Math.max( + bounds.width, + bounds.height, + ) + const strokeLength = pathRef.current.length + const distance = + startPointRef.current.getDistance(endPoint) + + const circumference = Math.PI * diameter + const isCircle = + diameter > 25 && + strokeLength > circumference * 0.3 && + distance < diameter * 0.5 + const cy = cyRef.current - if (!cy) return - const tapNode = (e: cytoscape.EventObject): void => { - const node = e.target as NodeSingular + if (cy) { + if (isCircle) { + const id = `q${cy.nodes().length}` + cy.add({ + group: 'nodes', + data: { id, displayLabel: id }, + position: { + x: bounds.center.x, + y: bounds.center.y, + }, + }) + syncToBackend() + } else { + const findClosest = ( + x: number, + y: number + ): NodeSingular | null => { + let min = Infinity + let closest: NodeSingular | null = null + + cy.nodes().forEach((node) => { + const pos = node.renderedPosition() + const dist = Math.hypot(pos.x - x, pos.y - y) + if (dist < min) { + min = dist + closest = node + } + }) - if (drawMode) { - if (!fromNode) { - setFromNode(node.id()) - node.addClass('edge-source') - } else { + return min < 75 ? closest : null + } + + + const startNode = findClosest( + startPointRef.current.x, + startPointRef.current.y, + ) + const endNode = findClosest( + endPoint.x, + endPoint.y, + ) + + if ( + startNode && + endNode && + startNode.id() !== endNode.id() + ) { cy.add({ group: 'edges', data: { - id: `e-${fromNode}-${node.id()}-${Date.now()}`, - source: fromNode, - target: node.id(), - label: 'edge' + id: `e-${startNode.id()}-${endNode.id()}-${Date.now()}`, + source: startNode.id(), + target: endNode.id(), + label: 'edge', }, }) - cy.nodes().removeClass('edge-source') - setDrawMode(false) - setFromNode(null) syncToBackend() } - return } - - setSelectedNode(node) - setSelectedEdge(null) } - const tapEdge = (e: cytoscape.EventObject): void => { - setSelectedEdge(e.target as EdgeSingular) - setSelectedNode(null) - } + pathRef.current.remove() + pathRef.current = null + startPointRef.current = null + setIsDrawing(false) + } - cy.on('tap', 'node', tapNode) - cy.on('tap', 'edge', tapEdge) + const handlePointerLeave = () => { + if (pathRef.current) pathRef.current.remove() + pathRef.current = null + startPointRef.current = null + setIsDrawing(false) + } - return () => { - cy.off('tap', 'node', tapNode) - cy.off('tap', 'edge', tapEdge) - } - }, [drawMode, fromNode]) + /* -------------------- Sync to backend -------------------- */ - /* -------------------- sync to backend -------------------- */ const syncToBackend = (): void => { const cy = cyRef.current if (!cy) return const fsa: FSA = { - states: cy.nodes()?.map((n) => n.id()), - transitions: cy.edges()?.map( + states: cy.nodes().map((n) => n.id()), + transitions: cy.edges().map( (e) => - `${e.source().id()}|${e.data('label') || ' '}|${e.target().id()}`, + `${e.source().id()}|${e.data( + 'label', + )}|${e.target().id()}`, ), initial_state: answer.initial_state, accept_states: answer.accept_states, alphabet: Array.from( - new Set(cy.edges().map((e) => String(e.data('label')))), + new Set( + cy.edges().map((e) => + String(e.data('label')), + ), + ), ), - config: JSON.stringify(config) + config: JSON.stringify(config), } handleChange(fsa) } - /* -------------------- add state -------------------- */ const addState = (): void => { const cy = cyRef.current if (!cy) return const id = `q${cy.nodes().length}` + cy.add({ group: 'nodes', data: { id, displayLabel: id }, @@ -212,87 +415,68 @@ export const FSAInput: React.FC = ({ syncToBackend() } - /* -------------------- apply initial / accept styling -------------------- */ - useEffect(() => { - const cy = cyRef.current - if (!cy) return - - cy.nodes().removeClass('initial accept') - - if (answer.initial_state) { - cy.$id(answer.initial_state).addClass('initial') - } - - for (const id of answer.accept_states) { - cy.$id(id).addClass('accept') - } - }, [answer.initial_state, answer.accept_states]) - - /* -------------------- apply feedback highlights -------------------- */ - useEffect(() => { - const cy = cyRef.current - if (!cy) return - - cy.nodes().removeClass('error-highlight') - cy.edges().removeClass('error-highlight') - - if (!feedback || !feedback.errors) return - - const highlights = feedback.errors - .map((e) => e.highlight) - .filter(Boolean) - - for (const h of highlights) { - if (!h) continue - + /* -------------------- apply initial / accept styling -------------------- */ + useEffect(() => { + const cy = cyRef.current + if (!cy) return + cy.nodes().removeClass('initial accept') + if (answer.initial_state) { + cy.$id(answer.initial_state).addClass('initial') + } + for (const id of answer.accept_states) { + cy.$id(id).addClass('accept') + } + }, [answer.initial_state, answer.accept_states]) + /* -------------------- apply feedback highlights -------------------- */ + useEffect(() => { + const cy = cyRef.current + if (!cy) return + cy.nodes().removeClass('error-highlight') + cy.edges().removeClass('error-highlight') + if (!feedback || !feedback.errors) return + const highlights = feedback.errors + .map((e) => e.highlight) + .filter(Boolean) + for (const h of highlights) { + if (!h) continue switch (h.type) { - case 'state': - case 'initial_state': - case 'accept_state': { - if (h.state_id) { - cy.$id(h.state_id).addClass('error-highlight') - } - break - } - + case 'state': + case 'initial_state': + case 'accept_state': { + if (h.state_id) { + cy.$id(h.state_id).addClass('error-highlight') + } + break + } case 'transition': { - cy.edges() - .filter((e) => { - const fromOk = h.from_state - ? e.source().id() === h.from_state - : true - const toOk = h.to_state - ? e.target().id() === h.to_state - : true - const symOk = h.symbol - ? e.data('label') === h.symbol - : true - return fromOk && toOk && symOk - }) - .addClass('error-highlight') - break - } - - case 'alphabet_symbol': { - if (h.symbol) { - cy.edges() + cy.edges() + .filter((e) => { + const fromOk = h.from_state ? e.source().id() === h.from_state : true + const toOk = h.to_state ? e.target().id() === h.to_state : true + const symOk = h.symbol ? e.data('label') === h.symbol : true + return fromOk && toOk && symOk + }) + .addClass('error-highlight') + break + } + case 'alphabet_symbol': { + if (h.symbol) { + cy.edges() .filter((e) => e.data('label') === h.symbol) - .addClass('error-highlight') - } - break - } - } - } + .addClass('error-highlight') + } + break + } + } + } }, [feedback]) - + /* -------------------- Render -------------------- */ return (
- {/* Left Panel */} = ({ handleChange={handleChange} answer={answer} feedback={feedback} - phase={phase} + phase={phase} + addState={addState} + pathRef={pathRef} /> - {/* Canvas Area */} -
- {isTeacherMode && ( -
- - Teacher Mode - - - -
- )} - -
+
+
+ +
- {/* Side Modal */} {isTeacherMode && configOpen && ( - <> -
setConfigOpen(false)} - /> - - { - const fsa: FSA = { - ...answer, - config: JSON.stringify(val), - } - handleChange(fsa) - setConfig(val) - }} - onClose={() => setConfigOpen(false)} - classes={classes} - /> - + { + handleChange({ + ...answer, + config: JSON.stringify(val), + }) + setConfig(val) + }} + onClose={() => setConfigOpen(false)} + classes={classes} + /> )}
) -} \ No newline at end of file +} diff --git a/src/types/FSA/components/FSAFeedbackPanel.tsx b/src/types/FSA/components/FSAFeedbackPanel.tsx index d9be12f..c252b9d 100644 --- a/src/types/FSA/components/FSAFeedbackPanel.tsx +++ b/src/types/FSA/components/FSAFeedbackPanel.tsx @@ -27,21 +27,26 @@ export function FSAFeedbackPanel({ } const safeFeedback = feedback + console.log(safeFeedback) return (
-
- { - phase == CheckPhase.PreviewError ? "Errors in Preview" : "Errors in Evaluation" - } -
+ { + safeFeedback.errors.length != 0 ? +
+ { + phase == CheckPhase.PreviewError ? "Errors in Preview" : "Errors in Evaluation" + } +
+ : null + } {/* ================= Summary ================= */} {safeFeedback.summary && (
void feedback: FSAFeedback | null phase: CheckPhase + + pathRef: React.MutableRefObject } export default function ItemPropertiesPanel({ @@ -44,7 +46,8 @@ export default function ItemPropertiesPanel({ handleChange, syncToBackend, feedback, - phase + phase, + pathRef }: ItemPropertiesPanelProps): JSX.Element { return (
@@ -66,15 +69,26 @@ export default function ItemPropertiesPanel({ Fit to Screen - {/* -------------------- Node Properties -------------------- */} diff --git a/yarn.lock b/yarn.lock index 62bcefd..628dd87 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5498,6 +5498,11 @@ pako@~1.0.2: resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== +paper@^0.12.18: + version "0.12.18" + resolved "https://registry.npmmirror.com/paper/-/paper-0.12.18.tgz#e024056217a35c36e2b5fda4629310fdc7025c91" + integrity sha512-ZSLIEejQTJZuYHhSSqAf4jXOnii0kPhCJGAnYAANtdS72aNwXJ9cP95tZHgq1tnNpvEwgQhggy+4OarviqTCGw== + parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" From 96cbd2e28e3df1cc57516836d51b9a2f819b6e47 Mon Sep 17 00:00:00 2001 From: everythingfades Date: Mon, 2 Mar 2026 14:04:11 +0000 Subject: [PATCH 16/29] fix: fix duplicates in package.json --- package.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/package.json b/package.json index 1b0d4d2..133ba19 100644 --- a/package.json +++ b/package.json @@ -51,9 +51,6 @@ "notistack": "^2.0.3", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-hook-form": "^7.31.2", - "react-katex": "^3.0.1", - "react-query": "^3.39.0", "reactflow": "^11.11.4", "react-dropzone": "^14.2.3", "react-hook-form": "^7.31.2", From ddecae21da13c62d346596e6908d3f4621f99fbc Mon Sep 17 00:00:00 2001 From: Zhibo Ren Date: Tue, 3 Mar 2026 11:45:07 +0000 Subject: [PATCH 17/29] added readme description for FSA --- src/types/FSA/README.md | 145 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 src/types/FSA/README.md diff --git a/src/types/FSA/README.md b/src/types/FSA/README.md new file mode 100644 index 0000000..105382a --- /dev/null +++ b/src/types/FSA/README.md @@ -0,0 +1,145 @@ +# FSA Response Area: Test Guide + +This guide is for testing the **FSA** response area only. + +## Scope + +- Covers local preview validation (`validateFSA`) and UI behavior in the FSA editor. +- Does **not** assume backend language-equivalence grading is available. + +## Run Locally + +1. Install deps: + + ```bash + yarn + ``` + +2. Start sandbox: + + ```bash + yarn dev + ``` + + If you only want to iterate in the local Vite mode, use: + + ```bash + yarn dev:fsa + ``` + +3. In Lambda Feedback sandbox settings, point to your local URL and ensure response type is `FSA`. + +## Quick Manual Test Flow + +1. Open/create an item with FSA response area. +2. In the editor, click `+ Add State` to create states. +3. Select a state to toggle `Initial State` and `Accepting State`. +4. Create transitions by either: + - clicking `✏️ Enable Draw Mode` and drawing from one state to another, or + - selecting an edge and editing `Transition Symbol`. +5. Watch the feedback panel in real time. + +Expected generic behavior: + +- Invalid automata show `Preview Errors` and summary: `The automaton is not a valid finite-state automaton.` +- Valid automata clear preview errors and show summary: `The automaton is a valid finite-state automaton.` +- If errors include highlights, the matching node/edge is marked in red. + +## Example Questions and Expected Responses + +Use these prompts while testing the UI. + +### Q1. “Build a DFA over `{a,b}` that accepts strings ending in `a`.” + +Suggested student automaton: + +- States: `q0`, `q1` +- Initial: `q0` +- Accept: `q1` +- Transitions: + - `q0|a|q1` + - `q0|b|q0` + - `q1|a|q1` + - `q1|b|q0` + +Expected response: + +- No preview errors. +- Summary is valid. +- Structural section should report deterministic = Yes and complete = Yes. + +### Q2. “Create any FSA with at least one transition, but forget to set an initial state.” + +Suggested student automaton: + +- States: `q0` +- Initial: *(unset / empty)* +- Accept: `q0` +- Transition: `q0|a|q0` + +Expected response: + +- Error code `INVALID_INITIAL`. +- Message similar to: `Initial state "" is not a valid state.` +- Summary is invalid. + +### Q3. “Create a machine where one transition uses a symbol not in the alphabet.” + +How to trigger: + +1. Create two states (`q0`, `q1`) and transition `q0 -> q1`. +2. Set edge label to `z`. +3. Ensure no other edge uses `z`, then edit state/edge data so alphabet effectively excludes intended symbol set (or create malformed transition data in saved answer payload). + +Expected response: + +- Error code `INVALID_TRANSITION_SYMBOL` for out-of-alphabet symbols. +- Transition/alphabet symbol highlighting appears in red. + +> Note: In normal UI flow, alphabet is derived from edge labels. This means this case is easiest to reproduce with malformed persisted data rather than pure UI actions. + +### Q4. “Create an automaton with an unreachable state.” + +Suggested student automaton: + +- States: `q0`, `q1`, `q2` +- Initial: `q0` +- Accept: `q1` +- Transitions: + - `q0|a|q1` + - `q1|a|q1` + - *(no transitions from/to `q2`)* + +Expected response: + +- May still be valid (no structural error required). +- Structural section lists `q2` under `Unreachable states`. + +### Q5. “Create two outgoing transitions from the same state with the same symbol.” + +Suggested student automaton: + +- States: `q0`, `q1`, `q2` +- Initial: `q0` +- Accept: `q1` +- Transitions: + - `q0|a|q1` + - `q0|a|q2` + +Expected response: + +- No hard preview error from local validator for nondeterminism. +- Structural section shows deterministic = No. + +## Regression Checklist + +- `Preview Errors` appears only when local validation has errors. +- Error count and error text update immediately after editing states/edges. +- Deleting selected node/edge updates feedback and structural metrics. +- `Fit to Screen` and draw mode toggling do not corrupt answer state. +- Initial/accept visual styles remain correct after edits. + +## Known Notes + +- Local preview validation is structural/syntactic and does not perform full language-equivalence checking. +- Some error types are easier to reproduce via malformed saved payloads than through normal UI controls. \ No newline at end of file From 7bb5403c730584419b65f6b880d3a22753cb894d Mon Sep 17 00:00:00 2001 From: Zhibo Ren Date: Tue, 3 Mar 2026 12:01:08 +0000 Subject: [PATCH 18/29] refactor(FSA): remove console.log statements from production code --- src/types/FSA/components/FSAFeedbackPanel.tsx | 1 - src/types/FSA/index.tsx | 8 +------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/types/FSA/components/FSAFeedbackPanel.tsx b/src/types/FSA/components/FSAFeedbackPanel.tsx index c252b9d..9a88188 100644 --- a/src/types/FSA/components/FSAFeedbackPanel.tsx +++ b/src/types/FSA/components/FSAFeedbackPanel.tsx @@ -27,7 +27,6 @@ export function FSAFeedbackPanel({ } const safeFeedback = feedback - console.log(safeFeedback) return (
diff --git a/src/types/FSA/index.tsx b/src/types/FSA/index.tsx index 8289321..a9ad7c1 100644 --- a/src/types/FSA/index.tsx +++ b/src/types/FSA/index.tsx @@ -53,7 +53,6 @@ export class FSAResponseAreaTub extends ResponseAreaTub { /* ---------- Extract submitted feedback ---------- */ const submittedFeedback: FSAFeedback | null = (() => { - console.log("raw:", props) const raw = props.feedback?.feedback if (!raw) return null @@ -61,21 +60,16 @@ export class FSAResponseAreaTub extends ResponseAreaTub { const jsonPart = raw.split('
')[1]?.trim() if (!jsonPart) return null return JSON.parse(jsonPart) - } catch (e) { - - console.error('Failed to parse feedback JSON:', e) + } catch { return null } })() - console.log("submitted:", submittedFeedback) /* ---------- Effective feedback ---------- */ const effectiveFeedback = this.previewFeedback ?? submittedFeedback - console.log(submittedFeedback, this.previewFeedback, effectiveFeedback) - return ( Date: Tue, 3 Mar 2026 12:03:08 +0000 Subject: [PATCH 19/29] refactor(FSA): extract magic numbers into named constants --- src/types/FSA/FSA.component.tsx | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/types/FSA/FSA.component.tsx b/src/types/FSA/FSA.component.tsx index 517604f..d0001cb 100644 --- a/src/types/FSA/FSA.component.tsx +++ b/src/types/FSA/FSA.component.tsx @@ -21,6 +21,23 @@ interface FSAInputProps { isTeacherMode: boolean } +/* -------------------- Layout / drawing constants -------------------- */ + +/** Minimum bounding-box diameter (px) for a drawn stroke to be recognised as a circle. */ +const MIN_CIRCLE_DIAMETER_PX = 25 +/** Stroke length must exceed this fraction of the estimated circumference. */ +const MIN_STROKE_CIRCUMFERENCE_RATIO = 0.3 +/** Start-to-end distance must be less than this fraction of the diameter (i.e. the stroke is "closed"). */ +const MAX_ENDPOINT_DISTANCE_RATIO = 0.5 +/** Maximum distance (px) from a pointer position to the nearest node for snapping. */ +const NODE_SNAP_DISTANCE_PX = 75 +/** Base offset (px) for random node placement on the X axis. */ +const NEW_NODE_OFFSET_X = 100 +/** Base offset (px) for random node placement on the Y axis. */ +const NEW_NODE_OFFSET_Y = 100 +/** Range (px) added to the base offset via Math.random(). */ +const NEW_NODE_RANDOM_RANGE = 300 + export const FSAInput: React.FC = ({ answer, handleChange, @@ -288,9 +305,9 @@ export const FSAInput: React.FC = ({ const circumference = Math.PI * diameter const isCircle = - diameter > 25 && - strokeLength > circumference * 0.3 && - distance < diameter * 0.5 + diameter > MIN_CIRCLE_DIAMETER_PX && + strokeLength > circumference * MIN_STROKE_CIRCUMFERENCE_RATIO && + distance < diameter * MAX_ENDPOINT_DISTANCE_RATIO const cy = cyRef.current @@ -323,7 +340,7 @@ export const FSAInput: React.FC = ({ } }) - return min < 75 ? closest : null + return min < NODE_SNAP_DISTANCE_PX ? closest : null } @@ -407,8 +424,8 @@ export const FSAInput: React.FC = ({ group: 'nodes', data: { id, displayLabel: id }, position: { - x: 100 + Math.random() * 300, - y: 100 + Math.random() * 300, + x: NEW_NODE_OFFSET_X + Math.random() * NEW_NODE_RANDOM_RANGE, + y: NEW_NODE_OFFSET_Y + Math.random() * NEW_NODE_RANDOM_RANGE, }, }) From 1c72c9efb56c29489058c122e377ce3a2744fbb6 Mon Sep 17 00:00:00 2001 From: Zhibo Ren Date: Tue, 3 Mar 2026 12:03:26 +0000 Subject: [PATCH 20/29] docs(FSA): add JSDoc comment to syncToBackend explaining its purpose --- src/types/FSA/FSA.component.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/types/FSA/FSA.component.tsx b/src/types/FSA/FSA.component.tsx index d0001cb..8f2b50a 100644 --- a/src/types/FSA/FSA.component.tsx +++ b/src/types/FSA/FSA.component.tsx @@ -387,6 +387,13 @@ export const FSAInput: React.FC = ({ /* -------------------- Sync to backend -------------------- */ + /** + * Reads the current Cytoscape graph and converts it into the + * flattened `FSA` data structure expected by the response-area + * pipeline. Called after every graph mutation (add/remove + * node or edge) so the parent component always holds an + * up-to-date serialisable snapshot of the automaton. + */ const syncToBackend = (): void => { const cy = cyRef.current if (!cy) return From d58c500b5a0ebf5979a3b0cd84c08fb65f019097 Mon Sep 17 00:00:00 2001 From: Zhibo Ren Date: Tue, 3 Mar 2026 12:04:46 +0000 Subject: [PATCH 21/29] docs(FSA): add TODO notes to move validation/structural logic to evaluation function --- src/types/FSA/validateFSA.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/types/FSA/validateFSA.ts b/src/types/FSA/validateFSA.ts index 01a4520..e27aa4c 100644 --- a/src/types/FSA/validateFSA.ts +++ b/src/types/FSA/validateFSA.ts @@ -9,6 +9,10 @@ type MutableFeedback = Pick; type OutgoingMap = Map>; +// TODO: Consider moving basic validation checks to the evaluation function +// so that multiple evaluation functions can share validation logic +// on the backend rather than duplicating it in the response area. + /* =========================== Basic validation checks =========================== */ @@ -168,6 +172,10 @@ const checkTransitions = ( return outgoing; }; +// TODO: Structural computations (determinism, completeness, reachability, +// dead-state analysis) should ideally live in the evaluation function +// so the response area stays a thin presentation layer. + /* =========================== Structural computations =========================== */ From 8d2fd36cd94ae97d6fa4e2eb711a7aad94a65290 Mon Sep 17 00:00:00 2001 From: Zhibo Ren Date: Tue, 3 Mar 2026 12:04:57 +0000 Subject: [PATCH 22/29] docs(FSA): clarify conversion ownership and feedback contract in FSA.md --- FSA.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/FSA.md b/FSA.md index 929f1eb..9e596a1 100644 --- a/FSA.md +++ b/FSA.md @@ -70,6 +70,13 @@ The controller class that integrates the editor into the application wizard. Since the frontend and backend see the data differently, the `FSAConverter` utility is used at the network boundary. +> **Design note:** Ideally the backend would handle this conversion so that +> multiple evaluation functions can consume the same canonical format without +> each reimplementing a converter. The current frontend-side conversion exists +> as a stop-gap because the Zod `jsonNestedSchema` enforces a 2-level nesting +> limit. A future iteration should move this responsibility to the backend +> API layer. + | Method | Source | Target | Reason | | --- | --- | --- | --- | | `toFrontend()` | `BackendFSA` | `FSA` | Unpacks objects into `"q0 | @@ -88,6 +95,10 @@ Since the frontend and backend see the data differently, the `FSAConverter` util ## 5. Important Implementation Notes +* **Feedback contract:** The evaluation function should return feedback in a + simple, directly-displayable form (matching the `FSAFeedback` type) so the + response area can render it without additional parsing or transformation. + * **Unique Identifiers**: Edge IDs in React Flow are generated as ``e-${from}-${symbol}-${to}``. If the automaton is Non-Deterministic (NFA), ensure symbol uniqueness or add a UUID to the ID string. * **Visual Cues**: * **Initial State**: Nodes matching `initial_state` are colored with a light teal background. From 0082e48998f045ef43c5d4f95ed8df15efff241e Mon Sep 17 00:00:00 2001 From: everythingfades Date: Wed, 4 Mar 2026 03:22:17 +0000 Subject: [PATCH 23/29] feat: update Readme.md for FSA --- FSA.md | 124 ------------ src/types/FSA/README.md | 241 ++++++++++++++--------- src/types/FSA/TestGuide.md | 145 ++++++++++++++ src/types/FSA/components/ConfigPanel.tsx | 5 +- 4 files changed, 289 insertions(+), 226 deletions(-) delete mode 100644 FSA.md create mode 100644 src/types/FSA/TestGuide.md diff --git a/FSA.md b/FSA.md deleted file mode 100644 index 9e596a1..0000000 --- a/FSA.md +++ /dev/null @@ -1,124 +0,0 @@ -# FSA Module Documentation - -This module provides a visual editor for Finite State Automata (FSA) built with **React Flow**. It is designed to bridge the gap between a **Python-style backend** (deeply nested objects) and a **TypeScript/Zod frontend** (restricted to 2-level JSON nesting). - -## 1. The Core Data Structures - -### Frontend Schema (`FSA`) - -To satisfy the `jsonNestedSchema` (which permits only 2 levels of nesting), we use a "flattened" string format for transitions. - -```typescript -// level 1: Object properties -// level 2: String arrays -export interface FSA { - states: string[]; - alphabet: string[]; - transitions: string[]; // Format: "from_state|symbol|to_state" - initial_state: string; - accept_states: string[]; -} - -``` - -### Backend Schema (`BackendFSA`) - -The Python backend uses standard object-oriented nesting for transitions. - -```typescript -export interface BackendFSA { - states: string[]; - alphabet: string[]; - transitions: Array<{ - from_state: string; - to_state: string; - symbol: string; - }>; - initial_state: string; - accept_states: string[]; -} - -``` - ---- - -## 2. Key Components - -### `FSAInput.component.tsx` - -The primary visual editor. - -* **State Management**: Uses `useNodesState` and `useEdgesState` from React Flow. -* **Syncing**: Every change (adding a node, connecting an edge, deleting) triggers a `syncChanges` function that converts the visual graph back into the flattened `FSA` interface. -* **User Interactions**: -* **Add State**: Prompt-based creation of new nodes. -* **Connections**: Dragging from one node to another prompts for a transition symbol (defaults to `ε`). -* **Deletion**: Selecting a node/edge and pressing **Backspace** or **Delete** removes the element and cleans up orphaned transitions. - - - -### `FSAResponseAreaTub.ts` - -The controller class that integrates the editor into the application wizard. - -* **Resilience**: Uses `defaultFSA` to prevent `undefined` errors. -* **Validation**: Uses `fsaAnswerSchema.safeParse()` to guard against corrupted data. - ---- - -## 3. Transformation Logic (`FSAConverter`) - -Since the frontend and backend see the data differently, the `FSAConverter` utility is used at the network boundary. - -> **Design note:** Ideally the backend would handle this conversion so that -> multiple evaluation functions can consume the same canonical format without -> each reimplementing a converter. The current frontend-side conversion exists -> as a stop-gap because the Zod `jsonNestedSchema` enforces a 2-level nesting -> limit. A future iteration should move this responsibility to the backend -> API layer. - -| Method | Source | Target | Reason | -| --- | --- | --- | --- | -| `toFrontend()` | `BackendFSA` | `FSA` | Unpacks objects into `"q0 | -| `toBackend()` | `FSA` | `BackendFSA` | Packs strings back into objects for the Python service logic. | - ---- - -## 4. Usage in the Pipeline - -1. **Load**: Data is fetched from the backend (`BackendFSA`). -2. **Convert**: `FSAConverter.toFrontend()` is called. -3. **Edit**: The user interacts with `FSAInput`. The `answer` state stays in the flattened `FSA` format. -4. [TODO] **Save**: On `onSubmit` or `onChange`, `FSAConverter.toBackend()` is called to transform the data back to the format the server expects. - ---- - -## 5. Important Implementation Notes - -* **Feedback contract:** The evaluation function should return feedback in a - simple, directly-displayable form (matching the `FSAFeedback` type) so the - response area can render it without additional parsing or transformation. - -* **Unique Identifiers**: Edge IDs in React Flow are generated as ``e-${from}-${symbol}-${to}``. If the automaton is Non-Deterministic (NFA), ensure symbol uniqueness or add a UUID to the ID string. -* **Visual Cues**: -* **Initial State**: Nodes matching `initial_state` are colored with a light teal background. -* **Accept States**: Nodes in `accept_states` are rendered with a double-border (4px double). - - -* **Alphabet Consistency**: The `alphabet` array is automatically derived from the unique labels present in the transitions during the sync process. - -## 6. Dev Notice: - -There is a temporary folder `/dev`, all the development stuff should be tested there - -run `yarn vite` or `yarn dev:fsa` to run - -also take notice in order for yarn to be configured correctly, there is a extra config - -```json -root: 'dev', // for dev only -``` - -in the vite.config.ts - -remember to remove it when we get to production \ No newline at end of file diff --git a/src/types/FSA/README.md b/src/types/FSA/README.md index 105382a..495fe78 100644 --- a/src/types/FSA/README.md +++ b/src/types/FSA/README.md @@ -1,145 +1,190 @@ -# FSA Response Area: Test Guide +Here is a polished **README-style** version, structured for a repository: -This guide is for testing the **FSA** response area only. +--- -## Scope +# FSA Editor Module -- Covers local preview validation (`validateFSA`) and UI behavior in the FSA editor. -- Does **not** assume backend language-equivalence grading is available. +A visual **Finite State Automata (FSA)** editor built for structured educational workflows. -## Run Locally +This module provides: -1. Install deps: +* An interactive graph-based automaton editor +* A flattened frontend schema compatible with strict JSON validation +* Backend conversion support for structured automata processing +* Integrated evaluation and feedback support via the wizard system - ```bash - yarn - ``` +--- -2. Start sandbox: +# Overview - ```bash - yarn dev - ``` +The FSA Editor bridges: - If you only want to iterate in the local Vite mode, use: +* A **flattened TypeScript/Zod frontend representation** +* A **nested backend automaton model** - ```bash - yarn dev:fsa - ``` +It enables users to construct automata visually while maintaining strict schema compatibility. -3. In Lambda Feedback sandbox settings, point to your local URL and ensure response type is `FSA`. +--- -## Quick Manual Test Flow +# 1. Core Data Structures -1. Open/create an item with FSA response area. -2. In the editor, click `+ Add State` to create states. -3. Select a state to toggle `Initial State` and `Accepting State`. -4. Create transitions by either: - - clicking `✏️ Enable Draw Mode` and drawing from one state to another, or - - selecting an edge and editing `Transition Symbol`. -5. Watch the feedback panel in real time. +## Frontend Schema (`FSA`) -Expected generic behavior: +To satisfy the two-level nesting restriction enforced by `jsonNestedSchema`, transitions are stored as flattened strings. -- Invalid automata show `Preview Errors` and summary: `The automaton is not a valid finite-state automaton.` -- Valid automata clear preview errors and show summary: `The automaton is a valid finite-state automaton.` -- If errors include highlights, the matching node/edge is marked in red. +```ts +export interface FSA { + states: string[]; + alphabet: string[]; + transitions: string[]; // "from_state|symbol|to_state" + initial_state: string; + accept_states: string[]; +} +``` -## Example Questions and Expected Responses +### Transition Encoding -Use these prompts while testing the UI. +Transitions use a pipe-separated format: -### Q1. “Build a DFA over `{a,b}` that accepts strings ending in `a`.” +``` +"from_state|symbol|to_state" +``` -Suggested student automaton: +This guarantees compatibility with frontend validation. -- States: `q0`, `q1` -- Initial: `q0` -- Accept: `q1` -- Transitions: - - `q0|a|q1` - - `q0|b|q0` - - `q1|a|q1` - - `q1|b|q0` +This type will be expanded in the backend with pydantic validation -Expected response: +--- -- No preview errors. -- Summary is valid. -- Structural section should report deterministic = Yes and complete = Yes. +# 2. Editor Operations -### Q2. “Create any FSA with at least one transition, but forget to set an initial state.” +## 2.1 Adding & Editing States and Transitions -Suggested student automaton: +### Draw Mode -- States: `q0` -- Initial: *(unset / empty)* -- Accept: `q0` -- Transition: `q0|a|q0` +Users can enable **Draw Mode** to sketch directly on the canvas. -Expected response: +The stroke will be red directly on the canvas -- Error code `INVALID_INITIAL`. -- Message similar to: `Initial state "" is not a valid state.` -- Summary is invalid. +* Drawing a **circle** → creates a new state. +* Drawing a **line between two states** → creates a transition. +* Gestures are automatically interpreted and converted into graph elements. -### Q3. “Create a machine where one transition uses a symbol not in the alphabet.” +--- -How to trigger: +### Manual Node Creation -1. Create two states (`q0`, `q1`) and transition `q0 -> q1`. -2. Set edge label to `z`. -3. Ensure no other edge uses `z`, then edit state/edge data so alphabet effectively excludes intended symbol set (or create malformed transition data in saved answer payload). +States can also be added manually (e.g., via UI controls or prompts). +This allows precise creation without gesture detection. -Expected response: +--- -- Error code `INVALID_TRANSITION_SYMBOL` for out-of-alphabet symbols. -- Transition/alphabet symbol highlighting appears in red. +### Selecting Nodes & Edges -> Note: In normal UI flow, alphabet is derived from edge labels. This means this case is easiest to reproduce with malformed persisted data rather than pure UI actions. +Clicking a node or edge: -### Q4. “Create an automaton with an unreachable state.” +* Displays its properties in the **ItemPropertiesPanel** (left side). +* Allows editing of: -Suggested student automaton: + * State name + * Transition label + * State type (initial/accepting) -- States: `q0`, `q1`, `q2` -- Initial: `q0` -- Accept: `q1` -- Transitions: - - `q0|a|q1` - - `q1|a|q1` - - *(no transitions from/to `q2`)* +--- -Expected response: +### Setting Initial & Accepting States -- May still be valid (no structural error required). -- Structural section lists `q2` under `Unreachable states`. +Within the properties panel, a state can be marked as: -### Q5. “Create two outgoing transitions from the same state with the same symbol.” +* **Initial State** +* **Accepting State** -Suggested student automaton: +Changes update immediately in the UI. -- States: `q0`, `q1`, `q2` -- Initial: `q0` -- Accept: `q1` -- Transitions: - - `q0|a|q1` - - `q0|a|q2` +--- -Expected response: +## 2.2 UI Conventions -- No hard preview error from local validator for nondeterminism. -- Structural section shows deterministic = No. +The editor follows consistent visual rules: -## Regression Checklist +| Type | Visual Style | +| ---------------------- | -------------------- | +| Initial State | Bold blue border | +| Accepting State | Double-circle border | +| ε (Epsilon) Transition | Purple dashed arrow | +| Errored Component | Highlighted in red (only if hightlight config is set in the evaluation params) | -- `Preview Errors` appears only when local validation has errors. -- Error count and error text update immediately after editing states/edges. -- Deleting selected node/edge updates feedback and structural metrics. -- `Fit to Screen` and draw mode toggling do not corrupt answer state. -- Initial/accept visual styles remain correct after edits. +These visual cues help users quickly identify automaton structure and validation issues. -## Known Notes +--- -- Local preview validation is structural/syntactic and does not perform full language-equivalence checking. -- Some error types are easier to reproduce via malformed saved payloads than through normal UI controls. \ No newline at end of file +# 3. Feedback & Evaluation + +## 3.1 Evaluation Parameters (Wizard Component) + +Evaluation parameters are configured in the **Wizard component**, inside a collapsible **Eval Params** panel. + +* These settings are **not displayed in the FSA input component**. +* This keeps the editor focused solely on automaton construction. + +the panel corresponds to the `FSAConfig` type + +```ts +export const fsaConfigSchema = z.object({ + evaluation_mode: z.enum(['strict', 'lenient', 'partial']).optional(), + expected_type: z.enum(['DFA', 'NFA', 'any']).optional(), + feedback_verbosity: z.enum(['minimal', 'standard', 'detailed']).optional(), + + check_minimality: z.boolean().optional(), + check_completeness: z.boolean().optional(), + + highlight_errors: z.boolean().optional(), +}) +``` + +please find the other details in ./src/FSA/types.ts + +--- + +## 3.2 Feedback Display + +Feedback (both preview and final submission): + +* Appears below the **ItemPropertiesPanel** +* Conforms to the `FSAFeedback` type +* Requires no additional transformation before rendering + +the FSAFeedback type can be seen below: + +```ts +export const FSAFeedbackSchema = z.object({ + summary: z.string().default(""), + + errors: z.array(ValidationErrorSchema).default([]), + warnings: z.array(ValidationErrorSchema).default([]), + + structural: StructuralInfoSchema.optional(), + language: LanguageComparisonSchema.optional(), + + test_results: z.array(TestResultSchema).default([]), + hints: z.array(z.string()).default([]), +}); +``` + +please find the other details in ./src/FSA/types.ts + +# 4. Running Guide + +For the first time we run this code, make sure to build with + +``` +yarn build +``` + +then we can + +``` +yarn dev +``` + +to run the sandbox \ No newline at end of file diff --git a/src/types/FSA/TestGuide.md b/src/types/FSA/TestGuide.md new file mode 100644 index 0000000..105382a --- /dev/null +++ b/src/types/FSA/TestGuide.md @@ -0,0 +1,145 @@ +# FSA Response Area: Test Guide + +This guide is for testing the **FSA** response area only. + +## Scope + +- Covers local preview validation (`validateFSA`) and UI behavior in the FSA editor. +- Does **not** assume backend language-equivalence grading is available. + +## Run Locally + +1. Install deps: + + ```bash + yarn + ``` + +2. Start sandbox: + + ```bash + yarn dev + ``` + + If you only want to iterate in the local Vite mode, use: + + ```bash + yarn dev:fsa + ``` + +3. In Lambda Feedback sandbox settings, point to your local URL and ensure response type is `FSA`. + +## Quick Manual Test Flow + +1. Open/create an item with FSA response area. +2. In the editor, click `+ Add State` to create states. +3. Select a state to toggle `Initial State` and `Accepting State`. +4. Create transitions by either: + - clicking `✏️ Enable Draw Mode` and drawing from one state to another, or + - selecting an edge and editing `Transition Symbol`. +5. Watch the feedback panel in real time. + +Expected generic behavior: + +- Invalid automata show `Preview Errors` and summary: `The automaton is not a valid finite-state automaton.` +- Valid automata clear preview errors and show summary: `The automaton is a valid finite-state automaton.` +- If errors include highlights, the matching node/edge is marked in red. + +## Example Questions and Expected Responses + +Use these prompts while testing the UI. + +### Q1. “Build a DFA over `{a,b}` that accepts strings ending in `a`.” + +Suggested student automaton: + +- States: `q0`, `q1` +- Initial: `q0` +- Accept: `q1` +- Transitions: + - `q0|a|q1` + - `q0|b|q0` + - `q1|a|q1` + - `q1|b|q0` + +Expected response: + +- No preview errors. +- Summary is valid. +- Structural section should report deterministic = Yes and complete = Yes. + +### Q2. “Create any FSA with at least one transition, but forget to set an initial state.” + +Suggested student automaton: + +- States: `q0` +- Initial: *(unset / empty)* +- Accept: `q0` +- Transition: `q0|a|q0` + +Expected response: + +- Error code `INVALID_INITIAL`. +- Message similar to: `Initial state "" is not a valid state.` +- Summary is invalid. + +### Q3. “Create a machine where one transition uses a symbol not in the alphabet.” + +How to trigger: + +1. Create two states (`q0`, `q1`) and transition `q0 -> q1`. +2. Set edge label to `z`. +3. Ensure no other edge uses `z`, then edit state/edge data so alphabet effectively excludes intended symbol set (or create malformed transition data in saved answer payload). + +Expected response: + +- Error code `INVALID_TRANSITION_SYMBOL` for out-of-alphabet symbols. +- Transition/alphabet symbol highlighting appears in red. + +> Note: In normal UI flow, alphabet is derived from edge labels. This means this case is easiest to reproduce with malformed persisted data rather than pure UI actions. + +### Q4. “Create an automaton with an unreachable state.” + +Suggested student automaton: + +- States: `q0`, `q1`, `q2` +- Initial: `q0` +- Accept: `q1` +- Transitions: + - `q0|a|q1` + - `q1|a|q1` + - *(no transitions from/to `q2`)* + +Expected response: + +- May still be valid (no structural error required). +- Structural section lists `q2` under `Unreachable states`. + +### Q5. “Create two outgoing transitions from the same state with the same symbol.” + +Suggested student automaton: + +- States: `q0`, `q1`, `q2` +- Initial: `q0` +- Accept: `q1` +- Transitions: + - `q0|a|q1` + - `q0|a|q2` + +Expected response: + +- No hard preview error from local validator for nondeterminism. +- Structural section shows deterministic = No. + +## Regression Checklist + +- `Preview Errors` appears only when local validation has errors. +- Error count and error text update immediately after editing states/edges. +- Deleting selected node/edge updates feedback and structural metrics. +- `Fit to Screen` and draw mode toggling do not corrupt answer state. +- Initial/accept visual styles remain correct after edits. + +## Known Notes + +- Local preview validation is structural/syntactic and does not perform full language-equivalence checking. +- Some error types are easier to reproduce via malformed saved payloads than through normal UI controls. \ No newline at end of file diff --git a/src/types/FSA/components/ConfigPanel.tsx b/src/types/FSA/components/ConfigPanel.tsx index 243ee25..4eed86a 100644 --- a/src/types/FSA/components/ConfigPanel.tsx +++ b/src/types/FSA/components/ConfigPanel.tsx @@ -1,9 +1,6 @@ import React from 'react' -import { z } from 'zod' -import { fsaConfigSchema } from '../type' - -type FSAConfig = z.infer +import { FSAConfig } from '../type' interface ConfigPanelProps { config: FSAConfig From 1fb6abadd32394849d4a67878e05eb8f9d4ed827 Mon Sep 17 00:00:00 2001 From: everythingfades Date: Wed, 4 Mar 2026 03:32:00 +0000 Subject: [PATCH 24/29] refactor: refactor out the CY_CONFIG --- src/types/FSA/FSA.component.tsx | 154 ++++++++++++++++---------------- src/types/FSA/index.tsx | 9 +- 2 files changed, 83 insertions(+), 80 deletions(-) diff --git a/src/types/FSA/FSA.component.tsx b/src/types/FSA/FSA.component.tsx index 8f2b50a..1423a6b 100644 --- a/src/types/FSA/FSA.component.tsx +++ b/src/types/FSA/FSA.component.tsx @@ -37,6 +37,83 @@ const NEW_NODE_OFFSET_X = 100 const NEW_NODE_OFFSET_Y = 100 /** Range (px) added to the base offset via Math.random(). */ const NEW_NODE_RANDOM_RANGE = 300 +/** Config for Cytoscape */ +const CY_CONFIG = (containerRef: React.MutableRefObject): cytoscape.CytoscapeOptions => { + return { + 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', + }, + }, + { + selector: 'node.initial', + style: { + 'border-width': 3, + 'border-color': '#1976d2', + }, + }, + { + selector: 'node.accept', + style: { + 'border-style': 'double', + 'border-width': 4, + }, + }, + { + selector: 'node.error-highlight', + style: { + 'background-color': '#ffebee', + 'border-color': '#d32f2f', + 'border-width': 4, + }, + }, + { + selector: 'edge', + style: { + label: 'data(label)', + 'curve-style': 'bezier', + 'target-arrow-shape': 'triangle', + 'line-color': '#555', + 'target-arrow-color': '#555', + 'text-background-color': '#fff', + 'text-background-opacity': 1, + 'text-background-padding': '3px', + }, + }, + { + selector: 'edge.error-highlight', + style: { + 'line-color': '#d32f2f', + 'target-arrow-color': '#d32f2f', + 'line-style': 'dashed', + width: 3, + }, + }, + { + selector: 'edge.epsilon', + style: { + 'line-style': 'dashed', + 'line-color': '#6a1b9a', + 'target-arrow-color': '#6a1b9a', + width: 3, + 'font-style': + 'italic', + }, + }, + ], + } +} export const FSAInput: React.FC = ({ answer, @@ -74,80 +151,7 @@ export const FSAInput: React.FC = ({ useEffect(() => { if (!containerRef.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', - }, - }, - { - selector: 'node.initial', - style: { - 'border-width': 3, - 'border-color': '#1976d2', - }, - }, - { - selector: 'node.accept', - style: { - 'border-style': 'double', - 'border-width': 4, - }, - }, - { - selector: 'node.error-highlight', - style: { - 'background-color': '#ffebee', - 'border-color': '#d32f2f', - 'border-width': 4, - }, - }, - { - selector: 'edge', - style: { - label: 'data(label)', - 'curve-style': 'bezier', - 'target-arrow-shape': 'triangle', - 'line-color': '#555', - 'target-arrow-color': '#555', - 'text-background-color': '#fff', - 'text-background-opacity': 1, - 'text-background-padding': '3px', - }, - }, - { - selector: 'edge.error-highlight', - style: { - 'line-color': '#d32f2f', - 'target-arrow-color': '#d32f2f', - 'line-style': 'dashed', - width: 3, - }, - }, - { - selector: 'edge.epsilon', - style: { - 'line-style': 'dashed', - 'line-color': '#6a1b9a', - 'target-arrow-color': '#6a1b9a', - width: 3, - 'font-style': - 'italic', - }, - }, - ], - }) + const cy: Core = cytoscape(CY_CONFIG(containerRef)) cyRef.current = cy return () => cy.destroy() @@ -385,7 +389,7 @@ export const FSAInput: React.FC = ({ setIsDrawing(false) } - /* -------------------- Sync to backend -------------------- */ + /* -------------------- Sync with React states -------------------- */ /** * Reads the current Cytoscape graph and converts it into the diff --git a/src/types/FSA/index.tsx b/src/types/FSA/index.tsx index a9ad7c1..7f4abdc 100644 --- a/src/types/FSA/index.tsx +++ b/src/types/FSA/index.tsx @@ -38,7 +38,6 @@ export class FSAResponseAreaTub extends ResponseAreaTub { // Preview passed — ensure it's cleared this.previewFeedback = null - // this.phase = CheckPhase.Idle } /* -------------------- Input -------------------- */ @@ -48,12 +47,12 @@ export class FSAResponseAreaTub extends ResponseAreaTub { const parsed = this.answerSchema.safeParse(props.answer) const validAnswer = parsed.success ? parsed.data : defaultFSA - // this.response = validAnswer - /* ---------- Extract submitted feedback ---------- */ const submittedFeedback: FSAFeedback | null = (() => { - const raw = props.feedback?.feedback + // since the props.feedback is a union of picks + if (!props.feedback || !('feedback' in props.feedback)) return null + const raw = props.feedback.feedback if (!raw) return null try { @@ -85,7 +84,7 @@ export class FSAResponseAreaTub extends ResponseAreaTub { this.previewFeedback = preview this.phase = CheckPhase.PreviewError } else { - this.previewFeedback = null // 🔥 THIS IS THE KEY + this.previewFeedback = null this.phase = CheckPhase.Idle } }} From eb36f17d9c8506c18a53e38fd4000219d1a4ed4b Mon Sep 17 00:00:00 2001 From: everythingfades Date: Wed, 4 Mar 2026 13:30:43 +0000 Subject: [PATCH 25/29] fix: unify CY display, added comments to avoid confusion --- src/types/FSA/FSA.component.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/types/FSA/FSA.component.tsx b/src/types/FSA/FSA.component.tsx index 1423a6b..8246f24 100644 --- a/src/types/FSA/FSA.component.tsx +++ b/src/types/FSA/FSA.component.tsx @@ -38,6 +38,10 @@ const NEW_NODE_OFFSET_Y = 100 /** Range (px) added to the base offset via Math.random(). */ const NEW_NODE_RANDOM_RANGE = 300 /** Config for Cytoscape */ +// please take special notice to the data(label) +// this means the cy id for node and edges are just for making them different, it is never passed in the data +// the displayed name will always be the data.label field +// as an analogy, think of the id as the SQL primary key, and the data.label is the stuff really displayed const CY_CONFIG = (containerRef: React.MutableRefObject): cytoscape.CytoscapeOptions => { return { container: containerRef.current, @@ -46,7 +50,7 @@ const CY_CONFIG = (containerRef: React.MutableRefObject): { selector: 'node', style: { - label: 'data(displayLabel)', + label: 'data(label)', 'text-valign': 'center', 'text-halign': 'center', width: 50, @@ -320,7 +324,7 @@ export const FSAInput: React.FC = ({ const id = `q${cy.nodes().length}` cy.add({ group: 'nodes', - data: { id, displayLabel: id }, + data: { id, label: id }, position: { x: bounds.center.x, y: bounds.center.y, @@ -433,7 +437,7 @@ export const FSAInput: React.FC = ({ cy.add({ group: 'nodes', - data: { id, displayLabel: id }, + data: { id, label: id }, position: { x: NEW_NODE_OFFSET_X + Math.random() * NEW_NODE_RANDOM_RANGE, y: NEW_NODE_OFFSET_Y + Math.random() * NEW_NODE_RANDOM_RANGE, From 785e3fdb8406b7188410436f878a86d3b0550d6f Mon Sep 17 00:00:00 2001 From: everythingfades Date: Thu, 5 Mar 2026 17:57:44 +0000 Subject: [PATCH 26/29] fix: setting the prop answer as the source of truth --- externals/styles/fonts.tsx | 174 +++--- src/sandbox-component.tsx | 4 +- src/types/FSA/FSA.component.tsx | 540 +++++++++--------- src/types/FSA/TestGuide.md | 6 - .../FSA/components/ItemPropertiesPanel.tsx | 197 +++++-- src/types/FSA/index.tsx | 4 - src/types/FSA/styles.ts | 7 + src/types/index.ts | 2 +- 8 files changed, 526 insertions(+), 408 deletions(-) diff --git a/externals/styles/fonts.tsx b/externals/styles/fonts.tsx index a50408e..4cd056f 100644 --- a/externals/styles/fonts.tsx +++ b/externals/styles/fonts.tsx @@ -1,80 +1,102 @@ -import { Fira_Sans, Fira_Mono, Lato, Roboto } from 'next/font/google' +// import { Fira_Sans, Fira_Mono, Lato, Roboto } from 'next/font/google' -export const roboto = Roboto({ - subsets: ['latin'], - weight: ['400', '700'], - style: ['normal', 'italic'], - fallback: [ - '-apple-system', - 'BlinkMacSystemFont', - 'Segoe UI', - 'Roboto', - 'Helvetica Neue', - 'Arial', - 'sans-serif', - 'Apple Color Emoji', - 'Segoe UI Emoji', - 'Segoe UI Symbol', - ], - variable: '--font-roboto', - preload: false, -}) +// export const roboto = Roboto({ +// subsets: ['latin'], +// weight: ['400', '700'], +// style: ['normal', 'italic'], +// fallback: [ +// '-apple-system', +// 'BlinkMacSystemFont', +// 'Segoe UI', +// 'Roboto', +// 'Helvetica Neue', +// 'Arial', +// 'sans-serif', +// 'Apple Color Emoji', +// 'Segoe UI Emoji', +// 'Segoe UI Symbol', +// ], +// variable: '--font-roboto', +// preload: false, +// }) -export const firaSans = Fira_Sans({ - subsets: ['latin'], - weight: ['300', '400', '500', '600', '700'], - style: ['normal', 'italic'], - fallback: [ - '-apple-system', - 'BlinkMacSystemFont', - 'Segoe UI', - 'Roboto', - 'Helvetica Neue', - 'Arial', - 'sans-serif', - 'Apple Color Emoji', - 'Segoe UI Emoji', - 'Segoe UI Symbol', - ], - variable: '--font-fira-sans', - preload: false, -}) +// export const firaSans = Fira_Sans({ +// subsets: ['latin'], +// weight: ['300', '400', '500', '600', '700'], +// style: ['normal', 'italic'], +// fallback: [ +// '-apple-system', +// 'BlinkMacSystemFont', +// 'Segoe UI', +// 'Roboto', +// 'Helvetica Neue', +// 'Arial', +// 'sans-serif', +// 'Apple Color Emoji', +// 'Segoe UI Emoji', +// 'Segoe UI Symbol', +// ], +// variable: '--font-fira-sans', +// preload: false, +// }) -export const firaMono = Fira_Mono({ - subsets: ['latin'], - weight: ['400'], - fallback: [ - '-apple-system', - 'BlinkMacSystemFont', - 'Segoe UI', - 'Roboto', - 'Helvetica Neue', - 'Arial', - 'sans-serif', - 'Apple Color Emoji', - 'Segoe UI Emoji', - 'Segoe UI Symbol', - ], - variable: '--font-fira-mono', - preload: false, -}) +// export const firaMono = Fira_Mono({ +// subsets: ['latin'], +// weight: ['400'], +// fallback: [ +// '-apple-system', +// 'BlinkMacSystemFont', +// 'Segoe UI', +// 'Roboto', +// 'Helvetica Neue', +// 'Arial', +// 'sans-serif', +// 'Apple Color Emoji', +// 'Segoe UI Emoji', +// 'Segoe UI Symbol', +// ], +// variable: '--font-fira-mono', +// preload: false, +// }) -export const lato = Lato({ - subsets: ['latin'], - weight: ['400'], - style: ['normal', 'italic'], - fallback: [ - '-apple-system', - 'BlinkMacSystemFont', - 'Segoe UI', - 'Roboto', - 'Helvetica Neue', - 'Arial', - 'sans-serif', - 'Apple Color Emoji', - 'Segoe UI Emoji', - 'Segoe UI Symbol', - ], - variable: '--font-lato', - preload: false, -}) +// export const lato = Lato({ +// subsets: ['latin'], +// weight: ['400'], +// style: ['normal', 'italic'], +// fallback: [ +// '-apple-system', +// 'BlinkMacSystemFont', +// 'Segoe UI', +// 'Roboto', +// 'Helvetica Neue', +// 'Arial', +// 'sans-serif', +// 'Apple Color Emoji', +// 'Segoe UI Emoji', +// 'Segoe UI Symbol', +// ], +// variable: '--font-lato', +// preload: false, +// }) + +// please see the sandbox issue #2 for this + +export const firaSans = { + className: 'font-fira-sans', + style: { fontFamily: 'Fira Sans, sans-serif' }, +} + +export const firaMono = { + className: 'font-fira-mono', + style: { fontFamily: 'Fira Mono, monospace' }, +} + +export const lato = { + className: 'font-lato', + style: { fontFamily: 'Lato, sans-serif' }, +} + +export const roboto = { + className: 'font-roboto', + style: { fontFamily: 'Roboto, sans-serif' }, +} \ No newline at end of file diff --git a/src/sandbox-component.tsx b/src/sandbox-component.tsx index ab01af5..6b31d6f 100644 --- a/src/sandbox-component.tsx +++ b/src/sandbox-component.tsx @@ -1,4 +1,6 @@ import { ThemeProvider } from '@styles/minimal/theme-provider' + +import { FSAResponseAreaTub } from './types/FSA' import { SandboxResponseAreaTub } from './types/Sandbox/index' function ResponseAreaInputWrapper({ children }: { children: React.ReactNode }) { @@ -6,7 +8,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 FSAResponseAreaTub { constructor() { super() diff --git a/src/types/FSA/FSA.component.tsx b/src/types/FSA/FSA.component.tsx index 8246f24..27a4531 100644 --- a/src/types/FSA/FSA.component.tsx +++ b/src/types/FSA/FSA.component.tsx @@ -37,10 +37,10 @@ const NEW_NODE_OFFSET_X = 100 const NEW_NODE_OFFSET_Y = 100 /** Range (px) added to the base offset via Math.random(). */ const NEW_NODE_RANDOM_RANGE = 300 -/** Config for Cytoscape */ + // please take special notice to the data(label) // this means the cy id for node and edges are just for making them different, it is never passed in the data -// the displayed name will always be the data.label field +// the displayed name will always be the data(label) field // as an analogy, think of the id as the SQL primary key, and the data.label is the stuff really displayed const CY_CONFIG = (containerRef: React.MutableRefObject): cytoscape.CytoscapeOptions => { return { @@ -104,21 +104,48 @@ const CY_CONFIG = (containerRef: React.MutableRefObject): width: 3, }, }, - { - selector: 'edge.epsilon', - style: { - 'line-style': 'dashed', - 'line-color': '#6a1b9a', - 'target-arrow-color': '#6a1b9a', - width: 3, - 'font-style': - 'italic', - }, + { + selector: 'edge.epsilon', + style: { + 'line-style': 'dashed', + 'line-color': '#6a1b9a', + 'target-arrow-color': '#6a1b9a', + width: 3, + 'font-style': 'italic', + }, }, ], } } +/* -------------------- Helpers -------------------- */ + +/** + * Parse a transition string of the form "from|symbol|to" into its parts. + * Returns null if the string is malformed. + */ +const parseTransition = ( + t: string, +): { from: string; symbol: string; to: string } | null => { + const parts = t.split('|') + if (parts.length !== 3) return null + const [from, symbol, to] = parts + // ideally this should never happen, as we cannot alter the data manually and + // everything in code should be kept safe + // but writing this for type safety + // else ts cries + if (!(from && to && symbol)) return null + return { from, to, symbol } +} + +/** + * Derive a stable Cytoscape edge id from a transition string so that + * reconciliation can match existing edges without re-adding them. + */ +const edgeIdFromTransition = (t: string): string => `e|${t}` + +/* -------------------- Component -------------------- */ + export const FSAInput: React.FC = ({ answer, handleChange, @@ -137,71 +164,197 @@ export const FSAInput: React.FC = ({ const pathRef = useRef(null) const startPointRef = useRef(null) - const [selectedNode, setSelectedNode] = - useState(null) - const [selectedEdge, setSelectedEdge] = - useState(null) + const [selectedNode, setSelectedNode] = useState(null) + const [selectedEdge, setSelectedEdge] = useState(null) const [drawMode, setDrawMode] = useState(false) const [fromNode, setFromNode] = useState(null) const [isDrawing, setIsDrawing] = useState(false) - const [config, setConfig] = - useState(DEFAULT_FSA_CONFIG) - const [configOpen, setConfigOpen] = - useState(true) + const [config, setConfig] = useState(DEFAULT_FSA_CONFIG) + const [configOpen, setConfigOpen] = useState(true) /* -------------------- init cytoscape -------------------- */ useEffect(() => { if (!containerRef.current) return const cy: Core = cytoscape(CY_CONFIG(containerRef)) - cyRef.current = cy return () => cy.destroy() }, []) - /* -------------------- node/edge handlers -------------------- */ - useEffect(() => { - const cy = cyRef.current - if (!cy) return + /* -------------------- Reconcile answer → Cytoscape -------------------- */ + /** + * This is the ONLY place Cytoscape nodes/edges are added or removed. + * It diffs `answer` against what Cytoscape currently holds and applies + * the minimum set of mutations, so existing node positions are preserved. + * + * Positions are intentionally NOT stored in `answer`; newly-added nodes + * are placed at a random offset so they don't stack on top of each other. + */ + useEffect(() => { + const cy = cyRef.current + if (!cy) return + + // ---- Nodes ---- + const cyNodeIds = new Set(cy.nodes().map((n) => n.id())) + const answerStateIds = new Set(answer.states) + + // Add nodes present in answer but missing from cy + for (const id of answerStateIds) { + if (!cyNodeIds.has(id)) { + cy.add({ + group: 'nodes', + data: { id, label: id }, + position: { + x: NEW_NODE_OFFSET_X + Math.random() * NEW_NODE_RANDOM_RANGE, + y: NEW_NODE_OFFSET_Y + Math.random() * NEW_NODE_RANDOM_RANGE, + }, + }) + } + } + + // Remove nodes present in cy but absent from answer + for (const id of cyNodeIds) { + if (!answerStateIds.has(id)) { + cy.$id(id).remove() + } + } + + // ---- Edges ---- + const cyEdgeIds = new Set(cy.edges().map((e) => e.id())) + const answerEdgeIds = new Set(answer.transitions.map(edgeIdFromTransition)) + + // Add edges present in answer but missing from cy + for (const t of answer.transitions) { + const id = edgeIdFromTransition(t) + if (cyEdgeIds.has(id)) continue + + const parsed = parseTransition(t) + if (!parsed) continue + + const { from, symbol, to } = parsed + + // Only add the edge if both endpoint nodes exist (guard against stale data) + if (!cy.$id(from).length || !cy.$id(to).length) continue + + cy.add({ + group: 'edges', + data: { id, source: from, target: to, label: symbol }, + }) + + // Apply epsilon styling immediately + if (symbol === 'ε' || symbol.toLowerCase() === 'epsilon' || symbol === '') { + cy.$id(id).addClass('epsilon') + } + } + + // Remove edges present in cy but absent from answer + for (const id of cyEdgeIds) { + if (!answerEdgeIds.has(id)) { + cy.$id(id).remove() + } + } + }, [answer.states, answer.transitions]) + + /* -------------------- apply initial / accept styling -------------------- */ + useEffect(() => { + const cy = cyRef.current + if (!cy) return + cy.nodes().removeClass('initial accept') + if (answer.initial_state) { + cy.$id(answer.initial_state).addClass('initial') + } + for (const id of answer.accept_states) { + cy.$id(id).addClass('accept') + } + }, [answer.initial_state, answer.accept_states]) + + /* -------------------- apply feedback highlights -------------------- */ + useEffect(() => { + const cy = cyRef.current + if (!cy) return + cy.nodes().removeClass('error-highlight') + cy.edges().removeClass('error-highlight') + if (!feedback || !feedback.errors) return + const highlights = feedback.errors.map((e) => e.highlight).filter(Boolean) + for (const h of highlights) { + if (!h) continue + switch (h.type) { + case 'state': + case 'initial_state': + case 'accept_state': { + if (h.state_id) { + cy.$id(h.state_id).addClass('error-highlight') + } + break + } + case 'transition': { + cy.edges() + .filter((e) => { + const fromOk = h.from_state ? e.source().id() === h.from_state : true + const toOk = h.to_state ? e.target().id() === h.to_state : true + const symOk = h.symbol ? e.data('label') === h.symbol : true + return fromOk && toOk && symOk + }) + .addClass('error-highlight') + break + } + case 'alphabet_symbol': { + if (h.symbol) { + cy.edges() + .filter((e) => e.data('label') === h.symbol) + .addClass('error-highlight') + } + break + } + } + } + }, [feedback]) + + /* -------------------- node/edge tap handlers -------------------- */ + useEffect(() => { + const cy = cyRef.current + if (!cy) return + const tapNode = (e: cytoscape.EventObject): void => { - 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: 'edge' - }, - }) - cy.nodes().removeClass('edge-source') - setDrawMode(false) - setFromNode(null) - syncToBackend() - } - return - } - setSelectedNode(node) - setSelectedEdge(null) - } - const tapEdge = (e: cytoscape.EventObject): void => { - setSelectedEdge(e.target as EdgeSingular) - setSelectedNode(null) - } - cy.on('tap', 'node', tapNode) - cy.on('tap', 'edge', tapEdge) - return () => { - cy.off('tap', 'node', tapNode) - cy.off('tap', 'edge', tapEdge) - } - }, [drawMode, fromNode]) + const node = e.target as NodeSingular + + if (drawMode) { + if (!fromNode) { + setFromNode(node.id()) + node.addClass('edge-source') + } else { + // Add the new edge to answer — reconciliation will add it to cy + const newTransition = `${fromNode}|edge|${node.id()}` + handleChange({ + ...answer, + transitions: [...answer.transitions, newTransition], + alphabet: Array.from(new Set([...answer.alphabet, 'edge'])), + }) + cy.nodes().removeClass('edge-source') + setDrawMode(false) + setFromNode(null) + } + return + } + + setSelectedNode(node) + setSelectedEdge(null) + } + + const tapEdge = (e: cytoscape.EventObject): void => { + setSelectedEdge(e.target as EdgeSingular) + setSelectedNode(null) + } + + cy.on('tap', 'node', tapNode) + cy.on('tap', 'edge', tapEdge) + return () => { + cy.off('tap', 'node', tapNode) + cy.off('tap', 'edge', tapEdge) + } + }, [drawMode, fromNode, answer]) /* -------------------- Paper setup -------------------- */ useEffect(() => { @@ -213,9 +366,7 @@ export const FSAInput: React.FC = ({ const updateSize = () => { if (!containerRef.current || !canvas) return - const { width, height } = - containerRef.current.getBoundingClientRect() - + const { width, height } = containerRef.current.getBoundingClientRect() canvas.width = width canvas.height = height project.view.viewSize = new paper.Size(width, height) @@ -224,8 +375,7 @@ export const FSAInput: React.FC = ({ updateSize() const resizeObserver = new ResizeObserver(updateSize) - if (containerRef.current) - resizeObserver.observe(containerRef.current) + if (containerRef.current) resizeObserver.observe(containerRef.current) return () => { resizeObserver.disconnect() @@ -236,15 +386,12 @@ export const FSAInput: React.FC = ({ /* -------------------- Drawing Handlers -------------------- */ - const handlePointerDown = ( - e: React.PointerEvent, - ) => { + const handlePointerDown = (e: React.PointerEvent) => { if (!drawMode || !paperProjectRef.current) return paperProjectRef.current.activate() - const rect = - drawCanvasRef.current?.getBoundingClientRect() + const rect = drawCanvasRef.current?.getBoundingClientRect() if (!rect) return const x = e.clientX - rect.left @@ -262,54 +409,32 @@ export const FSAInput: React.FC = ({ setIsDrawing(true) } - const handlePointerMove = ( - e: React.PointerEvent, - ) => { - if (!drawMode || !isDrawing || !pathRef.current) - return + const handlePointerMove = (e: React.PointerEvent) => { + if (!drawMode || !isDrawing || !pathRef.current) return - const rect = - drawCanvasRef.current?.getBoundingClientRect() + const rect = drawCanvasRef.current?.getBoundingClientRect() if (!rect) return pathRef.current.add( - new paper.Point( - e.clientX - rect.left, - e.clientY - rect.top, - ), + new paper.Point(e.clientX - rect.left, e.clientY - rect.top), ) } - const handlePointerUp = ( - e: React.PointerEvent, - ) => { - if ( - !drawMode || - !isDrawing || - !pathRef.current || - !startPointRef.current - ) { + const handlePointerUp = (e: React.PointerEvent) => { + if (!drawMode || !isDrawing || !pathRef.current || !startPointRef.current) { setIsDrawing(false) return } - const rect = - drawCanvasRef.current?.getBoundingClientRect() + const rect = drawCanvasRef.current?.getBoundingClientRect() if (!rect) return - const endPoint = new paper.Point( - e.clientX - rect.left, - e.clientY - rect.top, - ) + const endPoint = new paper.Point(e.clientX - rect.left, e.clientY - rect.top) const bounds = pathRef.current.bounds - const diameter = Math.max( - bounds.width, - bounds.height, - ) + const diameter = Math.max(bounds.width, bounds.height) const strokeLength = pathRef.current.length - const distance = - startPointRef.current.getDistance(endPoint) + const distance = startPointRef.current.getDistance(endPoint) const circumference = Math.PI * diameter const isCircle = @@ -321,24 +446,29 @@ export const FSAInput: React.FC = ({ if (cy) { if (isCircle) { - const id = `q${cy.nodes().length}` + // New state: generate a unique id and push into answer + const existingIds = new Set(answer.states) + let n = cy.nodes().length + let id = `q${n}` + while (existingIds.has(id)) id = `q${++n}` + + handleChange({ + ...answer, + states: [...answer.states, id], + }) + // Cytoscape placement: the reconciliation effect will add the node, + // but we want it at the drawn position — so we add it directly here + // and let reconciliation skip it (it already exists by then). cy.add({ group: 'nodes', data: { id, label: id }, - position: { - x: bounds.center.x, - y: bounds.center.y, - }, + position: { x: bounds.center.x, y: bounds.center.y }, }) - syncToBackend() } else { - const findClosest = ( - x: number, - y: number - ): NodeSingular | null => { + // New edge: snap start/end to nearest nodes + const findClosest = (x: number, y: number): NodeSingular | null => { let min = Infinity let closest: NodeSingular | null = null - cy.nodes().forEach((node) => { const pos = node.renderedPosition() const dist = Math.hypot(pos.x - x, pos.y - y) @@ -347,35 +477,19 @@ export const FSAInput: React.FC = ({ closest = node } }) - return min < NODE_SNAP_DISTANCE_PX ? closest : null } + const startNode = findClosest(startPointRef.current.x, startPointRef.current.y) + const endNode = findClosest(endPoint.x, endPoint.y) - const startNode = findClosest( - startPointRef.current.x, - startPointRef.current.y, - ) - const endNode = findClosest( - endPoint.x, - endPoint.y, - ) - - if ( - startNode && - endNode && - startNode.id() !== endNode.id() - ) { - cy.add({ - group: 'edges', - data: { - id: `e-${startNode.id()}-${endNode.id()}-${Date.now()}`, - source: startNode.id(), - target: endNode.id(), - label: 'edge', - }, + if (startNode && endNode) { + const newTransition = `${startNode.id()}|edge|${endNode.id()}` + handleChange({ + ...answer, + transitions: [...answer.transitions, newTransition], + alphabet: Array.from(new Set([...answer.alphabet, 'edge'])), }) - syncToBackend() } } } @@ -393,115 +507,28 @@ export const FSAInput: React.FC = ({ setIsDrawing(false) } - /* -------------------- Sync with React states -------------------- */ - - /** - * Reads the current Cytoscape graph and converts it into the - * flattened `FSA` data structure expected by the response-area - * pipeline. Called after every graph mutation (add/remove - * node or edge) so the parent component always holds an - * up-to-date serialisable snapshot of the automaton. - */ - const syncToBackend = (): void => { - const cy = cyRef.current - if (!cy) return - - const fsa: FSA = { - states: cy.nodes().map((n) => n.id()), - transitions: cy.edges().map( - (e) => - `${e.source().id()}|${e.data( - 'label', - )}|${e.target().id()}`, - ), - initial_state: answer.initial_state, - accept_states: answer.accept_states, - alphabet: Array.from( - new Set( - cy.edges().map((e) => - String(e.data('label')), - ), - ), - ), - config: JSON.stringify(config), - } - - handleChange(fsa) - } + /* -------------------- Public mutations (answer-driven) -------------------- */ const addState = (): void => { + const existingIds = new Set(answer.states) const cy = cyRef.current - if (!cy) return - - const id = `q${cy.nodes().length}` + let n = cy ? cy.nodes().length : answer.states.length + let id = `q${n}` + while (existingIds.has(id)) id = `q${++n}` + + // Position chosen now so we can place it immediately in cy; + // reconciliation will see the node already exists and skip re-adding. + const position = { + x: NEW_NODE_OFFSET_X + Math.random() * NEW_NODE_RANDOM_RANGE, + y: NEW_NODE_OFFSET_Y + Math.random() * NEW_NODE_RANDOM_RANGE, + } - cy.add({ - group: 'nodes', - data: { id, label: id }, - position: { - x: NEW_NODE_OFFSET_X + Math.random() * NEW_NODE_RANDOM_RANGE, - y: NEW_NODE_OFFSET_Y + Math.random() * NEW_NODE_RANDOM_RANGE, - }, - }) + // Add to cy first so we control the position + cy?.add({ group: 'nodes', data: { id, label: id }, position }) - syncToBackend() + handleChange({ ...answer, states: [...answer.states, id] }) } - /* -------------------- apply initial / accept styling -------------------- */ - useEffect(() => { - const cy = cyRef.current - if (!cy) return - cy.nodes().removeClass('initial accept') - if (answer.initial_state) { - cy.$id(answer.initial_state).addClass('initial') - } - for (const id of answer.accept_states) { - cy.$id(id).addClass('accept') - } - }, [answer.initial_state, answer.accept_states]) - /* -------------------- apply feedback highlights -------------------- */ - useEffect(() => { - const cy = cyRef.current - if (!cy) return - cy.nodes().removeClass('error-highlight') - cy.edges().removeClass('error-highlight') - if (!feedback || !feedback.errors) return - const highlights = feedback.errors - .map((e) => e.highlight) - .filter(Boolean) - for (const h of highlights) { - if (!h) continue - switch (h.type) { - case 'state': - case 'initial_state': - case 'accept_state': { - if (h.state_id) { - cy.$id(h.state_id).addClass('error-highlight') - } - break - } - case 'transition': { - cy.edges() - .filter((e) => { - const fromOk = h.from_state ? e.source().id() === h.from_state : true - const toOk = h.to_state ? e.target().id() === h.to_state : true - const symOk = h.symbol ? e.data('label') === h.symbol : true - return fromOk && toOk && symOk - }) - .addClass('error-highlight') - break - } - case 'alphabet_symbol': { - if (h.symbol) { - cy.edges() - .filter((e) => e.data('label') === h.symbol) - .addClass('error-highlight') - } - break - } - } - } - }, [feedback]) /* -------------------- Render -------------------- */ return ( @@ -516,27 +543,19 @@ export const FSAInput: React.FC = ({ setSelectedNode={setSelectedNode} selectedEdge={selectedEdge} setSelectedEdge={setSelectedEdge} - syncToBackend={syncToBackend} handleChange={handleChange} answer={answer} feedback={feedback} - phase={phase} - addState={addState} - pathRef={pathRef} + phase={phase} + addState={addState} + pathRef={pathRef} /> -
+
= ({ style={{ position: 'absolute', inset: 0, - pointerEvents: drawMode - ? 'auto' - : 'none', - cursor: drawMode - ? 'crosshair' - : 'default', + pointerEvents: drawMode ? 'auto' : 'none', + cursor: drawMode ? 'crosshair' : 'default', zIndex: 10, }} onPointerDown={handlePointerDown} @@ -563,10 +578,7 @@ export const FSAInput: React.FC = ({ { - handleChange({ - ...answer, - config: JSON.stringify(val), - }) + handleChange({ ...answer, config: JSON.stringify(val) }) setConfig(val) }} onClose={() => setConfigOpen(false)} @@ -575,4 +587,4 @@ export const FSAInput: React.FC = ({ )}
) -} +} \ No newline at end of file diff --git a/src/types/FSA/TestGuide.md b/src/types/FSA/TestGuide.md index 105382a..d42cb77 100644 --- a/src/types/FSA/TestGuide.md +++ b/src/types/FSA/TestGuide.md @@ -21,12 +21,6 @@ This guide is for testing the **FSA** response area only. yarn dev ``` - If you only want to iterate in the local Vite mode, use: - - ```bash - yarn dev:fsa - ``` - 3. In Lambda Feedback sandbox settings, point to your local URL and ensure response type is `FSA`. ## Quick Manual Test Flow diff --git a/src/types/FSA/components/ItemPropertiesPanel.tsx b/src/types/FSA/components/ItemPropertiesPanel.tsx index 47610e0..b3bde57 100644 --- a/src/types/FSA/components/ItemPropertiesPanel.tsx +++ b/src/types/FSA/components/ItemPropertiesPanel.tsx @@ -24,7 +24,6 @@ interface ItemPropertiesPanelProps { answer: FSA handleChange: (fsa: FSA) => void - syncToBackend: () => void feedback: FSAFeedback | null phase: CheckPhase @@ -44,11 +43,127 @@ export default function ItemPropertiesPanel({ setSelectedEdge, answer, handleChange, - syncToBackend, feedback, phase, - pathRef + pathRef, }: ItemPropertiesPanelProps): JSX.Element { + + /* -------------------- Derived ids -------------------- */ + const selectedNodeId = selectedNode?.id() ?? null + const selectedEdgeId = selectedEdge?.id() ?? null + + /* -------------------- Helpers -------------------- */ + + /** + * Parse the stable edge id back into "from|symbol|to" transition string. + * Edge ids are of the form "e|from|symbol|to". + */ + const transitionFromEdgeId = (edgeId: string): string => + edgeId.startsWith('e|') ? edgeId.slice(2) : edgeId + + const parseTransition = (t: string) => { + const parts = t.split('|') + return parts.length === 3 + ? { from: parts[0], symbol: parts[1], to: parts[2] } + : null + } + + /* -------------------- Delete -------------------- */ + + const handleDelete = () => { + if (selectedNodeId) { + // Remove state and all transitions that reference it + const newStates = answer.states.filter((s) => s !== selectedNodeId) + const newTransitions = answer.transitions.filter((t) => { + const p = parseTransition(t) + return p && p.from !== selectedNodeId && p.to !== selectedNodeId + }) + const newAcceptStates = answer.accept_states.filter((s) => s !== selectedNodeId) + const newInitial = + answer.initial_state === selectedNodeId ? '' : answer.initial_state + + handleChange({ + ...answer, + states: newStates, + transitions: newTransitions, + accept_states: newAcceptStates, + initial_state: newInitial, + alphabet: deriveAlphabet(newTransitions), + }) + setSelectedNode(null) + } + + if (selectedEdgeId) { + const transition = transitionFromEdgeId(selectedEdgeId) + const newTransitions = answer.transitions.filter((t) => t !== transition) + handleChange({ + ...answer, + transitions: newTransitions, + alphabet: deriveAlphabet(newTransitions), + }) + setSelectedEdge(null) + } + } + + /** Re-derive alphabet from the current transition list. */ + const deriveAlphabet = (transitions: string[]): string[] => + Array.from( + new Set( + transitions + .map((t) => parseTransition(t)?.symbol ?? '') + .filter(Boolean), + ), + ) + + /* -------------------- Node label edit -------------------- */ + + const handleNodeLabelChange = (value: string) => { + if (!selectedNodeId) return + // Update the label in cy directly (it is display-only, not stored in answer) + selectedNode?.data('label', value) + // Note: label is not part of FSA answer schema — it lives only in cy. + // If your FSA type does store labels, call handleChange here. + } + + /* -------------------- Edge symbol edit -------------------- */ + + const handleEdgeSymbolChange = (value: string) => { + if (!selectedEdgeId || !selectedEdge) return + + const oldTransition = transitionFromEdgeId(selectedEdgeId) + const parsed = parseTransition(oldTransition) + if (!parsed) return + + const newSymbol = value.trim() + const newTransition = `${parsed.from}|${newSymbol}|${parsed.to}` + const newEdgeId = `e|${newTransition}` + + // Update cy element in place (id can't change, so we update the label data + // and re-derive the answer transition list) + selectedEdge.data('label', newSymbol) + if (newSymbol === 'ε' || newSymbol.toLowerCase() === 'epsilon' || newSymbol === '') { + selectedEdge.addClass('epsilon') + } else { + selectedEdge.removeClass('epsilon') + } + + // Replace the old transition string in answer + const newTransitions = answer.transitions.map((t) => + t === oldTransition ? newTransition : t, + ) + + handleChange({ + ...answer, + transitions: newTransitions, + alphabet: deriveAlphabet(newTransitions), + }) + + // Keep selectedEdge in sync — the edge element itself hasn't been removed, + // only its data changed, so no re-selection needed. + } + + /* -------------------- Render -------------------- */ + return (
Item Properties
@@ -69,23 +184,22 @@ export default function ItemPropertiesPanel({ Fit to Screen -
@@ -110,13 +221,14 @@ export default function ItemPropertiesPanel({
{ handleChange({ ...answer, - initial_state: e.target.checked ? selectedNode.id() : answer.initial_state, + initial_state: e.target.checked + ? selectedNodeId! + : answer.initial_state, }) - // syncToBackend() }} /> @@ -126,17 +238,14 @@ export default function ItemPropertiesPanel({
{ handleChange({ ...answer, accept_states: e.target.checked - ? [...answer.accept_states, selectedNode.id()] - : answer.accept_states.filter( - (id) => id !== selectedNode.id(), - ), + ? [...answer.accept_states, selectedNodeId!] + : answer.accept_states.filter((id) => id !== selectedNodeId), }) - // syncToBackend() }} /> @@ -151,43 +260,19 @@ export default function ItemPropertiesPanel({ { - const value = e.target.value.trim() - - selectedEdge.data('label', value) - - if (value === 'ε' || value.toLowerCase() === 'epsilon' || value === '') { - selectedEdge.addClass('epsilon') - } else { - selectedEdge.removeClass('epsilon') - } - - syncToBackend() - }} + onChange={(e) => handleEdgeSymbolChange(e.target.value)} />
)} {/* -------------------- Delete -------------------- */} {(selectedNode || selectedEdge) && ( - )} - + +
) -} +} \ No newline at end of file diff --git a/src/types/FSA/index.tsx b/src/types/FSA/index.tsx index 7f4abdc..ed3e447 100644 --- a/src/types/FSA/index.tsx +++ b/src/types/FSA/index.tsx @@ -1,6 +1,3 @@ -// import React, { useMemo } from 'react' -// import { z } from 'zod' - import { BaseResponseAreaProps, @@ -21,7 +18,6 @@ export class FSAResponseAreaTub extends ResponseAreaTub { private previewFeedback: FSAFeedback | null = null private phase: CheckPhase = CheckPhase.Idle - // private response: FSA | null = null public readonly delegateFeedback = false public readonly delegateLivePreview = true diff --git a/src/types/FSA/styles.ts b/src/types/FSA/styles.ts index 84b9b17..0359863 100644 --- a/src/types/FSA/styles.ts +++ b/src/types/FSA/styles.ts @@ -144,6 +144,13 @@ export const useLocalStyles = makeStyles()((theme) => ({ borderRadius: 4, cursor: 'pointer', }, + deleteButton: { + padding: '6px 10px', + backgroundColor: '#fff', + border: '1px solid #ccc', + borderRadius: 4, + cursor: 'pointer', + }, epsilonButton: { marginTop: 6, padding: '4px 8px', diff --git a/src/types/index.ts b/src/types/index.ts index 43ecaa9..781b476 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -29,7 +29,7 @@ export const supportedResponseTypes = [ 'ESSAY', 'CODE', 'MILKDOWN', - 'FSA' + 'FSA', 'LIKERT', 'MATH_SINGLE_LINE', 'MATH_MULTI_LINES', From 14303e511c9017667890e3498d6b8ffb4411d394 Mon Sep 17 00:00:00 2001 From: everythingfades Date: Thu, 5 Mar 2026 18:06:26 +0000 Subject: [PATCH 27/29] fix: removed utils.ts --- src/types/FSA/utils.ts | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 src/types/FSA/utils.ts diff --git a/src/types/FSA/utils.ts b/src/types/FSA/utils.ts deleted file mode 100644 index 5654792..0000000 --- a/src/types/FSA/utils.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { CheckPhase, FSAFeedback } from "./type" - -export function mergeFeedback( - feedback: FSAFeedback | null, - previewFeedback: FSAFeedback | null, - phase: CheckPhase, -): FSAFeedback | null { - if (phase === CheckPhase.PreviewError) { - return previewFeedback - } - - return feedback -} \ No newline at end of file From dfe85a31d1daf4b162eab70ad25c1f91744dced6 Mon Sep 17 00:00:00 2001 From: everythingfades Date: Thu, 5 Mar 2026 18:08:46 +0000 Subject: [PATCH 28/29] fix: comments in the index.tsx, please note this wont be the final version as the preview needs complete rewrite --- src/types/FSA/index.tsx | 42 +++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/src/types/FSA/index.tsx b/src/types/FSA/index.tsx index ed3e447..40ea151 100644 --- a/src/types/FSA/index.tsx +++ b/src/types/FSA/index.tsx @@ -1,4 +1,3 @@ - import { BaseResponseAreaProps, BaseResponseAreaWizardProps, @@ -16,10 +15,17 @@ export class FSAResponseAreaTub extends ResponseAreaTub { protected answerSchema = fsaAnswerSchema protected answer: FSA = defaultFSA + // Holds validation feedback from the live preview pass (before submission). + // Null means the current answer is structurally valid. private previewFeedback: FSAFeedback | null = null + + // Tracks which stage of the check lifecycle the component is in, + // so FSAInput can render the appropriate UI affordances. private phase: CheckPhase = CheckPhase.Idle + // Feedback is handled locally via validateFSA rather than delegated to the platform. public readonly delegateFeedback = false + // Live preview is re-evaluated on every handleChange, so we own that loop too. public readonly delegateLivePreview = true initWithConfig = () => {} @@ -27,26 +33,29 @@ export class FSAResponseAreaTub extends ResponseAreaTub { /* -------------------- Custom Check -------------------- */ customCheck = () => { - // Block submission if preview validation fails + // If previewFeedback is still set, the answer failed live validation — + // block submission so the student must fix errors first. if (this.previewFeedback) { throw new Error('preview failed') } - // Preview passed — ensure it's cleared + // Live validation passed; nothing extra to do before the platform submits. this.previewFeedback = null } /* -------------------- Input -------------------- */ public InputComponent = (props: BaseResponseAreaProps): JSX.Element => { - // Ensure a valid FSA answer + // Guard against a malformed or missing answer (e.g. first render, corrupt state). const parsed = this.answerSchema.safeParse(props.answer) const validAnswer = parsed.success ? parsed.data : defaultFSA - /* ---------- Extract submitted feedback ---------- */ - + /* ---------- Extract submitted feedback ---------- + * props.feedback is a union type; we only care about the branch that + * carries a 'feedback' string, which is the platform's post-submission + * response. The string is formatted as "message
jsonPayload". + */ const submittedFeedback: FSAFeedback | null = (() => { - // since the props.feedback is a union of picks if (!props.feedback || !('feedback' in props.feedback)) return null const raw = props.feedback.feedback if (!raw) return null @@ -60,10 +69,11 @@ export class FSAResponseAreaTub extends ResponseAreaTub { } })() - /* ---------- Effective feedback ---------- */ - - const effectiveFeedback = - this.previewFeedback ?? submittedFeedback + /* ---------- Effective feedback ---------- + * previewFeedback (live) takes priority over submittedFeedback (post-submit) + * so the student sees real-time errors while editing. + */ + const effectiveFeedback = this.previewFeedback ?? submittedFeedback return ( { + // Propagate the new answer up to the platform. props.handleChange(val) + // Run live validation and update previewFeedback / phase so that + // FSAInput can highlight errors without waiting for a submission round-trip. const preview = validateFSA(val) if (preview.errors.length > 0) { @@ -91,6 +104,9 @@ export class FSAResponseAreaTub extends ResponseAreaTub { /* -------------------- Wizard -------------------- */ + // The wizard is the teacher-facing authoring view. Feedback is always null + // (teachers are constructing the answer, not being assessed), and the phase + // is fixed at Evaluated so the full graph UI is visible from the start. public WizardComponent = ( props: BaseResponseAreaWizardProps, ): JSX.Element => { @@ -101,6 +117,8 @@ export class FSAResponseAreaTub extends ResponseAreaTub { answer={this.answer} phase={CheckPhase.Evaluated} handleChange={(val: FSA) => { + // Keep the local answer mirror in sync so customCheck / re-renders + // always have the latest value without an extra props round-trip. this.answer = val props.handleChange({ responseType: this.responseType, @@ -111,4 +129,4 @@ export class FSAResponseAreaTub extends ResponseAreaTub { /> ) } -} +} \ No newline at end of file From aeebb8dcc74bad432b915cac10a0aefa3f16987f Mon Sep 17 00:00:00 2001 From: everythingfades Date: Fri, 6 Mar 2026 13:17:22 +0000 Subject: [PATCH 29/29] fix: fix label stale update --- .../FSA/components/ItemPropertiesPanel.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/types/FSA/components/ItemPropertiesPanel.tsx b/src/types/FSA/components/ItemPropertiesPanel.tsx index b3bde57..ac970d2 100644 --- a/src/types/FSA/components/ItemPropertiesPanel.tsx +++ b/src/types/FSA/components/ItemPropertiesPanel.tsx @@ -130,16 +130,21 @@ export default function ItemPropertiesPanel({ const handleEdgeSymbolChange = (value: string) => { if (!selectedEdgeId || !selectedEdge) return - const oldTransition = transitionFromEdgeId(selectedEdgeId) - const parsed = parseTransition(oldTransition) + // The edge id encodes the *original* from/to at creation time and never changes. + // We only use it to recover source and target — never the symbol. + const originalTransition = transitionFromEdgeId(selectedEdgeId) + const parsed = parseTransition(originalTransition) if (!parsed) return const newSymbol = value.trim() + + // Reconstruct the *current* transition string using the live cy label, + // not the id — because the symbol may have already been edited once. + const currentSymbol = selectedEdge.data('label') as string + const currentTransition = `${parsed.from}|${currentSymbol}|${parsed.to}` const newTransition = `${parsed.from}|${newSymbol}|${parsed.to}` - const newEdgeId = `e|${newTransition}` - // Update cy element in place (id can't change, so we update the label data - // and re-derive the answer transition list) + // Update the cy element label in place (id is immutable by design) selectedEdge.data('label', newSymbol) if (newSymbol === 'ε' || newSymbol.toLowerCase() === 'epsilon' || newSymbol === '') { selectedEdge.addClass('epsilon') @@ -147,9 +152,9 @@ export default function ItemPropertiesPanel({ selectedEdge.removeClass('epsilon') } - // Replace the old transition string in answer + // Replace the current transition string (not the original id-derived one) const newTransitions = answer.transitions.map((t) => - t === oldTransition ? newTransition : t, + t === currentTransition ? newTransition : t, ) handleChange({ @@ -157,9 +162,6 @@ export default function ItemPropertiesPanel({ transitions: newTransitions, alphabet: deriveAlphabet(newTransitions), }) - - // Keep selectedEdge in sync — the edge element itself hasn't been removed, - // only its data changed, so no re-selection needed. } /* -------------------- Render -------------------- */