diff --git a/.github/workflows/build-push-main.yml b/.github/workflows/build-push-main.yml deleted file mode 100644 index 65c6b20..0000000 --- a/.github/workflows/build-push-main.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Main Build Test - -on: - schedule: - - cron: "30 3 * * 0" # Runs every Sunday at 03:30 UTC - workflow_dispatch: # Allows manual triggering - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - - permissions: - # Give the default GITHUB_TOKEN write permission to commit and push the - # added or changed files to the repository. - contents: write - - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - with: - repository: eelab-dev/EEcircuit - - - name: Update Dependencies - run: npx --yes npm-check-updates -u - - - name: Install Dependencies - run: npm install - - - name: Build locally - run: npm run build - - - name: Install Playwright Browsers - run: npx playwright install --with-deps - - - name: Run Playwright tests - run: npx playwright test -g "EEcircuit Prod" - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 diff --git a/.github/workflows/build-push-next.yml b/.github/workflows/build-push-next.yml deleted file mode 100644 index 95d2e15..0000000 --- a/.github/workflows/build-push-next.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Next Build Deploy Test Push - -env: - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - GH_TOKEN: ${{secrets.PULL_REQ}} - -on: - schedule: - - cron: "0 0 * * 0" # Runs every Sunday at midnight - workflow_dispatch: # Allows manual triggering - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - - permissions: - # Give the default GITHUB_TOKEN write permission to commit and push the - # added or changed files to the repository. - contents: write - - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - with: - repository: eelab-dev/EEcircuit - - - name: Update Dependencies - run: npx --yes npm-check-updates -u - - - name: Install Dependencies - run: npm install - - - name: Update engine to next version - run: npm install eecircuit-engine@next - - - name: Pull Vercel env - env: - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - run: npx vercel pull --yes --token "$VERCEL_TOKEN" - - - name: Build Local for Vercel - env: - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - run: npx vercel build --prod --token "$VERCEL_TOKEN" - - - name: Deploy to Vercel - id: deploy - env: - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - run: | - set -euo pipefail - url=$(npx vercel deploy --prebuilt --prod --token "$VERCEL_TOKEN") - echo "url=$url" >> "$GITHUB_OUTPUT" - - - name: Alias deployment - if: ${{ steps.deploy.outputs.url != '' }} - env: - VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} - run: npx vercel alias "${{ steps.deploy.outputs.url }}" next.eecircuit.com --scope danchitnis-projects --token "$VERCEL_TOKEN" - - - name: check deployment - run: curl -s https://next.eecircuit.com | head -n 50 - - - name: Install Playwright Browsers - run: npx playwright install --with-deps - - - name: Run Playwright tests - run: npx playwright test -g "EEcircuit Next" - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3b02157..114eb6d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,7 +16,9 @@ jobs: node-version: "20" - name: Install npm packages - run: npm install + run: npm install --legacy-peer-deps - name: Build run: npm run build + env: + NODE_OPTIONS: "--max-old-space-size=4096" diff --git a/.gitignore b/.gitignore index 923af6a..16a962e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,8 @@ tests/repos test-results playwright-report .DS_Store +deploy_github_npm.py +switch_git_remote.py +TODO_filesystem.md +TODO_AI.md +TODO_spectre_PDK.md diff --git a/README.md b/README.md index db60fbd..1968c6d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,11 @@ # EEcircuit -EEcircuit is a circuit simulator based on [ngspice](https://sourceforge.net/p/ngspice/ngspice/) that operates directly in your browser using [WebAssembly](https://webassembly.org/) technology. It takes a spice-based netlist as input and produces analysis results from your simulations as output. You can visualize and plot the results in the browser using the high-performance WebGL plotting library, [webgl-plot](https://github.com/danchitnis/webgl-plot), or download the data in CSV format for further analysis. Importantly, your netlist and results are processed locally, meaning they always remain within your browser and are never uploaded to a server. This project focuses on facilitating rapid analysis and sharing of circuit ideas and results within the [VLSI](https://en.wikipedia.org/wiki/Very_Large_Scale_Integration) and chip-design communities. Additionally, since EEcircuit uses a text-based netlist as input, you can utilize [Git](https://git-scm.com/) for version control to track your changes effectively. +EEcircuit is a circuit simulator based on ngspice that operates directly in your browser using WebAssembly technology. It takes a spice-based netlist as input and produces analysis results from your simulations as output. You can visualize and plot the results in the browser using the high-performance WebGL plotting library, **webgl-plot**, or download the data in CSV format for further analysis. Importantly, your netlist and results are processed locally, meaning they always remain within your browser and are never uploaded to a server. This project focuses on facilitating rapid analysis and sharing of circuit ideas and results within the VLSI and chip-design communities. Additionally, since EEcircuit uses a text-based netlist as input, you can utilize Git for version control to track your changes effectively. + +### What's new + +The plot viewer has been significantly enhanced with an advanced interactive experience: three independent measurement cursors (**A**, **B**, **M**) can be placed on any curve via keyboard shortcuts, with delta readouts in the legend. Each curve now has a **per-curve color picker** accessible directly from the legend. Zoom and pan have been reworked — scroll to zoom the X axis, Shift+Scroll for the Y axis, right-click drag for rectangle zoom, and middle-click drag to pan. A full keyboard shortcut system covers cursor placement, view fit, zoom steps, and item deletion. The platform also demonstrates AI-assisted circuit design and debugging through integration with **Anthropic Claude**, **Google Gemini**, and **OpenAI GPT**. ## Getting started @@ -34,7 +38,26 @@ vin 1 0 0 pulse (0 1.8 0 0.1 0.1 15 30) ## Usage -Use your mouse to pan & zoom on the plot. left click for area **zoom** and right click hold and drag for **pan**. To reset the view **double click**. +### Plot controls + +| Action | Result | +|--------|--------| +| **Scroll** | Zoom X axis | +| **Shift + Scroll** | Zoom Y axis | +| **Right-click drag** | Zoom in on X axis (rectangle select) | +| **Middle-click drag** | Pan X + Y | +| **Left-click** | Select curve or cursor marker | + +### Keyboard shortcuts + +| Key | Action | +|-----|--------| +| `a` / `b` / `m` | Place cursor A / B / M on nearest curve | +| `c` | Clear all cursors | +| `f` | Fit view (reset zoom) | +| `[` / `]` | Zoom out / zoom in (X axis) | +| `Delete` | Remove selected curve or cursor | +| `Esc` | Deselect | ## Documentation diff --git a/TODO_AI.md b/TODO_AI.md new file mode 100644 index 0000000..c21d044 --- /dev/null +++ b/TODO_AI.md @@ -0,0 +1,194 @@ +# TODO: AI Chat Backend Proxy + +## Goal + +Allow public EEcircuit users to ask 3 AI-powered circuit questions per 24 hours +without needing their own API key. Rate limiting is enforced server-side. + +## Architecture + +``` +EEcircuit website (GitHub Pages, static) + ↓ POST /api/chat (no user key required) +Vercel Edge Function (eecircuit-api.vercel.app) + ↓ check rate limit → Vercel KV (Redis) + ↓ forward to OpenAI with secret key + ← AI response +EEcircuit website +``` + +## Rate Limiting + +- **3 requests per user per 24 hours** (rolling window) +- Identity: IP address + browser fingerprint (localStorage UUID + user agent hash) +- MAC address is NOT accessible from a browser (network layer only) +- Storage: Vercel KV (free tier: 30MB, sufficient for millions of rate limit entries) +- After 3 requests: show message "Free quota used. Provide your own API key to continue, + or try again in 24 hours." + +## Why Not Expose the Key in the Frontend? + +Any API key embedded in browser JS is visible in DevTools / network tab / page source. +Anyone can extract it and make unlimited calls. The proxy keeps the key server-side +as a Vercel environment variable, never sent to the browser. + +## Selected Models (OpenAI only for the free tier) + +| Model | Use case | +|---|---| +| `gpt-5.2` | Default — latest, best reasoning | +| `gpt-5.2-pro` | Premium option for users with own key | +| `gpt-4o` | Fallback — reliable, lower cost | + +## Implementation Steps + +### 1. New Vercel project: `eecircuit-api` + +Small project (~20 lines), separate from EEcircuit website repo. + +**File: `api/chat.ts`** (Vercel Edge Function) +```typescript +import { kv } from "@vercel/kv"; + +export const config = { runtime: "edge" }; + +const FREE_QUOTA = 3; +const WINDOW_MS = 24 * 60 * 60 * 1000; // 24 hours + +export default async function handler(req: Request) { + // CORS headers + if (req.method === "OPTIONS") { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "https://gaofeng-fan.github.io", + "Access-Control-Allow-Methods": "POST", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); + } + + // Rate limit check + const ip = req.headers.get("x-forwarded-for") ?? "unknown"; + const body = await req.json(); + const fingerprint = body.fingerprint ?? ""; + const key = `rl:${ip}:${fingerprint}`; + + // User providing their own key bypasses rate limit + const userKey = body.userApiKey; + const apiKey = userKey || process.env.OPENAI_API_KEY!; + + if (!userKey) { + const count = (await kv.get(key)) ?? 0; + if (count >= FREE_QUOTA) { + return Response.json( + { error: "Free quota (3/day) exceeded. Provide your own OpenAI API key to continue." }, + { status: 429 } + ); + } + await kv.set(key, count + 1, { px: WINDOW_MS }); + } + + // Forward to OpenAI + const openaiRes = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: body.model ?? "gpt-5.2", + messages: body.messages, + max_tokens: 1024, + }), + }); + + const data = await openaiRes.json(); + return Response.json(data, { + headers: { "Access-Control-Allow-Origin": "https://gaofeng-fan.github.io" }, + }); +} +``` + +### 2. Vercel setup + +1. Create new repo `eecircuit-api`, push to GitHub +2. Import into Vercel dashboard +3. Add env var: `OPENAI_API_KEY=sk-...` +4. Add Vercel KV store (Storage tab → KV → Create) +5. Deploy → get URL: `https://eecircuit-api.vercel.app` + +### 3. EEcircuit frontend changes + +In `src/ai/aiClient.ts`: +- Default endpoint: `https://eecircuit-api.vercel.app/api/chat` +- If user provides own key: call OpenAI directly (bypass proxy) +- Send `fingerprint` field: `localStorage.getItem("userId") || crypto.randomUUID()` + (generate once, persist in localStorage) + +In `src/ai/AiChat.tsx`: +- Show remaining free quota: "2 free questions remaining today" +- Show input for own API key (optional, for power users) +- After quota exhausted: show "Provide your own key or wait 24h" message + +## Cost Estimate + +- gpt-5.2: ~$5/MTok input, ~$20/MTok output +- Per question: ~2000 tokens context + ~500 output ≈ $0.015 +- 3 questions/user/day @ 100 users/day = 300 questions = $4.50/day +- Scale to 1000 users/day = $45/day — monitor and add stricter limits if needed + +## Notes + +- Keep "bring your own key" option in the UI — useful for power users and testing +- The proxy only supports OpenAI for the free tier (simpler, single key to manage) +- Anthropic and Google remain available for users with their own keys +- Add CORS restriction to only allow requests from the EEcircuit GitHub Pages domain + +--- + +## Monetization + +The AI feature has two natural revenue angles: + +### 1. AI question credits (pay-per-use) + +Beyond the 3 free questions/day, sell credit packs: + +| Tier | Price | Credits | +|---|---|---| +| Free | $0 | 3/day | +| Starter | $5 | 100 credits | +| Pro | $20 | 500 credits | +| Unlimited | $15/month | Unlimited | + +Each "credit" = one AI question. Implementation: Stripe one-time payment or +subscription → store credit balance in Vercel KV keyed by user ID. + +### 2. Domain-specific analog AI (premium model) + +Fine-tune or prompt-engineer a model specifically for analog circuit design: +- Trained on SPICE netlists, analog design textbooks, application notes +- Understands analogpy syntax natively +- Can suggest component values, explain simulation results, debug convergence + +Charge a premium for this specialized model vs. the generic GPT/Claude free tier. +Users who pay get routed to the fine-tuned model; free tier uses the standard model. + +### 3. AI-assisted PDK-aware design (long-term) + +Combine with the Spectre/PDK cloud compute (see TODO_spectre_PDK.md): +- AI suggests sizing for a specific process node (e.g. TSMC N28) +- Automatically generates a simulation-ready netlist +- Runs the simulation on cloud backend +- Returns results with AI interpretation + +This closes the loop: design → simulate → explain, all in one session. +Bundle pricing: AI credits + simulation credits in one subscription. + +### Implementation path + +1. Add user accounts (Auth0 or Clerk) — prerequisite for all paid tiers +2. Integrate Stripe for credit purchases +3. Store credit balance in Vercel KV +4. Route free vs. paid users to different model endpoints +5. Dashboard: show remaining credits, purchase history diff --git a/TODO_filesystem.md b/TODO_filesystem.md new file mode 100644 index 0000000..6b4ae26 --- /dev/null +++ b/TODO_filesystem.md @@ -0,0 +1,142 @@ +# TODO: Multi-Tab Editor + Local Filesystem Access + +## Goal + +Replace the single-file Monaco editor with a multi-tab, project-folder-aware IDE experience. +Users can open a local folder on their PC, edit all files as tabs, and have changes written +back to disk in real time — no upload/download required. + +--- + +## Background + +### Current localStorage behavior + +The website already auto-saves to **browser localStorage** on every simulation run: + +| localStorage key | Content | +|---|---| +| `netList` | SPICE netlist | +| `pythonCode` | Python (analogpy) code | +| `editorMode` | `"spice"` or `"python"` | +| `displayData` | Plot signal visibility settings | +| `aiApiKey` / `aiProvider` / `aiModel` | AI chat settings | + +This is transparent to users — no UI notification exists. A small status indicator +("auto-saved locally") would be a low-effort improvement. + +### The `.include` problem + +`tb.include("modelcard.CMOS90")` references a file that doesn't exist in the browser +environment. Users cannot see or edit model parameters. Multi-tab + filesystem access +solves this naturally — the model file is just another tab. + +--- + +## Architecture + +### Monaco Editor model system + +Monaco natively supports multiple **models** (one per file): + +```typescript +const model = monaco.editor.createModel(content, language, uri); +editor.setModel(model); // swap active model = switch tab +``` + +Tab chrome (the tab bar UI) must be built separately — Monaco provides the engine, +not the UI frame. + +### File System Access API + +Modern browser API for reading/writing local files with user consent: + +```typescript +// User picks a folder → browser shows OS folder picker +const dirHandle = await window.showDirectoryPicker(); + +// Read a file +const fileHandle = await dirHandle.getFileHandle("modelcard.CMOS90"); +const file = await fileHandle.getFile(); +const content = await file.text(); + +// Write back after edit +const writable = await fileHandle.createWritable(); +await writable.write(newContent); +await writable.close(); +``` + +The folder path is shown via `dirHandle.name` (folder name only; full path is not +exposed by the browser for security reasons). + +--- + +## Implementation Plan + +### Level 1 — Hardcoded modelcard tab (read-only, no filesystem) + +- Add a second Monaco model with hardcoded CMOS90 model content +- Simple tab bar above editor: `[ netlist ] [ modelcard.CMOS90 ]` +- No write-back; purely informational +- ~50-100 lines + +### Level 2 — Editable modelcard tab (recommended first step) + +- Two tabs: primary file + `modelcard.CMOS90` +- Editable with a Read-only / Edit toggle button per tab +- Sim engine intercepts `.include("modelcard.CMOS90")` and injects the edited content +- Content saved to localStorage alongside netlist +- ~150-250 lines; requires tracing `.include` resolution in `sim/` + +### Level 3 — Full local filesystem (File System Access API) + +- "Open Folder" button → `showDirectoryPicker()` +- All `.sp` / `.py` / `.mod` files in the folder open as tabs automatically +- Edits write back to disk in real time +- Folder name shown in header (e.g. `📁 my_project/`) +- `.include` resolution reads sibling files from the directory handle +- Graceful fallback to localStorage for unsupported browsers + +--- + +## Browser Support + +| Browser | `showDirectoryPicker` | Notes | +|---|---|---| +| Chrome / Edge | Full support | Best experience | +| Firefox | Partial | Flag required as of 2025 | +| Safari | Not supported | Common on Mac — needs fallback | + +**Mitigation:** detect support on load; show a banner for unsupported browsers +directing them to Chrome/Edge. localStorage fallback keeps the app functional. + +--- + +## UX Design Notes + +- Tab bar above the Monaco editor (not the bottom panel tabs) +- Each tab shows filename; active tab highlighted +- "Open Folder" button in the editor toolbar +- Read-only / Edit toggle per tab (pencil icon) +- Unsaved indicator (dot on tab) if write-back fails +- On first visit or unsupported browser: single-tab mode with localStorage (current behavior) + +--- + +## Files to Modify + +| File | Change | +|---|---| +| `src/editor/editorCustom.tsx` | Add multi-model support, tab bar UI | +| `src/EEcircuit.tsx` | Pass file content map to editor; handle "Open Folder" state | +| `src/sim/` | Trace `.include` resolution; inject virtual file content | + +--- + +## Open Questions + +1. How does `sim/` currently resolve `.include` directives? Does it ignore them, error, or + pass them through to the WASM ngspice layer? +2. Should the "Open Folder" session persist across page reloads? (The File System Access API + requires re-granting permission each session unless the site is installed as a PWA.) +3. Should Level 2 (editable modelcard) ship before Level 3, as a stepping stone? diff --git a/index.html b/index.html index cb53364..ff31085 100644 --- a/index.html +++ b/index.html @@ -6,42 +6,60 @@ - EEcircuit - + analogpy + - + - + - + - + - -
- - - - - - - - - - - - - - - - a SPICE based circuit simulator -
diff --git a/package-lock.json b/package-lock.json index bd6ac8a..6f78ccd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,14 @@ "version": "0.0.1", "license": "MIT", "dependencies": { + "-": "^0.0.1", "@chakra-ui/react": "^3.27.1", "@emotion/react": "^11.14.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.1", - "@vercel/analytics": "^1.5.0", + "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^1.2.0", + "baseline-browser-mapping": "^2.10.0", "comlink": "^4.4.2", "eecircuit-engine": "^1.5.6", "monaco-editor": "^0.54.0", @@ -52,6 +54,12 @@ "@rollup/rollup-linux-x64-gnu": "4.52.4" } }, + "node_modules/-": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/-/-/--0.0.1.tgz", + "integrity": "sha512-3HfneK3DGAm05fpyj20sT3apkNcvPpCuccOThOPdzz8sY7GgQGe0l93XH9bt+YzibcTIgUAIMoyVJI740RtgyQ==", + "license": "UNLICENSED" + }, "node_modules/@ark-ui/react": { "version": "5.26.0", "resolved": "https://registry.npmjs.org/@ark-ui/react/-/react-5.26.0.tgz", @@ -151,6 +159,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -488,6 +497,7 @@ "version": "3.27.1", "resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-3.27.1.tgz", "integrity": "sha512-cjvowtELfber0OKDWt2Udl/I4SVKnA89pFZA+I1EG6uwlUZRpgOdwQZVEQceMmc4iVRFbUJudoAuqWhQIl/Irw==", + "peer": true, "dependencies": { "@ark-ui/react": "^5.24.1", "@emotion/is-prop-valid": "^1.3.1", @@ -690,6 +700,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -755,6 +766,74 @@ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", + "integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz", + "integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz", + "integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz", + "integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/darwin-arm64": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz", @@ -772,6 +851,346 @@ "node": ">=18" } }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz", + "integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz", + "integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz", + "integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz", + "integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz", + "integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz", + "integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz", + "integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz", + "integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz", + "integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz", + "integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz", + "integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz", + "integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz", + "integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz", + "integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz", + "integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz", + "integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz", + "integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz", + "integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz", + "integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz", + "integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", @@ -1023,6 +1442,7 @@ "version": "3.10.0", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz", "integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==", + "peer": true, "dependencies": { "@swc/helpers": "^0.5.0" } @@ -1998,6 +2418,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz", "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~7.14.0" } @@ -2012,6 +2433,7 @@ "version": "19.2.2", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2067,6 +2489,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.0.tgz", "integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.0", "@typescript-eslint/types": "8.46.0", @@ -2282,9 +2705,9 @@ } }, "node_modules/@vercel/analytics": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.5.0.tgz", - "integrity": "sha512-MYsBzfPki4gthY5HnYN7jgInhAZ7Ac1cYDoRWFomwGHWEX7odTEzbtg9kf/QSo7XEsEAqlQugA6gJ2WS2DEa3g==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.6.1.tgz", + "integrity": "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==", "license": "MPL-2.0", "peerDependencies": { "@remix-run/react": "^2", @@ -3719,6 +4142,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4149,12 +4573,15 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.16", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz", - "integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==", - "dev": true, + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -4258,6 +4685,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -5632,6 +6060,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8503,6 +8932,7 @@ "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8511,6 +8941,7 @@ "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8729,6 +9160,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", "dev": true, + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9654,6 +10086,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -9918,6 +10351,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10161,6 +10595,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -10313,6 +10748,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -10620,21 +11056,6 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, - "node_modules/yaml": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", diff --git a/package.json b/package.json index 82e0c13..54d3375 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "vercel": "vercel", "serve": "serve dist", "dev": "vite", - "build": "vite build", + "build": "node --max-old-space-size=4096 node_modules/.bin/vite build", "preview": "vite preview", "check": "tsc --noEmit" }, @@ -42,12 +42,14 @@ "vite-tsconfig-paths": "^5.1.4" }, "dependencies": { + "-": "^0.0.1", "@chakra-ui/react": "^3.27.1", "@emotion/react": "^11.14.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.1", - "@vercel/analytics": "^1.5.0", + "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^1.2.0", + "baseline-browser-mapping": "^2.10.0", "comlink": "^4.4.2", "eecircuit-engine": "^1.5.6", "monaco-editor": "^0.54.0", diff --git a/public/analogpy.jpg b/public/analogpy.jpg new file mode 100644 index 0000000..bc4d30e Binary files /dev/null and b/public/analogpy.jpg differ diff --git a/public/analogpy.png b/public/analogpy.png new file mode 100644 index 0000000..a802712 Binary files /dev/null and b/public/analogpy.png differ diff --git a/src/EEcircuit.tsx b/src/EEcircuit.tsx index 844dce6..ac70650 100644 --- a/src/EEcircuit.tsx +++ b/src/EEcircuit.tsx @@ -60,14 +60,20 @@ import { DisplayDataType, makeDD } from "./displayData.ts"; import { useColorMode, useColorModeValue, + ColorModeButton, } from "./components/ui/color-mode.tsx"; +import { PyodideRunner } from "./python/pyodideRunner.ts"; +import AiChat from "./ai/AiChat.tsx"; + +type EditorMode = "spice" | "python"; let sim: SimArray; +let pyRunner: PyodideRunner | null = null; const store = globalThis.localStorage; let initialSimInfo = ""; let threadCount = 1; -const circuitDefault = `Basic RLC circuit +const circuitDefault = `Basic RLC circuit .include modelcard.CMOS90 r vdd 2 100.0 @@ -81,9 +87,51 @@ vin 1 0 0 pulse (0 1.8 0 0.1 0.1 15 30) .end`; -export default function EEcircuit(): JSX.Element { - // Create the count state. +const pythonDefault = `from analogpy import ( + Testbench, resistor, capacitor, inductor, + nmos, vsource, vpulse, Transient, DC, + generate_ngspice, generate_spectre, +) + +tb = Testbench("rlc_circuit") +tb.include("modelcard.CMOS90") + +vdd_net = tb.net("vdd") +net_rlc = tb.net("net_rlc") # shared node: R, L, C and MOSFET drain +gate = tb.net("gate") # MOSFET gate / input signal +gnd = tb.gnd() + +# Passive components +r_inst = tb.add_instance(resistor, "r", p=vdd_net, n=net_rlc, r=100.0, + schematic_position={'y_shift': -0.1}) +l_inst = tb.add_instance(inductor, "l", p=vdd_net, n=net_rlc, l=1) +c_inst = tb.add_instance(capacitor, "c", p=vdd_net, n=net_rlc, c=0.01) + +# NMOS transistor +m1_inst = tb.add_instance(nmos, "m1", d=net_rlc, g=gate, s=gnd, b=gnd, + model="N90", w=100e-6, l=0.09e-6, + schematic_position={'relative_to': l_inst.name, 'x_shift': 0.5, 'y_shift': -2}) + +# Power supply +vdd_inst = tb.add_instance(vsource, "vdd", p=vdd_net, n=gnd, dc=1.8, + schematic_position={'relative_to': "vin", 'x_shift': -4, 'y_shift': 0}) +# Input pulse +vin_inst = tb.add_instance(vpulse, "vin", p=gate, n=gnd, + val0=0, val1=1.8, delay=0, + rise=0.1, fall=0.1, width=15, period=30, + schematic_position={'relative_to': m1_inst.name, 'x_shift': -2, 'y_shift': -1}) + + +tb.add_analysis(Transient(stop=50, step=0.05)) +tb.add_analysis(DC()) + +tb.draw_wires('gate') +tb.draw_wires('vdd') +tb.draw_wires('net_rlc') +tb.draw_wires('0') +`; +export default function EEcircuit(): JSX.Element { const [isSimLoaded, setIsSimLoaded] = React.useState(false); const [isSimLoading, setIsSimLoading] = React.useState(false); const [isSimRunning, setIsSimRunning] = React.useState(false); @@ -92,15 +140,55 @@ export default function EEcircuit(): JSX.Element { const [netList, setNetList] = React.useState(circuitDefault); const [displayData, setDisplayData] = React.useState(); const [tabIndex, setTabIndex] = React.useState(0); + const [activeTab, setActiveTab] = React.useState("plot"); const [sweep, setSweep] = React.useState(false); const [progress, setProgress] = React.useState(0); const [threadCountNew, setThreadCountNew] = React.useState(1); + // Python / editor mode state + const [editorMode, setEditorMode] = React.useState( + () => (store.getItem("editorMode") as EditorMode) || "python" + ); + const [pythonCode, setPythonCode] = React.useState( + () => store.getItem("pythonCode") || pythonDefault + ); + const [generatedNgspice, setGeneratedNgspice] = React.useState(""); + const [generatedSpectre, setGeneratedSpectre] = React.useState(""); + const [schematicSvg, setSchematicSvg] = React.useState(""); + const [schematicZoom, setSchematicZoom] = React.useState(1.0); + const [isPyLoading, setIsPyLoading] = React.useState(false); + const [isFullscreen, setIsFullscreen] = React.useState(false); + const [isEditorMax, setIsEditorMax] = React.useState(false); + const [isSchematicMax, setIsSchematicMax] = React.useState(false); + const [isEditorWide, setIsEditorWide] = React.useState(false); + const [isMinimized, setIsMinimized] = React.useState(false); + const pythonOutputRef = React.useRef(""); + const cursorStateRef = React.useRef({ + a: { x: 0, y: 0, visible: false, name: "" }, + b: { x: 0, y: 0, visible: false, name: "" }, + m: { x: 0, y: 0, visible: false, name: "" }, + }); + const tabsContainerRef = React.useRef(null); + + const toggleFullscreen = React.useCallback(() => { + if (!isFullscreen) { + tabsContainerRef.current?.scrollIntoView({ behavior: "smooth" }); + } + setIsFullscreen(f => !f); + }, [isFullscreen]); + const colorMode = useColorModeValue("light", "dark"); useEffect(() => { - const loadedNetList = store.getItem("netList"); - setNetList(loadedNetList ? loadedNetList : circuitDefault); + const loadedMode = store.getItem("editorMode") as EditorMode | null; + if (loadedMode === "python") { + setEditorMode("python"); + const loadedPy = store.getItem("pythonCode"); + if (loadedPy) setPythonCode(loadedPy); + } else { + const loadedNetList = store.getItem("netList"); + setNetList(loadedNetList ? loadedNetList : circuitDefault); + } const loadedDisplayDataString = store.getItem("displayData"); if (loadedDisplayDataString) { @@ -138,45 +226,24 @@ export default function EEcircuit(): JSX.Element { if (resultArray && resultArray.results.length > 0) { const newDD = makeDD(resultArray.results[0], colorMode); const tempDD = [] as DisplayDataType[]; - newDD.forEach((newData, i) => { - let match = false; + newDD.forEach((newData) => { let visible = true; - let color = getColor(colorMode); if (displayData) { displayData.forEach((oldData) => { - //account for new color type - if (newData.name === oldData.name && oldData.color) { - match = true; + if (newData.name === oldData.name) { visible = oldData.visible; - color = oldData.color; } }); - if (match) { - tempDD.push({ - name: newData.name, - index: newData.index, - visible: visible, - color: color, - }); - } else { - tempDD.push({ - name: newData.name, - index: newData.index, - visible: true, - color: newData.color, - }); - } - } else { - tempDD.push({ - name: newData.name, - index: newData.index, - visible: true, - color: newData.color, - }); } + + tempDD.push({ + name: newData.name, + index: newData.index, + visible, + color: newData.color, // always use freshly computed HSL color + }); }); - console.log("makeDD->", tempDD); setDisplayData([...tempDD]); } }, [resultArray]); @@ -191,14 +258,125 @@ export default function EEcircuit(): JSX.Element { }, []);*/ const btRun = async () => { + // Python mode: run Python code first to generate netlist + if (editorMode === "python") { + setIsSimRunning(true); + store.setItem("pythonCode", pythonCode); + + // Initialize Pyodide if needed + if (!pyRunner || !pyRunner.isReady) { + setIsPyLoading(true); + toaster.create({ + description: "Loading Python runtime (first time only)...", + type: "info", + }); + pyRunner = new PyodideRunner(); + try { + await pyRunner.init(); + } catch (e) { + toaster.create({ + description: "Failed to load Python runtime: " + (e instanceof Error ? e.message : String(e)), + type: "error", + }); + setIsPyLoading(false); + setIsSimRunning(false); + return; + } + setIsPyLoading(false); + } + + // Run Python code + const pyResult = await pyRunner.runPython(pythonCode); + if (pyResult.error) { + const errMsg = `[Python Error]\n${pyResult.error}`; + setInfo(errMsg); + setActiveTab("info"); + toaster.create({ + description: "Python error — see Info tab for details", + type: "error", + }); + setIsSimRunning(false); + return; + } + + if (!pyResult.ngspice) { + const errMsg = "[Python Error]\nCode did not produce an ngspice netlist.\nMake sure to call print(generate_ngspice(tb)) or add a Testbench named 'tb'."; + setInfo(errMsg); + setActiveTab("info"); + toaster.create({ + description: "No netlist generated — see Info tab", + type: "error", + }); + setIsSimRunning(false); + return; + } + + setGeneratedNgspice(pyResult.ngspice); + setGeneratedSpectre(pyResult.spectre); + const svgData = pyResult.schematicSvg || ""; + console.log("schematicSvg length:", svgData.length, "first 100:", svgData.substring(0, 100)); + setSchematicSvg(svgData); + + // Store Python stdout for the Info tab + pythonOutputRef.current = pyResult.stdout || ""; + + // Use the generated netlist for simulation + setNetList(pyResult.ngspice); + setIsSimRunning(false); + + // Now run the simulation with the generated netlist + await runSimulation(pyResult.ngspice); + return; + } + + // SPICE mode: run directly + store.setItem("netList", netList); + await runSimulation(netList); + }; + + const runSimulation = async (netlistToRun: string) => { if (sim && threadCount === threadCountNew) { setIsSimRunning(true); - //setParser(getParser(netList)); - store.setItem("netList", netList); - sim.setNetList(netList); - const resultArray = await sim.runSim(); - setResultArray(resultArray); - setInfo(initialSimInfo + "\n\n" + (await sim.getInfo()) + "\n\n"); + sim.setNetList(netlistToRun); + try { + const resultArray = await sim.runSim(); + const errors = await sim.getError(); + if (errors.length > 0) { + setInfo(prev => prev + "\n\n[Simulation Error]\n" + errors.join("\n")); + setActiveTab("info"); + errors.forEach((e) => { + toaster.create({ + description: e, + type: "error", + }); + }); + } + if (resultArray.results.length === 0) { + toaster.create({ + description: "Simulation returned no results. Check your netlist syntax.", + type: "error", + }); + } else { + setResultArray(resultArray); + toaster.create({ + description: `Simulation complete ✓`, + type: "success", + }); + setActiveTab("plot"); + } + const pyPrefix = pythonOutputRef.current + ? `[Python output]\n${pythonOutputRef.current}\n` + : ""; + // Build a representative ngspice command from the netlist title line + const netlistTitle = netlistToRun.split("\n")[0].trim().replace(/\s+/g, "_") || "circuit"; + const fakeCmd = `$ ngspice ${netlistTitle}.sp\n`; + setInfo(pyPrefix + fakeCmd + "\n" + initialSimInfo + "\n\n" + (await sim.getInfo()) + "\n\n"); + } catch (e) { + toaster.create({ + description: e instanceof Error ? e.message : String(e), + type: "error", + }); + } setIsSimRunning(false); } else { //spawn worker thread @@ -213,8 +391,7 @@ export default function EEcircuit(): JSX.Element { setIsSimLoaded(true); setIsSimLoading(false); setProgress(0); - //initialSimInfo = await sim.getInfo(); //not yet working??????? - btRun(); + runSimulation(netlistToRun); } }; @@ -230,27 +407,16 @@ export default function EEcircuit(): JSX.Element { const change = React.useCallback( (name: string, check: boolean) => { - //const name = event; - - //index 0 is time - - if (isSimLoaded && displayData) { - const dd = displayData; - - dd.forEach((dd) => { - if (dd.name === name) { - dd.visible = check; - console.log("change->", check, name); - } - }); - console.log("change->", dd); - - setDisplayData([...dd]); - const stringDD = JSON.stringify(dd); - store.setItem("displayData", stringDD); - } + if (!displayData) return; + + const newDisplayData = displayData.map(item => + item.name === name ? { ...item, visible: check } : item + ); + + setDisplayData(newDisplayData); + store.setItem("displayData", JSON.stringify(newDisplayData)); }, - [displayData, isSimLoaded] + [displayData] ); const handleTabChange = (index: number) => { @@ -259,9 +425,23 @@ export default function EEcircuit(): JSX.Element { const handleEditor = React.useCallback((value: string | undefined) => { if (value) { - setNetList(value); + if (editorMode === "python") { + setPythonCode(value); + } else { + setNetList(value); + } } - }, []); + }, [editorMode]); + + const handleModeSwitch = React.useCallback(() => { + const newMode = editorMode === "spice" ? "python" : "spice"; + setEditorMode(newMode); + store.setItem("editorMode", newMode); + // Clear generated netlists when switching mode + setGeneratedNgspice(""); + setGeneratedSpectre(""); + setSchematicSvg(""); + }, [editorMode]); const handleDeSelectButton = React.useCallback(() => { if (displayData) { @@ -305,10 +485,19 @@ export default function EEcircuit(): JSX.Element { } setDisplayData(d); - //setResultArray({results:[...results], sweep:[...resultArray.sweep]}); } }, [displayData]); + const handleColorChange = React.useCallback( + (name: string, color: { r: number; g: number; b: number }) => { + if (!displayData) return; + setDisplayData(displayData.map(item => + item.name === name ? { ...item, color } : item + )); + }, + [displayData] + ); + const LineSelectBox = (): JSX.Element => { return ( @@ -372,125 +561,265 @@ export default function EEcircuit(): JSX.Element { }, []); return ( -
- - - }> - - - {displayBreakpoint == "base" ? <> : LineSelectBox()} - - - - - +
+ {/* Top Header Row: Branding + Controls */} + {!isFullscreen && ( + + + {/* Branding */} + + + + + + + + + + + + + + + + + + + + a SPICE based circuit simulator + + + + {/* Controls */} + - - { setOpen(e.open)} > Threads - - { - - - - } + + + - } - - - - - - - - - + + + - - + + + + + + + + + )} + + {/* Editor + Schematic panel — grows when tabs are minimized */} + + + {/* Left: text editor */} + + + + }> + + + + {/* Right: schematic panel — only on desktop */} + {displayBreakpoint !== "base" && ( + + {schematicSvg && !schematicSvg.startsWith("", wglp.linesData); - }, [resultArray, displayData]); + // Map displayData for faster lookup + const visibilityMap = new Map(); + displayData?.forEach((e) => { + visibilityMap.set(e.index, e.visible); + }); - useEffect(() => { - //console.log("plot->DD->", displayData); - //console.log("plot->DD->", wglp.linesData); - if (resultArray && displayData) { - if (resultArray.sweep.length > 0) { - displayData.forEach((e) => { - for (let i = 0; i < resultArray.sweep.length; i++) { - //wglp.linesData[(e.index - 1) * resultsArray.sweep.length + i].visible = e.visible; - const offset = isComplex(resultArray) ? 2 : 1; - const line = - wglp.linesData[e.index - offset + i * displayData.length]; - if (line) { - line.visible = e.visible; - } - } - }); - scaleUpdate(findMinMaxGlobal()); - //} - } else { - if (wglp.linesData.length == displayData.length) { - displayData.forEach((e) => { - //first item is time (offset=1) or frequency (offset=2) - const offset = isComplex(resultArray) ? 2 : 1; - wglp.linesData[e.index - offset].visible = e.visible; - }); - scaleUpdate(findMinMaxGlobal()); + // Update visibility using the tagged metadata + const offset = isComplex(resultArray) ? 2 : 1; + wglp.linesData.forEach((line: any) => { + if (line.sourceIndex !== undefined) { + const isVisible = visibilityMap.get(line.sourceIndex + offset); + line.visible = isVisible !== undefined ? isVisible : true; } + }); + + // Only reset view when new simulation data arrives, not on visibility/color/points changes + if (resultArray !== prevResultArrayRef.current) { + scaleUpdate(findMinMaxGlobal()); + prevResultArrayRef.current = resultArray; } } - //console.log("CANVAS CANVAS!!!!!!!!!", wglp.lines); - }, [displayData]); + // Re-add auxiliary elements + wglp.addSurface(zoomRect); + wglp.addAuxLine(crossXLine); + wglp.addAuxLine(crossYLine); + wglp.addAuxLine(cursorXLineA); + wglp.addAuxLine(cursorYLineA); + wglp.addAuxLine(cursorXLineB); + wglp.addAuxLine(cursorYLineB); + wglp.addAuxLine(cursorXLineM); + wglp.addAuxLine(cursorYLineM); + wglp.addSurface(dotA); + wglp.addSurface(dotB); + wglp.addSurface(dotM); + + cursorXLineA.visible = cursorA.visible; + cursorYLineA.visible = cursorA.visible; + cursorXLineB.visible = cursorB.visible; + cursorYLineB.visible = cursorB.visible; + cursorXLineM.visible = cursorM.visible; + cursorYLineM.visible = cursorM.visible; + + if (!resultArray || resultArray.results.length === 0) { + wglp.gOffsetX = -1; + wglp.gScaleX = 2; + } + }, [resultArray, displayData, plotOptions.showPoints]); + + // Remove the redundant second useEffect for visibility const findMinMaxGlobal = (): ScaleType => { - //??????????????????????? - let minY = 1e6; let maxY = -1e6; let minX = 0; let maxX = 1; for (let i = 0; i < wglp.linesData.length; i++) { - if (wglp.linesData[i].visible) { - const e = lineMinMax[i]; - maxY = maxY > e.maxY ? maxY : e.maxY; - minY = minY < e.minY ? minY : e.minY; + const line = wglp.linesData[i] as any; + if (line.visible && !line.isPoints) { + // Find corresponding entry in lineMinMax + // lineMinMax is populated per variable, so we use sourceIndex + const e = lineMinMax[line.sourceIndex]; + if (e) { + maxY = maxY > e.maxY ? maxY : e.maxY; + minY = minY < e.minY ? minY : e.minY; + } } } if (lineMinMax[0]) { @@ -386,6 +644,108 @@ function PlotArray({ return minmax; }; + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const key = e.key.toLowerCase(); + if (key === "a" || key === "b" || key === "m") { + if (!wglp || wglp.linesData.length === 0) return; + + // Find closest point among visible lines + let bestPoint = { x: 0, y: 0 }; + let bestName = ""; + let minDistanceSq = Infinity; + let found = false; + + wglp.linesData.forEach((baseLine) => { + if (!baseLine.visible) return; + const line = baseLine as WebglLine; + + const numPoints = line.numPoints; + let localBestX = 0; + let localBestY = 0; + let localMinDX = Infinity; + + for (let i = 0; i < numPoints; i++) { + const px = line.getX(i); + const py = line.getY(i); + const dx = Math.abs(px - crossXY.x); + if (dx < localMinDX) { + localMinDX = dx; + localBestX = px; + localBestY = py; + } + } + + if (localMinDX !== Infinity) { + const distSq = (localBestX - crossXY.x) ** 2 + (localBestY - crossXY.y) ** 2; + if (distSq < minDistanceSq) { + minDistanceSq = distSq; + bestPoint = { x: localBestX, y: localBestY }; + // Resolve signal name from displayData via sourceIndex + const srcIdx = (line as any).sourceIndex; + const offset = isComplex(resultArray) ? 2 : 1; + const dd = displayData?.find(d => d.index === srcIdx + offset); + bestName = dd ? dd.name : ""; + found = true; + } + } + }); + + if (found) { + if (key === "a") { + setCursorA({ ...bestPoint, visible: true, name: bestName }); + } else if (key === "b") { + setCursorB({ ...bestPoint, visible: true, name: bestName }); + } else { + setCursorM({ ...bestPoint, visible: true, name: bestName }); + } + } + } + + // 'c' to clear cursors + if (key === "c") { + setCursorA(prev => ({ ...prev, visible: false })); + setCursorB(prev => ({ ...prev, visible: false })); + setCursorM(prev => ({ ...prev, visible: false })); + } + + // 'f' to reset view + if (key === "f") { + scaleUpdate(findMinMaxGlobal()); + } + + // '[' to zoom out, ']' to zoom in (X axis, centered on current view) + if (key === "[" || key === "]") { + const factor = key === "]" ? 1.2 : 1 / 1.2; + const center = -wglp.gOffsetX / wglp.gScaleX; + wglp.gScaleX *= factor; + wglp.gOffsetX = -center * wglp.gScaleX; + } + + // Delete — remove selected item + if (e.key === "Delete" || e.key === "Backspace") { + const sel = selectedItemRef.current; + if (sel?.type === "cursor") { + if (sel.name === "A") setCursorA(prev => ({ ...prev, visible: false })); + else if (sel.name === "B") setCursorB(prev => ({ ...prev, visible: false })); + else if (sel.name === "M") setCursorM(prev => ({ ...prev, visible: false })); + setSelectedItem(null); + } else if (sel?.type === "curve" && checkCallBack) { + checkCallBack(sel.name, false); + setSelectedItem(null); + } + } + + // Escape — deselect + if (e.key === "Escape") { + setSelectedItem(null); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [crossXY]); + const scaleUpdate = (scale: ScaleType) => { let diffY = 0; let avgY = 0; @@ -413,29 +773,84 @@ function PlotArray({ const mouseDown = (e: React.MouseEvent) => { e.preventDefault(); - const eOffset = (e.target as HTMLCanvasElement).getBoundingClientRect().x; - //console.log(e.clientX - eOffset); //offset from the edge of the element + const rect = (e.target as HTMLCanvasElement).getBoundingClientRect(); + const eOffset = rect.x; + const width = rect.width; + const height = rect.height; + + // Shared select logic — only updates selection if something is hit, never deselects + const trySelect = () => { + if (!wglp) return; + const normX = ((e.clientX - rect.left) / width) * 2 - 1; + const normY = 1 - ((e.clientY - rect.top) / height) * 2; + const dataX = (normX - wglp.gOffsetX) / wglp.gScaleX; + const dataY = (normY - wglp.gOffsetY) / wglp.gScaleY; + const threshX = 25 / (width / 2) / wglp.gScaleX; + const threshY = 25 / (height / 2) / wglp.gScaleY; + + // Check cursor dots first + const cursors: Array<{ key: string; state: CursorState }> = [ + { key: "A", state: cursorARef.current }, + { key: "B", state: cursorBRef.current }, + { key: "M", state: cursorMRef.current }, + ]; + for (const { key, state } of cursors) { + if (state.visible && Math.abs(dataX - state.x) < threshX * 2 && Math.abs(dataY - state.y) < threshY * 2) { + setSelectedItem({ type: "cursor", name: key }); + return; + } + } + + // Check curves — convert to screen pixels, find closest named non-points line + const toScreenX = (dx: number) => (dx * wglp.gScaleX + wglp.gOffsetX + 1) / 2 * width; + const toScreenY = (dy: number) => (1 - (dy * wglp.gScaleY + wglp.gOffsetY)) / 2 * height; + const clickScreenX = (e.clientX - rect.left); + const clickScreenY = (e.clientY - rect.top); + let bestName = ""; + let bestDist = Infinity; + wglp.linesData.forEach((baseLine: any) => { + if (!baseLine.visible) return; + if (baseLine.isPoints) return; + if (!baseLine._name) return; + const line = baseLine as WebglLine; + for (let i = 0; i < line.numPoints; i++) { + const sx = toScreenX(line.getX(i)) - clickScreenX; + const sy = toScreenY(line.getY(i)) - clickScreenY; + const dist = sx * sx + sy * sy; + if (dist < bestDist) { bestDist = dist; bestName = baseLine._name ?? ""; } + } + }); + if (bestName) setSelectedItem({ type: "curve", name: bestName }); + }; + // Left click: selection only if (e.button == 0) { - (e.target as HTMLCanvasElement).style.cursor = "pointer"; - const width = (e.target as HTMLCanvasElement).getBoundingClientRect() - .width; - const cursorDownX = (2 * (e.clientX - eOffset - width / 2)) / width; - setMouseZoom({ + leftDragRef.current = { started: true, - cursorDownX: cursorDownX, - cursorOffsetX: 0, - }); - zoomRect.visible = true; + dragInitialX: 0, dragInitialY: 0, dragOffsetOld: 0, dragOffsetOldY: 0, + startClientX: e.clientX, startClientY: e.clientY, + isDragging: false, isZooming: false, longPressTimer: null, + }; } + + // Right click: X-axis zoom-in rectangle + select if (e.button == 2) { + (e.target as HTMLCanvasElement).style.cursor = "crosshair"; + const cursorDownX = (2 * (e.clientX - eOffset - width / 2)) / width; + setMouseZoom({ started: true, cursorDownX, cursorDownY: 0, cursorOffsetX: 0, cursorOffsetY: 0, zoomOut: false }); + zoomRect.visible = true; + trySelect(); + } + + // Middle click: pan X+Y + if (e.button == 1) { (e.target as HTMLCanvasElement).style.cursor = "grabbing"; - const dragInitialX = (e.clientX - eOffset) * devicePixelRatio; - const dragOffsetOld = wglp.gOffsetX; setMouseDrag({ started: true, - dragInitialX: dragInitialX, - dragOffsetOld: dragOffsetOld, + dragInitialX: (e.clientX - eOffset) * devicePixelRatio, + dragInitialY: (e.clientY - rect.top) * devicePixelRatio, + dragOffsetOld: wglp.gOffsetX, + dragOffsetOldY: wglp.gOffsetY, }); } }; @@ -452,7 +867,10 @@ function PlotArray({ setMouseZoom({ started: true, cursorDownX: mouseZoom.cursorDownX, - cursorOffsetX: cursorOffsetX, + cursorDownY: 0, + cursorOffsetX, + cursorOffsetY: 0, + zoomOut: mouseZoom.zoomOut, }); const z1 = (mouseZoom.cursorDownX - wglp.gOffsetX) / wglp.gScaleX; const z2 = (cursorOffsetX - wglp.gOffsetX) / wglp.gScaleX; @@ -469,13 +887,15 @@ function PlotArray({ ]);*/ zoomRect.visible = true; } - /************Mouse Drag Evenet********* */ + /************Mouse Drag Event (middle click) ********* */ if (mouseDrag.started) { - const moveX = - (e.clientX - xOffset) * devicePixelRatio - mouseDrag.dragInitialX; - const offsetX = moveX / width; - wglp.gOffsetX = offsetX + mouseDrag.dragOffsetOld; + const moveX = (e.clientX - xOffset) * devicePixelRatio - mouseDrag.dragInitialX; + const moveY = (e.clientY - yOffSet) * devicePixelRatio - mouseDrag.dragInitialY; + wglp.gOffsetX = moveX / width + mouseDrag.dragOffsetOld; + wglp.gOffsetY = -(moveY / height) + mouseDrag.dragOffsetOldY; } + + /************Right-drag: zoom rect (handled by mouseZoom state) ********* */ /*****************cross hair************** */ const canvas = canvasMain.current; @@ -499,53 +919,119 @@ function PlotArray({ const mouseUp = (e: React.MouseEvent) => { e.preventDefault(); - const eOffset = (e.target as HTMLCanvasElement).getBoundingClientRect().x; - if (mouseZoom.started) { - const width = (e.target as HTMLCanvasElement).getBoundingClientRect() - .width; - const cursorUpX = (2 * (e.clientX - eOffset - width / 2)) / width; - const zoomFactor = - Math.abs(cursorUpX - mouseZoom.cursorDownX) / (2 * wglp.gScaleX); - const offsetFactor = - (mouseZoom.cursorDownX + cursorUpX - 2 * wglp.gOffsetX) / - (2 * wglp.gScaleX); - - if (zoomFactor > 0) { - wglp.gScaleX = 1 / zoomFactor; - wglp.gOffsetX = -offsetFactor / zoomFactor; + const rect = (e.target as HTMLCanvasElement).getBoundingClientRect(); + const width = rect.width; + const height = rect.height; + + // Left click up: select only + if (e.button == 0 && leftDragRef.current.started && wglp) { + const normX = ((e.clientX - rect.left) / width) * 2 - 1; + const normY = 1 - ((e.clientY - rect.top) / height) * 2; + const dataX = (normX - wglp.gOffsetX) / wglp.gScaleX; + const dataY = (normY - wglp.gOffsetY) / wglp.gScaleY; + const threshX = 25 / (width / 2) / wglp.gScaleX; + const threshY = 25 / (height / 2) / wglp.gScaleY; + let hit = false; + for (const { key, state } of [ + { key: "A", state: cursorARef.current }, + { key: "B", state: cursorBRef.current }, + { key: "M", state: cursorMRef.current }, + ] as Array<{ key: string; state: CursorState }>) { + if (state.visible && Math.abs(dataX - state.x) < threshX * 2 && Math.abs(dataY - state.y) < threshY * 2) { + setSelectedItem({ type: "cursor", name: key }); + hit = true; + break; + } } + if (!hit) { + const toScreenX = (dx: number) => (dx * wglp.gScaleX + wglp.gOffsetX + 1) / 2 * width; + const toScreenY = (dy: number) => (1 - (dy * wglp.gScaleY + wglp.gOffsetY)) / 2 * height; + const clickScreenX = e.clientX - rect.left; + const clickScreenY = e.clientY - rect.top; + let bestName = ""; + let bestDist = Infinity; + wglp.linesData.forEach((baseLine: any) => { + if (!baseLine.visible) return; + if (baseLine.isPoints) return; + if (!baseLine._name) return; + const line = baseLine as WebglLine; + for (let i = 0; i < line.numPoints; i++) { + const sx = toScreenX(line.getX(i)) - clickScreenX; + const sy = toScreenY(line.getY(i)) - clickScreenY; + const dist = sx * sx + sy * sy; + if (dist < bestDist) { bestDist = dist; bestName = baseLine._name ?? ""; } + } + }); + console.log(`[SELECT] result="${bestName}" bestDist=${Math.sqrt(bestDist).toFixed(1)}px`); + if (bestName) setSelectedItem({ type: "curve", name: bestName }); + } + leftDragRef.current.started = false; + } - setMouseZoom({ started: false, cursorDownX: 0, cursorOffsetX: 0 }); + // Stop right-click zoom rect — X-axis zoom only + if (e.button == 2) { + if (mouseZoom.started) { + const cursorUpX = (2 * (e.clientX - rect.left - width / 2)) / width; + const dragDistX = Math.abs(cursorUpX - mouseZoom.cursorDownX); + if (dragDistX > 0.03) { + const zoomFactor = dragDistX / (2 * wglp.gScaleX); + const offsetFactor = (mouseZoom.cursorDownX + cursorUpX - 2 * wglp.gOffsetX) / (2 * wglp.gScaleX); + wglp.gScaleX = 1 / zoomFactor; + wglp.gOffsetX = -offsetFactor / zoomFactor; + } + setMouseZoom({ started: false, cursorDownX: 0, cursorDownY: 0, cursorOffsetX: 0, cursorOffsetY: 0, zoomOut: false }); + zoomRect.visible = false; + } } - /************Mouse Drag Evenet********* */ - setMouseDrag({ started: false, dragInitialX: 0, dragOffsetOld: 0 }); + + // Stop middle-click pan + setMouseDrag({ started: false, dragInitialX: 0, dragInitialY: 0, dragOffsetOld: 0, dragOffsetOldY: 0 }); (e.target as HTMLCanvasElement).style.cursor = "grab"; zoomRect.visible = false; }; const doubleClick = (e: React.MouseEvent) => { e.preventDefault(); - scaleUpdate(findMinMaxGlobal()); - setZoomStatus({ scale: wglp.gScaleX, offset: wglp.gOffsetX }); }; function wheelEvent(e: React.WheelEvent) { - //e.preventDefault(); - //const eOffset = (e.target as HTMLCanvasElement).getBoundingClientRect().x; - //const width = (e.target as HTMLCanvasElement).getBoundingClientRect().width; - //const cursorOffsetX = (2 * (e.clientX - eOffset - width / 2)) / width; + // On macOS, Shift+scroll swaps deltaY to deltaX, so use whichever is non-zero + const delta = e.shiftKey ? (e.deltaX || e.deltaY) : e.deltaY; + + const canvas = canvasMain.current; + if (!canvas) return; + + // Convert mouse position to normalized canvas coordinates (-1 to +1) + const rect = canvas.getBoundingClientRect(); + const mouseNormX = ((e.clientX - rect.left) / rect.width) * 2 - 1; + const mouseNormY = 1 - ((e.clientY - rect.top) / rect.height) * 2; + if (e.shiftKey) { - //offset += e.deltaY * 0.1; - //wglp.gOffsetX = 0.1 * offset; + // Shift+scroll: Y-axis zoom centered on mouse Y position + const oldScaleY = wglp.gScaleY; + let newScaleY = oldScaleY; + if (delta < 0) { + newScaleY = oldScaleY + -1 * delta * (oldScaleY * 0.001); + } else { + newScaleY = oldScaleY - delta * (oldScaleY * 0.001); + } + // Keep data point under mouse fixed: mouseNormY = dataY * newScaleY + newOffsetY + const dataY = (mouseNormY - wglp.gOffsetY) / oldScaleY; + wglp.gScaleY = newScaleY; + wglp.gOffsetY = mouseNormY - dataY * newScaleY; } else { - let scale = wglp.gScaleX; - if (e.deltaY < 0) { - scale = wglp.gScaleX + -1 * e.deltaY * (wglp.gScaleX * 0.001); + // Normal scroll: X-axis zoom centered on mouse X position + const oldScaleX = wglp.gScaleX; + let newScaleX = oldScaleX; + if (delta < 0) { + newScaleX = oldScaleX + -1 * delta * (oldScaleX * 0.001); } else { - scale = wglp.gScaleX - e.deltaY * (wglp.gScaleX * 0.001); + newScaleX = oldScaleX - delta * (oldScaleX * 0.001); } - - wglp.gScaleX = 1 * scale; + // Keep data point under mouse fixed: mouseNormX = dataX * newScaleX + newOffsetX + const dataX = (mouseNormX - wglp.gOffsetX) / oldScaleX; + wglp.gScaleX = newScaleX; + wglp.gOffsetX = mouseNormX - dataX * newScaleX; } } @@ -559,6 +1045,12 @@ function PlotArray({ setPlotOptions(o); }; + const pointsBoxHandle = (e: CheckboxCheckedChangeDetails) => { + const o = { ...plotOptions }; + o.showPoints = e.checked === true; + setPlotOptions(o); + }; + useEffect(() => { crossXLine.visible = plotOptions.crosshair; crossYLine.visible = plotOptions.crosshair; @@ -601,7 +1093,7 @@ function PlotArray({ const canvasStyle = { width: "100%", - height: "60vh", + height: heightProp ?? "40vh", } as React.CSSProperties; /*const handleLog10YCheckbox = (e: React.ChangeEvent) => { @@ -634,51 +1126,108 @@ function PlotArray({ return ( <> - - - Axis - - - Crosshair - - {plotOptions.crosshair ? ( - <> - - {`X: ${unitConvert2string(crossXY.x, 3)}`} - - - {`Y: ${unitConvert2string(crossXY.y, 3)}`} - - - ) : ( - <> - )} - {isSweep ? ( + + + + Axis + + + Crosshair + + + Points + + + + + + + {showShortcuts && ( + + + Keyboard Shortcuts + + + a / b / m — place cursor A / B / M on nearest curve + c — clear cursors + f — fit view + [ / ] — zoom out / zoom in (X axis) + Click curve/cursor — select it (highlights) + Delete — remove selected curve/cursor + Esc — deselect + Scroll — zoom X axis + Shift + Scroll — zoom Y axis + Left-click — select curve/marker + Right-click drag — zoom in X axis + Middle-click drag — pan X+Y + + )} + + + + + + + {plotOptions.crosshair && ( + <> + + {`X: ${unitConvert2string(crossXY.x, 3)}`} + + + {`Y: ${unitConvert2string(crossXY.y, 3)}`} + + + )} + + {checkCallBack && ( + setShowLegend(e.checked === true)} + > + Legend + + )} + + + + {isSweep ? ( + Sweep slider - ) : ( - <> - )} - {plotOptions.sweepSlider && isSweep ? ( - <> + {plotOptions.sweepSlider && ( {`${unitConvert2string(sliderValue, 3)}`} - - ) : ( - <> - )} - - {/*Neg - Log10X - - Log10Y - */} - + )} + + ) : null} {plotOptions.sweepSlider && isSweep ? ( )} - - + - {isAxis ? ( - - ) : ( - <> - )} - - - - + + + {isAxis ? ( + + ) : ( + <> + )} + + + {isAxis ? ( + + ) : ( + <> + )} + + + + + + {selectedItem && (() => { + let bg = "#ECC94B"; // yellow + let color = "black"; + if (selectedItem.type === "cursor") { + bg = selectedItem.name === "A" ? "#ED8936" : selectedItem.name === "B" ? "#4299E1" : "#48BB78"; + color = "white"; + } else { + const line = wglp?.linesData.find((l: any) => l._name === selectedItem.name) as any; + if (line?._origColor) { + const { r, g, b } = line._origColor; + bg = `rgb(${Math.round(r*255)},${Math.round(g*255)},${Math.round(b*255)})`; + const luminance = 0.299 * r + 0.587 * g + 0.114 * b; + color = luminance < 0.5 ? "white" : "black"; + } + } + return ( + + + {selectedItem.type === "cursor" ? `Marker ${selectedItem.name}` : `"${selectedItem.name}"`} selected — Del to remove · Esc to deselect + + + ); + })()} + {(cursorA.visible || cursorB.visible || cursorM.visible) && ( + + {cursorA.visible && ( + + {`A${cursorA.name ? ` [${cursorA.name}]` : ""}: (${unitConvert2string(cursorA.x, 3)}, ${unitConvert2string(cursorA.y, 3)})`} + + )} + {cursorB.visible && ( + + {`B${cursorB.name ? ` [${cursorB.name}]` : ""}: (${unitConvert2string(cursorB.x, 3)}, ${unitConvert2string(cursorB.y, 3)})`} + + )} + {cursorA.visible && cursorB.visible && (() => { + const dx = cursorB.x - cursorA.x; + const dy = cursorB.y - cursorA.y; + return ( + + {`dX: ${unitConvert2string(Math.abs(dx), 3)}, dY: ${unitConvert2string(Math.abs(dy), 3)}, dy/dx: ${unitConvert2string(dx !== 0 ? dy / dx : Infinity, 3)}`} + + ); + })()} + {cursorM.visible && ( + + {`M${cursorM.name ? ` [${cursorM.name}]` : ""}: (${unitConvert2string(cursorM.x, 3)}, ${unitConvert2string(cursorM.y, 3)})`} + + )} + + )} + + + + + {/* Overlay legend */} + {checkCallBack && showLegend && displayData && displayData.length > 0 && ( + + + + + + + + + + + + {displayData.map((d) => ( + + + + { + const v = e.target.value; + const r = parseInt(v.slice(1, 3), 16) / 255; + const g = parseInt(v.slice(3, 5), 16) / 255; + const b = parseInt(v.slice(5, 7), 16) / 255; + onColorChange?.(d.name, { r, g, b }); + }} + /> + + checkCallBack(d.name, e.checked === true)} + > + + {d.name} + + + {cursorA.visible && cursorA.name === d.name && ( + + )} + {cursorB.visible && cursorB.name === d.name && ( + + )} + {cursorM.visible && cursorM.name === d.name && ( + + )} + + ))} + - - - - {isAxis ? ( - - ) : ( - <> - )} - - + )} + ); } diff --git a/src/python/pyodideRunner.ts b/src/python/pyodideRunner.ts new file mode 100644 index 0000000..ebc68c5 --- /dev/null +++ b/src/python/pyodideRunner.ts @@ -0,0 +1,54 @@ +/** + * PyodideRunner - manages the Pyodide Web Worker for executing + * analogpy Python code in the browser. + */ + +import * as ComLink from "comlink"; +import type { PyodideWorkerType, PythonResult } from "./pyodideWorker.ts"; + +export type { PythonResult }; + +export class PyodideRunner { + private worker: ComLink.Remote | null = null; + private _isReady = false; + private _isLoading = false; + + get isReady(): boolean { + return this._isReady; + } + + get isLoading(): boolean { + return this._isLoading; + } + + async init(): Promise { + if (this._isReady) return "already initialized"; + if (this._isLoading) return "loading in progress"; + + this._isLoading = true; + + const rawWorker = new Worker( + new URL("./pyodideWorker.ts", import.meta.url), + { type: "module" } + ); + this.worker = ComLink.wrap(rawWorker); + + // Call init without any complex baseUrl detection + const msg = await this.worker.init(); + this._isReady = true; + this._isLoading = false; + return msg; + } + + async runPython(code: string): Promise { + if (!this.worker || !this._isReady) { + return { + ngspice: "", + spectre: "", + schematicSvg: "", + error: "PyodideRunner not initialized. Call init() first.", + }; + } + return await this.worker.runPython(code); + } +} \ No newline at end of file diff --git a/src/python/pyodideWorker.ts b/src/python/pyodideWorker.ts new file mode 100644 index 0000000..1b88b4d --- /dev/null +++ b/src/python/pyodideWorker.ts @@ -0,0 +1,167 @@ +/** + * Pyodide Web Worker + * + * Loads Pyodide (Python WASM runtime) and executes analogpy Python code. + * Returns the generated ngspice and Spectre netlists. + * + * Communication via Comlink RPC. + */ + +import * as ComLink from "comlink"; + +// Pyodide types (loaded dynamically from CDN) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let pyodide: any = null; +let isReady = false; + +export type PythonResult = { + ngspice: string; + spectre: string; + schematicSvg: string; + stdout: string; + error: string; +}; + +const pyodideWorker = { + /** + * Initialize Pyodide and install analogpy. + */ + async init(): Promise { + if (isReady) return "already initialized"; + + // Revert to reliable CDN loading for local development + const pyodideModule = await import( + /* @vite-ignore */ + "https://cdn.jsdelivr.net/pyodide/v0.27.5/full/pyodide.mjs" + ); + pyodide = await pyodideModule.loadPyodide(); + + // Prepare micropip + await pyodide.loadPackage("micropip"); + const micropip = pyodide.pyimport("micropip"); + + // Pre-load binary packages + try { + await pyodide.loadPackage(["numpy", "matplotlib"]); + } catch (e) { + console.warn("Failed to pre-load binary packages:", e); + } + + // Install pure python dependencies + await micropip.install(["pyyaml", "schemdraw"]); + + // Install analogpy + await micropip.install("analogpy", {keep_going: true}); + + isReady = true; + return "Pyodide ready with analogpy"; + }, + + /** + * Execute Python code and capture generated netlists. + */ + async runPython(code: string): Promise { + if (!isReady) { + return { + ngspice: "", + spectre: "", + schematicSvg: "", + error: "Pyodide not initialized. Call init() first.", + }; + } + + try { + const wrappedCode = ` +import sys +import io + +# Capture stdout (print() output goes here) +_stdout_capture = io.StringIO() +sys.stdout = _stdout_capture + +_ngspice_result = "" +_spectre_result = "" +_svg_result = "" + +# ---- User code ---- +${code} +# ---- End user code ---- + +# Restore stdout +sys.stdout = sys.__stdout__ +_captured_output = _stdout_capture.getvalue() + +# Auto-detect Testbench and generate netlists for simulation +try: + from analogpy import generate_ngspice as _gen_ng + from analogpy import generate_spectre as _gen_sp + from analogpy.testbench import Testbench as _TBClass + _tb = None + for _name in reversed([v for v in dir() if not v.startswith('_')]): + _obj = eval(_name) + if isinstance(_obj, _TBClass): + _tb = _obj + break + if _tb is not None: + _ngspice_result = _gen_ng(_tb) + _spectre_result = _gen_sp(_tb) +except Exception: + pass + +# Auto-generate SVG schematic +try: + from analogpy.testbench import Testbench as _TBClass2 + _tb2 = None + for _name2 in reversed([v for v in dir() if not v.startswith('_')]): + _obj2 = eval(_name2) + if isinstance(_obj2, _TBClass2): + _tb2 = _obj2 + break + if _tb2 is not None: + import matplotlib + matplotlib.use('Agg') + from analogpy.visualization.svg import render_block_diagram_svg + from analogpy.visualization.symbols import get_default_renderer, SymbolStyle + get_default_renderer().default_style = SymbolStyle.DETAILED + _svg_result = render_block_diagram_svg(_tb2) +except Exception as _svg_err: + _svg_result = f"" + +# Last resort: use raw stdout if auto-detect produced nothing +if not _ngspice_result and _captured_output.strip(): + _ngspice_result = _captured_output.strip() + +{"ngspice": _ngspice_result, "spectre": _spectre_result, "schematicSvg": _svg_result, "stdout": _captured_output, "error": ""} +`; + + const result = pyodide.runPython(wrappedCode); + const jsResult = result.toJs({ dict_converter: Object.fromEntries }); + result.destroy(); + + return { + ngspice: jsResult.ngspice || "", + spectre: jsResult.spectre || "", + schematicSvg: jsResult.schematicSvg || "", + stdout: jsResult.stdout || "", + error: "", + }; + } catch (e: unknown) { + const errorMsg = e instanceof Error ? e.message : String(e); + return { + ngspice: "", + spectre: "", + schematicSvg: "", + stdout: "", + error: errorMsg, + }; + } + }, + + isReady(): boolean { + return isReady; + }, +}; + +export type PyodideWorkerType = typeof pyodideWorker; + +ComLink.expose(pyodideWorker); \ No newline at end of file diff --git a/switch_git_remote.py b/switch_git_remote.py new file mode 100755 index 0000000..1bd282e --- /dev/null +++ b/switch_git_remote.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Toggle git remote for main branch and email between Apple internal and public GitHub.""" + +import subprocess + +# Configuration +APPLE_EMAIL = "gaofeng_fan@apple.com" +PUBLIC_EMAIL = "circuitmuggle@gmail.com" + + +def run_git(args: list[str]) -> str: + """Run a git command and return output.""" + return subprocess.run( + ["git"] + args, + capture_output=True, + text=True, + ).stdout.strip() + + +def main() -> None: + # Use branch.main.remote to determine current state + current_remote = run_git(["config", "branch.main.remote"]) + + if current_remote == "enterprise": + new_email, new_remote, target = PUBLIC_EMAIL, "origin", "Public GitHub" + else: + new_email, new_remote, target = APPLE_EMAIL, "enterprise", "Apple Internal" + + print(f"Switching to {target}...") + run_git(["config", "user.email", new_email]) + run_git(["config", "branch.main.remote", new_remote]) + + print(f" Email: {new_email}") + print(f" Remote: {new_remote} (branch main)") + print("\nDone!") + + +if __name__ == "__main__": + main() diff --git a/tests/lib/eecircuit-test.ts b/tests/lib/eecircuit-test.ts index 122c8c1..c9f6be8 100644 --- a/tests/lib/eecircuit-test.ts +++ b/tests/lib/eecircuit-test.ts @@ -98,7 +98,7 @@ export async function testEEcircuit(page: Page, url: string) { const resp = await page.goto(url, { waitUntil: 'networkidle' }); console.log('Page.goto response status:', resp?.status(), 'url:', resp?.url()); - await expect(page).toHaveTitle(/EEcircuit/); + await expect(page).toHaveTitle(/analogpy/); // Now perform UI actions await page.getByRole('button', { name: 'Run' }).click(); diff --git a/vite.config.mts b/vite.config.mts index 314a01c..14e41a3 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -4,6 +4,7 @@ import react from "@vitejs/plugin-react"; import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ + base: "/", resolve: { preserveSymlinks: true, },