From bce861981036f11f4316ef6f41274a7604deb68a Mon Sep 17 00:00:00 2001 From: Ahmad Tajwar <165593573+Tajwarbot@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:51:50 +0600 Subject: [PATCH] Add implicit plotting, live MCP sessions, and additive graph workflows --- .github/workflows/ci.yml | 16 + AGENTS.md | 81 ++-- README.md | 24 +- eslint.config.js | 1 + .../graphly/.claude-plugin/plugin.json | 9 + .../graphly/.codex-plugin/plugin.json | 21 + integrations/graphly/README.md | 7 + integrations/graphly/skills/graphly/SKILL.md | 16 + mcp-server/README.md | 73 ++-- mcp-server/index.js | 131 +++--- mcp-server/package.json | 5 +- mcp-server/session.js | 96 +++++ mcp-server/session.test.js | 97 +++++ netlify/functions/plot.js | 2 +- package.json | 4 +- src/App.jsx | 386 ++++++----------- src/app_render.test.jsx | 23 +- src/chatbot.test.jsx | 41 ++ src/components/AIChatbox.jsx | 406 ++---------------- src/components/ApiKeyModal.jsx | 61 +-- src/components/HomePage.css | 43 ++ src/components/HomePage.jsx | 60 +++ src/components/ThreeDGraph.jsx | 170 ++++---- src/lib/chatIntent.js | 196 +++++++++ src/lib/functionEquation.test.js | 13 + src/lib/graphSessionClient.js | 26 ++ src/lib/graphState.js | 12 + src/lib/implicitContours.js | 53 +++ src/lib/implicitContours.test.js | 26 ++ src/lib/mathEngine.js | 59 ++- src/lib/security.js | 6 +- src/lib/surface.worker.js | 8 + src/lib/surfaceEngine.js | 107 +++++ src/lib/surfaceTopology.test.js | 30 ++ src/main.jsx | 13 +- src/regression.test.js | 44 ++ src/surfaceEngine.test.js | 50 +++ tailwind.config.js | 2 +- vitest.config.js | 3 + 39 files changed, 1479 insertions(+), 942 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 integrations/graphly/.claude-plugin/plugin.json create mode 100644 integrations/graphly/.codex-plugin/plugin.json create mode 100644 integrations/graphly/README.md create mode 100644 integrations/graphly/skills/graphly/SKILL.md create mode 100644 mcp-server/session.js create mode 100644 mcp-server/session.test.js create mode 100644 src/chatbot.test.jsx create mode 100644 src/components/HomePage.css create mode 100644 src/components/HomePage.jsx create mode 100644 src/lib/chatIntent.js create mode 100644 src/lib/functionEquation.test.js create mode 100644 src/lib/graphSessionClient.js create mode 100644 src/lib/graphState.js create mode 100644 src/lib/implicitContours.js create mode 100644 src/lib/implicitContours.test.js create mode 100644 src/lib/surface.worker.js create mode 100644 src/lib/surfaceEngine.js create mode 100644 src/lib/surfaceTopology.test.js create mode 100644 src/regression.test.js create mode 100644 src/surfaceEngine.test.js create mode 100644 vitest.config.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..11d5869 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,16 @@ +name: Checks +on: [push, pull_request] +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm test + - run: npm run test:mcp + - run: npm run lint + - run: npm run build diff --git a/AGENTS.md b/AGENTS.md index 894314c..ff34560 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,70 +1,61 @@ -# Graphly Agent Instructions & MCP Tool Specification +# Graphly agent integration -Welcome, AI Assistant! If you have been provided this repository, you have been invited to interface with **Graphly**, a high-performance 2D & 3D mathematical plotter, calculus evaluator, and data visualization suite. +Graphly plots 2D curves, 3D surfaces, and tabular data. Prefer the local MCP tools for an ongoing graphing conversation; preserve existing layers unless the user asks to edit or remove them. ---- +## Start the MCP server -## 1. Quick MCP Server Installation & Launch +Run `node /absolute/path/to/Graphly/mcp-server/index.js` over stdio with Node.js 20 or later. The MCP package has no dependencies. -Graphly includes a built-in Model Context Protocol (MCP) server ready to execute over `stdio`. +Set `GRAPHLY_BASE_URL` to the frontend running this revision. During development, run `npm run dev -- --host 127.0.0.1` from the repository and use `http://127.0.0.1:5173` (match the actual port). The default is `https://graphly.netlify.app`; live sessions and implicit surfaces require deploying the updated frontend first. -### Local Execution Command -```bash -node mcp-server/index.js -``` - -### Automatic Configuration for Claude Desktop, Cursor, and IDEs -To register Graphly with your client's MCP configuration (`claude_desktop_config.json`, `.cursor/mcp.json`, or `mcp_config.json`): +Example client configuration: ```json { "mcpServers": { "graphly": { "command": "node", - "args": ["/mcp-server/index.js"], - "env": { - "GRAPHLY_BASE_URL": "https://graphly.netlify.app" - } + "args": ["/absolute/path/to/Graphly/mcp-server/index.js"], + "env": { "GRAPHLY_BASE_URL": "http://127.0.0.1:5173" } } } } ``` ---- +## Live tools and workflow + +- `create_graph`: create a graph with `dimension: "2d"` or `"3d"` and optional `title`. Retain the returned graph ID and open its live URL once. +- `upsert_expression`: send `graph_id` and `equation`. **Omit `expression_id` to append a new layer.** Reuse an existing ID only when intentionally editing that layer. Optional `visible` and six-digit hex `color` control display. +- `get_graph`: inspect current server state, including stable expression IDs and revision. +- `remove_expression`: remove only the specified `expression_id` from `graph_id`. +- `set_view`: set `bounds` with ordered finite `xMin`, `xMax`, `yMin`, `yMax`, optional `zMin`, `zMax`; or set `camera` with `position` and `target` triples in mathematical x/y/z coordinates. +- `export_graph`: return a standalone snapshot link containing every expression, title, and view. It contains no live capability token and survives MCP process restarts. + +Use dimension from the user's context. After discussing 3D surfaces, interpret an ambiguous shape in that context or clarify briefly. A 2D ellipse and a 3D ellipsoid are different objects. + +## Equations + +The updated 3D renderer accepts implicit equations directly: -## 2. Tools Available to the Agent +- Sphere: `x^2+y^2+z^2=9` +- Ellipsoid: `x^2/9+y^2/4+z^2=1` +- Vertical cylinder: `x^2+y^2=9` in a 3D graph +- Paraboloid: `z=x^2+y^2` +- Saddle (hyperbolic paraboloid): `z=(x^2-y^2)/4` +- One-sheet hyperboloid: `x^2+y^2-z^2=1` -When connected via MCP or when operating Graphly directly, use the following tools: +Do not split spheres or ellipsoids into positive and negative square-root surfaces. Use full equations. Numerical extraction has finite resolution; singularities and very small features can require tighter bounds. Do not promise arbitrary equations render perfectly. -### Tool A: `plot_function` -Plots an explicit 2D mathematical curve $y = f(x)$. -- **`expression`** *(string, required)*: The mathematical formula in terms of $x$ (e.g. `'sin(x)'`, `'x^2 - 4'`, `'1/x'`). -- **`xMin` / `xMax`** *(number, optional)*: Explicit viewport bounds. +## Legacy tools -### Tool B: `plot_3d_surface` -Renders an interactive 3D surface $z = f(x, y)$ with orbit controls and coordinate axes. -- **`expression`** *(string, required)*: Formula in terms of $x$ and $y$ (e.g. `'(x^2 - y^2)/4'` for hyperboloid/saddle, `'(x^2 + y^2)/6'` for paraboloid, `'sin(x)*cos(y)'` for ripple). +`plot_function` accepts an explicit 2D expression and optional paired `xMin`/`xMax`. `plot_3d_surface` accepts a 3D expression or full implicit equation with the updated viewer. `plot_data_table` accepts optional `name` and `rows: [{x: 1, y: 2}]` with finite numbers. These tools produce one-plot share links; use live tools for incremental multi-expression work. -### Tool C: `plot_data_table` -Plots tabular coordinates with scatter nodes and automatic regression trendlines. -- **`name`** *(string)*: Name of dataset. -- **`rows`** *(array of {x, y})*: Numeric data points. +## Verification and session limits ---- +The server reports **accepted**, meaning it stored state; this does not verify rendering. Inspect the viewer before claiming a graph is displayed correctly. Report expression errors honestly. Never claim to have opened a viewer or changed a browser tab without doing so. -## 3. Remote Web Integration (ChatGPT Actions & Claude Web) +Live sessions use a loopback SSE bridge with an Origin check and capability token. They exist only for the MCP process lifetime, on the same computer as the viewer. Browser local-network restrictions may require local viewing. Keep live URLs private. Exported URLs contain graph data readable by anyone with the link. -If you are operating in a web-only environment (e.g. ChatGPT Actions, Claude Web, or Custom GPT): +The bridge currently sends server state to the viewer, not manual viewer edits back to the server. Later tool updates restore session-owned layers while preserving other user layers. Inline MCP Apps support depends on the host and is not implemented by this bridge. This server does not host or publish the frontend. -1. **OpenAPI Schema**: Read and register `https://graphly.netlify.app/openapi.json`. -2. **AI Plugin Manifest**: `https://graphly.netlify.app/.well-known/ai-plugin.json`. -3. **Endpoint**: Call `POST https://graphly.netlify.app/api/plot` with: - ```json - { - "mode": "3d", - "expression": "(x^2 - y^2) / 4", - "name": "Hyperbolic Paraboloid" - } - ``` -4. Output the returned `url` to the user as a clickable markdown badge: - `[Open in Graphly](https://graphly.netlify.app/?state=...)` +See `mcp-server/README.md` for setup details. Run `npm test --prefix mcp-server` with loopback networking permitted to verify protocol, session, bridge, and export behavior. diff --git a/README.md b/README.md index 3de15dd..8d0e628 100644 --- a/README.md +++ b/README.md @@ -19,23 +19,19 @@ It features an **AI-powered scanner** that turns images of data tables into edit --- -## **🤖 Connect to AI Assistants (Claude, ChatGPT, Cursor)** +## Connect AI assistants -Graphly comes with a built-in **Model Context Protocol (MCP)** server and **OpenAPI Action** specifications, allowing AI models to plot mathematical graphs and 3D surfaces directly. +The local Node.js MCP server supports live graph sessions: create a graph once, then append or edit individual equations in the same browser tab. See [MCP setup and tools](mcp-server/README.md) and the optional [Codex / Claude Code skill plugin](integrations/graphly/README.md). -### One-Prompt Setup for Any AI Coding Agent -Simply prompt your AI assistant (Cursor, Antigravity, Claude Code, or Copilot): -> *"Read https://github.com/Tajwarbot/Graphly/blob/main/AGENTS.md and configure the Graphly MCP server for my environment."* +Run the updated frontend locally before using the new tools; the hosted website needs this build deployed before it can render live sessions. Client configuration and inline viewer support vary. The existing OpenAPI endpoint and legacy plot tools generate standalone links. -The AI will automatically inspect the repository, register the `plot_function`, `plot_3d_surface`, and `plot_data_table` tools, and configure your local settings. +## Equation support and current limits -### ChatGPT Web (Custom GPT Action) -1. Go to **ChatGPT** → **My GPTs** → **Create a GPT** → **Configure** → **Actions**. -2. Click **Import from URL** and paste: - ``` - https://graphly.netlify.app/openapi.json - ``` -3. Save. ChatGPT can now natively generate live Graphly 2D curves, 3D quadric surfaces, and scatter trendlines. +Enter full equations such as `x^2/9+y^2/4=1` in 2D or `x^2/9+y^2/4+z^2=1` in 3D. Explicit surfaces such as `z=x^2+y^2` also work. New assistant plots append layers; targeted edits use layer IDs. Draft layers are restored from this browser's local storage. + +Implicit surfaces are sampled in a finite domain on a background worker. This is a numerical renderer, not a symbolic solver: very small features, isolated points, and equations without sign changes can be missed. Inequalities and parametric surfaces are not supported by this path. Live MCP state lasts for the server process; export a snapshot to retain all equations and view settings. Browser edits are currently one-way and do not update MCP state. + +Run `npm test`, `npm run test:mcp`, `npm run lint`, and `npm run build` to validate changes. MCP tests require loopback networking. --- @@ -69,7 +65,7 @@ Follow these instructions to run the project locally. ```env VITE_GEMINI_API_KEY=your_actual_api_key_here ``` - * **Note:** This is only for local convenience. The app now supports entering your key directly in the UI, which is saved securely in your browser's Local Storage. + * **Note:** This is only for local convenience. The app now supports entering your key directly in the UI, which is saved in your browser's Local Storage. 4. **Run the development server:** ```bash diff --git a/eslint.config.js b/eslint.config.js index 4fa125d..793c7c1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,6 +6,7 @@ import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ globalIgnores(['dist']), + { files: ['mcp-server/**/*.js', 'api/**/*.js', 'netlify/**/*.js'], languageOptions: { globals: globals.node } }, { files: ['**/*.{js,jsx}'], extends: [ diff --git a/integrations/graphly/.claude-plugin/plugin.json b/integrations/graphly/.claude-plugin/plugin.json new file mode 100644 index 0000000..a13cf13 --- /dev/null +++ b/integrations/graphly/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "graphly", + "version": "1.1.0", + "description": "Graphly plotting workflow for local MCP sessions.", + "author": { + "name": "Tajwarbot" + }, + "repository": "https://github.com/Tajwarbot/Graphly" +} diff --git a/integrations/graphly/.codex-plugin/plugin.json b/integrations/graphly/.codex-plugin/plugin.json new file mode 100644 index 0000000..61c7d08 --- /dev/null +++ b/integrations/graphly/.codex-plugin/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "graphly", + "version": "1.1.0", + "description": "Graphly plotting workflow for local MCP sessions.", + "author": { + "name": "Tajwarbot" + }, + "repository": "https://github.com/Tajwarbot/Graphly", + "skills": "./skills/", + "interface": { + "displayName": "Graphly", + "shortDescription": "Plot and refine equations in a live Graphly viewer.", + "developerName": "Tajwarbot", + "category": "Productivity", + "longDescription": "Create and refine mathematical plots with Graphly local MCP sessions and a browser viewer.", + "defaultPrompt": "Use Graphly to plot and refine my equations.", + "capabilities": [ + "Interactive" + ] + } +} diff --git a/integrations/graphly/README.md b/integrations/graphly/README.md new file mode 100644 index 0000000..41d37b9 --- /dev/null +++ b/integrations/graphly/README.md @@ -0,0 +1,7 @@ +# Graphly agent integration + +This plugin supplies a plotting skill for Codex and Claude Code. Register the local MCP server separately using [the setup and live-session guide](../../mcp-server/README.md). It intentionally contains no machine-specific executable paths or automatic config writes. + +For Claude Code, load this directory with `claude --plugin-dir /absolute/path/to/Graphly/integrations/graphly`. For Codex, install this plugin directory through your supported local-plugin workflow, or copy `skills/graphly` into your personal skills directory. Restart the client after registering MCP tools. + +The repository includes both `.codex-plugin/plugin.json` and `.claude-plugin/plugin.json`. A plugin skill alone does not start the viewer or server. Run the updated local frontend and configure `GRAPHLY_BASE_URL` as described in the server guide before testing live sessions. diff --git a/integrations/graphly/skills/graphly/SKILL.md b/integrations/graphly/skills/graphly/SKILL.md new file mode 100644 index 0000000..e467758 --- /dev/null +++ b/integrations/graphly/skills/graphly/SKILL.md @@ -0,0 +1,16 @@ +--- +name: graphly +description: Plot, compare, and refine mathematical equations with the Graphly MCP server and its live browser viewer. Use when the user requests Graphly plotting or edits to an existing Graphly graph. +--- + +Use the connected Graphly MCP tools. If unavailable, explain that the local MCP server must be registered using the repository's mcp-server/README.md; this skill does not install or start it. + +For a new workspace, call create_graph with the appropriate dimension and open its returned viewer URL once. Retain graph_id for follow-up requests. New plots append through upsert_expression without expression_id. Intentional edits reuse the exact expression_id from get_graph; removals use remove_expression. Preserve unrelated layers. Clarify only when the target of an edit is ambiguous. + +Pass equations directly: an ellipse is 2D (x^2/9+y^2/4=1), an ellipsoid is 3D (x^2/9+y^2/4+z^2=1), and a paraboloid can be z=x^2+y^2. Do not split closed surfaces into square-root halves. Use set_view for finite increasing bounds or a 3D camera. + +Tool acceptance confirms stored state, not successful rendering. Inspect the viewer before claiming visual verification; report any displayed evaluation errors. Implicit rendering samples a finite domain and may miss small features or zero sets without sign changes. + +Use export_graph for a durable, multi-expression snapshot link. Live sessions last only while their local MCP process runs. Keep capability-bearing live links private. Manual edits in the viewer are not synchronized back to the server and can be replaced by subsequent server revisions; use MCP edits for session-managed layers. + +Legacy plot_function, plot_3d_surface, and plot_data_table return standalone links. Prefer a live session for iterative equation work. Host inline-view support varies; do not promise an embedded graph when the client only supports browser links. diff --git a/mcp-server/README.md b/mcp-server/README.md index d9daf1e..7720e15 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -1,59 +1,50 @@ -# Graphly MCP Server & External AI Integration +# Graphly MCP server -This directory contains the **Model Context Protocol (MCP)** server for Graphly, enabling external AI assistants like **Claude Desktop** and **ChatGPT (via GPT Actions)** to generate interactive graphs. +Dependency-free Node.js (20+) MCP server over stdio. Run `node /absolute/path/to/Graphly/mcp-server/index.js` from an MCP-capable client. Stdout is reserved for JSON-RPC; errors go to stderr. ---- +## Choose the viewer -## 1. Architectural Model: Shareable Link vs. Live Session +**Live sessions require the updated Graphly frontend in this repository.** Until this build is deployed, run Graphly locally (`npm install`, `npm run dev -- --host 127.0.0.1`) and set `GRAPHLY_BASE_URL=http://127.0.0.1:5173` in the MCP server's environment. Match the actual Vite port if it changes. The default remains `https://graphly.netlify.app` for existing link tools; its deployed version may not yet support live sessions or implicit surfaces. -> [!IMPORTANT] -> **Clickable Link Architecture (Implemented)**: -> External tools call Graphly endpoints or MCP tools to serialize a complete graph specification into a compact, state-encoded URL: -> `https://graphly.netlify.app/?state=` -> When the user clicks the generated link in Claude or ChatGPT, Graphly immediately unpacks the state and renders the exact visualization in their browser. -> -> **Live Session Sync (Future Phase 8)**: -> Drawing live into an already-open browser tab without user navigation would require a real-time WebSocket session-pairing architecture (pairing codes, room coordination, bi-directional socket tunnels). This is flagged as a prospective Phase 8. - ---- - -## 2. Registering with Claude Desktop - -To use Graphly tools directly inside Claude Desktop: - -1. Open your Claude Desktop configuration file: - - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` - - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` -2. Add `graphly` under `mcpServers`: +Example MCP client configuration (substitute your actual repository path): ```json { "mcpServers": { "graphly": { "command": "node", - "args": [ - "C:\\Users\\ahmad\\Downloads\\Graphly\\mcp-server\\index.js" - ], - "env": { - "GRAPHLY_BASE_URL": "https://graphly.netlify.app" - } + "args": ["/absolute/path/to/Graphly/mcp-server/index.js"], + "env": { "GRAPHLY_BASE_URL": "http://127.0.0.1:5173" } } } } ``` -3. Restart Claude Desktop. -4. Claude can now invoke: - - `plot_function(expression)` - - `plot_data_table(rows, name)` - - `plot_3d_surface(expression)` +Client configuration format varies. The command and environment above can also be entered through a client's MCP settings. No API key or dependency installation is needed for this server. + +## Live workflow + +1. Call `create_graph` with `dimension: "3d"` (or `"2d"`) and optional title. Open the returned URL once. +2. Call `upsert_expression` with its `graph_id` and `equation: "x^2/9+y^2/4+z^2=1"`. +3. Add a plane with another `upsert_expression`, `equation: "z=0"`. **Omit `expression_id` to append.** Existing curves are preserved. +4. To intentionally edit a curve, reuse that curve's ID from the returned graph snapshot. +5. Use `set_view` for bounds (`xMin`, `xMax`, `yMin`, `yMax`, optional `zMin`, `zMax`) or a 3D camera (`position` and `target`, each a mathematical x/y/z triple). +6. Use `get_graph` to inspect state, `remove_expression` to remove one layer, and `export_graph` for a durable snapshot link containing all expressions and the view. + +The same browser tab receives complete revisioned snapshots over server-sent events and reconnects automatically. Local MCP sessions stay in memory **only while that server process runs**. Export a snapshot to preserve a graph across restarts; the exported link is a copy, not a live session. + +Tool results report `accepted`: the server stored the requested state. This is not a render acknowledgement or proof the equation is valid. The viewer displays evaluation errors. Agent responses must not claim to have visually verified a graph without inspecting the viewer. Manual edits in the viewer are not sent back to the MCP server; later tool updates restore the server's session-owned layers. User layers outside the session are preserved. + +## Compatibility tools + +`plot_function`, `plot_3d_surface`, and `plot_data_table` still generate standalone one-plot links. For ongoing conversations and multiple equations, prefer live graph tools. The updated 3D viewer accepts full equations, including spheres and ellipsoids; do not split closed surfaces into square-root halves. + +## Local connection and privacy + +The bridge starts lazily on an ephemeral port bound only to `127.0.0.1`. A random capability token is placed in the viewer URL fragment, which is not sent to the web hosting server. The bridge checks the exact viewer Origin and capability token on every event-stream request. It serves read-only snapshots and no files or mutation endpoint. Keep live links private: they grant access to that process's session snapshots from the configured viewer origin. Exported links contain graph data directly; anyone with an export link can read it. ---- +The viewer must run on the same computer as the MCP process. Browser local-network permissions, remote development environments, CSP, or hosted clients may block loopback connections. Use the local viewer when needed. This implementation provides a browser viewer; it does not claim universal inline MCP Apps support. No external hosting or publishing happens when a tool is called. -## 3. Registering with ChatGPT (GPT Actions) +## Validation -1. Go to ChatGPT → **My GPTs** → **Create a GPT**. -2. Under the **Configure** tab, scroll down to **Actions** → **Create new action**. -3. Copy and paste the OpenAPI specification from [`docs/openapi-gpt-action.json`](../docs/openapi-gpt-action.json). -4. Set Authentication to **None** (the link generator is public). -5. ChatGPT will now format graph requests into clickable Graphly links. +Run `npm test --prefix mcp-server` (or `node --test mcp-server/session.test.js`) in an environment permitting loopback listeners. Tests cover state isolation, append/edit behavior, validation, Origin/token enforcement, live revisions, and stdio protocol errors. Legacy tools return validation failures as MCP `isError` results rather than silently hanging. diff --git a/mcp-server/index.js b/mcp-server/index.js index d6b5673..ffa591d 100644 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -7,6 +7,7 @@ */ import readline from 'readline'; +import { createSessionStore, startBridge } from './session.js'; const BASE_URL = process.env.GRAPHLY_BASE_URL || "https://graphly.netlify.app"; @@ -20,8 +21,28 @@ function getShareableUrl(state) { return `${BASE_URL}/?state=${encodeURIComponent(encoded)}`; } +const store = createSessionStore(); +let bridgePromise; +const sessionResult = async graph => { + bridgePromise ||= startBridge(store, BASE_URL).catch(error => { bridgePromise = undefined; throw error; }); + const bridge = await bridgePromise; + const url = bridge.url(graph.id); + return { content: [{ type: 'text', text: `Graph accepted (revision ${graph.revision}). Open the live viewer: ${url}. Rendering is not verified by the server.` }], structuredContent: { graph, url } }; +}; +const graphId = { type: 'string', description: 'ID returned by create_graph' }; +const expressionId = { type: 'string', description: 'Stable ID; omit to append a new expression, reuse only for an intentional edit' }; +const tool = (name, description, properties, required) => ({ name, description, inputSchema: { type: 'object', properties, required, additionalProperties: false } }); +const SESSION_TOOLS = [ + tool('create_graph', 'Create a live graph session, retained for the lifetime of this server. Open its URL once, then update by graph_id.', { dimension: { type: 'string', enum: ['2d', '3d'] }, title: { type: 'string' } }, []), + tool('upsert_expression', 'Append an equation to a live graph. Reuse expression_id only when editing an existing equation. 3D supports implicit equations such as x^2+y^2+z^2=9.', { graph_id: graphId, expression_id: expressionId, equation: { type: 'string' }, color: { type: 'string', pattern: '^#[0-9a-fA-F]{6}$' }, visible: { type: 'boolean' } }, ['graph_id', 'equation']), + tool('remove_expression', 'Remove one expression from a live graph.', { graph_id: graphId, expression_id: expressionId }, ['graph_id', 'expression_id']), + tool('export_graph', 'Create a durable share link containing every expression and the view. No local session token is included.', { graph_id: graphId }, ['graph_id']), + tool('get_graph', 'Read authoritative graph state. Accepted means stored, not proof of a successful render.', { graph_id: graphId }, ['graph_id']), + tool('set_view', 'Update graph bounds or 3D camera. Camera uses mathematical x/y/z coordinates.', { graph_id: graphId, bounds: { type: 'object', properties: Object.fromEntries(['xMin','xMax','yMin','yMax','zMin','zMax'].map(k => [k, { type: 'number' }])), required: ['xMin','xMax','yMin','yMax'], additionalProperties: false }, camera: { type: 'object', properties: Object.fromEntries(['position','target'].map(k => [k, { type: 'array', items: { type: 'number' }, minItems: 3, maxItems: 3 }])), required: ['position','target'], additionalProperties: false } }, ['graph_id']) +]; + // Tool definitions schema -const TOOLS = [ +const TOOLS = [...SESSION_TOOLS, { name: "plot_function", description: "Generate a shareable Graphly URL to view an explicit 2D mathematical curve y = f(x).", @@ -66,13 +87,13 @@ const TOOLS = [ }, { name: "plot_3d_surface", - description: "Generate a shareable Graphly URL to view an interactive 3D surface z = f(x, y) with OrbitControls.", + description: "Generate a shareable Graphly URL for a 3D explicit surface or implicit equation such as x^2+y^2+z^2=9. Requires the updated viewer.", inputSchema: { type: "object", properties: { expression: { type: "string", - description: "The 3D surface expression in terms of x and y, e.g. 'sin(x) * cos(y)', '(x^2 - y^2)/4'" + description: "An explicit expression or complete implicit equation, e.g. 'sin(x)*cos(y)' or 'x^2/9+y^2/4+z^2=1'" } }, required: ["expression"] @@ -81,7 +102,22 @@ const TOOLS = [ ]; // Handle MCP Tool Calls -function callTool(name, args) { +async function callTool(name, args) { + if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Tool arguments must be an object'); + if (name === 'create_graph') return sessionResult(store.create(args)); + if (name === 'upsert_expression') return sessionResult(store.upsert(args)); + if (name === 'remove_expression') return sessionResult(store.remove(args)); + if (name === 'export_graph') { + const graph = store.get(args.graph_id); + const state = { mode: graph.dimension === '2d' ? 'function' : '3d', expressions: graph.expressions, view: graph.view, title: graph.title }; + const url = getShareableUrl(state); + return { content: [{ type: 'text', text: `Graph snapshot: ${url}` }], structuredContent: { state, url } }; + } + if (name === 'get_graph') return sessionResult(store.get(args.graph_id)); + if (name === 'set_view') return sessionResult(store.setView(args)); + if (['plot_function', 'plot_3d_surface'].includes(name) && (typeof args.expression !== 'string' || !args.expression.trim() || args.expression.length > 4096)) throw new Error('Provide a nonempty expression (maximum 4096 characters)'); + if (name === 'plot_data_table' && (!Array.isArray(args.rows) || args.rows.length > 10000 || !args.rows.every(row => row && Number.isFinite(row.x) && Number.isFinite(row.y)))) throw new Error('rows must contain at most 10000 finite numeric x/y points'); + if (name === 'plot_function' && (args.xMin !== undefined || args.xMax !== undefined) && (!Number.isFinite(args.xMin) || !Number.isFinite(args.xMax) || args.xMin >= args.xMax)) throw new Error('Provide finite xMin < xMax'); if (name === "plot_function") { const state = { mode: "function", @@ -131,7 +167,7 @@ function callTool(name, args) { content: [ { type: "text", - text: `Graphly 3D Surface Created (z = ${args.expression}): [View 3D Surface in Graphly](${url})\nDirect URL: ${url}` + text: `Graphly 3D Surface Created (${args.expression}): [View 3D Surface in Graphly](${url})\nDirect URL: ${url}` } ] }; @@ -147,68 +183,25 @@ const rl = readline.createInterface({ terminal: false }); -rl.on('line', (line) => { +let queue = Promise.resolve(); +const send = message => process.stdout.write(JSON.stringify(message) + '\n'); +async function handle(line) { if (!line.trim()) return; - - try { - const msg = JSON.parse(line); - const { id, method, params } = msg; - - // MCP Handshake - if (method === 'initialize') { - const response = { - jsonrpc: "2.0", - id, - result: { - protocolVersion: "2024-11-05", - capabilities: { - tools: {} - }, - serverInfo: { - name: "graphly-mcp-server", - version: "1.0.0" - } - } - }; - process.stdout.write(JSON.stringify(response) + '\n'); - return; - } - - // List Tools - if (method === 'tools/list') { - const response = { - jsonrpc: "2.0", - id, - result: { - tools: TOOLS - } - }; - process.stdout.write(JSON.stringify(response) + '\n'); - return; - } - - // Call Tool - if (method === 'tools/call') { - const { name, arguments: toolArgs } = params; - const toolResult = callTool(name, toolArgs || {}); - const response = { - jsonrpc: "2.0", - id, - result: toolResult - }; - process.stdout.write(JSON.stringify(response) + '\n'); - return; - } - - // Default response for notifications or unhandled methods - if (id !== undefined) { - process.stdout.write(JSON.stringify({ - jsonrpc: "2.0", - id, - error: { code: -32601, message: `Method not found: ${method}` } - }) + '\n'); - } - } catch (e) { - process.stderr.write(`Error processing line: ${e.message}\n`); - } -}); + let msg; + try { msg = JSON.parse(line); } + catch { send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); return; } + if (!msg || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') { send({ jsonrpc: '2.0', id: msg?.id ?? null, error: { code: -32600, message: 'Invalid request' } }); return; } + const { id, method, params } = msg; + if (id === undefined) return; + let result; + if (method === 'initialize') result = { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'graphly-mcp-server', version: '1.1.0' } }; + else if (method === 'ping') result = {}; + else if (method === 'tools/list') result = { tools: TOOLS }; + else if (method === 'tools/call') { + try { result = await callTool(params?.name, params?.arguments || {}); } + catch (error) { result = { isError: true, content: [{ type: 'text', text: error.message }] }; } + } else { send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } }); return; } + send({ jsonrpc: '2.0', id, result }); +} +rl.on('line', line => { queue = queue.then(() => handle(line)).catch(error => process.stderr.write(`${error.message}\n`)); }); +rl.on('close', () => { queue.then(async () => { if (bridgePromise) (await bridgePromise).close(); }).catch(() => {}); }); diff --git a/mcp-server/package.json b/mcp-server/package.json index 7f800ed..65694af 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,11 +1,12 @@ { "name": "graphly-mcp-server", - "version": "1.0.0", + "version": "1.1.0", "description": "Model Context Protocol (MCP) server for Graphly plotting tools", "main": "index.js", "type": "module", "scripts": { - "start": "node index.js" + "start": "node index.js", + "test": "node --test session.test.js" }, "dependencies": {} } diff --git a/mcp-server/session.js b/mcp-server/session.js new file mode 100644 index 0000000..c61ac9c --- /dev/null +++ b/mcp-server/session.js @@ -0,0 +1,96 @@ +import http from 'node:http'; +import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; + +const text = (value, name) => { + if (typeof value !== 'string' || !value.trim() || value.length > 4096) throw new Error(`${name} must be a nonempty string (maximum 4096 characters)`); + return value.trim(); +}; +export function createSessionStore() { + const graphs = new Map(); + const listeners = new Set(); + function get(id) { const graph = graphs.get(id); if (!graph) throw new Error('Unknown graph_id'); return structuredClone(graph); } + function update(id, fn) { + const graph = get(id); fn(graph); graph.revision++; graph.status = 'accepted'; + graphs.set(id, graph); for (const listener of listeners) listener(structuredClone(graph)); return get(id); + } + return { + get, + subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }, + create({ dimension = '3d', title = 'Graphly graph' } = {}) { + if (!['2d', '3d'].includes(dimension)) throw new Error('dimension must be 2d or 3d'); + if (graphs.size >= 100) throw new Error('Session limit reached (100 graphs). Restart the server to clear sessions.'); + const graph = { id: randomUUID(), dimension, title: text(title, 'title'), expressions: [], view: {}, revision: 0, status: 'accepted' }; + graphs.set(graph.id, graph); return get(graph.id); + }, + upsert({ graph_id, expression_id, equation, color, visible = true }) { + equation = text(equation, 'equation'); + if (expression_id !== undefined) expression_id = text(expression_id, 'expression_id'); + if (typeof visible !== 'boolean') throw new Error('visible must be boolean'); + if (color !== undefined && !/^#[0-9a-f]{6}$/i.test(color)) throw new Error('color must be a six digit hex color'); + return update(graph_id, graph => { + const id = expression_id || randomUUID(); + const index = graph.expressions.findIndex(item => item.id === id); + if (index < 0 && graph.expressions.length >= 100) throw new Error('Expression limit reached (100)'); + const item = { ...(index < 0 ? {} : graph.expressions[index]), id, equation, visible, ...(color ? { color } : {}) }; + if (index < 0) graph.expressions.push(item); else graph.expressions[index] = item; + }); + }, + remove({ graph_id, expression_id }) { + return update(graph_id, graph => { if (!graph.expressions.some(e => e.id === expression_id)) throw new Error('Unknown expression_id'); graph.expressions = graph.expressions.filter(e => e.id !== expression_id); }); + }, + setView({ graph_id, bounds, camera }) { + if (!bounds && !camera) throw new Error('Provide bounds or camera'); + if (bounds) { + for (const axis of ['x', 'y', ...(bounds.zMin !== undefined || bounds.zMax !== undefined ? ['z'] : [])]) { + if (!Number.isFinite(bounds[`${axis}Min`]) || !Number.isFinite(bounds[`${axis}Max`]) || bounds[`${axis}Min`] >= bounds[`${axis}Max`]) throw new Error(`Invalid ${axis} bounds`); + } + bounds = Object.fromEntries(Object.entries(bounds).filter(([key]) => /^(x|y|z)(Min|Max)$/.test(key))); + } + if (camera) { + for (const key of ['position', 'target']) if (!Array.isArray(camera[key]) || camera[key].length !== 3 || !camera[key].every(Number.isFinite)) throw new Error('Camera needs finite position and target triples'); + camera = { position: camera.position, target: camera.target }; + if (camera.position.every((v, i) => v === camera.target[i])) throw new Error('Camera position must differ from target'); + } + return update(graph_id, graph => { graph.view = { ...graph.view, ...(bounds ? { bounds } : {}), ...(camera ? { camera } : {}) }; }); + } + }; +} + +export async function startBridge(store, baseUrl) { + const origin = new URL(baseUrl).origin; + const token = randomBytes(32).toString('hex'); + const clients = new Set(); + const server = http.createServer((req, res) => { + if (req.headers.origin !== origin) { res.writeHead(403); res.end('Origin denied'); return; } + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + if (req.method === 'OPTIONS') { + res.setHeader('Access-Control-Allow-Methods', 'GET'); + res.setHeader('Access-Control-Allow-Private-Network', 'true'); + res.writeHead(204); res.end(); return; + } + const url = new URL(req.url, 'http://127.0.0.1'); + const candidate = Buffer.from(url.searchParams.get('token') || ''); + const secret = Buffer.from(token); + if (candidate.length !== secret.length || !timingSafeEqual(candidate, secret)) { res.writeHead(403); res.end('Invalid session token'); return; } + if (req.method !== 'GET' || url.pathname !== '/events') { res.writeHead(404); res.end(); return; } + let graph; + try { graph = store.get(url.searchParams.get('graph')); } catch { res.writeHead(404); res.end('Unknown graph'); return; } + if (clients.size >= 32) { res.writeHead(429); res.end('Too many viewers'); return; } + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' }); + const send = value => res.write(`data: ${JSON.stringify(value)}\n\n`); + send(graph); clients.add(res); + const unsubscribe = store.subscribe(next => { if (next.id === graph.id) send(next); }); + const heartbeat = setInterval(() => res.write(': heartbeat\n\n'), 15000); + req.on('close', () => { clearInterval(heartbeat); unsubscribe(); clients.delete(res); }); + }); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); + return { + url(graphId) { + const url = new URL(baseUrl); + url.hash = `graphlySession=${encodeURIComponent(JSON.stringify({ endpoint: `http://127.0.0.1:${server.address().port}`, token, graphId }))}`; + return url.href; + }, + close() { for (const client of clients) client.end(); server.close(); } + }; +} diff --git a/mcp-server/session.test.js b/mcp-server/session.test.js new file mode 100644 index 0000000..72a46d7 --- /dev/null +++ b/mcp-server/session.test.js @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import readline from 'node:readline'; +import { createSessionStore, startBridge } from './session.js'; + +test('append, edit, isolate, remove and validate graph state atomically', () => { + const store = createSessionStore(); + const graph = store.create(); + store.upsert({ graph_id: graph.id, expression_id: 'sphere', equation: 'x^2+y^2+z^2=9' }); + store.upsert({ graph_id: graph.id, equation: 'z=0' }); + const edited = store.upsert({ graph_id: graph.id, expression_id: 'sphere', equation: 'x^2+y^2+z^2=4' }); + assert.equal(edited.expressions.length, 2); + assert.equal(edited.revision, 3); + edited.expressions.length = 0; + assert.equal(store.get(graph.id).expressions.length, 2); + assert.throws(() => store.setView({ graph_id: graph.id, bounds: { xMin: 5, xMax: 1, yMin: -1, yMax: 1 } })); + assert.equal(store.get(graph.id).revision, 3); + assert.equal(store.remove({ graph_id: graph.id, expression_id: 'sphere' }).expressions.length, 1); + assert.equal(store.create().expressions.length, 0); +}); + +test('bridge enforces capability and exact origin; streams revisions', async t => { + const store = createSessionStore(); + const graph = store.create(); + const bridge = await startBridge(store, 'http://localhost:5173'); + t.after(() => bridge.close()); + const session = JSON.parse(new URLSearchParams(new URL(bridge.url(graph.id)).hash.slice(1)).get('graphlySession')); + const url = `${session.endpoint}/events?graph=${graph.id}&token=${session.token}`; + assert.equal((await fetch(url)).status, 403); + assert.equal((await fetch(url, { headers: { Origin: 'http://evil.example' } })).status, 403); + assert.equal((await fetch(url.replace(session.token, 'bad'), { headers: { Origin: 'http://localhost:5173' } })).status, 403); + const abort = new AbortController(); + t.after(() => abort.abort()); + const response = await fetch(url, { headers: { Origin: 'http://localhost:5173' }, signal: abort.signal }); + assert.equal(response.status, 200); + const reader = response.body.getReader(); + const initial = new TextDecoder().decode((await reader.read()).value); + assert.match(initial, /"revision":0/); + store.upsert({ graph_id: graph.id, equation: 'z=x^2+y^2' }); + const next = new TextDecoder().decode((await reader.read()).value); + assert.match(next, /"revision":1/); + await reader.cancel(); +}); + +test('stdio handles handshake, invalid tool arguments and malformed JSON without hanging', async () => { + const child = spawn(process.execPath, [new URL('./index.js', import.meta.url).pathname], { stdio: ['pipe', 'pipe', 'pipe'] }); + const messages = []; + child.stderr.on('data', data => process.stderr.write(data)); + readline.createInterface({ input: child.stdout }).on('line', line => messages.push(JSON.parse(line))); + const done = new Promise(resolve => child.on('close', resolve)); + child.stdin.end([ + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }), + JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'plot_function', arguments: {} } }), + 'invalid', + JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/list' }) + ].join('\n')); + await done; + assert.equal(messages[0].result.serverInfo.version, '1.1.0'); + assert.equal(messages[1].result.isError, true); + assert.equal(messages[2].error.code, -32700); + assert(messages[3].result.tools.some(tool => tool.name === 'upsert_expression')); +}); + +test('export preserves multiple equations and view without local credentials', async t => { + const child = spawn(process.execPath, [new URL('./index.js', import.meta.url).pathname], { stdio: ['pipe', 'pipe', 'pipe'] }); + t.after(() => child.kill()); + const pending = new Map(); + readline.createInterface({ input: child.stdout }).on('line', line => { + const message = JSON.parse(line); + pending.get(message.id)?.(message.result); + pending.delete(message.id); + }); + let nextId = 0; + const call = (name, args) => new Promise(resolve => { + const id = ++nextId; + pending.set(id, resolve); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }) + '\n'); + }); + const created = await call('create_graph', { dimension: '3d', title: 'Ellipsoid and plane' }); + assert.equal(created.isError, undefined); + const graph_id = created.structuredContent.graph.id; + await call('upsert_expression', { graph_id, equation: 'x^2/9+y^2/4+z^2=1' }); + await call('upsert_expression', { graph_id, equation: 'z=0' }); + await call('set_view', { graph_id, bounds: { xMin: -4, xMax: 4, yMin: -4, yMax: 4, zMin: -2, zMax: 2 } }); + const exported = await call('export_graph', { graph_id }); + const url = new URL(exported.structuredContent.url); + assert.equal(url.hash, ''); + const state = JSON.parse(Buffer.from(url.searchParams.get('state'), 'base64').toString()); + assert.equal(state.mode, '3d'); + assert.equal(state.expressions.length, 2); + assert.equal(state.title, 'Ellipsoid and plane'); + assert.equal(state.view.bounds.zMin, -2); + assert.equal(JSON.stringify(state).includes('token'), false); + assert.equal(JSON.stringify(state).includes('endpoint'), false); + assert.deepEqual(state, exported.structuredContent.state); +}); diff --git a/netlify/functions/plot.js b/netlify/functions/plot.js index 1ae593c..4ec96bc 100644 --- a/netlify/functions/plot.js +++ b/netlify/functions/plot.js @@ -3,7 +3,7 @@ * Endpoint: POST /api/plot or GET /api/plot */ -export async function handler(event, context) { +export async function handler(event) { const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', diff --git a/package.json b/package.json index cd9b0e7..a99b95c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:mcp": "node --test mcp-server/*.test.js" }, "dependencies": { "@google/generative-ai": "^0.24.1", diff --git a/src/App.jsx b/src/App.jsx index 6dd39c4..083524e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -18,7 +18,11 @@ import { FunctionGraph } from './components/FunctionGraph'; import { ThreeDGraph } from './components/ThreeDGraph'; import { AIChatbox } from './components/AIChatbox'; import { Logo, LogoIcon } from './components/Logo'; -import { MathBackground } from './components/MathBackground'; +import { HomePage } from './components/HomePage'; +import { makeSurface, makeDataset, appendDataset } from './lib/graphState.js'; +import { compileSurface } from './lib/surfaceEngine.js'; +import { connectGraphSession } from './lib/graphSessionClient.js'; +import { generateImplicitPoints } from './lib/implicitContours.js'; // SECURITY: Import security utilities for rate limiting, validation, and API key handling import { @@ -30,6 +34,21 @@ import { // --- CONFIGURATION --- const LOCAL_STORAGE_KEY = "graphly_local_data"; +function draftKey(key) { + if (typeof window === 'undefined') return key; + try { + const session = JSON.parse(new URLSearchParams(window.location.hash.slice(1)).get('graphlySession')); + return session?.graphId ? `${key}:${session.graphId}` : key; + } catch { return key; } +} +function readWorkspace(key, fallback) { + if (typeof window === 'undefined') return fallback; + try { + const value = JSON.parse(localStorage.getItem(draftKey(key))); + if (key.endsWith('3d')) return Array.isArray(value) && value.every(s => typeof s.equation === 'string') ? value : fallback; + return value && Array.isArray(value.datasets) && value.globalConfig ? value : fallback; + } catch { return fallback; } +} // --- MATH & UTILS --- @@ -194,12 +213,8 @@ function FunctionEvaluator({ equation }) { export default function App() { const [view, setView] = useState('dashboard'); const [appMode, setAppMode] = useState('data'); // 'data' | 'function' - const [functionList, setFunctionList] = useState([ - { id: 'fn-1', expression: 'sin(x)', color: '#0044FF', visible: true }, - { id: 'fn-2', expression: 'x^2 / 4', color: '#000000', visible: true } - ]); - const [viewportBounds, setViewportBounds] = useState({ xMin: -10, xMax: 10, yMin: -10, yMax: 10 }); - const [currentGraph, setCurrentGraph] = useState(null); + const setViewportBounds = bounds => setZoomDomain({ x: [bounds.xMin, bounds.xMax], y: [bounds.yMin, bounds.yMax] }); + const [currentGraph, setCurrentGraph] = useState(() => readWorkspace('graphly_draft_2d', null)); const [isImporting, setIsImporting] = useState(false); const [showCSVModal, setShowCSVModal] = useState(false); const [csvText, setCsvText] = useState(""); @@ -232,7 +247,6 @@ export default function App() { const [selectedData, setSelectedData] = useState(null); // Cursor position for interactive background - const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); const [cursorCoords, setCursorCoords] = useState(null); const [quickFunctionExpr, setQuickFunctionExpr] = useState(''); @@ -250,8 +264,12 @@ export default function App() { loadGraphs(); }, []); + const didHydrateUrl = useRef(false); + // Hydrate state from shareable URL (?state=... or ?mode=...&fn=...) useEffect(() => { + if (didHydrateUrl.current) return; + didHydrateUrl.current = true; try { const search = window.location.search; if (!search) return; @@ -269,6 +287,20 @@ export default function App() { } const parsed = JSON.parse(jsonStr); + if (Array.isArray(parsed.expressions)) { + if (parsed.expressions.length > 100 || parsed.expressions.some(item => typeof item.equation !== 'string')) throw new Error('Invalid graph snapshot'); + if (parsed.mode === '3d') { + setThreeDSurfaces(parsed.expressions.map((item,i) => ({...makeSurface(item.equation,i),...item}))); + setThreeDView(parsed.view || {}); + setAppMode('3d'); + } else { + setCurrentGraph({...appendDataset(null,makeDataset({equation:'x'})), title:parsed.title || 'Shared graph', datasets:parsed.expressions.map((item,i) => ({...makeDataset({equation:item.equation}),...item,name:item.equation,color:item.color || THEMES[i % THEMES.length].color}))}); + if (parsed.view?.bounds) setViewportBounds(parsed.view.bounds); + setAppMode('2d'); + } + setView('editor'); + return; + } if (parsed.mode === '3d') { if (parsed.expression) { setThreeDEquation(parsed.expression); @@ -368,117 +400,6 @@ export default function App() { }; }, [view, currentGraph]); - const printGraph = () => { - const svgElement = document.querySelector('.recharts-surface'); - if (!svgElement) { - alert("Could not find graph to download."); - return; - } - - // Serialize SVG - const serializer = new XMLSerializer(); - const svgString = serializer.serializeToString(svgElement); - const svgBlob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" }); - const url = URL.createObjectURL(svgBlob); - - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - const img = new Image(); - - // High res for print - img.onload = () => { - const padding = 40; - const titleHeight = 80; - const width = img.width + (padding * 2); - const height = img.height + titleHeight + padding; - - canvas.width = width; - canvas.height = height; - - ctx.fillStyle = "white"; - ctx.fillRect(0, 0, width, height); - - // Title - ctx.font = "bold 36px sans-serif"; - ctx.fillStyle = "#334155"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText(currentGraph.title || "Untitled Graph", width / 2, (titleHeight / 2) + 20); - - // Graph - ctx.drawImage(img, padding, titleHeight); - - const pngUrl = canvas.toDataURL("image/png"); - - // --- PRINTING VIA IFRAME --- - // This isolates the print content from the main app UI - const printFrame = document.createElement('iframe'); - printFrame.style.position = 'fixed'; - printFrame.style.right = '0'; - printFrame.style.bottom = '0'; - printFrame.style.width = '0'; - printFrame.style.height = '0'; - printFrame.style.border = '0'; - document.body.appendChild(printFrame); - - // Detect orientation based on screen aspect ratio - const isLandscape = window.innerWidth > window.innerHeight; - const pageSize = isLandscape ? 'landscape' : 'portrait'; - // A4 dimensions in mm - const a4Width = isLandscape ? 297 : 210; - const a4Height = isLandscape ? 210 : 297; - - const frameDoc = printFrame.contentWindow.document; - frameDoc.open(); - frameDoc.write(` - - - - - - - - - `); - frameDoc.close(); - - // Wait for image to load in iframe then print - printFrame.onload = () => { - printFrame.contentWindow.focus(); - printFrame.contentWindow.print(); - - // Cleanup - setTimeout(() => { - document.body.removeChild(printFrame); - URL.revokeObjectURL(url); - }, 1000); - }; - }; - - img.src = url; - }; - const exportCSV = () => { if (!currentGraph || !currentGraph.datasets.length) return; let csvContent = "data:text/csv;charset=utf-8,"; @@ -536,7 +457,7 @@ export default function App() { }; const createFunctionGraph = (expression = 'sin(x) * x') => { - const cleanExpr = expression.replace(/^(y\s*=\s*|f\(x\)\s*=\s*)/i, '').trim(); + const cleanExpr = expression.trim(); const newDs = { id: `ds-${Date.now()}`, name: `f(x) = ${cleanExpr}`, @@ -991,7 +912,11 @@ export default function App() { // This gives the "Desmos" feel of infinite scrolling const range = currentDomain.x[1] - currentDomain.x[0]; const buffer = range * 0.5; // Render a bit outside view - points = generateFunctionPoints(ds.equation, currentDomain.x[0] - buffer, currentDomain.x[1] + buffer, 400); + try { + points = compileMathFunction(ds.equation)?.type === 'implicit' + ? generateImplicitPoints(ds.equation, {xMin:currentDomain.x[0],xMax:currentDomain.x[1],yMin:currentDomain.y[0],yMax:currentDomain.y[1]}) + : generateFunctionPoints(ds.equation, currentDomain.x[0] - buffer, currentDomain.x[1] + buffer, 400); + } catch { points = []; } } else { // Standard Data Dataset points = ds.data.map(d => ({ @@ -1091,7 +1016,7 @@ export default function App() { try { e.currentTarget.setPointerCapture(e.pointerId); - } catch (err) { + } catch { // ignore if capture fails } @@ -1170,7 +1095,7 @@ export default function App() { activePointers.current.delete(e.pointerId); try { e.currentTarget.releasePointerCapture(e.pointerId); - } catch (err) { + } catch { // ignore } @@ -1267,34 +1192,40 @@ export default function App() { }; // --- AI CHAT TOOL HANDLERS --- - const [threeDEquation, setThreeDEquation] = useState('sin(x) * cos(y)'); + const [threeDSurfaces, setThreeDSurfaces] = useState(() => readWorkspace('graphly_draft_3d', [])); + const [liveStatus, setLiveStatus] = useState(''); + const [threeDView, setThreeDView] = useState({}); + + useEffect(() => { try { localStorage.setItem(draftKey('graphly_draft_2d'), JSON.stringify(currentGraph)); } catch { /* Workspace remains available in memory. */ } }, [currentGraph]); + useEffect(() => { try { localStorage.setItem(draftKey('graphly_draft_3d'), JSON.stringify(threeDSurfaces)); } catch { /* Workspace remains available in memory. */ } }, [threeDSurfaces]); + + useEffect(() => connectGraphSession({ + onGraph: graph => { + setLiveStatus(`Live graph · revision ${graph.revision}`); + setAppMode(graph.dimension); + setView('editor'); + if (graph.dimension === '3d') { + setThreeDSurfaces(previous => [...previous.filter(s => !s.sessionGraphId), ...graph.expressions.map((item, i) => ({ ...makeSurface(item.equation, i), ...item, name: item.equation, sessionGraphId: graph.id }))]); + setThreeDView(graph.view || {}); + } else { + setCurrentGraph(previous => { + const base = previous || appendDataset(null, makeDataset({equation:'x'})); + return { ...base, title: graph.title, datasets: [...(previous?.datasets || []).filter(d => !d.sessionGraphId), ...graph.expressions.map((item, i) => ({ ...makeDataset({equation:item.equation}), ...item, name:item.equation, color:item.color || THEMES[i % THEMES.length].color, sessionGraphId:graph.id }))] }; + }); + if (graph.view?.bounds) setZoomDomain({x:[graph.view.bounds.xMin,graph.view.bounds.xMax],y:[graph.view.bounds.yMin,graph.view.bounds.yMax]}); + } + }, + onError: error => setLiveStatus(error.message), + }), []); + const setThreeDEquation = expression => setThreeDSurfaces([makeSurface(expression)]); const handleAIPlotFunction = (expression) => { setAppMode('2d'); setView('editor'); - const cleanExpr = (expression || 'sin(x)').replace(/^(y\s*=\s*|f\(x\)\s*=\s*)/i, '').trim(); - const newDs = { - id: `ds-${Date.now()}`, - name: cleanExpr.includes('=') ? cleanExpr : `f(x) = ${cleanExpr}`, - data: [], - equation: cleanExpr, - visible: true, - color: THEMES[(currentGraph?.datasets?.length || 0) % THEMES.length].color, - config: { - type: 'function', - xKey: 'x', - yKey: 'y', - showTrendline: false, - trendlineType: 'linear', - trendlineColor: '#ef4444' - } - }; - if (currentGraph) { - setCurrentGraph(prev => ({ ...prev, datasets: [...prev.datasets, newDs] })); - setExpandedDatasetId(newDs.id); - } else { - createFunctionGraph(cleanExpr); - } + const cleanExpr = (expression || 'sin(x)').trim(); + const newDs = makeDataset({ equation: cleanExpr, name: cleanExpr.includes('=') ? cleanExpr : `f(x) = ${cleanExpr}` }); + setCurrentGraph(prev => appendDataset(prev, newDs)); + setExpandedDatasetId(newDs.id); }; const handleAIPlotImplicit = (expression) => { @@ -1304,42 +1235,44 @@ export default function App() { const handleAILoadDataTable = (name, rows) => { setAppMode('2d'); setView('editor'); - const newDataset = { - id: `ds-${Date.now()}`, - name: name || "AI Data", - data: rows, - visible: true, - color: THEMES[0].color, - config: { type: 'scatter', xKey: 'x', yKey: 'y', showTrendline: true, trendlineType: 'linear', trendlineColor: '#ef4444' } - }; - if (currentGraph) { - setCurrentGraph(prev => ({ ...prev, datasets: [...prev.datasets, newDataset] })); - } else { - setCurrentGraph({ - title: name || "AI Data Plot", - datasets: [newDataset], - globalConfig: { showGrid: true, enableZoom: true, xAxisLabel: "X", yAxisLabel: "Y", aspectRatio: "auto", showLabels: false }, - annotations: [], - createdAt: new Date().toISOString() - }); - } + if (!Array.isArray(rows) || !rows.length || rows.some(row => !Number.isFinite(row.x) || !Number.isFinite(row.y))) throw new Error('Provide a nonempty table with numeric x and y values.'); + const newDataset = makeDataset({ name: name || 'AI Data', rows }); + setCurrentGraph(prev => appendDataset(prev, newDataset)); + setExpandedDatasetId(newDataset.id); }; const handleAISwitchTo3D = (expression) => { if (expression && typeof expression === 'string') { - setThreeDEquation(expression); + compileSurface(expression); + setThreeDSurfaces(prev => [...prev, makeSurface(expression, prev.length)]); } setAppMode('3d'); setView('editor'); }; + const handleAIUpdateExpression = (layerId, expression) => { + const surface = threeDSurfaces.find(s => s.id === layerId); + if (surface) { + compileSurface(expression); + setThreeDSurfaces(previous => previous.map(s => s.id === layerId ? {...s,equation:expression} : s)); + setAppMode('3d'); setView('editor'); return; + } + const dataset = currentGraph?.datasets.find(d => d.id === layerId && d.config.type === 'function'); + if (!dataset) throw new Error('No expression with that layer ID exists.'); + if (!compileMathFunction(expression) || /\bz\b/.test(expression)) throw new Error('Enter a valid 2D equation.'); + setCurrentGraph(previous => ({...previous,datasets:previous.datasets.map(d => d.id === layerId ? {...d,equation:expression,name:expression} : d)})); + setAppMode('2d'); setView('editor'); + }; + const handleAISetViewportBounds = (bounds) => { + if (![bounds.xMin,bounds.xMax,bounds.yMin,bounds.yMax].every(Number.isFinite) || bounds.xMin >= bounds.xMax || bounds.yMin >= bounds.yMax) throw new Error('Provide increasing finite viewport bounds.'); setViewportBounds(bounds); }; return (
+ {liveStatus &&
{liveStatus}
} {/* PRINT STYLES - ROBUST */}