An interactive, Desmos-style geometry sketchpad built for CodeSignal math courses. Users see an infinite, zoomable coordinate plane and can draw and manipulate shapes directly on it; optionally a configured Q&A panel asks the learner a question about what they've drawn.
The app is two pieces:
- A React + SVG canvas frontend (
src/) - A small Express server (
server.js) that seeds and reports state
A backing config.json file at the repo root seeds the initial objects and,
optionally, the task question.
npm install
npm run dev # Vite on :5173 + Express on :3000Open http://localhost:5173. Vite proxies /api/* to the Express server.
For a one-process production build:
npm run preview # builds dist/, serves everything from Express on :3000The canvas fills the viewport. There's no fixed window — the plane extends
indefinitely in every direction, with axis tick labels (1, 2, 5, 10,
…) that adapt as you zoom in or out.
- Drag on empty canvas → pan the view.
- Scroll wheel → zoom toward the cursor.
- Hold
Space→ temporarily switch to pan mode regardless of the active tool. - Middle-mouse drag → pan, regardless of tool.
- HUD (top-right of canvas):
−/+zoom buttons, current zoom factor, and a Reset view button that returns to origin + default zoom.
| Icon | Tool | Action |
|---|---|---|
| ↖ | Drag / Select | Click an object to select; drag a body to translate; drag a handle to resize; drag empty space to pan. |
| ● | Point | Click to place a single point at the cursor. An inline label editor opens at the new point — type a label and press Enter (or click away to skip). |
| ╱ | Line | Either drag from one endpoint to the other, or click the first endpoint and then click the second. Snap-to-grid applies to both clicks. |
| ○ | Circle | Press at the center, drag outward to set radius, release. |
| ▭ | Rectangle | Press one corner, drag to the opposite corner, release. |
| ⬠ | Polygon | Click each vertex in turn. Close with one of: click near vertex #1 (a hollow ring appears when you're close), double-click anywhere, or press Enter. Esc cancels. |
To the left of the tools: undo / redo (also ⌘Z / Ctrl+Z and
⇧⌘Z / Ctrl+Y). To the right: an eraser button that clears the
canvas (undoable).
While drawing or editing, the cursor snaps to the nearest target within ~10 px. Snap targets are:
- Major grid intersections — the grid step adapts with zoom (
0.1,0.5,1,2,5,10, …), so snap targets follow naturally. - Intersection points between existing objects.
- Vertices / anchors of existing objects (line endpoints, rectangle corners, polygon vertices, circle centers, points). Function curves don't contribute snap points.
Intersection and vertex snaps beat the grid when they're at least as close. A small ring is shown at the snap target so you can see it lock on. Move mode does not snap the cursor, but translating a selected object snaps its anchors to nearby intersection points and the translation delta also snaps to whole grid steps.
Clicking an object in Drag / Select mode selects it. The selected object stays the same color but renders slightly thicker, and its sidebar row is highlighted. Polygons and rectangles are hit-tested on their edges only — clicking inside the filled area doesn't select them, so points or other shapes sitting inside a polygon can be grabbed cleanly.
Once selected:
- Drag the body of any line/circle/rectangle/polygon/point to translate it. The delta snaps to whole grid steps when within 10 px of one.
- Drag a handle (small white square) at a corner / endpoint / vertex / circumference to resize or move that piece. The opposite anchor stays pinned for rectangles; the line's other endpoint stays put for lines; the circle's center stays put for radius drag.
- Drag the rotation handle (small white circle outside the bottom-right corner of a rectangle, or near the outermost vertex of a polygon) to rotate the shape around its center. Rotation snaps to 15° when close.
- Delete / Backspace removes the selected object (undoable).
- Arrow keys nudge the selected object by 1 unit; Shift + Arrow nudges by 5 units. Each press is one undo step. Function curves are skipped.
- Double-click a point's label to re-open the inline label editor.
- All edits commit a single undo step per drag.
Any time two objects geometrically intersect, the intersection points are highlighted with a small amber dot. Click a dot to display its coordinates as a label. Pairs supported:
- line × line, line × rectangle/polygon edges, line × circle
- rectangle/polygon × circle (edges)
- rectangle × rectangle, polygon × polygon (edges)
- circle × circle
- function curve × line/rectangle/polygon/circle (numerical, via sample-and-bisect)
- function curve × function curve (in the currently visible x range)
Self-intersection is not tracked.
The sidebar on the left lists every object. Each row shows an icon, the auto-generated description, and the type. Clicking a row selects the object on the canvas (and vice versa).
Objects without an explicit label get a default name based on type:
points/circles/lines show their coordinates; rectangles show
"Rectangle"; polygons are named by vertex count
("Triangle" for 3, "Quadrilateral" for 4, up to "Octagon" for 8;
beyond that it falls back to "Polygon (N points)"). Setting label
in config.json or via the editor always wins.
Selecting a row expands an inline editor underneath with the object's parameters as input fields. Edit any field and press Enter (or click away) to commit; Esc reverts. The editor stays in sync with on-canvas edits.
At the bottom of the list is a single + Add formula row. Click it, type a formula, and press Enter to add it. Supported formulas:
y = <expression>— adds a function curve. Example:y = x^2 - 4.(x - a)^2 + (y - b)^2 = r^2— adds a circle.
- Every change is saved both to the browser's
localStorageand POSTed to/api/state(in-memory on the server). - On reload, the React app calls
GET /api/configand shows the seed objects fromconfig.json. - The browser falls back to
localStorageif the API is unreachable, so the app degrades gracefully when only Vite is running.
If config.json includes a task, a floating panel appears in the
bottom-right of the viewport with the question. It supports two modes:
- Freeform — a textarea.
onChangeis debounced 300 ms. - Multiple choice — 2–4 radio buttons.
onChangefires immediately.
There's no submit button. Every change is POSTed to /api/task/answer and
held in server memory as submittedAnswer until the next change. The
panel collapses to a small ? pill via its header chevron.
If task is absent or fails validation, the panel does not render.
The vanilla module powering the panel lives at src/shared/task-panel/
and is dependency-free, so it can move into the design system later.
Served by server.js on the same port as the app in production (:3000)
and proxied through Vite in development.
| Method | Path | Description |
|---|---|---|
GET |
/api/config |
Returns { objects, sidebarOpen, viewCenter } from config.json. Always re-reads from disk. |
GET |
/api/state |
Returns the current in-memory objects array. ?format=text returns a human-readable summary plus a list of all intersection points. |
POST |
/api/state |
Body { objects: GeomObject[] }. Replaces the in-memory state. Called automatically by the frontend after every change. |
GET |
/api/task |
Returns { task, submittedAnswer }. ?format=text returns a human-readable block including the learner's current answer (or "(no task configured)"). |
POST |
/api/task/answer |
Body { answer: string }. For multiple_choice, validates that answer is "" or one of options (else 400). |
State is not persisted across server restarts. config.json is read
only — it's the seed, never the destination.
The single source of truth for what loads when the app starts.
objectsis required (use[]if empty).taskis optional. When absent or invalid, the Q&A panel does not render and the app runs in "building mode". On invalid task the server logs aTask validation: …warning.sidebarOpenis optional.truestarts the app with the Objects sidebar expanded;false(or omitted) starts it collapsed. Users can still toggle it during the session; this only controls the initial state on page load.viewCenteris optional. A tuple of world coordinates that the canvas is centered on at startup:[x, y]to set only the center, or[x, y, zoom]to also set the initial zoom (the multiplier shown in the HUD; default60, must be> 0). Defaults to[0, 0](the origin) at the default zoom. The "Reset view" button also returns the canvas to this center and zoom. Malformed entries are ignored.- Unknown top-level keys are ignored.
Every object needs a unique string id and a discriminating type.
label is optional on every type — when set it's the row title in the
sidebar and the text drawn next to the shape; when omitted the app
auto-generates a description (e.g. "Circle (0, 0), r=3").
Coordinates use standard math convention: +y is up, (0, 0) is the
origin. The visible window roughly covers x, y ∈ [-10, 10] at the
default zoom — anything farther will be off-screen until the user pans.
{ "id": "p1", "type": "point", "x": 0, "y": 0, "label": "Origin" }{ "id": "l1", "type": "line", "x1": 0, "y1": 0, "x2": 6, "y2": 0 }{ "id": "c1", "type": "circle", "cx": 0, "cy": 0, "r": 3 }r must be > 0.
{
"id": "r1",
"type": "rectangle",
"x": -2, "y": -2,
"w": 4, "h": 4,
"rotation": 0.7853981633974483
}(x, y)is the starting corner;wandhare signed offsets so the opposite corner is(x + w, y + h). Negative values are accepted.rotationis optional, in radians, applied counterclockwise around the rectangle's center(x + w/2, y + h/2). Default is0.- 30° →
0.5235987755982988 - 45° →
0.7853981633974483 - 60° →
1.0471975511965976 - 90° →
1.5707963267948966 - Convert:
radians = degrees * Math.PI / 180.
- 30° →
{
"id": "poly1",
"type": "polygon",
"points": [[0,0],[6,0],[0,4]],
"rotation": 0.7853981633974483
}pointsis an array of[x, y]pairs, minimum 3, in order. The polygon closes automatically (no need to repeat the first vertex).rotationis optional, in radians, applied counterclockwise around the polygon's centroid (the average ofpoints). Default is0. Same convention as rectangle rotation. Editingpointsin the sidebar is WYSIWYG: typed coordinates are treated as world positions and any existing rotation resets to 0.
{ "id": "fn1", "type": "function", "expression": "x^2 - 4", "label": "y = x²−4" }expressionis the right-hand side only.- Parsed by
mathjs. Supported:+ - * / ^,sqrt(x),abs(x),sin(x),cos(x),tan(x),exp(x),log(x)(natural log),log10(x),pi,e, and parentheses.
Two types. Anything else makes the panel hide.
{
"task": {
"question": "What is the area of this right triangle?",
"type": "freeform",
"correctAnswer": "12"
}
}question: non-empty string.type: literal"freeform".correctAnswer: string (numbers must be wrapped:"12", not12). Not shown to the learner — used only for grading.
{
"task": {
"question": "Which figure has four equal sides?",
"type": "multiple_choice",
"options": ["Triangle", "Square", "Pentagon", "Hexagon"],
"correctAnswer": "Square"
}
}question: non-empty string.type: literal"multiple_choice".options: array of 2–4 strings. Each renders as one radio row.correctAnswer: must match one of theoptionsstrings exactly.
- Top-level: malformed JSON → seed becomes
{ objects: [], task: null }, warning logged. Same fortaskfailing validation. - Per-object: the frontend doesn't deeply validate object schemas. A
malformed object may render as empty /
NaNrather than throw, so the config should follow the schemas above. - Per-task (server-side):
task.typenot in{"freeform", "multiple_choice"}→ ignored.questionmissing or empty → ignored.correctAnswernot a string → ignored.- For multiple_choice:
optionsmust be an array of 2–4 strings;correctAnswermust be inoptions.
Two ready-made configs live in examples/:
examples/config-freeform.json— a right triangle plus a freeform area question.examples/config-mc.json— a right triangle as 3 lines plus a multiple-choice area question.
Drop either into config.json and reload.
Parabola–line intersection (freeform):
{
"objects": [
{ "id": "para", "type": "function", "expression": "x^2", "label": "y = x²" },
{ "id": "line", "type": "line", "x1": -3, "y1": 4, "x2": 3, "y2": 4 }
],
"task": {
"question": "At what x-values does y = x² intersect y = 4? Enter both, comma-separated.",
"type": "freeform",
"correctAnswer": "-2, 2"
}
}Rotated square:
{
"objects": [
{
"id": "diamond",
"type": "rectangle",
"x": -2, "y": -2, "w": 4, "h": 4,
"rotation": 0.7853981633974483,
"label": "diamond"
}
]
}.
├── config.json # Seed config — edit to control startup state
├── examples/ # Drop-in example configs
├── server.js # Express API + production static server
├── intersections.js # Server-side intersection math (mirrors src/lib/intersections.ts)
├── src/
│ ├── App.tsx # Root: owns objects, selection, history, mounts everything
│ ├── components/
│ │ ├── Canvas.tsx # The infinite plane: rendering, tools, handles, intersections
│ │ ├── Toolbar.tsx # Top bar: undo/redo, tools, eraser
│ │ ├── Sidebar.tsx # Object list + inline editor + "Add formula" row
│ │ └── TaskPanelMount.tsx# Thin React wrapper around the vanilla task-panel
│ ├── shared/task-panel/ # Vanilla JS+CSS Q&A panel (portable, dep-free)
│ ├── lib/
│ │ ├── geometry.ts # GeomObject types, formula parsing, label helpers
│ │ ├── intersections.ts # Frontend intersection math
│ │ └── api.ts # fetch/postState with localStorage fallback
│ ├── styles.css # Global app styles
│ └── main.tsx # Vite entry
└── index.html
| Keys | Action |
|---|---|
Space (hold) |
Temporary pan mode |
⌘Z / Ctrl+Z |
Undo |
⇧⌘Z / Ctrl+Shift+Z / Ctrl+Y |
Redo |
Delete / Backspace |
Delete selected object |
↑ ↓ ← → |
Nudge the selected object by 1 unit |
Shift + arrow |
Nudge the selected object by 5 units |
Enter |
Close polygon while drawing |
Esc |
Cancel in-progress drawing |
Shortcuts are suppressed while focus is in a text input.
{ "objects": [ /* GeomObject[] */ ], "task": { /* Task — optional */ }, "sidebarOpen": false, /* optional, defaults to false */ "viewCenter": [0, 0] /* optional, [x, y] or [x, y, zoom] — defaults to origin at 60× */ }