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/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/package.json b/package.json index b7a8bf5..133ba19 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", @@ -35,6 +36,7 @@ "@nivo/core": "^0.88.0", "@nivo/line": "^0.88.0", "@nivo/pie": "^0.88.0", + "cytoscape": "^3.33.1", "axios": "^1.7.2", "date-fns": "^2.28.0", "firebase": "^11.0.1", @@ -49,6 +51,7 @@ "notistack": "^2.0.3", "react": "^18.3.1", "react-dom": "^18.3.1", + "reactflow": "^11.11.4", "react-dropzone": "^14.2.3", "react-hook-form": "^7.31.2", "react-image-crop": "^11.0.10", 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 new file mode 100644 index 0000000..27a4531 --- /dev/null +++ b/src/types/FSA/FSA.component.tsx @@ -0,0 +1,590 @@ +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' + +interface FSAInputProps { + answer: FSA + handleChange: (fsa: FSA) => void + feedback: FSAFeedback | null + phase: CheckPhase + 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 + +// 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, + layout: { name: 'preset' }, + style: [ + { + selector: 'node', + style: { + label: 'data(label)', + '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', + }, + }, + ], + } +} + +/* -------------------- 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, + feedback, + phase, + isTeacherMode, +}) => { + const { classes } = useLocalStyles() + + const cyRef = useRef(null) + const containerRef = useRef(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 [isDrawing, setIsDrawing] = useState(false) + + 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() + }, []) + + /* -------------------- 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 { + // 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(() => { + 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 > MIN_CIRCLE_DIAMETER_PX && + strokeLength > circumference * MIN_STROKE_CIRCUMFERENCE_RATIO && + distance < diameter * MAX_ENDPOINT_DISTANCE_RATIO + + const cy = cyRef.current + + if (cy) { + if (isCircle) { + // 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 }, + }) + } else { + // 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) + if (dist < min) { + min = dist + 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) + + if (startNode && endNode) { + const newTransition = `${startNode.id()}|edge|${endNode.id()}` + handleChange({ + ...answer, + transitions: [...answer.transitions, newTransition], + alphabet: Array.from(new Set([...answer.alphabet, 'edge'])), + }) + } + } + } + + pathRef.current.remove() + pathRef.current = null + startPointRef.current = null + setIsDrawing(false) + } + + const handlePointerLeave = () => { + if (pathRef.current) pathRef.current.remove() + pathRef.current = null + startPointRef.current = null + setIsDrawing(false) + } + + /* -------------------- Public mutations (answer-driven) -------------------- */ + + const addState = (): void => { + const existingIds = new Set(answer.states) + const cy = cyRef.current + 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, + } + + // Add to cy first so we control the position + cy?.add({ group: 'nodes', data: { id, label: id }, position }) + + handleChange({ ...answer, states: [...answer.states, id] }) + } + + /* -------------------- Render -------------------- */ + + return ( +
+ + +
+
+ + +
+ + {isTeacherMode && configOpen && ( + { + 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/README.md b/src/types/FSA/README.md new file mode 100644 index 0000000..495fe78 --- /dev/null +++ b/src/types/FSA/README.md @@ -0,0 +1,190 @@ +Here is a polished **README-style** version, structured for a repository: + +--- + +# FSA Editor Module + +A visual **Finite State Automata (FSA)** editor built for structured educational workflows. + +This module provides: + +* 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 + +--- + +# Overview + +The FSA Editor bridges: + +* A **flattened TypeScript/Zod frontend representation** +* A **nested backend automaton model** + +It enables users to construct automata visually while maintaining strict schema compatibility. + +--- + +# 1. Core Data Structures + +## Frontend Schema (`FSA`) + +To satisfy the two-level nesting restriction enforced by `jsonNestedSchema`, transitions are stored as flattened strings. + +```ts +export interface FSA { + states: string[]; + alphabet: string[]; + transitions: string[]; // "from_state|symbol|to_state" + initial_state: string; + accept_states: string[]; +} +``` + +### Transition Encoding + +Transitions use a pipe-separated format: + +``` +"from_state|symbol|to_state" +``` + +This guarantees compatibility with frontend validation. + +This type will be expanded in the backend with pydantic validation + +--- + +# 2. Editor Operations + +## 2.1 Adding & Editing States and Transitions + +### Draw Mode + +Users can enable **Draw Mode** to sketch directly on the canvas. + +The stroke will be red directly on the canvas + +* 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. + +--- + +### Manual Node Creation + +States can also be added manually (e.g., via UI controls or prompts). +This allows precise creation without gesture detection. + +--- + +### Selecting Nodes & Edges + +Clicking a node or edge: + +* Displays its properties in the **ItemPropertiesPanel** (left side). +* Allows editing of: + + * State name + * Transition label + * State type (initial/accepting) + +--- + +### Setting Initial & Accepting States + +Within the properties panel, a state can be marked as: + +* **Initial State** +* **Accepting State** + +Changes update immediately in the UI. + +--- + +## 2.2 UI Conventions + +The editor follows consistent visual rules: + +| 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) | + +These visual cues help users quickly identify automaton structure and validation issues. + +--- + +# 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..d42cb77 --- /dev/null +++ b/src/types/FSA/TestGuide.md @@ -0,0 +1,139 @@ +# 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 + ``` + +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 new file mode 100644 index 0000000..4eed86a --- /dev/null +++ b/src/types/FSA/components/ConfigPanel.tsx @@ -0,0 +1,130 @@ +import React from 'react' + +import { FSAConfig } from '../type' + +interface ConfigPanelProps { + config: FSAConfig + setConfig: (config: FSAConfig) => void + onClose: () => void + classes: Record +} + +export default function ConfigPanel({ + config, + setConfig, + onClose, + classes, +}: ConfigPanelProps) { + return ( +
+
+ Evaluation Settings + + ✕ + +
+ +
+ {/* evaluation_mode */} +
+ + +
+ + {/* expected_type */} +
+ + +
+ + {/* feedback_verbosity */} +
+ + +
+ + {/* check_minimality */} +
+ +
+ + {/* check_completeness */} +
+ +
+ + {/* highlight_errors */} +
+ +
+ + +
+
+ ) +} diff --git a/src/types/FSA/components/FSAFeedbackPanel.tsx b/src/types/FSA/components/FSAFeedbackPanel.tsx new file mode 100644 index 0000000..9a88188 --- /dev/null +++ b/src/types/FSA/components/FSAFeedbackPanel.tsx @@ -0,0 +1,255 @@ +import React from 'react' + +import { + CheckPhase, + type FSAFeedback, +} from '../type' + +interface FSAFeedbackPanelProps { + feedback: FSAFeedback | null + phase: CheckPhase +} + +export function FSAFeedbackPanel({ + feedback, + phase, +}: FSAFeedbackPanelProps) { + + + if (!feedback) { + return ( +
+ {phase === CheckPhase.PreviewError + ? 'Preview errors found' + : 'No feedback yet'} +
+ ) + } + + const safeFeedback = feedback + + return ( +
+ { + safeFeedback.errors.length != 0 ? +
+ { + phase == CheckPhase.PreviewError ? "Errors in Preview" : "Errors in Evaluation" + } +
+ : null + } + {/* ================= 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 new file mode 100644 index 0000000..ac970d2 --- /dev/null +++ b/src/types/FSA/components/ItemPropertiesPanel.tsx @@ -0,0 +1,280 @@ +import type { Core, NodeSingular, EdgeSingular } from 'cytoscape' +import React from 'react' + +import { CheckPhase, FSA, FSAFeedback } from '../type' + +import { FSAFeedbackPanel } from './FSAFeedbackPanel' + +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 + + feedback: FSAFeedback | null + phase: CheckPhase + + pathRef: React.MutableRefObject +} + +export default function ItemPropertiesPanel({ + cyRef, + classes, + addState, + drawMode, + setDrawMode, + setFromNode, + selectedNode, + setSelectedNode, + selectedEdge, + setSelectedEdge, + answer, + handleChange, + feedback, + phase, + 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 + + // 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}` + + // 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') + } else { + selectedEdge.removeClass('epsilon') + } + + // Replace the current transition string (not the original id-derived one) + const newTransitions = answer.transitions.map((t) => + t === currentTransition ? newTransition : t, + ) + + handleChange({ + ...answer, + transitions: newTransitions, + alphabet: deriveAlphabet(newTransitions), + }) + } + + /* -------------------- Render -------------------- */ + + return ( +
+
Item Properties
+ + {/* -------------------- Actions -------------------- */} + + + + + + + {/* -------------------- Node Properties -------------------- */} + {selectedNode && ( + <> +
+ + handleNodeLabelChange(e.target.value)} + /> +
+ + {/* Initial State (unique) */} +
+ { + handleChange({ + ...answer, + initial_state: e.target.checked + ? selectedNodeId! + : answer.initial_state, + }) + }} + /> + +
+ + {/* Accepting State (multiple allowed) */} +
+ { + handleChange({ + ...answer, + accept_states: e.target.checked + ? [...answer.accept_states, selectedNodeId!] + : answer.accept_states.filter((id) => id !== selectedNodeId), + }) + }} + /> + +
+ + )} + + {/* -------------------- Edge Properties -------------------- */} + {selectedEdge && ( +
+ + 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 new file mode 100644 index 0000000..40ea151 --- /dev/null +++ b/src/types/FSA/index.tsx @@ -0,0 +1,132 @@ +import { + BaseResponseAreaProps, + BaseResponseAreaWizardProps, +} from '../base-props.type' +import { ResponseAreaTub } from '../response-area-tub' + +import { FSAInput } from './FSA.component' +import { fsaAnswerSchema, FSA, defaultFSA, FSAFeedback, CheckPhase } from './type' +import { validateFSA } from './validateFSA' + +export class FSAResponseAreaTub extends ResponseAreaTub { + public readonly responseType = 'FSA' + public readonly displayWideInput = true + + 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 = () => {} + + /* -------------------- Custom Check -------------------- */ + + customCheck = () => { + // 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') + } + + // Live validation passed; nothing extra to do before the platform submits. + this.previewFeedback = null + } + + /* -------------------- Input -------------------- */ + + public InputComponent = (props: BaseResponseAreaProps): JSX.Element => { + // 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 ---------- + * 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 = (() => { + if (!props.feedback || !('feedback' in props.feedback)) return 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 ---------- + * 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) { + this.previewFeedback = preview + this.phase = CheckPhase.PreviewError + } else { + this.previewFeedback = null + this.phase = CheckPhase.Idle + } + }} + isTeacherMode={false} + /> + ) + } + + /* -------------------- 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 => { + return ( + { + // 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, + answer: val, + }) + }} + isTeacherMode={true} + /> + ) + } +} \ 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..0359863 --- /dev/null +++ b/src/types/FSA/styles.ts @@ -0,0 +1,165 @@ +import { makeStyles } from "@styles"; + +export const useLocalStyles = makeStyles()((theme) => ({ + /* ---------------- Root Container ---------------- */ + + container: { + width: '100%', + height: 600, + border: '1px solid #ddd', + fontFamily: 'sans-serif', + display: 'grid', + gridTemplateColumns: '280px 1fr', + position: 'relative', + backgroundColor: '#fff', + }, + + /* ---------------- Left Panel ---------------- */ + + panel: { + padding: theme.spacing(2), + borderRight: '1px solid #ddd', + backgroundColor: '#fafafa', + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + 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, + }, + + cyWrapper: { + flexGrow: 1, + }, + + /* ---------------- Overlay ---------------- */ + + overlayBackdrop: { + position: 'absolute', + inset: 0, + backgroundColor: 'rgba(0,0,0,0.3)', + zIndex: 10, + }, + + /* ---------------- Side Modal ---------------- */ + + sideModal: { + position: 'absolute', + top: 0, + right: 0, + height: '100%', + width: 380, + backgroundColor: '#fafafa', + borderLeft: '1px solid #ddd', + boxShadow: '-4px 0 12px rgba(0,0,0,0.15)', + display: 'flex', + flexDirection: 'column', + zIndex: 11, + }, + + 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, + }, + + sideModalBody: { + padding: theme.spacing(2), + overflowY: 'auto', + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + }, + + /* ---------------- Form Controls ---------------- */ + + 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 10px', + backgroundColor: '#fff', + border: '1px solid #ccc', + borderRadius: 4, + cursor: 'pointer', + }, + epsilonButton: { + marginTop: 6, + padding: '4px 8px', + borderRadius: 4, + border: '1px solid #6a1b9a', + backgroundColor: '#f3e5f5', + color: '#6a1b9a', + fontWeight: 600, + cursor: 'pointer', + width: 'fit-content', + }, +})); diff --git a/src/types/FSA/type.ts b/src/types/FSA/type.ts new file mode 100644 index 0000000..bd44a01 --- /dev/null +++ b/src/types/FSA/type.ts @@ -0,0 +1,194 @@ +// 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()), + config: z.string() +}); + +export type FSA = z.infer; + +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(), +}) + +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 +} + +/* =========================== + 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().nullable().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; + +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/validateFSA.ts b/src/types/FSA/validateFSA.ts new file mode 100644 index 0000000..e27aa4c --- /dev/null +++ b/src/types/FSA/validateFSA.ts @@ -0,0 +1,341 @@ +import type { FSA, FSAFeedback } from "./type"; +import { ValidationError } from "./type"; + +/* =========================== + Internal helper types +=========================== */ + +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 +=========================== */ + +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; +}; + +// 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 +=========================== */ + +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; +}; diff --git a/src/types/index.ts b/src/types/index.ts index 0869168..781b476 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,6 @@ import { CodeResponseAreaTub } from './Code' import { EssayResponseAreaTub } from './Essay' +import { FSAResponseAreaTub } from './FSA' import { ImagesResponseAreaTub } from './Images' import { LikertResponseAreaTub } from './Likert' import { MathMultiLinesResponseAreaTub } from './MathMultiLines' @@ -28,6 +29,7 @@ export const supportedResponseTypes = [ 'ESSAY', 'CODE', 'MILKDOWN', + 'FSA', 'LIKERT', 'MATH_SINGLE_LINE', 'MATH_MULTI_LINES', @@ -58,6 +60,8 @@ const createReponseAreaTub = (type: string): ResponseAreaTub => { return new EssayResponseAreaTub() case 'CODE': return new CodeResponseAreaTub() + case 'FSA': + return new FSAResponseAreaTub() case 'LIKERT': return new LikertResponseAreaTub() case 'MATH_SINGLE_LINE': diff --git a/vite.config.ts b/vite.config.ts index 2b0af76..b385a8c 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 2f15a67..28395f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2335,6 +2335,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" @@ -2769,38 +2835,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" @@ -2808,6 +2991,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" @@ -2828,6 +3016,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" @@ -2853,6 +3097,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" @@ -3608,6 +3857,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" @@ -3769,6 +4023,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, d3-array@^2.3.0, d3-array@^2.5.0: version "2.12.1" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-2.12.1.tgz#e20b41aafcdffdf5d50928004ececf815a465e81" @@ -3812,20 +4071,23 @@ d3-delaunay@^6.0.4: dependencies: delaunator "5" -d3-dispatch@1, d3-dispatch@^1.0.3, d3-dispatch@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-1.0.6.tgz#00d37bcee4dd8cd97729dd893a0ac29caaba5d58" - integrity sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA== +"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-ease@1: - version "1.0.7" - resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-1.0.7.tgz#9a834890ef8b8ae8c558b2fe55bd57f5993b85e2" - integrity sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ== +"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-format@1 - 2": - version "2.0.0" - resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-2.0.0.tgz#a10bcc0f986c372b729ba447382413aabf5b0767" - integrity sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA== +"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" @@ -3901,10 +4163,10 @@ d3-scale@^4.0.2: d3-time "2.1.1 - 3" d3-time-format "2 - 4" -d3-selection@1.4.2, d3-selection@^1.1.0: - version "1.4.2" - resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.4.2.tgz#dcaa49522c0dbf32d6c1858afc26b6094555bc5c" - integrity sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg== +"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" @@ -3946,22 +4208,32 @@ 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: - version "1.0.10" - resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.10.tgz#dfe76b8a91748831b13b6d9c793ffbd508dd9de5" - integrity sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw== +"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@^1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-1.3.2.tgz#a98ef2151be8d8600543434c1ca80140ae23b398" - integrity sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA== +"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" - d3-dispatch "1" - d3-ease "1" - d3-interpolate "1" - d3-selection "^1.1.0" - d3-timer "1" + 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" @@ -5955,6 +6227,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" @@ -6470,6 +6747,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" @@ -7362,6 +7651,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" @@ -7603,3 +7897,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"