From 4075b5431d0f4749af7f2f633d161c3658d3b0b4 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 13 Jan 2026 21:22:47 +0100 Subject: [PATCH 001/656] full rewrite --- .github/workflows/build-deploy-docs.yml | 58 - .github/workflows/ci.yml | 205 - .gitignore | 38 - CODE_OF_CONDUCT.md | 128 - Dockerfile | 62 - LICENSE | 21 - README.md | 98 - docs/Makefile | 20 - docs/conf.py | 60 - docs/contributing.rst | 53 - docs/images/global_vars/0.png | Bin 35522 -> 0 bytes docs/images/global_vars/1.png | Bin 21126 -> 0 bytes docs/images/global_vars/2.png | Bin 14747 -> 0 bytes docs/images/global_vars/3.png | Bin 116127 -> 0 bytes docs/images/solver_prms/0.png | Bin 58873 -> 0 bytes docs/images/step_by_step_guide/0.png | Bin 73911 -> 0 bytes docs/images/step_by_step_guide/1.png | Bin 87870 -> 0 bytes docs/images/step_by_step_guide/2.png | Bin 169651 -> 0 bytes docs/images/step_by_step_guide/3.png | Bin 79931 -> 0 bytes docs/images/step_by_step_guide/4.png | Bin 109527 -> 0 bytes docs/images/step_by_step_guide/5.png | Bin 43017 -> 0 bytes docs/images/step_by_step_guide/6.png | Bin 114919 -> 0 bytes docs/index.rst | 51 - docs/installation.rst | 74 - docs/make.bat | 35 - docs/roadmap.rst | 34 - docs/support.rst | 18 - docs/usage.rst | 237 - eslint.config.js | 33 - example_graphs/bouncing_ball.json | 167 - example_graphs/festim_two_walls.json | 413 -- example_graphs/fmcw_radar.json | 417 -- example_graphs/harmonic_oscillator.json | 326 - example_graphs/linear_feedback.json | 255 - example_graphs/pendulum.json | 236 - example_graphs/pid.json | 336 - example_graphs/spectrum.json | 199 - example_graphs/stick_slip.json | 586 -- example_graphs/thermostat.json | 288 - example_graphs/van_der_pol.json | 93 - index.html | 16 - package-lock.json | 5785 ------------------ package.json | 37 - public/vite.svg | 1 - pyproject.toml | 55 - requirements.txt | 7 - src/App.jsx | 1258 ---- src/assets/react.svg | 1 - src/backend.py | 580 -- src/components/ContextMenu.jsx | 39 - src/components/CustomEdge.jsx | 31 - src/components/DnDContext.jsx | 19 - src/components/EdgeDetails.jsx | 73 - src/components/EventsTab.jsx | 492 -- src/components/GlobalVariablesTab.jsx | 304 - src/components/GraphView.jsx | 334 - src/components/LogDock.jsx | 100 - src/components/NodeSidebar.jsx | 422 -- src/components/PythonCodeEditor.jsx | 141 - src/components/ResultsPanel.jsx | 67 - src/components/ShareModal.jsx | 165 - src/components/Sidebar.jsx | 156 - src/components/SolverPanel.jsx | 369 -- src/components/TopBar.jsx | 125 - src/components/nodes/AddSubNode.jsx | 112 - src/components/nodes/AdderNode.jsx | 29 - src/components/nodes/AmplifierNode.jsx | 90 - src/components/nodes/BubblerNode.jsx | 111 - src/components/nodes/ConstantNode.jsx | 24 - src/components/nodes/CustomHandle.jsx | 18 - src/components/nodes/DefaultNode.jsx | 24 - src/components/nodes/DelayNode.jsx | 47 - src/components/nodes/DynamicHandleNode.jsx | 131 - src/components/nodes/FunctionNode.jsx | 125 - src/components/nodes/IntegratorNode.jsx | 28 - src/components/nodes/MultiplierNode.jsx | 29 - src/components/nodes/ProcessNode.jsx | 98 - src/components/nodes/ScopeNode.jsx | 48 - src/components/nodes/Splitters.jsx | 186 - src/components/nodes/StepSourceNode.jsx | 45 - src/components/nodes/SwitchNode.jsx | 54 - src/components/nodes/WallNode.jsx | 68 - src/config.js | 28 - src/main.jsx | 10 - src/nodeConfig.js | 203 - src/python/__init__.py | 17 - src/python/convert_to_python.py | 213 - src/python/custom_pathsim_blocks.py | 238 - src/python/pathsim_utils.py | 810 --- src/python/templates/__init__.py | 0 src/python/templates/block_macros.py | 63 - src/python/templates/template_with_macros.py | 80 - src/styles/App.css | 491 -- src/styles/PythonCodeEditor.css | 138 - src/styles/index.css | 68 - src/utils.js | 25 - src/utils/urlSharing.js | 186 - test/__init__.py | 0 test/test_backend.py | 197 - test/test_convert_python.py | 171 - test/test_custom_blocks.py | 28 - test/test_events.py | 23 - test/test_examples.py | 59 - test/test_files/bubbler.json | 190 - test/test_files/constant_delay_scope.json | 112 - test/test_files/custom_nodes.json | 78 - test/test_files/same_label.json | 153 - test/test_files/spectrum.json | 189 - vite.config.js | 7 - 109 files changed, 20162 deletions(-) delete mode 100644 .github/workflows/build-deploy-docs.yml delete mode 100644 .github/workflows/ci.yml delete mode 100644 .gitignore delete mode 100644 CODE_OF_CONDUCT.md delete mode 100644 Dockerfile delete mode 100644 LICENSE delete mode 100644 README.md delete mode 100644 docs/Makefile delete mode 100644 docs/conf.py delete mode 100644 docs/contributing.rst delete mode 100644 docs/images/global_vars/0.png delete mode 100644 docs/images/global_vars/1.png delete mode 100644 docs/images/global_vars/2.png delete mode 100644 docs/images/global_vars/3.png delete mode 100644 docs/images/solver_prms/0.png delete mode 100644 docs/images/step_by_step_guide/0.png delete mode 100644 docs/images/step_by_step_guide/1.png delete mode 100644 docs/images/step_by_step_guide/2.png delete mode 100644 docs/images/step_by_step_guide/3.png delete mode 100644 docs/images/step_by_step_guide/4.png delete mode 100644 docs/images/step_by_step_guide/5.png delete mode 100644 docs/images/step_by_step_guide/6.png delete mode 100644 docs/index.rst delete mode 100644 docs/installation.rst delete mode 100644 docs/make.bat delete mode 100644 docs/roadmap.rst delete mode 100644 docs/support.rst delete mode 100644 docs/usage.rst delete mode 100644 eslint.config.js delete mode 100644 example_graphs/bouncing_ball.json delete mode 100644 example_graphs/festim_two_walls.json delete mode 100644 example_graphs/fmcw_radar.json delete mode 100644 example_graphs/harmonic_oscillator.json delete mode 100644 example_graphs/linear_feedback.json delete mode 100644 example_graphs/pendulum.json delete mode 100644 example_graphs/pid.json delete mode 100644 example_graphs/spectrum.json delete mode 100644 example_graphs/stick_slip.json delete mode 100644 example_graphs/thermostat.json delete mode 100644 example_graphs/van_der_pol.json delete mode 100644 index.html delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 public/vite.svg delete mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 src/App.jsx delete mode 100644 src/assets/react.svg delete mode 100644 src/backend.py delete mode 100644 src/components/ContextMenu.jsx delete mode 100644 src/components/CustomEdge.jsx delete mode 100644 src/components/DnDContext.jsx delete mode 100644 src/components/EdgeDetails.jsx delete mode 100644 src/components/EventsTab.jsx delete mode 100644 src/components/GlobalVariablesTab.jsx delete mode 100644 src/components/GraphView.jsx delete mode 100644 src/components/LogDock.jsx delete mode 100644 src/components/NodeSidebar.jsx delete mode 100644 src/components/PythonCodeEditor.jsx delete mode 100644 src/components/ResultsPanel.jsx delete mode 100644 src/components/ShareModal.jsx delete mode 100644 src/components/Sidebar.jsx delete mode 100644 src/components/SolverPanel.jsx delete mode 100644 src/components/TopBar.jsx delete mode 100644 src/components/nodes/AddSubNode.jsx delete mode 100644 src/components/nodes/AdderNode.jsx delete mode 100644 src/components/nodes/AmplifierNode.jsx delete mode 100644 src/components/nodes/BubblerNode.jsx delete mode 100644 src/components/nodes/ConstantNode.jsx delete mode 100644 src/components/nodes/CustomHandle.jsx delete mode 100644 src/components/nodes/DefaultNode.jsx delete mode 100644 src/components/nodes/DelayNode.jsx delete mode 100644 src/components/nodes/DynamicHandleNode.jsx delete mode 100644 src/components/nodes/FunctionNode.jsx delete mode 100644 src/components/nodes/IntegratorNode.jsx delete mode 100644 src/components/nodes/MultiplierNode.jsx delete mode 100644 src/components/nodes/ProcessNode.jsx delete mode 100644 src/components/nodes/ScopeNode.jsx delete mode 100644 src/components/nodes/Splitters.jsx delete mode 100644 src/components/nodes/StepSourceNode.jsx delete mode 100644 src/components/nodes/SwitchNode.jsx delete mode 100644 src/components/nodes/WallNode.jsx delete mode 100644 src/config.js delete mode 100644 src/main.jsx delete mode 100644 src/nodeConfig.js delete mode 100644 src/python/__init__.py delete mode 100644 src/python/convert_to_python.py delete mode 100644 src/python/custom_pathsim_blocks.py delete mode 100644 src/python/pathsim_utils.py delete mode 100644 src/python/templates/__init__.py delete mode 100644 src/python/templates/block_macros.py delete mode 100644 src/python/templates/template_with_macros.py delete mode 100644 src/styles/App.css delete mode 100644 src/styles/PythonCodeEditor.css delete mode 100644 src/styles/index.css delete mode 100644 src/utils.js delete mode 100644 src/utils/urlSharing.js delete mode 100644 test/__init__.py delete mode 100644 test/test_backend.py delete mode 100644 test/test_convert_python.py delete mode 100644 test/test_custom_blocks.py delete mode 100644 test/test_events.py delete mode 100644 test/test_examples.py delete mode 100644 test/test_files/bubbler.json delete mode 100644 test/test_files/constant_delay_scope.json delete mode 100644 test/test_files/custom_nodes.json delete mode 100644 test/test_files/same_label.json delete mode 100644 test/test_files/spectrum.json delete mode 100644 vite.config.js diff --git a/.github/workflows/build-deploy-docs.yml b/.github/workflows/build-deploy-docs.yml deleted file mode 100644 index f08f2126..00000000 --- a/.github/workflows/build-deploy-docs.yml +++ /dev/null @@ -1,58 +0,0 @@ -# Sample workflow for building and deploying a Jekyll site to GitHub Pages -name: Build and deploy docs - -on: - # Runs on pushes targeting the default branch - push: - branches: ["main"] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - # Build job - build: - runs-on: ubuntu-latest - env: - PUBLISH_DIR: ./_build - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Pages - uses: actions/configure-pages@v5 - - name: Install dependencies - run: | - pip install sphinx sphinx-book-theme - - - name: Sphinx build - run: | - sphinx-build docs _build - - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: ${{ env.PUBLISH_DIR }} - - # Deployment job - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 2699e7ca..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,205 +0,0 @@ -name: CI - Build and Test Application - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [20.x] - python-version: ['3.10', '3.11'] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Cache Python dependencies - uses: actions/cache@v3 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - pip install .[dev] - pip install -r requirements.txt - - - name: Install Node.js dependencies - run: npm ci - - - name: Lint frontend code - run: npm run lint - continue-on-error: true - - - name: Build frontend - run: npm run build - - - name: Run Python tests - run: | - python -m pytest test/ -v - - - name: Start backend server and health check - run: | - # Start the backend server in the background - python -m src.backend & - BACKEND_PID=$! - - # Wait for the server to start - echo "Waiting for backend server to start..." - sleep 10 - - # Health check - test if the server is responding - max_attempts=30 - attempt=1 - - while [ $attempt -le $max_attempts ]; do - if curl -f http://localhost:8000/health 2>/dev/null; then - echo "Backend server is healthy!" - break - elif curl -f http://localhost:8000/ 2>/dev/null; then - echo "Backend server is responding!" - break - else - echo "Attempt $attempt/$max_attempts: Backend not ready yet..." - sleep 2 - attempt=$((attempt + 1)) - fi - done - - if [ $attempt -gt $max_attempts ]; then - echo "Backend server failed to start properly" - kill $BACKEND_PID 2>/dev/null || true - exit 1 - fi - - # Test basic API endpoints if they exist - echo "Testing backend endpoints..." - - # Clean up - kill $BACKEND_PID 2>/dev/null || true - echo "Backend server test completed successfully!" - - - name: Test frontend build serves correctly - run: | - # Start the preview server in the background - npm run preview & - FRONTEND_PID=$! - - # Wait for the server to start - echo "Waiting for frontend server to start..." - sleep 5 - - # Health check for frontend - max_attempts=15 - attempt=1 - - while [ $attempt -le $max_attempts ]; do - if curl -f http://localhost:4173/ 2>/dev/null; then - echo "Frontend server is serving correctly!" - break - else - echo "Attempt $attempt/$max_attempts: Frontend not ready yet..." - sleep 2 - attempt=$((attempt + 1)) - fi - done - - if [ $attempt -gt $max_attempts ]; then - echo "Frontend server failed to start properly" - kill $FRONTEND_PID 2>/dev/null || true - exit 1 - fi - - # Clean up - kill $FRONTEND_PID 2>/dev/null || true - echo "Frontend server test completed successfully!" - - integration-test: - runs-on: ubuntu-latest - needs: test - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' - cache: 'npm' - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install .[dev] - pip install -r requirements.txt - npm ci - - - name: Build frontend - run: npm run build - - - name: Test full application stack - run: | - # Start both frontend and backend - echo "Starting full application stack..." - - # Start backend - python -m src.backend & - BACKEND_PID=$! - - # Start frontend preview - npm run preview & - FRONTEND_PID=$! - - # Wait for both services - sleep 15 - - # Test both services are running - echo "Testing backend..." - if ! curl -f http://localhost:8000/ 2>/dev/null; then - echo "Backend failed to start" - kill $BACKEND_PID $FRONTEND_PID 2>/dev/null || true - exit 1 - fi - - echo "Testing frontend..." - if ! curl -f http://localhost:4173/ 2>/dev/null; then - echo "Frontend failed to start" - kill $BACKEND_PID $FRONTEND_PID 2>/dev/null || true - exit 1 - fi - - echo "Both services are running successfully!" - - # Clean up - kill $BACKEND_PID $FRONTEND_PID 2>/dev/null || true - - echo "Integration test completed successfully!" \ No newline at end of file diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 20c40458..00000000 --- a/.gitignore +++ /dev/null @@ -1,38 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? -*.pyc -venv/ -build -docs/_build - -# python -__pycache__ -*.py[cod] -*.pyo -*.pyd -.Python -*.egg -*.egg-info -*_version.py diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index a86b54c3..00000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,128 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behaviour that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behaviour and will take appropriate and fair corrective action in -response to any behaviour that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behaviour may be -reported to the community leaders responsible for enforcement at -remidm@mit.edu. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behaviour deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behaviour was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behaviour. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behaviour. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behaviour, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index e484fc98..00000000 --- a/Dockerfile +++ /dev/null @@ -1,62 +0,0 @@ -# Multi-stage build optimized for Google Cloud Run -FROM node:18-alpine AS frontend-build - -# Build frontend -WORKDIR /app -COPY package*.json ./ -RUN npm ci -COPY . . -RUN npm run build - -# Python backend stage -FROM python:3.11-slim - -# Build argument for version (can be set at build time) -ARG VERSION=0.1.0 - -# Install system dependencies -RUN apt-get update && apt-get install -y \ - gcc \ - && rm -rf /var/lib/apt/lists/* - -# Set working directory -WORKDIR /app - -# Copy Python requirements and install -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy source code and package configuration -COPY pyproject.toml . -COPY src/ ./src/ - -# Install python core package with setuptools-scm version override -RUN SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PATHVIEW=${VERSION} pip install . - -# Install gunicorn for production WSGI server -RUN pip install gunicorn -# COPY *.py ./ - -# Copy built frontend from previous stage -COPY --from=frontend-build /app/dist ./dist - -# Create non-root user for security -RUN useradd --create-home --shell /bin/bash app \ - && chown -R app:app /app -USER app - -# Set environment variables -ENV FLASK_APP=src.backend -ENV FLASK_ENV=production -ENV PYTHONPATH=/app -ENV PORT=8080 - -# Expose port (Cloud Run uses PORT env variable) -EXPOSE $PORT - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:$PORT/health || exit 1 - -# Use gunicorn for production -CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 --timeout 0 src.backend:app diff --git a/LICENSE b/LICENSE deleted file mode 100644 index e119235f..00000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 tasnimxvi - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md deleted file mode 100644 index 84fb5e21..00000000 --- a/README.md +++ /dev/null @@ -1,98 +0,0 @@ -# PathView -An interactive visual tool built with React Flow and a python (Flask) backend for system modelling using PathSim. - -# Required Installations -Make sure the following are installed on your system: -- Node.js + npm -- Python 3.8+ -- pip for Python package management - -# Project Structure -```bash -pathview/ - -├── package.json # Frontend (React) dependencies - -├── requirements.txt # Backend dependencies - -├── src/ - -│ ├── backend.py # Python backend API - -│ ├── components/ # JSx components - -│ ├── python/ # python package source - -│ ├── main.jsx - -│ ├── App.jsx # main App - -│ ├── nodeConfig.js # file to configure all the nodes - -``` - -# Installation and Setup -Once in the directory, install frontend and backend dependencies: - -Front end -```bash -npm install -``` -Back end - -Recommend setting up a virtual environment. Proceed as follows: -```bash -cd src -python -m venv venv -source venv/bin/activate # macOS/Linux -venv\Scripts\activate # Windows - -pip install -r requirements.txt # install the web app requirements (eg. Flask) -pip install -e .[dev] # install the python module containing pathsim utils -``` -# Running Application -You can run both frontend and backend at once -``` -npm run start:both -``` -This will: -- Start the React frontend at http://localhost:5173 -- Start the Flask backend at http://localhost:8000 - -If you are working on one side, you can also run the following commands for front end and back end respectively: -``` -npm run dev -``` -``` -npm run start:backend -``` - - -# Building the documentation - -The project uses Sphinx to generate documentation from the reStructuredText files in the `docs/` directory. - -## Prerequisites -Make sure you have Sphinx installed: -```bash -pip install sphinx sphinx-book-theme -``` - -## Building HTML Documentation -To build the HTML documentation: -```bash -cd docs -make html -``` - -The generated documentation will be available in `docs/_build/html/index.html`. - -## Viewing the Documentation -After building, you can view the documentation by opening `docs/_build/html/index.html` in your web browser, or serve it locally: -```bash -# From the docs/_build/html directory -python -m http.server 8080 -``` -Then visit http://localhost:8080 in your browser. - - diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index d4bb2cbb..00000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = . -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 0abc37a3..00000000 --- a/docs/conf.py +++ /dev/null @@ -1,60 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# For the full list of built-in configuration values, see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Project information ----------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information - -project = "PathView" -copyright = "2025, Tasnim Zulfiqar, James Dark, Remi Delaporte-Mathurin" -author = "Tasnim Zulfiqar, James Dark, Remi Delaporte-Mathurin" - -# -- General configuration --------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration - -extensions = [] - -templates_path = ["_templates"] -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] - - -# -- Options for HTML output ------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output - -html_theme = "sphinx_book_theme" -html_static_path = ["_static"] - -html_theme_options = { - "repository_url": "https://github.com/festim-dev/PathView", - "use_repository_button": True, - "use_edit_page_button": True, - "repository_branch": "main", - "path_to_docs": "./docs", - "icon_links": [ - { - "name": "GitHub Discussions", - "url": "https://github.com/festim-dev/pathview/discussions", - "icon": "fa-solid fa-comments", - }, - { - "name": "Slack", - "url": "https://join.slack.com/t/festim-dev/shared_invite/zt-246hw8d6o-htWASLsbdosUo_2nRKCf9g", - "icon": "fa-brands fa-slack", - }, - ], - "article_header_end": [ - "navbar-icon-links", - "article-header-buttons", - ], -} - -html_sidebars = { - "**": [ - "navbar-logo", - "search-button-field", - "sbt-sidebar-nav", - ], -} - -html_title = "PathView Documentation" diff --git a/docs/contributing.rst b/docs/contributing.rst deleted file mode 100644 index ae0ea710..00000000 --- a/docs/contributing.rst +++ /dev/null @@ -1,53 +0,0 @@ -=============================== -Contributing Guide -=============================== - -Code of Conduct ----------------- - -Please read our `Code of Conduct <../CODE_OF_CONDUCT.md>`_. - -Contributing Guidelines ------------------------ - -**Getting Started** - 1. Fork the repository - 2. Create a feature branch (``git checkout -b awesome-feature``) - 3. Make your changes - 4. Add tests for new functionality if needed - 5. Ensure all tests pass - 6. Submit a pull request - -**Development Standards** - - Follow existing code style and conventions - - Write clear, descriptive commit messages - - Include tests for new features - - Update documentation as needed - - Ensure backwards compatibility when possible - -**Types of Contributions** - - 🐛 Bug reports and fixes - - ✨ New features and enhancements - - 📚 Documentation improvements - - 🎨 UI/UX improvements - - 🧪 Test coverage improvements - -Communication Channels ------------------------ - -**GitHub Discussions** - - General questions and help - - Feature requests and ideas - - Community showcases - - Development discussions - -**Issues** - - Bug reports - - Specific feature requests - - Documentation issues - -**Pull Requests** - - Provide clear description of changes - - Reference related issues - - Include screenshots for UI changes - - Ensure CI checks pass \ No newline at end of file diff --git a/docs/images/global_vars/0.png b/docs/images/global_vars/0.png deleted file mode 100644 index d9ac6f9bb8d19fdeeb29afe51d2aeef0ecf5d30d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35522 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 r)ayu;9|TCLy?6a0qU}rD?2jZ#=k5j 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 diff --git a/docs/images/global_vars/3.png b/docs/images/global_vars/3.png deleted file mode 100644 index 15e28906f91d9b8e4cb4bf9ee3c0eac04fd225e8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 116127 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 z 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 F{0{@x#KHgo diff --git a/docs/images/solver_prms/0.png b/docs/images/solver_prms/0.png deleted file mode 100644 index cd54bf2e4715b94660e8b9622170e2936384fdb8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58873 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 diff --git a/docs/images/step_by_step_guide/1.png b/docs/images/step_by_step_guide/1.png deleted file mode 100644 index e0d02cbdce574776a4b337a0865e9b17a58bc14d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 87870 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ú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 diff --git a/docs/images/step_by_step_guide/2.png b/docs/images/step_by_step_guide/2.png deleted file mode 100644 index a0fb5ff6f8fb7fd720c6ffa6b21227d838405726..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 169651 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�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
- - - - \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 7e42936e..00000000 --- a/package-lock.json +++ /dev/null @@ -1,5785 +0,0 @@ -{ - "name": "pathview", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "pathview", - "version": "0.0.0", - "dependencies": { - "@codemirror/lang-python": "^6.2.1", - "@uiw/codemirror-theme-vscode": "^4.24.2", - "@uiw/react-codemirror": "^4.24.2", - "@xyflow/react": "^12.8.1", - "lz-string": "^1.5.0", - "plotly.js": "^3.0.3", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-plotly.js": "^2.6.0" - }, - "devDependencies": { - "@eslint/js": "^9.25.0", - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", - "@vitejs/plugin-react": "^4.4.1", - "concurrently": "^9.2.0", - "eslint": "^9.25.0", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.19", - "globals": "^16.0.0", - "vite": "^6.3.5" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.5.tgz", - "integrity": "sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", - "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.4", - "@babel/parser": "^7.27.4", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.27.4", - "@babel/types": "^7.27.3", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz", - "integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.27.5", - "@babel/types": "^7.27.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", - "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", - "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz", - "integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", - "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/parser": "^7.27.4", - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/types": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", - "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@choojs/findup": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@choojs/findup/-/findup-0.2.1.tgz", - "integrity": "sha512-YstAqNb0MCN8PjdLCDfRsBcGVRN41f3vgLvaI0IrIcBp4AqILRSS0DeWNGkicC+f/zRIPJLc+9RURVSepwvfBw==", - "dependencies": { - "commander": "^2.15.1" - }, - "bin": { - "findup": "bin/findup.js" - } - }, - "node_modules/@codemirror/autocomplete": { - "version": "6.18.6", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz", - "integrity": "sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0" - } - }, - "node_modules/@codemirror/commands": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz", - "integrity": "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.4.0", - "@codemirror/view": "^6.27.0", - "@lezer/common": "^1.1.0" - } - }, - "node_modules/@codemirror/lang-python": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", - "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", - "dependencies": { - "@codemirror/autocomplete": "^6.3.2", - "@codemirror/language": "^6.8.0", - "@codemirror/state": "^6.0.0", - "@lezer/common": "^1.2.1", - "@lezer/python": "^1.1.4" - } - }, - "node_modules/@codemirror/language": { - "version": "6.11.2", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.2.tgz", - "integrity": "sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.23.0", - "@lezer/common": "^1.1.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0", - "style-mod": "^4.0.0" - } - }, - "node_modules/@codemirror/lint": { - "version": "6.8.5", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz", - "integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.35.0", - "crelt": "^1.0.5" - } - }, - "node_modules/@codemirror/search": { - "version": "6.5.11", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", - "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "crelt": "^1.0.5" - } - }, - "node_modules/@codemirror/state": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", - "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", - "dependencies": { - "@marijn/find-cluster-break": "^1.0.0" - } - }, - "node_modules/@codemirror/theme-one-dark": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", - "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/highlight": "^1.0.0" - } - }, - "node_modules/@codemirror/view": { - "version": "6.38.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", - "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", - "dependencies": { - "@codemirror/state": "^6.5.0", - "crelt": "^1.0.6", - "style-mod": "^4.1.0", - "w3c-keyname": "^2.2.4" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", - "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", - "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", - "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", - "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", - "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", - "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", - "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", - "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", - "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", - "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", - "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", - "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", - "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", - "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", - "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", - "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", - "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", - "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", - "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", - "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", - "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", - "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", - "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", - "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", - "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", - "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", - "integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", - "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", - "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.15.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@lezer/common": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz", - "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==" - }, - "node_modules/@lezer/highlight": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", - "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", - "dependencies": { - "@lezer/common": "^1.0.0" - } - }, - "node_modules/@lezer/lr": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", - "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", - "dependencies": { - "@lezer/common": "^1.0.0" - } - }, - "node_modules/@lezer/python": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", - "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/@mapbox/geojson-rewind": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", - "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", - "dependencies": { - "get-stream": "^6.0.1", - "minimist": "^1.2.6" - }, - "bin": { - "geojson-rewind": "geojson-rewind" - } - }, - "node_modules/@mapbox/geojson-types": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz", - "integrity": "sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==" - }, - "node_modules/@mapbox/jsonlint-lines-primitives": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", - "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@mapbox/mapbox-gl-supported": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz", - "integrity": "sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==", - "peerDependencies": { - "mapbox-gl": ">=0.32.1 <2.0.0" - } - }, - "node_modules/@mapbox/point-geometry": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", - "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==" - }, - "node_modules/@mapbox/tiny-sdf": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", - "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==" - }, - "node_modules/@mapbox/unitbezier": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", - "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==" - }, - "node_modules/@mapbox/vector-tile": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", - "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", - "dependencies": { - "@mapbox/point-geometry": "~0.1.0" - } - }, - "node_modules/@mapbox/whoots-js": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", - "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@maplibre/maplibre-gl-style-spec": { - "version": "20.4.0", - "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", - "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==", - "dependencies": { - "@mapbox/jsonlint-lines-primitives": "~2.0.2", - "@mapbox/unitbezier": "^0.0.1", - "json-stringify-pretty-compact": "^4.0.0", - "minimist": "^1.2.8", - "quickselect": "^2.0.0", - "rw": "^1.3.3", - "tinyqueue": "^3.0.0" - }, - "bin": { - "gl-style-format": "dist/gl-style-format.mjs", - "gl-style-migrate": "dist/gl-style-migrate.mjs", - "gl-style-validate": "dist/gl-style-validate.mjs" - } - }, - "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", - "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==" - }, - "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/tinyqueue": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", - "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==" - }, - "node_modules/@marijn/find-cluster-break": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", - "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==" - }, - "node_modules/@plotly/d3": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@plotly/d3/-/d3-3.8.2.tgz", - "integrity": "sha512-wvsNmh1GYjyJfyEBPKJLTMzgf2c2bEbSIL50lmqVUi+o1NHaLPi1Lb4v7VxXXJn043BhNyrxUrWI85Q+zmjOVA==" - }, - "node_modules/@plotly/d3-sankey": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@plotly/d3-sankey/-/d3-sankey-0.7.2.tgz", - "integrity": "sha512-2jdVos1N3mMp3QW0k2q1ph7Gd6j5PY1YihBrwpkFnKqO+cqtZq3AdEYUeSGXMeLsBDQYiqTVcihYfk8vr5tqhw==", - "dependencies": { - "d3-array": "1", - "d3-collection": "1", - "d3-shape": "^1.2.0" - } - }, - "node_modules/@plotly/d3-sankey-circular": { - "version": "0.33.1", - "resolved": "https://registry.npmjs.org/@plotly/d3-sankey-circular/-/d3-sankey-circular-0.33.1.tgz", - "integrity": "sha512-FgBV1HEvCr3DV7RHhDsPXyryknucxtfnLwPtCKKxdolKyTFYoLX/ibEfX39iFYIL7DYbVeRtP43dbFcrHNE+KQ==", - "dependencies": { - "d3-array": "^1.2.1", - "d3-collection": "^1.0.4", - "d3-shape": "^1.2.0", - "elementary-circuits-directed-graph": "^1.0.4" - } - }, - "node_modules/@plotly/mapbox-gl": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/@plotly/mapbox-gl/-/mapbox-gl-1.13.4.tgz", - "integrity": "sha512-sR3/Pe5LqT/fhYgp4rT4aSFf1rTsxMbGiH6Hojc7PH36ny5Bn17iVFUjpzycafETURuFbLZUfjODO8LvSI+5zQ==", - "dependencies": { - "@mapbox/geojson-rewind": "^0.5.2", - "@mapbox/geojson-types": "^1.0.2", - "@mapbox/jsonlint-lines-primitives": "^2.0.2", - "@mapbox/mapbox-gl-supported": "^1.5.0", - "@mapbox/point-geometry": "^0.1.0", - "@mapbox/tiny-sdf": "^1.1.1", - "@mapbox/unitbezier": "^0.0.0", - "@mapbox/vector-tile": "^1.3.1", - "@mapbox/whoots-js": "^3.1.0", - "csscolorparser": "~1.0.3", - "earcut": "^2.2.2", - "geojson-vt": "^3.2.1", - "gl-matrix": "^3.2.1", - "grid-index": "^1.1.0", - "murmurhash-js": "^1.0.0", - "pbf": "^3.2.1", - "potpack": "^1.0.1", - "quickselect": "^2.0.0", - "rw": "^1.3.3", - "supercluster": "^7.1.0", - "tinyqueue": "^2.0.3", - "vt-pbf": "^3.1.1" - }, - "engines": { - "node": ">=6.4.0" - } - }, - "node_modules/@plotly/point-cluster": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/@plotly/point-cluster/-/point-cluster-3.1.9.tgz", - "integrity": "sha512-MwaI6g9scKf68Orpr1pHZ597pYx9uP8UEFXLPbsCmuw3a84obwz6pnMXGc90VhgDNeNiLEdlmuK7CPo+5PIxXw==", - "dependencies": { - "array-bounds": "^1.0.1", - "binary-search-bounds": "^2.0.4", - "clamp": "^1.0.1", - "defined": "^1.0.0", - "dtype": "^2.0.0", - "flatten-vertex-data": "^1.0.2", - "is-obj": "^1.0.1", - "math-log2": "^1.0.1", - "parse-rect": "^1.2.0", - "pick-by-alias": "^1.2.0" - } - }, - "node_modules/@plotly/regl": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@plotly/regl/-/regl-2.1.2.tgz", - "integrity": "sha512-Mdk+vUACbQvjd0m/1JJjOOafmkp/EpmHjISsopEz5Av44CBq7rPC05HHNbYGKVyNUF2zmEoBS/TT0pd0SPFFyw==" - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.11", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.11.tgz", - "integrity": "sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.0.tgz", - "integrity": "sha512-xEiEE5oDW6tK4jXCAyliuntGR+amEMO7HLtdSshVuhFnKTYoeYMyXQK7pLouAJJj5KHdwdn87bfHAR2nSdNAUA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.0.tgz", - "integrity": "sha512-uNSk/TgvMbskcHxXYHzqwiyBlJ/lGcv8DaUfcnNwict8ba9GTTNxfn3/FAoFZYgkaXXAdrAA+SLyKplyi349Jw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.0.tgz", - "integrity": "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.0.tgz", - "integrity": "sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.0.tgz", - "integrity": "sha512-u5AZzdQJYJXByB8giQ+r4VyfZP+walV+xHWdaFx/1VxsOn6eWJhK2Vl2eElvDJFKQBo/hcYIBg/jaKS8ZmKeNQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.0.tgz", - "integrity": "sha512-qC0kS48c/s3EtdArkimctY7h3nHicQeEUdjJzYVJYR3ct3kWSafmn6jkNCA8InbUdge6PVx6keqjk5lVGJf99g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.0.tgz", - "integrity": "sha512-x+e/Z9H0RAWckn4V2OZZl6EmV0L2diuX3QB0uM1r6BvhUIv6xBPL5mrAX2E3e8N8rEHVPwFfz/ETUbV4oW9+lQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.0.tgz", - "integrity": "sha512-1exwiBFf4PU/8HvI8s80icyCcnAIB86MCBdst51fwFmH5dyeoWVPVgmQPcKrMtBQ0W5pAs7jBCWuRXgEpRzSCg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.0.tgz", - "integrity": "sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.0.tgz", - "integrity": "sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.0.tgz", - "integrity": "sha512-xw+FTGcov/ejdusVOqKgMGW3c4+AgqrfvzWEVXcNP6zq2ue+lsYUgJ+5Rtn/OTJf7e2CbgTFvzLW2j0YAtj0Gg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.0.tgz", - "integrity": "sha512-bKGibTr9IdF0zr21kMvkZT4K6NV+jjRnBoVMt2uNMG0BYWm3qOVmYnXKzx7UhwrviKnmK46IKMByMgvpdQlyJQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.0.tgz", - "integrity": "sha512-vV3cL48U5kDaKZtXrti12YRa7TyxgKAIDoYdqSIOMOFBXqFj2XbChHAtXquEn2+n78ciFgr4KIqEbydEGPxXgA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.0.tgz", - "integrity": "sha512-TDKO8KlHJuvTEdfw5YYFBjhFts2TR0VpZsnLLSYmB7AaohJhM8ctDSdDnUGq77hUh4m/djRafw+9zQpkOanE2Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.0.tgz", - "integrity": "sha512-8541GEyktXaw4lvnGp9m84KENcxInhAt6vPWJ9RodsB/iGjHoMB2Pp5MVBCiKIRxrxzJhGCxmNzdu+oDQ7kwRA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.0.tgz", - "integrity": "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.0.tgz", - "integrity": "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.0.tgz", - "integrity": "sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.0.tgz", - "integrity": "sha512-3XJ0NQtMAXTWFW8FqZKcw3gOQwBtVWP/u8TpHP3CRPXD7Pd6s8lLdH3sHWh8vqKCyyiI8xW5ltJScQmBU9j7WA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.0.tgz", - "integrity": "sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@turf/area": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.2.0.tgz", - "integrity": "sha512-zuTTdQ4eoTI9nSSjerIy4QwgvxqwJVciQJ8tOPuMHbXJ9N/dNjI7bU8tasjhxas/Cx3NE9NxVHtNpYHL0FSzoA==", - "dependencies": { - "@turf/helpers": "^7.2.0", - "@turf/meta": "^7.2.0", - "@types/geojson": "^7946.0.10", - "tslib": "^2.8.1" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/bbox": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.2.0.tgz", - "integrity": "sha512-wzHEjCXlYZiDludDbXkpBSmv8Zu6tPGLmJ1sXQ6qDwpLE1Ew3mcWqt8AaxfTP5QwDNQa3sf2vvgTEzNbPQkCiA==", - "dependencies": { - "@turf/helpers": "^7.2.0", - "@turf/meta": "^7.2.0", - "@types/geojson": "^7946.0.10", - "tslib": "^2.8.1" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/centroid": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-7.2.0.tgz", - "integrity": "sha512-yJqDSw25T7P48au5KjvYqbDVZ7qVnipziVfZ9aSo7P2/jTE7d4BP21w0/XLi3T/9bry/t9PR1GDDDQljN4KfDw==", - "dependencies": { - "@turf/helpers": "^7.2.0", - "@turf/meta": "^7.2.0", - "@types/geojson": "^7946.0.10", - "tslib": "^2.8.1" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/helpers": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.2.0.tgz", - "integrity": "sha512-cXo7bKNZoa7aC7ydLmUR02oB3IgDe7MxiPuRz3cCtYQHn+BJ6h1tihmamYDWWUlPHgSNF0i3ATc4WmDECZafKw==", - "dependencies": { - "@types/geojson": "^7946.0.10", - "tslib": "^2.8.1" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@turf/meta": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.2.0.tgz", - "integrity": "sha512-igzTdHsQc8TV1RhPuOLVo74Px/hyPrVgVOTgjWQZzt3J9BVseCdpfY/0cJBdlSRI4S/yTmmHl7gAqjhpYH5Yaw==", - "dependencies": { - "@turf/helpers": "^7.2.0", - "@types/geojson": "^7946.0.10" - }, - "funding": { - "url": "https://opencollective.com/turf" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", - "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.20.7" - } - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" - }, - "node_modules/@types/d3-drag": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", - "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-selection": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", - "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==" - }, - "node_modules/@types/d3-transition": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", - "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", - "dependencies": { - "@types/d3-selection": "*" - } - }, - "node_modules/@types/d3-zoom": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", - "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", - "dependencies": { - "@types/d3-interpolate": "*", - "@types/d3-selection": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/geojson": { - "version": "7946.0.16", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", - "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==" - }, - "node_modules/@types/geojson-vt": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", - "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", - "dependencies": { - "@types/geojson": "*" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mapbox__point-geometry": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", - "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==" - }, - "node_modules/@types/mapbox__vector-tile": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", - "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", - "dependencies": { - "@types/geojson": "*", - "@types/mapbox__point-geometry": "*", - "@types/pbf": "*" - } - }, - "node_modules/@types/pbf": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", - "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==" - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true - }, - "node_modules/@types/react": { - "version": "18.3.23", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", - "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", - "devOptional": true, - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@types/supercluster": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", - "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", - "dependencies": { - "@types/geojson": "*" - } - }, - "node_modules/@uiw/codemirror-extensions-basic-setup": { - "version": "4.24.2", - "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.24.2.tgz", - "integrity": "sha512-wW/gjLRvVUeYyhdh2TApn25cvdcR+Rhg6R/j3eTOvXQzU1HNzNYCVH4YKVIfgtfdM/Xs+N8fkk+rbr1YvBppCg==", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/commands": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/lint": "^6.0.0", - "@codemirror/search": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0" - }, - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - }, - "peerDependencies": { - "@codemirror/autocomplete": ">=6.0.0", - "@codemirror/commands": ">=6.0.0", - "@codemirror/language": ">=6.0.0", - "@codemirror/lint": ">=6.0.0", - "@codemirror/search": ">=6.0.0", - "@codemirror/state": ">=6.0.0", - "@codemirror/view": ">=6.0.0" - } - }, - "node_modules/@uiw/codemirror-theme-vscode": { - "version": "4.24.2", - "resolved": "https://registry.npmjs.org/@uiw/codemirror-theme-vscode/-/codemirror-theme-vscode-4.24.2.tgz", - "integrity": "sha512-5rLvAMKNjqtHSy3BZnI1Q4ZLqUDDJC5fKOp8Mv5VjPqEmAxEAIGrIXBnhAFlRg5VjHbHEMtM1O5ugL3IeHoBdQ==", - "dependencies": { - "@uiw/codemirror-themes": "4.24.2" - }, - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - } - }, - "node_modules/@uiw/codemirror-themes": { - "version": "4.24.2", - "resolved": "https://registry.npmjs.org/@uiw/codemirror-themes/-/codemirror-themes-4.24.2.tgz", - "integrity": "sha512-0fQusJE08APL+1WM0xYqvampVM2RpABHjvZbcJWHdRc+teZbrP8907VW1ZX1tO/7/xgRRt4Fc3RHPCcTs7b0NQ==", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0" - }, - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - }, - "peerDependencies": { - "@codemirror/language": ">=6.0.0", - "@codemirror/state": ">=6.0.0", - "@codemirror/view": ">=6.0.0" - } - }, - "node_modules/@uiw/react-codemirror": { - "version": "4.24.2", - "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.24.2.tgz", - "integrity": "sha512-kp7DhTq4RR+M2zJBQBrHn1dIkBrtOskcwJX4vVsKGByReOvfMrhqRkGTxYMRDTX6x75EG2mvBJPDKYcUQcHWBw==", - "dependencies": { - "@babel/runtime": "^7.18.6", - "@codemirror/commands": "^6.1.0", - "@codemirror/state": "^6.1.1", - "@codemirror/theme-one-dark": "^6.0.0", - "@uiw/codemirror-extensions-basic-setup": "4.24.2", - "codemirror": "^6.0.0" - }, - "funding": { - "url": "https://jaywcjlove.github.io/#/sponsor" - }, - "peerDependencies": { - "@babel/runtime": ">=7.11.0", - "@codemirror/state": ">=6.0.0", - "@codemirror/theme-one-dark": ">=6.0.0", - "@codemirror/view": ">=6.0.0", - "codemirror": ">=6.0.0", - "react": ">=17.0.0", - "react-dom": ">=17.0.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.2.tgz", - "integrity": "sha512-QNVT3/Lxx99nMQWJWF7K4N6apUEuT0KlZA3mx/mVaoGj3smm/8rc8ezz15J1pcbcjDK0V15rpHetVfya08r76Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.11", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" - } - }, - "node_modules/@xyflow/react": { - "version": "12.8.1", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.1.tgz", - "integrity": "sha512-t5Rame4Gc/540VcOZd28yFe9Xd8lyjKUX+VTiyb1x4ykNXZH5zyDmsu+lj9je2O/jGBVb0pj1Vjcxrxyn+Xk2g==", - "dependencies": { - "@xyflow/system": "0.0.65", - "classcat": "^5.0.3", - "zustand": "^4.4.0" - }, - "peerDependencies": { - "react": ">=17", - "react-dom": ">=17" - } - }, - "node_modules/@xyflow/system": { - "version": "0.0.65", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.65.tgz", - "integrity": "sha512-AliQPQeurQMoNlOdySnRoDQl9yDSA/1Lqi47Eo0m98lHcfrTdD9jK75H0tiGj+0qRC10SKNUXyMkT0KL0opg4g==", - "dependencies": { - "@types/d3-drag": "^3.0.7", - "@types/d3-interpolate": "^3.0.4", - "@types/d3-selection": "^3.0.10", - "@types/d3-transition": "^3.0.8", - "@types/d3-zoom": "^3.0.8", - "d3-drag": "^3.0.0", - "d3-interpolate": "^3.0.1", - "d3-selection": "^3.0.0", - "d3-zoom": "^3.0.0" - } - }, - "node_modules/abs-svg-path": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", - "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==" - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-bounds": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-bounds/-/array-bounds-1.0.1.tgz", - "integrity": "sha512-8wdW3ZGk6UjMPJx/glyEt0sLzzwAE1bhToPsO1W2pbpR2gULyxe3BjSiuJFheP50T/GgODVPz2fuMUmIywt8cQ==" - }, - "node_modules/array-find-index": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", - "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-normalize": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array-normalize/-/array-normalize-1.1.4.tgz", - "integrity": "sha512-fCp0wKFLjvSPmCn4F5Tiw4M3lpMZoHlCjfcs7nNzuj3vqQQ1/a8cgB9DXcpDSn18c+coLnaW7rqfcYCvKbyJXg==", - "dependencies": { - "array-bounds": "^1.0.0" - } - }, - "node_modules/array-range": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-range/-/array-range-1.0.1.tgz", - "integrity": "sha512-shdaI1zT3CVNL2hnx9c0JMc0ZogGaxDs5e85akgHWKYa0yVbIyp06Ind3dVkTj/uuFrzaHBOyqFzo+VV6aXgtA==" - }, - "node_modules/array-rearrange": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/array-rearrange/-/array-rearrange-2.2.2.tgz", - "integrity": "sha512-UfobP5N12Qm4Qu4fwLDIi2v6+wZsSf6snYSxAMeKhrh37YGnNWZPRmVEKc/2wfms53TLQnzfpG8wCx2Y/6NG1w==" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/base64-arraybuffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", - "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/binary-search-bounds": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz", - "integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==" - }, - "node_modules/bit-twiddle": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bit-twiddle/-/bit-twiddle-1.0.2.tgz", - "integrity": "sha512-B9UhK0DKFZhoTFcfvAzhqsjStvGJp9vYWf3+6SNTtdSQnvIgfkHbgHrg/e4+TH71N2GDu8tpmCVoyfrL1d7ntA==" - }, - "node_modules/bitmap-sdf": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", - "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==" - }, - "node_modules/bl": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", - "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", - "dependencies": { - "readable-stream": "^2.3.5", - "safe-buffer": "^5.1.1" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/browserslist": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", - "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "caniuse-lite": "^1.0.30001718", - "electron-to-chromium": "^1.5.160", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001724", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001724.tgz", - "integrity": "sha512-WqJo7p0TbHDOythNTqYujmaJTvtYRZrjpP8TCvH6Vb9CYJerJNKamKzIWOM4BkQatWj9H2lYulpdAQNBe7QhNA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/canvas-fit": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/canvas-fit/-/canvas-fit-1.5.0.tgz", - "integrity": "sha512-onIcjRpz69/Hx5bB5HGbYKUF2uC6QT6Gp+pfpGm3A7mPfcluSLV5v4Zu+oflDUwLdUw0rLIBhUbi0v8hM4FJQQ==", - "dependencies": { - "element-size": "^1.1.1" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/clamp": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz", - "integrity": "sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==" - }, - "node_modules/classcat": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", - "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==" - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/codemirror": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", - "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/commands": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/lint": "^6.0.0", - "@codemirror/search": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0" - } - }, - "node_modules/color-alpha": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/color-alpha/-/color-alpha-1.0.4.tgz", - "integrity": "sha512-lr8/t5NPozTSqli+duAN+x+no/2WaKTeWvxhHGN+aXT6AJ8vPlzLa7UriyjWak0pSC2jHol9JgjBYnnHsGha9A==", - "dependencies": { - "color-parse": "^1.3.8" - } - }, - "node_modules/color-alpha/node_modules/color-parse": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", - "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", - "dependencies": { - "color-name": "^1.0.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-id": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/color-id/-/color-id-1.1.0.tgz", - "integrity": "sha512-2iRtAn6dC/6/G7bBIo0uupVrIne1NsQJvJxZOBCzQOfk7jRq97feaDZ3RdzuHakRXXnHGNwglto3pqtRx1sX0g==", - "dependencies": { - "clamp": "^1.0.1" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/color-normalize": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/color-normalize/-/color-normalize-1.5.0.tgz", - "integrity": "sha512-rUT/HDXMr6RFffrR53oX3HGWkDOP9goSAQGBkUaAYKjOE2JxozccdGyufageWDlInRAjm/jYPrf/Y38oa+7obw==", - "dependencies": { - "clamp": "^1.0.1", - "color-rgba": "^2.1.1", - "dtype": "^2.0.0" - } - }, - "node_modules/color-normalize/node_modules/color-parse": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", - "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", - "dependencies": { - "color-name": "^1.0.0" - } - }, - "node_modules/color-normalize/node_modules/color-rgba": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/color-rgba/-/color-rgba-2.4.0.tgz", - "integrity": "sha512-Nti4qbzr/z2LbUWySr7H9dk3Rl7gZt7ihHAxlgT4Ho90EXWkjtkL1avTleu9yeGuqrt/chxTB6GKK8nZZ6V0+Q==", - "dependencies": { - "color-parse": "^1.4.2", - "color-space": "^2.0.0" - } - }, - "node_modules/color-parse": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-2.0.0.tgz", - "integrity": "sha512-g2Z+QnWsdHLppAbrpcFWo629kLOnOPtpxYV69GCqm92gqSgyXbzlfyN3MXs0412fPBkFmiuS+rXposgBgBa6Kg==", - "dependencies": { - "color-name": "^1.0.0" - } - }, - "node_modules/color-rgba": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/color-rgba/-/color-rgba-3.0.0.tgz", - "integrity": "sha512-PPwZYkEY3M2THEHHV6Y95sGUie77S7X8v+h1r6LSAPF3/LL2xJ8duUXSrkic31Nzc4odPwHgUbiX/XuTYzQHQg==", - "dependencies": { - "color-parse": "^2.0.0", - "color-space": "^2.0.0" - } - }, - "node_modules/color-space": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/color-space/-/color-space-2.3.2.tgz", - "integrity": "sha512-BcKnbOEsOarCwyoLstcoEztwT0IJxqqQkNwDuA3a65sICvvHL2yoeV13psoDFh5IuiOMnIOKdQDwB4Mk3BypiA==" - }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "engines": [ - "node >= 0.8" - ], - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/concurrently": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz", - "integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2", - "lodash": "^4.17.21", - "rxjs": "^7.8.1", - "shell-quote": "^1.8.1", - "supports-color": "^8.1.1", - "tree-kill": "^1.2.2", - "yargs": "^17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" - }, - "node_modules/country-regex": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/country-regex/-/country-regex-1.1.0.tgz", - "integrity": "sha512-iSPlClZP8vX7MC3/u6s3lrDuoQyhQukh5LyABJ3hvfzbQ3Yyayd4fp04zjLnfi267B/B2FkumcWWgrbban7sSA==" - }, - "node_modules/crelt": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-font": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/css-font/-/css-font-1.2.0.tgz", - "integrity": "sha512-V4U4Wps4dPDACJ4WpgofJ2RT5Yqwe1lEH6wlOOaIxMi0gTjdIijsc5FmxQlZ7ZZyKQkkutqqvULOp07l9c7ssA==", - "dependencies": { - "css-font-size-keywords": "^1.0.0", - "css-font-stretch-keywords": "^1.0.1", - "css-font-style-keywords": "^1.0.1", - "css-font-weight-keywords": "^1.0.0", - "css-global-keywords": "^1.0.1", - "css-system-font-keywords": "^1.0.0", - "pick-by-alias": "^1.2.0", - "string-split-by": "^1.0.0", - "unquote": "^1.1.0" - } - }, - "node_modules/css-font-size-keywords": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/css-font-size-keywords/-/css-font-size-keywords-1.0.0.tgz", - "integrity": "sha512-Q+svMDbMlelgCfH/RVDKtTDaf5021O486ZThQPIpahnIjUkMUslC+WuOQSWTgGSrNCH08Y7tYNEmmy0hkfMI8Q==" - }, - "node_modules/css-font-stretch-keywords": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/css-font-stretch-keywords/-/css-font-stretch-keywords-1.0.1.tgz", - "integrity": "sha512-KmugPO2BNqoyp9zmBIUGwt58UQSfyk1X5DbOlkb2pckDXFSAfjsD5wenb88fNrD6fvS+vu90a/tsPpb9vb0SLg==" - }, - "node_modules/css-font-style-keywords": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/css-font-style-keywords/-/css-font-style-keywords-1.0.1.tgz", - "integrity": "sha512-0Fn0aTpcDktnR1RzaBYorIxQily85M2KXRpzmxQPgh8pxUN9Fcn00I8u9I3grNr1QXVgCl9T5Imx0ZwKU973Vg==" - }, - "node_modules/css-font-weight-keywords": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/css-font-weight-keywords/-/css-font-weight-keywords-1.0.0.tgz", - "integrity": "sha512-5So8/NH+oDD+EzsnF4iaG4ZFHQ3vaViePkL1ZbZ5iC/KrsCY+WHq/lvOgrtmuOQ9pBBZ1ADGpaf+A4lj1Z9eYA==" - }, - "node_modules/css-global-keywords": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/css-global-keywords/-/css-global-keywords-1.0.1.tgz", - "integrity": "sha512-X1xgQhkZ9n94WDwntqst5D/FKkmiU0GlJSFZSV3kLvyJ1WC5VeyoXDOuleUD+SIuH9C7W05is++0Woh0CGfKjQ==" - }, - "node_modules/css-system-font-keywords": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/css-system-font-keywords/-/css-system-font-keywords-1.0.0.tgz", - "integrity": "sha512-1umTtVd/fXS25ftfjB71eASCrYhilmEsvDEI6wG/QplnmlfmVM5HkZ/ZX46DT5K3eblFPgLUHt5BRCb0YXkSFA==" - }, - "node_modules/csscolorparser": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", - "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==" - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/d": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", - "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", - "dependencies": { - "es5-ext": "^0.10.64", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/d3-array": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", - "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==" - }, - "node_modules/d3-collection": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", - "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==" - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-dispatch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", - "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-drag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", - "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-selection": "3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-force": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", - "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==", - "dependencies": { - "d3-collection": "1", - "d3-dispatch": "1", - "d3-quadtree": "1", - "d3-timer": "1" - } - }, - "node_modules/d3-force/node_modules/d3-dispatch": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", - "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==" - }, - "node_modules/d3-force/node_modules/d3-timer": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", - "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==" - }, - "node_modules/d3-format": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", - "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==" - }, - "node_modules/d3-geo": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", - "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==", - "dependencies": { - "d3-array": "1" - } - }, - "node_modules/d3-geo-projection": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-2.9.0.tgz", - "integrity": "sha512-ZULvK/zBn87of5rWAfFMc9mJOipeSo57O+BBitsKIXmU4rTVAnX1kSsJkE0R+TxY8pGNoM1nbyRRE7GYHhdOEQ==", - "dependencies": { - "commander": "2", - "d3-array": "1", - "d3-geo": "^1.12.0", - "resolve": "^1.1.10" - }, - "bin": { - "geo2svg": "bin/geo2svg", - "geograticule": "bin/geograticule", - "geoproject": "bin/geoproject", - "geoquantize": "bin/geoquantize", - "geostitch": "bin/geostitch" - } - }, - "node_modules/d3-hierarchy": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", - "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==" - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", - "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" - }, - "node_modules/d3-quadtree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", - "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==" - }, - "node_modules/d3-selection": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", - "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", - "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", - "dependencies": { - "d3-path": "1" - } - }, - "node_modules/d3-time": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", - "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==" - }, - "node_modules/d3-time-format": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", - "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", - "dependencies": { - "d3-time": "1" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-transition": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", - "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "dependencies": { - "d3-color": "1 - 3", - "d3-dispatch": "1 - 3", - "d3-ease": "1 - 3", - "d3-interpolate": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "d3-selection": "2 - 3" - } - }, - "node_modules/d3-zoom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", - "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "dependencies": { - "d3-dispatch": "1 - 3", - "d3-drag": "2 - 3", - "d3-interpolate": "1 - 3", - "d3-selection": "2 - 3", - "d3-transition": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/defined": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", - "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/detect-kerning": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-kerning/-/detect-kerning-2.1.2.tgz", - "integrity": "sha512-I3JIbrnKPAntNLl1I6TpSQQdQ4AutYzv/sKMFKbepawV/hlH0GmYKhUoOEMd4xqaUHT+Bm0f4127lh5qs1m1tw==" - }, - "node_modules/draw-svg-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/draw-svg-path/-/draw-svg-path-1.0.0.tgz", - "integrity": "sha512-P8j3IHxcgRMcY6sDzr0QvJDLzBnJJqpTG33UZ2Pvp8rw0apCHhJCWqYprqrXjrgHnJ6tuhP1iTJSAodPDHxwkg==", - "dependencies": { - "abs-svg-path": "~0.1.1", - "normalize-svg-path": "~0.1.0" - } - }, - "node_modules/dtype": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dtype/-/dtype-2.0.0.tgz", - "integrity": "sha512-s2YVcLKdFGS0hpFqJaTwscsyt0E8nNFdmo73Ocd81xNPj4URI4rj6D60A+vFMIw7BXWlb4yRkEwfBqcZzPGiZg==", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/dup": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/dup/-/dup-1.0.0.tgz", - "integrity": "sha512-Bz5jxMMC0wgp23Zm15ip1x8IhYRqJvF3nFC0UInJUDkN1z4uNPk9jTnfCUJXbOGiQ1JbXLQsiV41Fb+HXcj5BA==" - }, - "node_modules/duplexify": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", - "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", - "dependencies": { - "end-of-stream": "^1.0.0", - "inherits": "^2.0.1", - "readable-stream": "^2.0.0", - "stream-shift": "^1.0.0" - } - }, - "node_modules/earcut": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", - "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.171", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.171.tgz", - "integrity": "sha512-scWpzXEJEMrGJa4Y6m/tVotb0WuvNmasv3wWVzUAeCgKU0ToFOhUW6Z+xWnRQANMYGxN4ngJXIThgBJOqzVPCQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/element-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/element-size/-/element-size-1.1.1.tgz", - "integrity": "sha512-eaN+GMOq/Q+BIWy0ybsgpcYImjGIdNLyjLFJU4XsLHXYQao5jCNb36GyN6C2qwmDDYSfIBmKpPpr4VnBdLCsPQ==" - }, - "node_modules/elementary-circuits-directed-graph": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/elementary-circuits-directed-graph/-/elementary-circuits-directed-graph-1.3.1.tgz", - "integrity": "sha512-ZEiB5qkn2adYmpXGnJKkxT8uJHlW/mxmBpmeqawEHzPxh9HkLD4/1mFYX5l0On+f6rcPIt8/EWlRU2Vo3fX6dQ==", - "dependencies": { - "strongly-connected-components": "^1.0.1" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/es5-ext": { - "version": "0.10.64", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", - "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", - "hasInstallScript": true, - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", - "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", - "dependencies": { - "d": "^1.0.2", - "ext": "^1.7.0" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/es6-weak-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", - "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.46", - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/esbuild": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", - "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.5", - "@esbuild/android-arm": "0.25.5", - "@esbuild/android-arm64": "0.25.5", - "@esbuild/android-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/freebsd-arm64": "0.25.5", - "@esbuild/freebsd-x64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-ia32": "0.25.5", - "@esbuild/linux-loong64": "0.25.5", - "@esbuild/linux-mips64el": "0.25.5", - "@esbuild/linux-ppc64": "0.25.5", - "@esbuild/linux-riscv64": "0.25.5", - "@esbuild/linux-s390x": "0.25.5", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/netbsd-arm64": "0.25.5", - "@esbuild/netbsd-x64": "0.25.5", - "@esbuild/openbsd-arm64": "0.25.5", - "@esbuild/openbsd-x64": "0.25.5", - "@esbuild/sunos-x64": "0.25.5", - "@esbuild/win32-arm64": "0.25.5", - "@esbuild/win32-ia32": "0.25.5", - "@esbuild/win32-x64": "0.25.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/escodegen": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", - "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/eslint": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", - "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.1", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.14.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.29.0", - "@eslint/plugin-kit": "^0.3.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.20", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", - "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": ">=8.40" - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "dependencies": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "dependencies": { - "type": "^2.7.2" - } - }, - "node_modules/falafel": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/falafel/-/falafel-2.2.5.tgz", - "integrity": "sha512-HuC1qF9iTnHDnML9YZAdCDQwT0yKl/U55K4XSUXqGAA2GLoafFgWRqdAbhWJxXaYD4pyoVxAJ8wH670jMpI9DQ==", - "dependencies": { - "acorn": "^7.1.1", - "isarray": "^2.0.1" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/falafel/node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-isnumeric": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-isnumeric/-/fast-isnumeric-1.1.4.tgz", - "integrity": "sha512-1mM8qOr2LYz8zGaUdmiqRDiuue00Dxjgcb1NQR7TnhLVh6sQyngP9xvLo7Sl7LZpP/sk5eb+bcyWXw530NTBZw==", - "dependencies": { - "is-string-blank": "^1.0.1" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/flatten-vertex-data": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/flatten-vertex-data/-/flatten-vertex-data-1.0.2.tgz", - "integrity": "sha512-BvCBFK2NZqerFTdMDgqfHBwxYWnxeCkwONsw6PvBMcUXqo8U/KDWwmXhqx1x2kLIg7DqIsJfOaJFOmlua3Lxuw==", - "dependencies": { - "dtype": "^2.0.0" - } - }, - "node_modules/font-atlas": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/font-atlas/-/font-atlas-2.1.0.tgz", - "integrity": "sha512-kP3AmvX+HJpW4w3d+PiPR2X6E1yvsBXt2yhuCw+yReO9F1WYhvZwx3c95DGZGwg9xYzDGrgJYa885xmVA+28Cg==", - "dependencies": { - "css-font": "^1.0.0" - } - }, - "node_modules/font-measure": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/font-measure/-/font-measure-1.2.2.tgz", - "integrity": "sha512-mRLEpdrWzKe9hbfaF3Qpr06TAjquuBVP5cHy4b3hyeNdjc9i0PO6HniGsX5vjL5OWv7+Bd++NiooNpT/s8BvIA==", - "dependencies": { - "css-font": "^1.2.0" - } - }, - "node_modules/from2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", - "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", - "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/geojson-vt": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", - "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==" - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-canvas-context": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/get-canvas-context/-/get-canvas-context-1.0.2.tgz", - "integrity": "sha512-LnpfLf/TNzr9zVOGiIY6aKCz8EKuXmlYNV7CM2pUjBa/B+c2I15tS7KLySep75+FuerJdmArvJLcsAXWEy2H0A==" - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gl-mat4": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gl-mat4/-/gl-mat4-1.2.0.tgz", - "integrity": "sha512-sT5C0pwB1/e9G9AvAoLsoaJtbMGjfd/jfxo8jMCKqYYEnjZuFvqV5rehqar0538EmssjdDeiEWnKyBSTw7quoA==" - }, - "node_modules/gl-matrix": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz", - "integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==" - }, - "node_modules/gl-text": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/gl-text/-/gl-text-1.4.0.tgz", - "integrity": "sha512-o47+XBqLCj1efmuNyCHt7/UEJmB9l66ql7pnobD6p+sgmBUdzfMZXIF0zD2+KRfpd99DJN+QXdvTFAGCKCVSmQ==", - "dependencies": { - "bit-twiddle": "^1.0.2", - "color-normalize": "^1.5.0", - "css-font": "^1.2.0", - "detect-kerning": "^2.1.2", - "es6-weak-map": "^2.0.3", - "flatten-vertex-data": "^1.0.2", - "font-atlas": "^2.1.0", - "font-measure": "^1.2.2", - "gl-util": "^3.1.2", - "is-plain-obj": "^1.1.0", - "object-assign": "^4.1.1", - "parse-rect": "^1.2.0", - "parse-unit": "^1.0.1", - "pick-by-alias": "^1.2.0", - "regl": "^2.0.0", - "to-px": "^1.0.1", - "typedarray-pool": "^1.1.0" - } - }, - "node_modules/gl-util": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/gl-util/-/gl-util-3.1.3.tgz", - "integrity": "sha512-dvRTggw5MSkJnCbh74jZzSoTOGnVYK+Bt+Ckqm39CVcl6+zSsxqWk4lr5NKhkqXHL6qvZAU9h17ZF8mIskY9mA==", - "dependencies": { - "is-browser": "^2.0.1", - "is-firefox": "^1.0.3", - "is-plain-obj": "^1.1.0", - "number-is-integer": "^1.0.1", - "object-assign": "^4.1.0", - "pick-by-alias": "^1.2.0", - "weak-map": "^1.0.5" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/global-prefix": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", - "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", - "dependencies": { - "ini": "^4.1.3", - "kind-of": "^6.0.3", - "which": "^4.0.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/global-prefix/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "engines": { - "node": ">=16" - } - }, - "node_modules/global-prefix/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^16.13.0 || >=18.0.0" - } - }, - "node_modules/globals": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz", - "integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glsl-inject-defines": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/glsl-inject-defines/-/glsl-inject-defines-1.0.3.tgz", - "integrity": "sha512-W49jIhuDtF6w+7wCMcClk27a2hq8znvHtlGnrYkSWEr8tHe9eA2dcnohlcAmxLYBSpSSdzOkRdyPTrx9fw49+A==", - "dependencies": { - "glsl-token-inject-block": "^1.0.0", - "glsl-token-string": "^1.0.1", - "glsl-tokenizer": "^2.0.2" - } - }, - "node_modules/glsl-resolve": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/glsl-resolve/-/glsl-resolve-0.0.1.tgz", - "integrity": "sha512-xxFNsfnhZTK9NBhzJjSBGX6IOqYpvBHxxmo+4vapiljyGNCY0Bekzn0firQkQrazK59c1hYxMDxYS8MDlhw4gA==", - "dependencies": { - "resolve": "^0.6.1", - "xtend": "^2.1.2" - } - }, - "node_modules/glsl-resolve/node_modules/resolve": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-0.6.3.tgz", - "integrity": "sha512-UHBY3viPlJKf85YijDUcikKX6tmF4SokIDp518ZDVT92JNDcG5uKIthaT/owt3Sar0lwtOafsQuwrg22/v2Dwg==" - }, - "node_modules/glsl-resolve/node_modules/xtend": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.2.0.tgz", - "integrity": "sha512-SLt5uylT+4aoXxXuwtQp5ZnMMzhDb1Xkg4pEqc00WUJCQifPfV9Ub1VrNhp9kXkrjZD2I2Hl8WnjP37jzZLPZw==", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/glsl-token-assignments": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/glsl-token-assignments/-/glsl-token-assignments-2.0.2.tgz", - "integrity": "sha512-OwXrxixCyHzzA0U2g4btSNAyB2Dx8XrztY5aVUCjRSh4/D0WoJn8Qdps7Xub3sz6zE73W3szLrmWtQ7QMpeHEQ==" - }, - "node_modules/glsl-token-defines": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/glsl-token-defines/-/glsl-token-defines-1.0.0.tgz", - "integrity": "sha512-Vb5QMVeLjmOwvvOJuPNg3vnRlffscq2/qvIuTpMzuO/7s5kT+63iL6Dfo2FYLWbzuiycWpbC0/KV0biqFwHxaQ==", - "dependencies": { - "glsl-tokenizer": "^2.0.0" - } - }, - "node_modules/glsl-token-depth": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/glsl-token-depth/-/glsl-token-depth-1.1.2.tgz", - "integrity": "sha512-eQnIBLc7vFf8axF9aoi/xW37LSWd2hCQr/3sZui8aBJnksq9C7zMeUYHVJWMhFzXrBU7fgIqni4EhXVW4/krpg==" - }, - "node_modules/glsl-token-descope": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/glsl-token-descope/-/glsl-token-descope-1.0.2.tgz", - "integrity": "sha512-kS2PTWkvi/YOeicVjXGgX5j7+8N7e56srNDEHDTVZ1dcESmbmpmgrnpjPcjxJjMxh56mSXYoFdZqb90gXkGjQw==", - "dependencies": { - "glsl-token-assignments": "^2.0.0", - "glsl-token-depth": "^1.1.0", - "glsl-token-properties": "^1.0.0", - "glsl-token-scope": "^1.1.0" - } - }, - "node_modules/glsl-token-inject-block": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/glsl-token-inject-block/-/glsl-token-inject-block-1.1.0.tgz", - "integrity": "sha512-q/m+ukdUBuHCOtLhSr0uFb/qYQr4/oKrPSdIK2C4TD+qLaJvqM9wfXIF/OOBjuSA3pUoYHurVRNao6LTVVUPWA==" - }, - "node_modules/glsl-token-properties": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/glsl-token-properties/-/glsl-token-properties-1.0.1.tgz", - "integrity": "sha512-dSeW1cOIzbuUoYH0y+nxzwK9S9O3wsjttkq5ij9ZGw0OS41BirKJzzH48VLm8qLg+au6b0sINxGC0IrGwtQUcA==" - }, - "node_modules/glsl-token-scope": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/glsl-token-scope/-/glsl-token-scope-1.1.2.tgz", - "integrity": "sha512-YKyOMk1B/tz9BwYUdfDoHvMIYTGtVv2vbDSLh94PT4+f87z21FVdou1KNKgF+nECBTo0fJ20dpm0B1vZB1Q03A==" - }, - "node_modules/glsl-token-string": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/glsl-token-string/-/glsl-token-string-1.0.1.tgz", - "integrity": "sha512-1mtQ47Uxd47wrovl+T6RshKGkRRCYWhnELmkEcUAPALWGTFe2XZpH3r45XAwL2B6v+l0KNsCnoaZCSnhzKEksg==" - }, - "node_modules/glsl-token-whitespace-trim": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/glsl-token-whitespace-trim/-/glsl-token-whitespace-trim-1.0.0.tgz", - "integrity": "sha512-ZJtsPut/aDaUdLUNtmBYhaCmhIjpKNg7IgZSfX5wFReMc2vnj8zok+gB/3Quqs0TsBSX/fGnqUUYZDqyuc2xLQ==" - }, - "node_modules/glsl-tokenizer": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/glsl-tokenizer/-/glsl-tokenizer-2.1.5.tgz", - "integrity": "sha512-XSZEJ/i4dmz3Pmbnpsy3cKh7cotvFlBiZnDOwnj/05EwNp2XrhQ4XKJxT7/pDt4kp4YcpRSKz8eTV7S+mwV6MA==", - "dependencies": { - "through2": "^0.6.3" - } - }, - "node_modules/glsl-tokenizer/node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" - }, - "node_modules/glsl-tokenizer/node_modules/readable-stream": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "node_modules/glsl-tokenizer/node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" - }, - "node_modules/glsl-tokenizer/node_modules/through2": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", - "integrity": "sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==", - "dependencies": { - "readable-stream": ">=1.0.33-1 <1.1.0-0", - "xtend": ">=4.0.0 <4.1.0-0" - } - }, - "node_modules/glslify": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/glslify/-/glslify-7.1.1.tgz", - "integrity": "sha512-bud98CJ6kGZcP9Yxcsi7Iz647wuDz3oN+IZsjCRi5X1PI7t/xPKeL0mOwXJjo+CRZMqvq0CkSJiywCcY7kVYog==", - "dependencies": { - "bl": "^2.2.1", - "concat-stream": "^1.5.2", - "duplexify": "^3.4.5", - "falafel": "^2.1.0", - "from2": "^2.3.0", - "glsl-resolve": "0.0.1", - "glsl-token-whitespace-trim": "^1.0.0", - "glslify-bundle": "^5.0.0", - "glslify-deps": "^1.2.5", - "minimist": "^1.2.5", - "resolve": "^1.1.5", - "stack-trace": "0.0.9", - "static-eval": "^2.0.5", - "through2": "^2.0.1", - "xtend": "^4.0.0" - }, - "bin": { - "glslify": "bin.js" - } - }, - "node_modules/glslify-bundle": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/glslify-bundle/-/glslify-bundle-5.1.1.tgz", - "integrity": "sha512-plaAOQPv62M1r3OsWf2UbjN0hUYAB7Aph5bfH58VxJZJhloRNbxOL9tl/7H71K7OLJoSJ2ZqWOKk3ttQ6wy24A==", - "dependencies": { - "glsl-inject-defines": "^1.0.1", - "glsl-token-defines": "^1.0.0", - "glsl-token-depth": "^1.1.1", - "glsl-token-descope": "^1.0.2", - "glsl-token-scope": "^1.1.1", - "glsl-token-string": "^1.0.1", - "glsl-token-whitespace-trim": "^1.0.0", - "glsl-tokenizer": "^2.0.2", - "murmurhash-js": "^1.0.0", - "shallow-copy": "0.0.1" - } - }, - "node_modules/glslify-deps": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/glslify-deps/-/glslify-deps-1.3.2.tgz", - "integrity": "sha512-7S7IkHWygJRjcawveXQjRXLO2FTjijPDYC7QfZyAQanY+yGLCFHYnPtsGT9bdyHiwPTw/5a1m1M9hamT2aBpag==", - "dependencies": { - "@choojs/findup": "^0.2.0", - "events": "^3.2.0", - "glsl-resolve": "0.0.1", - "glsl-tokenizer": "^2.0.0", - "graceful-fs": "^4.1.2", - "inherits": "^2.0.1", - "map-limit": "0.0.1", - "resolve": "^1.0.0" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" - }, - "node_modules/grid-index": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", - "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-hover": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-hover/-/has-hover-1.0.1.tgz", - "integrity": "sha512-0G6w7LnlcpyDzpeGUTuT0CEw05+QlMuGVk1IHNAlHrGJITGodjZu3x8BNDUMfKJSZXNB2ZAclqc1bvrd+uUpfg==", - "dependencies": { - "is-browser": "^2.0.1" - } - }, - "node_modules/has-passive-events": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-passive-events/-/has-passive-events-1.0.0.tgz", - "integrity": "sha512-2vSj6IeIsgvsRMyeQ0JaCX5Q3lX4zMn5HpoVc7MEhQ6pv8Iq9rsXjsp+E5ZwaT7T0xhMT0KmU8gtt1EFVdbJiw==", - "dependencies": { - "is-browser": "^2.0.1" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ini": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", - "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/is-browser": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-browser/-/is-browser-2.1.0.tgz", - "integrity": "sha512-F5rTJxDQ2sW81fcfOR1GnCXT6sVJC104fCyfj+mjpwNEwaPYSn5fte5jiHmBg3DHsIoL/l8Kvw5VN5SsTRcRFQ==" - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-finite": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", - "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-firefox": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-firefox/-/is-firefox-1.0.3.tgz", - "integrity": "sha512-6Q9ITjvWIm0Xdqv+5U12wgOKEM2KoBw4Y926m0OFkvlCxnbG94HKAsVz8w3fWcfAS5YA2fJORXX1dLrkprCCxA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-iexplorer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-iexplorer/-/is-iexplorer-1.0.0.tgz", - "integrity": "sha512-YeLzceuwg3K6O0MLM3UyUUjKAlyULetwryFp1mHy1I5PfArK0AEqlfa+MR4gkJjcbuJXoDJCvXbyqZVf5CR2Sg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-mobile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-4.0.0.tgz", - "integrity": "sha512-mlcHZA84t1qLSuWkt2v0I2l61PYdyQDt4aG1mLIXF5FDMm4+haBCxCPYSr/uwqQNRk1MiTizn0ypEuRAOLRAew==" - }, - "node_modules/is-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", - "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-plain-obj": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", - "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-string-blank": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-string-blank/-/is-string-blank-1.0.1.tgz", - "integrity": "sha512-9H+ZBCVs3L9OYqv8nuUAzpcT9OTgMD1yAWrG7ihlnibdkbtB850heAmYWxHuXc4CHy4lKeK69tN+ny1K7gBIrw==" - }, - "node_modules/is-svg-path": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-svg-path/-/is-svg-path-1.0.2.tgz", - "integrity": "sha512-Lj4vePmqpPR1ZnRctHv8ltSh1OrSxHkhUkd7wi+VQdcdP15/KvQFyk7LhNuM7ZW0EVbJz8kZLVmL9quLrfq4Kg==" - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stringify-pretty-compact": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", - "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/kdbush": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", - "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/map-limit": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/map-limit/-/map-limit-0.0.1.tgz", - "integrity": "sha512-pJpcfLPnIF/Sk3taPW21G/RQsEEirGaFpCW3oXRwH9dnFHPHNGjNyvh++rdmC2fNqEaTw2MhYJraoJWAHx8kEg==", - "dependencies": { - "once": "~1.3.0" - } - }, - "node_modules/map-limit/node_modules/once": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz", - "integrity": "sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/mapbox-gl": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", - "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", - "peer": true, - "dependencies": { - "@mapbox/geojson-rewind": "^0.5.2", - "@mapbox/geojson-types": "^1.0.2", - "@mapbox/jsonlint-lines-primitives": "^2.0.2", - "@mapbox/mapbox-gl-supported": "^1.5.0", - "@mapbox/point-geometry": "^0.1.0", - "@mapbox/tiny-sdf": "^1.1.1", - "@mapbox/unitbezier": "^0.0.0", - "@mapbox/vector-tile": "^1.3.1", - "@mapbox/whoots-js": "^3.1.0", - "csscolorparser": "~1.0.3", - "earcut": "^2.2.2", - "geojson-vt": "^3.2.1", - "gl-matrix": "^3.2.1", - "grid-index": "^1.1.0", - "murmurhash-js": "^1.0.0", - "pbf": "^3.2.1", - "potpack": "^1.0.1", - "quickselect": "^2.0.0", - "rw": "^1.3.3", - "supercluster": "^7.1.0", - "tinyqueue": "^2.0.3", - "vt-pbf": "^3.1.1" - }, - "engines": { - "node": ">=6.4.0" - } - }, - "node_modules/maplibre-gl": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", - "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==", - "dependencies": { - "@mapbox/geojson-rewind": "^0.5.2", - "@mapbox/jsonlint-lines-primitives": "^2.0.2", - "@mapbox/point-geometry": "^0.1.0", - "@mapbox/tiny-sdf": "^2.0.6", - "@mapbox/unitbezier": "^0.0.1", - "@mapbox/vector-tile": "^1.3.1", - "@mapbox/whoots-js": "^3.1.0", - "@maplibre/maplibre-gl-style-spec": "^20.3.1", - "@types/geojson": "^7946.0.14", - "@types/geojson-vt": "3.2.5", - "@types/mapbox__point-geometry": "^0.1.4", - "@types/mapbox__vector-tile": "^1.3.4", - "@types/pbf": "^3.0.5", - "@types/supercluster": "^7.1.3", - "earcut": "^3.0.0", - "geojson-vt": "^4.0.2", - "gl-matrix": "^3.4.3", - "global-prefix": "^4.0.0", - "kdbush": "^4.0.2", - "murmurhash-js": "^1.0.0", - "pbf": "^3.3.0", - "potpack": "^2.0.0", - "quickselect": "^3.0.0", - "supercluster": "^8.0.1", - "tinyqueue": "^3.0.0", - "vt-pbf": "^3.1.3" - }, - "engines": { - "node": ">=16.14.0", - "npm": ">=8.1.0" - }, - "funding": { - "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" - } - }, - "node_modules/maplibre-gl/node_modules/@mapbox/tiny-sdf": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz", - "integrity": "sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA==" - }, - "node_modules/maplibre-gl/node_modules/@mapbox/unitbezier": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", - "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==" - }, - "node_modules/maplibre-gl/node_modules/earcut": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", - "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==" - }, - "node_modules/maplibre-gl/node_modules/geojson-vt": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", - "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==" - }, - "node_modules/maplibre-gl/node_modules/potpack": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", - "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==" - }, - "node_modules/maplibre-gl/node_modules/quickselect": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", - "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==" - }, - "node_modules/maplibre-gl/node_modules/supercluster": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", - "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", - "dependencies": { - "kdbush": "^4.0.2" - } - }, - "node_modules/maplibre-gl/node_modules/tinyqueue": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", - "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==" - }, - "node_modules/math-log2": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/math-log2/-/math-log2-1.0.1.tgz", - "integrity": "sha512-9W0yGtkaMAkf74XGYVy4Dqw3YUMnTNB2eeiw9aQbUl4A3KmuCEHTt2DgAB07ENzOYAjsYSAYufkAq0Zd+jU7zA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mouse-change": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/mouse-change/-/mouse-change-1.4.0.tgz", - "integrity": "sha512-vpN0s+zLL2ykyyUDh+fayu9Xkor5v/zRD9jhSqjRS1cJTGS0+oakVZzNm5n19JvvEj0you+MXlYTpNxUDQUjkQ==", - "dependencies": { - "mouse-event": "^1.0.0" - } - }, - "node_modules/mouse-event": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/mouse-event/-/mouse-event-1.0.5.tgz", - "integrity": "sha512-ItUxtL2IkeSKSp9cyaX2JLUuKk2uMoxBg4bbOWVd29+CskYJR9BGsUqtXenNzKbnDshvupjUewDIYVrOB6NmGw==" - }, - "node_modules/mouse-event-offset": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mouse-event-offset/-/mouse-event-offset-3.0.2.tgz", - "integrity": "sha512-s9sqOs5B1Ykox3Xo8b3Ss2IQju4UwlW6LSR+Q5FXWpprJ5fzMLefIIItr3PH8RwzfGy6gxs/4GAmiNuZScE25w==" - }, - "node_modules/mouse-wheel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mouse-wheel/-/mouse-wheel-1.2.0.tgz", - "integrity": "sha512-+OfYBiUOCTWcTECES49neZwL5AoGkXE+lFjIvzwNCnYRlso+EnfvovcBxGoyQ0yQt806eSPjS675K0EwWknXmw==", - "dependencies": { - "right-now": "^1.0.0", - "signum": "^1.0.0", - "to-px": "^1.0.1" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/murmurhash-js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", - "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/native-promise-only": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz", - "integrity": "sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/needle": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", - "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", - "dependencies": { - "debug": "^3.2.6", - "iconv-lite": "^0.4.4", - "sax": "^1.2.4" - }, - "bin": { - "needle": "bin/needle" - }, - "engines": { - "node": ">= 4.4.x" - } - }, - "node_modules/needle/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" - }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-svg-path": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-0.1.0.tgz", - "integrity": "sha512-1/kmYej2iedi5+ROxkRESL/pI02pkg0OBnaR4hJkSIX6+ORzepwbuUXfrdZaPjysTsJInj0Rj5NuX027+dMBvA==" - }, - "node_modules/number-is-integer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/number-is-integer/-/number-is-integer-1.0.1.tgz", - "integrity": "sha512-Dq3iuiFBkrbmuQjGFFF3zckXNCQoSD37/SdSbgcBailUx6knDvDwb5CympBgcoWHy36sfS12u74MHYkXyHq6bg==", - "dependencies": { - "is-finite": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parenthesis": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/parenthesis/-/parenthesis-3.1.8.tgz", - "integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==" - }, - "node_modules/parse-rect": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/parse-rect/-/parse-rect-1.2.0.tgz", - "integrity": "sha512-4QZ6KYbnE6RTwg9E0HpLchUM9EZt6DnDxajFZZDSV4p/12ZJEvPO702DZpGvRYEPo00yKDys7jASi+/w7aO8LA==", - "dependencies": { - "pick-by-alias": "^1.2.0" - } - }, - "node_modules/parse-svg-path": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", - "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==" - }, - "node_modules/parse-unit": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parse-unit/-/parse-unit-1.0.1.tgz", - "integrity": "sha512-hrqldJHokR3Qj88EIlV/kAyAi/G5R2+R56TBANxNMy0uPlYcttx0jnMW6Yx5KsKPSbC3KddM/7qQm3+0wEXKxg==" - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, - "node_modules/pbf": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", - "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", - "dependencies": { - "ieee754": "^1.1.12", - "resolve-protobuf-schema": "^2.1.0" - }, - "bin": { - "pbf": "bin/pbf" - } - }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" - }, - "node_modules/pick-by-alias": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pick-by-alias/-/pick-by-alias-1.2.0.tgz", - "integrity": "sha512-ESj2+eBxhGrcA1azgHs7lARG5+5iLakc/6nlfbpjcLl00HuuUOIuORhYXN4D1HfvMSKuVtFQjAlnwi1JHEeDIw==" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/plotly.js": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/plotly.js/-/plotly.js-3.0.3.tgz", - "integrity": "sha512-prm0irr/60HwldgvZUTaY+emK+PZ1XcOOLAKAYgXQNlyIoJ3sd7xRwB/aCtZcwCDbbcvbyX8pIyLkq0gcYFZng==", - "dependencies": { - "@plotly/d3": "3.8.2", - "@plotly/d3-sankey": "0.7.2", - "@plotly/d3-sankey-circular": "0.33.1", - "@plotly/mapbox-gl": "1.13.4", - "@plotly/regl": "^2.1.2", - "@turf/area": "^7.1.0", - "@turf/bbox": "^7.1.0", - "@turf/centroid": "^7.1.0", - "base64-arraybuffer": "^1.0.2", - "canvas-fit": "^1.5.0", - "color-alpha": "1.0.4", - "color-normalize": "1.5.0", - "color-parse": "2.0.0", - "color-rgba": "3.0.0", - "country-regex": "^1.1.0", - "d3-force": "^1.2.1", - "d3-format": "^1.4.5", - "d3-geo": "^1.12.1", - "d3-geo-projection": "^2.9.0", - "d3-hierarchy": "^1.1.9", - "d3-interpolate": "^3.0.1", - "d3-time": "^1.1.0", - "d3-time-format": "^2.2.3", - "fast-isnumeric": "^1.1.4", - "gl-mat4": "^1.2.0", - "gl-text": "^1.4.0", - "has-hover": "^1.0.1", - "has-passive-events": "^1.0.0", - "is-mobile": "^4.0.0", - "maplibre-gl": "^4.7.1", - "mouse-change": "^1.4.0", - "mouse-event-offset": "^3.0.2", - "mouse-wheel": "^1.2.0", - "native-promise-only": "^0.8.1", - "parse-svg-path": "^0.1.2", - "point-in-polygon": "^1.1.0", - "polybooljs": "^1.2.2", - "probe-image-size": "^7.2.3", - "regl-error2d": "^2.0.12", - "regl-line2d": "^3.1.3", - "regl-scatter2d": "^3.3.1", - "regl-splom": "^1.0.14", - "strongly-connected-components": "^1.0.1", - "superscript-text": "^1.0.0", - "svg-path-sdf": "^1.1.3", - "tinycolor2": "^1.4.2", - "to-px": "1.0.1", - "topojson-client": "^3.1.0", - "webgl-context": "^2.2.0", - "world-calendars": "^1.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/point-in-polygon": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", - "integrity": "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==" - }, - "node_modules/polybooljs": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/polybooljs/-/polybooljs-1.2.2.tgz", - "integrity": "sha512-ziHW/02J0XuNuUtmidBc6GXE8YohYydp3DWPWXYsd7O721TjcmN+k6ezjdwkDqep+gnWnFY+yqZHvzElra2oCg==" - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/potpack": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", - "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/probe-image-size": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.2.3.tgz", - "integrity": "sha512-HubhG4Rb2UH8YtV4ba0Vp5bQ7L78RTONYu/ujmCu5nBI8wGv24s4E9xSKBi0N1MowRpxk76pFCpJtW0KPzOK0w==", - "dependencies": { - "lodash.merge": "^4.6.2", - "needle": "^2.5.2", - "stream-parser": "~0.3.1" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/protocol-buffers-schema": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", - "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==" - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/quickselect": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", - "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==" - }, - "node_modules/raf": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", - "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", - "dependencies": { - "performance-now": "^2.1.0" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, - "node_modules/react-plotly.js": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/react-plotly.js/-/react-plotly.js-2.6.0.tgz", - "integrity": "sha512-g93xcyhAVCSt9kV1svqG1clAEdL6k3U+jjuSzfTV7owaSU9Go6Ph8bl25J+jKfKvIGAEYpe4qj++WHJuc9IaeA==", - "dependencies": { - "prop-types": "^15.8.1" - }, - "peerDependencies": { - "plotly.js": ">1.34.0", - "react": ">0.13.0" - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readable-stream/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" - }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "node_modules/regl": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/regl/-/regl-2.1.1.tgz", - "integrity": "sha512-+IOGrxl3FZ8ZM9ixCWQZzFRiRn7Rzn9bu3iFHwg/yz4tlOUQgbO4PHLgG+1ZT60zcIV8tief6Qrmyl8qcoJP0g==" - }, - "node_modules/regl-error2d": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/regl-error2d/-/regl-error2d-2.0.12.tgz", - "integrity": "sha512-r7BUprZoPO9AbyqM5qlJesrSRkl+hZnVKWKsVp7YhOl/3RIpi4UDGASGJY0puQ96u5fBYw/OlqV24IGcgJ0McA==", - "dependencies": { - "array-bounds": "^1.0.1", - "color-normalize": "^1.5.0", - "flatten-vertex-data": "^1.0.2", - "object-assign": "^4.1.1", - "pick-by-alias": "^1.2.0", - "to-float32": "^1.1.0", - "update-diff": "^1.1.0" - } - }, - "node_modules/regl-line2d": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/regl-line2d/-/regl-line2d-3.1.3.tgz", - "integrity": "sha512-fkgzW+tTn4QUQLpFKsUIE0sgWdCmXAM3ctXcCgoGBZTSX5FE2A0M7aynz7nrZT5baaftLrk9te54B+MEq4QcSA==", - "dependencies": { - "array-bounds": "^1.0.1", - "array-find-index": "^1.0.2", - "array-normalize": "^1.1.4", - "color-normalize": "^1.5.0", - "earcut": "^2.1.5", - "es6-weak-map": "^2.0.3", - "flatten-vertex-data": "^1.0.2", - "object-assign": "^4.1.1", - "parse-rect": "^1.2.0", - "pick-by-alias": "^1.2.0", - "to-float32": "^1.1.0" - } - }, - "node_modules/regl-scatter2d": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/regl-scatter2d/-/regl-scatter2d-3.3.1.tgz", - "integrity": "sha512-seOmMIVwaCwemSYz/y4WE0dbSO9svNFSqtTh5RE57I7PjGo3tcUYKtH0MTSoshcAsreoqN8HoCtnn8wfHXXfKQ==", - "dependencies": { - "@plotly/point-cluster": "^3.1.9", - "array-range": "^1.0.1", - "array-rearrange": "^2.2.2", - "clamp": "^1.0.1", - "color-id": "^1.1.0", - "color-normalize": "^1.5.0", - "color-rgba": "^2.1.1", - "flatten-vertex-data": "^1.0.2", - "glslify": "^7.0.0", - "is-iexplorer": "^1.0.0", - "object-assign": "^4.1.1", - "parse-rect": "^1.2.0", - "pick-by-alias": "^1.2.0", - "to-float32": "^1.1.0", - "update-diff": "^1.1.0" - } - }, - "node_modules/regl-scatter2d/node_modules/color-parse": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", - "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", - "dependencies": { - "color-name": "^1.0.0" - } - }, - "node_modules/regl-scatter2d/node_modules/color-rgba": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/color-rgba/-/color-rgba-2.4.0.tgz", - "integrity": "sha512-Nti4qbzr/z2LbUWySr7H9dk3Rl7gZt7ihHAxlgT4Ho90EXWkjtkL1avTleu9yeGuqrt/chxTB6GKK8nZZ6V0+Q==", - "dependencies": { - "color-parse": "^1.4.2", - "color-space": "^2.0.0" - } - }, - "node_modules/regl-splom": { - "version": "1.0.14", - "resolved": "https://registry.npmjs.org/regl-splom/-/regl-splom-1.0.14.tgz", - "integrity": "sha512-OiLqjmPRYbd7kDlHC6/zDf6L8lxgDC65BhC8JirhP4ykrK4x22ZyS+BnY8EUinXKDeMgmpRwCvUmk7BK4Nweuw==", - "dependencies": { - "array-bounds": "^1.0.1", - "array-range": "^1.0.1", - "color-alpha": "^1.0.4", - "flatten-vertex-data": "^1.0.2", - "parse-rect": "^1.2.0", - "pick-by-alias": "^1.2.0", - "raf": "^3.4.1", - "regl-scatter2d": "^3.2.3" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve-protobuf-schema": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", - "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", - "dependencies": { - "protocol-buffers-schema": "^3.3.1" - } - }, - "node_modules/right-now": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/right-now/-/right-now-1.0.0.tgz", - "integrity": "sha512-DA8+YS+sMIVpbsuKgy+Z67L9Lxb1p05mNxRpDPNksPDEFir4vmBlUtuN9jkTGn9YMMdlBuK7XQgFiz6ws+yhSg==" - }, - "node_modules/rollup": { - "version": "4.44.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.0.tgz", - "integrity": "sha512-qHcdEzLCiktQIfwBq420pn2dP+30uzqYxv9ETm91wdt2R9AFcWfjNAmje4NWlnCIQ5RMTzVf0ZyisOKqHR6RwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.44.0", - "@rollup/rollup-android-arm64": "4.44.0", - "@rollup/rollup-darwin-arm64": "4.44.0", - "@rollup/rollup-darwin-x64": "4.44.0", - "@rollup/rollup-freebsd-arm64": "4.44.0", - "@rollup/rollup-freebsd-x64": "4.44.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.44.0", - "@rollup/rollup-linux-arm-musleabihf": "4.44.0", - "@rollup/rollup-linux-arm64-gnu": "4.44.0", - "@rollup/rollup-linux-arm64-musl": "4.44.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.44.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.44.0", - "@rollup/rollup-linux-riscv64-gnu": "4.44.0", - "@rollup/rollup-linux-riscv64-musl": "4.44.0", - "@rollup/rollup-linux-s390x-gnu": "4.44.0", - "@rollup/rollup-linux-x64-gnu": "4.44.0", - "@rollup/rollup-linux-x64-musl": "4.44.0", - "@rollup/rollup-win32-arm64-msvc": "4.44.0", - "@rollup/rollup-win32-ia32-msvc": "4.44.0", - "@rollup/rollup-win32-x64-msvc": "4.44.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/rw": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/sax": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==" - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/shallow-copy": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/shallow-copy/-/shallow-copy-0.0.1.tgz", - "integrity": "sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw==" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signum": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/signum/-/signum-1.0.0.tgz", - "integrity": "sha512-yodFGwcyt59XRh7w5W3jPcIQb3Bwi21suEfT7MAWnBX3iCdklJpgDgvGT9o04UonglZN5SNMfJFkHIR/jO8GHw==" - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stack-trace": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.9.tgz", - "integrity": "sha512-vjUc6sfgtgY0dxCdnc40mK6Oftjo9+2K8H/NG81TMhgL392FtiPA9tn9RLyTxXmTLPJPjF3VyzFp6bsWFLisMQ==", - "engines": { - "node": "*" - } - }, - "node_modules/static-eval": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", - "integrity": "sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA==", - "dependencies": { - "escodegen": "^2.1.0" - } - }, - "node_modules/stream-parser": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz", - "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", - "dependencies": { - "debug": "2" - } - }, - "node_modules/stream-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/stream-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/stream-shift": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", - "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==" - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "node_modules/string-split-by": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string-split-by/-/string-split-by-1.0.0.tgz", - "integrity": "sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==", - "dependencies": { - "parenthesis": "^3.1.5" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strongly-connected-components": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/strongly-connected-components/-/strongly-connected-components-1.0.1.tgz", - "integrity": "sha512-i0TFx4wPcO0FwX+4RkLJi1MxmcTv90jNZgxMu9XRnMXMeFUY1VJlIoXpZunPUvUUqbCT1pg5PEkFqqpcaElNaA==" - }, - "node_modules/style-mod": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", - "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==" - }, - "node_modules/supercluster": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", - "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", - "dependencies": { - "kdbush": "^3.0.0" - } - }, - "node_modules/supercluster/node_modules/kdbush": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", - "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==" - }, - "node_modules/superscript-text": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/superscript-text/-/superscript-text-1.0.0.tgz", - "integrity": "sha512-gwu8l5MtRZ6koO0icVTlmN5pm7Dhh1+Xpe9O4x6ObMAsW+3jPbW14d1DsBq1F4wiI+WOFjXF35pslgec/G8yCQ==" - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/svg-arc-to-cubic-bezier": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", - "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==" - }, - "node_modules/svg-path-bounds": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/svg-path-bounds/-/svg-path-bounds-1.0.2.tgz", - "integrity": "sha512-H4/uAgLWrppIC0kHsb2/dWUYSmb4GE5UqH06uqWBcg6LBjX2fu0A8+JrO2/FJPZiSsNOKZAhyFFgsLTdYUvSqQ==", - "dependencies": { - "abs-svg-path": "^0.1.1", - "is-svg-path": "^1.0.1", - "normalize-svg-path": "^1.0.0", - "parse-svg-path": "^0.1.2" - } - }, - "node_modules/svg-path-bounds/node_modules/normalize-svg-path": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", - "integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==", - "dependencies": { - "svg-arc-to-cubic-bezier": "^3.0.0" - } - }, - "node_modules/svg-path-sdf": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/svg-path-sdf/-/svg-path-sdf-1.1.3.tgz", - "integrity": "sha512-vJJjVq/R5lSr2KLfVXVAStktfcfa1pNFjFOgyJnzZFXlO/fDZ5DmM8FpnSKKzLPfEYTVeXuVBTHF296TpxuJVg==", - "dependencies": { - "bitmap-sdf": "^1.0.0", - "draw-svg-path": "^1.0.0", - "is-svg-path": "^1.0.1", - "parse-svg-path": "^0.1.2", - "svg-path-bounds": "^1.0.1" - } - }, - "node_modules/through2": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", - "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", - "dependencies": { - "readable-stream": "~2.3.6", - "xtend": "~4.0.1" - } - }, - "node_modules/tinycolor2": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", - "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==" - }, - "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyqueue": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", - "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==" - }, - "node_modules/to-float32": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/to-float32/-/to-float32-1.1.0.tgz", - "integrity": "sha512-keDnAusn/vc+R3iEiSDw8TOF7gPiTLdK1ArvWtYbJQiVfmRg6i/CAvbKq3uIS0vWroAC7ZecN3DjQKw3aSklUg==" - }, - "node_modules/to-px": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/to-px/-/to-px-1.0.1.tgz", - "integrity": "sha512-2y3LjBeIZYL19e5gczp14/uRWFDtDUErJPVN3VU9a7SJO+RjGRtYR47aMN2bZgGlxvW4ZcEz2ddUPVHXcMfuXw==", - "dependencies": { - "parse-unit": "^1.0.1" - } - }, - "node_modules/topojson-client": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", - "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", - "dependencies": { - "commander": "2" - }, - "bin": { - "topo2geo": "bin/topo2geo", - "topomerge": "bin/topomerge", - "topoquantize": "bin/topoquantize" - } - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/type": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", - "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" - }, - "node_modules/typedarray-pool": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/typedarray-pool/-/typedarray-pool-1.2.0.tgz", - "integrity": "sha512-YTSQbzX43yvtpfRtIDAYygoYtgT+Rpjuxy9iOpczrjpXLgGoyG7aS5USJXV2d3nn8uHTeb9rXDvzS27zUg5KYQ==", - "dependencies": { - "bit-twiddle": "^1.0.0", - "dup": "^1.0.0" - } - }, - "node_modules/unquote": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", - "integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==" - }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/update-diff": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/update-diff/-/update-diff-1.1.0.tgz", - "integrity": "sha512-rCiBPiHxZwT4+sBhEbChzpO5hYHjm91kScWgdHf4Qeafs6Ba7MBl+d9GlGv72bcTZQO0sLmtQS1pHSWoCLtN/A==" - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vt-pbf": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", - "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", - "dependencies": { - "@mapbox/point-geometry": "0.1.0", - "@mapbox/vector-tile": "^1.3.1", - "pbf": "^3.2.1" - } - }, - "node_modules/w3c-keyname": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", - "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" - }, - "node_modules/weak-map": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/weak-map/-/weak-map-1.0.8.tgz", - "integrity": "sha512-lNR9aAefbGPpHO7AEnY0hCFjz1eTkWCXYvkTRrTHs9qv8zJp+SkVYpzfLIFXQQiG3tVvbNFQgVg2bQS8YGgxyw==" - }, - "node_modules/webgl-context": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/webgl-context/-/webgl-context-2.2.0.tgz", - "integrity": "sha512-q/fGIivtqTT7PEoF07axFIlHNk/XCPaYpq64btnepopSWvKNFkoORlQYgqDigBIuGA1ExnFd/GnSUnBNEPQY7Q==", - "dependencies": { - "get-canvas-context": "^1.0.1" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/world-calendars": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/world-calendars/-/world-calendars-1.0.4.tgz", - "integrity": "sha512-VGRnLJS+xJmGDPodgJRnGIDwGu0s+Cr9V2HB3EzlDZ5n0qb8h5SJtGUEkjrphZYAglEiXZ6kiXdmk0H/h/uu/w==", - "dependencies": { - "object-assign": "^4.1.0" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 33acbed9..00000000 --- a/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "pathview", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "lint": "eslint .", - "preview": "vite preview", - "start:backend": "python src/backend.py", - "start:both": "concurrently \"npm run dev\" \"npm run start:backend\"" - }, - "dependencies": { - "@codemirror/lang-python": "^6.2.1", - "@uiw/codemirror-theme-vscode": "^4.24.2", - "@uiw/react-codemirror": "^4.24.2", - "@xyflow/react": "^12.8.1", - "lz-string": "^1.5.0", - "plotly.js": "^3.0.3", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-plotly.js": "^2.6.0" - }, - "devDependencies": { - "@eslint/js": "^9.25.0", - "@types/react": "^18.2.0", - "@types/react-dom": "^18.2.0", - "@vitejs/plugin-react": "^4.4.1", - "concurrently": "^9.2.0", - "eslint": "^9.25.0", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.19", - "globals": "^16.0.0", - "vite": "^6.3.5" - } -} diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb1..00000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 77b2b0a9..00000000 --- a/pyproject.toml +++ /dev/null @@ -1,55 +0,0 @@ -[build-system] -requires = ["setuptools>=61.0", "wheel", "setuptools-scm[toml] >= 7.0.5"] -build-backend = "setuptools.build_meta" - -[project] -name = "pathview" -dynamic = ["version"] -description = "A Graphical User Interface for System Simulation" -readme = "README.md" -license = {file = "LICENSE"} -authors = [ - {name = "Your Name", email = "your.email@example.com"} -] -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Science/Research", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", -] -requires-python = ">=3.8" -dependencies = [ - "pathsim>=0.8.2,<0.14.0", - "pathsim-chem<0.1.1", - "matplotlib>=3.7.0", - "numpy>=1.24.0", - "plotly>=6.0", - "jinja2", -] - -[project.optional-dependencies] -dev = [ - "pytest" -] - - -[project.urls] -Homepage = "https://github.com/festim-dev/pathview" -Documentation = "https://pathview.readthedocs.io/" -Repository = "https://github.com/festim-dev/pathview.git" -Issues = "https://github.com/festim-dev/pathview/issues" - -[tool.setuptools] -packages = ["pathview"] -package-dir = {"pathview" = "src/python"} - -[tool.setuptools.package-data] -pathview = ["templates/*"] - -[tool.setuptools_scm] -write_to = "src/python/_version.py" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e9a902c9..00000000 --- a/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -# Web application dependencies (not needed for core package) -Flask>=3.1.1 -flask-cors>=6.0.1 - -# Development dependencies -sphinx>=4.0.0 -docutils>=0.17.0 diff --git a/src/App.jsx b/src/App.jsx deleted file mode 100644 index ee8189b4..00000000 --- a/src/App.jsx +++ /dev/null @@ -1,1258 +0,0 @@ -// * Imports * -import { useState, useCallback, useEffect, useRef, version } from 'react'; -import { - ReactFlowProvider, - useReactFlow, - useNodesState, - useEdgesState, -} from '@xyflow/react'; -import '@xyflow/react/dist/style.css'; -import './styles/App.css'; -import { getApiEndpoint } from './config.js'; -import { - getGraphDataFromURL, - generateShareableURL, - updateURLWithGraphData, - clearGraphDataFromURL -} from './utils/urlSharing.js'; -import Sidebar from './components/Sidebar'; -import NodeSidebar from './components/NodeSidebar'; -import { DnDProvider, useDnD } from './components/DnDContext.jsx'; -import EventsTab from './components/EventsTab.jsx'; -import GlobalVariablesTab from './components/GlobalVariablesTab.jsx'; -import { makeEdge } from './components/CustomEdge'; -import { nodeTypes, nodeDynamicHandles } from './nodeConfig.js'; -import LogDock from './components/LogDock.jsx'; -import TopBar from './components/TopBar.jsx'; -import GraphView from './components/GraphView.jsx'; -import EdgeDetails from './components/EdgeDetails.jsx'; -import SolverPanel from './components/SolverPanel.jsx'; -import ResultsPanel from './components/ResultsPanel.jsx'; -import ShareModal from './components/ShareModal.jsx'; - -// * Declaring variables * - -// Default solver parameters -const DEFAULT_SOLVER_PARAMS = { - dt: '0.01', - dt_min: '1e-16', - dt_max: '', - Solver: 'SSPRK22', - tolerance_fpi: '1e-10', - iterations_max: '200', - log: 'true', - simulation_duration: '10.0', - extra_params: '{}' -}; - -// Defining initial nodes and edges. In the data section, we have label, but also parameters specific to the node. -const initialNodes = []; -const initialEdges = []; - -// For Drag and Drop functionality -const DnDFlow = () => { - // State management for nodes and edges: adds the initial nodes and edges to the graph and handles node selection - const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - const [selectedNode, setSelectedNode] = useState(null); - const [sidebarVisible, setSidebarVisible] = useState(true); - const [activeTab, setActiveTab] = useState('graph'); - const [simulationResults, setSimulationResults] = useState(null); - const [selectedEdge, setSelectedEdge] = useState(null); - const [nodeCounter, setNodeCounter] = useState(1); - const [menu, setMenu] = useState(null); - const [copiedNode, setCopiedNode] = useState(null); - const [copyFeedback, setCopyFeedback] = useState(''); - const ref = useRef(null); - const [csvData, setCsvData] = useState(null); - const [htmlData, setHtmlData] = useState(null); - const reactFlowWrapper = useRef(null); - // const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); - // const [edges, setEdges, onEdgesChange] = useEdgesState([]); - const { screenToFlowPosition } = useReactFlow(); - const [type] = useDnD(); - - // for the log dock - const [dockOpen, setDockOpen] = useState(false); - const onToggleLogs = useCallback(() => setDockOpen(o => !o), []); - const [logLines, setLogLines] = useState([]); - const sseRef = useRef(null); - const append = (line) => setLogLines((prev) => [...prev, line]); - - // for version information - const [versionInfo, setVersionInfo] = useState(null); - - // const onConnect = useCallback((params) => setEdges((eds) => addEdge(params, eds)), []); - - const onDragOver = useCallback((event) => { - event.preventDefault(); - event.dataTransfer.dropEffect = 'move'; - }, []); - - const onDragStart = (event, nodeType) => { - setType(nodeType); - event.dataTransfer.setData('text/plain', nodeType); - event.dataTransfer.effectAllowed = 'move'; - }; - - - // Solver parameters state - const [solverParams, setSolverParams] = useState(DEFAULT_SOLVER_PARAMS); - - // Global variables state - const [globalVariables, setGlobalVariables] = useState([]); - const [events, setEvents] = useState([]); - - // Python code editor state - const [pythonCode, setPythonCode] = useState("# Define your Python variables and functions here\n# Example:\n# my_variable = 42\n# def my_function(x):\n# return x * 2\n"); - - // State for URL sharing feedback - const [shareUrlFeedback, setShareUrlFeedback] = useState(''); - const [showShareModal, setShowShareModal] = useState(false); - const [shareableURL, setShareableURL] = useState(''); - const [urlMetadata, setUrlMetadata] = useState(null); - - // Load graph data from URL on component mount - useEffect(() => { - const loadGraphFromURL = () => { - const urlGraphData = getGraphDataFromURL(); - if (urlGraphData) { - try { - // Validate that it's a valid graph file - if (!urlGraphData.nodes || !Array.isArray(urlGraphData.nodes)) { - console.warn("Invalid graph data in URL"); - return; - } - - // Load the graph data and ensure nodeColor exists on all nodes - const { - nodes: loadedNodes, - edges: loadedEdges, - nodeCounter: loadedNodeCounter, - solverParams: loadedSolverParams, - globalVariables: loadedGlobalVariables, - events: loadedEvents, - pythonCode: loadedPythonCode - } = urlGraphData; - - // Ensure all loaded nodes have a nodeColor property - const nodesWithColors = (loadedNodes || []).map(node => ({ - ...node, - data: { - ...node.data, - nodeColor: node.data.nodeColor || '#DDE6ED' - } - })); - - setNodes(nodesWithColors); - setEdges(loadedEdges || []); - setSelectedNode(null); - setNodeCounter(loadedNodeCounter ?? loadedNodes.length); - setSolverParams(loadedSolverParams ?? DEFAULT_SOLVER_PARAMS); - setGlobalVariables(loadedGlobalVariables ?? []); - setEvents(loadedEvents ?? []); - setPythonCode(loadedPythonCode ?? "# Define your Python variables and functions here\n# Example:\n# my_variable = 42\n# def my_function(x):\n# return x * 2\n"); - - console.log('Graph loaded from URL successfully'); - } catch (error) { - console.error('Error loading graph from URL:', error); - } - } - }; - - loadGraphFromURL(); - }, []); // Empty dependency array means this runs once on mount - - const [defaultValues, setDefaultValues] = useState({}); - const [isEditingLabel, setIsEditingLabel] = useState(false); - const [tempLabel, setTempLabel] = useState(''); - const [nodeDocumentation, setNodeDocumentation] = useState({}); - const [isDocumentationExpanded, setIsDocumentationExpanded] = useState(false); - const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(true); - - // Function to fetch default values for a node type (with caching) - const fetchDefaultValues = async (nodeType) => { - // Check if we already have cached values for this node type - if (defaultValues[nodeType]) { - return defaultValues[nodeType]; - } - - try { - const response = await fetch(getApiEndpoint(`/default-values/${nodeType}`)); - if (response.ok) { - const defaults = await response.json(); - // Cache the values - setDefaultValues(prev => ({ - ...prev, - [nodeType]: defaults - })); - return defaults; - } else { - console.error('Failed to fetch default values'); - return {}; - } - } catch (error) { - console.error('Error fetching default values:', error); - return {}; - } - }; - - // Function to fetch version information - const fetchVersionInfo = async () => { - try { - const response = await fetch(getApiEndpoint('/version')); - if (response.ok) { - const versionData = await response.json(); - setVersionInfo(versionData); - return versionData; - } else { - console.error('Failed to fetch version information'); - return null; - } - } catch (error) { - console.error('Error fetching version information:', error); - return null; - } - }; - - // Function to fetch documentation for a node type - const fetchNodeDocumentation = async (nodeType) => { - try { - const response = await fetch(getApiEndpoint(`/get-docs/${nodeType}`)); - if (response.ok) { - const result = await response.json(); - return { - html: result.html || result.docstring || 'No documentation available for this node type.', - text: result.docstring || 'No documentation available for this node type.' - }; - } else { - console.error('Failed to fetch documentation'); - return { - html: '

Failed to load documentation.

', - text: 'Failed to load documentation.' - }; - } - } catch (error) { - console.error('Error fetching documentation:', error); - return { - html: '

Error loading documentation.

', - text: 'Error loading documentation.' - }; - } - }; - - // Function to preload all documentation at startup - const preloadAllDocumentation = async () => { - const availableTypes = Object.keys(nodeTypes); - - try { - // Convert types array to a string (or could be sent as JSON array) - const response = await fetch(getApiEndpoint(`/get-all-docs`)); - - if (response.ok) { - const allDocs = await response.json(); - setNodeDocumentation(allDocs); - } else { - console.error('Failed to preload documentation'); - // Fallback: initialize empty documentation for all types - const documentationCache = {}; - availableTypes.forEach(nodeType => { - documentationCache[nodeType] = { - html: '

No documentation available for this node type.

', - text: 'No documentation available for this node type.' - }; - }); - setNodeDocumentation(documentationCache); - } - } catch (error) { - console.error('Error preloading documentation:', error); - // Fallback: initialize empty documentation for all types - const documentationCache = {}; - availableTypes.forEach(nodeType => { - documentationCache[nodeType] = { - html: '

Error loading documentation.

', - text: 'Error loading documentation.' - }; - }); - setNodeDocumentation(documentationCache); - } - }; - - // Function to preload all default values at startup - const preloadDefaultValues = async () => { - const availableTypes = Object.keys(nodeTypes); - - try { - const response = await fetch(getApiEndpoint(`/default-values-all`)); - - if (response.ok) { - const allDefaults = await response.json(); - setDefaultValues(allDefaults); - } else { - console.error('Failed to preload default values'); - // Fallback: initialize empty defaults for all types - const defaultValuesCache = {}; - availableTypes.forEach(nodeType => { - defaultValuesCache[nodeType] = {}; - }); - setDefaultValues(defaultValuesCache); - } - } catch (error) { - console.error('Error preloading default values:', error); - // Fallback: initialize empty defaults for all types - const defaultValuesCache = {}; - availableTypes.forEach(nodeType => { - defaultValuesCache[nodeType] = {}; - }); - setDefaultValues(defaultValuesCache); - } - }; - - // Preload all default values and documentation when component mounts - useEffect(() => { - preloadDefaultValues(); - preloadAllDocumentation(); - fetchVersionInfo(); // Fetch version information on component mount - }, []); - - const onDrop = useCallback( - async (event) => { - event.preventDefault(); - - // check if the dropped element is valid - if (!type) { - return; - } - const position = screenToFlowPosition({ - x: event.clientX, - y: event.clientY, - }); - const newNodeId = nodeCounter.toString(); - - // Fetch default values for this node type - let defaults = {}; - - try { - defaults = await fetchDefaultValues(type); - } catch (error) { - console.warn(`Failed to fetch default values for ${type}, using empty defaults:`, error); - defaults = {}; - } - - // Create node data with label and initialize all expected fields as empty strings - let nodeData = { - label: `${type} ${newNodeId}`, - nodeColor: '#DDE6ED' // Default node color - }; - - // if node in nodeDynamicHandles, ensure add outputCount and inputCount to data - if (nodeDynamicHandles.includes(type)) { - nodeData.inputCount = 1; - nodeData.outputCount = 1; - } - - // Initialize all expected parameters as empty strings - Object.keys(defaults).forEach(key => { - nodeData[key] = ''; - }); - - const newNode = { - id: newNodeId, - type: type, - position: position, - data: nodeData, - }; - - setNodes((nds) => [...nds, newNode]); - setNodeCounter((count) => count + 1); - }, - [screenToFlowPosition, type, nodeCounter, fetchDefaultValues, setDefaultValues, setNodes, setNodeCounter], - ); - - // Function to save a graph to computer with "Save As" dialog - const saveGraph = async () => { - const graphData = { - version: versionInfo ? Object.fromEntries(Object.entries(versionInfo).filter(([key]) => key !== 'status')) : 'unknown', - nodes, - edges, - nodeCounter, - solverParams, - globalVariables, - events, - pythonCode - }; - - // Check if File System Access API is supported - if ('showSaveFilePicker' in window) { - try { - // Modern approach: Use File System Access API for proper "Save As" dialog - const fileHandle = await window.showSaveFilePicker({ - suggestedName: 'pathview_graph.json', - types: [{ - description: 'JSON files', - accept: { - 'application/json': ['.json'] - } - }] - }); - - // Create a writable stream and write the data - const writable = await fileHandle.createWritable(); - await writable.write(JSON.stringify(graphData, null, 2)); - await writable.close(); - - } catch (error) { - if (error.name !== 'AbortError') { - console.error('Error saving file:', error); - alert('Failed to save file.'); - } - // User cancelled the dialog - no error message needed - } - } else { - // Fallback for browsers (like Firefox and Safari) that don't support File System Access API - const blob = new Blob([JSON.stringify(graphData, null, 2)], { - type: 'application/json' - }); - - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'graph.json'; - - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } - }; - - // Function to load a saved graph from computer - const loadGraph = async () => { - // Check if File System Access API is supported - if ('showOpenFilePicker' in window) { - try { - // Modern approach: Use File System Access API - const [fileHandle] = await window.showOpenFilePicker({ - types: [{ - description: 'JSON files', - accept: { - 'application/json': ['.json'] - } - }], - multiple: false - }); - - const file = await fileHandle.getFile(); - const text = await file.text(); - - try { - const graphData = JSON.parse(text); - - // Validate that it's a valid graph file - if (!graphData.nodes || !Array.isArray(graphData.nodes)) { - alert("Invalid file format. Please select a valid graph JSON file."); - return; - } - - // Load the graph data and ensure nodeColor exists on all nodes - const { - nodes: loadedNodes, - edges: loadedEdges, - nodeCounter: loadedNodeCounter, - solverParams: loadedSolverParams, - globalVariables: loadedGlobalVariables, - events: loadedEvents, - pythonCode: loadedPythonCode - } = graphData; - - // Ensure all loaded nodes have a nodeColor property - const nodesWithColors = (loadedNodes || []).map(node => ({ - ...node, - data: { - ...node.data, - nodeColor: node.data.nodeColor || '#DDE6ED' - } - })); - - setNodes(nodesWithColors); - setEdges(loadedEdges || []); - setSelectedNode(null); - setNodeCounter(loadedNodeCounter ?? loadedNodes.length); - setSolverParams(loadedSolverParams ?? DEFAULT_SOLVER_PARAMS); - setGlobalVariables(loadedGlobalVariables ?? []); - setEvents(loadedEvents ?? []); - setPythonCode(loadedPythonCode ?? "# Define your Python variables and functions here\n# Example:\n# my_variable = 42\n# def my_function(x):\n# return x * 2\n"); - } catch (error) { - console.error('Error parsing file:', error); - alert('Error reading file. Please make sure it\'s a valid JSON file.'); - } - } catch (error) { - if (error.name !== 'AbortError') { - console.error('Error opening file:', error); - alert('Failed to open file.'); - } - // User cancelled the dialog - no error message needed - } - } else { - // Fallback for browsers that don't support File System Access API - const fileInput = document.createElement('input'); - fileInput.type = 'file'; - fileInput.accept = '.json'; - fileInput.style.display = 'none'; - - fileInput.onchange = (event) => { - const file = event.target.files[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = (e) => { - try { - const graphData = JSON.parse(e.target.result); - - if (!graphData.nodes || !Array.isArray(graphData.nodes)) { - alert("Invalid file format. Please select a valid graph JSON file."); - return; - } - - const { - nodes: loadedNodes, - edges: loadedEdges, - nodeCounter: loadedNodeCounter, - solverParams: loadedSolverParams, - globalVariables: loadedGlobalVariables, - events: loadedEvents, - pythonCode: loadedPythonCode - } = graphData; - - // Ensure all loaded nodes have a nodeColor property - const nodesWithColors = (loadedNodes || []).map(node => ({ - ...node, - data: { - ...node.data, - nodeColor: node.data.nodeColor || '#DDE6ED' - } - })); - - setNodes(nodesWithColors); - setEdges(loadedEdges || []); - setSelectedNode(null); - setNodeCounter(loadedNodeCounter ?? loadedNodes.length); - setSolverParams(loadedSolverParams ?? DEFAULT_SOLVER_PARAMS); - setGlobalVariables(loadedGlobalVariables ?? []); - setEvents(loadedEvents ?? []); - setPythonCode(loadedPythonCode ?? "# Define your Python variables and functions here\n# Example:\n# my_variable = 42\n# def my_function(x):\n# return x * 2\n"); - } catch (error) { - console.error('Error parsing file:', error); - alert('Error reading file. Please make sure it\'s a valid JSON file.'); - } - }; - - reader.readAsText(file); - document.body.removeChild(fileInput); - }; - - document.body.appendChild(fileInput); - fileInput.click(); - } - }; - - // Allows user to clear user inputs and go back to default settings - const resetGraph = () => { - setNodes(initialNodes); - setEdges(initialEdges); - setSelectedNode(null); - setNodeCounter(0); - setSolverParams(DEFAULT_SOLVER_PARAMS); - setGlobalVariables([]); - // Clear URL when resetting graph - clearGraphDataFromURL(); - }; - - // Share current graph via URL - const shareGraphURL = async () => { - const graphData = { - version: versionInfo ? Object.fromEntries(Object.entries(versionInfo).filter(([key]) => key !== 'status')) : 'unknown', - nodes, - edges, - nodeCounter, - solverParams, - globalVariables, - events, - pythonCode - }; - - try { - const urlResult = generateShareableURL(graphData); - if (urlResult) { - setShareableURL(urlResult.url); - setUrlMetadata({ - length: urlResult.length, - isSafe: urlResult.isSafe, - maxLength: urlResult.maxLength - }); - setShowShareModal(true); - // Only update browser URL if it's safe length - if (urlResult.isSafe) { - updateURLWithGraphData(graphData, true); - } - } else { - setShareUrlFeedback('Error generating share URL'); - setTimeout(() => setShareUrlFeedback(''), 3000); - } - } catch (error) { - console.error('Error sharing graph URL:', error); - setShareUrlFeedback('Error generating share URL'); - setTimeout(() => setShareUrlFeedback(''), 3000); - } - }; - - const downloadCsv = async () => { - if (!csvData) return; - - const { time, series } = csvData; - const labels = Object.keys(series); - const header = ["time", ...labels].join(","); - const rows = [header]; - - time.forEach((t, i) => { - const row = [t]; - for (const label of labels) { - const val = series[label][i] ?? "NaN"; - row.push(val); - } - rows.push(row.join(",")); - }); - - const csvString = rows.join("\n"); - const blob = new Blob([csvString], { type: "text/csv" }); - const filename = `simulation_${new Date().toISOString().replace(/[:.]/g, "-")}.csv`; - - try { - if ("showSaveFilePicker" in window) { - const options = { - suggestedName: filename, - types: [{ - description: "CSV File", - accept: { "text/csv": [".csv"] } - }] - }; - - const handle = await window.showSaveFilePicker(options); - const writable = await handle.createWritable(); - await writable.write(blob); - await writable.close(); - } else { - throw new Error("showSaveFilePicker not supported"); - } - } catch (err) { - console.warn("Falling back to automatic download:", err); - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(a.href); - } - }; - - const downloadHtml = async () => { - const blob = new Blob([htmlData], { type: "text/html" }); - const filename = `simulation_${new Date().toISOString().replace(/[:.]/g, "-")}.html`; - - try { - if ("showSaveFilePicker" in window) { - const options = { - suggestedName: filename, - types: [{ - description: "HTML File", - accept: { "text/html": [".html"] } - }] - }; - - const handle = await window.showSaveFilePicker(options); - const writable = await handle.createWritable(); - await writable.write(blob); - await writable.close(); - } else { - throw new Error("showSaveFilePicker not supported"); - } - } catch (err) { - console.warn("Falling back to automatic download:", err); - const a = document.createElement("a"); - a.href = URL.createObjectURL(blob); - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(a.href); - } - }; - - - - // Allows user to save to python script - const saveToPython = async () => { - try { - const graphData = { - nodes, - edges, - nodeCounter, - solverParams, - globalVariables, - pythonCode, - events - }; - - const response = await fetch(getApiEndpoint('/convert-to-python'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ graph: graphData }), - }); - - const result = await response.json(); - - if (result.success) { - // Check if File System Access API is supported - if ('showSaveFilePicker' in window) { - try { - // Modern approach: Use File System Access API for proper "Save As" dialog - const fileHandle = await window.showSaveFilePicker({ - suggestedName: 'pathsim_script.py', - types: [{ - description: 'Python files', - accept: { - 'text/x-python': ['.py'] - } - }] - }); - - // Create a writable stream and write the Python script - const writable = await fileHandle.createWritable(); - await writable.write(result.script); - await writable.close(); - } catch (error) { - if (error.name !== 'AbortError') { - console.error('Error saving Python file:', error); - alert('Failed to save Python script.'); - } - // User cancelled the dialog - no error message needed - } - } else { - // Fallback for browsers (Firefox, Safari) that don't support File System Access API - const blob = new Blob([result.script], { type: 'text/x-python' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'pathsim_script.py'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } - } else { - alert(`Error generating Python script: ${result.error}`); - } - } catch (error) { - console.error('Error:', error); - alert('Failed to generate Python script. Make sure the backend is running.'); - } - }; - // Function to run pathsim simulation - const runPathsim = async () => { - setDockOpen(true); - setLogLines([]); - - if (sseRef.current) sseRef.current.close(); - const es = new EventSource(getApiEndpoint('/logs/stream')); - sseRef.current = es; - - es.addEventListener('start', () => append('log stream connected…')); - es.onmessage = (evt) => append(evt.data); - es.onerror = () => { append('log stream error'); es.close(); sseRef.current = null; }; - - try { - const graphData = { - nodes, - edges, - solverParams, - globalVariables, - events, - pythonCode - }; - - const response = await fetch(getApiEndpoint('/run-pathsim'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ graph: graphData }), - }); - - // Check if response is ok first - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - // Check if response has content - const responseText = await response.text(); - if (!responseText.trim()) { - throw new Error('Server returned empty response'); - } - - // Try to parse JSON - let result; - try { - result = JSON.parse(responseText); - } catch (jsonError) { - console.error('Failed to parse JSON response:', responseText); - throw new Error(`Invalid JSON response: ${jsonError.message}`); - } - - if (sseRef.current) { sseRef.current.close(); sseRef.current = null; } - - if (result.success) { - // Store results and switch to results tab - setSimulationResults(result.plot); - setCsvData(result.csv_data); - setHtmlData(result.html); - setActiveTab('results'); - } else { - alert(`Error running Pathsim simulation: ${result.error}`); - } - } catch (error) { - console.error('Error details:', { - message: error.message, - stack: error.stack, - response: error.response || 'No response object' - }); - - if (sseRef.current) { - sseRef.current.close(); - sseRef.current = null; - } - - // Provide more specific error messages - let errorMessage = 'Failed to run Pathsim simulation. Make sure the backend is running.'; - - if (error.message.includes('JSON')) { - errorMessage = 'Server response was not valid JSON. This might be due to a server error or network issue.'; - } else if (error.message.includes('HTTP')) { - errorMessage = `Server error: ${error.message}`; - } else if (error.message.includes('empty response')) { - errorMessage = 'Server returned empty response. The simulation might have failed silently.'; - } - - alert(`${errorMessage} : ${error.message}`); - } - }; - - //When user connects two nodes by dragging, creates an edge according to the styles in our makeEdge function - const onConnect = useCallback( - (params) => { - let edgeId = `e${params.source}-${params.target}`; - - // If sourceHandle or targetHandle is specified, append it to the edge ID - if (params.sourceHandle) { - edgeId += `-from_${params.sourceHandle}`; - } - - if (params.targetHandle) { - edgeId += `-to_${params.targetHandle}`; - } - const newEdge = makeEdge({ - id: edgeId, - source: params.source, - target: params.target, - sourceHandle: params.sourceHandle, - targetHandle: params.targetHandle, - }); - - setEdges([...edges, newEdge]); - }, - [edges, setEdges] - ); - // Function that when we click on a node, sets that node as the selected node - const onNodeClick = async (event, node) => { - setSelectedNode(node); - setSelectedEdge(null); // Clear selected edge when selecting a node - // Reset all edge styles when selecting a node - setEdges((eds) => - eds.map((e) => ({ - ...e, - style: { - ...e.style, - strokeWidth: 2, - stroke: '#ECDFCC', - }, - markerEnd: { - ...e.markerEnd, - color: '#ECDFCC', - }, - })) - ); - - // Fetch default values and documentation for this node type - if (node.type && !defaultValues[node.type]) { - const defaults = await fetchDefaultValues(node.type); - setDefaultValues(prev => ({ ...prev, [node.type]: defaults })); - } - - if (node.type && !nodeDocumentation[node.type]) { - const docs = await fetchNodeDocumentation(node.type); - setNodeDocumentation(prev => ({ ...prev, [node.type]: docs })); - } - }; - // Function that when we click on an edge, sets that edge as the selected edge - const onEdgeClick = (event, edge) => { - setSelectedEdge(edge); - setSelectedNode(null); // Clear selected node when selecting an edge - // Update edge styles to highlight the selected edge - setEdges((eds) => - eds.map((e) => ({ - ...e, - style: { - ...e.style, - strokeWidth: e.id === edge.id ? 3 : 2, - stroke: e.id === edge.id ? '#ffd700' : '#ECDFCC', - }, - markerEnd: { - ...e.markerEnd, - color: e.id === edge.id ? '#ffd700' : '#ECDFCC', - }, - })) - ); - }; - // Function to deselect everything when clicking on the background - const onPaneClick = () => { - setSelectedNode(null); - setSelectedEdge(null); - setMenu(null); // Close context menu when clicking on pane - // Reset all edge styles when deselecting - setEdges((eds) => - eds.map((e) => ({ - ...e, - style: { - ...e.style, - strokeWidth: 2, - stroke: '#ECDFCC', - }, - markerEnd: { - ...e.markerEnd, - color: '#ECDFCC', - }, - })) - ); - }; - - // Function to pop context menu when right-clicking on a node - const onNodeContextMenu = useCallback( - (event, node) => { - // Prevent native context menu from showing - event.preventDefault(); - - // Get the ReactFlow pane's bounding rectangle to calculate relative position - const pane = ref.current.getBoundingClientRect(); - - // Position the context menu directly at the click coordinates relative to the pane - setMenu({ - id: node.id, - top: event.clientY - pane.top, - left: event.clientX - pane.left, - right: false, - bottom: false, - }); - }, - [setMenu], - ); - - // Function to delete the selected node - const deleteSelectedNode = () => { - if (selectedNode) { - setNodes((nds) => nds.filter((node) => node.id !== selectedNode.id)); - setEdges((eds) => - eds.filter((edge) => edge.source !== selectedNode.id && edge.target !== selectedNode.id) - ); - setSelectedNode(null); - } - }; - // Function to delete the selected edge - const deleteSelectedEdge = () => { - if (selectedEdge) { - setEdges((eds) => { - const filteredEdges = eds.filter((edge) => edge.id !== selectedEdge.id); - // Reset styles for remaining edges - return filteredEdges.map((e) => ({ - ...e, - style: { - ...e.style, - strokeWidth: 2, - stroke: '#ECDFCC', - }, - markerEnd: { - ...e.markerEnd, - color: '#ECDFCC', - }, - })); - }); - setSelectedEdge(null); - } - }; - - // Function to duplicate a node - const duplicateNode = useCallback((nodeId, options = {}) => { - const node = nodes.find(n => n.id === nodeId); - if (!node) return; - - const newNodeId = nodeCounter.toString(); - - // Calculate position based on source (context menu vs keyboard) - let position; - if (options.fromKeyboard) { - // For keyboard shortcuts, place the duplicate at a more visible offset - position = { - x: node.position.x + 100, - y: node.position.y + 100, - }; - } else { - // For context menu, use smaller offset - position = { - x: node.position.x + 50, - y: node.position.y + 50, - }; - } - - const newNode = { - ...node, - selected: false, - dragging: false, - id: newNodeId, - position, - data: { - ...node.data, - label: node.data.label ? node.data.label.replace(node.id, newNodeId) : `${node.type} ${newNodeId}` - } - }; - - setNodes((nds) => [...nds, newNode]); - setNodeCounter((count) => count + 1); - setMenu(null); // Close the context menu - }, [nodes, nodeCounter, setNodeCounter, setNodes, setMenu]); - - // Keyboard event handler for deleting selected items - useEffect(() => { - const handleKeyDown = (event) => { - // Don't trigger deletion if user is typing in an input field - if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') { - return; - } - - // Handle Ctrl+C (copy) - if (event.ctrlKey && event.key === 'c' && selectedNode) { - event.preventDefault(); - setCopiedNode(selectedNode); - setCopyFeedback(`Copied: ${selectedNode.data.label || selectedNode.id}`); - - // Clear feedback after 2 seconds - setTimeout(() => { - setCopyFeedback(''); - }, 2000); - - console.log('Node copied:', selectedNode.id); - return; - } - - // Handle Ctrl+V (paste) - if (event.ctrlKey && event.key === 'v' && copiedNode) { - event.preventDefault(); - duplicateNode(copiedNode.id, { fromKeyboard: true }); - return; - } - - // Handle Ctrl+D (duplicate selected node directly) - if (event.ctrlKey && event.key === 'd' && selectedNode) { - event.preventDefault(); - duplicateNode(selectedNode.id, { fromKeyboard: true }); - return; - } - - if (event.key === 'Delete' || event.key === 'Backspace') { - if (selectedEdge) { - deleteSelectedEdge(); - } else if (selectedNode) { - deleteSelectedNode(); - } - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [selectedEdge, selectedNode, copiedNode, duplicateNode, setCopyFeedback]); - - return ( -
- - {/* Tab Navigation */} - - - {/* Graph Editor Tab */} - {activeTab === 'graph' && ( -
- {/* Sidebar */} -
-
- -
-
- - {/* Main content area that moves with sidebar */} -
- - - {/* Log Dock */} - setDockOpen(false)} - lines={logLines} - progress={null} - /> - - {/* Node Sidebar */} - - - {/* Edge Details */} - setSelectedEdge(null)} - onDelete={deleteSelectedEdge} - /> -
-
- )} - - {/* Events tab */} - {activeTab === 'events' && } - - {/* Solver Parameters Tab */} - { - activeTab === 'solver' && ( - - ) - } - - {/* Global Variables Tab */} - { - activeTab === 'globals' && ( - - ) - } - - {/* Results Tab */} - { - activeTab === 'results' && ( - - ) - } - - {/* Share URL Modal */} - setShowShareModal(false)} - shareableURL={shareableURL} - urlMetadata={urlMetadata} - /> - -
- ); -} - -export function App() { - return ( - - - - - - ); -} - diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9b..00000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/backend.py b/src/backend.py deleted file mode 100644 index f0e4625d..00000000 --- a/src/backend.py +++ /dev/null @@ -1,580 +0,0 @@ -import os -import json -from flask import Flask, request, jsonify -from flask_cors import CORS - -import plotly.graph_objects as go -from plotly.subplots import make_subplots -import plotly -import json as plotly_json -import inspect -import io -from contextlib import redirect_stdout, redirect_stderr - -from pathview.convert_to_python import convert_graph_to_python -from pathview.pathsim_utils import make_pathsim_model, map_str_to_object -from pathsim.blocks import Scope, Spectrum - -# Sphinx imports for docstring processing -from docutils.core import publish_parts - -# imports for logging progress -from flask import Response, stream_with_context -import logging -from queue import Queue, Empty - - -def docstring_to_html(docstring): - """Convert a Python docstring to HTML using docutils (like Sphinx does).""" - if not docstring: - return "

No documentation available.

" - - try: - # Use docutils to convert reStructuredText to HTML - # This is similar to what Sphinx does internally - overrides = { - "input_encoding": "utf-8", - "doctitle_xform": False, - "initial_header_level": 2, - } - - parts = publish_parts( - source=docstring, writer_name="html", settings_overrides=overrides - ) - - # Return just the body content (without full HTML document structure) - html_content = parts["body"] - - # Clean up the HTML a bit for better display in the sidebar - html_content = html_content.replace('
', "
") - - return html_content - - except Exception as e: - # Fallback in case of any parsing errors - import html - - escaped = html.escape(docstring) - return f"
Error parsing docstring: {str(e)}\n\n{escaped}
" - - -# Configure Flask app for Cloud Run -app = Flask(__name__, static_folder="../dist", static_url_path="") - -# Configure CORS based on environment -if os.getenv("FLASK_ENV") == "production": - # Production: Allow Cloud Run domains and common domains - CORS( - app, - resources={ - r"/*": { - "origins": ["*"], # Allow all origins for Cloud Run - "methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - "allow_headers": ["Content-Type", "Authorization"], - } - }, - ) -else: - # Development: Only allow localhost - CORS( - app, - resources={ - r"/*": {"origins": ["http://localhost:5173", "http://localhost:3000"]} - }, - supports_credentials=True, - ) - - -### for capturing logs from pathsim - - -@app.get("/logs/stream") -def logs_stream(): - def gen(): - yield "retry: 500\n\n" - while True: - try: - # Use a timeout to prevent indefinite blocking - line = log_queue.get(timeout=30) - for chunk in line.replace("\r", "\n").splitlines(): - yield f"data: {chunk}\n\n" - except Empty: - # Send a heartbeat to keep connection alive - yield "data: \n\n" - except Exception as e: - # Log the error and break the loop to close the connection - yield f"data: Error in log stream: {str(e)}\n\n" - break - - return Response(gen(), mimetype="text/event-stream") - - -log_queue = Queue() - - -class QueueHandler(logging.Handler): - def emit(self, record): - try: - msg = self.format(record) - log_queue.put_nowait(msg) - except Exception: - pass - - -qhandler = QueueHandler() -qhandler.setLevel(logging.INFO) -qhandler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")) - -root = logging.getLogger() -root.setLevel(logging.INFO) -root.addHandler(qhandler) - -### log backend ends - - -# Serve React frontend for production -@app.route("/") -def serve_frontend(): - """Serve the React frontend in production.""" - if os.getenv("FLASK_ENV") == "production": - return app.send_static_file("index.html") - else: - return jsonify({"message": "PathView API", "status": "running"}) - - -# Health check endpoint for Cloud Run -@app.route("/health", methods=["GET"]) -def health_check(): - return jsonify({"status": "healthy", "message": "PathView Backend is running"}), 200 - - -# Version information endpoint -@app.route("/version", methods=["GET"]) -def get_version(): - try: - # Get pathsim version - import pathsim - - pathsim_version = getattr(pathsim, "__version__", "Unknown") - - import pathview - - pathview_version = getattr(pathview, "__version__", "Unknown") - - return jsonify( - { - "pathsim_version": pathsim_version, - "pathview_version": pathview_version, - "status": "success", - } - ), 200 - except Exception as e: - return jsonify( - { - "pathsim_version": "Unknown", - "pathview_version": "Unknown", - "status": "error", - "error": str(e), - } - ), 200 - - -@app.route("/default-values-all", methods=["GET"]) -def get_all_default_values(): - try: - all_default_values = {} - for node_type, block_class in map_str_to_object.items(): - parameters_for_class = inspect.signature(block_class.__init__).parameters - default_values = {} - for param in parameters_for_class: - if param != "self": # Skip 'self' parameter - default_value = parameters_for_class[param].default - if default_value is inspect._empty: - default_values[param] = None # Handle empty defaults - else: - default_values[param] = default_value - # check if default value is serializable to JSON - if not isinstance( - default_value, (int, float, str, bool, list, dict) - ): - # Attempt to convert to JSON serializable type - try: - default_values[param] = json.dumps(default_value) - except TypeError: - # If conversion fails, set to a string 'default' - default_values[param] = "default" - all_default_values[node_type] = default_values - - return jsonify(all_default_values) - except Exception as e: - return jsonify({"error": f"Could not get all default values: {str(e)}"}), 400 - - -# returns default values for parameters of a node -@app.route("/default-values/", methods=["GET"]) -def get_default_values(node_type): - try: - if node_type not in map_str_to_object: - return jsonify({"error": f"Unknown node type: {node_type}"}), 400 - - block_class = map_str_to_object[node_type] - parameters_for_class = inspect.signature(block_class.__init__).parameters - default_values = {} - for param in parameters_for_class: - if param != "self": # Skip 'self' parameter - default_value = parameters_for_class[param].default - if default_value is inspect._empty: - default_values[param] = None # Handle empty defaults - else: - default_values[param] = default_value - # check if default value is serializable to JSON - if not isinstance( - default_value, (int, float, str, bool, list, dict) - ): - # Attempt to convert to JSON serializable type - try: - default_values[param] = json.dumps(default_value) - except TypeError: - # If conversion fails, set to a string 'default' - default_values[param] = "default" - return jsonify(default_values) - except Exception as e: - return jsonify( - {"error": f"Could not get default values for {node_type}: {str(e)}"} - ), 400 - - -@app.route("/get-all-docs", methods=["GET"]) -def get_all_docs(): - try: - all_docs = {} - for node_type, block_class in map_str_to_object.items(): - docstring = inspect.getdoc(block_class) - - # If no docstring, provide a basic description - if not docstring: - docstring = f"No documentation available for {node_type}." - - # Convert docstring to HTML using docutils/Sphinx-style processing - html_content = docstring_to_html(docstring) - - all_docs[node_type] = { - "docstring": docstring, # Keep original for backwards compatibility - "html": html_content, # New HTML version - } - - return jsonify(all_docs) - except Exception as e: - return jsonify({"error": f"Could not get docs for all nodes: {str(e)}"}), 400 - - -@app.route("/get-docs/", methods=["GET"]) -def get_docs(node_type): - try: - if node_type not in map_str_to_object: - return jsonify({"error": f"Unknown node type: {node_type}"}), 400 - - block_class = map_str_to_object[node_type] - docstring = inspect.getdoc(block_class) - - # If no docstring, provide a basic description - if not docstring: - docstring = f"No documentation available for {node_type}." - - # Convert docstring to HTML using docutils/Sphinx-style processing - html_content = docstring_to_html(docstring) - - return jsonify( - { - "docstring": docstring, # Keep original for backwards compatibility - "html": html_content, # New HTML version - } - ) - except Exception as e: - return jsonify({"error": f"Could not get docs for {node_type}: {str(e)}"}), 400 - - -# Function to convert graph to Python script -@app.route("/convert-to-python", methods=["POST"]) -def convert_to_python(): - try: - data = request.json - graph_data = data.get("graph") - - if not graph_data: - return jsonify({"error": "No graph data provided"}), 400 - - # Generate the Python script directly using the imported function - script_content = convert_graph_to_python(graph_data) - - return jsonify( - { - "success": True, - "script": script_content, - "message": "Python script generated successfully", - } - ) - - except Exception as e: - return jsonify({"success": False, "error": f"Server error: {str(e)}"}), 500 - - -# Helper function to extract CSV payload from scopes -def make_csv_payload(scopes): - csv_payload = {"time": [], "series": {}} - - max_len = 0 - for scope in scopes: - time, values = scope.read() - max_len = max(max_len, len(time)) - csv_payload["time"] = time.tolist() - for i, series in enumerate(values): - label = scope.labels[i] if i < len(scope.labels) else f"{scope.label} {i}" - csv_payload["series"][label] = series.tolist() - - return csv_payload - - -def make_plot(simulation): - scopes = [block for block in simulation.blocks if isinstance(block, Scope)] - spectra = [block for block in simulation.blocks if isinstance(block, Spectrum)] - print(f"Found {len(scopes)} scopes and {len(spectra)} spectra") - - # FIXME right now only the scopes are converted to CSV - # extra work is needed since spectra and scopes don't share the same x axis - csv_payload = make_csv_payload(scopes) - - # Share x only if there are only scopes or only spectra - shared_x = len(scopes) * len(spectra) == 0 - n_rows = len(scopes) + len(spectra) - - if n_rows == 0: - # No scopes or spectra to plot - return jsonify( - { - "success": True, - "plot": "{}", - "html": "

No scopes or spectra to display

", - "csv_data": csv_payload, - "message": "Pathsim simulation completed successfully", - } - ) - - absolute_vertical_spacing = 0.05 - relative_vertical_spacing = absolute_vertical_spacing / n_rows - fig = make_subplots( - rows=n_rows, - cols=1, - shared_xaxes=shared_x, - subplot_titles=[scope.label for scope in scopes] - + [spec.label for spec in spectra], - vertical_spacing=relative_vertical_spacing, - ) - - # make scope plots - for i, scope in enumerate(scopes): - sim_time, data = scope.read() - - for p, d in enumerate(data): - lb = scope.labels[p] if p < len(scope.labels) else f"port {p}" - if isinstance(scope, Spectrum): - d = abs(d) - fig.add_trace( - go.Scatter(x=sim_time, y=d, mode="lines", name=lb), - row=i + 1, - col=1, - ) - - fig.update_xaxes(title_text="Time", row=len(scopes), col=1) - - # make spectrum plots - for i, spec in enumerate(spectra): - freq, data = spec.read() - - for p, d in enumerate(data): - lb = spec.labels[p] if p < len(spec.labels) else f"port {p}" - d = abs(d) - fig.add_trace( - go.Scatter(x=freq, y=d, mode="lines", name=lb), - row=len(scopes) + i + 1, - col=1, - ) - fig.update_xaxes(title_text="Frequency", row=len(scopes) + i + 1, col=1) - - fig.update_layout(height=500 * (len(scopes) + len(spectra)), hovermode="x unified") - - return fig, csv_payload - - -# Function to convert graph to pathsim and run simulation -@app.route("/run-pathsim", methods=["POST"]) -def run_pathsim(): - try: - data = request.json - graph_data = data.get("graph") - if not graph_data: - return jsonify({"error": "No graph data provided"}), 400 - - my_simulation, duration = make_pathsim_model(graph_data) - - # get the pathsim logger and add the queue handler - logger = my_simulation.logger - logger.addHandler(qhandler) - - # Run the simulation - my_simulation.run(duration) - - # Generate the plot - try: - fig, csv_payload = make_plot(my_simulation) - print("Created plot figure") - - # Convert plot to JSON - try: - plot_data = plotly_json.dumps(fig, cls=plotly.utils.PlotlyJSONEncoder) - plot_html = fig.to_html() - print("Converted plot to JSON and HTML") - except Exception as plot_error: - print(f"Error converting plot to JSON: {str(plot_error)}") - return jsonify( - { - "success": False, - "error": f"Plot generation error: {str(plot_error)}", - } - ), 500 - - return jsonify( - { - "success": True, - "plot": plot_data, - "html": plot_html, - "csv_data": csv_payload, - "message": "Pathsim simulation completed successfully", - } - ) - - except Exception as plot_creation_error: - print(f"Error during plot creation: {str(plot_creation_error)}") - return jsonify( - { - "success": False, - "error": f"Plot creation error: {str(plot_creation_error)}", - } - ), 500 - - except Exception as e: - # Log the full error for debugging - import traceback - - error_details = traceback.format_exc() - print(f"Error in run_pathsim: {error_details}") - return jsonify({"success": False, "error": f"Server error: {str(e)}"}), 500 - - -@app.route("/execute-python", methods=["POST"]) -def execute_python(): - """Execute Python code and returns variables/functions.""" - - try: - data = request.json - code = data.get("code", "") - - if not code.strip(): - return jsonify({"success": False, "error": "No code provided"}), 400 - - # Create a temporary namespace that includes current eval_namespace - temp_namespace = {} - # temp_namespace.update(globals()) - - # Capture stdout and stderr - stdout_capture = io.StringIO() - stderr_capture = io.StringIO() - - try: - with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): - exec(code, temp_namespace) - - # Capture any output - output = stdout_capture.getvalue() - error_output = stderr_capture.getvalue() - - if error_output: - return jsonify({"success": False, "error": error_output}), 400 - - # Find new variables and functions - vars = set(temp_namespace.keys()) - # new_vars = vars_after - vars_before - - # Filter out built-ins and modules, keep user-defined items - user_variables = {} - user_functions = [] - - for var_name in vars: - if not var_name.startswith("__"): - value = temp_namespace[var_name] - if callable(value) and hasattr(value, "__name__"): - user_functions.append(var_name) - else: - # Try to serialize the value for display - try: - if isinstance(value, (int, float, str, bool, list, dict)): - user_variables[var_name] = value - else: - user_variables[var_name] = str(value) - except Exception: - user_variables[var_name] = ( - f"<{type(value).__name__} object>" - ) - - return jsonify( - { - "success": True, - "output": output if output else None, - "variables": user_variables, - "functions": user_functions, - "message": f"Executed successfully. Added {len(user_variables)} variables and {len(user_functions)} functions to namespace.", - } - ) - - except SyntaxError as e: - return jsonify({"success": False, "error": f"Syntax Error: {str(e)}"}), 400 - except Exception as e: - return jsonify({"success": False, "error": f"Runtime Error: {str(e)}"}), 400 - - except Exception as e: - return jsonify({"success": False, "error": f"Server error: {str(e)}"}), 500 - - -# Catch-all route for React Router (SPA routing) -@app.route("/") -def catch_all(path): - """Serve React app for all routes in production (for client-side routing).""" - if os.getenv("FLASK_ENV") == "production": - return app.send_static_file("index.html") - else: - return jsonify({"error": "Route not found"}), 404 - - -# Global error handler to ensure all errors return JSON -@app.errorhandler(Exception) -def handle_exception(e): - """Global exception handler to ensure JSON responses.""" - import traceback - from werkzeug.exceptions import HTTPException - - error_details = traceback.format_exc() - print(f"Unhandled exception: {error_details}") - - # For HTTP exceptions, return a cleaner response - if isinstance(e, HTTPException): - return jsonify( - {"success": False, "error": f"{e.name}: {e.description}"} - ), e.code - - # For all other exceptions, return a generic JSON error - return jsonify({"success": False, "error": f"Internal server error: {str(e)}"}), 500 - - -if __name__ == "__main__": - port = int(os.getenv("PORT", 8000)) - app.run(host="0.0.0.0", port=port, debug=os.getenv("FLASK_ENV") != "production") diff --git a/src/components/ContextMenu.jsx b/src/components/ContextMenu.jsx deleted file mode 100644 index 956a53f2..00000000 --- a/src/components/ContextMenu.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import React, { useCallback } from 'react'; -import { useReactFlow } from '@xyflow/react'; - -export default function ContextMenu({ - id, - top, - left, - right, - bottom, - onClick, - onDuplicate, - ...props -}) { - const { setNodes, setEdges } = useReactFlow(); - - const duplicateNode = useCallback(() => { - onDuplicate(id); - }, [id, onDuplicate]); - - const deleteNode = useCallback(() => { - setNodes((nodes) => nodes.filter((node) => node.id !== id)); - setEdges((edges) => edges.filter((edge) => edge.source !== id && edge.target !== id)); - onClick && onClick(); // Close menu after action - }, [id, setNodes, setEdges, onClick]); - - return ( -
-

- node: {id} -

- - -
- ); -} \ No newline at end of file diff --git a/src/components/CustomEdge.jsx b/src/components/CustomEdge.jsx deleted file mode 100644 index fa2edd1b..00000000 --- a/src/components/CustomEdge.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import { MarkerType } from '@xyflow/react'; - -const defaultEdgeStyle = { - strokeWidth: 2, - stroke: '#ECDFCC', -}; - -const defaultMarkerEnd = { - type: MarkerType.ArrowClosed, - width: 20, - height: 20, - color: '#ECDFCC', -}; - -export function makeEdge({ id, source, target, sourceHandle, targetHandle, type = 'smoothstep', label, dashed = false}) { - return { - id, - source, - target, - sourceHandle, - targetHandle, - type, - label, - data: { }, - style: { - ...defaultEdgeStyle, - ...(dashed ? { strokeDasharray: '4 3' } : {}), - }, - markerEnd: defaultMarkerEnd, - }; -} diff --git a/src/components/DnDContext.jsx b/src/components/DnDContext.jsx deleted file mode 100644 index 6f434c1b..00000000 --- a/src/components/DnDContext.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import { createContext, useContext, useState } from 'react'; - -const DnDContext = createContext([null, (_) => {}]); - -export const DnDProvider = ({ children }) => { - const [type, setType] = useState(null); - - return ( - - {children} - - ); -} - -export default DnDContext; - -export const useDnD = () => { - return useContext(DnDContext); -} \ No newline at end of file diff --git a/src/components/EdgeDetails.jsx b/src/components/EdgeDetails.jsx deleted file mode 100644 index 800341f5..00000000 --- a/src/components/EdgeDetails.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from 'react'; - -export default function EdgeDetails({ edge, onClose, onDelete }) { - if (!edge) return null; - - return ( -
-
-

Selected Edge

- -
- ID: {edge.id} -
-
- Source: {edge.source} -
-
- Target: {edge.target} -
-
- Type: {edge.type} -
- -
- - -
-
- ); -} diff --git a/src/components/EventsTab.jsx b/src/components/EventsTab.jsx deleted file mode 100644 index fb1cb1af..00000000 --- a/src/components/EventsTab.jsx +++ /dev/null @@ -1,492 +0,0 @@ -import { useState } from 'react'; - -// Define default parameters for each event type -const eventDefaults = { - 'Schedule': { - t_start: '0', - t_end: 'None', - t_period: '1', - func_act: '', - tolerance: '1e-16' - }, - 'ScheduleList': { - times_evt: '', - func_act: '', - tolerance: '1e-16' - }, - 'ZeroCrossingDown': { - func_evt: '', - func_act: '', - tolerance: '1e-8' - }, - 'ZeroCrossingUp': { - func_evt: '', - func_act: '', - tolerance: '1e-8' - }, - 'ZeroCrossing': { - func_evt: '', - func_act: '', - tolerance: '1e-8' - }, - 'Condition': { - func_evt: '', - func_act: '', - tolerance: '1e-8' - } -}; - -const EventsTab = ({ events, setEvents }) => { - // Initialize with defaults for the initial event type - const initialEventType = 'ZeroCrossingDown'; - const [currentEvent, setCurrentEvent] = useState(() => { - return { - name: '', - type: initialEventType, - ...eventDefaults[initialEventType] - }; - }); - - // State to track if we're editing an existing event - const [editingEventId, setEditingEventId] = useState(null); - - const eventTypes = [ - 'Condition', - 'Schedule', - 'ScheduleList', - 'ZeroCrossing', - 'ZeroCrossingUp', - 'ZeroCrossingDown' - ]; - - const handleInputChange = (field, value) => { - setCurrentEvent(prev => ({ - ...prev, - [field]: value - })); - }; - - const handleTypeChange = (newType) => { - // When type changes, reset the event to defaults for that type - const defaults = eventDefaults[newType] || {}; - setCurrentEvent({ - name: currentEvent.name, // Keep the name - type: newType, - ...defaults - }); - }; - - const addEvent = () => { - if (currentEvent.name) { - // Validate required fields based on event type - - // For Schedule, func_act is required - if (['Schedule', 'ScheduleList'].includes(currentEvent.type) && !currentEvent.func_act) { - alert('func_act is required for Schedule events'); - return; - } - - // For other event types, both func_evt and func_act are typically required - if (!['Schedule', 'ScheduleList'].includes(currentEvent.type) && (!currentEvent.func_evt || !currentEvent.func_act)) { - alert('Both func_evt and func_act are required for this event type'); - return; - } - - setEvents(prev => [...prev, { ...currentEvent, id: Date.now() }]); - - // Reset to defaults for current type - const resetDefaults = eventDefaults[currentEvent.type] || {}; - setCurrentEvent({ - name: '', - type: currentEvent.type, - ...resetDefaults - }); - } - }; - - const editEvent = (event) => { - setCurrentEvent({ ...event }); - setEditingEventId(event.id); - }; - - const saveEditedEvent = () => { - if (currentEvent.name) { - - // For Schedule, func_act is required - if (currentEvent.type === 'Schedule' && !currentEvent.func_act) { - alert('func_act is required for Schedule events'); - return; - } - - // For other event types, both func_evt and func_act are typically required - if (currentEvent.type !== 'Schedule' && (!currentEvent.func_evt || !currentEvent.func_act)) { - alert('Both func_evt and func_act are required for this event type'); - return; - } - - setEvents(prev => prev.map(event => - event.id === editingEventId ? { ...currentEvent } : event - )); - - // Reset form and exit edit mode - cancelEdit(); - } - }; - - const cancelEdit = () => { - setEditingEventId(null); - // Reset to defaults for current type - const resetDefaults = eventDefaults[currentEvent.type] || {}; - setCurrentEvent({ - name: '', - type: currentEvent.type, - ...resetDefaults - }); - }; - - const deleteEvent = (id) => { - setEvents(prev => prev.filter(event => event.id !== id)); - }; - - return ( -
-
-

- Events -

- - {/* Add New Event Form */} -
-

- {editingEventId ? 'Edit Event' : 'Add New Event'} -

- -
-
- - handleInputChange('name', e.target.value)} - placeholder="e.g., E1, shutdown_event" - style={{ - width: '80%', - padding: '10px', - backgroundColor: '#1e1e2f', - border: '1px solid #555', - borderRadius: '4px', - color: '#ffffff', - fontSize: '14px', - }} - /> -
- -
- - -
-
- - {/* Dynamic parameter fields based on event type */} -
- {(() => { - const typeDefaults = eventDefaults[currentEvent.type] || {}; - const allParams = new Set([ - // don't include 'name', 'type', since included above + 'id' cannot be edited - ...Object.keys(currentEvent).filter(key => key !== 'name' && key !== 'type' && key !== 'id'), - ...Object.keys(typeDefaults) - ]); - - return Array.from(allParams).map(key => { - const currentValue = currentEvent[key] || ''; - const defaultValue = typeDefaults[key]; - const placeholder = defaultValue !== undefined && defaultValue !== null ? - String(defaultValue) : ''; - - // Check if this is a function parameter (contains 'func' in the name) - const isFunctionParam = key.toLowerCase().includes('func'); - - return ( -
- - {isFunctionParam ? ( - + {:else} + +
+ {#if content} + {@html renderedHtml} + {:else} + Double-click to add note... + {/if} +
+ {/if} +
+
+ + diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte new file mode 100644 index 00000000..0a38e716 --- /dev/null +++ b/src/lib/components/nodes/BaseNode.svelte @@ -0,0 +1,757 @@ + + + +
+ + {#if (hasPreloaded || previewsPinned) && plotData()} +
+ +
+ {/if} + + + {#if selected} +
+ {/if} + + +
+ +
+ {data.name} + {#if typeDef} + {typeDef.name} + {/if} +
+ + + {#if validPinnedParams().length > 0 && typeDef} + +
e.stopPropagation()} ondblclick={(e) => e.stopPropagation()}> + {#each validPinnedParams() as paramName} + {@const paramDef = typeDef.params.find(p => p.name === paramName)} + {#if paramDef} +
+ + handlePinnedParamChange(paramName, e.currentTarget.value)} + onmousedown={(e) => e.stopPropagation()} + onfocus={(e) => e.stopPropagation()} + use:paramInput + /> +
+ {/if} + {/each} +
+ {/if} +
+ + + {#if allowsDynamicInputs && selected} +
+ + +
+ {/if} + + + {#if allowsDynamicOutputs && selected} +
+ + +
+ {/if} + + + {#key `${rotation}-${data.inputs.length}`} + {#each data.inputs as port, i} + handleInputMouseEnter(e, port)} + onmouseleave={() => handleInputMouseLeave(port)} + /> + {/each} + {/key} + + + {#key `${rotation}-${data.outputs.length}`} + {#each data.outputs as port, i} + handleOutputMouseEnter(e, port)} + onmouseleave={() => handleOutputMouseLeave(port)} + /> + {/each} + {/key} +
+ + diff --git a/src/lib/components/nodes/EventNode.svelte b/src/lib/components/nodes/EventNode.svelte new file mode 100644 index 00000000..728a41a8 --- /dev/null +++ b/src/lib/components/nodes/EventNode.svelte @@ -0,0 +1,95 @@ + + + +
+ +
+
+ {data.name} + {#if typeDef} + {typeDef.name} + {/if} +
+
+
+ + diff --git a/src/lib/components/nodes/EventPreview.svelte b/src/lib/components/nodes/EventPreview.svelte new file mode 100644 index 00000000..51cb92f1 --- /dev/null +++ b/src/lib/components/nodes/EventPreview.svelte @@ -0,0 +1,47 @@ + + +
+
+ {event.name} +
+
+ + diff --git a/src/lib/components/nodes/NodePreview.svelte b/src/lib/components/nodes/NodePreview.svelte new file mode 100644 index 00000000..c4487078 --- /dev/null +++ b/src/lib/components/nodes/NodePreview.svelte @@ -0,0 +1,74 @@ + + +
+ {node.name} +
+ + diff --git a/src/lib/components/nodes/PlotPreview.svelte b/src/lib/components/nodes/PlotPreview.svelte new file mode 100644 index 00000000..256dc441 --- /dev/null +++ b/src/lib/components/nodes/PlotPreview.svelte @@ -0,0 +1,319 @@ + + +
+ + + + {#if hasData()} + {#each cachedPaths as path} + + {/each} + {:else} + No data + {/if} + +
+ + diff --git a/src/lib/components/nodes/previewQueue.ts b/src/lib/components/nodes/previewQueue.ts new file mode 100644 index 00000000..90d3ffe3 --- /dev/null +++ b/src/lib/components/nodes/previewQueue.ts @@ -0,0 +1,67 @@ +/** + * Shared render queue for PlotPreview components. + * Processes previews at a throttled rate to prevent UI freezes during streaming. + * Pauses processing when page is hidden to save CPU. + */ + +type RenderTask = () => void; + +const queue = new Map(); +let rafId: number | null = null; +let lastProcessTime = 0; +const MIN_PROCESS_INTERVAL = 1000 / 10; // Max 10fps for preview updates + +// Visibility API - pause processing when tab is hidden +let isPageVisible = typeof document !== 'undefined' ? !document.hidden : true; + +function handleVisibilityChange() { + isPageVisible = !document.hidden; + // Resume processing if there are queued tasks + if (isPageVisible && queue.size > 0 && rafId === null) { + rafId = requestAnimationFrame(processQueue); + } +} + +if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', handleVisibilityChange); +} + +function processQueue(timestamp: number) { + rafId = null; + + // Skip processing when page is hidden + if (!isPageVisible) return; + + if (queue.size === 0) return; + + // Throttle processing rate + if (timestamp - lastProcessTime < MIN_PROCESS_INTERVAL) { + rafId = requestAnimationFrame(processQueue); + return; + } + lastProcessTime = timestamp; + + // Process all queued tasks in one batch + const tasks = Array.from(queue.values()); + queue.clear(); + + for (const task of tasks) { + task(); + } + + // If more tasks were added during processing, schedule next batch + if (queue.size > 0) { + rafId = requestAnimationFrame(processQueue); + } +} + +export function enqueueRender(id: symbol, task: RenderTask) { + queue.set(id, task); + if (rafId === null && isPageVisible) { + rafId = requestAnimationFrame(processQueue); + } +} + +export function cancelRender(id: symbol) { + queue.delete(id); +} diff --git a/src/lib/components/panels/CodeEditor.svelte b/src/lib/components/panels/CodeEditor.svelte new file mode 100644 index 00000000..0b07c3bd --- /dev/null +++ b/src/lib/components/panels/CodeEditor.svelte @@ -0,0 +1,181 @@ + + +
+
+ {#if isLoading} +
Loading editor...
+ {/if} +
+ + {#if error} +
{error}
+ {/if} + + +
+ + diff --git a/src/lib/components/panels/ConsolePanel.svelte b/src/lib/components/panels/ConsolePanel.svelte new file mode 100644 index 00000000..2e7a9963 --- /dev/null +++ b/src/lib/components/panels/ConsolePanel.svelte @@ -0,0 +1,182 @@ + + +
+
+ {#if logs.length === 0} +
+

Run simulation to see output

+

Ctrl+Enter

+
+ {:else} + {#each logs as log (log.id)} +
+ {log.message} +
+ {/each} + {/if} +
+ {#if !autoScroll && logs.length > 0} + + {/if} +
+ + diff --git a/src/lib/components/panels/EventsPanel.svelte b/src/lib/components/panels/EventsPanel.svelte new file mode 100644 index 00000000..9b433523 --- /dev/null +++ b/src/lib/components/panels/EventsPanel.svelte @@ -0,0 +1,154 @@ + + +
+
+
+ {#each eventTypes as eventType} + + {/each} +
+
+ + +
+ + diff --git a/src/lib/components/panels/NodeLibrary.svelte b/src/lib/components/panels/NodeLibrary.svelte new file mode 100644 index 00000000..fb04e497 --- /dev/null +++ b/src/lib/components/panels/NodeLibrary.svelte @@ -0,0 +1,382 @@ + + + +
+
+ + + {#if searchQuery} + + {/if} +
+ +
+ {#each Array.from(groupedNodes().entries()) as [category, nodes]} +
+ + {#if !collapsedCategories.has(category)} +
+ {#each nodes as node} + + {/each} +
+ {/if} +
+ {:else} +
+ No nodes found + Try "gain", "source", or "plot" +
+ {/each} +
+ + +
+ + diff --git a/src/lib/components/panels/PlotPanel.svelte b/src/lib/components/panels/PlotPanel.svelte new file mode 100644 index 00000000..71ddb9ac --- /dev/null +++ b/src/lib/components/panels/PlotPanel.svelte @@ -0,0 +1,302 @@ + + +
+ {#if !collapsed} +
+ {#if hasPlots} + {#if viewMode === 'tiles'} + +
+ {#each allPlots() as plot (plot.id)} +
+ +
+ {/each} +
+ {:else} + +
+ {#each allPlots() as plot, i (plot.id)} + {#if activeTab === i} + + {/if} + {/each} +
+ {/if} + {:else} +
+

Run simulation to see plots

+

Add Scope or Spectrum nodes to record signals

+
+ {/if} +
+ {/if} +
+ + diff --git a/src/lib/components/panels/SignalPlot.svelte b/src/lib/components/panels/SignalPlot.svelte new file mode 100644 index 00000000..bbee8539 --- /dev/null +++ b/src/lib/components/panels/SignalPlot.svelte @@ -0,0 +1,304 @@ + + +
+
+
+ + diff --git a/src/lib/components/panels/SimulationPanel.svelte b/src/lib/components/panels/SimulationPanel.svelte new file mode 100644 index 00000000..4588ac9a --- /dev/null +++ b/src/lib/components/panels/SimulationPanel.svelte @@ -0,0 +1,508 @@ + + +
+
+
+ +
+ updateSetting('duration', e.currentTarget.value)} + spellcheck="false" + use:paramInput + /> + s +
+
+ +
+ +
+ updateSetting('dt', e.currentTarget.value)} + spellcheck="false" + use:paramInput + /> + s +
+
+ +
+ + +
+ +
+ +
+
+ Adaptive + ? +
+
+ Fixed + ? +
+ + +
+ Explicit + ? +
+
+ {#each solverMatrix.adaptive.explicit as solver} + + {/each} +
+
+ {#each solverMatrix.fixed.explicit as solver} + + {/each} +
+ + +
+ Implicit + ? +
+
+ {#each solverMatrix.adaptive.implicit as solver} + + {/each} +
+
+ {#each solverMatrix.fixed.implicit as solver} + + {/each} +
+
+
+ + +
+
+ + updateSetting('rtol', e.currentTarget.value)} + spellcheck="false" + use:paramInput + /> +
+
+ + updateSetting('atol', e.currentTarget.value)} + spellcheck="false" + use:paramInput + /> +
+
+ + updateSetting('ftol', e.currentTarget.value)} + spellcheck="false" + use:paramInput + /> +
+
+ + +
+
+ +
+ updateSetting('dt_min', e.currentTarget.value)} + spellcheck="false" + use:paramInput + /> + s +
+
+
+ +
+ updateSetting('dt_max', e.currentTarget.value)} + spellcheck="false" + use:paramInput + /> + s +
+
+
+ + +
+ + Ghost Traces + ? + +
+ {#each [0, 1, 2, 3, 4, 5, 6, 7, 8] as n} + + {/each} +
+
+
+ + diff --git a/src/lib/components/panels/plotQueue.ts b/src/lib/components/panels/plotQueue.ts new file mode 100644 index 00000000..facda7d8 --- /dev/null +++ b/src/lib/components/panels/plotQueue.ts @@ -0,0 +1,80 @@ +/** + * Shared render queue for Plotly plots in tiles mode. + * Batches and throttles plot updates to prevent UI freezes during streaming. + * Pauses processing when page is hidden to save CPU. + */ + +type RenderTask = () => void; + +const queue = new Map(); +let rafId: number | null = null; +let lastProcessTime = 0; +const MIN_PROCESS_INTERVAL = 1000 / 15; // Max 15fps for plot updates (slightly higher than previews) + +// Visibility API - pause processing when tab is hidden +let isPageVisible = typeof document !== 'undefined' ? !document.hidden : true; + +function handleVisibilityChange() { + isPageVisible = !document.hidden; + // Resume processing if there are queued tasks + if (isPageVisible && queue.size > 0 && rafId === null) { + rafId = requestAnimationFrame(processQueue); + } +} + +if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', handleVisibilityChange); +} + +function processQueue(timestamp: number) { + rafId = null; + + // Skip processing when page is hidden + if (!isPageVisible) return; + + if (queue.size === 0) return; + + // Throttle processing rate + if (timestamp - lastProcessTime < MIN_PROCESS_INTERVAL) { + rafId = requestAnimationFrame(processQueue); + return; + } + lastProcessTime = timestamp; + + // Process all queued tasks in one batch + const tasks = Array.from(queue.values()); + queue.clear(); + + for (const task of tasks) { + task(); + } + + // If more tasks were added during processing, schedule next batch + if (queue.size > 0) { + rafId = requestAnimationFrame(processQueue); + } +} + +/** + * Enqueue a plot update. Replaces any existing task for the same component. + */ +export function enqueuePlotUpdate(id: symbol, task: RenderTask) { + queue.set(id, task); + if (rafId === null && isPageVisible) { + rafId = requestAnimationFrame(processQueue); + } +} + +/** + * Cancel a pending plot update. + */ +export function cancelPlotUpdate(id: symbol) { + queue.delete(id); +} + +/** + * Check if page is currently visible (for components that need to know) + */ +export function isVisible(): boolean { + return isPageVisible; +} diff --git a/src/lib/constants/handles.ts b/src/lib/constants/handles.ts new file mode 100644 index 00000000..9d7138e7 --- /dev/null +++ b/src/lib/constants/handles.ts @@ -0,0 +1,46 @@ +/** + * Handle and port naming constants + * Centralizes the format strings used for port names and handle IDs + */ + +/** + * Port name generators + */ +export const PORT_NAME = { + input: (index: number) => `in ${index}`, + output: (index: number) => `out ${index}` +} as const; + +// Pre-compiled regex patterns for handle ID parsing +const INPUT_INDEX_REGEX = /-input-(\d+)$/; +const OUTPUT_INDEX_REGEX = /-output-(\d+)$/; + +/** + * Handle ID generators and parsers + * Handle format: "{nodeId}-{direction}-{index}" e.g., "node1-output-0" + */ +export const HANDLE_ID = { + input: (nodeId: string, index: number) => `${nodeId}-input-${index}`, + output: (nodeId: string, index: number) => `${nodeId}-output-${index}`, + + /** + * Parse handle ID to extract index + * @returns The port index, or null if not parseable + */ + parseIndex: (handleId: string, direction: 'input' | 'output'): number | null => { + const regex = direction === 'input' ? INPUT_INDEX_REGEX : OUTPUT_INDEX_REGEX; + const match = handleId.match(regex); + return match ? parseInt(match[1], 10) : null; + }, + + /** + * Parse both source and target handles from a connection + * @returns Object with sourceIndex and targetIndex, or null values if not parseable + */ + parseConnection: (sourceHandle: string, targetHandle: string) => { + return { + sourceIndex: HANDLE_ID.parseIndex(sourceHandle, 'output'), + targetIndex: HANDLE_ID.parseIndex(targetHandle, 'input') + }; + } +} as const; diff --git a/src/lib/constants/layout.ts b/src/lib/constants/layout.ts new file mode 100644 index 00000000..62a17e74 --- /dev/null +++ b/src/lib/constants/layout.ts @@ -0,0 +1,40 @@ +/** + * Layout constants - Must match CSS variables in app.css + * + * CSS Variables (app.css): + * --panel-gap: var(--space-md) = 12px + * --panel-toggles-width: 52px + */ + +/** Gap between panels (matches --panel-gap / --space-md) */ +export const PANEL_GAP = 12; + +/** Width of the panel toggles sidebar (matches --panel-toggles-width) */ +export const PANEL_TOGGLES_WIDTH = 52; + +/** Minimum width for bottom panels when split */ +export const MIN_BOTTOM_PANEL_WIDTH = 300; + +/** Default offset for duplicated nodes */ +export const DUPLICATE_OFFSET = { x: 50, y: 50 }; + +/** Navigation bar height (12px top margin + 44px logo + 12px gap) */ +export const NAV_HEIGHT = 68; + +/** Default initial panel dimensions */ +export const PANEL_DEFAULTS = { + width: 320, + height: 280, + minWidth: 200, + minHeight: 150, + maxWidth: 800 +} as const; + +/** + * Calculate total available width for bottom panels + * Layout: [toggles + gap][panel1][gap][panel2][gap] + */ +export function getBottomPanelTotalWidth(viewportWidth: number): number { + // W1 + W2 = viewport - toggles - 3*gap + return viewportWidth - PANEL_TOGGLES_WIDTH - PANEL_GAP * 3; +} diff --git a/src/lib/constants/messages.ts b/src/lib/constants/messages.ts new file mode 100644 index 00000000..5f2bd721 --- /dev/null +++ b/src/lib/constants/messages.ts @@ -0,0 +1,29 @@ +/** + * User-facing messages and progress indicators + */ + +export const PROGRESS_MESSAGES = { + LOADING_PYODIDE: 'Loading Pyodide...', + INSTALLING_DEPS: 'Installing NumPy and SciPy...', + INSTALLING_PATHSIM: 'Installing PathSim...', + STARTING_WORKER: 'Starting worker...', + STARTING_SIMULATION: 'Starting simulation...' +} as const; + +export const STATUS_MESSAGES = { + READY: 'Ready', + COMPLETE: 'Complete', + STOPPED: 'Stopped', + RUNNING: 'Running' +} as const; + +export const ERROR_MESSAGES = { + SIMULATION_FAILED: 'Simulation failed:', + NO_SIMULATION_TO_CONTINUE: 'No simulation to continue. Run a simulation first.', + WORKER_NOT_INITIALIZED: 'Worker not initialized', + FAILED_TO_LOAD_PYODIDE: 'Failed to load Pyodide' +} as const; + +export const SUCCESS_MESSAGES = { + SIMULATION_COMPLETED: 'Simulation completed successfully' +} as const; diff --git a/src/lib/constants/nodeTypes.ts b/src/lib/constants/nodeTypes.ts new file mode 100644 index 00000000..dc841a67 --- /dev/null +++ b/src/lib/constants/nodeTypes.ts @@ -0,0 +1,10 @@ +/** + * Centralized node type identifiers + * These match the PathSim block class names directly + */ +export const NODE_TYPES = { + SUBSYSTEM: 'Subsystem', + INTERFACE: 'Interface' +} as const; + +export type NodeTypeId = (typeof NODE_TYPES)[keyof typeof NODE_TYPES]; diff --git a/src/lib/constants/python.ts b/src/lib/constants/python.ts new file mode 100644 index 00000000..bf0a2f93 --- /dev/null +++ b/src/lib/constants/python.ts @@ -0,0 +1,44 @@ +/** + * Python/Pyodide related constants + */ + +export const PYODIDE_VERSION = '0.26.2'; +export const PYODIDE_CDN_URL = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/pyodide.mjs`; + +/** + * Code section headers used in generated Python code + */ +export const CODE_SECTIONS = { + IMPORTS: '# IMPORTS', + CODE_CONTEXT: '# CODE CONTEXT', + USER_DEFINED_CODE: '# USER-DEFINED CODE', + BLOCKS: '# BLOCKS', + NODE_ID_MAPPING: '# NODE ID MAPPING (for data extraction)', + NODE_NAME_MAPPING: '# NODE NAME MAPPING', + CONNECTIONS: '# CONNECTIONS', + EVENTS: '# EVENTS', + SIMULATION: '# SIMULATION', + RUN: '# RUN', + MAIN: '# MAIN' +} as const; + +/** + * Category order for organizing blocks in formatted export + */ +export const BLOCK_CATEGORY_ORDER: string[] = [ + 'Sources', + 'Dynamic', + 'Algebraic', + 'Mixed', + 'Recording', + 'Subsystem' +]; + +/** + * Timeout constants for Pyodide operations (in milliseconds) + */ +export const TIMEOUTS = { + SIMULATION: 300000, // 5 minutes + INIT: 120000, // 2 minutes + VALIDATION: 30000 // 30 seconds +} as const; diff --git a/src/lib/events/definitions.ts b/src/lib/events/definitions.ts new file mode 100644 index 00000000..733f2b3d --- /dev/null +++ b/src/lib/events/definitions.ts @@ -0,0 +1,8 @@ +/** + * PathSim event type definitions + * Auto-generated from PathSim - run 'python scripts/extract-events.py' to update + */ + +import { extractedEvents } from './generated/events'; + +export const eventDefinitions = extractedEvents; diff --git a/src/lib/events/generated/events.ts b/src/lib/events/generated/events.ts new file mode 100644 index 00000000..5e2933fb --- /dev/null +++ b/src/lib/events/generated/events.ts @@ -0,0 +1,182 @@ +// Auto-generated by scripts/extract-events.py - DO NOT EDIT +// Re-run 'python scripts/extract-events.py' to update + +import type { EventTypeDefinition } from '../types'; + +export const extractedEvents: EventTypeDefinition[] = +[ + { + "type": "pathsim.events.ZeroCrossing", + "name": "ZeroCrossing", + "eventClass": "ZeroCrossing", + "description": "Subclass of base 'Event' that triggers if the event function crosses zero.", + "docstringHtml": "

Subclass of base 'Event' that triggers if the event function crosses zero.\nThis is a bidirectional zero-crossing detector.

\n

Monitors system state by evaluating an event function (func_evt) with scalar output and\ntesting for zero crossings (sign changes).

\n
\nfunc_evt(time) -> event?\n
\n

If an event is detected, some action (func_act) is performed on the system state.

\n
\nfunc_evt(time) == 0 -> event -> func_act(time)\n
\n
\n

Example

\n

Initialize a zero-crossing event handler like this:

\n
\n# define the event function\ndef evt(t):\n    # here we have a zero-crossing at 't==10'\n    return t - 10\n\n# define the action function (callback)\ndef act(t):\n    # do something at event resolution\n    pass\n\n# initialize the event manager\nE = ZeroCrossing(\n    func_evt=evt,  # the event function\n    func_act=act   # the action function\n    )\n
\n
\n
\n

Parameters

\n
\n
func_evt : callable
\n
event function, where zeros are events
\n
func_act : callable
\n
action function for event resolution
\n
tolerance : float
\n
tolerance to check if detection is close to actual event
\n
\n
\n", + "params": [ + { + "name": "func_evt", + "type": "callable", + "default": "None", + "description": "event function, where zeros are events" + }, + { + "name": "func_act", + "type": "callable", + "default": "None", + "description": "action function for event resolution" + }, + { + "name": "tolerance", + "type": "number", + "default": "0.0001", + "description": "tolerance to check if detection is close to actual event" + } + ] + }, + { + "type": "pathsim.events.ZeroCrossingUp", + "name": "ZeroCrossingUp", + "eventClass": "ZeroCrossingUp", + "description": "Modification of standard 'ZeroCrossing' event where events are only triggered", + "docstringHtml": "

Modification of standard 'ZeroCrossing' event where events are only triggered\nif the event function changes sign from negative to positive (up). Also called\nunidirectional zero-crossing.

\n", + "params": [ + { + "name": "func_evt", + "type": "callable", + "default": "None", + "description": "" + }, + { + "name": "func_act", + "type": "callable", + "default": "None", + "description": "" + }, + { + "name": "tolerance", + "type": "number", + "default": "0.0001", + "description": "" + } + ] + }, + { + "type": "pathsim.events.ZeroCrossingDown", + "name": "ZeroCrossingDown", + "eventClass": "ZeroCrossingDown", + "description": "Modification of standard 'ZeroCrossing' event where events are only triggered", + "docstringHtml": "

Modification of standard 'ZeroCrossing' event where events are only triggered\nif the event function changes sign from positive to negative (down). Also called\nunidirectional zero-crossing.

\n", + "params": [ + { + "name": "func_evt", + "type": "callable", + "default": "None", + "description": "" + }, + { + "name": "func_act", + "type": "callable", + "default": "None", + "description": "" + }, + { + "name": "tolerance", + "type": "number", + "default": "0.0001", + "description": "" + } + ] + }, + { + "type": "pathsim.events.Schedule", + "name": "Schedule", + "eventClass": "Schedule", + "description": "Subclass of base 'Event' that triggers dependent on the evaluation time.", + "docstringHtml": "

Subclass of base 'Event' that triggers dependent on the evaluation time.

\n

Monitors time in every timestep and triggers periodically (period). This event\ndoes not have an event function as the event condition only depends on time.

\n
\ntime == next_schedule_time -> event\n
\n
\n

Example

\n

Initialize a scheduled event handler like this:

\n
\n#define the action function (callback)\ndef act(t):\n    #do something at event resolution\n    pass\n\n#initialize the event manager\nE = Schedule(\n    t_start=0,    #starting at t=0\n    t_end=None,   #never ending\n    t_period=3,   #triggering every 3 time units\n    func_act=act  #resulting in a callback\n    )\n
\n
\n
\n

Parameters

\n
\n
t_start : float
\n
starting time for schedule
\n
t_end : float
\n
termination time for schedule
\n
t_period : float
\n
time period of schedule, when events are triggered
\n
func_act : callable
\n
action function for event resolution
\n
tolerance : float
\n
tolerance to check if detection is close to actual event
\n
\n
\n", + "params": [ + { + "name": "t_start", + "type": "number", + "default": "0", + "description": "starting time for schedule" + }, + { + "name": "t_end", + "type": "number", + "default": "None", + "description": "termination time for schedule" + }, + { + "name": "t_period", + "type": "number", + "default": "1", + "description": "time period of schedule, when events are triggered" + }, + { + "name": "func_act", + "type": "callable", + "default": "None", + "description": "action function for event resolution" + }, + { + "name": "tolerance", + "type": "number", + "default": "1e-16", + "description": "tolerance to check if detection is close to actual event" + } + ] + }, + { + "type": "pathsim.events.ScheduleList", + "name": "ScheduleList", + "eventClass": "ScheduleList", + "description": "Subclass of base 'Schedule' that triggers dependent on the evaluation time.", + "docstringHtml": "

Subclass of base 'Schedule' that triggers dependent on the evaluation time.

\n

Monitors time in every timestep and triggers at the next event time from the\ntime list. This event does not have an event function as the event condition\nonly depends on time.

\n
\ntime == next_scheduled_time -> event\n
\n
\n

Example

\n

Initialize a scheduled event handler like this:

\n
\n#define the action function (callback)\ndef act(t):\n    #do something at event resolution\n    pass\n\n#initialize the event manager\nE = ScheduleList(\n    times_evt=[1, 5, 12, 300],  #event times where to trigger\n    func_act=act                #resulting in a callback\n    )\n
\n
\n
\n

Parameters

\n
\n
times_evt : list[float]
\n
list of event times in ascending order
\n
func_act : callable
\n
action function for event resolution
\n
tolerance : float
\n
tolerance to check if detection is close to actual event
\n
\n
\n", + "params": [ + { + "name": "times_evt", + "type": "array", + "default": "None", + "description": "list of event times in ascending order" + }, + { + "name": "func_act", + "type": "callable", + "default": "None", + "description": "action function for event resolution" + }, + { + "name": "tolerance", + "type": "number", + "default": "1e-16", + "description": "tolerance to check if detection is close to actual event" + } + ] + }, + { + "type": "pathsim.events.Condition", + "name": "Condition", + "eventClass": "Condition", + "description": "Subclass of base 'Event' that triggers if the event function evaluates to 'True',", + "docstringHtml": "

Subclass of base 'Event' that triggers if the event function evaluates to 'True',\ni.e. the condition is satisfied.

\n

Monitors system state by evaluating an event function (func_evt) with boolean output.\nThe event is considered detected when the event function evaluates to 'True' for the\nfirst time. Subsequent evaluations to 'True' are not considered unless the event is reset.

\n
\nfunc_evt(time) -> event?\n
\n

If an event is detected, some action (func_act) is performed on the system state.

\n
\nfunc_evt(time) == True -> event -> func_act(time)\n
\n
\n

Note

\n

Condition event functions evaluate to boolean and are therefore not smooth.\nTherefore uses bisection method for event location instead of secant method.

\n
\n
\n

Example

\n

Initialize a conditional event handler like this:

\n
\n#define the event function\ndef evt(t):\n    return t > 10\n\n#define the action function (callback)\ndef act(t):\n    #do something at event resolution\n    pass\n\n#initialize the event manager\nE = Condition(\n    func_evt=evt,  #the event function\n    func_act=act   #the action function\n    )\n
\n
\n", + "params": [ + { + "name": "func_evt", + "type": "callable", + "default": "None", + "description": "" + }, + { + "name": "func_act", + "type": "callable", + "default": "None", + "description": "" + }, + { + "name": "tolerance", + "type": "number", + "default": "0.0001", + "description": "" + } + ] + } +]; diff --git a/src/lib/events/index.ts b/src/lib/events/index.ts new file mode 100644 index 00000000..c92a9098 --- /dev/null +++ b/src/lib/events/index.ts @@ -0,0 +1,7 @@ +/** + * Events module exports + */ + +export * from './types'; +export * from './definitions'; +export { eventRegistry } from './registry'; diff --git a/src/lib/events/registry.ts b/src/lib/events/registry.ts new file mode 100644 index 00000000..793ddc07 --- /dev/null +++ b/src/lib/events/registry.ts @@ -0,0 +1,49 @@ +/** + * Event type registry + * Manages all available event type definitions + */ + +import type { EventTypeDefinition } from './types'; +import { eventDefinitions } from './definitions'; + +class EventRegistry { + private events = new Map(); + + constructor() { + // Register all built-in event definitions + for (const def of eventDefinitions) { + this.register(def); + } + } + + /** + * Register an event type definition + */ + register(definition: EventTypeDefinition): void { + this.events.set(definition.type, definition); + } + + /** + * Get an event type definition by type ID + */ + get(type: string): EventTypeDefinition | undefined { + return this.events.get(type); + } + + /** + * Get all registered event type definitions + */ + getAll(): EventTypeDefinition[] { + return Array.from(this.events.values()); + } + + /** + * Check if an event type is registered + */ + has(type: string): boolean { + return this.events.has(type); + } +} + +// Singleton instance +export const eventRegistry = new EventRegistry(); diff --git a/src/lib/events/types.ts b/src/lib/events/types.ts new file mode 100644 index 00000000..542fdf9e --- /dev/null +++ b/src/lib/events/types.ts @@ -0,0 +1,14 @@ +/** + * Event type definitions + * + * Re-exports types from centralized location for backwards compatibility. + * New code should import from '$lib/types' directly. + */ + +export type { + EventCategory, + EventParamType, + EventParamDefinition, + EventTypeDefinition, + EventInstance +} from '$lib/types/events'; diff --git a/src/lib/nodes/defineNode.ts b/src/lib/nodes/defineNode.ts new file mode 100644 index 00000000..092b91e0 --- /dev/null +++ b/src/lib/nodes/defineNode.ts @@ -0,0 +1,97 @@ +/** + * Node definition helper + * Factory function for creating node type definitions + */ + +import type { NodeTypeDefinition, NodeCategory, ParamType, ParamDefinition, NodeShape } from './types'; +import { PORT_COLORS } from '$lib/utils/colors'; + +interface DefineNodeOptions { + name: string; + category: NodeCategory; + description?: string; + blockClass: string; // PathSim class name + + // Port configuration + inputs?: string[]; // Named input ports + outputs?: string[]; // Named output ports + minInputs?: number; // minimum ports (default 1) + minOutputs?: number; + maxInputs?: number | null; // null = unlimited + maxOutputs?: number | null; + + // Shape override (defaults based on category) + shape?: NodeShape; + + // Parameters + params?: Record< + string, + { + type: ParamType; + default: unknown; + description?: string; + min?: number; + max?: number; + options?: string[]; + } + >; +} + +/** + * Create a node type definition + */ +export function defineNode(options: DefineNodeOptions): NodeTypeDefinition { + const { + name, + category, + description = `${name} block`, + blockClass, + inputs = ['in 0'], + outputs = ['out 0'], + minInputs = 1, + minOutputs = 1, + maxInputs = null, + maxOutputs = null, + shape, + params = {} + } = options; + + // Build parameter definitions + const paramDefs: ParamDefinition[] = Object.entries(params).map(([paramName, def]) => ({ + name: paramName, + type: def.type, + default: def.default, + description: def.description, + min: def.min, + max: def.max, + options: def.options + })); + + return { + type: blockClass, + name, + category, + description, + blockClass, + shape, + + ports: { + inputs: inputs.map((portName) => ({ + name: portName, + direction: 'input' as const, + color: PORT_COLORS.default + })), + outputs: outputs.map((portName) => ({ + name: portName, + direction: 'output' as const, + color: PORT_COLORS.default + })), + minInputs, + minOutputs, + maxInputs, + maxOutputs + }, + + params: paramDefs + }; +} diff --git a/src/lib/nodes/docstrings.ts b/src/lib/nodes/docstrings.ts new file mode 100644 index 00000000..7f6be443 --- /dev/null +++ b/src/lib/nodes/docstrings.ts @@ -0,0 +1,27 @@ +/** + * Block Docstrings + * + * Extracts docstrings from generated blocks.ts - no separate file needed. + */ + +import { extractedBlocks } from './generated/blocks'; + +/** + * Get docstring for a specific block type + */ +export function getDocstring(blockType: string): string | undefined { + return extractedBlocks[blockType]?.docstringHtml; +} + +/** + * Get all docstrings as a record + */ +export function getAllDocstrings(): Record { + const docstrings: Record = {}; + for (const [name, block] of Object.entries(extractedBlocks)) { + if (block.docstringHtml) { + docstrings[name] = block.docstringHtml; + } + } + return docstrings; +} diff --git a/src/lib/nodes/features/index.ts b/src/lib/nodes/features/index.ts new file mode 100644 index 00000000..f70128bd --- /dev/null +++ b/src/lib/nodes/features/index.ts @@ -0,0 +1,25 @@ +/** + * Block Features Module + * + * Provides metadata-driven feature definitions for blocks. + */ + +export type { + FeatureType, + FeatureDefinition, + PlotPreviewConfig, + PlotPreviewFeature, + DataDisplayConfig, + DataDisplayFeature, + BlockFeatures +} from './types'; + +export { isPlotPreviewFeature, isDataDisplayFeature } from './types'; + +export { + registerBlockFeatures, + getBlockFeatures, + hasFeature, + getFeature, + getAllBlockFeatures +} from './registry'; diff --git a/src/lib/nodes/features/registry.ts b/src/lib/nodes/features/registry.ts new file mode 100644 index 00000000..d587a8ba --- /dev/null +++ b/src/lib/nodes/features/registry.ts @@ -0,0 +1,67 @@ +/** + * Block Feature Registry + * + * Maps block types to their features. + * Features are registered here rather than hardcoded in components. + */ + +import type { FeatureDefinition, BlockFeatures, PlotPreviewFeature } from './types'; + +/** Registry of block features */ +const blockFeatures = new Map(); + +/** Register features for a block type */ +export function registerBlockFeatures(blockType: string, features: FeatureDefinition[]): void { + blockFeatures.set(blockType, features); +} + +/** Get features for a block type */ +export function getBlockFeatures(blockType: string): FeatureDefinition[] { + return blockFeatures.get(blockType) || []; +} + +/** Check if a block has a specific feature type */ +export function hasFeature(blockType: string, featureType: FeatureDefinition['type']): boolean { + const features = getBlockFeatures(blockType); + return features.some((f) => f.type === featureType); +} + +/** Get a specific feature from a block */ +export function getFeature( + blockType: string, + featureType: T['type'] +): T | undefined { + const features = getBlockFeatures(blockType); + return features.find((f) => f.type === featureType) as T | undefined; +} + +/** Get all registered block features */ +export function getAllBlockFeatures(): BlockFeatures[] { + return Array.from(blockFeatures.entries()).map(([blockType, features]) => ({ + blockType, + features + })); +} + +// Register built-in block features +// These replace the hardcoded checks in BaseNode.svelte + +registerBlockFeatures('Scope', [ + { + type: 'plot-preview', + config: { + plotType: 'time-series', + dataSource: 'scope' + } + } satisfies PlotPreviewFeature +]); + +registerBlockFeatures('Spectrum', [ + { + type: 'plot-preview', + config: { + plotType: 'spectrum', + dataSource: 'spectrum' + } + } satisfies PlotPreviewFeature +]); diff --git a/src/lib/nodes/features/types.ts b/src/lib/nodes/features/types.ts new file mode 100644 index 00000000..6e5d3f86 --- /dev/null +++ b/src/lib/nodes/features/types.ts @@ -0,0 +1,55 @@ +/** + * Block Feature Type Definitions + * + * Features are optional behaviors that blocks can have, + * defined declaratively in metadata rather than hardcoded. + */ + +/** Available feature types */ +export type FeatureType = 'plot-preview' | 'data-display' | 'configurable-size' | 'live-update'; + +/** Base feature definition */ +export interface FeatureDefinition { + type: FeatureType; + config?: Record; +} + +/** Plot preview feature configuration */ +export interface PlotPreviewConfig extends Record { + plotType: 'time-series' | 'spectrum' | 'xy'; + dataSource: 'scope' | 'spectrum'; +} + +/** Plot preview feature */ +export interface PlotPreviewFeature { + type: 'plot-preview'; + config: PlotPreviewConfig; +} + +/** Data display feature configuration */ +export interface DataDisplayConfig extends Record { + format: 'number' | 'array' | 'matrix'; + precision?: number; +} + +/** Data display feature */ +export interface DataDisplayFeature { + type: 'data-display'; + config: DataDisplayConfig; +} + +/** Block with features */ +export interface BlockFeatures { + blockType: string; + features: FeatureDefinition[]; +} + +/** Type guard for plot preview feature */ +export function isPlotPreviewFeature(feature: FeatureDefinition): feature is PlotPreviewFeature { + return feature.type === 'plot-preview'; +} + +/** Type guard for data display feature */ +export function isDataDisplayFeature(feature: FeatureDefinition): feature is DataDisplayFeature { + return feature.type === 'data-display'; +} diff --git a/src/lib/nodes/generated/blocks.ts b/src/lib/nodes/generated/blocks.ts new file mode 100644 index 00000000..d27340bd --- /dev/null +++ b/src/lib/nodes/generated/blocks.ts @@ -0,0 +1,1477 @@ +// Auto-generated by scripts/extract-blocks.py - DO NOT EDIT +// Re-run 'python scripts/extract-blocks.py' to update + +import type { NodeCategory } from '$lib/types'; + +export interface ExtractedParam { + type: string; + default: string | null; + description: string; + min?: number; + max?: number; + options?: string[]; +} + +export interface ExtractedBlock { + blockClass: string; + description: string; + docstringHtml: string; + params: Record; + inputs: string[]; + outputs: string[]; +} + +export interface UIOverride { + maxInputs?: number | null; + maxOutputs?: number | null; + defaultInputs?: string[]; + defaultOutputs?: string[]; + shape?: string; +} + +export const extractedBlocks: Record = +{ + "Constant": { + "blockClass": "Constant", + "description": "Produces a constant output signal (SISO)", + "docstringHtml": "

Produces a constant output signal (SISO)

\n
\n

Parameters

\n
\n
value : float
\n
constant defining block output
\n
\n
\n", + "params": { + "value": { + "type": "integer", + "default": "1", + "description": "constant defining block output" + } + }, + "inputs": [], + "outputs": [ + "out" + ] + }, + "Source": { + "blockClass": "Source", + "description": "Source that produces an arbitrary time dependent output,", + "docstringHtml": "

Source that produces an arbitrary time dependent output,\ndefined by the func (callable).

\n
\n\\begin{equation*}\ny(t) = \\mathrm{func}(t)\n\\end{equation*}\n
\n
\n

Note

\n

This block is purely algebraic and its internal function (func) will\nbe called multiple times per timestep, each time when Simulation._update(t)\nis called in the global simulation loop.

\n
\n
\n

Example

\n

For example a ramp:

\n
\nfrom pathsim.blocks import Source\n\nsrc = Source(lambda t : t)\n
\n

or a simple sinusoid with some frequency:

\n
\nimport numpy as np\nfrom pathsim.blocks import Source\n\n#some parameter\nomega = 100\n\n#the function that gets evaluated\ndef f(t):\n    return np.sin(omega * t)\n\nsrc = Source(f)\n
\n

Because the Source block only has a single argument, it can be\nused to decorate a function and make it a PathSim block. This might\nbe handy in some cases to keep definitions concise and localized\nin the code:

\n
\nimport numpy as np\nfrom pathsim.blocks import Source\n\n#does the same as the definition above\n\n@Source\ndef src(t):\n    omega = 100\n    return np.sin(omega * t)\n\n#'src' is now a PathSim block\n
\n
\n
\n

Parameters

\n
\n
func : callable
\n
function defining time dependent block output
\n
\n
\n", + "params": { + "func": { + "type": "callable", + "default": null, + "description": "function defining time dependent block output" + } + }, + "inputs": [], + "outputs": [ + "out" + ] + }, + "SinusoidalSource": { + "blockClass": "SinusoidalSource", + "description": "Source block that generates a sinusoid wave", + "docstringHtml": "

Source block that generates a sinusoid wave

\n
\n

Parameters

\n
\n
frequency : float
\n
frequency of the sinusoid
\n
amplitude : float
\n
amplitude of the sinusoid
\n
phase : float
\n
phase of the sinusoid
\n
\n
\n", + "params": { + "frequency": { + "type": "integer", + "default": "1", + "description": "frequency of the sinusoid" + }, + "amplitude": { + "type": "integer", + "default": "1", + "description": "amplitude of the sinusoid" + }, + "phase": { + "type": "integer", + "default": "0", + "description": "phase of the sinusoid" + } + }, + "inputs": [], + "outputs": [ + "out" + ] + }, + "StepSource": { + "blockClass": "StepSource", + "description": "Discrete time unit step source block.", + "docstringHtml": "

Discrete time unit step source block.

\n

Utilizes a scheduled event to set the block output\nto the specified output levels at the defined event times.

\n

The arguments can be vectorial and in that case, the output is set to the\namplitude that corresponds to the defined delay like a zero-order-hold stage.\nThis functionality enables adding external or time series measurement data\ninto the system.

\n
\n

Examples

\n

This is how to use the source as a unit step source:

\n
\nfrom pathsim.blocks import StepSource\n\n#default, starts at 0, jumps to 1\nstp = StepSource()\n
\n

And this is how to configure it with multiple consecutive steps:

\n
\nfrom pathsim.blocks import StepSource\n\n#starts at 0, jumps to 1 at 1, jumps to -1 at 2 and jumps back to 0 at 3\nstp = StepSource(amplitude=[1, -1, 0], tau=[1, 2, 3])\n
\n

Similarly implementing measured time series data via zoh:

\n
\nimport numpy as np\nfrom pathsim.blocks import StepSource\n\n#some random time series arrays\ntimes, data = np.linspace(0, 100, 1000), np.random.rand(1000)\n\n#pass them to the block\nstp = StepSource(amplitude=data, tau=times)\n
\n
\n
\n

Parameters

\n
\n
amplitude : float | list[float]
\n
amplitude of the step signal, or amplitudes / output\nlevels of the multiple steps
\n
tau : float | list[float]
\n
delay of the step, or delays of the different steps
\n
\n
\n
\n

Attributes

\n
\n
Evt : ScheduleList
\n
internal scheduled event directly accessible
\n
events : list[ScheduleList]
\n
list of interna events
\n
\n
\n", + "params": { + "amplitude": { + "type": "integer", + "default": "1", + "description": "amplitude of the step signal, or amplitudes / output levels of the multiple steps" + }, + "tau": { + "type": "number", + "default": "0.0", + "description": "delay of the step, or delays of the different steps" + } + }, + "inputs": [], + "outputs": [ + "out" + ] + }, + "PulseSource": { + "blockClass": "PulseSource", + "description": "Generates a periodic pulse waveform with defined rise and fall times", + "docstringHtml": "

Generates a periodic pulse waveform with defined rise and fall times\nusing a hybrid approach with scheduled events and continuous updates.

\n

Scheduled events trigger phase changes (low, rising, high, falling),\nand the update method calculates the output value based on the\ncurrent phase, performing linear interpolation during rise and fall.

\n
\n

Parameters

\n
\n
amplitude : float, optional
\n
Peak amplitude of the pulse. Default is 1.0.
\n
T : float, optional
\n
Period of the pulse train. Must be positive. Default is 1.0.
\n
t_rise : float, optional
\n
Duration of the rising edge. Default is 0.0.
\n
t_fall : float, optional
\n
Duration of the falling edge. Default is 0.0.
\n
tau : float, optional
\n
Initial delay before the first pulse cycle begins. Default is 0.0.
\n
duty : float, optional
\n
Duty cycle, ratio of the pulse ON duration (plateau time only)\nto the total period T (must be between 0 and 1). Default is 0.5.\nThe high plateau duration is T * duty.
\n
\n
\n
\n

Attributes

\n
\n
events : list[Schedule]
\n
Internal scheduled events triggering phase transitions.
\n
_phase : str
\n
Current phase of the pulse ('low', 'rising', 'high', 'falling').
\n
_phase_start_time : float
\n
Simulation time when the current phase began.
\n
\n
\n", + "params": { + "amplitude": { + "type": "number", + "default": "1.0", + "description": "Peak amplitude of the pulse. Default is 1.0." + }, + "T": { + "type": "number", + "default": "1.0", + "description": "Period of the pulse train. Must be positive. Default is 1.0." + }, + "t_rise": { + "type": "number", + "default": "0.0", + "description": "Duration of the rising edge. Default is 0.0." + }, + "t_fall": { + "type": "number", + "default": "0.0", + "description": "Duration of the falling edge. Default is 0.0." + }, + "tau": { + "type": "number", + "default": "0.0", + "description": "Initial delay before the first pulse cycle begins. Default is 0.0." + }, + "duty": { + "type": "number", + "default": "0.5", + "description": "Duty cycle, ratio of the pulse ON duration (plateau time only) to the total period T (must be between 0 and 1). Default is 0.5. The high plateau duration is `T * duty`." + } + }, + "inputs": [], + "outputs": [ + "out" + ] + }, + "TriangleWaveSource": { + "blockClass": "TriangleWaveSource", + "description": "Source block that generates an analog triangle wave", + "docstringHtml": "

Source block that generates an analog triangle wave

\n
\n

Parameters

\n
\n
frequency : float
\n
frequency of the triangle wave
\n
amplitude : float
\n
amplitude of the triangle wave
\n
phase : float
\n
phase of the triangle wave
\n
\n
\n", + "params": { + "frequency": { + "type": "integer", + "default": "1", + "description": "frequency of the triangle wave" + }, + "amplitude": { + "type": "integer", + "default": "1", + "description": "amplitude of the triangle wave" + }, + "phase": { + "type": "integer", + "default": "0", + "description": "phase of the triangle wave" + } + }, + "inputs": [], + "outputs": [ + "out" + ] + }, + "SquareWaveSource": { + "blockClass": "SquareWaveSource", + "description": "Discrete time square wave source.", + "docstringHtml": "

Discrete time square wave source.

\n

Utilizes scheduled events to periodically set\nthe block output at discrete times.

\n
\n

Parameters

\n
\n
amplitude : float
\n
amplitude of the square wave signal
\n
frequency : float
\n
frequency of the square wave signal
\n
phase : float
\n
phase of the square wave signal
\n
\n
\n
\n

Attributes

\n
\n
events : list[Schedule]
\n
internal scheduled events
\n
\n
\n", + "params": { + "amplitude": { + "type": "integer", + "default": "1", + "description": "amplitude of the square wave signal" + }, + "frequency": { + "type": "integer", + "default": "1", + "description": "frequency of the square wave signal" + }, + "phase": { + "type": "integer", + "default": "0", + "description": "phase of the square wave signal" + } + }, + "inputs": [], + "outputs": [ + "out" + ] + }, + "GaussianPulseSource": { + "blockClass": "GaussianPulseSource", + "description": "Source block that generates a gaussian pulse", + "docstringHtml": "

Source block that generates a gaussian pulse

\n
\n

Parameters

\n
\n
amplitude : float
\n
amplitude of the gaussian pulse
\n
f_max : float
\n
maximum frequency component of the gaussian pulse (steepness)
\n
tau : float
\n
time delay of the gaussian pulse
\n
\n
\n", + "params": { + "amplitude": { + "type": "integer", + "default": "1", + "description": "amplitude of the gaussian pulse" + }, + "f_max": { + "type": "number", + "default": "1000.0", + "description": "maximum frequency component of the gaussian pulse (steepness)" + }, + "tau": { + "type": "number", + "default": "0.0", + "description": "time delay of the gaussian pulse" + } + }, + "inputs": [], + "outputs": [ + "out" + ] + }, + "ChirpPhaseNoiseSource": { + "blockClass": "ChirpPhaseNoiseSource", + "description": "Chirp source, sinusoid with frequency ramp up and ramp down, plus phase noise.", + "docstringHtml": "

Chirp source, sinusoid with frequency ramp up and ramp down, plus phase noise.

\n

This works by using a time dependent triangle wave for the frequency\nand integrating it with a numerical integration engine to get a\ncontinuous phase. This phase is then used to evaluate a sinusoid.

\n

Additionally the chirp source can have white and cumulative phase noise.\nMathematically it looks like this for the contributions to the phase from\nthe triangular wave:

\n
\n\\begin{equation*}\n\\varphi_t(t) = \\int_0^t \\mathrm{tri}_{f_0, B, T}(\\tau) \\, d\\tau\n\\end{equation*}\n
\n

And from the white (w) and cumulative (c) noise:

\n
\n\\begin{equation*}\n\\varphi_n(t) = \\sigma_w \\, n_w(t) + \\sigma_c \\int_0^t n_c(\\tau) \\, d\\tau\n\\end{equation*}\n
\n

The phase contributions are then used to evaluate a sinusoid to get the final chirp signal:

\n
\n\\begin{equation*}\ny(t) = A \\sin(\\varphi_t(t) + \\varphi_n(t) + \\varphi_0)\n\\end{equation*}\n
\n
\n

Parameters

\n
\n
amplitude : float
\n
amplitude of the chirp signal
\n
f0 : float
\n
start frequency of the chirp signal
\n
BW : float
\n
bandwidth of the frequency ramp of the chirp signal
\n
T : float
\n
period of the frequency ramp of the chirp signal
\n
phase : float
\n
phase of sinusoid (initial, radians)
\n
sig_cum : float
\n
weight for cumulative phase noise contribution
\n
sig_white : float
\n
weight for white phase noise contribution
\n
sampling_rate : float, None
\n
frequency with which phase noise is sampled (Hz). If None,\nnoise is sampled every timestep (default is 10 Hz)
\n
\n
\n
\n

Attributes

\n
\n
noise_1 : float
\n
internal noise value for white phase noise
\n
noise_2 : float
\n
internal noise value for cumulative phase noise
\n
events : list[Schedule]
\n
scheduled event for periodic sampling (only if sampling_rate is set)
\n
\n
\n", + "params": { + "amplitude": { + "type": "integer", + "default": "1", + "description": "amplitude of the chirp signal" + }, + "f0": { + "type": "integer", + "default": "1", + "description": "start frequency of the chirp signal" + }, + "BW": { + "type": "integer", + "default": "1", + "description": "bandwidth of the frequency ramp of the chirp signal" + }, + "T": { + "type": "integer", + "default": "1", + "description": "period of the frequency ramp of the chirp signal" + }, + "phase": { + "type": "integer", + "default": "0", + "description": "phase of sinusoid (initial, radians)" + }, + "sig_cum": { + "type": "integer", + "default": "0", + "description": "weight for cumulative phase noise contribution" + }, + "sig_white": { + "type": "integer", + "default": "0", + "description": "weight for white phase noise contribution" + }, + "sampling_rate": { + "type": "integer", + "default": "10", + "description": "frequency with which phase noise is sampled (Hz). If None, noise is sampled every timestep (default is 10 Hz)" + } + }, + "inputs": [], + "outputs": [ + "out" + ] + }, + "ClockSource": { + "blockClass": "ClockSource", + "description": "Discrete time clock source block.", + "docstringHtml": "

Discrete time clock source block.

\n

Utilizes scheduled events to periodically set\nthe block output to 0 or 1 at discrete times.

\n
\n

Parameters

\n
\n
T : float
\n
period of the clock
\n
tau : float
\n
clock delay
\n
\n
\n
\n

Attributes

\n
\n
events : list[Schedule]
\n
internal scheduled event list
\n
\n
\n", + "params": { + "T": { + "type": "integer", + "default": "1", + "description": "period of the clock" + }, + "tau": { + "type": "integer", + "default": "0", + "description": "clock delay" + } + }, + "inputs": [], + "outputs": [ + "out" + ] + }, + "WhiteNoise": { + "blockClass": "WhiteNoise", + "description": "White noise source with uniform spectral density.", + "docstringHtml": "

White noise source with uniform spectral density. Samples from\ndistribution with 'sampling_rate' and holds noise values constant\nfor time bins (zero-order-hold).

\n

If no 'sampling_rate' (None) is specified, every simulation timestep\ngets a new noise value. This is the default setting.

\n
\n

Parameters

\n
\n
spectral_density : float
\n
noise spectral density
\n
noise : float
\n
internal noise value
\n
sampling_rate : float, None
\n
frequency with which the noise is sampled
\n
\n
\n
\n

Attributes

\n
\n
events : list[Schedule]
\n
scheduled event for periodic sampling
\n
\n
\n", + "params": { + "spectral_density": { + "type": "integer", + "default": "1", + "description": "noise spectral density" + }, + "sampling_rate": { + "type": "any", + "default": null, + "description": "frequency with which the noise is sampled" + } + }, + "inputs": [], + "outputs": [ + "out" + ] + }, + "PinkNoise": { + "blockClass": "PinkNoise", + "description": "Pink noise (1/f) source using the Voss-McCartney algorithm.", + "docstringHtml": "

Pink noise (1/f) source using the Voss-McCartney algorithm.

\n

Generates noise with power spectral density inversely proportional to\nfrequency. Samples from distribution with 'sampling_rate' and holds\nnoise values constant for time bins (zero-order-hold).

\n

The Voss-McCartney algorithm maintains num_octaves independent\nrandom values. At each sample n, octaves are selectively updated based\non the binary representation of n:

\n
    \n
  • Octave 0: updated every sample (when n & 1 == 1)
  • \n
  • Octave 1: updated every 2nd sample (when n & 2 == 2)
  • \n
  • Octave 2: updated every 4th sample (when n & 4 == 4)
  • \n
  • Octave k: updated every \\(2^k\\) samples
  • \n
\n

The pink noise output is the sum of all octaves, scaled to achieve the\ndesired spectral density:

\n
\n\\begin{equation*}\ny[n] = \\sqrt{\\frac{S_0}{N \\cdot dt}} \\sum_{k=0}^{N-1} x_k[n]\n\\end{equation*}\n
\n

where \\(S_0\\) is the spectral density, \\(N\\) is num_octaves,\n\\(dt\\) is the sampling timestep, and \\(x_k[n]\\) are the octave\nvalues (each drawn from \\(\\mathcal{N}(0, 1)\\) when updated).

\n
\n

Note

\n

If no 'sampling_rate' (None) is specified, every simulation timestep\ngets a new noise value. This is the default setting.

\n
\n
\n

Parameters

\n
\n
spectral_density : float
\n
noise spectral density \\(S_0\\)
\n
num_octaves : int
\n
number of octaves (levels of randomness), default is 16
\n
sampling_rate : float, None
\n
frequency with which the noise is sampled
\n
\n
\n
\n

Attributes

\n
\n
n_samples : int
\n
internal sample counter
\n
octave_values : array[float]
\n
internal random numbers for octaves in the Voss-McCartney algorithm
\n
events : list[Schedule]
\n
scheduled event for periodic sampling
\n
\n
\n
\n

References

\n\n\n\n\n\n
[1]Voss, R. F., & Clarke, J. (1978). "1/f noise" in music: Music from\n1/f noise. The Journal of the Acoustical Society of America, 63(1),\n258-263.
\n
\n", + "params": { + "spectral_density": { + "type": "integer", + "default": "1", + "description": "noise spectral density :math:`S_0`" + }, + "num_octaves": { + "type": "integer", + "default": "16", + "description": "number of octaves (levels of randomness), default is 16" + }, + "sampling_rate": { + "type": "any", + "default": null, + "description": "frequency with which the noise is sampled" + } + }, + "inputs": [], + "outputs": [ + "out" + ] + }, + "RandomNumberGenerator": { + "blockClass": "RandomNumberGenerator", + "description": "Generates a random output value using `numpy.random.rand`.", + "docstringHtml": "

Generates a random output value using numpy.random.rand.

\n

If no sampling_rate (None) is specified, every simulation timestep gets\na random value. Otherwise an internal Schedule event is used to periodically\nsample a random value and set the output like a sero-order-hold stage.

\n
\n

Parameters

\n
\n
sampling_rate : float, None
\n
number of random samples per time unit
\n
\n
\n
\n

Attributes

\n
\n
_sample : float
\n
internal random number state in case that\nno samplingrate is provided
\n
Evt : Schedule
\n
internal event that periodically samples a random\nvalue in case samplingrate is provided
\n
\n
\n", + "params": { + "sampling_rate": { + "type": "any", + "default": null, + "description": "number of random samples per time unit" + } + }, + "inputs": [], + "outputs": [ + "out" + ] + }, + "Integrator": { + "blockClass": "Integrator", + "description": "Integrates the input signal using a numerical integration engine like this:", + "docstringHtml": "

Integrates the input signal using a numerical integration engine like this:

\n
\n\\begin{equation*}\ny(t) = \\int_0^t u(\\tau) \\ d \\tau\n\\end{equation*}\n
\n

or in differential form like this:

\n
\n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x}(t) &= u(t) \\\\\n y(t) &= x(t)\n\\end{eqnarray}\n\\end{equation*}\n
\n

The Integrator block is inherently MIMO capable, so u and y can be vectors.

\n
\n

Example

\n

This is how to initialize the integrator:

\n
\n#initial value 0.0\ni1 = Integrator()\n\n#initial value 2.5\ni2 = Integrator(2.5)\n
\n
\n
\n

Parameters

\n
\n
initial_value : float, array
\n
initial value of integrator
\n
\n
\n", + "params": { + "initial_value": { + "type": "number", + "default": "0.0", + "description": "initial value of integrator" + } + }, + "inputs": [], + "outputs": [] + }, + "Differentiator": { + "blockClass": "Differentiator", + "description": "Differentiates the input signal (SISO) using a first order transfer function", + "docstringHtml": "

Differentiates the input signal (SISO) using a first order transfer function\nwith a pole at the origin which implements a high pass filter.

\n
\n\\begin{equation*}\nH_\\mathrm{diff}(s) = \\frac{s}{1 + s / f_\\mathrm{max}}\n\\end{equation*}\n
\n

The approximation holds for signals up to a frequency of approximately f_max.

\n
\n

Note

\n

Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.

\n
\n
\n

Note

\n

Since this is an approximation of real differentiation, the approximation will not hold\nif there are high frequency components present in the signal. For example if you have\ndiscontinuities such as steps or squere waves.

\n
\n
\n

Example

\n

The block is initialized like this:

\n
\n#cutoff at 1kHz\nD = Differentiator(f_max=1e3)\n
\n
\n
\n

Parameters

\n
\n
f_max : float
\n
highest expected signal frequency
\n
\n
\n
\n

Attributes

\n
\n
op_dyn : DynamicOperator
\n
internal dynamic operator for ODE component
\n
op_alg : DynamicOperator
\n
internal algebraic operator
\n
\n
\n", + "params": { + "f_max": { + "type": "number", + "default": "100.0", + "description": "highest expected signal frequency" + } + }, + "inputs": [], + "outputs": [] + }, + "Delay": { + "blockClass": "Delay", + "description": "Delays the input signal by a time constant 'tau' in seconds", + "docstringHtml": "

Delays the input signal by a time constant 'tau' in seconds\nusing an adaptive rolling buffer.

\n

Mathematically this block creates a time delay of the input signal like this:

\n
\n\\begin{equation*}\ny(t) =\n\\begin{cases}\nx(t - \\tau) & , t \\geq \\tau \\\\\n0 & , t < \\tau\n\\end{cases}\n\\end{equation*}\n
\n
\n

Note

\n

The internal adaptive buffer uses interpolation for the evaluation. This is\nrequired to be compatible with variable step solvers. It has a drawback however.\nThe order of the ode solver used will degrade when this block is used, due to\nthe interpolation.

\n
\n
\n

Example

\n

The block is initialized like this:

\n
\n#5 time units delay\nD = Delay(tau=5)\n
\n
\n
\n

Parameters

\n
\n
tau : float
\n
delay time constant
\n
\n
\n
\n

Attributes

\n
\n
_buffer : AdaptiveBuffer
\n
internal interpolatable adaptive rolling buffer
\n
\n
\n", + "params": { + "tau": { + "type": "number", + "default": "0.001", + "description": "delay time constant" + } + }, + "inputs": [], + "outputs": [] + }, + "ODE": { + "blockClass": "ODE", + "description": "This block implements an ordinary differential equation (ODE)", + "docstringHtml": "

This block implements an ordinary differential equation (ODE)\ndefined by its right hand side

\n
\n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x}(t) =& \\mathrm{func}(x(t), u(t), t) \\\\\n y(t) =& x(t)\n\\end{eqnarray}\n\\end{equation*}\n
\n

with inhomogenity (input) u and state vector x. The function\ncan be nonlinear and the ODE can be of arbitrary order.\nThe block utilizes the integration engine to solve the ODE\nby integrating the func, which is the right hand side function.

\n
\n

Example

\n

For example a linear 1st order ODE:

\n
\node = ODE(lambda x, u, t: -x)\n
\n

Or something more complex like the Van der Pol system, where it makes\nsense to also specify the jacobian, which improves convergence for\nimplicit solvers but is not needed in most cases:

\n
\nimport numpy as np\n\n#initial condition\nx0 = np.array([2, 0])\n\n#van der Pol parameter\nmu = 1000\n\ndef func(x, u, t):\n    return np.array([x[1], mu*(1 - x[0]**2)*x[1] - x[0]])\n\n#analytical jacobian (optional)\ndef jac(x, u, t):\n    return np.array(\n        [[0                , 1               ],\n         [-mu*2*x[0]*x[1]-1, mu*(1 - x[0]**2)]]\n         )\n\n#finally the block\nvdp = ODE(func, x0, jac)\n
\n
\n
\n

Parameters

\n
\n
func : callable
\n
right hand side function of ODE
\n
initial_value : array[float]
\n
initial state / initial condition
\n
jac : callable, None
\n
jacobian of 'func' or 'None'
\n
\n
\n
\n

Attributes

\n
\n
op_dyn : DynamicOperator
\n
internal dynamic operator for ODE right hand side 'func'
\n
\n
\n", + "params": { + "func": { + "type": "callable", + "default": null, + "description": "right hand side function of ODE" + }, + "initial_value": { + "type": "number", + "default": "0.0", + "description": "initial state / initial condition" + }, + "jac": { + "type": "any", + "default": null, + "description": "jacobian of 'func' or 'None'" + } + }, + "inputs": [], + "outputs": [] + }, + "DynamicalSystem": { + "blockClass": "DynamicalSystem", + "description": "This block implements a nonlinear dynamical system / nonlinear state space model.", + "docstringHtml": "

This block implements a nonlinear dynamical system / nonlinear state space model.

\n

Its basically the same as the ODE block with the addition of an output equation\nthat takes the state, input and time as arguments:

\n
\n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x}(t) =& \\mathrm{func}_\\mathrm{dyn}(x(t), u(t), t) \\\\\n y(t) =& \\mathrm{func}_\\mathrm{alg}(x(t), u(t), t)\n\\end{eqnarray}\n\\end{equation*}\n
\n
\n

Parameters

\n
\n
func_dyn : callable
\n
right hand side function of ode-part of the system
\n
func_alg : callable
\n
output function of the system
\n
initial_value : array[float]
\n
initial state / initial condition
\n
jac_dyn : callable | None
\n
optional jacobian of func_dyn to improve convergence\nfor implicit ode solvers
\n
\n
\n
\n

Attributes

\n
\n
op_dyn : DynamicOperator
\n
internal dynamic operator for func_dyn
\n
op_alg : DynamicOperator
\n
internal dynamic operator for func_alg
\n
\n
\n", + "params": { + "func_dyn": { + "type": "callable", + "default": null, + "description": "right hand side function of ode-part of the system" + }, + "func_alg": { + "type": "callable", + "default": null, + "description": "output function of the system" + }, + "initial_value": { + "type": "number", + "default": "0.0", + "description": "initial state / initial condition" + }, + "jac_dyn": { + "type": "any", + "default": null, + "description": "optional jacobian of `func_dyn` to improve convergence for implicit ode solvers" + } + }, + "inputs": [], + "outputs": [] + }, + "StateSpace": { + "blockClass": "StateSpace", + "description": "This block defines a linear time invariant (LTI) multi input multi output (MIMO)", + "docstringHtml": "

This block defines a linear time invariant (LTI) multi input multi output (MIMO)\nstate space model with the structure

\n
\n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x} &= \\mathbf{A} x + \\mathbf{B} u \\\\\n y &= \\mathbf{C} x + \\mathbf{D} u\n\\end{eqnarray}\n\\end{equation*}\n
\n

where A, B, C and D are the state space matrices, x is the state,\nu the input and y the output vector.

\n
\n

Example

\n

A SISO state space block with two internal states can be initialized\nlike this:

\n
\nS = StateSpace(\n    A=-np.eye(2),\n    B=np.ones((2, 1)),\n    C=np.ones((1, 2)),\n    D=1.0\n    )\n
\n

and a MIMO (2 in, 2 out) state space block with three internal states\ncan be initialized like this:

\n
\nS = StateSpace(\n    A=-np.eye(3),\n    B=np.ones((3, 2)),\n    C=np.ones((2, 3)),\n    D=np.ones((2, 2))\n    )\n
\n
\n
\n

Parameters

\n
\n
A, B, C, D : array_like
\n
real valued state space matrices
\n
initial_value : array_like, None
\n
initial state / initial condition
\n
\n
\n
\n

Attributes

\n
\n
op_dyn : DynamicOperator
\n
internal dynamic operator for state equation
\n
op_alg : DynamicOperator
\n
internal algebraic operator for mapping to outputs
\n
\n
\n", + "params": { + "A": { + "type": "number", + "default": "-1.0", + "description": "" + }, + "B": { + "type": "number", + "default": "1.0", + "description": "" + }, + "C": { + "type": "number", + "default": "-1.0", + "description": "" + }, + "D": { + "type": "number", + "default": "1.0", + "description": "real valued state space matrices" + }, + "initial_value": { + "type": "any", + "default": null, + "description": "initial state / initial condition" + } + }, + "inputs": [], + "outputs": [] + }, + "PID": { + "blockClass": "PID", + "description": "Proportional-Integral-Differntiation (PID) controller.", + "docstringHtml": "

Proportional-Integral-Differntiation (PID) controller.

\n

The transfer function is defined as

\n
\n\\begin{equation*}\nH_\\mathrm{diff}(s) = K_p + K_i \\frac{1}{s} + K_d \\frac{s}{1 + s / f_\\mathrm{max}}\n\\end{equation*}\n
\n

where the differentiation is approximated by a high pass filter that holds\nfor signals up to a frequency of approximately f_max.

\n
\n

Note

\n

Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.\nSince this block uses an approximation of real differentiation, the approximation will\nnot hold if there are high frequency components present in the signal. For example if\nyou have discontinuities such as steps or square waves.

\n
\n
\n

Example

\n

The block is initialized like this:

\n
\n#cutoff at 1kHz\npid = PID(Kp=2, Ki=0.5, Kd=0.1, f_max=1e3)\n
\n
\n
\n

Parameters

\n
\n
Kp : float
\n
poroportional controller coefficient
\n
Ki : float
\n
integral controller coefficient
\n
Kd : float
\n
differentiator controller coefficient
\n
f_max : float
\n
highest expected signal frequency
\n
\n
\n
\n

Attributes

\n
\n
op_dyn : DynamicOperator
\n
internal dynamic operator for ODE component
\n
op_alg : DynamicOperator
\n
internal algebraic operator
\n
\n
\n", + "params": { + "Kp": { + "type": "integer", + "default": "0", + "description": "poroportional controller coefficient" + }, + "Ki": { + "type": "integer", + "default": "0", + "description": "integral controller coefficient" + }, + "Kd": { + "type": "integer", + "default": "0", + "description": "differentiator controller coefficient" + }, + "f_max": { + "type": "integer", + "default": "100", + "description": "highest expected signal frequency" + } + }, + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] + }, + "AntiWindupPID": { + "blockClass": "AntiWindupPID", + "description": "Proportional-Integral-Differntiation (PID) controller with tracking", + "docstringHtml": "

Proportional-Integral-Differntiation (PID) controller with tracking\nanti-windup mechanism (back-calculation).

\n

Anti-windup mechanisms are needed when the magnitude of the control signal\nfrom the PID controller is limited by some real world saturation. In these cases,\nthe integrator will continue to acumulate the control error and "wind itself up".\nOnce the setpoint is reached, this can result in significant overshoots. This\nimplementation adds a conditional feedback term to the internal integrator that\n"unwinds" it when the PID output crosses some limits. This is pretty much a\ndeadzone feedback element for the integrator.

\n

Mathematically, this block implements the following set of ODEs

\n
\n\\begin{equation*}\n\\begin{eqnarray}\n\\dot{x}_1 =& f_\\mathrm{max} (u - x_1) \\\\\n\\dot{x}_2 =& u - w \\\\\n\\end{eqnarray}\n\\end{equation*}\n
\n

with the anti-windup feedback (depending on the pid output)

\n
\n\\begin{equation*}\nw = K_s (y - \\min(\\max(y, y_\\mathrm{min}), y_\\mathrm{max}))\n\\end{equation*}\n
\n

and the output itself

\n
\n\\begin{equation*}\ny = K_p u - K_d f_\\mathrm{max} x_1 + K_i x_2\n\\end{equation*}\n
\n
\n

Note

\n

Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.\nSince this block uses an approximation of real differentiation, the approximation will\nnot hold if there are high frequency components present in the signal. For example if\nyou have discontinuities such as steps or squere waves.

\n
\n
\n

Example

\n

The block is initialized like this:

\n
\n#cutoff at 1kHz, windup limits at [-5, 5]\npid = AntiWindupPID(Kp=2, Ki=0.5, Kd=0.1, f_max=1e3, limits=[-5, 5])\n
\n
\n
\n

Parameters

\n
\n
Kp : float
\n
poroportional controller coefficient
\n
Ki : float
\n
integral controller coefficient
\n
Kd : float
\n
differentiator controller coefficient
\n
f_max : float
\n
highest expected signal frequency
\n
Ks : float
\n
feedback term for back calculation for anti-windup control of integrator
\n
limits : array_like[float]
\n
lower and upper limit for PID output that triggers anti-windup of integrator
\n
\n
\n
\n

Attributes

\n
\n
op_dyn : DynamicOperator
\n
internal dynamic operator for ODE component
\n
op_alg : DynamicOperator
\n
internal algebraic operator
\n
\n
\n", + "params": { + "Kp": { + "type": "integer", + "default": "0", + "description": "poroportional controller coefficient" + }, + "Ki": { + "type": "integer", + "default": "0", + "description": "integral controller coefficient" + }, + "Kd": { + "type": "integer", + "default": "0", + "description": "differentiator controller coefficient" + }, + "f_max": { + "type": "integer", + "default": "100", + "description": "highest expected signal frequency" + }, + "Ks": { + "type": "integer", + "default": "10", + "description": "feedback term for back calculation for anti-windup control of integrator" + }, + "limits": { + "type": "array", + "default": "[-10, 10]", + "description": "lower and upper limit for PID output that triggers anti-windup of integrator" + } + }, + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] + }, + "TransferFunctionNumDen": { + "blockClass": "TransferFunctionNumDen", + "description": "This block defines a LTI (SISO) transfer function.", + "docstringHtml": "

This block defines a LTI (SISO) transfer function.

\n

The transfer function is defined in polynomial (numerator-denominator) form

\n
\n\\begin{equation*}\n\\mathbf{H}(s) = \\frac{b_n + b_{n-1} s + \\dots + b_{0} s^n}{a_m + a_{m-1} s + \\dots + a_{0} s^m}\n\\end{equation*}\n
\n

where Num is the list of numerator polynomial coefficients and Den the\nlist of denominator coefficients.

\n

Upon initialization, the state space realization of the transfer function is\ncomputed using scipy.signal.TransferFunction(Num, Den).to_ss().

\n

The resulting state space model of the form

\n
\n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x} &= \\mathbf{A} x + \\mathbf{B} u \\\\\n y &= \\mathbf{C} x + \\mathbf{D} u\n\\end{eqnarray}\n\\end{equation*}\n
\n

is handled the same as the 'StateSpace' block, where A, B, C and D\nare the state space matrices, x is the internal state, u the input and\ny the output vector.

\n
\n

Parameters

\n
\n
Num : array_like
\n
numerator polynomial coefficients
\n
Den : array_like
\n
denominator polynomial coefficients
\n
\n
\n", + "params": { + "Num": { + "type": "array", + "default": "[1]", + "description": "numerator polynomial coefficients" + }, + "Den": { + "type": "array", + "default": "[1, 1]", + "description": "denominator polynomial coefficients" + } + }, + "inputs": [], + "outputs": [] + }, + "TransferFunctionZPG": { + "blockClass": "TransferFunctionZPG", + "description": "This block defines a LTI (SISO) transfer function.", + "docstringHtml": "

This block defines a LTI (SISO) transfer function.

\n

The transfer function is defined in zeros-poles-gain (ZPG) form

\n
\n\\begin{equation*}\n\\mathbf{H}(s) = k \\frac{(s - z_1)(s - z_2)\\cdots(s - z_m)}{(s - p_1)(s - p_2)\\cdots(s - p_n)}\n\\end{equation*}\n
\n

where Zeros are the scalar (possibly complex conjugate) zeros of the\ntransfer function, and Poles are the poles (denominator zeros) of the\ntransfer function. Gain is the scalar factor k.

\n

Upon initialization, the state space realization of the transfer function is\ncomputed using scipy.signal.ZerosPolesGain(Zeros, Poles, Gain).to_ss().

\n

The resulting state space model of the form

\n
\n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x} &= \\mathbf{A} x + \\mathbf{B} u \\\\\n y &= \\mathbf{C} x + \\mathbf{D} u\n\\end{eqnarray}\n\\end{equation*}\n
\n

is handled the same as the 'StateSpace' block, where A, B, C and D\nare the state space matrices, x is the internal state, u the input and\ny the output vector.

\n
\n

Parameters

\n
\n
Poles : array_like
\n
transfer function poles
\n
Zeros : array_like
\n
transfer function zeros
\n
Gain : float
\n
gain term of transfer function
\n
\n
\n", + "params": { + "Zeros": { + "type": "array", + "default": "[]", + "description": "transfer function zeros" + }, + "Poles": { + "type": "array", + "default": "[-1]", + "description": "transfer function poles" + }, + "Gain": { + "type": "number", + "default": "1.0", + "description": "gain term of transfer function" + } + }, + "inputs": [], + "outputs": [] + }, + "ButterworthLowpassFilter": { + "blockClass": "ButterworthLowpassFilter", + "description": "Direct implementation of a low pass butterworth filter block.", + "docstringHtml": "

Direct implementation of a low pass butterworth filter block.

\n

Follows the same structure as the 'StateSpace' block in the\n'pathsim.blocks' module. The numerator and denominator of the\nfilter transfer function are generated and then the transfer\nfunction is realized as a state space model.

\n
\n

Parameters

\n
\n
Fc : float
\n
corner frequency of the filter in [Hz]
\n
n : int
\n
filter order
\n
\n
\n", + "params": { + "Fc": { + "type": "integer", + "default": "100", + "description": "corner frequency of the filter in [Hz]" + }, + "n": { + "type": "integer", + "default": "2", + "description": "filter order" + } + }, + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] + }, + "ButterworthHighpassFilter": { + "blockClass": "ButterworthHighpassFilter", + "description": "Direct implementation of a high pass butterworth filter block.", + "docstringHtml": "

Direct implementation of a high pass butterworth filter block.

\n

Follows the same structure as the 'StateSpace' block in the\n'pathsim.blocks' module. The numerator and denominator of the\nfilter transfer function are generated and then the transfer\nfunction is realized as a state space model.

\n
\n

Parameters

\n
\n
Fc : float
\n
corner frequency of the filter in [Hz]
\n
n : int
\n
filter order
\n
\n
\n", + "params": { + "Fc": { + "type": "integer", + "default": "100", + "description": "corner frequency of the filter in [Hz]" + }, + "n": { + "type": "integer", + "default": "2", + "description": "filter order" + } + }, + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] + }, + "ButterworthBandpassFilter": { + "blockClass": "ButterworthBandpassFilter", + "description": "Direct implementation of a bandpass butterworth filter block.", + "docstringHtml": "

Direct implementation of a bandpass butterworth filter block.

\n

Follows the same structure as the 'StateSpace' block in the\n'pathsim.blocks' module. The numerator and denominator of the\nfilter transfer function are generated and then the transfer\nfunction is realized as a state space model.

\n
\n

Parameters

\n
\n
Fc : list[float]
\n
corner frequencies (left, right) of the filter in [Hz]
\n
n : int
\n
filter order
\n
\n
\n", + "params": { + "Fc": { + "type": "array", + "default": "[50, 100]", + "description": "corner frequencies (left, right) of the filter in [Hz]" + }, + "n": { + "type": "integer", + "default": "2", + "description": "filter order" + } + }, + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] + }, + "ButterworthBandstopFilter": { + "blockClass": "ButterworthBandstopFilter", + "description": "Direct implementation of a bandstop butterworth filter block.", + "docstringHtml": "

Direct implementation of a bandstop butterworth filter block.

\n

Follows the same structure as the 'StateSpace' block in the\n'pathsim.blocks' module. The numerator and denominator of the\nfilter transfer function are generated and then the transfer\nfunction is realized as a state space model.

\n
\n

Parameters

\n
\n
Fc : tuple[float], list[float]
\n
corner frequencies (left, right) of the filter in [Hz]
\n
n : int
\n
filter order
\n
\n
\n", + "params": { + "Fc": { + "type": "array", + "default": "[50, 100]", + "description": "corner frequencies (left, right) of the filter in [Hz]" + }, + "n": { + "type": "integer", + "default": "2", + "description": "filter order" + } + }, + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] + }, + "Adder": { + "blockClass": "Adder", + "description": "Summs / adds up all input signals to a single output signal (MISO)", + "docstringHtml": "

Summs / adds up all input signals to a single output signal (MISO)

\n

This is how it works in the default case

\n
\n\\begin{equation*}\ny(t) = \\sum_i u_i(t)\n\\end{equation*}\n
\n

and like this when additional operations are defined

\n
\n\\begin{equation*}\ny(t) = \\sum_i \\mathrm{op}_i \\cdot u_i(t)\n\\end{equation*}\n
\n
\n

Example

\n

This is the default initialization that just adds up all the inputs:

\n
\nA = Adder()\n
\n

and this is the initialization with specific operations that subtracts\nthe second from first input and neglects all others:

\n
\nA = Adder('+-')\n
\n
\n
\n

Note

\n

This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.

\n
\n
\n

Parameters

\n
\n
operations : str, optional
\n
optional string of operations to be applied before\nsummation, i.e. '+-' will compute the difference,\n'None' will just perform regular sum
\n
\n
\n
\n

Attributes

\n
\n
_ops : dict
\n
dict that maps string operations to numerical
\n
_ops_array : array_like
\n
operations converted to array
\n
op_alg : Operator
\n
internal algebraic operator
\n
\n
\n", + "params": { + "operations": { + "type": "any", + "default": null, + "description": "optional string of operations to be applied before summation, i.e. '+-' will compute the difference, 'None' will just perform regular sum" + } + }, + "inputs": [], + "outputs": [ + "out" + ] + }, + "Multiplier": { + "blockClass": "Multiplier", + "description": "Multiplies all signals from all input ports (MISO).", + "docstringHtml": "

Multiplies all signals from all input ports (MISO).

\n
\n\\begin{equation*}\ny(t) = \\prod_i u_i(t)\n\\end{equation*}\n
\n
\n

Note

\n

This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.

\n
\n
\n

Attributes

\n
\n
op_alg : Operator
\n
internal algebraic operator that wraps 'prod'
\n
\n
\n", + "params": {}, + "inputs": [], + "outputs": [ + "out" + ] + }, + "Amplifier": { + "blockClass": "Amplifier", + "description": "Amplifies the input signal by", + "docstringHtml": "

Amplifies the input signal by\nmultiplication with a constant gain term like this:

\n
\n\\begin{equation*}\ny(t) = \\mathrm{gain} \\cdot u(t)\n\\end{equation*}\n
\n
\n

Note

\n

This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.

\n
\n
\n

Example

\n

The block is initialized like this:

\n
\n#amplification by factor 5\nA = Amplifier(gain=5)\n
\n
\n
\n

Parameters

\n
\n
gain : float
\n
amplifier gain
\n
\n
\n
\n

Attributes

\n
\n
op_alg : Operator
\n
internal algebraic operator
\n
\n
\n", + "params": { + "gain": { + "type": "number", + "default": "1.0", + "description": "amplifier gain" + } + }, + "inputs": [], + "outputs": [] + }, + "Function": { + "blockClass": "Function", + "description": "Arbitrary MIMO function block, defined by a callable object,", + "docstringHtml": "

Arbitrary MIMO function block, defined by a callable object,\ni.e. function or lambda expression.

\n

The function can have multiple arguments that are then provided\nby the input channels of the function block.

\n

Form multi input, the function has to specify multiple arguments\nand for multi output, the aoutputs have to be provided as a\ntuple or list.

\n

In the context of the global system, this block implements algebraic\ncomponents of the global system ODE/DAE.

\n
\n\\begin{equation*}\n\\vec{y} = \\mathrm{func}(\\vec{u})\n\\end{equation*}\n
\n
\n

Note

\n

This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.\nTherefore func must be purely algebraic and not introduce states,\ndelay, etc. For interfacing with external stateful APIs, use the\nWrapper block.

\n
\n
\n

Note

\n

If the outputs are provided as a single numpy array, they are\nconsidered a single output. For MIMO, output has to be tuple.

\n
\n
\n

Example

\n

consider the function:

\n
\nfrom pathsim.blocks import Function\n\ndef f(a, b, c):\n    return a**2, a*b, b/c\n\nfn = Function(f)\n
\n

then, when the block is uldated, the input channels of the block are\nassigned to the function arguments following this scheme:

\n
\ninputs[0] -> a\ninputs[1] -> b\ninputs[2] -> c\n
\n

and the function outputs are assigned to the\noutput channels of the block in the same way:

\n
\na**2 -> outputs[0]\na*b  -> outputs[1]\nb/c  -> outputs[2]\n
\n

Because the Function block only has a single argument, it can be\nused to decorate a function and make it a PathSim block. This might\nbe handy in some cases to keep definitions concise and localized\nin the code:

\n
\nfrom pathsim.blocks import Function\n\n#does the same as the definition above\n\n@Function\ndef fn(a, b, c):\n    return a**2, a*b, b/c\n\n#'fn' is now a PathSim block\n
\n
\n
\n

Parameters

\n
\n
func : callable
\n
MIMO function that defines algebraic block IO behaviour, signature func(*tuple)
\n
\n
\n
\n

Attributes

\n
\n
op_alg : Operator
\n
internal algebraic operator that wraps func
\n
\n
\n", + "params": { + "func": { + "type": "callable", + "default": null, + "description": "MIMO function that defines algebraic block IO behaviour, signature `func(*tuple)`" + } + }, + "inputs": [], + "outputs": [] + }, + "Sin": { + "blockClass": "Sin", + "description": "Sine operator block.", + "docstringHtml": "

Sine operator block.

\n

This block supports vector inputs. This is the operation it does:

\n
\n\\begin{equation*}\n\\vec{y} = \\sin(\\vec{u})\n\\end{equation*}\n
\n
\n

Attributes

\n
\n
op_alg : Operator
\n
internal algebraic operator
\n
\n
\n", + "params": {}, + "inputs": [], + "outputs": [] + }, + "Cos": { + "blockClass": "Cos", + "description": "Cosine operator block.", + "docstringHtml": "

Cosine operator block.

\n

This block supports vector inputs. This is the operation it does:

\n
\n\\begin{equation*}\n\\vec{y} = \\cos(\\vec{u})\n\\end{equation*}\n
\n
\n

Attributes

\n
\n
op_alg : Operator
\n
internal algebraic operator
\n
\n
\n", + "params": {}, + "inputs": [], + "outputs": [] + }, + "Tan": { + "blockClass": "Tan", + "description": "Tangent operator block.", + "docstringHtml": "

Tangent operator block.

\n

This block supports vector inputs. This is the operation it does:

\n
\n\\begin{equation*}\n\\vec{y} = \\tan(\\vec{u})\n\\end{equation*}\n
\n
\n

Attributes

\n
\n
op_alg : Operator
\n
internal algebraic operator
\n
\n
\n", + "params": {}, + "inputs": [], + "outputs": [] + }, + "Tanh": { + "blockClass": "Tanh", + "description": "Hyperbolic tangent operator block.", + "docstringHtml": "

Hyperbolic tangent operator block.

\n

This block supports vector inputs. This is the operation it does:

\n
\n\\begin{equation*}\n\\vec{y} = \\tanh(\\vec{u})\n\\end{equation*}\n
\n
\n

Attributes

\n
\n
op_alg : Operator
\n
internal algebraic operator
\n
\n
\n", + "params": {}, + "inputs": [], + "outputs": [] + }, + "Abs": { + "blockClass": "Abs", + "description": "Absolute value operator block.", + "docstringHtml": "

Absolute value operator block.

\n

This block supports vector inputs. This is the operation it does:

\n
\n\\begin{equation*}\n\\vec{y} = \\vert| \\vec{u} \\vert|\n\\end{equation*}\n
\n
\n

Attributes

\n
\n
op_alg : Operator
\n
internal algebraic operator
\n
\n
\n", + "params": {}, + "inputs": [], + "outputs": [] + }, + "Sqrt": { + "blockClass": "Sqrt", + "description": "Square root operator block.", + "docstringHtml": "

Square root operator block.

\n

This block supports vector inputs. This is the operation it does:

\n
\n\\begin{equation*}\n\\vec{y} = \\sqrt{|\\vec{u}|}\n\\end{equation*}\n
\n
\n

Attributes

\n
\n
op_alg : Operator
\n
internal algebraic operator
\n
\n
\n", + "params": {}, + "inputs": [], + "outputs": [] + }, + "Exp": { + "blockClass": "Exp", + "description": "Exponential operator block.", + "docstringHtml": "

Exponential operator block.

\n

This block supports vector inputs. This is the operation it does:

\n
\n\\begin{equation*}\n\\vec{y} = e^{\\vec{u}}\n\\end{equation*}\n
\n
\n

Attributes

\n
\n
op_alg : Operator
\n
internal algebraic operator
\n
\n
\n", + "params": {}, + "inputs": [], + "outputs": [] + }, + "Log": { + "blockClass": "Log", + "description": "Natural logarithm operator block.", + "docstringHtml": "

Natural logarithm operator block.

\n

This block supports vector inputs. This is the operation it does:

\n
\n\\begin{equation*}\n\\vec{y} = \\ln(\\vec{u})\n\\end{equation*}\n
\n
\n

Attributes

\n
\n
op_alg : Operator
\n
internal algebraic operator
\n
\n
\n", + "params": {}, + "inputs": [], + "outputs": [] + }, + "Log10": { + "blockClass": "Log10", + "description": "Base-10 logarithm operator block.", + "docstringHtml": "

Base-10 logarithm operator block.

\n

This block supports vector inputs. This is the operation it does:

\n
\n\\begin{equation*}\n\\vec{y} = \\log_{10}(\\vec{u})\n\\end{equation*}\n
\n
\n

Attributes

\n
\n
op_alg : Operator
\n
internal algebraic operator
\n
\n
\n", + "params": {}, + "inputs": [], + "outputs": [] + }, + "Mod": { + "blockClass": "Mod", + "description": "Modulo operator block.", + "docstringHtml": "

Modulo operator block.

\n

This block supports vector inputs. This is the operation it does:

\n
\n\\begin{equation*}\n\\vec{y} = \\vec{u} \\bmod m\n\\end{equation*}\n
\n
\n

Note

\n

modulo is not differentiable at discontinuities

\n
\n
\n

Parameters

\n
\n
modulus : float
\n
modulus value
\n
\n
\n
\n

Attributes

\n
\n
op_alg : Operator
\n
internal algebraic operator
\n
\n
\n", + "params": { + "modulus": { + "type": "number", + "default": "1.0", + "description": "modulus value" + } + }, + "inputs": [], + "outputs": [] + }, + "Clip": { + "blockClass": "Clip", + "description": "Clipping/saturation operator block.", + "docstringHtml": "

Clipping/saturation operator block.

\n

This block supports vector inputs. This is the operation it does:

\n
\n\\begin{equation*}\n\\vec{y} = \\text{clip}(\\vec{u}, u_{min}, u_{max})\n\\end{equation*}\n
\n
\n

Parameters

\n
\n
min_val : float, array_like
\n
minimum clipping value
\n
max_val : float, array_like
\n
maximum clipping value
\n
\n
\n
\n

Attributes

\n
\n
op_alg : Operator
\n
internal algebraic operator
\n
\n
\n", + "params": { + "min_val": { + "type": "number", + "default": "-1.0", + "description": "minimum clipping value" + }, + "max_val": { + "type": "number", + "default": "1.0", + "description": "maximum clipping value" + } + }, + "inputs": [], + "outputs": [] + }, + "Pow": { + "blockClass": "Pow", + "description": "Raise to power operator block.", + "docstringHtml": "

Raise to power operator block.

\n

This block supports vector inputs. This is the operation it does:

\n
\n\\begin{equation*}\n\\vec{y} = \\vec{u}^{p}\n\\end{equation*}\n
\n
\n

Parameters

\n
\n
exponent : float, array_like
\n
exponent to raise the input to the power of
\n
\n
\n
\n

Attributes

\n
\n
op_alg : Operator
\n
internal algebraic operator
\n
\n
\n", + "params": { + "exponent": { + "type": "integer", + "default": "2", + "description": "exponent to raise the input to the power of" + } + }, + "inputs": [], + "outputs": [] + }, + "Switch": { + "blockClass": "Switch", + "description": "Switch block that selects between its inputs and copies", + "docstringHtml": "

Switch block that selects between its inputs and copies\none of them to the output.

\n
\n

Example

\n

The block is initialized like this:

\n
\n#default None -> no passthrough\ns1 = Switch()\n\n#selecting port 2 as passthrough\ns2 = Switch(2)\n\n#change the state of the switch to port 3\ns2.select(3)\n
\n

Sets block output depending on self.state like this:

\n
\nstate == None -> outputs[0] = 0\n\nstate == 0 -> outputs[0] = inputs[0]\n\nstate == 1 -> outputs[0] = inputs[1]\n\nstate == 2 -> outputs[0] = inputs[2]\n\n...\n
\n
\n
\n

Parameters

\n
\n
state : int, None
\n
state of the switch
\n
\n
\n", + "params": { + "state": { + "type": "any", + "default": null, + "description": "state of the switch" + } + }, + "inputs": [], + "outputs": [] + }, + "LUT": { + "blockClass": "LUT", + "description": "N-dimensional lookup table with linear interpolation functionality.", + "docstringHtml": "

N-dimensional lookup table with linear interpolation functionality.

\n

This class implements a multi-dimensional lookup table that uses scipy's\nLinearNDInterpolator [1] for piecewise linear interpolation in N-dimensional\nspace. The interpolation is based on Delaunay triangulation of the input points,\nproviding smooth linear interpolation between data points. For points outside\nthe convex hull of the input data, the interpolator returns NaN values.

\n

The LUT acts as a Function block.

\n\n
\n

Parameters

\n
\n
points : array_like of shape (n, ndim)
\n
2-D array of data point coordinates where n is the number of points\nand ndim is the dimensionality of the space. Each row represents a\nsingle data point in ndim-dimensional space.
\n
values : array_like of shape (n,) or (n, m)
\n
N-D array of data values at the corresponding points. If 1-D, represents\nscalar values at each point. If 2-D, each column represents a different\noutput dimension (m output values per input point).
\n
\n
\n
\n

Attributes

\n
\n
points : ndarray
\n
Stored array of input point coordinates.
\n
values : ndarray
\n
Stored array of output values at each point.
\n
inter : scipy.interpolate.LinearNDInterpolator
\n
The scipy linear interpolator object used for interpolation.
\n
\n
\n", + "params": { + "points": { + "type": "any", + "default": null, + "description": "2-D array of data point coordinates where n is the number of points and ndim is the dimensionality of the space. Each row represents a single data point in ndim-dimensional space." + }, + "values": { + "type": "any", + "default": null, + "description": "N-D array of data values at the corresponding points. If 1-D, represents scalar values at each point. If 2-D, each column represents a different output dimension (m output values per input point)." + } + }, + "inputs": [], + "outputs": [] + }, + "LUT1D": { + "blockClass": "LUT1D", + "description": "One-dimensional lookup table with linear interpolation functionality.", + "docstringHtml": "

One-dimensional lookup table with linear interpolation functionality.

\n

This class implements a 1-dimensional lookup table that uses scipy's interp1d [1]\nfor piecewise linear interpolation along a single axis. The interpolation\nprovides linear interpolation between adjacent data points and supports\nextrapolation beyond the input data range using the 'extrapolate' fill mode.

\n

The LUT1D acts as a Function block.

\n\n
\n

Parameters

\n
\n
points : array_like of shape (n,)
\n
1-D array of monotonically increasing data point coordinates where n\nis the number of points. These represent the independent variable values\nat which the dependent values are known.
\n
values : array_like of shape (n,) or (n, m)
\n
1-D or 2-D array of data values at the corresponding points. If 1-D,\nrepresents scalar values at each point. If 2-D with shape (n, m),\neach column represents a different output dimension, allowing the\nlookup table to return m-dimensional vectors.
\n
fill_value : float or str, optional
\n
The value to use for points outside the interpolation range. If "extrapolate",\nthe interpolator will use linear extrapolation. Default is "extrapolate".\nSee https://docs.scipy.org/doc/scipy-1.16.1/reference/generated/scipy.interpolate.interp1d.html for more details
\n
\n
\n
\n

Attributes

\n
\n
points : ndarray
\n
Flattened array of input point coordinates, stored as 1-D array.
\n
values : ndarray
\n
Stored array of output values at each point, preserving original shape.
\n
inter : scipy.interpolate.interp1d
\n
The scipy 1D interpolator object used for linear interpolation with\nextrapolation enabled beyond the data range.
\n
\n
\n", + "params": { + "points": { + "type": "any", + "default": null, + "description": "1-D array of monotonically increasing data point coordinates where n is the number of points. These represent the independent variable values at which the dependent values are known." + }, + "values": { + "type": "any", + "default": null, + "description": "1-D or 2-D array of data values at the corresponding points. If 1-D, represents scalar values at each point. If 2-D with shape (n, m), each column represents a different output dimension, allowing the lookup table to return m-dimensional vectors." + }, + "fill_value": { + "type": "string", + "default": "\"extrapolate\"", + "description": "The value to use for points outside the interpolation range. If \"extrapolate\", the interpolator will use linear extrapolation. Default is \"extrapolate\". See https://docs.scipy.org/doc/scipy-1.16.1/reference/generated/scipy.interpolate.interp1d.html for more details" + } + }, + "inputs": [], + "outputs": [] + }, + "SampleHold": { + "blockClass": "SampleHold", + "description": "Sample and hold stage that samples the inputs", + "docstringHtml": "

Sample and hold stage that samples the inputs\nperiodically using scheduled events and produces\nthem at the output.

\n
\n

Parameters

\n
\n
T : float
\n
sampling period
\n
tau : float
\n
delay
\n
\n
\n
\n

Attributes

\n
\n
events : list[Schedule]
\n
internal scheduled event for periodic sampling
\n
\n
\n", + "params": { + "T": { + "type": "integer", + "default": "1", + "description": "sampling period" + }, + "tau": { + "type": "integer", + "default": "0", + "description": "delay" + } + }, + "inputs": [], + "outputs": [] + }, + "FIR": { + "blockClass": "FIR", + "description": "Models a discrete-time Finite-Impulse-Response (FIR) filter.", + "docstringHtml": "

Models a discrete-time Finite-Impulse-Response (FIR) filter.

\n

This block applies an FIR filter to an input signal sampled periodically.\nThe output at each sample time is a weighted sum of the current and a finite number\nof past input samples. The operation is triggered by a scheduled event.

\n

Functionality:

\n
\n\\begin{equation*}\ny[n] = b[0] x[n] + b[1] x[n-1] + \\dots + b[N] x[n-N]\n\\end{equation*}\n
\n

where b are the filter coefficients and N is the filter order (number of\ncoefficients - 1).

\n
    \n
  1. Samples the input inputs[0] at intervals of T, starting after delay tau.
  2. \n
  3. Stores the current and past len(coefficients) - 1 input samples in an internal buffer.
  4. \n
  5. Computes the filter output using the dot product of the coefficients\nand the buffered input samples.
  6. \n
  7. Outputs the result on outputs[0].
  8. \n
  9. Holds the output constant between updates.
  10. \n
\n
\n

Parameters

\n
\n
coeffs : array_like
\n
List or numpy array of FIR filter coefficients [b0, b1, ..., bN].\nThe number of coefficients determines the filter's order and memory.
\n
T : float, optional
\n
Sampling period (time between input samples and output updates). Default is 1.
\n
tau : float, optional
\n
Initial delay before the first sample is processed. Default is 0.
\n
\n
\n
\n

Input Ports

\n
\n
inputs[0] : float
\n
Input signal sample at the current time step.
\n
\n
\n
\n

Output Ports

\n
\n
outputs[0] : float
\n
Filtered output signal sample.
\n
\n
\n
\n

Attributes

\n
\n
buffer : deque
\n
Internal buffer storing the most recent input samples.
\n
events : list[Schedule]
\n
Internal scheduled event triggering the filter calculation.
\n
\n
\n", + "params": { + "coeffs": { + "type": "array", + "default": "[1.0]", + "description": "List or numpy array of FIR filter coefficients [b0, b1, ..., bN]. The number of coefficients determines the filter's order and memory." + }, + "T": { + "type": "integer", + "default": "1", + "description": "Sampling period (time between input samples and output updates). Default is 1." + }, + "tau": { + "type": "integer", + "default": "0", + "description": "Initial delay before the first sample is processed. Default is 0." + } + }, + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] + }, + "ADC": { + "blockClass": "ADC", + "description": "Models an ideal Analog-to-Digital Converter (ADC).", + "docstringHtml": "

Models an ideal Analog-to-Digital Converter (ADC).

\n

This block samples an analog input signal periodically, quantizes it\naccording to the specified number of bits and input span, and outputs\nthe resulting digital code on multiple output ports. The sampling\nis triggered by a scheduled event.

\n

Functionality:

\n
    \n
  1. Samples the analog input inputs[0] at intervals of T, starting after delay tau.
  2. \n
  3. Clips the input voltage to the defined span [min_voltage, max_voltage].
  4. \n
  5. Scales the clipped voltage to the range [0, 1].
  6. \n
  7. Quantizes the scaled value to an integer code between 0 and 2^n_bits - 1 using flooring.
  8. \n
  9. Converts the integer code to an n_bits binary representation.
  10. \n
  11. Outputs the binary code on ports 0 (LSB) to n_bits-1 (MSB).
  12. \n
\n

Ideal characteristics:

\n
    \n
  • Instantaneous sampling at scheduled times.
  • \n
  • Perfect, noise-free quantization.
  • \n
  • No aperture jitter or other dynamic errors.
  • \n
\n
\n

Parameters

\n
\n
n_bits : int, optional
\n
Number of bits for the digital output code. Default is 4.
\n
span : list[float] or tuple[float], optional
\n
The valid analog input value range [min_voltage, max_voltage].\nInputs outside this range will be clipped. Default is [-1, 1].
\n
T : float, optional
\n
Sampling period (time between samples). Default is 1 time unit.
\n
tau : float, optional
\n
Initial delay before the first sample is taken. Default is 0.
\n
\n
\n
\n

Attributes

\n
\n
events : list[Schedule]
\n
Internal scheduled event responsible for periodic sampling and conversion.
\n
\n
\n", + "params": { + "n_bits": { + "type": "integer", + "default": "4", + "description": "Number of bits for the digital output code. Default is 4." + }, + "span": { + "type": "array", + "default": "[-1, 1]", + "description": "The valid analog input value range [min_voltage, max_voltage]. Inputs outside this range will be clipped. Default is [-1, 1]." + }, + "T": { + "type": "integer", + "default": "1", + "description": "Sampling period (time between samples). Default is 1 time unit." + }, + "tau": { + "type": "integer", + "default": "0", + "description": "Initial delay before the first sample is taken. Default is 0." + } + }, + "inputs": [ + "in" + ], + "outputs": [ + "b4", + "b3", + "b2", + "b1" + ] + }, + "DAC": { + "blockClass": "DAC", + "description": "Models an ideal Digital-to-Analog Converter (DAC).", + "docstringHtml": "

Models an ideal Digital-to-Analog Converter (DAC).

\n

This block reads a digital input code periodically from its input ports,\nreconstructs the corresponding analog value based on the number of bits\nand output span, and holds the output constant between updates. The update\nis triggered by a scheduled event.

\n

Functionality:

\n
    \n
  1. Reads the digital code from input ports 0 (LSB) to n_bits-1 (MSB) at intervals of T, starting after delay tau.
  2. \n
  3. Interprets the inputs as an unsigned binary integer code.
  4. \n
  5. Converts the integer code to a fractional value between 0 and (2^n_bits - 1) / 2^n_bits.
  6. \n
  7. Scales this fractional value to the specified analog output span.
  8. \n
  9. Outputs the resulting analog value on outputs[0].
  10. \n
  11. Holds the output value constant until the next scheduled update.
  12. \n
\n

Ideal characteristics:

\n
    \n
  • Instantaneous update at scheduled times.
  • \n
  • Perfect, noise-free reconstruction.
  • \n
  • No glitches or settling time.
  • \n
\n
\n

Parameters

\n
\n
n_bits : int, optional
\n
Number of digital input bits expected. Default is 4.
\n
span : list[float] or tuple[float], optional
\n
The analog output value range [min_voltage, max_voltage] corresponding\nto the digital codes 0 and 2^n_bits - 1, respectively (approximately).\nDefault is [-1, 1].
\n
T : float, optional
\n
Update period (time between output updates). Default is 1 time unit.
\n
tau : float, optional
\n
Initial delay before the first output update. Default is 0.
\n
\n
\n
\n

Attributes

\n
\n
events : list[Schedule]
\n
Internal scheduled event responsible for periodic updates.
\n
\n
\n", + "params": { + "n_bits": { + "type": "integer", + "default": "4", + "description": "Number of digital input bits expected. Default is 4." + }, + "span": { + "type": "array", + "default": "[-1, 1]", + "description": "The analog output value range [min_voltage, max_voltage] corresponding to the digital codes 0 and 2^n_bits - 1, respectively (approximately). Default is [-1, 1]." + }, + "T": { + "type": "integer", + "default": "1", + "description": "Update period (time between output updates). Default is 1 time unit." + }, + "tau": { + "type": "integer", + "default": "0", + "description": "Initial delay before the first output update. Default is 0." + } + }, + "inputs": [ + "b4", + "b3", + "b2", + "b1" + ], + "outputs": [ + "out" + ] + }, + "Counter": { + "blockClass": "Counter", + "description": "Counter block that counts the number of detected bidirectional", + "docstringHtml": "

Counter block that counts the number of detected bidirectional\nzero-crossing events and sets the output accordingly.

\n
\n

Parameters

\n
\n
start : int
\n
counter start (initial condition)
\n
threshold : float
\n
threshold for zero crossing
\n
\n
\n
\n

Attributes

\n
\n
E : ZeroCrossing
\n
internal event manager
\n
events : list[ZeroCrossing]
\n
internal zero crossing event
\n
\n
\n", + "params": { + "start": { + "type": "integer", + "default": "0", + "description": "counter start (initial condition)" + }, + "threshold": { + "type": "number", + "default": "0.0", + "description": "threshold for zero crossing" + } + }, + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] + }, + "CounterUp": { + "blockClass": "CounterUp", + "description": "Counter block that counts the number of detected unidirectional", + "docstringHtml": "

Counter block that counts the number of detected unidirectional\nzero-crossing events and sets the output accordingly.

\n
\n

Note

\n

This is a modification of 'Counter' which only counts\nunidirectional zero-crossings (low -> high)

\n
\n
\n

Parameters

\n
\n
start : int
\n
counter start (initial condition)
\n
threshold : float
\n
threshold for zero crossing
\n
\n
\n
\n

Attributes

\n
\n
E : ZeroCrossingUp
\n
internal event manager
\n
events : list[ZeroCrossing]
\n
internal zero crossing event
\n
\n
\n", + "params": { + "start": { + "type": "integer", + "default": "0", + "description": "counter start (initial condition)" + }, + "threshold": { + "type": "number", + "default": "0.0", + "description": "threshold for zero crossing" + } + }, + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] + }, + "CounterDown": { + "blockClass": "CounterDown", + "description": "Counter block that counts the number of detected unidirectional", + "docstringHtml": "

Counter block that counts the number of detected unidirectional\nzero-crossing events and sets the output accordingly.

\n
\n

Note

\n

This is a modification of 'Counter' which only counts\nunidirectional zero-crossings (high -> low)

\n
\n
\n

Parameters

\n
\n
start : int
\n
counter start (initial condition)
\n
threshold : float
\n
threshold for zero crossing
\n
\n
\n
\n

Attributes

\n
\n
E : ZeroCrossingDown
\n
internal event manager
\n
events : list[ZeroCrossing]
\n
internal zero crossing event
\n
\n
\n", + "params": { + "start": { + "type": "integer", + "default": "0", + "description": "counter start (initial condition)" + }, + "threshold": { + "type": "number", + "default": "0.0", + "description": "threshold for zero crossing" + } + }, + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] + }, + "Relay": { + "blockClass": "Relay", + "description": "Relay block with hysteresis (Schmitt trigger).", + "docstringHtml": "

Relay block with hysteresis (Schmitt trigger).

\n

Switches output between two values based on input crossing upper and lower\nthresholds. The hysteresis prevents rapid switching when input is noisy.

\n

When input rises above threshold_up, output switches to value_up.\nWhen input falls below threshold_down, output switches to value_down.

\n
\n

Examples

\n

Basic thermostat that turns heater on below 19°C, off above 21°C:

\n
\nfrom pathsim.blocks import Relay\n\nthermostat = Relay(\n    threshold_up=21.0,\n    threshold_down=19.0,\n    value_up=0.0,\n    value_down=1.0\n    )\n
\n
\n
\n

Parameters

\n
\n
threshold_up : float
\n
threshold for transitioning to upper relay state value_up (default: 1.0)
\n
threshold_down : float
\n
threshold for transitioning to lower relay state value_down (default: 0.0)
\n
value_up : float
\n
value for upper relay state (default: 1.0)
\n
value_down : float
\n
value for lower relay state (default: 0.0)
\n
\n
\n
\n

Attributes

\n
\n
events : list[ZeroCrossingUp, ZeroCrossingDown]
\n
internal zero crossing events for relay state transitions
\n
\n
\n", + "params": { + "threshold_up": { + "type": "number", + "default": "1.0", + "description": "threshold for transitioning to upper relay state `value_up` (default: 1.0)" + }, + "threshold_down": { + "type": "number", + "default": "0.0", + "description": "threshold for transitioning to lower relay state `value_down` (default: 0.0)" + }, + "value_up": { + "type": "number", + "default": "1.0", + "description": "value for upper relay state (default: 1.0)" + }, + "value_down": { + "type": "number", + "default": "0.0", + "description": "value for lower relay state (default: 0.0)" + } + }, + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] + }, + "Scope": { + "blockClass": "Scope", + "description": "Block for recording time domain data with variable sampling rate.", + "docstringHtml": "

Block for recording time domain data with variable sampling rate.

\n

A time threshold can be set by t_wait to start recording data after the simulation\ntime is larger then the specified waiting time, i.e. t - t_wait > 0.\nThis is useful for recording data only after all the transients have settled.

\n

The block uses an interal Schedule event, when sampling_rate is provided,\notherwise it just samples at every simulation timestep.

\n
\n

Parameters

\n
\n
sampling_rate : int, None
\n
number of samples per time unit, default is every timestep
\n
t_wait : float
\n
wait time before starting recording, optional
\n
labels : list[str]
\n
labels for the scope traces, and for the csv, optional
\n
\n
\n
\n

Attributes

\n
\n
recording : dict
\n
recording, where key is time, and value the recorded values
\n
_sample_next_timestep : bool
\n
flag to indicate this is a timestep to sample, only used for event based sampling\nwhen sampling_rate is provided as an arg
\n
events : list[Schedule]
\n
internal scheduled event for periodic input sampling when sampling_rate is provided
\n
\n
\n", + "params": { + "sampling_rate": { + "type": "any", + "default": null, + "description": "number of samples per time unit, default is every timestep" + }, + "t_wait": { + "type": "number", + "default": "0.0", + "description": "wait time before starting recording, optional" + }, + "labels": { + "type": "any", + "default": null, + "description": "labels for the scope traces, and for the csv, optional" + } + }, + "inputs": [], + "outputs": [] + }, + "Spectrum": { + "blockClass": "Spectrum", + "description": "Block for fourier spectrum analysis (basically a spectrum analyzer), computes", + "docstringHtml": "

Block for fourier spectrum analysis (basically a spectrum analyzer), computes\ncontinuous time running fourier transform (RFT) of the incoming signal.

\n

A time threshold can be set by 't_wait' to start recording data only after the\nsimulation time is larger then the specified waiting time, i.e. 't - t_wait > dt'.\nThis is useful for recording the steady state after all the transients have settled.

\n

An exponential forgetting factor 'alpha' can be specified for realtime spectral\nanalysis. It biases the spectral components exponentially to the most recent signal\nvalues by applying a single sided exponential window like this:

\n
\n\\begin{equation*}\n\\int_0^t u(\\tau) \\exp(\\alpha (t-\\tau)) \\exp(-j \\omega \\tau)\\ d \\tau\n\\end{equation*}\n
\n

It is also known as the 'exponentially forgetting transform' (EFT) and a form of\nshort time fourier transform (STFT). It is implemented as a 1st order statespace model

\n
\n\\begin{equation*}\n\\dot{x} = - \\alpha x + \\exp(-j \\omega t) u\n\\end{equation*}\n
\n

where 'u' is the input signal and 'x' is the state variable that represents the\ncomplex fourier coefficient to the frequency 'omega'. The ODE is integrated using the\nnumerical integration engine of the block.

\n
\n

Example

\n

This is how to initialize it:

\n
\nimport numpy as np\n\n#linear frequencies (0Hz, DC -> 1kHz)\nsp1 = Spectrum(\n    freq=np.linspace(0, 1e3, 100),\n    labels=['x1', 'x2'] #labels for two inputs\n    )\n\n#log frequencies (1Hz -> 1kHz)\nsp2 = Spectrum(\n    freq=np.logspace(0, 3, 100)\n    )\n\n#log frequencies including DC (0Hz, DC + 1Hz -> 1kHz)\nsp3 = Spectrum(\n    freq=np.hstack([0.0, np.logspace(0, 3, 100)])\n    )\n\n#arbitrary frequencies\nsp4 = Spectrum(\n    freq=np.array([0, 0.5, 20, 1e3])\n    )\n
\n
\n
\n

Note

\n

This block is relatively slow! But it is valuable for long running simulations\nwith few evaluation frequencies, where just FFT'ing the time series data\nwouldnt be efficient OR if only the evaluation at weirdly spaced frequencies\nis required. Otherwise its more efficient to just do an FFT on the time\nseries recording after the simulation has finished.

\n
\n
\n

Parameters

\n
\n
freq : array[float]
\n
list of evaluation frequencies for RFT, can be arbitrarily spaced
\n
t_wait : float
\n
wait time before starting RFT
\n
alpha : float
\n
exponential forgetting factor for realtime spectrum
\n
labels : list[str]
\n
labels for the inputs
\n
\n
\n", + "params": { + "freq": { + "type": "array", + "default": "[]", + "description": "list of evaluation frequencies for RFT, can be arbitrarily spaced" + }, + "t_wait": { + "type": "number", + "default": "0.0", + "description": "wait time before starting RFT" + }, + "alpha": { + "type": "number", + "default": "0.0", + "description": "exponential forgetting factor for realtime spectrum" + }, + "labels": { + "type": "array", + "default": "[]", + "description": "labels for the inputs" + } + }, + "inputs": [], + "outputs": [] + }, + "Subsystem": { + "blockClass": "Subsystem", + "description": "Subsystem class that holds its own blocks and connecions and", + "docstringHtml": "

Subsystem class that holds its own blocks and connecions and\ncan natively interface with the main simulation loop.

\n

IO interface is realized by a special 'Interface' block, that has extra\nmethods for setting and getting inputs and outputs and serves\nas the interface of the internal blocks to the outside.

\n

The subsystem doesnt use its 'inputs' and 'outputs' dicts directly.\nIt exclusively handles data transfer via the 'Interface' block.

\n

This class can be used just like any other block during the simulation,\nsince it implements the required methods 'update' for the fixed-point\niteration (resolving algebraic loops with instant time blocks),\nthe 'step' method that performs timestepping (especially for dynamic\nblocks with internal states) and the 'solve' method for solving the\nimplicit update equation for implicit solvers.

\n
\n

Example

\n

This is how we can wrap up multiple blocks within a subsystem.\nIn this case vanderpol system built from discrete components\ninstead of using an ODE block (in practice you should use\na monolithic ODE whenever possible due to performance).

\n
\nfrom pathsim import Subsystem, Interface, Connection\nfrom pathsim.blocks import Integrator, Function\n\n#van der Pol parameter\nmu = 1000\n\n#blocks in the subsystem\nIf = Interface() # this is the interface to the outside\nI1 = Integrator(2)\nI2 = Integrator(0)\nFn = Function(lambda x1, x2: mu*(1 - x1**2)*x2 - x1)\n\nsub_blocks = [If, I1, I2, Fn]\n\n#connections in the subsystem\nsub_connections = [\n    Connection(I2, I1, Fn[1], If[1]),\n    Connection(I1, Fn, If),\n    Connection(Fn, I2)\n    ]\n\n#the subsystem acts just like a normal block\nvdp = Subsystem(sub_blocks, sub_connections)\n
\n
\n
\n

Parameters

\n
\n
blocks : list[Block] | None
\n
internal blocks of the subsystem
\n
connections : list[Connection] | None
\n
internal connections of the subsystem
\n
\n

events : list[Event] | None\ntolerance_fpi : float

\n
\nabsolute tolerance for convergence of algebraic loops\ndefault see ´SIM_TOLERANCE_FPI´ in ´_constants.py´
\n
\n
iterations_max : int
\n
maximum allowed number of iterations for algebraic loop\nsolver, default see ´SIM_ITERATIONS_MAX´ in ´_constants.py´
\n
\n
\n
\n

Attributes

\n
\n
interface : Interface
\n
internal interface block for data transfer to the outside
\n
graph : Graph
\n
internal graph representation for fast system funcion\nevluations using DAG with algebraic depths
\n
boosters : None | list[ConnectionBooster]
\n
list of boosters (fixed point accelerators) that wrap\nalgebraic loop closing connections assembled from the\nsystem graph
\n
\n
\n", + "params": { + "blocks": { + "type": "any", + "default": null, + "description": "internal blocks of the subsystem" + }, + "connections": { + "type": "any", + "default": null, + "description": "internal connections of the subsystem" + }, + "events": { + "type": "any", + "default": null, + "description": "" + }, + "tolerance_fpi": { + "type": "number", + "default": "1e-10", + "description": "absolute tolerance for convergence of algebraic loops default see ´SIM_TOLERANCE_FPI´ in ´_constants.py´" + }, + "iterations_max": { + "type": "integer", + "default": "200", + "description": "maximum allowed number of iterations for algebraic loop solver, default see ´SIM_ITERATIONS_MAX´ in ´_constants.py´" + } + }, + "inputs": [], + "outputs": [] + }, + "Interface": { + "blockClass": "Interface", + "description": "Bare-bone block that serves as a data interface for the 'Subsystem' class.", + "docstringHtml": "

Bare-bone block that serves as a data interface for the 'Subsystem' class.

\n

It works like this:

\n
    \n
  • Internal blocks of the subsystem are connected to the inputs and outputs\nof this Interface block via the internal connections.
  • \n
  • It behaves like a normal block (inherits the main 'Block' class methods).
  • \n
  • It implements some special methods to get and set the inputs and outputs\nof the blocks, that are used to translate between the internal blocks of the\nsubsystem and the inputs and outputs of the subsystem.
  • \n
  • Handles data transfer to and from the internal subsystem blocks\nto and from the inputs and outputs of the subsystem.
  • \n
\n", + "params": {}, + "inputs": [], + "outputs": [] + } +}; + +export const blockConfig: Record, string[]> = { + Sources: ["Constant", "Source", "SinusoidalSource", "StepSource", "PulseSource", "TriangleWaveSource", "SquareWaveSource", "GaussianPulseSource", "ChirpPhaseNoiseSource", "ClockSource", "WhiteNoise", "PinkNoise", "RandomNumberGenerator"], + Dynamic: ["Integrator", "Differentiator", "Delay", "ODE", "DynamicalSystem", "StateSpace", "PID", "AntiWindupPID", "TransferFunctionNumDen", "TransferFunctionZPG", "ButterworthLowpassFilter", "ButterworthHighpassFilter", "ButterworthBandpassFilter", "ButterworthBandstopFilter"], + Algebraic: ["Adder", "Multiplier", "Amplifier", "Function", "Sin", "Cos", "Tan", "Tanh", "Abs", "Sqrt", "Exp", "Log", "Log10", "Mod", "Clip", "Pow", "Switch", "LUT", "LUT1D"], + Mixed: ["SampleHold", "FIR", "ADC", "DAC", "Counter", "CounterUp", "CounterDown", "Relay"], + Recording: ["Scope", "Spectrum"], +}; + +export const uiOverrides: Record = +{ + "Constant": { + "maxInputs": 0, + "maxOutputs": 1 + }, + "Source": { + "maxInputs": 0, + "maxOutputs": 1 + }, + "SinusoidalSource": { + "maxInputs": 0, + "maxOutputs": 1 + }, + "StepSource": { + "maxInputs": 0, + "maxOutputs": 1 + }, + "PulseSource": { + "maxInputs": 0, + "maxOutputs": 1 + }, + "TriangleWaveSource": { + "maxInputs": 0, + "maxOutputs": 1 + }, + "SquareWaveSource": { + "maxInputs": 0, + "maxOutputs": 1 + }, + "GaussianPulseSource": { + "maxInputs": 0, + "maxOutputs": 1 + }, + "ClockSource": { + "maxInputs": 0, + "maxOutputs": 1 + }, + "WhiteNoise": { + "maxInputs": 0, + "maxOutputs": 1 + }, + "PinkNoise": { + "maxInputs": 0, + "maxOutputs": 1 + }, + "ChirpPhaseNoiseSource": { + "maxInputs": 0, + "maxOutputs": 1 + }, + "RandomNumberGenerator": { + "maxInputs": 0, + "maxOutputs": 1 + }, + "Integrator": { + "maxInputs": null, + "maxOutputs": null + }, + "Differentiator": { + "maxInputs": null, + "maxOutputs": null + }, + "Delay": { + "maxInputs": null, + "maxOutputs": null + }, + "ODE": { + "maxInputs": null, + "maxOutputs": null + }, + "DynamicalSystem": { + "maxInputs": null, + "maxOutputs": null + }, + "StateSpace": { + "maxInputs": null, + "maxOutputs": null + }, + "PID": { + "maxInputs": 1, + "maxOutputs": 1 + }, + "AntiWindupPID": { + "maxInputs": 1, + "maxOutputs": 1 + }, + "TransferFunctionNumDen": { + "maxInputs": 1, + "maxOutputs": 1 + }, + "TransferFunctionZPG": { + "maxInputs": 1, + "maxOutputs": 1 + }, + "ButterworthLowpassFilter": { + "maxInputs": null, + "maxOutputs": null + }, + "ButterworthHighpassFilter": { + "maxInputs": null, + "maxOutputs": null + }, + "ButterworthBandpassFilter": { + "maxInputs": null, + "maxOutputs": null + }, + "ButterworthBandstopFilter": { + "maxInputs": null, + "maxOutputs": null + }, + "Adder": { + "maxInputs": null, + "maxOutputs": 1 + }, + "Multiplier": { + "maxInputs": null, + "maxOutputs": 1 + }, + "Amplifier": { + "maxInputs": null, + "maxOutputs": null + }, + "Function": { + "maxInputs": null, + "maxOutputs": null + }, + "Sin": { + "maxInputs": null, + "maxOutputs": null + }, + "Cos": { + "maxInputs": null, + "maxOutputs": null + }, + "Tan": { + "maxInputs": null, + "maxOutputs": null + }, + "Tanh": { + "maxInputs": null, + "maxOutputs": null + }, + "Abs": { + "maxInputs": null, + "maxOutputs": null + }, + "Sqrt": { + "maxInputs": null, + "maxOutputs": null + }, + "Exp": { + "maxInputs": null, + "maxOutputs": null + }, + "Log": { + "maxInputs": null, + "maxOutputs": null + }, + "Log10": { + "maxInputs": null, + "maxOutputs": null + }, + "Mod": { + "maxInputs": null, + "maxOutputs": null + }, + "Clip": { + "maxInputs": null, + "maxOutputs": null + }, + "Pow": { + "maxInputs": null, + "maxOutputs": null + }, + "Switch": { + "maxInputs": null, + "maxOutputs": 1 + }, + "LUT": { + "maxInputs": null, + "maxOutputs": null + }, + "LUT1D": { + "maxInputs": 1, + "maxOutputs": 1 + }, + "SampleHold": {}, + "FIR": {}, + "ADC": {}, + "DAC": {}, + "Counter": { + "maxInputs": 1, + "maxOutputs": 1 + }, + "CounterUp": { + "maxInputs": 1, + "maxOutputs": 1 + }, + "CounterDown": { + "maxInputs": 1, + "maxOutputs": 1 + }, + "Relay": { + "maxInputs": 1, + "maxOutputs": 1 + }, + "Scope": { + "maxInputs": null, + "maxOutputs": 0 + }, + "Spectrum": { + "maxInputs": null, + "maxOutputs": 0 + } +}; diff --git a/src/lib/nodes/generated/version.ts b/src/lib/nodes/generated/version.ts new file mode 100644 index 00000000..7527cfc0 --- /dev/null +++ b/src/lib/nodes/generated/version.ts @@ -0,0 +1,8 @@ +// Auto-generated by extraction scripts - DO NOT EDIT +// Re-run extraction scripts to update + +export const GENERATED_VERSION = { + pathsimVersion: '0.8.0', + generatedAt: '2025-01-05T00:00:00.000Z', + extractorVersion: '1.0.0' +}; diff --git a/src/lib/nodes/index.ts b/src/lib/nodes/index.ts new file mode 100644 index 00000000..219fd21d --- /dev/null +++ b/src/lib/nodes/index.ts @@ -0,0 +1,23 @@ +/** + * Node module entry point + * Re-exports all node-related functionality and initializes node definitions + */ + +// Re-export types +export * from './types'; + +// Re-export registry (initializes blocks from generated data on import) +export { nodeRegistry } from './registry'; + +// Re-export defineNode helper +export { defineNode } from './defineNode'; + +// Re-export generated block config for external use +export { blockConfig, extractedBlocks, uiOverrides } from './generated/blocks'; + +// Register subsystem nodes after main registry is initialized +import { registerSubsystemNodes } from './subsystem'; +registerSubsystemNodes(); + +// Re-export subsystem definitions +export { SubsystemDefinition, InterfaceDefinition } from './subsystem'; diff --git a/src/lib/nodes/registry.ts b/src/lib/nodes/registry.ts new file mode 100644 index 00000000..9acb9851 --- /dev/null +++ b/src/lib/nodes/registry.ts @@ -0,0 +1,171 @@ +/** + * Node type registry + * Manages all registered node types and provides lookup functionality + */ + +import type { NodeTypeDefinition, NodeCategory, ParamDefinition, ParamType } from './types'; +import { defineNode } from './defineNode'; +import { + extractedBlocks, + blockConfig, + uiOverrides, + type ExtractedBlock, + type UIOverride +} from './generated/blocks'; + +class NodeRegistry { + private nodes: Map = new Map(); + private byCategory: Map = new Map(); + + /** + * Register a new node type + */ + register(definition: NodeTypeDefinition): void { + this.nodes.set(definition.type, definition); + + const categoryNodes = this.byCategory.get(definition.category) || []; + categoryNodes.push(definition); + this.byCategory.set(definition.category, categoryNodes); + } + + /** + * Get a node type by its type ID + */ + get(type: string): NodeTypeDefinition | undefined { + return this.nodes.get(type); + } + + /** + * Get all node types in a category + */ + getByCategory(category: NodeCategory): NodeTypeDefinition[] { + return this.byCategory.get(category) || []; + } + + /** + * Get all registered categories + */ + getAllCategories(): NodeCategory[] { + return Array.from(this.byCategory.keys()); + } + + /** + * Get all registered node types + */ + getAll(): NodeTypeDefinition[] { + return Array.from(this.nodes.values()); + } + + /** + * Check if a node type is registered + */ + has(type: string): boolean { + return this.nodes.has(type); + } + + /** + * Get the count of registered nodes + */ + get size(): number { + return this.nodes.size; + } +} + +// Export singleton instance +export const nodeRegistry = new NodeRegistry(); + +/** + * Convert extracted block to node definition options + */ +function createNodeFromExtracted( + name: string, + category: NodeCategory, + extracted: ExtractedBlock, + override: UIOverride = {} +): void { + // Build params from extracted data + const params: Record< + string, + { + type: ParamType; + default: unknown; + description?: string; + min?: number; + max?: number; + options?: string[]; + } + > = {}; + + for (const [paramName, paramInfo] of Object.entries(extracted.params)) { + params[paramName] = { + type: paramInfo.type as ParamType, + default: paramInfo.default, + description: paramInfo.description, + min: paramInfo.min, + max: paramInfo.max, + options: paramInfo.options + }; + } + + // Determine inputs - use override defaults, extracted ports, or let defineNode use its defaults + let inputs: string[] | undefined; + if (override.defaultInputs && override.defaultInputs.length > 0) { + inputs = override.defaultInputs; + } else if (extracted.inputs.length > 0) { + inputs = extracted.inputs; + } else if (override.maxInputs === 0) { + inputs = []; + } + // else: undefined - let defineNode use its default ['in 0'] + + // Determine outputs - use override defaults, extracted ports, or let defineNode use its defaults + let outputs: string[] | undefined; + if (override.defaultOutputs && override.defaultOutputs.length > 0) { + outputs = override.defaultOutputs; + } else if (extracted.outputs.length > 0) { + outputs = extracted.outputs; + } else if (override.maxOutputs === 0) { + outputs = []; + } + // else: undefined - let defineNode use its default ['out 0'] + + const definition = defineNode({ + name, + category, + blockClass: extracted.blockClass, + description: extracted.description, + inputs, + outputs, + maxInputs: override.maxInputs, + maxOutputs: override.maxOutputs, + params + }); + + // Add docstringHtml from extracted data + if (extracted.docstringHtml) { + definition.docstring = extracted.docstringHtml; + } + + nodeRegistry.register(definition); +} + +/** + * Initialize registry with all blocks from generated data + */ +function initializeRegistry(): void { + for (const [category, blockNames] of Object.entries(blockConfig)) { + for (const blockName of blockNames) { + const extracted = extractedBlocks[blockName as keyof typeof extractedBlocks]; + const override = uiOverrides[blockName as keyof typeof uiOverrides] || {}; + + if (extracted) { + createNodeFromExtracted(blockName, category as NodeCategory, extracted, override); + } else { + console.warn(`Block "${blockName}" not found in extracted blocks`); + } + } + } +} + +// Initialize on module load +initializeRegistry(); diff --git a/src/lib/nodes/shapes.ts b/src/lib/nodes/shapes.ts new file mode 100644 index 00000000..f7fc7625 --- /dev/null +++ b/src/lib/nodes/shapes.ts @@ -0,0 +1,8 @@ +/** + * Shape utilities + * + * Re-exports from shapes/ directory for backwards compatibility. + * New code should import from '$lib/nodes/shapes/index' directly. + */ + +export * from './shapes/index'; diff --git a/src/lib/nodes/shapes/index.ts b/src/lib/nodes/shapes/index.ts new file mode 100644 index 00000000..01d27764 --- /dev/null +++ b/src/lib/nodes/shapes/index.ts @@ -0,0 +1,17 @@ +/** + * Node shapes module + * + * Provides shape definitions and utilities for node rendering. + */ + +export { + type ShapeDefinition, + registerShape, + getShape, + getAllShapes, + getShapeForCategory, + setCategoryShape, + getCategoryShapeMapping +} from './registry'; + +export { getShapeCssClass, isSubsystem, isInterface } from './utils'; diff --git a/src/lib/nodes/shapes/registry.ts b/src/lib/nodes/shapes/registry.ts new file mode 100644 index 00000000..39a4dd0c --- /dev/null +++ b/src/lib/nodes/shapes/registry.ts @@ -0,0 +1,100 @@ +/** + * Shape Registry + * Data-driven shape definitions for nodes + */ + +import type { NodeCategory } from '$lib/types'; + +/** Shape definition */ +export interface ShapeDefinition { + id: string; + name: string; + cssClass: string; + borderRadius: string; +} + +/** Shape registry - maps shape IDs to definitions */ +const shapes = new Map(); + +/** Register a shape */ +export function registerShape(shape: ShapeDefinition): void { + shapes.set(shape.id, shape); +} + +/** Get a shape by ID */ +export function getShape(id: string): ShapeDefinition | undefined { + return shapes.get(id); +} + +/** Get all registered shapes */ +export function getAllShapes(): ShapeDefinition[] { + return Array.from(shapes.values()); +} + +// Register built-in shapes +registerShape({ + id: 'pill', + name: 'Pill', + cssClass: 'shape-pill', + borderRadius: '20px' +}); + +registerShape({ + id: 'rect', + name: 'Rectangle', + cssClass: 'shape-rect', + borderRadius: '4px' +}); + +registerShape({ + id: 'circle', + name: 'Circle', + cssClass: 'shape-circle', + borderRadius: '16px' +}); + +registerShape({ + id: 'diamond', + name: 'Diamond', + cssClass: 'shape-diamond', + borderRadius: '4px' +}); + +registerShape({ + id: 'mixed', + name: 'Mixed', + cssClass: 'shape-mixed', + borderRadius: '12px 4px 12px 4px' +}); + +registerShape({ + id: 'default', + name: 'Default', + cssClass: 'shape-default', + borderRadius: '8px' +}); + +/** Category to shape mapping */ +const categoryShapeMap: Record = { + Sources: 'pill', + Dynamic: 'rect', + Algebraic: 'rect', + Mixed: 'mixed', + Recording: 'pill', + Subsystem: 'rect' +}; + +/** Get shape ID for a category */ +export function getShapeForCategory(category: NodeCategory | string): string { + return categoryShapeMap[category] || 'default'; +} + +/** Update category to shape mapping */ +export function setCategoryShape(category: string, shapeId: string): void { + categoryShapeMap[category] = shapeId; +} + +/** Get the category-shape mapping (for UI/debugging) */ +export function getCategoryShapeMapping(): Record { + return { ...categoryShapeMap }; +} diff --git a/src/lib/nodes/shapes/utils.ts b/src/lib/nodes/shapes/utils.ts new file mode 100644 index 00000000..219c3b3a --- /dev/null +++ b/src/lib/nodes/shapes/utils.ts @@ -0,0 +1,42 @@ +/** + * Shape utility functions + * Uses the shape registry for data-driven shape handling + */ + +import type { NodeTypeDefinition, NodeInstance } from '$lib/types'; +import { NODE_TYPES } from '$lib/constants/nodeTypes'; +import { getShape, getShapeForCategory } from './registry'; + +/** + * Get CSS class for a node based on its type definition + */ +export function getShapeCssClass(typeDef: NodeTypeDefinition): string { + // First check if type definition has explicit shape override + if (typeDef.shape) { + const shape = getShape(typeDef.shape); + if (shape) { + return shape.cssClass; + } + } + + // Fall back to category-based shape + const shapeId = getShapeForCategory(typeDef.category); + const shape = getShape(shapeId); + return shape?.cssClass || 'shape-default'; +} + +/** + * Check if a node type is a subsystem + */ +export function isSubsystem(node: NodeInstance | NodeTypeDefinition): boolean { + const type = 'type' in node ? node.type : (node as NodeTypeDefinition).type; + return type === NODE_TYPES.SUBSYSTEM; +} + +/** + * Check if a node type is an interface + */ +export function isInterface(node: NodeInstance | NodeTypeDefinition): boolean { + const type = 'type' in node ? node.type : (node as NodeTypeDefinition).type; + return type === NODE_TYPES.INTERFACE; +} diff --git a/src/lib/nodes/subsystem.ts b/src/lib/nodes/subsystem.ts new file mode 100644 index 00000000..e217a361 --- /dev/null +++ b/src/lib/nodes/subsystem.ts @@ -0,0 +1,75 @@ +/** + * Subsystem node definitions + * Defines Subsystem and Interface blocks for hierarchical graph composition + */ + +import { defineNode } from './defineNode'; +import { nodeRegistry } from './registry'; +import { extractedBlocks } from './generated/blocks'; + +/** + * Subsystem block - container for nested blocks + * Double-click to drill down into the subsystem's internal graph + * Ports are defined by Interface blocks inside the subsystem + */ +export const SubsystemDefinition = defineNode({ + name: 'Subsystem', + category: 'Subsystem', + description: '', // Set from extractedBlocks in registerSubsystemNodes + blockClass: 'Subsystem', + inputs: [], // Ports populated dynamically from Interface blocks + outputs: [], + minInputs: 0, // Ports controlled by Interface + minOutputs: 0, + maxInputs: null, // Dynamic based on Interface + maxOutputs: null, + shape: 'rect', + params: {} +}); + +/** + * Interface block - defines subsystem ports + * Only valid inside a Subsystem. Connects internal signals to external ports. + * Interface inputs → signals coming INTO the subsystem (parent's outputs) + * Interface outputs → signals going OUT of the subsystem (parent's inputs) + * + * NOTE: Interface is auto-created when a Subsystem is placed, not shown in library. + */ +export const InterfaceDefinition = defineNode({ + name: 'Interface', + category: 'Subsystem', + description: '', // Set from extractedBlocks in registerSubsystemNodes + blockClass: 'Interface', + inputs: [], // Start empty - user adds ports as needed + outputs: [], + minInputs: 0, // Interface can have zero ports + minOutputs: 0, + maxInputs: null, + maxOutputs: null, + shape: 'rect', + params: {} +}); + +/** + * Register subsystem nodes + * Called after the main registry is initialized + * Descriptions and docstrings are set here (not at module init time) to ensure extractedBlocks is loaded + */ +export function registerSubsystemNodes(): void { + // Set descriptions and docstrings from extracted PathSim documentation + const subsystemData = extractedBlocks['Subsystem']; + const interfaceData = extractedBlocks['Interface']; + + if (subsystemData) { + SubsystemDefinition.description = subsystemData.description; + SubsystemDefinition.docstring = subsystemData.docstringHtml; + } + + if (interfaceData) { + InterfaceDefinition.description = interfaceData.description; + InterfaceDefinition.docstring = interfaceData.docstringHtml; + } + + nodeRegistry.register(SubsystemDefinition); + nodeRegistry.register(InterfaceDefinition); +} diff --git a/src/lib/nodes/types.ts b/src/lib/nodes/types.ts new file mode 100644 index 00000000..43bf6247 --- /dev/null +++ b/src/lib/nodes/types.ts @@ -0,0 +1,70 @@ +/** + * Node type definitions + * + * Re-exports types from centralized location for backwards compatibility. + * New code should import from '$lib/types' directly. + */ + +// Re-export all node-related types from centralized location +export type { + PortDirection, + PortDefinition, + PortInstance, + ParamType, + ParamDefinition, + NodeCategory, + NodeShape, + NodeTypeDefinition, + SubsystemGraph, + NodeInstance, + Connection, + Annotation +} from '$lib/types/nodes'; + +// Re-export simulation types used in this module +export type { SolverType, SimulationSettings } from '$lib/types/simulation'; + +// Re-export schema types +export type { GraphFile } from '$lib/types/schema'; +export { GRAPH_FILE_VERSION } from '$lib/types/schema'; + +// Import extracted defaults from PathSim (generated by scripts/extract-simulation.py) +import { extractedSimulationParams, uiOnlyParams } from '$lib/simulation/generated/simulation'; +import type { SolverType, SimulationSettings } from '$lib/types/simulation'; + +// Helper to get default value from extracted params +function getExtractedDefault(key: string): string { + const param = extractedSimulationParams[key]; + return param?.default ?? ''; +} + +// Default values for simulation settings (used as placeholders and code gen fallback) +// Values extracted from PathSim via scripts/extract-simulation.py +export const DEFAULT_SIMULATION_SETTINGS: SimulationSettings = { + duration: getExtractedDefault('duration'), + dt: getExtractedDefault('dt'), + solver: (getExtractedDefault('solver') || 'SSPRK22') as SolverType, + adaptive: true, + atol: getExtractedDefault('atol'), + rtol: getExtractedDefault('rtol'), + ftol: getExtractedDefault('ftol'), + dt_min: getExtractedDefault('dt_min'), + dt_max: getExtractedDefault('dt_max'), + ghostTraces: parseInt(uiOnlyParams.ghostTraces?.default || '0'), + plotResults: uiOnlyParams.plotResults?.default === 'true' +}; + +// Initial empty settings (defaults shown as placeholders) +export const INITIAL_SIMULATION_SETTINGS: SimulationSettings = { + duration: '', + dt: '', + solver: 'SSPRK22', // Solver always has a value (selected from matrix) + adaptive: true, + atol: '', + rtol: '', + ftol: '', + dt_min: '', + dt_max: '', + ghostTraces: 0, + plotResults: true +}; diff --git a/src/lib/nodes/versionCheck.ts b/src/lib/nodes/versionCheck.ts new file mode 100644 index 00000000..77e0ba52 --- /dev/null +++ b/src/lib/nodes/versionCheck.ts @@ -0,0 +1,54 @@ +/** + * Version Check Utility + * + * Checks if generated code is in sync with PathSim version. + * Warns developers if extraction scripts need to be re-run. + */ + +import { GENERATED_VERSION } from './generated/version'; + +export interface VersionCheckResult { + current: string; + generated: string; + needsUpdate: boolean; +} + +/** + * Get the version the generated code was built for + */ +export function getGeneratedVersion(): typeof GENERATED_VERSION { + return GENERATED_VERSION; +} + +/** + * Check if versions match + * Note: This is called manually or from Python after PathSim is loaded + */ +export function checkVersion(currentPathSimVersion: string): VersionCheckResult { + const needsUpdate = currentPathSimVersion !== GENERATED_VERSION.pathsimVersion; + + if (needsUpdate) { + console.warn( + `[PathView] Generated code is for PathSim ${GENERATED_VERSION.pathsimVersion}, ` + + `but ${currentPathSimVersion} is installed. ` + + `Run 'npm run extract-all' to update.` + ); + } + + return { + current: currentPathSimVersion, + generated: GENERATED_VERSION.pathsimVersion, + needsUpdate + }; +} + +/** + * Log version info to console (for debugging) + */ +export function logVersionInfo(): void { + console.info('[PathView] Generated code info:', { + pathsimVersion: GENERATED_VERSION.pathsimVersion, + generatedAt: GENERATED_VERSION.generatedAt, + extractorVersion: GENERATED_VERSION.extractorVersion + }); +} diff --git a/src/lib/plotting/plotUtils.ts b/src/lib/plotting/plotUtils.ts new file mode 100644 index 00000000..009f9e30 --- /dev/null +++ b/src/lib/plotting/plotUtils.ts @@ -0,0 +1,336 @@ +/** + * Plotly.js configuration utilities + * Uses CSS variables from app.css for consistent theming + */ + +/** + * Read a CSS variable value from the document root + * Automatically reflects current theme (light/dark) + */ +function getCssVar(name: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +} + +/** + * Build base layout using CSS variables + * Reads current theme automatically from computed styles + */ +export function getBaseLayout(): Partial { + const textMuted = getCssVar('--text-muted'); + const textDisabled = getCssVar('--text-disabled'); + const surfaceRaised = getCssVar('--surface-raised'); + const border = getCssVar('--border'); + const accent = getCssVar('--accent'); + + return { + paper_bgcolor: 'transparent', + plot_bgcolor: 'transparent', + font: { + color: textMuted, + family: 'Inter, system-ui, sans-serif', + size: 11 + }, + margin: { + l: 60, + r: 15, + t: 10, + b: 45 + }, + xaxis: { + gridcolor: border, + gridwidth: 0.5, + zeroline: false, + linecolor: border, + linewidth: 1.5, + tickfont: { size: 10, color: textDisabled }, + title: { standoff: 10 } + }, + yaxis: { + gridcolor: border, + gridwidth: 0.5, + zeroline: false, + linecolor: border, + linewidth: 1.5, + tickfont: { size: 10, color: textDisabled }, + title: { standoff: 5 } + }, + legend: { + bgcolor: surfaceRaised, + bordercolor: border, + borderwidth: 1, + font: { size: 10, color: textMuted } + }, + modebar: { + bgcolor: 'transparent', + color: textDisabled, + activecolor: accent + }, + hoverlabel: { + bgcolor: surfaceRaised, + bordercolor: border, + font: { color: textMuted, size: 11 } + } + }; +} + +// Scope plot (time-domain) layout +export function getScopeLayout(title: string = 'Scope', showLegend: boolean = false): Partial { + const baseLayout = getBaseLayout(); + // Format y-axis label as "Scope: Name" or just "Scope" if no custom name + const yAxisLabel = title.toLowerCase().startsWith('scope') ? title : `Scope: ${title}`; + return { + ...baseLayout, + xaxis: { + ...baseLayout.xaxis, + title: { text: 'Time (s)', font: { size: 11 }, standoff: 10 } + }, + yaxis: { + ...baseLayout.yaxis, + title: { text: yAxisLabel, font: { size: 11 }, standoff: 5 } + }, + showlegend: showLegend, + legend: { + ...baseLayout.legend, + x: 0.01, + xanchor: 'left', + y: 0.99, + yanchor: 'top' + }, + hovermode: 'closest' + }; +} + +// Spectrum plot (frequency-domain) layout +// Uses index-based x-axis with frequency tick labels for equal spacing +export function getSpectrumLayout( + title: string = 'Spectrum', + frequencies?: number[], + showLegend: boolean = false +): Partial { + const baseLayout = getBaseLayout(); + + // Generate tick labels from frequency values if provided + let tickvals: number[] | undefined; + let ticktext: string[] | undefined; + + if (frequencies && frequencies.length > 0) { + // Pick ~6 evenly spaced tick positions + const numTicks = Math.min(6, frequencies.length); + const step = Math.max(1, Math.floor((frequencies.length - 1) / (numTicks - 1))); + tickvals = []; + ticktext = []; + + for (let i = 0; i < frequencies.length; i += step) { + tickvals.push(i); + ticktext.push(formatFrequency(frequencies[i])); + } + // Always include the last point + if (tickvals[tickvals.length - 1] !== frequencies.length - 1) { + tickvals.push(frequencies.length - 1); + ticktext.push(formatFrequency(frequencies[frequencies.length - 1])); + } + } + + // Format y-axis label as "Spectrum: Name" or just "Spectrum" if no custom name + const yAxisLabel = title.toLowerCase().startsWith('spectrum') ? title : `Spectrum: ${title}`; + + return { + ...baseLayout, + xaxis: { + ...baseLayout.xaxis, + title: { text: 'Frequency (Hz)', font: { size: 11 }, standoff: 10 }, + tickvals, + ticktext, + tickangle: 0 + }, + yaxis: { + ...baseLayout.yaxis, + title: { text: yAxisLabel, font: { size: 11 }, standoff: 5 }, + type: 'log' + }, + showlegend: showLegend, + legend: { + ...baseLayout.legend, + x: 0.01, + xanchor: 'left', + y: 0.99, + yanchor: 'top' + }, + hovermode: 'closest' + }; +} + +// Format frequency for display (compact notation) +function formatFrequency(freq: number): string { + if (freq >= 1e6) return (freq / 1e6).toFixed(1) + 'M'; + if (freq >= 1e3) return (freq / 1e3).toFixed(1) + 'k'; + if (freq >= 1) return freq.toFixed(1); + return freq.toExponential(1); +} + +// Signal colors for traces (after accent) - matches node color palette +const TRACE_COLORS = [ + '#E57373', // Red + '#81C784', // Green + '#64B5F6', // Blue + '#BA68C8', // Purple + '#4DD0E1', // Cyan + '#FFB74D', // Orange + '#F06292', // Pink + '#4DB6AC', // Teal + '#90A4AE' // Grey +]; + +/** + * Get signal color for a trace index + * First trace uses accent color, last uses edge color + */ +export function getSignalColor(index: number): string { + if (index === 0) { + return getCssVar('--accent'); + } + const traceIndex = (index - 1) % (TRACE_COLORS.length + 1); + if (traceIndex < TRACE_COLORS.length) { + return TRACE_COLORS[traceIndex]; + } + return getCssVar('--edge'); +} + +// Legacy export for backwards compatibility +export const SIGNAL_COLORS = [ + '#0070C0', // PathSim blue (accent) + ...TRACE_COLORS +]; + +// Common plot configuration +export const plotConfig: Partial = { + responsive: true, + displaylogo: false, + displayModeBar: 'hover', + modeBarButtonsToRemove: ['lasso2d', 'select2d'], + modeBarButtonsToAdd: [], + toImageButtonOptions: { + format: 'svg', + filename: 'pathview_plot', + height: 600, + width: 1000, + scale: 2 + }, + scrollZoom: true +}; + +// Always use SVG scatter - WebGL (scattergl) causes warnings during streaming updates +// SVG performance is sufficient for typical simulation datasets +const TRACE_TYPE = 'scatter' as const; + +// Create scope trace +export function createScopeTrace( + time: number[], + signal: number[], + index: number, + name?: string +): Partial { + const traceName = name || `port ${index}`; + const color = getSignalColor(index); + return { + x: time, + y: signal, + type: TRACE_TYPE, + mode: 'lines', + name: traceName, + legendgroup: `signal-${index}`, + line: { + color, + width: 1.5 + }, + hovertemplate: `${traceName}
t = %{x:.4g} s
y = %{y:.4g}` + }; +} + +// Create ghost scope trace (previous run) with reduced opacity +export function createGhostScopeTrace( + time: number[], + signal: number[], + signalIndex: number, + ghostIndex: number, + totalGhosts: number +): Partial { + // Linear opacity: 50% for most recent ghost, 20% for oldest + const opacity = totalGhosts === 1 + ? 0.5 + : 0.5 - (ghostIndex / (totalGhosts - 1)) * 0.3; + const baseColor = getSignalColor(signalIndex); + return { + x: time, + y: signal, + type: TRACE_TYPE, + mode: 'lines', + showlegend: false, + hoverinfo: 'skip', + legendgroup: `signal-${signalIndex}`, + line: { + color: baseColor, + width: 1, + }, + opacity + }; +} + +// Create spectrum trace - uses indices for equal spacing +export function createSpectrumTrace( + frequency: number[], + magnitude: number[], + index: number, + name?: string +): Partial { + const traceName = name || `port ${index}`; + const color = getSignalColor(index); + // Use indices for x-axis (equal spacing) + const indices = Array.from({ length: magnitude.length }, (_, i) => i); + return { + x: indices, + y: magnitude, + type: TRACE_TYPE, + mode: 'lines', + name: traceName, + legendgroup: `signal-${index}`, + line: { + color, + width: 1.5 + }, + // Store frequency in customdata for hover + customdata: frequency, + hovertemplate: `${traceName}
f = %{customdata:.2f} Hz
mag = %{y:.4g}` + }; +} + +// Create ghost spectrum trace (previous run) with reduced opacity +export function createGhostSpectrumTrace( + frequency: number[], + magnitude: number[], + signalIndex: number, + ghostIndex: number, + totalGhosts: number +): Partial { + // Linear opacity: 50% for most recent ghost, 20% for oldest + const opacity = totalGhosts === 1 + ? 0.5 + : 0.5 - (ghostIndex / (totalGhosts - 1)) * 0.3; + const baseColor = getSignalColor(signalIndex); + // Use indices for x-axis (equal spacing) + const indices = Array.from({ length: magnitude.length }, (_, i) => i); + return { + x: indices, + y: magnitude, + type: TRACE_TYPE, + mode: 'lines', + showlegend: false, + hoverinfo: 'skip', + legendgroup: `signal-${signalIndex}`, + line: { + color: baseColor, + width: 1, + }, + opacity + }; +} diff --git a/src/lib/pyodide/backend/index.ts b/src/lib/pyodide/backend/index.ts new file mode 100644 index 00000000..aea26ee5 --- /dev/null +++ b/src/lib/pyodide/backend/index.ts @@ -0,0 +1,142 @@ +/** + * Backend Module + * Provides a general-purpose streaming REPL interface for Python execution + * + * This module abstracts the Python execution backend, allowing different + * implementations (Pyodide, local server, remote) to be swapped. + */ + +// Re-export types +export type { Backend, BackendState, REPLRequest, REPLResponse } from './types'; + +// Re-export state store +export { backendState } from './state'; + +// Re-export registry +export { + getBackend, + createBackend, + switchBackend, + getBackendType, + hasBackend, + terminateBackend, + type BackendType +} from './registry'; + +// Re-export PyodideBackend for direct use if needed +export { PyodideBackend } from './pyodide/backend'; + +// ============================================================================ +// Backward-Compatible Convenience Functions +// These delegate to the current backend and maintain API compatibility +// ============================================================================ + +import { getBackend } from './registry'; +import { backendState } from './state'; +import { consoleStore } from '$lib/stores/console'; + +// Alias for backward compatibility +export const replState = { + subscribe: backendState.subscribe +}; + +/** + * Initialize the backend + */ +export async function init(): Promise { + const backend = getBackend(); + + // Set up console output callbacks + backend.onStdout((value) => consoleStore.output(value)); + backend.onStderr((value) => consoleStore.error(value)); + + // Log initialization progress + const unsubscribe = backend.subscribe((state) => { + if (state.progress && state.loading) { + consoleStore.info(state.progress); + } + }); + + try { + consoleStore.info('Initializing Python REPL...'); + await backend.init(); + consoleStore.info('Python REPL ready'); + } finally { + unsubscribe(); + } +} + +/** + * Terminate the backend + */ +export function terminate(): void { + getBackend().terminate(); +} + +/** + * Execute Python code (no return value) + */ +export async function exec(code: string, timeout?: number): Promise { + return getBackend().exec(code, timeout); +} + +/** + * Evaluate a Python expression and return the result + */ +export async function evaluate(expr: string, timeout?: number): Promise { + return getBackend().evaluate(expr, timeout); +} + +/** + * Start autonomous streaming + */ +export function startStreaming( + expr: string, + onData: (data: T) => void, + onDone: () => void, + onError: (error: Error) => void +): void { + getBackend().startStreaming(expr, onData, onDone, onError); +} + +/** + * Stop streaming + */ +export function stopStreaming(): void { + getBackend().stopStreaming(); +} + +/** + * Check if streaming is active + */ +export function isStreaming(): boolean { + return getBackend().isStreaming(); +} + +/** + * Execute code during active streaming (queued for next loop iteration) + */ +export function execDuringStreaming(code: string): void { + getBackend().execDuringStreaming(code); +} + +/** + * Check if backend is ready + */ +export function isReady(): boolean { + return getBackend().isReady(); +} + +/** + * Check if backend is loading + */ +export function isLoading(): boolean { + return getBackend().isLoading(); +} + +/** + * Get current error (if any) + */ +export function getError(): string | null { + return getBackend().getError(); +} diff --git a/src/lib/pyodide/backend/pyodide/backend.ts b/src/lib/pyodide/backend/pyodide/backend.ts new file mode 100644 index 00000000..93d4d1b0 --- /dev/null +++ b/src/lib/pyodide/backend/pyodide/backend.ts @@ -0,0 +1,429 @@ +/** + * Pyodide Backend + * Implements the Backend interface using Pyodide in a Web Worker + */ + +import { get } from 'svelte/store'; +import type { Backend, BackendState, REPLRequest, REPLResponse, REPLErrorResponse } from '../types'; +import { backendState } from '../state'; +import { TIMEOUTS } from '$lib/constants/python'; +import { PROGRESS_MESSAGES, STATUS_MESSAGES } from '$lib/constants/messages'; + +interface PendingRequest { + resolve: (value: string | undefined) => void; + reject: (error: Error) => void; + timeoutId: ReturnType; +} + +interface StreamState { + id: string | null; + onData: ((data: unknown) => void) | null; + onDone: (() => void) | null; + onError: ((error: Error) => void) | null; +} + +/** + * Pyodide Backend Implementation + * + * Runs Python code via Pyodide in a Web Worker. + * Supports streaming with code injection between generator steps. + */ +export class PyodideBackend implements Backend { + private worker: Worker | null = null; + private messageId = 0; + private pendingRequests = new Map(); + private isInitializing = false; + + private streamState: StreamState = { + id: null, + onData: null, + onDone: null, + onError: null + }; + + // Output callbacks + private stdoutCallback: ((value: string) => void) | null = null; + private stderrCallback: ((value: string) => void) | null = null; + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + async init(): Promise { + const state = this.getState(); + + // Already initialized or loading - nothing to do + if (this.worker && (state.initialized || state.loading)) { + return; + } + + // Terminate existing worker if it exists (e.g., after error) + if (this.worker) { + this.worker.terminate(); + this.worker = null; + } + + backendState.update((s) => ({ + ...s, + loading: true, + error: null, + progress: PROGRESS_MESSAGES.STARTING_WORKER + })); + this.isInitializing = true; + + try { + this.worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }); + + this.worker.onmessage = (event: MessageEvent) => { + this.handleResponse(event.data); + }; + + this.worker.onerror = (event) => { + backendState.update((s) => ({ + ...s, + loading: false, + error: event.message || 'Worker error' + })); + }; + + // Send init message + this.sendRequest({ type: 'init' }); + + // Wait for ready + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Initialization timeout')), + TIMEOUTS.INIT + ); + + const unsubscribe = backendState.subscribe((state) => { + if (state.initialized) { + clearTimeout(timeout); + unsubscribe(); + resolve(); + } + if (state.error) { + clearTimeout(timeout); + unsubscribe(); + reject(new Error(state.error)); + } + }); + }); + } catch (error) { + backendState.update((s) => ({ + ...s, + loading: false, + error: error instanceof Error ? error.message : String(error) + })); + throw error; + } + } + + terminate(): void { + // Reject all pending requests and clear their timeouts + for (const [, request] of this.pendingRequests) { + clearTimeout(request.timeoutId); + request.reject(new Error('Backend terminated')); + } + this.pendingRequests.clear(); + + // Clear stream state + this.streamState = { + id: null, + onData: null, + onDone: null, + onError: null + }; + + // Terminate worker + if (this.worker) { + this.worker.terminate(); + this.worker = null; + } + + // Reset state + backendState.reset(); + } + + // ------------------------------------------------------------------------- + // State + // ------------------------------------------------------------------------- + + getState(): BackendState { + return backendState.get(); + } + + subscribe(callback: (state: BackendState) => void): () => void { + return backendState.subscribe(callback); + } + + isReady(): boolean { + return this.getState().initialized; + } + + isLoading(): boolean { + return this.getState().loading; + } + + getError(): string | null { + return this.getState().error; + } + + // ------------------------------------------------------------------------- + // Execution + // ------------------------------------------------------------------------- + + async exec(code: string, timeout: number = TIMEOUTS.SIMULATION): Promise { + if (!this.worker) { + await this.init(); + } + + if (!this.isReady()) { + throw new Error('Backend not initialized'); + } + + const id = this.generateId(); + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + if (this.pendingRequests.has(id)) { + this.pendingRequests.delete(id); + reject(new Error('Execution timeout')); + } + }, timeout); + + this.pendingRequests.set(id, { + resolve: () => resolve(), + reject, + timeoutId + }); + + this.sendRequest({ type: 'exec', id, code }); + }); + } + + async evaluate(expr: string, timeout: number = TIMEOUTS.SIMULATION): Promise { + if (!this.worker) { + await this.init(); + } + + if (!this.isReady()) { + throw new Error('Backend not initialized'); + } + + const id = this.generateId(); + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + if (this.pendingRequests.has(id)) { + this.pendingRequests.delete(id); + reject(new Error('Evaluation timeout')); + } + }, timeout); + + this.pendingRequests.set(id, { + resolve: (value) => { + if (value === undefined) { + reject(new Error('No value returned from eval')); + return; + } + try { + resolve(JSON.parse(value) as T); + } catch { + reject(new Error(`Failed to parse eval result: ${value}`)); + } + }, + reject, + timeoutId + }); + + this.sendRequest({ type: 'eval', id, expr }); + }); + } + + // ------------------------------------------------------------------------- + // Streaming + // ------------------------------------------------------------------------- + + startStreaming( + expr: string, + onData: (data: T) => void, + onDone: () => void, + onError: (error: Error) => void + ): void { + if (!this.worker) { + onError(new Error('Backend not initialized')); + return; + } + + if (!this.isReady()) { + onError(new Error('Backend not initialized')); + return; + } + + // Stop any existing stream + if (this.streamState.id) { + this.stopStreaming(); + } + + const id = this.generateId(); + this.streamState = { + id, + onData: onData as (data: unknown) => void, + onDone, + onError + }; + + this.sendRequest({ type: 'stream-start', id, expr }); + } + + stopStreaming(): void { + if (!this.worker || !this.streamState.id) return; + + // Just send stop message - worker will send stream-done which triggers callback + this.sendRequest({ type: 'stream-stop' }); + } + + isStreaming(): boolean { + return this.streamState.id !== null; + } + + execDuringStreaming(code: string): void { + if (!this.worker || !this.streamState.id) { + console.warn('Cannot exec during streaming: no active stream'); + return; + } + this.sendRequest({ type: 'stream-exec', code }); + } + + // ------------------------------------------------------------------------- + // Output Callbacks + // ------------------------------------------------------------------------- + + onStdout(callback: (value: string) => void): void { + this.stdoutCallback = callback; + } + + onStderr(callback: (value: string) => void): void { + this.stderrCallback = callback; + } + + // ------------------------------------------------------------------------- + // Private Methods + // ------------------------------------------------------------------------- + + private generateId(): string { + return `repl_${++this.messageId}`; + } + + private sendRequest(request: REPLRequest): void { + if (!this.worker) { + throw new Error('Worker not initialized'); + } + this.worker.postMessage(request); + } + + private handleResponse(response: REPLResponse): void { + switch (response.type) { + case 'ready': + backendState.update((s) => ({ + ...s, + initialized: true, + loading: false, + progress: STATUS_MESSAGES.READY + })); + this.isInitializing = false; + break; + + case 'progress': + backendState.update((s) => ({ ...s, progress: response.value || '' })); + break; + + case 'ok': + if (response.id && this.pendingRequests.has(response.id)) { + const pending = this.pendingRequests.get(response.id)!; + clearTimeout(pending.timeoutId); + pending.resolve(undefined); + this.pendingRequests.delete(response.id); + } + break; + + case 'value': + if (response.id && this.pendingRequests.has(response.id)) { + const pending = this.pendingRequests.get(response.id)!; + clearTimeout(pending.timeoutId); + pending.resolve(response.value); + this.pendingRequests.delete(response.id); + } + break; + + case 'error': + this.handleError(response); + break; + + case 'stdout': + if (response.value && this.stdoutCallback) { + this.stdoutCallback(response.value); + } + break; + + case 'stderr': + if (response.value && this.stderrCallback) { + this.stderrCallback(response.value); + } + break; + + case 'stream-data': + if ( + response.id === this.streamState.id && + this.streamState.onData && + response.value + ) { + try { + this.streamState.onData(JSON.parse(response.value)); + } catch { + // Ignore parse errors + } + } + break; + + case 'stream-done': + if (response.id === this.streamState.id && this.streamState.onDone) { + this.streamState.onDone(); + this.streamState = { + id: null, + onData: null, + onDone: null, + onError: null + }; + } + break; + } + } + + private handleError(response: REPLErrorResponse): void { + const { id, error, traceback } = response; + const errorMsg = traceback ? `${error}\n${traceback}` : error || 'Unknown error'; + + // Handle pending request errors + if (id && this.pendingRequests.has(id)) { + const pending = this.pendingRequests.get(id)!; + clearTimeout(pending.timeoutId); + pending.reject(new Error(errorMsg)); + this.pendingRequests.delete(id); + } + + // Handle streaming errors + if (id === this.streamState.id && this.streamState.onError) { + this.streamState.onError(new Error(errorMsg)); + this.streamState = { + id: null, + onData: null, + onDone: null, + onError: null + }; + } + + backendState.update((s) => ({ ...s, error: error || 'Unknown error' })); + } +} diff --git a/src/lib/pyodide/backend/pyodide/worker.ts b/src/lib/pyodide/backend/pyodide/worker.ts new file mode 100644 index 00000000..07f05564 --- /dev/null +++ b/src/lib/pyodide/backend/pyodide/worker.ts @@ -0,0 +1,264 @@ +/** + * Pyodide Web Worker + * Executes Python code via Pyodide in a separate thread + */ + +import { PYODIDE_CDN_URL } from '$lib/constants/python'; +import { PROGRESS_MESSAGES, ERROR_MESSAGES } from '$lib/constants/messages'; +import type { REPLRequest, REPLResponse } from '../types'; + +import type { PyodideInterface } from 'https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.mjs'; + +let pyodide: PyodideInterface | null = null; +let isInitialized = false; +let streamingActive = false; +const streamingCodeQueue: string[] = []; + +/** + * Send a response to the main thread + */ +function send(response: REPLResponse): void { + postMessage(response); +} + +/** + * Initialize Pyodide and install packages + */ +async function initialize(): Promise { + if (isInitialized) { + send({ type: 'ready' }); + return; + } + + send({ type: 'progress', value: PROGRESS_MESSAGES.LOADING_PYODIDE }); + + const { loadPyodide } = await import( + /* @vite-ignore */ + PYODIDE_CDN_URL + ); + + pyodide = await loadPyodide(); + if (!pyodide) throw new Error(ERROR_MESSAGES.FAILED_TO_LOAD_PYODIDE); + + // Capture stdout/stderr + pyodide.setStdout({ + batched: (msg: string) => send({ type: 'stdout', value: msg }) + }); + pyodide.setStderr({ + batched: (msg: string) => send({ type: 'stderr', value: msg }) + }); + + send({ type: 'progress', value: PROGRESS_MESSAGES.INSTALLING_DEPS }); + await pyodide.loadPackage(['numpy', 'scipy', 'micropip']); + + send({ type: 'progress', value: PROGRESS_MESSAGES.INSTALLING_PATHSIM }); + await pyodide.runPythonAsync(` +import micropip +await micropip.install('pathsim') + `); + + // Verify and print version + await pyodide.runPythonAsync(` +import pathsim +print(f"PathSim {pathsim.__version__} loaded successfully") + `); + + // Import numpy as np and gc globally + await pyodide.runPythonAsync(`import numpy as np`); + await pyodide.runPythonAsync(`import gc`); + + // Capture clean state for later cleanup + await pyodide.runPythonAsync(`_clean_globals = set(globals().keys())`); + + isInitialized = true; + send({ type: 'ready' }); +} + +/** + * Execute Python code (no return value) + */ +async function execCode(id: string, code: string): Promise { + if (!pyodide) throw new Error(ERROR_MESSAGES.WORKER_NOT_INITIALIZED); + + try { + await pyodide.runPythonAsync(code); + send({ type: 'ok', id }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + // Try to get traceback + let traceback: string | undefined; + try { + traceback = (await pyodide.runPythonAsync(` +import traceback +traceback.format_exc() + `)) as string; + } catch { + // Ignore traceback extraction errors + } + send({ type: 'error', id, error: errorMsg, traceback }); + } +} + +/** + * Evaluate Python expression and return JSON result + * Note: _to_json helper is injected via REPL_SETUP_CODE + */ +async function evalExpr(id: string, expr: string): Promise { + if (!pyodide) throw new Error(ERROR_MESSAGES.WORKER_NOT_INITIALIZED); + + try { + const result = await pyodide.runPythonAsync(` +_eval_result = ${expr} +json.dumps(_eval_result, default=_to_json if '_to_json' in dir() else str) + `); + + send({ type: 'value', id, value: result as string }); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + let traceback: string | undefined; + try { + traceback = (await pyodide.runPythonAsync(` +import traceback +traceback.format_exc() + `)) as string; + } catch { + // Ignore + } + send({ type: 'error', id, error: errorMsg, traceback }); + } +} + +/** + * Run streaming loop - steps generator continuously and posts results + * Runs autonomously until done or stopped + */ +async function runStreamingLoop(id: string, expr: string): Promise { + if (!pyodide) throw new Error(ERROR_MESSAGES.WORKER_NOT_INITIALIZED); + + streamingActive = true; + // Clear any stale code from previous runs + streamingCodeQueue.length = 0; + + try { + while (streamingActive) { + // Execute any queued code first (for runtime parameter changes, events, etc.) + // Errors in queued code are reported but don't stop the simulation + while (streamingCodeQueue.length > 0) { + const code = streamingCodeQueue.shift()!; + try { + await pyodide.runPythonAsync(code); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + send({ type: 'stderr', value: `Stream exec error: ${errorMsg}` }); + } + } + + // Step the generator + const result = await pyodide.runPythonAsync(` +_eval_result = ${expr} +json.dumps(_eval_result, default=_to_json if '_to_json' in dir() else str) + `); + + // Parse result + const parsed = JSON.parse(result as string); + + // Check if stopped during Python execution - still send final data + if (!streamingActive) { + if (!parsed.done && parsed.result) { + send({ type: 'stream-data', id, value: result as string }); + } + break; + } + + // Check if simulation completed + if (parsed.done) { + break; + } + + // Send result and continue + send({ type: 'stream-data', id, value: result as string }); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + let traceback: string | undefined; + try { + traceback = (await pyodide.runPythonAsync(` +import traceback +traceback.format_exc() + `)) as string; + } catch { + // Ignore + } + send({ type: 'error', id, error: errorMsg, traceback }); + } finally { + streamingActive = false; + // Always send done when loop ends (whether completed, stopped, or error) + send({ type: 'stream-done', id }); + } +} + +/** + * Stop streaming loop + */ +function stopStreaming(): void { + streamingActive = false; +} + +// Handle messages from main thread +self.onmessage = async (event: MessageEvent) => { + const { type } = event.data; + // Extract fields based on request type + const id = 'id' in event.data ? event.data.id : undefined; + const code = 'code' in event.data ? event.data.code : undefined; + const expr = 'expr' in event.data ? event.data.expr : undefined; + + try { + switch (type) { + case 'init': + await initialize(); + break; + + case 'exec': + if (!id || typeof code !== 'string') { + throw new Error('Invalid exec request: missing id or code'); + } + await execCode(id, code); + break; + + case 'eval': + if (!id || typeof expr !== 'string') { + throw new Error('Invalid eval request: missing id or expr'); + } + await evalExpr(id, expr); + break; + + case 'stream-start': + if (!id || typeof expr !== 'string') { + throw new Error('Invalid stream-start request: missing id or expr'); + } + // Don't await - let it run autonomously + runStreamingLoop(id, expr); + break; + + case 'stream-stop': + stopStreaming(); + break; + + case 'stream-exec': + if (typeof code === 'string' && streamingActive) { + // Queue code to be executed between generator steps + streamingCodeQueue.push(code); + } + break; + + default: + throw new Error(`Unknown message type: ${type}`); + } + } catch (error) { + send({ + type: 'error', + id, + error: error instanceof Error ? error.message : String(error) + }); + } +}; diff --git a/src/lib/pyodide/backend/registry.ts b/src/lib/pyodide/backend/registry.ts new file mode 100644 index 00000000..e1eaaddc --- /dev/null +++ b/src/lib/pyodide/backend/registry.ts @@ -0,0 +1,76 @@ +/** + * Backend Registry + * Factory and management for Python execution backends + */ + +import type { Backend } from './types'; +import { PyodideBackend } from './pyodide/backend'; + +export type BackendType = 'pyodide' | 'local' | 'remote'; + +let currentBackend: Backend | null = null; +let currentBackendType: BackendType | null = null; + +/** + * Get the current backend, creating a Pyodide backend if none exists + */ +export function getBackend(): Backend { + if (!currentBackend) { + currentBackend = createBackend('pyodide'); + currentBackendType = 'pyodide'; + } + return currentBackend; +} + +/** + * Create a backend by type + */ +export function createBackend(type: BackendType): Backend { + switch (type) { + case 'pyodide': + return new PyodideBackend(); + case 'local': + case 'remote': + throw new Error(`Backend type '${type}' not yet implemented`); + default: + throw new Error(`Unknown backend type: ${type}`); + } +} + +/** + * Switch to a different backend + * Terminates the current backend before creating the new one + */ +export function switchBackend(type: BackendType): Backend { + if (currentBackend) { + currentBackend.terminate(); + } + currentBackend = createBackend(type); + currentBackendType = type; + return currentBackend; +} + +/** + * Get the current backend type + */ +export function getBackendType(): BackendType | null { + return currentBackendType; +} + +/** + * Check if a backend is currently active + */ +export function hasBackend(): boolean { + return currentBackend !== null; +} + +/** + * Terminate the current backend + */ +export function terminateBackend(): void { + if (currentBackend) { + currentBackend.terminate(); + currentBackend = null; + currentBackendType = null; + } +} diff --git a/src/lib/pyodide/backend/state.ts b/src/lib/pyodide/backend/state.ts new file mode 100644 index 00000000..fcc69084 --- /dev/null +++ b/src/lib/pyodide/backend/state.ts @@ -0,0 +1,28 @@ +/** + * Backend State Store + * Shared state management for all backend implementations + */ + +import { writable, get } from 'svelte/store'; +import type { BackendState } from './types'; + +const initialState: BackendState = { + initialized: false, + loading: false, + error: null, + progress: '' +}; + +const internal = writable(initialState); + +/** + * Backend state store + * Tracks initialization status, loading state, errors, and progress + */ +export const backendState = { + subscribe: internal.subscribe, + get: () => get(internal), + set: internal.set, + update: internal.update, + reset: () => internal.set(initialState) +}; diff --git a/src/lib/pyodide/backend/types.ts b/src/lib/pyodide/backend/types.ts new file mode 100644 index 00000000..a37418ad --- /dev/null +++ b/src/lib/pyodide/backend/types.ts @@ -0,0 +1,207 @@ +/** + * REPL Backend Types + * Transport-agnostic interface for Python execution backends + */ + +// ============================================================================ +// Protocol Types +// ============================================================================ + +/** + * Request messages (main thread → backend) + */ +export type REPLRequest = + | { type: 'init' } + | { type: 'exec'; id: string; code: string } + | { type: 'eval'; id: string; expr: string } + | { type: 'stream-start'; id: string; expr: string } + | { type: 'stream-stop' } + | { type: 'stream-exec'; code: string }; + +/** + * Error response variant (extracted for type-safe handling) + */ +export type REPLErrorResponse = { type: 'error'; id?: string; error: string; traceback?: string }; + +/** + * Response messages (backend → main thread) + */ +export type REPLResponse = + | { type: 'ready' } + | { type: 'ok'; id: string } + | { type: 'value'; id: string; value: string } + | REPLErrorResponse + | { type: 'stdout'; value: string } + | { type: 'stderr'; value: string } + | { type: 'progress'; value: string } + | { type: 'stream-data'; id: string; value: string } + | { type: 'stream-done'; id: string }; + +// ============================================================================ +// Backend State +// ============================================================================ + +/** + * Backend state tracked by the main thread + */ +export interface BackendState { + initialized: boolean; + loading: boolean; + error: string | null; + progress: string; +} + +// ============================================================================ +// Backend Interface +// ============================================================================ + +/** + * Backend Interface + * + * Any Python execution backend must implement this interface. + * Backends handle the actual execution of Python code, whether via + * Pyodide in a Web Worker, a local Flask server, or a remote service. + * + * ## Lifecycle + * - `init()` - Initialize the backend (load runtime, connect, etc.) + * - `terminate()` - Clean up resources + * + * ## Execution + * - `exec(code)` - Execute Python code (no return value) + * - `evaluate(expr)` - Evaluate expression and return JSON result + * + * ## Streaming + * The streaming API runs an autonomous loop that: + * 1. Processes queued code (from `execDuringStreaming`) + * 2. Steps a generator expression + * 3. Sends results via callback + * + * This enables live simulation updates while allowing runtime + * parameter changes via code injection between steps. + */ +export interface Backend { + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + /** + * Initialize the backend + * Resolves when ready to execute code + */ + init(): Promise; + + /** + * Terminate the backend and clean up resources + */ + terminate(): void; + + // ------------------------------------------------------------------------- + // State + // ------------------------------------------------------------------------- + + /** + * Get current backend state + */ + getState(): BackendState; + + /** + * Subscribe to state changes + * @returns Unsubscribe function + */ + subscribe(callback: (state: BackendState) => void): () => void; + + /** + * Check if backend is ready + */ + isReady(): boolean; + + /** + * Check if backend is loading + */ + isLoading(): boolean; + + /** + * Get current error (if any) + */ + getError(): string | null; + + // ------------------------------------------------------------------------- + // Execution + // ------------------------------------------------------------------------- + + /** + * Execute Python code (no return value) + * @param code - Python code to execute + * @param timeout - Optional timeout in milliseconds + */ + exec(code: string, timeout?: number): Promise; + + /** + * Evaluate a Python expression and return the result + * @param expr - Python expression to evaluate + * @param timeout - Optional timeout in milliseconds + * @returns Parsed JSON result + */ + evaluate(expr: string, timeout?: number): Promise; + + // ------------------------------------------------------------------------- + // Streaming + // ------------------------------------------------------------------------- + + /** + * Start autonomous streaming loop + * + * The backend will continuously: + * 1. Execute any queued code (from execDuringStreaming) + * 2. Evaluate the expression + * 3. Call onData with the result + * 4. Repeat until expression returns {done: true} or stopStreaming is called + * + * @param expr - Expression to evaluate repeatedly (should return {done, result}) + * @param onData - Callback for each result + * @param onDone - Callback when streaming completes + * @param onError - Callback for errors + */ + startStreaming( + expr: string, + onData: (data: T) => void, + onDone: () => void, + onError: (error: Error) => void + ): void; + + /** + * Stop the streaming loop + * The backend will send stream-done when it actually stops + */ + stopStreaming(): void; + + /** + * Check if streaming is active + */ + isStreaming(): boolean; + + /** + * Execute code during active streaming + * + * Code is queued and executed between generator steps. + * Use this for runtime parameter changes, event injection, etc. + * Errors in queued code are reported but don't stop the stream. + * + * @param code - Python code to execute + */ + execDuringStreaming(code: string): void; + + // ------------------------------------------------------------------------- + // Output Callbacks + // ------------------------------------------------------------------------- + + /** + * Set callback for stdout output + */ + onStdout(callback: (value: string) => void): void; + + /** + * Set callback for stderr output + */ + onStderr(callback: (value: string) => void): void; +} diff --git a/src/lib/pyodide/bridge.ts b/src/lib/pyodide/bridge.ts new file mode 100644 index 00000000..e9defab4 --- /dev/null +++ b/src/lib/pyodide/bridge.ts @@ -0,0 +1,616 @@ +/** + * Pyodide Bridge + * Main-thread interface for running PathSim simulations + * + * This module provides the high-level API for simulation operations, + * implemented using the low-level REPL primitives (exec/eval). + */ + +import { writable, get } from 'svelte/store'; +import { consoleStore } from '$lib/stores/console'; +import { settingsStore } from '$lib/stores/settings'; +import { TIMEOUTS } from '$lib/constants/python'; +import { PROGRESS_MESSAGES, STATUS_MESSAGES, SUCCESS_MESSAGES } from '$lib/constants/messages'; + +// Import backend primitives +import { + init as initRepl, + exec, + evaluate, + terminate as terminateRepl, + replState, + startStreaming, + stopStreaming as stopReplStreaming, + execDuringStreaming +} from './backend'; + +// Re-export for use in other modules +export { execDuringStreaming }; + +// Re-export replState as pyodideState for backwards compatibility +export { replState as pyodideState }; + +// Import Python helpers +import { + REPL_SETUP_CODE, + generateRunCode, + EXTRACT_RESULTS_EXPR, + generateValidationSetupCode, + generateParamValidationCode, + VALIDATION_RESULT_EXPR, + CLEAR_STATE_CODE, + CLEANUP_TEMP_CODE, + toBase64, + generateStreamingStartCode, + STREAMING_STEP_EXPR, + STREAMING_STOP_CODE +} from './pythonHelpers'; + +// Result types +export interface SimulationResult { + scopeData: Record< + string, + { + time: number[]; + signals: number[][]; + labels?: string[]; + } + >; + spectrumData: Record< + string, + { + frequency: number[]; + magnitude: number[][]; + labels?: string[]; + } + >; + nodeNames: Record; +} + +export interface ValidationError { + nodeId: string; + param: string; + error: string; +} + +export interface ValidationResult { + valid: boolean; + errors: ValidationError[]; +} + +// Simulation phases for UI state management +export type SimulationPhase = 'idle' | 'starting' | 'running' | 'complete' | 'error'; + +// Store for simulation state +const initialSimulationState = { + phase: 'idle' as SimulationPhase, + progress: '', + error: null as string | null, + result: null as SimulationResult | null, + resultHistory: [] as SimulationResult[] +}; + +export const simulationState = { + ...writable({ ...initialSimulationState }), + reset() { + this.set({ ...initialSimulationState, resultHistory: [] }); + } +}; + +/** + * Create empty result structure preserving node IDs but clearing data. + * This keeps plots mounted while showing loading state. + */ +function createEmptyResultStructure(result: SimulationResult | null): SimulationResult { + if (!result) { + return { scopeData: {}, spectrumData: {}, nodeNames: {} }; + } + return { + scopeData: Object.fromEntries( + Object.entries(result.scopeData).map(([id, data]) => [ + id, + { time: [], signals: data.signals.map(() => []), labels: data.labels } + ]) + ), + spectrumData: Object.fromEntries( + Object.entries(result.spectrumData).map(([id, data]) => [ + id, + { frequency: [], magnitude: data.magnitude.map(() => []), labels: data.labels } + ]) + ), + nodeNames: { ...result.nodeNames } + }; +} + +// Track if helper functions have been injected +let helpersInjected = false; + +// Track if streaming simulation is active +let streamingActive = false; + +/** + * Compute updated result history for ghost traces + * Uses user's ghostTraces setting (constrained to 0-6 by UI) + */ +function computeResultHistory( + currentResult: SimulationResult | null, + currentHistory: SimulationResult[], + maxHistory: number +): SimulationResult[] { + if (maxHistory === 0) return []; + if (!currentResult) return currentHistory; + return [currentResult, ...currentHistory].slice(0, maxHistory); +} + +/** + * Merge incremental streaming result with accumulated result + * Scope data is appended, spectrum data is replaced + */ +function mergeStreamingResult( + accumulated: SimulationResult | null, + incremental: SimulationResult +): SimulationResult { + if (!accumulated) { + return incremental; + } + + // Merge scope data by appending time and signals + const mergedScopeData: SimulationResult['scopeData'] = { ...accumulated.scopeData }; + for (const [id, data] of Object.entries(incremental.scopeData)) { + if (mergedScopeData[id]) { + // Append to existing scope data + mergedScopeData[id] = { + time: [...mergedScopeData[id].time, ...data.time], + signals: mergedScopeData[id].signals.map((sig, i) => [...sig, ...(data.signals[i] || [])]), + labels: data.labels || mergedScopeData[id].labels + }; + } else { + // New scope + mergedScopeData[id] = data; + } + } + + return { + scopeData: mergedScopeData, + // Spectrum data is not incremental, just use latest + spectrumData: incremental.spectrumData, + // Merge node names + nodeNames: { ...accumulated.nodeNames, ...incremental.nodeNames } + }; +} + +/** + * Inject helper functions into Python namespace + */ +async function injectHelpers(): Promise { + if (helpersInjected) return; + await exec(REPL_SETUP_CODE); + helpersInjected = true; +} + +/** + * Initialize Pyodide + */ +export async function initPyodide(): Promise { + await initRepl(); + await injectHelpers(); +} + +/** + * Streaming step result from Python + */ +interface StreamingStepResult { + done: boolean; + result: SimulationResult | null; +} + +// Streaming configuration +const STREAMING_TICKRATE = 10; // Hz - how often Python yields data +const UI_UPDATE_RATE = 10; // Hz - max UI update frequency (decoupled from simulation) + +/** + * Runs the rAF-based streaming loop that consumes results from the worker. + * Decouples simulation rate from UI update rate. + * + * @param initialResult - Starting accumulated result (null for new run, existing for continue) + * @param onUpdate - Optional callback for each UI update + * @returns Final accumulated result + */ +async function runStreamingLoop( + initialResult: SimulationResult | null, + onUpdate?: (result: SimulationResult) => void +): Promise { + let accumulatedResult = initialResult; + const resultQueue: SimulationResult[] = []; + let rafId: number | null = null; + let lastUIUpdate = 0; + let finalResult: SimulationResult | null = initialResult; + + // rAF loop consumes queue and updates UI at display rate + function updateUI(timestamp: number) { + // Drain queue and merge all pending results + if (resultQueue.length > 0 && timestamp - lastUIUpdate >= 1000 / UI_UPDATE_RATE) { + while (resultQueue.length > 0) { + const result = resultQueue.shift()!; + accumulatedResult = mergeStreamingResult(accumulatedResult, result); + } + finalResult = accumulatedResult; + lastUIUpdate = timestamp; + + if (streamingActive) { + simulationState.update((s) => ({ + ...s, + result: accumulatedResult + })); + if (onUpdate && accumulatedResult) { + onUpdate(accumulatedResult); + } + } + } + + // Continue if still streaming + if (streamingActive) { + rafId = requestAnimationFrame(updateUI); + } + } + + // Start rAF loop + rafId = requestAnimationFrame(updateUI); + + // Start autonomous streaming - worker runs loop and pushes results + await new Promise((resolve, reject) => { + startStreaming( + STREAMING_STEP_EXPR, + // onData - push to queue + (stepResult) => { + if (stepResult.result) { + resultQueue.push(stepResult.result); + } + }, + // onDone + () => resolve(), + // onError + (error) => reject(error) + ); + }); + + // Process any remaining queued results + while (resultQueue.length > 0) { + const result = resultQueue.shift()!; + accumulatedResult = mergeStreamingResult(accumulatedResult, result); + } + finalResult = accumulatedResult; + + // Stop rAF loop + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + + return finalResult; +} + +/** + * Run a streaming simulation with live updates + * @param code - Setup code that creates blocks, connections, and sim + * @param duration - Simulation duration expression + * @param onUpdate - Callback for each streaming update + */ +export async function runStreamingSimulation( + code: string, + duration: string, + onUpdate?: (result: SimulationResult) => void +): Promise { + // Ensure initialized + const state = get(replState); + if (!state.initialized) { + await initRepl(); + } + + streamingActive = true; + + // Update simulation state - preserve result structure for smooth transition + const ghostTraces = settingsStore.get().ghostTraces; + simulationState.update((s) => ({ + ...s, + phase: 'starting', + progress: PROGRESS_MESSAGES.STARTING_SIMULATION, + error: null, + // With ghost traces: clear data so ghosts are visible without duplicate + // Without ghost traces: keep old result visible until new data arrives + result: ghostTraces > 0 ? createEmptyResultStructure(s.result) : s.result, + resultHistory: computeResultHistory(s.result, s.resultHistory, ghostTraces) + })); + + try { + // Clear previous simulation state, then inject helpers fresh + await exec(CLEAR_STATE_CODE); + helpersInjected = false; + await injectHelpers(); + + // Execute setup code (creates blocks, connections, sim) + const wrappedCode = generateRunCode(code); + await exec(wrappedCode); + + // Start streaming generator with optimized tickrate + await exec(generateStreamingStartCode(duration, STREAMING_TICKRATE)); + + // Update phase to running + simulationState.update((s) => ({ ...s, phase: 'running' })); + consoleStore.info('Streaming simulation started'); + + // Run streaming loop (starts from null for new simulation) + const finalResult = await runStreamingLoop(null, onUpdate); + + // Clean up streaming state + await exec(STREAMING_STOP_CODE); + await exec(CLEANUP_TEMP_CODE); + + // Update state with final phase + // If stopped externally AND state was reset (result is null), don't overwrite + simulationState.update((s) => { + if (!streamingActive && s.result === null) { + return s; + } + return { + ...s, + phase: streamingActive ? 'complete' : 'idle', + progress: streamingActive ? STATUS_MESSAGES.COMPLETE : STATUS_MESSAGES.STOPPED, + result: finalResult + }; + }); + + if (streamingActive) { + consoleStore.info(SUCCESS_MESSAGES.SIMULATION_COMPLETED); + } + + return finalResult; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + consoleStore.error(`Streaming simulation failed: ${errorMsg}`); + + // Clean up on error + try { + await exec(STREAMING_STOP_CODE); + } catch { + // Ignore cleanup errors + } + + simulationState.update((s) => ({ + ...s, + phase: 'error', + error: errorMsg + })); + + throw error; + } finally { + streamingActive = false; + } +} + +/** + * Continue a streaming simulation from where it left off + * @param durationExpr - Duration expression + * @param onUpdate - Callback for each streaming update + */ +export async function continueStreamingSimulation( + durationExpr: string, + onUpdate?: (result: SimulationResult) => void +): Promise { + const state = get(replState); + if (!state.initialized) { + throw new Error('No simulation to continue. Run a simulation first.'); + } + + await injectHelpers(); + + streamingActive = true; + + simulationState.update((s) => ({ + ...s, + phase: 'starting', + progress: 'Continuing simulation...', + error: null, + // Keep existing result visible while continuing + resultHistory: computeResultHistory(s.result, s.resultHistory, settingsStore.get().ghostTraces) + })); + + try { + // Check if simulation exists + await exec(` +if 'sim' not in dir() or sim is None: + raise RuntimeError("No simulation to continue. Run a simulation first.") + `); + + // Start streaming generator with reset=False and optimized tickrate + await exec(generateStreamingStartCode(durationExpr, STREAMING_TICKRATE, false)); + + // Update phase to running + simulationState.update((s) => ({ ...s, phase: 'running' })); + consoleStore.info('Continuing simulation (streaming)...'); + + // Run streaming loop (starts from existing result for continuation) + const existingResult = get(simulationState).result; + const finalResult = await runStreamingLoop(existingResult, onUpdate); + + // Clean up + await exec(STREAMING_STOP_CODE); + + // Update state with final result + // If stopped externally AND state was reset (result is null), don't overwrite + simulationState.update((s) => { + if (!streamingActive && s.result === null) { + return s; + } + return { + ...s, + phase: streamingActive ? 'complete' : 'idle', + progress: streamingActive ? STATUS_MESSAGES.COMPLETE : STATUS_MESSAGES.STOPPED, + result: finalResult + }; + }); + + if (streamingActive) { + consoleStore.info('Simulation continued successfully'); + } + + return finalResult; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + consoleStore.error(`Continue simulation failed: ${errorMsg}`); + + try { + await exec(STREAMING_STOP_CODE); + } catch { + // Ignore cleanup errors + } + + simulationState.update((s) => ({ + ...s, + phase: 'error', + error: errorMsg + })); + + throw error; + } finally { + streamingActive = false; + } +} + +/** + * Reset simulation state completely. + * Use when loading a new model or creating a new graph. + * Stops any running simulation, clears results, and clears Python state. + */ +export async function resetSimulation(): Promise { + // Stop streaming immediately (synchronous part) + stopReplStreaming(); + streamingActive = false; + + // Reset state store (clears results) + simulationState.reset(); + + // Clear Python state (async, may fail if not initialized) + const state = get(replState); + if (state.initialized) { + try { + // Stop generator and clear namespace + await exec(STREAMING_STOP_CODE, TIMEOUTS.VALIDATION); + await exec(CLEAR_STATE_CODE); + } catch { + // Ignore errors during cleanup + } + } + + // Reset helpers flag so they get re-injected on next simulation + helpersInjected = false; +} + +/** + * Validate code context and parameter expressions + */ +export async function validateGraph( + codeContext: string, + nodeParams: Record> +): Promise { + const state = get(replState); + if (!state.initialized) { + await initPyodide(); + } + + try { + // Setup validation namespace and validate code context + const codeContextBase64 = toBase64(codeContext); + await exec(generateValidationSetupCode(codeContextBase64), TIMEOUTS.VALIDATION); + + // Validate parameter expressions + const nodeParamsBase64 = toBase64(JSON.stringify(nodeParams)); + await exec(generateParamValidationCode(nodeParamsBase64), TIMEOUTS.VALIDATION); + + // Get validation result + const result = await evaluate(VALIDATION_RESULT_EXPR, TIMEOUTS.VALIDATION); + + // Clean up + await exec(CLEANUP_TEMP_CODE); + + return result; + } catch (error) { + // If validation itself fails, return as error + return { + valid: false, + errors: [ + { + nodeId: '__validation__', + param: '', + error: error instanceof Error ? error.message : String(error) + } + ] + }; + } +} + +/** + * Stop the current simulation gracefully + * Sends sim.stop() to Python which will stop at the next timestep + * Also aborts streaming loop if active + */ +export async function stopSimulation(): Promise { + // Stop REPL streaming first + stopReplStreaming(); + + // Mark streaming as inactive + streamingActive = false; + + // Update phase but keep results visible + simulationState.update((s) => ({ + ...s, + phase: 'idle', + progress: STATUS_MESSAGES.STOPPED, + error: null + })); + + const state = get(replState); + if (!state.initialized) return; + + try { + // Stop streaming generator if active + await exec(STREAMING_STOP_CODE, TIMEOUTS.VALIDATION); + + // Send stop signal to simulation + await exec(` +if 'sim' in dir() and sim is not None: + sim.stop() + `, TIMEOUTS.VALIDATION); + } catch { + // Ignore errors - simulation might not exist yet + } +} + +/** + * Force stop by terminating the worker + * Use this when graceful stop doesn't work + */ +export function forceStop(): void { + // Mark streaming as inactive + streamingActive = false; + + // Update phase but keep results visible + simulationState.update((s) => ({ + ...s, + phase: 'idle', + progress: STATUS_MESSAGES.STOPPED, + error: null + })); + + terminateRepl(); + helpersInjected = false; +} + +/** + * Truncate simulation result history + */ +export function truncateResultHistory(maxSize: number): void { + simulationState.update((s) => ({ + ...s, + resultHistory: s.resultHistory.slice(0, maxSize) + })); +} diff --git a/src/lib/pyodide/codeBuilder.ts b/src/lib/pyodide/codeBuilder.ts new file mode 100644 index 00000000..9122fa64 --- /dev/null +++ b/src/lib/pyodide/codeBuilder.ts @@ -0,0 +1,156 @@ +/** + * Python Code Builder Utilities + * Low-level utilities for building Python code strings + */ + +import type { Connection } from '$lib/nodes/types'; + +/** Options for parameter string generation */ +export interface ParamStringOptions { + /** Use multi-line formatting with indentation */ + multiLine?: boolean; + /** Indentation string for multi-line output */ + indent?: string; + /** Skip params starting with underscore (internal params) */ + skipInternal?: boolean; +} + +/** + * Generate a parameter string for Python constructor calls. + * All param values are treated as raw Python expressions - no parsing or transformation. + * + * @param params - Object of param name to value + * @param validParamNames - Set of valid param names (others are skipped) + * @param options - Formatting options + * @returns Parameter string like "x=1, y=2" or multi-line formatted + */ +export function generateParamString( + params: Record, + validParamNames: Set, + options: ParamStringOptions = {} +): string { + const { multiLine = false, indent = ' ', skipInternal = false } = options; + + const parts: string[] = []; + + for (const [name, value] of Object.entries(params)) { + // Skip null/undefined/empty + if (value === null || value === undefined || value === '') continue; + // Skip internal params if requested (starting with underscore) + if (skipInternal && name.startsWith('_')) continue; + // Skip params not defined in the type definition + if (!validParamNames.has(name)) continue; + + // Pass through verbatim - value is a Python expression + parts.push(`${name}=${value}`); + } + + if (multiLine && parts.length > 0) { + return '\n' + parts.map((p) => indent + p).join(',\n') + '\n'; + } + return parts.join(', '); +} + +/** + * Group connections by source (nodeId + portIndex) for multi-target Connection syntax. + * PathSim allows Connection(src[0], tgt1[0], tgt2[1]) for fan-out from same output. + */ +export function groupConnectionsBySource( + connections: Connection[], + nodeVars: Map +): Map { + const groups = new Map< + string, + { sourceVar: string; sourcePort: number; targets: { varName: string; port: number }[] } + >(); + + for (const conn of connections) { + const sourceVar = nodeVars.get(conn.sourceNodeId); + const targetVar = nodeVars.get(conn.targetNodeId); + if (!sourceVar || !targetVar) continue; + + const key = `${conn.sourceNodeId}:${conn.sourcePortIndex}`; + if (!groups.has(key)) { + groups.set(key, { + sourceVar, + sourcePort: conn.sourcePortIndex, + targets: [] + }); + } + groups.get(key)!.targets.push({ varName: targetVar, port: conn.targetPortIndex }); + } + + return groups; +} + +/** + * Generate connection lines from grouped connections + * + * @param connections - Array of connections + * @param nodeVars - Map of nodeId to variable name + * @param indent - Indentation string (default ' ') + * @returns Array of connection lines like " Connection(src[0], tgt[1])," + */ +export function generateConnectionLines( + connections: Connection[], + nodeVars: Map, + indent: string = ' ' +): string[] { + const lines: string[] = []; + const groups = groupConnectionsBySource(connections, nodeVars); + + for (const { sourceVar, sourcePort, targets } of groups.values()) { + const targetParts = targets.map((t) => `${t.varName}[${t.port}]`); + lines.push(`${indent}Connection(${sourceVar}[${sourcePort}], ${targetParts.join(', ')}),`); + } + + return lines; +} + +/** + * Generate a Python list definition + * + * @param listName - Variable name for the list (e.g., 'blocks') + * @param items - Array of item variable names + * @param indent - Indentation for items (default ' ') + * @returns Array of lines defining the list + */ +export function generateListDefinition( + listName: string, + items: string[], + indent: string = ' ' +): string[] { + const lines: string[] = []; + lines.push(`${listName} = [`); + for (const item of items) { + lines.push(`${indent}${item},`); + } + lines.push(']'); + return lines; +} + +/** + * Sanitize a name for use as a Python variable. + * - Removes invalid characters + * - Replaces spaces with underscores + * - Ensures doesn't start with number + * - Lowercases + */ +export function sanitizeName(name: string): string { + if (!name) return ''; + + let sanitized = ''; + for (const char of name) { + if (/[a-zA-Z0-9_]/.test(char)) { + sanitized += char; + } else if (char === ' ') { + sanitized += '_'; + } + } + + if (sanitized && /^[0-9]/.test(sanitized)) { + sanitized = 'n_' + sanitized; + } + + return sanitized.toLowerCase(); +} diff --git a/src/lib/pyodide/index.ts b/src/lib/pyodide/index.ts new file mode 100644 index 00000000..b80f30c2 --- /dev/null +++ b/src/lib/pyodide/index.ts @@ -0,0 +1,53 @@ +/** + * Pyodide module entry point + * Re-exports all Pyodide-related functionality + */ + +// High-level simulation API +export { + initPyodide, + runStreamingSimulation, + continueStreamingSimulation, + resetSimulation, + validateGraph, + stopSimulation, + forceStop, + truncateResultHistory, + pyodideState, + simulationState, + type SimulationResult, + type ValidationResult, + type ValidationError +} from './bridge'; + +// Code generation +export { + generatePythonCode, + runGraphStreamingSimulation, + exportToPython, + validateGraphSimulation, + generateBlockCode, + generateSingleEventCode, + sanitizeName +} from './pathsimRunner'; + +// Low-level backend API (for advanced usage and future streaming) +export { + init as initRepl, + exec, + evaluate, + terminate as terminateRepl, + isReady as isReplReady, + isLoading as isReplLoading, + replState, + // Backend registry for switching backends + getBackend, + createBackend, + switchBackend, + getBackendType, + hasBackend, + terminateBackend, + type BackendType, + type Backend, + type BackendState +} from './backend'; diff --git a/src/lib/pyodide/pathsimRunner.ts b/src/lib/pyodide/pathsimRunner.ts new file mode 100644 index 00000000..8ad18334 --- /dev/null +++ b/src/lib/pyodide/pathsimRunner.ts @@ -0,0 +1,1075 @@ +/** + * PathSim Runner + * Converts graph state to Python code and runs simulations + */ + +import type { NodeInstance, Connection, SimulationSettings } from '$lib/nodes/types'; +import { DEFAULT_SIMULATION_SETTINGS } from '$lib/nodes/types'; +import type { EventInstance } from '$lib/events/types'; +import { nodeRegistry } from '$lib/nodes/registry'; +import { eventRegistry } from '$lib/events/registry'; +import { NODE_TYPES } from '$lib/constants/nodeTypes'; +import { BLOCK_CATEGORY_ORDER } from '$lib/constants/python'; +import { isSubsystem, isInterface } from '$lib/nodes/shapes'; +import { graphStore, findParentSubsystem } from '$lib/stores/graph'; +import { + runStreamingSimulation, + validateGraph as validateGraphBridge, + type SimulationResult, + type ValidationResult +} from './bridge'; +import { + generateParamString, + generateConnectionLines, + generateListDefinition, + sanitizeName +} from './codeBuilder'; + +// Re-export sanitizeName for external use +export { sanitizeName } from './codeBuilder'; + +/** + * Get setting value or fall back to default + */ +function getSettingOrDefault( + settings: SimulationSettings, + key: K +): SimulationSettings[K] { + const value = settings[key]; + if (value === '' || value === null || value === undefined) { + return DEFAULT_SIMULATION_SETTINGS[key]; + } + return value; +} + + +/** + * Generate block parameter string (skips internal params starting with _) + */ +function generateBlockParams( + params: Record, + validParamNames: Set, + multiLine: boolean = false +): string { + return generateParamString(params, validParamNames, { + multiLine, + skipInternal: true + }); +} + +/** + * Generate event parameter string + */ +function generateEventParams( + params: Record, + validParamNames: Set, + multiLine: boolean = false +): string { + return generateParamString(params, validParamNames, { multiLine }); +} + +/** + * Generate Python code for events + */ +function generateEventCode( + events: EventInstance[], + lines: string[], + nodeVars: Map, + varNames: string[], + multiLine: boolean = false +): void { + if (events.length === 0) return; + + lines.push('# EVENTS'); + + for (const event of events) { + const typeDef = eventRegistry.get(event.type); + if (!typeDef) continue; + + // Generate variable name for event + let varName = sanitizeName(event.name); + if (!varName || varNames.includes(varName)) { + varName = `event_${varNames.length}`; + } + varNames.push(varName); + + const validParamNames = new Set(typeDef.params.map(p => p.name)); + const params = generateEventParams(event.params, validParamNames, multiLine); + + if (params) { + lines.push(`${varName} = ${typeDef.eventClass}(${params})`); + } else { + lines.push(`${varName} = ${typeDef.eventClass}()`); + } + } + + lines.push(''); + lines.push('events = ['); + // Only add events we generated (not blocks) + for (const event of events) { + const varName = varNames.find(v => v === sanitizeName(event.name) || v.startsWith('event_')); + if (varName) { + lines.push(` ${varName},`); + } + } + lines.push(']'); + lines.push(''); +} + +/** + * Get child nodes of a subsystem (from nested graph structure) + */ +function getChildNodes(subsystemNode: NodeInstance): NodeInstance[] { + return subsystemNode.graph?.nodes ?? []; +} + +/** + * Get child connections of a subsystem (from nested graph structure) + */ +function getChildConnections(subsystemNode: NodeInstance): Connection[] { + return subsystemNode.graph?.connections ?? []; +} + +/** + * Get child events of a subsystem (from nested graph structure) + */ +function getChildEvents(subsystemNode: NodeInstance): EventInstance[] { + return subsystemNode.graph?.events ?? []; +} + + + +/** + * Generate event definitions and return event variable names + */ +function generateEventDefinitions( + events: EventInstance[], + existingVarNames: string[], + lines: string[], + multiLine: boolean = false +): string[] { + const eventVarNames: string[] = []; + + for (const event of events) { + const typeDef = eventRegistry.get(event.type); + if (!typeDef) continue; + + let varName = sanitizeName(event.name); + if (!varName || existingVarNames.includes(varName) || eventVarNames.includes(varName)) { + varName = `event_${eventVarNames.length}`; + } + eventVarNames.push(varName); + + const validParamNames = new Set(typeDef.params.map(p => p.name)); + const params = generateEventParams(event.params, validParamNames, multiLine); + + if (params) { + lines.push(`${varName} = ${typeDef.eventClass}(${params})`); + } else { + lines.push(`${varName} = ${typeDef.eventClass}()`); + } + } + + return eventVarNames; +} + + +/** + * Generate the Simulation constructor + */ +function generateSimulationSetup( + settings: SimulationSettings, + hasEvents: boolean, + lines: string[], + indent: string = ' ' +): void { + lines.push('sim = Simulation('); + lines.push(`${indent}blocks,`); + lines.push(`${indent}connections,`); + if (hasEvents) { + lines.push(`${indent}events,`); + } + lines.push(`${indent}Solver=${getSettingOrDefault(settings, 'solver')},`); + lines.push(`${indent}dt=${getSettingOrDefault(settings, 'dt')},`); + lines.push(`${indent}dt_min=${getSettingOrDefault(settings, 'dt_min')},`); + + const dtMax = getSettingOrDefault(settings, 'dt_max'); + if (dtMax) { + lines.push(`${indent}dt_max=${dtMax},`); + } + + lines.push(`${indent}tolerance_lte_rel=${getSettingOrDefault(settings, 'rtol')},`); + lines.push(`${indent}tolerance_lte_abs=${getSettingOrDefault(settings, 'atol')},`); + lines.push(`${indent}tolerance_fpi=${getSettingOrDefault(settings, 'ftol')},`); + lines.push(')'); +} + +/** + * Recursively collect all nodes including those inside subsystems + */ +function getAllNodesRecursively(nodes: NodeInstance[]): NodeInstance[] { + const allNodes: NodeInstance[] = []; + for (const node of nodes) { + allNodes.push(node); + if (isSubsystem(node)) { + const childNodes = getChildNodes(node); + allNodes.push(...getAllNodesRecursively(childNodes)); + } + } + return allNodes; +} + +/** Options for subsystem code generation */ +interface SubsystemCodeOptions { + /** Use multi-line formatting with keyword arguments (for export) */ + formatted?: boolean; +} + +/** + * Generate code for a subsystem and its contents + * Returns the variable name for the subsystem + */ +function generateSubsystemCode( + subsystemNode: NodeInstance, + nodeVars: Map, + varNames: string[], + lines: string[], + prefix: string = '', + options: SubsystemCodeOptions = {} +): string { + const { formatted = false } = options; + const childNodes = getChildNodes(subsystemNode); + const childConnections = getChildConnections(subsystemNode); + const childEvents = getChildEvents(subsystemNode); + + // Generate subsystem variable name + let subsystemVarName = sanitizeName(subsystemNode.name); + if (!subsystemVarName || varNames.includes(subsystemVarName)) { + subsystemVarName = `subsystem_${varNames.length}`; + } + varNames.push(subsystemVarName); + nodeVars.set(subsystemNode.id, subsystemVarName); + + const subPrefix = prefix + subsystemVarName + '_'; + + // Find Interface block(s) inside this subsystem + const interfaceNodes = childNodes.filter(isInterface); + + // Generate internal blocks (excluding Interface - it's handled separately) + const internalBlocks = childNodes.filter((n) => !isInterface(n)); + const internalVarNames: string[] = []; + const internalNodeVars = new Map(); + + // Add section comment for formatted output + if (formatted) { + lines.push(''); + lines.push(`# Subsystem: ${subsystemNode.name}`); + } + + // First, generate Interface block + for (const iface of interfaceNodes) { + const ifaceVarName = subPrefix + 'interface'; + internalVarNames.push(ifaceVarName); + internalNodeVars.set(iface.id, ifaceVarName); + lines.push(`${ifaceVarName} = Interface()`); + } + + // Generate internal blocks + for (const node of internalBlocks) { + // Check if this is a nested subsystem + if (isSubsystem(node)) { + // Recursively generate nested subsystem + generateSubsystemCode( + node, + internalNodeVars, + internalVarNames, + lines, + subPrefix, + options + ); + } else { + const typeDef = nodeRegistry.get(node.type); + if (!typeDef) continue; + + let varName = subPrefix + sanitizeName(node.name); + if (!varName || internalVarNames.includes(varName)) { + varName = `${subPrefix}block_${internalVarNames.length}`; + } + internalVarNames.push(varName); + internalNodeVars.set(node.id, varName); + + const validParamNames = new Set(typeDef.params.map((p) => p.name)); + const params = generateBlockParams(node.params, validParamNames, formatted); + + if (params) { + lines.push(`${varName} = ${typeDef.blockClass}(${params})`); + } else { + lines.push(`${varName} = ${typeDef.blockClass}()`); + } + } + } + + // Propagate internal block IDs to parent nodeVars (for _node_id_map) + for (const [nodeId, varName] of internalNodeVars) { + nodeVars.set(nodeId, varName); + } + + // Generate internal events (need to be defined before Subsystem constructor) + const eventVarNames: string[] = []; + if (childEvents.length > 0) { + for (const event of childEvents) { + const typeDef = eventRegistry.get(event.type); + if (!typeDef) continue; + + let eventVarName = subPrefix + sanitizeName(event.name); + if (!eventVarName || varNames.includes(eventVarName) || eventVarNames.includes(eventVarName)) { + eventVarName = `${subPrefix}event_${eventVarNames.length}`; + } + eventVarNames.push(eventVarName); + + const validParamNames = new Set(typeDef.params.map(p => p.name)); + const params = generateEventParams(event.params, validParamNames, formatted); + + if (params) { + lines.push(`${eventVarName} = ${typeDef.eventClass}(${params})`); + } else { + lines.push(`${eventVarName} = ${typeDef.eventClass}()`); + } + } + } + + // Create Subsystem with inline blocks and connections using kwargs + lines.push(`${subsystemVarName} = Subsystem(`); + + // Blocks list + lines.push(' blocks=['); + for (const varName of internalVarNames) { + lines.push(` ${varName},`); + } + lines.push(' ],'); + + // Connections list (grouped by source for multi-target syntax) + lines.push(' connections=['); + const connLines = generateConnectionLines(childConnections, internalNodeVars, ' '); + for (const line of connLines) { + lines.push(line); + } + lines.push(' ],'); + + // Events list (if any) + if (childEvents.length > 0) { + lines.push(' events=['); + for (const eventVarName of eventVarNames) { + lines.push(` ${eventVarName},`); + } + lines.push(' ],'); + } + + lines.push(')'); + if (!formatted) { + lines.push(''); + } + + return subsystemVarName; +} + +/** + * Group nodes by category + */ +function groupNodesByCategory( + nodes: NodeInstance[] +): Map }[]> { + const groups = new Map }[]>(); + + for (const node of nodes) { + const typeDef = nodeRegistry.get(node.type); + if (!typeDef) continue; + + const category = typeDef.category || 'Other'; + if (!groups.has(category)) { + groups.set(category, []); + } + groups.get(category)!.push({ node, typeDef }); + } + + return groups; +} + +/** + * Generate Python code from graph state + * @param includeNodeIdMap - Include node ID mapping for web data extraction (default: true) + */ +export function generatePythonCode( + nodes: NodeInstance[], + connections: Connection[], + settings: SimulationSettings, + codeContext: string, + includeNodeIdMap: boolean = true, + events: EventInstance[] = [] +): string { + const lines: string[] = []; + + // Check if we have any subsystems + const hasSubsystems = nodes.some(isSubsystem); + + // Check if we have any events + const hasEvents = events.length > 0; + const eventClasses = new Set( + events.map(e => eventRegistry.get(e.type)?.eventClass).filter(Boolean) + ); + + // 1. Imports + lines.push('# IMPORTS'); + lines.push('import numpy as np'); + if (hasSubsystems) { + lines.push('from pathsim import Simulation, Connection, Subsystem, Interface'); + } else { + lines.push('from pathsim import Simulation, Connection'); + } + lines.push('from pathsim.blocks import *'); + lines.push(`from pathsim.solvers import ${getSettingOrDefault(settings, 'solver')}`); + if (hasEvents) { + lines.push(`from pathsim.events import ${[...eventClasses].join(', ')}`); + } + lines.push(''); + + // 2. Code context (user-defined variables/functions) + if (codeContext.trim()) { + lines.push('# CODE CONTEXT'); + lines.push(codeContext.trim()); + lines.push(''); + } + + // 3. Create blocks + lines.push('# BLOCKS'); + const nodeVars = new Map(); + const varNames: string[] = []; + + // With nested structure, input nodes are already root-level + const rootNodes = nodes; + + // First, generate subsystems (they need to be defined before being used) + const subsystemNodes = rootNodes.filter(isSubsystem); + for (const subsystemNode of subsystemNodes) { + generateSubsystemCode(subsystemNode, nodeVars, varNames, lines); + } + + // Then generate regular blocks (excluding subsystems and interfaces) + const regularNodes = rootNodes.filter((n) => !isSubsystem(n) && !isInterface(n)); + + regularNodes.forEach((node, index) => { + const typeDef = nodeRegistry.get(node.type); + if (!typeDef) { + console.warn(`Unknown node type: ${node.type}`); + return; + } + + // Generate variable name + let varName = sanitizeName(node.name); + if (!varName || varNames.includes(varName)) { + varName = `block_${index}`; + } + varNames.push(varName); + nodeVars.set(node.id, varName); + + // Get valid param names from type definition + const validParamNames = new Set(typeDef.params.map(p => p.name)); + + // Generate parameter string (only includes valid params) + const params = generateBlockParams(node.params, validParamNames); + + if (params) { + lines.push(`${varName} = ${typeDef.blockClass}(${params})`); + } else { + lines.push(`${varName} = ${typeDef.blockClass}()`); + } + }); + + lines.push(''); + lines.push(...generateListDefinition('blocks', varNames)); + lines.push(''); + + // Create node ID mapping for data extraction (only for web simulation) + if (includeNodeIdMap) { + lines.push('# NODE ID MAPPING (for data extraction)'); + lines.push('_node_id_map = {'); + for (const [nodeId, varName] of nodeVars) { + lines.push(` id(${varName}): "${nodeId}",`); + } + lines.push('}'); + lines.push(''); + + // Create node name mapping (nodeId -> name) for all nodes including subsystems + lines.push('# NODE NAME MAPPING'); + lines.push('_node_name_map = {'); + const allNodes = getAllNodesRecursively(nodes); + for (const node of allNodes) { + // Escape quotes in node names + const escapedName = node.name.replace(/"/g, '\\"'); + lines.push(` "${node.id}": "${escapedName}",`); + } + lines.push('}'); + lines.push(''); + } + + // 4. Connections (grouped by source for multi-target syntax) + lines.push('# CONNECTIONS'); + lines.push('connections = ['); + const connLines = generateConnectionLines(connections, nodeVars, ' '); + for (const line of connLines) { + lines.push(line); + } + lines.push(']'); + lines.push(''); + + // 5. Events (if any) + if (hasEvents) { + lines.push('# EVENTS'); + const eventVarNames = generateEventDefinitions(events, varNames, lines); + lines.push(''); + lines.push(...generateListDefinition('events', eventVarNames)); + lines.push(''); + } + + // 6. Simulation setup + lines.push('# SIMULATION'); + generateSimulationSetup(settings, hasEvents, lines); + lines.push(''); + + // 7. Run simulation (always reset=True for fresh runs) + lines.push('# RUN'); + lines.push(`sim.run(duration=${getSettingOrDefault(settings, 'duration')}, reset=True)`); + + return lines.join('\n'); +} + +/** + * Generate well-formatted Python code for standalone export + */ +function generateFormattedPythonCode( + nodes: NodeInstance[], + connections: Connection[], + settings: SimulationSettings, + codeContext: string, + events: EventInstance[] = [] +): string { + const lines: string[] = []; + const divider = '# ' + '─'.repeat(76); + const now = new Date(); + const timestamp = now.toISOString().replace('T', ' ').split('.')[0]; + + // Header banner + lines.push('#!/usr/bin/env python3'); + lines.push('# -*- coding: utf-8 -*-'); + lines.push('"""'); + lines.push('PathSim Simulation'); + lines.push('=================='); + lines.push(''); + lines.push(`Generated by PathView Web on ${timestamp}`); + lines.push('https://make.pathsim.org'); + lines.push(''); + lines.push('PathSim documentation: https://docs.pathsim.org'); + lines.push('"""'); + lines.push(''); + + // Check if we have subsystems + const hasSubsystems = nodes.some(isSubsystem); + + // Check if we have events + const hasEvents = events.length > 0; + const eventClasses = new Set( + events.map(e => eventRegistry.get(e.type)?.eventClass).filter(Boolean) + ); + + // Imports section + lines.push(divider); + lines.push('# IMPORTS'); + lines.push(divider); + lines.push(''); + lines.push('import numpy as np'); + lines.push('import matplotlib.pyplot as plt'); + lines.push(''); + if (hasSubsystems) { + lines.push('from pathsim import Simulation, Connection, Subsystem, Interface'); + } else { + lines.push('from pathsim import Simulation, Connection'); + } + lines.push('from pathsim.blocks import ('); + + // Collect unique block classes by category (excluding Subsystem category - handled separately) + // Use all nodes recursively to include blocks inside subsystems + const allNodes = getAllNodesRecursively(nodes); + const blocksByCategory = groupNodesByCategory(allNodes); + const importedClasses = new Set(); + + for (const category of BLOCK_CATEGORY_ORDER) { + if (category === 'Subsystem') continue; // Skip - Subsystem/Interface imported from pathsim + const group = blocksByCategory.get(category); + if (!group || group.length === 0) continue; + + const classNames = [...new Set(group.map((g) => g.typeDef!.blockClass))].sort(); + for (const className of classNames) { + if (!importedClasses.has(className) && className !== 'Subsystem' && className !== 'Interface') { + importedClasses.add(className); + } + } + } + + // Also check for any remaining categories not in BLOCK_CATEGORY_ORDER + for (const [category, group] of blocksByCategory) { + if (BLOCK_CATEGORY_ORDER.includes(category)) continue; + const classNames = [...new Set(group.map((g) => g.typeDef!.blockClass))].sort(); + for (const className of classNames) { + if (!importedClasses.has(className) && className !== 'Subsystem' && className !== 'Interface') { + importedClasses.add(className); + } + } + } + + // Output imports grouped + const sortedClasses = [...importedClasses].sort(); + for (let i = 0; i < sortedClasses.length; i++) { + const comma = i < sortedClasses.length - 1 ? ',' : ''; + lines.push(` ${sortedClasses[i]}${comma}`); + } + lines.push(')'); + lines.push(`from pathsim.solvers import ${getSettingOrDefault(settings, 'solver')}`); + if (hasEvents) { + lines.push(`from pathsim.events import ${[...eventClasses].join(', ')}`); + } + lines.push(''); + + // Code context (user-defined variables/functions) + if (codeContext.trim()) { + lines.push(divider); + lines.push('# USER-DEFINED CODE'); + lines.push(divider); + lines.push(''); + lines.push(codeContext.trim()); + lines.push(''); + } + + // Blocks section - grouped by category + lines.push(divider); + lines.push('# BLOCKS'); + lines.push(divider); + + const nodeVars = new Map(); + const varNames: string[] = []; + let nodeIndex = 0; + + // With nested structure, input nodes are already root-level + const rootNodes = nodes; + + // First, generate subsystems (they need to be defined before being used in connections) + const subsystemNodes = rootNodes.filter(isSubsystem); + for (const subsystemNode of subsystemNodes) { + generateSubsystemCode(subsystemNode, nodeVars, varNames, lines, '', { formatted: true }); + } + + // Then generate regular blocks (excluding subsystems and interfaces) + // Group only root-level, non-subsystem, non-interface nodes + const regularRootNodes = rootNodes.filter((n) => !isSubsystem(n) && !isInterface(n)); + const regularBlocksByCategory = groupNodesByCategory(regularRootNodes); + + for (const category of BLOCK_CATEGORY_ORDER) { + if (category === 'Subsystem') continue; // Already handled above + const group = regularBlocksByCategory.get(category); + if (!group || group.length === 0) continue; + + lines.push(''); + lines.push(`# ${category}`); + + for (const { node, typeDef } of group) { + // Generate variable name + let varName = sanitizeName(node.name); + if (!varName || varNames.includes(varName)) { + varName = `block_${nodeIndex}`; + } + varNames.push(varName); + nodeVars.set(node.id, varName); + nodeIndex++; + + // Get valid param names from type definition + const validParamNames = new Set(typeDef!.params.map((p) => p.name)); + + // Generate parameter string (multi-line for readability) + const params = generateBlockParams(node.params, validParamNames, true); + + if (params) { + lines.push(`${varName} = ${typeDef!.blockClass}(${params})`); + } else { + lines.push(`${varName} = ${typeDef!.blockClass}()`); + } + } + } + + // Handle any remaining categories (excluding Subsystem) + for (const [category, group] of regularBlocksByCategory) { + if (BLOCK_CATEGORY_ORDER.includes(category)) continue; + + lines.push(''); + lines.push(`# ${category}`); + + for (const { node, typeDef } of group) { + let varName = sanitizeName(node.name); + if (!varName || varNames.includes(varName)) { + varName = `block_${nodeIndex}`; + } + varNames.push(varName); + nodeVars.set(node.id, varName); + nodeIndex++; + + const validParamNames = new Set(typeDef!.params.map((p) => p.name)); + const params = generateBlockParams(node.params, validParamNames, true); + + if (params) { + lines.push(`${varName} = ${typeDef!.blockClass}(${params})`); + } else { + lines.push(`${varName} = ${typeDef!.blockClass}()`); + } + } + } + + // Add blocks list at end of section + lines.push(''); + lines.push(...generateListDefinition('blocks', varNames)); + lines.push(''); + + // Connections section + lines.push(divider); + lines.push('# CONNECTIONS'); + lines.push(divider); + lines.push(''); + + // Connections (grouped by source for multi-target syntax) + if (connections.length === 0) { + lines.push('connections = []'); + } else { + lines.push('connections = ['); + const connLines = generateConnectionLines(connections, nodeVars, ' '); + for (const line of connLines) { + lines.push(line); + } + lines.push(']'); + } + lines.push(''); + + // Events section (if any) + if (hasEvents) { + lines.push(divider); + lines.push('# EVENTS'); + lines.push(divider); + lines.push(''); + const eventVarNames = generateEventDefinitions(events, varNames, lines, true); + lines.push(''); + lines.push(...generateListDefinition('events', eventVarNames)); + lines.push(''); + } + + // Simulation section + lines.push(divider); + lines.push('# SIMULATION'); + lines.push(divider); + lines.push(''); + generateSimulationSetup(settings, hasEvents, lines); + lines.push(''); + + // Main block + lines.push(divider); + lines.push('# MAIN'); + lines.push(divider); + lines.push(''); + lines.push("if __name__ == '__main__':"); + lines.push(''); + lines.push(' # Run simulation'); + lines.push(` sim.run(duration=${getSettingOrDefault(settings, 'duration')})`); + lines.push(''); + lines.push(' # Plot results'); + lines.push(' sim.plot()'); + lines.push(' plt.show()'); + lines.push(''); + + return lines.join('\n'); +} + +/** + * Run streaming simulation from graph state with live updates + * @param onUpdate - Callback called for each streaming update + */ +export async function runGraphStreamingSimulation( + nodes: NodeInstance[], + connections: Connection[], + settings: SimulationSettings, + codeContext: string, + events: EventInstance[] = [], + onUpdate?: (result: SimulationResult) => void +): Promise { + // Generate code without the sim.run() call - streaming will handle that + const code = generatePythonCodeForStreaming(nodes, connections, settings, codeContext, events); + const duration = getSettingOrDefault(settings, 'duration'); + return runStreamingSimulation(code, String(duration), onUpdate); +} + +/** + * Generate Python code for streaming simulation (without sim.run()) + */ +function generatePythonCodeForStreaming( + nodes: NodeInstance[], + connections: Connection[], + settings: SimulationSettings, + codeContext: string, + events: EventInstance[] = [] +): string { + const lines: string[] = []; + + // Check if we have any subsystems + const hasSubsystems = nodes.some(isSubsystem); + + // Check if we have any events + const hasEvents = events.length > 0; + const eventClasses = new Set( + events.map(e => eventRegistry.get(e.type)?.eventClass).filter(Boolean) + ); + + // 1. Imports + lines.push('# IMPORTS'); + lines.push('import numpy as np'); + if (hasSubsystems) { + lines.push('from pathsim import Simulation, Connection, Subsystem, Interface'); + } else { + lines.push('from pathsim import Simulation, Connection'); + } + lines.push('from pathsim.blocks import *'); + lines.push(`from pathsim.solvers import ${getSettingOrDefault(settings, 'solver')}`); + if (hasEvents) { + lines.push(`from pathsim.events import ${[...eventClasses].join(', ')}`); + } + lines.push(''); + + // 2. Code context (user-defined variables/functions) + if (codeContext.trim()) { + lines.push('# CODE CONTEXT'); + lines.push(codeContext.trim()); + lines.push(''); + } + + // 3. Create blocks + lines.push('# BLOCKS'); + const nodeVars = new Map(); + const varNames: string[] = []; + + // With nested structure, input nodes are already root-level + const rootNodes = nodes; + + // First, generate subsystems (they need to be defined before being used) + const subsystemNodes = rootNodes.filter(isSubsystem); + for (const subsystemNode of subsystemNodes) { + generateSubsystemCode(subsystemNode, nodeVars, varNames, lines); + } + + // Then generate regular blocks (excluding subsystems and interfaces) + const regularNodes = rootNodes.filter((n) => !isSubsystem(n) && !isInterface(n)); + + regularNodes.forEach((node, index) => { + const typeDef = nodeRegistry.get(node.type); + if (!typeDef) { + console.warn(`Unknown node type: ${node.type}`); + return; + } + + // Generate variable name + let varName = sanitizeName(node.name); + if (!varName || varNames.includes(varName)) { + varName = `block_${index}`; + } + varNames.push(varName); + nodeVars.set(node.id, varName); + + // Get valid param names from type definition + const validParamNames = new Set(typeDef.params.map(p => p.name)); + + // Generate parameter string (only includes valid params) + const params = generateBlockParams(node.params, validParamNames); + + if (params) { + lines.push(`${varName} = ${typeDef.blockClass}(${params})`); + } else { + lines.push(`${varName} = ${typeDef.blockClass}()`); + } + }); + + lines.push(''); + lines.push(...generateListDefinition('blocks', varNames)); + lines.push(''); + + // Create node ID mapping for data extraction + lines.push('# NODE ID MAPPING (for data extraction)'); + lines.push('_node_id_map = {'); + for (const [nodeId, varName] of nodeVars) { + lines.push(` id(${varName}): "${nodeId}",`); + } + lines.push('}'); + lines.push(''); + + // Create node name mapping (nodeId -> name) for all nodes including subsystems + lines.push('# NODE NAME MAPPING'); + lines.push('_node_name_map = {'); + const allNodes = getAllNodesRecursively(nodes); + for (const node of allNodes) { + // Escape quotes in node names + const escapedName = node.name.replace(/"/g, '\\"'); + lines.push(` "${node.id}": "${escapedName}",`); + } + lines.push('}'); + lines.push(''); + + // 4. Connections (grouped by source for multi-target syntax) + lines.push('# CONNECTIONS'); + lines.push('connections = ['); + const connLines = generateConnectionLines(connections, nodeVars, ' '); + for (const line of connLines) { + lines.push(line); + } + lines.push(']'); + lines.push(''); + + // 5. Events (if any) + if (hasEvents) { + lines.push('# EVENTS'); + const eventVarNames = generateEventDefinitions(events, varNames, lines); + lines.push(''); + lines.push(...generateListDefinition('events', eventVarNames)); + lines.push(''); + } + + // 6. Simulation setup (no sim.run() - streaming will handle that) + lines.push('# SIMULATION'); + generateSimulationSetup(settings, hasEvents, lines); + + // Note: No sim.run() here - streaming generator will run the simulation + + return lines.join('\n'); +} + +/** + * Export graph to standalone Python script + */ +export function exportToPython( + nodes: NodeInstance[], + connections: Connection[], + settings: SimulationSettings, + codeContext: string, + events: EventInstance[] = [] +): string { + return generateFormattedPythonCode(nodes, connections, settings, codeContext, events); +} + +/** + * Generate Python code for a single block + * For Subsystem blocks, generates the full hierarchical code with internal blocks/connections + */ +export function generateBlockCode( + node: NodeInstance, + allNodes?: NodeInstance[], + allConnections?: Connection[] +): string { + const typeDef = nodeRegistry.get(node.type); + if (!typeDef) return ''; + + // Handle Interface blocks - generate parent Subsystem code instead + if (node.type === NODE_TYPES.INTERFACE) { + const rootNodes = allNodes || graphStore.getAllNodes(); + const parentSubsystem = findParentSubsystem(rootNodes, node.id); + if (parentSubsystem) { + return generateBlockCode(parentSubsystem, allNodes, allConnections); + } + return '# Interface block (no parent subsystem found)'; + } + + const varName = sanitizeName(node.name) || 'block'; + + // Handle Subsystem blocks specially - generate full hierarchical code + if (node.type === NODE_TYPES.SUBSYSTEM && allNodes && allConnections) { + const lines: string[] = []; + const nodeVars = new Map(); + const varNames: string[] = []; + + generateSubsystemCode(node, nodeVars, varNames, lines, '', { formatted: true }); + + return lines.join('\n'); + } + + // Regular block - simple code generation + const validParamNames = new Set(typeDef.params.map((p) => p.name)); + const params = generateBlockParams(node.params, validParamNames, true); + + if (params) { + return `${varName} = ${typeDef.blockClass}(${params})`; + } + return `${varName} = ${typeDef.blockClass}()`; +} + +/** + * Generate Python code for a single event instance + */ +export function generateSingleEventCode(event: EventInstance): string { + const typeDef = eventRegistry.get(event.type); + if (!typeDef) return ''; + + const varName = sanitizeName(event.name) || 'event'; + const validParamNames = new Set(typeDef.params.map((p) => p.name)); + const params = generateEventParams(event.params, validParamNames, true); + + if (params) { + return `${varName} = ${typeDef.eventClass}(${params})`; + } + return `${varName} = ${typeDef.eventClass}()`; +} + +/** + * Extract node parameters for validation + * Returns a map of nodeId -> { paramName: paramValue } + */ +function extractNodeParams(nodes: NodeInstance[]): Record> { + const result: Record> = {}; + + for (const node of nodes) { + const typeDef = nodeRegistry.get(node.type); + if (!typeDef) continue; + + const validParamNames = new Set(typeDef.params.map((p) => p.name)); + const nodeParams: Record = {}; + + for (const [name, value] of Object.entries(node.params)) { + // Skip null/undefined/empty + if (value === null || value === undefined || value === '') continue; + // Skip internal params + if (name.startsWith('_')) continue; + // Skip params not in type definition + if (!validParamNames.has(name)) continue; + + nodeParams[name] = String(value); + } + + if (Object.keys(nodeParams).length > 0) { + result[node.id] = nodeParams; + } + } + + return result; +} + +/** + * Validate graph before running simulation + * Checks code context syntax and all parameter expressions + */ +export async function validateGraphSimulation( + nodes: NodeInstance[], + codeContext: string +): Promise { + const nodeParams = extractNodeParams(nodes); + return validateGraphBridge(codeContext, nodeParams); +} + +export type { ValidationResult }; diff --git a/src/lib/pyodide/pythonHelpers.ts b/src/lib/pyodide/pythonHelpers.ts new file mode 100644 index 00000000..6e2a9842 --- /dev/null +++ b/src/lib/pyodide/pythonHelpers.ts @@ -0,0 +1,291 @@ +/** + * Python code templates for REPL operations + * These are injected into the Python namespace as helper functions + */ + +/** + * Setup code injected after REPL initialization + * Provides helper functions for data extraction + */ +export const REPL_SETUP_CODE = ` +import json +import gc +import numpy as np + +def _to_json(obj): + """Convert Python object to JSON-serializable form.""" + if hasattr(obj, 'tolist'): + return obj.tolist() + if isinstance(obj, (set, frozenset)): + return list(obj) + if isinstance(obj, bytes): + return obj.decode('utf-8', errors='replace') + return obj + +def _step_streaming_gen(): + """Step the streaming generator and return result dict.""" + global _sim_streaming, _sim_gen + if '_sim_gen' not in globals() or not _sim_streaming: + return {'done': True, 'result': None} + try: + result = next(_sim_gen) + return {'done': False, 'result': result} + except StopIteration: + _sim_streaming = False + return {'done': True, 'result': None} + +def _extract_scope_data(blocks, node_id_map, incremental=False): + """Extract data from Scope blocks recursively. + + If incremental=True, only returns data accumulated since last read. + """ + scope_data = {} + + def find_scopes(block_list): + for block in block_list: + block_name = type(block).__name__ + block_id = node_id_map.get(id(block), str(id(block))) + + if block_name == 'Scope': + try: + data = block.read(incremental=incremental) + if data is not None: + time_arr, signals = data + labels = list(block.labels) if hasattr(block, 'labels') and block.labels else [] + scope_data[block_id] = { + 'time': time_arr.tolist() if hasattr(time_arr, 'tolist') else list(time_arr), + 'signals': [s.tolist() if hasattr(s, 'tolist') else list(s) for s in signals], + 'labels': labels + } + except Exception as e: + print(f"Error reading Scope: {e}") + elif block_name == 'Subsystem': + if hasattr(block, 'blocks'): + find_scopes(block.blocks) + + find_scopes(blocks) + return scope_data + + +def _extract_spectrum_data(blocks, node_id_map): + """Extract data from Spectrum blocks recursively.""" + spectrum_data = {} + + def find_spectrums(block_list): + for block in block_list: + block_name = type(block).__name__ + block_id = node_id_map.get(id(block), str(id(block))) + + if block_name == 'Spectrum': + try: + data = block.read() + if data is not None: + freq_arr, magnitude = data + + # Convert complex to magnitude if needed + if np.iscomplexobj(magnitude): + magnitude = np.abs(magnitude) + + freq_list = freq_arr.tolist() if hasattr(freq_arr, 'tolist') else list(freq_arr) + + # Handle both single array and list of arrays + if hasattr(magnitude, 'ndim') and magnitude.ndim == 1: + mag_list = [magnitude.tolist()] + elif hasattr(magnitude, 'ndim') and magnitude.ndim == 2: + mag_list = [m.tolist() for m in magnitude] + else: + mag_list = [m.tolist() if hasattr(m, 'tolist') else list(m) for m in magnitude] + + labels = list(block.labels) if hasattr(block, 'labels') and block.labels else [] + spectrum_data[block_id] = { + 'frequency': freq_list, + 'magnitude': mag_list, + 'labels': labels + } + except Exception as e: + print(f"Error reading Spectrum: {e}") + elif block_name == 'Subsystem': + if hasattr(block, 'blocks'): + find_spectrums(block.blocks) + + find_spectrums(blocks) + return spectrum_data + + +def _extract_all_data(blocks, node_id_map, node_name_map=None, incremental=False): + """Extract all recording block data. + + If incremental=True, only returns data accumulated since last read. + """ + return { + 'scopeData': _extract_scope_data(blocks, node_id_map, incremental=incremental), + 'spectrumData': _extract_spectrum_data(blocks, node_id_map), + 'nodeNames': node_name_map or {} + } +`; + +/** + * Generate code to run a simulation and extract results + */ +export function generateRunCode(simulationCode: string): string { + return ` +import sys +import traceback + +_simulation_error = None + +try: +${indentCode(simulationCode, 4)} +except Exception as e: + tb = traceback.format_exc() + _simulation_error = f"{type(e).__name__}: {e}" + print("=" * 60, file=sys.stderr) + print("SIMULATION ERROR", file=sys.stderr) + print("=" * 60, file=sys.stderr) + print(tb, file=sys.stderr) + print("=" * 60, file=sys.stderr) + raise +`; +} + +/** + * Generate code to extract simulation results + */ +export const EXTRACT_RESULTS_EXPR = `_extract_all_data(blocks, _node_id_map, _node_name_map if '_node_name_map' in globals() else {})`; + +/** + * Generate validation code for code context + */ +export function generateValidationSetupCode(codeContextBase64: string): string { + return ` +import base64 + +_validation_namespace = {'np': np} +_validation_errors = [] + +# Decode and execute code context +_code_context = base64.b64decode("${codeContextBase64}").decode('utf-8') +try: + exec(_code_context, _validation_namespace) +except Exception as e: + _validation_errors.append({ + 'nodeId': '__code_context__', + 'param': '', + 'error': f"Code context error: {type(e).__name__}: {e}" + }) +`; +} + +/** + * Generate validation code for parameter expressions + */ +export function generateParamValidationCode(nodeParamsBase64: string): string { + return ` +import json +import base64 + +if not _validation_errors: + _node_params = json.loads(base64.b64decode("${nodeParamsBase64}").decode('utf-8')) + + for node_id, params in _node_params.items(): + for param_name, expr in params.items(): + if expr is None or expr == '': + continue + try: + eval(str(expr), _validation_namespace) + except Exception as e: + _validation_errors.append({ + 'nodeId': node_id, + 'param': param_name, + 'error': f"{type(e).__name__}: {e}" + }) +`; +} + +/** + * Expression to get validation result + */ +export const VALIDATION_RESULT_EXPR = `{'valid': len(_validation_errors) == 0, 'errors': _validation_errors}`; + +/** + * Code to clear simulation state - deletes everything except clean globals + */ +export const CLEAR_STATE_CODE = ` +import gc +_cg = globals().get('_clean_globals', None) +if _cg is not None: + for _var in list(globals().keys()): + if _var not in _cg and _var != '_cg': + try: + del globals()[_var] + except: + pass + del _cg +gc.collect() +`; + +/** + * Code to clean up temporary variables after simulation + * (Subset cleanup for use during simulation, not full reset) + */ +export const CLEANUP_TEMP_CODE = ` +import gc +for _var in ['_simulation_error', '_validation_errors', '_validation_namespace']: + if _var in globals(): + try: + del globals()[_var] + except: + pass +gc.collect() +`; + +/** + * Generate code to start streaming simulation + */ +export function generateStreamingStartCode(duration: string, tickrate: number = 10, reset: boolean = true): string { + return ` +_sim_gen = sim.run_streaming( + duration=${duration}, + reset=${reset ? 'True' : 'False'}, + tickrate=${tickrate}, + func_callback=lambda: _extract_all_data(blocks, _node_id_map, _node_name_map if '_node_name_map' in globals() else {}, incremental=True) +) +_sim_streaming = True +`; +} + +/** + * Expression to step generator and get result in single evaluate call + */ +export const STREAMING_STEP_EXPR = `_step_streaming_gen()`; + +/** + * Code to stop streaming and clean up generator + */ +export const STREAMING_STOP_CODE = ` +_sim_streaming = False +if '_sim_gen' in globals(): + try: + _sim_gen.close() + except: + pass +`; + +/** + * Helper to indent code + */ +function indentCode(code: string, spaces: number): string { + const indent = ' '.repeat(spaces); + return code + .split('\n') + .map((line) => indent + line) + .join('\n'); +} + +/** + * Helper to escape code for base64 encoding + */ +export function toBase64(str: string): string { + // Use encodeURIComponent to handle Unicode, then btoa + return btoa(unescape(encodeURIComponent(str))); +} diff --git a/src/lib/schema/cleanParams.ts b/src/lib/schema/cleanParams.ts new file mode 100644 index 00000000..2117c2ed --- /dev/null +++ b/src/lib/schema/cleanParams.ts @@ -0,0 +1,53 @@ +/** + * Shared param cleaning utilities for file export + */ + +import type { NodeInstance } from '$lib/nodes/types'; +import { nodeRegistry } from '$lib/nodes'; + +/** + * Clean node params for export: + * - Remove empty values (null, undefined, '') + * - Remove params not in type definition (dead params) + * - Keep internal UI params (starting with _) for layout preservation + */ +export function cleanNodeParams(node: NodeInstance): Record { + const typeDef = nodeRegistry.get(node.type); + const validParamNames = new Set(typeDef?.params.map(p => p.name) || []); + + const cleaned: Record = {}; + for (const [name, value] of Object.entries(node.params)) { + // Skip empty values + if (value === null || value === undefined || value === '') continue; + // Keep internal UI params (e.g., _rotation) for layout + if (name.startsWith('_')) { + cleaned[name] = value; + continue; + } + // Skip params not in type definition (dead params) + if (!validParamNames.has(name)) continue; + + cleaned[name] = value; + } + return cleaned; +} + +/** + * Recursively clean params for a node and all nodes in its subsystem graph + */ +export function cleanNodeForExport(node: NodeInstance): NodeInstance { + const cleaned = { + ...node, + params: cleanNodeParams(node) + }; + + // Recursively clean subsystem graphs + if (cleaned.graph?.nodes) { + cleaned.graph = { + ...cleaned.graph, + nodes: cleaned.graph.nodes.map(n => cleanNodeForExport(n)) + }; + } + + return cleaned; +} diff --git a/src/lib/schema/componentOps.ts b/src/lib/schema/componentOps.ts new file mode 100644 index 00000000..b720fa1e --- /dev/null +++ b/src/lib/schema/componentOps.ts @@ -0,0 +1,402 @@ +/** + * Component operations for exporting and importing blocks, subsystems, and models + */ + +import type { NodeInstance, Connection } from '$lib/nodes/types'; +import type { EventInstance } from '$lib/events/types'; +import type { Position } from '$lib/types'; +import type { + ComponentFile, + ComponentType, + BlockContent, + SubsystemContent, + COMPONENT_EXTENSIONS +} from '$lib/types/component'; +import { graphStore, regenerateGraphIds } from '$lib/stores/graph'; +import { eventStore } from '$lib/stores/events'; +import { historyStore } from '$lib/stores/history'; +import { generateId } from '$lib/stores/utils'; +import { NODE_TYPES } from '$lib/constants/nodeTypes'; +import { nodeRegistry } from '$lib/nodes'; +import { downloadJson } from '$lib/utils/download'; +import { cleanNodeForExport } from './cleanParams'; + +const COMPONENT_VERSION = '1.0'; + +/** + * Validate that all node types in a graph are registered + * @returns Array of invalid type names, empty if all valid + */ +function validateNodeTypes(nodes: NodeInstance[]): string[] { + const invalidTypes: string[] = []; + + for (const node of nodes) { + // Skip special types that are always valid + if (node.type === NODE_TYPES.SUBSYSTEM || node.type === NODE_TYPES.INTERFACE) { + // Recursively validate subsystem internal graphs + if (node.graph?.nodes) { + invalidTypes.push(...validateNodeTypes(node.graph.nodes)); + } + continue; + } + + if (!nodeRegistry.has(node.type)) { + invalidTypes.push(node.type); + } + + // Recursively validate subsystem internal graphs + if (node.graph?.nodes) { + invalidTypes.push(...validateNodeTypes(node.graph.nodes)); + } + } + + return [...new Set(invalidTypes)]; // Remove duplicates +} + +/** + * Check if File System Access API is available + */ +function hasFileSystemAccess(): boolean { + return 'showSaveFilePicker' in window; +} + +/** + * Create a block component file from a node + */ +export function createBlockFile(node: NodeInstance): ComponentFile { + // Deep clone and clean params + const clonedNode = JSON.parse(JSON.stringify(node)); + const cleanedNode = cleanNodeForExport(clonedNode); + + // Remove graph property for blocks (only subsystems have graphs) + delete cleanedNode.graph; + + return { + version: COMPONENT_VERSION, + type: 'block', + metadata: { + name: node.name, + created: new Date().toISOString(), + modified: new Date().toISOString() + }, + content: { + node: cleanedNode + } as BlockContent + }; +} + +/** + * Create a subsystem component file from a subsystem node + */ +export function createSubsystemFile(node: NodeInstance): ComponentFile { + if (node.type !== NODE_TYPES.SUBSYSTEM) { + throw new Error('Node is not a subsystem'); + } + + // Deep clone and clean params (recursively for nested subsystems) + const clonedNode = JSON.parse(JSON.stringify(node)); + const cleanedNode = cleanNodeForExport(clonedNode); + + return { + version: COMPONENT_VERSION, + type: 'subsystem', + metadata: { + name: node.name, + created: new Date().toISOString(), + modified: new Date().toISOString() + }, + content: { + node: cleanedNode + } as SubsystemContent + }; +} + +/** + * Get file extension for component type + */ +function getExtension(type: ComponentType): string { + const extensions: Record = { + block: '.blk', + subsystem: '.sub', + model: '.pvm' + }; + return extensions[type]; +} + +/** + * Get file type description for dialogs + */ +function getFileTypeDescription(type: ComponentType): string { + const descriptions: Record = { + block: 'PathView Block', + subsystem: 'PathView Subsystem', + model: 'PathView Model' + }; + return descriptions[type]; +} + +/** + * Export a component to file (opens save dialog) + */ +export async function exportComponent(type: ComponentType, nodeId: string): Promise { + const node = graphStore.getNode(nodeId); + if (!node) { + console.error('Node not found:', nodeId); + return false; + } + + // Validate type matches node + if (type === 'subsystem' && node.type !== NODE_TYPES.SUBSYSTEM) { + console.error('Cannot export non-subsystem as subsystem'); + return false; + } + + // Create the component file + const componentFile = type === 'subsystem' ? createSubsystemFile(node) : createBlockFile(node); + + const extension = getExtension(type); + const suggestedName = `${node.name}${extension}`; + + if (hasFileSystemAccess()) { + try { + const handle = await (window as any).showSaveFilePicker({ + suggestedName, + types: [ + { + description: getFileTypeDescription(type), + accept: { 'application/json': [extension] } + } + ] + }); + + const json = JSON.stringify(componentFile, null, 2); + const writable = await handle.createWritable(); + await writable.write(json); + await writable.close(); + return true; + } catch (error: any) { + if (error.name === 'AbortError') { + return false; // User cancelled + } + console.error('Failed to save component:', error); + // Fall through to download fallback + } + } + + // Fallback: download file + downloadComponent(componentFile, suggestedName); + return true; +} + +/** + * Download component file (fallback for browsers without File System Access API) + */ +function downloadComponent(file: ComponentFile, filename: string): void { + downloadJson(file, filename); +} + +/** + * Detect file format from parsed JSON + */ +function detectFileFormat( + json: unknown +): 'component' | 'legacy-model' | 'unknown' { + if (typeof json !== 'object' || json === null) { + return 'unknown'; + } + + const obj = json as Record; + + // New component format has explicit type field + if ('type' in obj && ['block', 'subsystem', 'model'].includes(obj.type as string)) { + return 'component'; + } + + // Legacy model format has graph and version but no type + if ('graph' in obj && 'version' in obj) { + return 'legacy-model'; + } + + return 'unknown'; +} + +/** + * Load and validate a component file + */ +export async function loadComponentFile(file: File): Promise { + const text = await file.text(); + const json = JSON.parse(text); + + const format = detectFileFormat(json); + + if (format === 'unknown') { + throw new Error('Invalid file format'); + } + + if (format === 'legacy-model') { + // Convert legacy model format to component format + return { + version: COMPONENT_VERSION, + type: 'model', + metadata: { + name: json.metadata?.name || file.name.replace(/\.(json|pvm)$/, ''), + created: json.metadata?.created || new Date().toISOString(), + modified: json.metadata?.modified || new Date().toISOString() + }, + content: { + graph: json.graph, + events: json.events, + codeContext: json.codeContext, + simulationSettings: json.simulationSettings + } + }; + } + + // Already in component format + return json as ComponentFile; +} + +/** + * Import component into current graph at position + * @returns IDs of imported nodes + */ +export async function importComponent( + file: File, + position: Position +): Promise { + const componentFile = await loadComponentFile(file); + + switch (componentFile.type) { + case 'block': + return importBlock(componentFile.content as BlockContent, position); + case 'subsystem': + return importSubsystem(componentFile.content as SubsystemContent, position); + case 'model': + // For now, model import not supported via context menu + // Full models should be opened via File > Open + console.warn('Model import not supported. Use File > Open instead.'); + return []; + default: + throw new Error(`Unknown component type: ${componentFile.type}`); + } +} + +/** + * Import a block at the given position + */ +function importBlock(content: BlockContent, position: Position): string[] { + const node = content.node; + + // Validate node type is registered + const invalidTypes = validateNodeTypes([node]); + if (invalidTypes.length > 0) { + throw new Error(`Unknown block type(s): ${invalidTypes.join(', ')}`); + } + + // Generate new ID + const newId = generateId(); + + // Create new node with regenerated IDs + const newNode: NodeInstance = { + ...node, + id: newId, + position: { ...position }, + inputs: node.inputs.map((port, index) => ({ + ...port, + id: `${newId}-input-${index}`, + nodeId: newId + })), + outputs: node.outputs.map((port, index) => ({ + ...port, + id: `${newId}-output-${index}`, + nodeId: newId + })) + }; + + // Clear selection and add node + historyStore.mutate(() => { + graphStore.clearSelection(); + eventStore.clearSelection(); + graphStore.pasteNodes([newNode], []); + }); + + return [newId]; +} + +/** + * Import a subsystem at the given position + */ +function importSubsystem(content: SubsystemContent, position: Position): string[] { + const node = content.node; + + // Validate all node types in subsystem are registered + const invalidTypes = validateNodeTypes([node]); + if (invalidTypes.length > 0) { + throw new Error(`Unknown block type(s): ${invalidTypes.join(', ')}`); + } + + // Generate new ID for the subsystem node + const newId = generateId(); + + // Create new node with regenerated IDs + const newNode: NodeInstance = { + ...node, + id: newId, + position: { ...position }, + inputs: node.inputs.map((port, index) => ({ + ...port, + id: `${newId}-input-${index}`, + nodeId: newId + })), + outputs: node.outputs.map((port, index) => ({ + ...port, + id: `${newId}-output-${index}`, + nodeId: newId + })) + }; + + // Recursively regenerate IDs in the subsystem's internal graph + if (newNode.graph) { + newNode.graph = regenerateGraphIds(newNode.graph); + } + + // Clear selection and add node + historyStore.mutate(() => { + graphStore.clearSelection(); + eventStore.clearSelection(); + graphStore.pasteNodes([newNode], []); + }); + + return [newId]; +} + +/** + * Open file picker and import component at position + */ +export async function openComponentImportDialog( + position: Position +): Promise { + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.blk,.sub,.pvm,.json'; + + input.onchange = async () => { + if (input.files?.[0]) { + try { + const ids = await importComponent(input.files[0], position); + resolve(ids); + } catch (error) { + console.error('Failed to import component:', error); + alert('Failed to import component. Make sure it is a valid PathView file.'); + resolve([]); + } + } else { + resolve([]); + } + }; + + input.oncancel = () => resolve([]); + input.click(); + }); +} diff --git a/src/lib/schema/fileOps.ts b/src/lib/schema/fileOps.ts new file mode 100644 index 00000000..84db7518 --- /dev/null +++ b/src/lib/schema/fileOps.ts @@ -0,0 +1,463 @@ +/** + * File operations for saving and loading graph files + */ + +import { tick } from 'svelte'; +import { writable } from 'svelte/store'; +import type { NodeInstance, Connection, SimulationSettings, GraphFile, SolverType } from '$lib/nodes/types'; +import { GRAPH_FILE_VERSION, INITIAL_SIMULATION_SETTINGS } from '$lib/nodes/types'; +import { cleanNodeForExport } from './cleanParams'; +import { graphStore } from '$lib/stores/graph'; +import { eventStore } from '$lib/stores/events'; +import { settingsStore } from '$lib/stores/settings'; +import { codeContextStore } from '$lib/stores/codeContext'; +import { consoleStore } from '$lib/stores/console'; +import { historyStore } from '$lib/stores/history'; +import { simulationState, resetSimulation } from '$lib/pyodide/bridge'; +import { requestAssemblyAnimation } from '$lib/animation/assemblyAnimation'; +import { downloadJson } from '$lib/utils/download'; + +const STORAGE_KEY = 'pathview_autosave'; +const FILE_EXTENSION = '.pvm'; +const LEGACY_EXTENSION = '.json'; + +// Debounce timer for immediate autosave +let autosaveDebounceTimer: ReturnType | null = null; + +// Current file handle for Save functionality (File System Access API) +let currentFileHandle: FileSystemFileHandle | null = null; + +// Current file name as a reactive store +const currentFileNameStore = writable(null); + +/** + * Check if File System Access API is available + */ +function hasFileSystemAccess(): boolean { + return 'showSaveFilePicker' in window && 'showOpenFilePicker' in window; +} + +/** + * Current file name store (for reactive UI updates) + */ +export const currentFileName = { subscribe: currentFileNameStore.subscribe }; + +/** + * Get current file name (for display purposes) + */ +export function getCurrentFileName(): string | null { + let name: string | null = null; + currentFileNameStore.subscribe(n => name = n)(); + return name; +} + +/** + * Clear current file (e.g., when creating new graph) + */ +export function clearCurrentFile(): void { + currentFileHandle = null; + currentFileNameStore.set(null); +} + +/** + * Create a GraphFile object from current state + */ +export function createGraphFile(name?: string): GraphFile { + const { nodes, connections, annotations } = graphStore.toJSON(); + const events = eventStore.toJSON(); + const settings = settingsStore.get(); + const code = codeContextStore.getCode(); + + // Clean params from all nodes (remove internal UI params, empty values, dead params) + const cleanedNodes = nodes.map(n => cleanNodeForExport(n)); + + return { + version: GRAPH_FILE_VERSION, + metadata: { + created: new Date().toISOString(), + modified: new Date().toISOString(), + name: name || 'Untitled' + }, + graph: { + nodes: cleanedNodes, + connections, + annotations + }, + events, + codeContext: { + code + }, + simulationSettings: settings + }; +} + +/** + * Migrate old node type format to new format + * Old: 'pathsim.sources.StepSource' -> New: 'StepSource' + */ +function migrateNodeType(type: string): string { + if (type.startsWith('pathsim.')) { + // Extract the last part after the final dot + const parts = type.split('.'); + return parts[parts.length - 1]; + } + return type; +} + +/** + * Migrate a GraphFile from old format to new format + */ +function migrateGraphFile(file: GraphFile): GraphFile { + const migrateNodes = (nodes: NodeInstance[]): NodeInstance[] => { + return nodes.map(node => { + const migratedNode = { ...node, type: migrateNodeType(node.type) }; + // Recursively migrate subsystem graphs (preserving connections, annotations, and events) + if (migratedNode.graph) { + migratedNode.graph = { + nodes: migrateNodes(migratedNode.graph.nodes || []), + connections: migratedNode.graph.connections || [], + annotations: migratedNode.graph.annotations, + events: migratedNode.graph.events + }; + } + return migratedNode; + }); + }; + + return { + ...file, + graph: file.graph ? { + ...file.graph, + nodes: migrateNodes(file.graph.nodes || []) + } : file.graph + }; +} + +/** + * Load a GraphFile into the application state + */ +export async function loadGraphFile(file: GraphFile): Promise { + // Migrate old format if needed + file = migrateGraphFile(file); + // Validate version + if (!file.version) { + throw new Error('Invalid file: missing version'); + } + + // Reset simulation state (stops running simulation, clears results and Python state) + resetSimulation(); // Fire and forget - synchronous part stops immediately + + // Clear previous state and wait for UI to update + // This ensures FlowCanvas sees empty state before new data arrives + graphStore.clear(); + eventStore.clear(); + consoleStore.clear(); + await tick(); + + // Load graph (including annotations) + graphStore.fromJSON( + file.graph?.nodes || [], + file.graph?.connections || [], + file.graph?.annotations || [] + ); + + // Load events + if (file.events && file.events.length > 0) { + eventStore.fromJSON(file.events); + } + + // Load code context + if (file.codeContext?.code) { + codeContextStore.setCode(file.codeContext.code); + } else { + codeContextStore.clear(); + } + + // Load settings (use initial empty values for missing fields) + if (file.simulationSettings) { + const s = file.simulationSettings as unknown as Record; + // Use initial empty values as base, preserve what's in the file + const mergedSettings: SimulationSettings = { + ...INITIAL_SIMULATION_SETTINGS, + solver: (s.solver as SolverType) ?? INITIAL_SIMULATION_SETTINGS.solver, + ghostTraces: (s.ghostTraces as number) ?? INITIAL_SIMULATION_SETTINGS.ghostTraces, + plotResults: (s.plotResults as boolean) ?? INITIAL_SIMULATION_SETTINGS.plotResults, + adaptive: (s.adaptive as boolean) ?? INITIAL_SIMULATION_SETTINGS.adaptive, + // Preserve string values as-is (empty strings show placeholders) + duration: s.duration != null ? String(s.duration) : INITIAL_SIMULATION_SETTINGS.duration, + dt: s.dt != null ? String(s.dt) : INITIAL_SIMULATION_SETTINGS.dt, + rtol: s.rtol != null ? String(s.rtol) : INITIAL_SIMULATION_SETTINGS.rtol, + atol: s.atol != null ? String(s.atol) : INITIAL_SIMULATION_SETTINGS.atol, + ftol: s.ftol != null ? String(s.ftol) : INITIAL_SIMULATION_SETTINGS.ftol, + dt_min: s.dt_min != null ? String(s.dt_min) : INITIAL_SIMULATION_SETTINGS.dt_min, + dt_max: s.dt_max != null ? String(s.dt_max) : INITIAL_SIMULATION_SETTINGS.dt_max + }; + settingsStore.set(mergedSettings); + } else { + settingsStore.reset(); + } + + // Clear undo/redo history - fresh file = fresh history + historyStore.clear(); + + // Trigger assembly animation for loaded graph + requestAssemblyAnimation(); +} + +/** + * Save to localStorage (autosave) + */ +export function autoSave(): void { + try { + const file = createGraphFile('Autosave'); + localStorage.setItem(STORAGE_KEY, JSON.stringify(file)); + } catch (error) { + console.warn('Autosave failed:', error); + } +} + +/** + * Debounced autosave - saves after a short delay to batch rapid changes + */ +export function debouncedAutoSave(delayMs: number = 500): void { + if (autosaveDebounceTimer) { + clearTimeout(autosaveDebounceTimer); + } + autosaveDebounceTimer = setTimeout(() => { + autoSave(); + autosaveDebounceTimer = null; + }, delayMs); +} + +/** + * Load from localStorage (restore autosave) + */ +export async function loadAutoSave(): Promise { + try { + const data = localStorage.getItem(STORAGE_KEY); + if (!data) return false; + + const file = JSON.parse(data) as GraphFile; + + // Validate the file has proper structure + if (!file.version || !file.graph) { + clearAutoSave(); + return false; + } + + await loadGraphFile(file); + return true; + } catch (error) { + console.warn('Failed to restore autosave, clearing:', error); + clearAutoSave(); + return false; + } +} + +/** + * Clear autosave + */ +export function clearAutoSave(): void { + localStorage.removeItem(STORAGE_KEY); +} + +/** + * Check if autosave exists + */ +export function hasAutoSave(): boolean { + return localStorage.getItem(STORAGE_KEY) !== null; +} + +/** + * Save graph to current file, or prompt if no current file + */ +export async function saveFile(): Promise { + // If we have a file handle, save directly to it + if (currentFileHandle && hasFileSystemAccess()) { + try { + const file = createGraphFile(getCurrentFileName() || undefined); + const json = JSON.stringify(file, null, 2); + const writable = await currentFileHandle.createWritable(); + await writable.write(json); + await writable.close(); + return true; + } catch (error) { + // User may have revoked permission, fall through to Save As + console.warn('Failed to save to current file:', error); + } + } + + // No current file or save failed, prompt for new file + return saveAsFile(); +} + +/** + * Save graph to a new file (always prompts) + */ +export async function saveAsFile(): Promise { + const suggestedName = (getCurrentFileName() || 'pathview_graph') + FILE_EXTENSION; + + if (hasFileSystemAccess()) { + try { + const handle = await (window as any).showSaveFilePicker({ + suggestedName, + types: [{ + description: 'PathView Model', + accept: { 'application/json': ['.pvm', '.json'] } + }] + }); + + const name = handle.name.replace(/\.(pvm|json)$/, ''); + const file = createGraphFile(name); + const json = JSON.stringify(file, null, 2); + + const writable = await handle.createWritable(); + await writable.write(json); + await writable.close(); + + // Update current file reference + currentFileHandle = handle; + currentFileNameStore.set(name); + return true; + } catch (error: any) { + if (error.name === 'AbortError') { + return false; // User cancelled + } + console.error('Failed to save file:', error); + // Fall back to download + } + } + + // Fallback: download file + downloadGraphFile(suggestedName); + return true; +} + +/** + * Legacy download-based save (fallback for browsers without File System Access API) + */ +function downloadGraphFile(filename: string): void { + const name = filename.replace(/\.(pvm|json)$/, ''); + const file = createGraphFile(name); + downloadJson(file, filename); + + // Set current file name for subsequent saves + currentFileNameStore.set(name); +} + +/** + * Open file dialog and load graph + */ +export async function openFile(): Promise { + if (hasFileSystemAccess()) { + try { + const [handle] = await (window as any).showOpenFilePicker({ + types: [{ + description: 'PathView Model', + accept: { 'application/json': ['.pvm', '.json'] } + }], + multiple: false + }); + + const file = await handle.getFile(); + const text = await file.text(); + const graphFile = JSON.parse(text) as GraphFile; + await loadGraphFile(graphFile); + + // Track the file handle for future saves + currentFileHandle = handle; + currentFileNameStore.set(handle.name.replace(/\.(pvm|json)$/, '')); + + return graphFile; + } catch (error: any) { + if (error.name === 'AbortError') { + return null; // User cancelled + } + console.error('Failed to open file:', error); + alert('Failed to open file. Make sure it is a valid PathView JSON file.'); + return null; + } + } + + // Fallback for browsers without File System Access API + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.pvm,.json'; + + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) { + resolve(null); + return; + } + + try { + const text = await file.text(); + const graphFile = JSON.parse(text) as GraphFile; + await loadGraphFile(graphFile); + + // Track file name (but no handle in fallback mode) + currentFileHandle = null; + currentFileNameStore.set(file.name.replace(/\.(pvm|json)$/, '')); + + resolve(graphFile); + } catch (error) { + console.error('Failed to open file:', error); + alert('Failed to open file. Make sure it is a valid PathView JSON file.'); + resolve(null); + } + }; + + input.oncancel = () => resolve(null); + input.click(); + }); +} + +/** + * Load graph from URL (e.g., example files) + */ +export async function loadGraphFromUrl(url: string): Promise { + try { + const res = await fetch(url); + if (!res.ok) throw new Error('Failed to fetch file'); + const graphFile = JSON.parse(await res.text()) as GraphFile; + await loadGraphFile(graphFile); + + // Extract name from URL + const name = url.split('/').pop()?.replace(/\.(pvm|json)$/, '') || null; + currentFileHandle = null; + currentFileNameStore.set(name); + + return graphFile; + } catch (error) { + console.error('Failed to load file from URL:', error); + return null; + } +} + +/** + * Create new empty graph + */ +export function newGraph(): void { + // Reset simulation state (stops running simulation, clears results and Python state) + resetSimulation(); // Fire and forget - synchronous part stops immediately + + graphStore.clear(); + eventStore.clear(); + codeContextStore.clear(); + consoleStore.clear(); + settingsStore.reset(); + historyStore.clear(); + clearAutoSave(); + clearCurrentFile(); +} + +/** + * Set up autosave interval + * Returns cleanup function + */ +export function setupAutoSave(intervalMs: number = 30000): () => void { + const timer = setInterval(autoSave, intervalMs); + return () => clearInterval(timer); +} diff --git a/src/lib/simulation/generated/simulation.ts b/src/lib/simulation/generated/simulation.ts new file mode 100644 index 00000000..69835ed7 --- /dev/null +++ b/src/lib/simulation/generated/simulation.ts @@ -0,0 +1,123 @@ +// Auto-generated by scripts/extract-simulation.py - DO NOT EDIT +// Re-run 'python scripts/extract-simulation.py' to update + +/** + * Extracted from PathSim Simulation class + */ + +export interface ExtractedSimulationParam { + pathsim_name: string; + type: string; + default: string | null; + description: string; +} + +export const simulationDescription = "Class that performs transient analysis of the dynamical system, defined by the"; + +export const simulationDocstringHtml = "

Class that performs transient analysis of the dynamical system, defined by the\nblocks and connecions. It manages all the blocks and connections and the timestep update.

\n

The global system equation is evaluated by fixed point iteration, so the information from\neach timestep gets distributed within the entire system and is available for all blocks at\nall times.

\n

The minimum number of fixed-point iterations 'iterations_min' is set to 'None' by default\nand then the length of the longest internal signal path (with passthrough) is used as the\nestimate for minimum number of iterations needed for the information to reach all instant\ntime blocks in each timestep. Dont change this unless you know that the actual path is\nshorter or something similar that prohibits instant time information flow.

\n

Convergence check for the fixed-point iteration loop with 'tolerance_fpi' is based on\nmax absolute error (max-norm) to previous iteration and should not be touched.

\n

Multiple numerical integrators are implemented in the 'pathsim.solvers' module.\nThe default solver is a fixed timestep 2nd order Strong Stability Preserving Runge Kutta\n(SSPRK22) method which is quite fast and has ok accuracy, especially if you are forced to\ntake small steps to cover the behaviour of forcing functions. Adaptive timestepping and\nimplicit integrators are also available.

\n

Manages an event handling system based on zero crossing detection. Uses 'Event' objects\nto monitor solver states of stateful blocks and applys transformations on the state in\ncase an event is detected.

\n
\n

Example

\n

This is how to setup a simple system simulation using the 'Simulation' class:

\n
\nimport numpy as np\n\nfrom pathsim import Simulation, Connection\nfrom pathsim.blocks import Source, Integrator, Scope\n\nsrc = Source(lambda t: np.cos(2*np.pi*t))\nitg = Integrator()\nsco = Scope(labels=["source", "integrator"])\n\nsim = Simulation(\n    blocks=[src, itg, sco],\n    connections=[\n        Connection(src[0], itg[0], sco[0]),\n        Connection(itg[0], sco[1])\n        ],\n    dt=0.01\n    )\n\nsim.run(4)\nsim.plot()\n
\n
\n
\n

Parameters

\n
\n
blocks : list[Block]
\n
blocks that define the system
\n
connections : list[Connection]
\n
connections that connect the blocks
\n
events : list[Event]
\n
list of event trackers (zero crossing detection, schedule, etc.)
\n
dt : float
\n
transient simulation timestep in time units,\ndefault see \u00b4SIM_TIMESTEP\u00b4 in \u00b4_constants.py\u00b4
\n
dt_min : float
\n
lower bound for transient simulation timestep,\ndefault see \u00b4SIM_TIMESTEP_MIN\u00b4 in \u00b4_constants.py\u00b4
\n
dt_max : float
\n
upper bound for transient simulation timestep,\ndefault see \u00b4SIM_TIMESTEP_MAX\u00b4 in \u00b4_constants.py\u00b4
\n
Solver : Solver
\n
ODE solver class for numerical integration from \u00b4pathsim.solvers\u00b4,\ndefault is \u00b4pathsim.solvers.ssprk22.SSPRK22\u00b4 (2nd order expl. Runge Kutta)
\n
tolerance_fpi : float
\n
absolute tolerance for convergence of algebraic loops\nand internal optimizers of implicit ODE solvers,\ndefault see \u00b4SIM_TOLERANCE_FPI\u00b4 in \u00b4_constants.py\u00b4
\n
iterations_max : int
\n
maximum allowed number of iterations for implicit ODE\nsolver optimizers and algebraic loop solver,\ndefault see \u00b4SIM_ITERATIONS_MAX\u00b4 in \u00b4_constants.py\u00b4
\n
log : bool | string
\n
flag to enable logging, default see \u00b4LOG_ENABLE\u00b4 in \u00b4_constants.py\u00b4\n(alternatively a path to a log file can be specified)
\n
solver_kwargs : dict
\n
additional parameters for numerical solvers such as absolute\n(\u00b4tolerance_lte_abs\u00b4) and relative (\u00b4tolerance_lte_rel\u00b4) tolerance,\ndefaults are defined in \u00b4_constants.py\u00b4
\n
\n
\n
\n

Attributes

\n
\n
time : float
\n
global simulation time, starting at \u00b40.0\u00b4
\n
graph : Graph
\n
internal graph representation for fast system funcion evluations\nusing DAG with algebraic depths
\n
boosters : None | list[ConnectionBooster]
\n
list of boosters (fixed point accelerators) that wrap algebraic\nloop closing connections assembled from the system graph
\n
engine : Solver
\n
global integrator (ODE solver) instance serving as a dummy to\nget attributes and access to intermediate evaluation stages
\n
logger : logging.Logger
\n
global simulation logger
\n
_blocks_dyn : set[Block]
\n
blocks with internal \u00b4Solver\u00b4 instances (stateful)
\n
_blocks_evt : set[Block]
\n
blocks with internal events (discrete time, eventful)
\n
_active : bool
\n
flag for setting the simulation as active, used for interrupts
\n
\n
\n"; + +export const extractedSimulationParams: Record = +{ + "dt": { + "pathsim_name": "dt", + "type": "number", + "default": "0.01", + "description": "transient simulation timestep in time units, default see \u00b4SIM_TIMESTEP\u00b4 in \u00b4_constants.py\u00b4" + }, + "dt_min": { + "pathsim_name": "dt_min", + "type": "number", + "default": "1e-16", + "description": "lower bound for transient simulation timestep, default see \u00b4SIM_TIMESTEP_MIN\u00b4 in \u00b4_constants.py\u00b4" + }, + "dt_max": { + "pathsim_name": "dt_max", + "type": "any", + "default": null, + "description": "upper bound for transient simulation timestep, default see \u00b4SIM_TIMESTEP_MAX\u00b4 in \u00b4_constants.py\u00b4" + }, + "solver": { + "pathsim_name": "Solver", + "type": "string", + "default": "SSPRK22", + "description": "ODE solver class for numerical integration from \u00b4pathsim.solvers\u00b4, default is \u00b4pathsim.solvers.ssprk22.SSPRK22\u00b4 (2nd order expl. Runge Kutta)" + }, + "ftol": { + "pathsim_name": "tolerance_fpi", + "type": "number", + "default": "1e-10", + "description": "absolute tolerance for convergence of algebraic loops and internal optimizers of implicit ODE solvers, default see \u00b4SIM_TOLERANCE_FPI\u00b4 in \u00b4_constants.py\u00b4" + }, + "iterations_max": { + "pathsim_name": "iterations_max", + "type": "integer", + "default": "200", + "description": "maximum allowed number of iterations for implicit ODE solver optimizers and algebraic loop solver, default see \u00b4SIM_ITERATIONS_MAX\u00b4 in \u00b4_constants.py\u00b4" + }, + "log": { + "pathsim_name": "log", + "type": "boolean", + "default": "True", + "description": "flag to enable logging, default see \u00b4LOG_ENABLE\u00b4 in \u00b4_constants.py\u00b4 (alternatively a path to a log file can be specified)" + }, + "rtol": { + "pathsim_name": "tolerance_lte_rel", + "type": "number", + "default": "0.0001", + "description": "Relative error tolerance for adaptive timestep control" + }, + "atol": { + "pathsim_name": "tolerance_lte_abs", + "type": "number", + "default": "1e-08", + "description": "Absolute error tolerance for adaptive timestep control" + }, + "duration": { + "pathsim_name": "duration", + "type": "number", + "default": "10.0", + "description": "Total simulation time" + } +}; + +// UI-only parameters (not from PathSim) +export const uiOnlyParams: Record = +{ + "ghostTraces": { + "pathsim_name": "", + "type": "integer", + "default": "0", + "description": "Number of previous simulation traces to show faded" + }, + "plotResults": { + "pathsim_name": "", + "type": "boolean", + "default": "true", + "description": "Auto-open plot panel after simulation" + } +}; + +export const solverConfig = +{ + "adaptive": { + "explicit": [ + "RKBS32", + "RKCK54" + ], + "implicit": [ + "GEAR52A", + "ESDIRK43" + ] + }, + "fixed": { + "explicit": [ + "SSPRK22", + "RK4" + ], + "implicit": [ + "BDF2" + ] + } +}; + +export const availableSolvers = ["BDF2", "ESDIRK43", "GEAR52A", "RK4", "RKBS32", "RKCK54", "SSPRK22"]; diff --git a/src/lib/stores/clipboard.ts b/src/lib/stores/clipboard.ts new file mode 100644 index 00000000..d725879f --- /dev/null +++ b/src/lib/stores/clipboard.ts @@ -0,0 +1,284 @@ +/** + * Clipboard store for copy/paste operations + * Stores deep clones of nodes, connections, and events + */ + +import { writable, get } from 'svelte/store'; +import type { NodeInstance, Connection } from '$lib/nodes/types'; +import type { EventInstance } from '$lib/events/types'; +import type { Position } from '$lib/types'; +import { graphStore, regenerateGraphIds } from '$lib/stores/graph'; +import { eventStore } from '$lib/stores/events'; +import { historyStore } from '$lib/stores/history'; +import { NODE_TYPES } from '$lib/constants/nodeTypes'; +import { generateId } from '$lib/stores/utils'; +import { deleteSelectedItems } from '$lib/stores/selection'; + +interface ClipboardContent { + nodes: NodeInstance[]; + connections: Connection[]; + events: EventInstance[]; + // Center of the copied selection (for positioning on paste) + center: Position; +} + +const clipboard = writable(null); + +/** + * Calculate the bounding box center of a set of items with positions + */ +function calculateCenter(items: Array<{ position: Position }>): Position { + if (items.length === 0) return { x: 0, y: 0 }; + + let minX = Infinity, minY = Infinity; + let maxX = -Infinity, maxY = -Infinity; + + for (const item of items) { + minX = Math.min(minX, item.position.x); + minY = Math.min(minY, item.position.y); + maxX = Math.max(maxX, item.position.x); + maxY = Math.max(maxY, item.position.y); + } + + return { + x: (minX + maxX) / 2, + y: (minY + maxY) / 2 + }; +} + +/** + * Copy selected nodes and events to clipboard + */ +function copy(): boolean { + const selectedNodeIds = get(graphStore.selectedNodeIds); + const selectedEventIds = get(eventStore.selectedEventIds); + + if (selectedNodeIds.size === 0 && selectedEventIds.size === 0) { + return false; + } + + const nodesMap = get(graphStore.nodes); + const connections = get(graphStore.connections); + + // Deep clone selected nodes (excluding Interface blocks) + const copiedNodes: NodeInstance[] = []; + for (const id of selectedNodeIds) { + const node = nodesMap.get(id); + if (node && node.type !== NODE_TYPES.INTERFACE) { + copiedNodes.push(JSON.parse(JSON.stringify(node))); + } + } + + // Find connections where BOTH source and target are in the selection + const copiedConnections: Connection[] = []; + for (const conn of connections) { + if (selectedNodeIds.has(conn.sourceNodeId) && selectedNodeIds.has(conn.targetNodeId)) { + copiedConnections.push(JSON.parse(JSON.stringify(conn))); + } + } + + // Deep clone selected events + const copiedEvents: EventInstance[] = []; + if (graphStore.isAtRoot()) { + for (const id of selectedEventIds) { + const event = eventStore.getEvent(id); + if (event) { + copiedEvents.push(JSON.parse(JSON.stringify(event))); + } + } + } else { + // Inside subsystem - events from subsystem + for (const id of selectedEventIds) { + const event = graphStore.getSubsystemEvent(id); + if (event) { + copiedEvents.push(JSON.parse(JSON.stringify(event))); + } + } + } + + // Calculate center of all copied items + const allItems = [ + ...copiedNodes.map(n => ({ position: n.position })), + ...copiedEvents.map(e => ({ position: e.position })) + ]; + const center = calculateCenter(allItems); + + clipboard.set({ + nodes: copiedNodes, + connections: copiedConnections, + events: copiedEvents, + center + }); + + return true; +} + +/** + * Paste clipboard contents at target position + * @param targetPosition - Position to center the pasted items at (flow coordinates) + * @returns IDs of pasted nodes and events + */ +function paste(targetPosition: Position): { nodeIds: string[]; eventIds: string[] } { + const content = get(clipboard); + if (!content || (content.nodes.length === 0 && content.events.length === 0)) { + return { nodeIds: [], eventIds: [] }; + } + + return historyStore.mutate(() => { + // Calculate offset from original center to target position + const offset = { + x: targetPosition.x - content.center.x, + y: targetPosition.y - content.center.y + }; + + // Map from old node ID to new node ID + const nodeIdMap = new Map(); + const nodesToPaste: NodeInstance[] = []; + + // Prepare nodes with new IDs and offset positions + for (const node of content.nodes) { + const newId = generateId(); + nodeIdMap.set(node.id, newId); + + const newNode: NodeInstance = { + ...node, + id: newId, + position: { + x: node.position.x + offset.x, + y: node.position.y + offset.y + }, + inputs: node.inputs.map((port, index) => ({ + ...port, + id: `${newId}-input-${index}`, + nodeId: newId + })), + outputs: node.outputs.map((port, index) => ({ + ...port, + id: `${newId}-output-${index}`, + nodeId: newId + })) + }; + + // Recursively regenerate IDs in subsystem graph (handles nested subsystems) + if (newNode.graph) { + newNode.graph = regenerateGraphIds(newNode.graph); + } + + nodesToPaste.push(newNode); + } + + // Prepare connections with remapped IDs + const connectionsToPaste: Connection[] = []; + for (const conn of content.connections) { + const newSourceId = nodeIdMap.get(conn.sourceNodeId); + const newTargetId = nodeIdMap.get(conn.targetNodeId); + + if (newSourceId && newTargetId) { + connectionsToPaste.push({ + id: generateId(), + sourceNodeId: newSourceId, + sourcePortIndex: conn.sourcePortIndex, + targetNodeId: newTargetId, + targetPortIndex: conn.targetPortIndex + }); + } + } + + // Clear selection before paste + graphStore.clearSelection(); + eventStore.clearSelection(); + + // Paste nodes and connections using graphStore + const nodeIds = graphStore.pasteNodes(nodesToPaste, connectionsToPaste); + + // Paste events + const eventIds: string[] = []; + for (const event of content.events) { + const newPosition = { + x: event.position.x + offset.x, + y: event.position.y + offset.y + }; + + if (graphStore.isAtRoot()) { + // Add event at root level + const newEvent = eventStore.addEvent(event.type, newPosition, event.name); + if (newEvent) { + // Copy params and color + if (Object.keys(event.params).length > 0) { + eventStore.updateEventParams(newEvent.id, event.params); + } + if (event.color) { + eventStore.updateEventColor(newEvent.id, event.color); + } + eventIds.push(newEvent.id); + eventStore.selectEvent(newEvent.id, true); + } + } else { + // Add event to subsystem + const newId = generateId(); + const newEvent: EventInstance = { + ...event, + id: newId, + position: newPosition + }; + graphStore.addSubsystemEvent(newEvent); + eventIds.push(newId); + } + } + + return { nodeIds, eventIds }; + }); +} + +/** + * Cut selected nodes and events (copy + delete) + * @returns true if something was cut + */ +function cut(): boolean { + // First copy the selection + if (!copy()) { + return false; + } + + // Then delete selected items + deleteSelectedItems(); + return true; +} + +/** + * Check if clipboard has content + */ +function hasContent(): boolean { + const content = get(clipboard); + return content !== null && (content.nodes.length > 0 || content.events.length > 0); +} + +/** + * Clear clipboard + */ +function clear(): void { + clipboard.set(null); +} + +/** + * Get clipboard content summary (for UI feedback) + */ +function getSummary(): { nodes: number; connections: number; events: number } | null { + const content = get(clipboard); + if (!content) return null; + return { + nodes: content.nodes.length, + connections: content.connections.length, + events: content.events.length + }; +} + +export const clipboardStore = { + subscribe: clipboard.subscribe, + copy, + cut, + paste, + hasContent, + clear, + getSummary +}; diff --git a/src/lib/stores/codeContext.ts b/src/lib/stores/codeContext.ts new file mode 100644 index 00000000..17dbb867 --- /dev/null +++ b/src/lib/stores/codeContext.ts @@ -0,0 +1,88 @@ +/** + * Code context store + * Manages user-defined Python code for variables and functions + */ + +import { writable, derived, get } from 'svelte/store'; + +const code = writable(''); +const lastError = writable(null); + +// Extract defined names from code (simple regex-based) +const definedNames = derived(code, ($code) => { + const names: string[] = []; + + // Match function definitions: def function_name( + const funcMatches = $code.matchAll(/def\s+(\w+)\s*\(/g); + for (const match of funcMatches) { + names.push(match[1]); + } + + // Match variable assignments: variable_name = + const varMatches = $code.matchAll(/^(\w+)\s*=/gm); + for (const match of varMatches) { + names.push(match[1]); + } + + // Return unique names + return [...new Set(names)]; +}); + +export const codeContextStore = { + // Direct store subscriptions + code: { subscribe: code.subscribe }, + lastError: { subscribe: lastError.subscribe }, + definedNames: { subscribe: definedNames.subscribe }, + + /** + * Set the code content + */ + setCode(newCode: string): void { + code.set(newCode); + lastError.set(null); + }, + + /** + * Get the current code + */ + getCode(): string { + return get(code); + }, + + /** + * Set an error message + */ + setError(error: string): void { + lastError.set(error); + }, + + /** + * Clear error + */ + clearError(): void { + lastError.set(null); + }, + + /** + * Check if a name is defined in the code + */ + hasName(name: string): boolean { + const names = get(definedNames); + return names.includes(name); + }, + + /** + * Get all defined names + */ + getDefinedNames(): string[] { + return get(definedNames); + }, + + /** + * Clear the code context + */ + clear(): void { + code.set(''); + lastError.set(null); + } +}; diff --git a/src/lib/stores/codePreview.ts b/src/lib/stores/codePreview.ts new file mode 100644 index 00000000..ba8069be --- /dev/null +++ b/src/lib/stores/codePreview.ts @@ -0,0 +1,23 @@ +/** + * Store for managing the global code preview dialog + * Used by context menus to show code without opening properties dialog + */ + +import { writable } from 'svelte/store'; + +interface CodePreviewState { + code: string; + title: string; +} + +const { subscribe, set } = writable(null); + +export const codePreviewStore = { + subscribe, + open(code: string, title: string) { + set({ code, title }); + }, + close() { + set(null); + } +}; diff --git a/src/lib/stores/console.ts b/src/lib/stores/console.ts new file mode 100644 index 00000000..27a18616 --- /dev/null +++ b/src/lib/stores/console.ts @@ -0,0 +1,100 @@ +/** + * Console store for capturing simulation logs + * Uses batching to avoid excessive UI updates during rapid logging + */ + +import { writable, get } from 'svelte/store'; + +export interface LogEntry { + id: number; + timestamp: Date; + level: 'info' | 'warning' | 'error' | 'output'; + message: string; +} + +let nextId = 0; +const MAX_ENTRIES = 500; + +function createConsoleStore() { + const { subscribe, set, update } = writable([]); + + // Batching state + let pendingEntries: LogEntry[] = []; + let flushTimeout: ReturnType | null = null; + const BATCH_DELAY = 50; // ms - flush every 50ms max + + function flushPending() { + if (pendingEntries.length === 0) return; + + const toAdd = pendingEntries; + pendingEntries = []; + flushTimeout = null; + + update((entries) => { + const combined = [...entries, ...toAdd]; + // Keep only the last MAX_ENTRIES to prevent memory bloat + return combined.length > MAX_ENTRIES + ? combined.slice(-MAX_ENTRIES) + : combined; + }); + } + + function scheduleFlush() { + if (flushTimeout === null) { + flushTimeout = setTimeout(flushPending, BATCH_DELAY); + } + } + + return { + subscribe, + + log(message: string, level: LogEntry['level'] = 'info') { + pendingEntries.push({ + id: nextId++, + timestamp: new Date(), + level, + message + }); + scheduleFlush(); + }, + + info(message: string) { + this.log(message, 'info'); + }, + + warn(message: string) { + this.log(message, 'warning'); + }, + + error(message: string) { + this.log(message, 'error'); + }, + + output(message: string) { + this.log(message, 'output'); + }, + + clear() { + pendingEntries = []; + if (flushTimeout) { + clearTimeout(flushTimeout); + flushTimeout = null; + } + set([]); + }, + + getAll() { + return get({ subscribe }); + }, + + // Force flush any pending entries (useful before reading) + flush() { + if (flushTimeout) { + clearTimeout(flushTimeout); + } + flushPending(); + } + }; +} + +export const consoleStore = createConsoleStore(); diff --git a/src/lib/stores/contextMenu.ts b/src/lib/stores/contextMenu.ts new file mode 100644 index 00000000..e78beda9 --- /dev/null +++ b/src/lib/stores/contextMenu.ts @@ -0,0 +1,115 @@ +/** + * Context menu store + * Manages context menu state for nodes, edges, and canvas + */ + +import { writable, get } from 'svelte/store'; +import type { Position } from '$lib/types'; + +export type ContextMenuTarget = + | { type: 'node'; nodeId: string } + | { type: 'event'; eventId: string } + | { type: 'edge'; edgeId: string } + | { type: 'canvas' } + | { type: 'selection'; nodeIds: string[] } + | { type: 'annotation'; annotationId: string }; + +interface ContextMenuState { + open: boolean; + position: Position; + target: ContextMenuTarget | null; +} + +const state = writable({ + open: false, + position: { x: 0, y: 0 }, + target: null +}); + +export const contextMenuStore = { + subscribe: state.subscribe, + + /** + * Open context menu for a node + */ + openForNode(nodeId: string, position: Position): void { + state.set({ + open: true, + position, + target: { type: 'node', nodeId } + }); + }, + + /** + * Open context menu for an event + */ + openForEvent(eventId: string, position: Position): void { + state.set({ + open: true, + position, + target: { type: 'event', eventId } + }); + }, + + /** + * Open context menu for multiple selected nodes + */ + openForSelection(nodeIds: string[], position: Position): void { + state.set({ + open: true, + position, + target: { type: 'selection', nodeIds } + }); + }, + + /** + * Open context menu for an edge + */ + openForEdge(edgeId: string, position: Position): void { + state.set({ + open: true, + position, + target: { type: 'edge', edgeId } + }); + }, + + /** + * Open context menu for canvas (empty area) + */ + openForCanvas(position: Position): void { + state.set({ + open: true, + position, + target: { type: 'canvas' } + }); + }, + + /** + * Open context menu for an annotation + */ + openForAnnotation(annotationId: string, position: Position): void { + state.set({ + open: true, + position, + target: { type: 'annotation', annotationId } + }); + }, + + /** + * Close the context menu + */ + close(): void { + state.set({ + open: false, + position: { x: 0, y: 0 }, + target: null + }); + }, + + /** + * Get current state + */ + getState(): ContextMenuState { + return get(state); + } +}; diff --git a/src/lib/stores/dropTargetBridge.ts b/src/lib/stores/dropTargetBridge.ts new file mode 100644 index 00000000..72b90b90 --- /dev/null +++ b/src/lib/stores/dropTargetBridge.ts @@ -0,0 +1,21 @@ +/** + * Drop target bridge + * Bridges drop events from outside SvelteFlow context to handlers inside + * Note: This is not a reactive store - it's a callback registration utility + */ + +type DropHandler = (event: DragEvent) => void; + +let dropHandler: DropHandler | null = null; + +export const dropTargetBridge = { + registerDropHandler(handler: DropHandler): void { + dropHandler = handler; + }, + + handleDrop(event: DragEvent): void { + if (dropHandler) { + dropHandler(event); + } + } +}; diff --git a/src/lib/stores/eventDialog.ts b/src/lib/stores/eventDialog.ts new file mode 100644 index 00000000..34a00467 --- /dev/null +++ b/src/lib/stores/eventDialog.ts @@ -0,0 +1,30 @@ +/** + * Store for managing the event properties dialog + */ +import { writable, get } from 'svelte/store'; + +const internal = writable(null); + +export const eventDialogStore = { + subscribe: internal.subscribe, + + open(eventId: string): void { + internal.set(eventId); + }, + + close(): void { + internal.set(null); + }, + + isOpen(): boolean { + return get(internal) !== null; + }, + + getOpenId(): string | null { + return get(internal); + } +}; + +// Convenience function exports for ergonomic usage +export const openEventDialog = (eventId: string) => eventDialogStore.open(eventId); +export const closeEventDialog = () => eventDialogStore.close(); diff --git a/src/lib/stores/eventFacade.ts b/src/lib/stores/eventFacade.ts new file mode 100644 index 00000000..8cce962c --- /dev/null +++ b/src/lib/stores/eventFacade.ts @@ -0,0 +1,173 @@ +/** + * Unified Event Facade + * + * Provides a single interface for event operations that works at both: + * - Root level (delegates to eventStore) + * - Subsystem level (delegates to graphStore subsystem event methods) + * + * This eliminates the need for consumers to check isAtRoot() before every operation. + */ + +import { derived } from 'svelte/store'; +import { eventStore } from './events'; +import { graphStore } from './graph'; +import { eventRegistry } from '$lib/events/registry'; +import type { EventInstance } from '$lib/events/types'; +import type { Position } from '$lib/types'; + +/** + * Check if we're at the root level + */ +function isAtRoot(): boolean { + return graphStore.isAtRoot(); +} + +/** + * Unified event operations that work at any navigation level + */ +export const unifiedEvents = { + /** + * Subscribe to current events (root or subsystem based on navigation) + * Returns a derived store that switches between sources + */ + get events() { + return derived( + [eventStore.eventsArray, graphStore.subsystemEvents, graphStore.currentPath], + ([$rootEvents, $subsystemEvents, $path]) => { + return $path.length === 0 ? $rootEvents : Array.from($subsystemEvents.values()); + } + ); + }, + + /** + * Add an event at the current navigation level + */ + addEvent( + type: string, + position: Position, + name?: string + ): EventInstance | null { + if (isAtRoot()) { + return eventStore.addEvent(type, position, name); + } else { + // Create event instance for subsystem + const typeDef = eventRegistry.get(type); + if (!typeDef) return null; + + const event: EventInstance = { + id: crypto.randomUUID(), + type, + name: name || typeDef.name, + position, + params: {} + }; + + const success = graphStore.addSubsystemEvent(event); + return success ? event : null; + } + }, + + /** + * Remove an event at the current navigation level + */ + removeEvent(id: string): void { + if (isAtRoot()) { + eventStore.removeEvent(id); + } else { + graphStore.removeSubsystemEvent(id); + } + }, + + /** + * Update an event's position + */ + updateEventPosition(id: string, position: Position): void { + if (isAtRoot()) { + eventStore.updateEventPosition(id, position); + } else { + graphStore.updateSubsystemEventPosition(id, position); + } + }, + + /** + * Update an event's name + */ + updateEventName(id: string, name: string): void { + if (isAtRoot()) { + eventStore.updateEventName(id, name); + } else { + graphStore.updateSubsystemEventName(id, name); + } + }, + + /** + * Update an event's parameters + */ + updateEventParams(id: string, params: Record): void { + if (isAtRoot()) { + eventStore.updateEventParams(id, params); + } else { + graphStore.updateSubsystemEventParams(id, params); + } + }, + + /** + * Update an event's color + */ + updateEventColor(id: string, color: string | undefined): void { + if (isAtRoot()) { + eventStore.updateEventColor(id, color); + } else { + graphStore.updateSubsystemEventColor(id, color); + } + }, + + /** + * Get an event by ID at the current navigation level + */ + getEvent(id: string): EventInstance | undefined { + if (isAtRoot()) { + return eventStore.getEvent(id); + } else { + return graphStore.getSubsystemEvent(id); + } + }, + + /** + * Get all events at the current navigation level + */ + getAll(): EventInstance[] { + if (isAtRoot()) { + return eventStore.getAll(); + } else { + return graphStore.getSubsystemEvents(); + } + }, + + /** + * Select an event + */ + selectEvent(id: string, addToSelection = false): void { + // Selection is only supported at root level currently + if (isAtRoot()) { + eventStore.selectEvent(id, addToSelection); + } + }, + + /** + * Clear event selection + */ + clearSelection(): void { + if (isAtRoot()) { + eventStore.clearSelection(); + } + }, + + /** + * Check if we're at root level (useful for components that need to know) + */ + isAtRoot +}; + +// Re-export for convenience +export { eventStore } from './events'; diff --git a/src/lib/stores/events.ts b/src/lib/stores/events.ts new file mode 100644 index 00000000..375becab --- /dev/null +++ b/src/lib/stores/events.ts @@ -0,0 +1,309 @@ +/** + * Event store - Manages PathSim events separately from the graph + * Events are diamond-shaped nodes without ports + */ + +import { writable, derived, get } from 'svelte/store'; +import type { EventInstance } from '$lib/events/types'; +import type { Position } from '$lib/types'; +import { eventRegistry } from '$lib/events/registry'; +import { triggerSelectNodes, triggerClearSelection } from '$lib/stores/viewActions'; +import { generateId } from '$lib/stores/utils'; + +// All events in the model +const events = writable>(new Map()); + +// Selected event IDs +const selectedEventIds = writable>(new Set()); + +/** + * Direct setter for event selection - used by FlowCanvas to sync from SvelteFlow + * This bypasses the trigger system to avoid loops + */ +export function setEventSelection(ids: Set): void { + selectedEventIds.set(ids); +} + +// Derived stores +const eventsArray = derived(events, ($events) => Array.from($events.values())); + +const selectedEvents = derived([events, selectedEventIds], ([$events, $selectedIds]) => + Array.from($selectedIds) + .map((id) => $events.get(id)) + .filter((e): e is EventInstance => e !== undefined) +); + +/** + * Event store actions + */ +export const eventStore = { + // Subscribe to stores + events: { subscribe: events.subscribe }, + eventsArray: { subscribe: eventsArray.subscribe }, + selectedEventIds: { subscribe: selectedEventIds.subscribe }, + selectedEvents: { subscribe: selectedEvents.subscribe }, + + /** + * Add a new event + */ + addEvent( + type: string, + position: Position, + name?: string + ): EventInstance | null { + const typeDef = eventRegistry.get(type); + if (!typeDef) { + console.error(`Unknown event type: ${type}`); + return null; + } + + const id = generateId(); + const event: EventInstance = { + id, + type, + name: name || typeDef.name, + position, + params: {} // Start empty - defaults shown as placeholders, code gen uses Python defaults + }; + + events.update((e) => { + const newMap = new Map(e); + newMap.set(id, event); + return newMap; + }); + + return event; + }, + + /** + * Remove an event + */ + removeEvent(id: string): void { + events.update((e) => { + const newMap = new Map(e); + newMap.delete(id); + return newMap; + }); + + selectedEventIds.update((ids) => { + const newSet = new Set(ids); + newSet.delete(id); + return newSet; + }); + }, + + /** + * Update an event's position + */ + updateEventPosition(id: string, position: Position): void { + events.update((e) => { + const event = e.get(id); + if (event) { + const newMap = new Map(e); + newMap.set(id, { ...event, position: { ...position } }); + return newMap; + } + return e; + }); + }, + + /** + * Update an event's name + */ + updateEventName(id: string, name: string): void { + events.update((e) => { + const event = e.get(id); + if (event) { + const newMap = new Map(e); + newMap.set(id, { ...event, name }); + return newMap; + } + return e; + }); + }, + + /** + * Update an event's parameters + */ + updateEventParams(id: string, params: Record): void { + events.update((e) => { + const event = e.get(id); + if (event) { + const newMap = new Map(e); + newMap.set(id, { ...event, params: { ...event.params, ...params } }); + return newMap; + } + return e; + }); + }, + + /** + * Update an event's color + */ + updateEventColor(id: string, color: string | undefined): void { + events.update((e) => { + const event = e.get(id); + if (event) { + const newMap = new Map(e); + newMap.set(id, { ...event, color }); + return newMap; + } + return e; + }); + }, + + /** + * Select an event (triggers SvelteFlow update) + */ + selectEvent(id: string, addToSelection = false): void { + if (addToSelection) { + // Get currently selected events and add the new one + const current = get(selectedEventIds); + triggerSelectNodes([...current, id], true); + } else { + triggerSelectNodes([id], false); + } + }, + + /** + * Deselect an event (triggers SvelteFlow update) + */ + deselectEvent(id: string): void { + const current = get(selectedEventIds); + const remaining = [...current].filter(eventId => eventId !== id); + triggerSelectNodes(remaining, false); + }, + + /** + * Clear event selection (triggers SvelteFlow update) + */ + clearSelection(): void { + triggerClearSelection(); + }, + + /** + * Check if any events are selected + */ + hasSelection(): boolean { + return get(selectedEventIds).size > 0; + }, + + /** + * Clear all events + */ + clear(): void { + events.set(new Map()); + selectedEventIds.set(new Set()); + }, + + /** + * Get an event by ID + */ + getEvent(id: string): EventInstance | undefined { + return get(events).get(id); + }, + + /** + * Get all events as array + */ + getAll(): EventInstance[] { + return Array.from(get(events).values()); + }, + + /** + * Select all events + */ + selectAll(): void { + const allIds = Array.from(get(events).keys()); + selectedEventIds.set(new Set(allIds)); + }, + + /** + * Duplicate selected events + */ + duplicateSelected(): string[] { + const selected = get(selectedEventIds); + if (selected.size === 0) return []; + + const currentEvents = get(events); + const newEventIds: string[] = []; + const offset = { x: 50, y: 50 }; + + selected.forEach((id) => { + const original = currentEvents.get(id); + if (!original) return; + + const newId = generateId(); + const newEvent: EventInstance = { + id: newId, + type: original.type, + name: original.name, + position: { + x: original.position.x + offset.x, + y: original.position.y + offset.y + }, + // Deep clone params to avoid shared references + params: JSON.parse(JSON.stringify(original.params)), + color: original.color + }; + + events.update((e) => { + const newMap = new Map(e); + newMap.set(newId, newEvent); + return newMap; + }); + + newEventIds.push(newId); + }); + + if (newEventIds.length > 0) { + triggerSelectNodes(newEventIds, false); + } + + return newEventIds; + }, + + /** + * Nudge selected events by a delta + */ + nudgeSelectedEvents(delta: { x: number; y: number }): void { + const selected = get(selectedEventIds); + if (selected.size === 0) return; + + events.update((e) => { + const newMap = new Map(e); + selected.forEach((id) => { + const event = newMap.get(id); + if (event) { + newMap.set(id, { + ...event, + position: { + x: event.position.x + delta.x, + y: event.position.y + delta.y + } + }); + } + }); + return newMap; + }); + }, + + /** + * Get current state as JSON-serializable array + */ + toJSON(): EventInstance[] { + return Array.from(get(events).values()); + }, + + /** + * Load state from JSON + */ + fromJSON(eventList: EventInstance[]): void { + if (!eventList || !Array.isArray(eventList)) { + events.set(new Map()); + } else { + events.set(new Map(eventList.map((e) => [e.id, e]))); + } + selectedEventIds.set(new Set()); + } +}; diff --git a/src/lib/stores/graph/annotations.ts b/src/lib/stores/graph/annotations.ts new file mode 100644 index 00000000..b1ebc6d9 --- /dev/null +++ b/src/lib/stores/graph/annotations.ts @@ -0,0 +1,123 @@ +/** + * Graph store - Annotation operations + */ + +import { get } from 'svelte/store'; +import type { Annotation } from '$lib/nodes/types'; +import type { Position } from '$lib/types'; +import { + selectedNodeIds, + generateId, + getCurrentGraph, + updateCurrentAnnotations +} from './state'; + +/** + * Add an annotation to the current graph context + */ +export function addAnnotation(position: Position): string { + const id = generateId(); + const annotation: Annotation = { + id, + position, + content: '', + width: 200, + height: 100 + }; + + updateCurrentAnnotations( + // Map updater (root) + a => { + const newMap = new Map(a); + newMap.set(id, annotation); + return newMap; + }, + // Array updater (subsystem) + a => [...a, annotation] + ); + + return id; +} + +/** + * Update an annotation's content, size, or position + * Note: Position updates during drag are blocked by historyStore.isDragging + */ +export function updateAnnotation(id: string, updates: Partial): void { + updateCurrentAnnotations( + // Map updater (root) + a => { + const annotation = a.get(id); + if (annotation) { + const newMap = new Map(a); + newMap.set(id, { ...annotation, ...updates }); + return newMap; + } + return a; + }, + // Array updater (subsystem) + a => a.map(ann => ann.id === id ? { ...ann, ...updates } : ann) + ); +} + +/** + * Update an annotation's position + */ +export function updateAnnotationPosition(id: string, position: Position): void { + updateAnnotation(id, { position }); +} + +/** + * Remove an annotation + */ +export function removeAnnotation(id: string): void { + updateCurrentAnnotations( + // Map updater (root) + a => { + const newMap = new Map(a); + newMap.delete(id); + return newMap; + }, + // Array updater (subsystem) + a => a.filter(ann => ann.id !== id) + ); +} + +/** + * Get an annotation by ID + */ +export function getAnnotation(id: string): Annotation | undefined { + return getCurrentGraph().annotations.get(id); +} + +/** + * Nudge selected annotations by a delta + */ +export function nudgeSelectedAnnotations(delta: Position): void { + const selected = get(selectedNodeIds); + if (selected.size === 0) return; + + const nudgePosition = (annotation: Annotation) => ({ + ...annotation, + position: { + x: annotation.position.x + delta.x, + y: annotation.position.y + delta.y + } + }); + + updateCurrentAnnotations( + // Map updater (root) + a => { + const newMap = new Map(a); + selected.forEach(id => { + const annotation = newMap.get(id); + if (annotation) { + newMap.set(id, nudgePosition(annotation)); + } + }); + return newMap; + }, + // Array updater (subsystem) + a => a.map(ann => selected.has(ann.id) ? nudgePosition(ann) : ann) + ); +} diff --git a/src/lib/stores/graph/connections.ts b/src/lib/stores/graph/connections.ts new file mode 100644 index 00000000..b0be5577 --- /dev/null +++ b/src/lib/stores/graph/connections.ts @@ -0,0 +1,95 @@ +/** + * Graph store - Connection operations + */ + +import { get } from 'svelte/store'; +import type { Connection } from '$lib/nodes/types'; +import { + rootNodes, + rootConnections, + generateId, + getCurrentGraph, + updateCurrentConnections +} from './state'; + +/** + * Add a connection between two ports + */ +export function addConnection( + sourceNodeId: string, + sourcePortIndex: number, + targetNodeId: string, + targetPortIndex: number +): Connection | null { + const currentGraph = getCurrentGraph(); + const sourceNode = currentGraph.nodes.get(sourceNodeId); + const targetNode = currentGraph.nodes.get(targetNodeId); + + if (!sourceNode || !targetNode) { + console.error('Invalid node IDs for connection'); + return null; + } + + if (sourcePortIndex >= sourceNode.outputs.length || targetPortIndex >= targetNode.inputs.length) { + console.error('Invalid port indices for connection'); + return null; + } + + // Check if target port already has a connection + const connections = currentGraph.connections; + const portOccupied = connections.some( + c => c.targetNodeId === targetNodeId && c.targetPortIndex === targetPortIndex + ); + + if (portOccupied) { + console.warn('Target port already has a connection'); + return null; + } + + const connection: Connection = { + id: generateId(), + sourceNodeId, + sourcePortIndex, + targetNodeId, + targetPortIndex + }; + + updateCurrentConnections(c => [...c, connection]); + + return connection; +} + +/** + * Remove a connection + */ +export function removeConnection(id: string): void { + updateCurrentConnections(c => c.filter(conn => conn.id !== id)); +} + +/** + * Get all connections recursively (for code generation) + */ +export function getAllConnections(): { connection: Connection; subsystemId?: string }[] { + const all: { connection: Connection; subsystemId?: string }[] = []; + + // Root connections + for (const conn of get(rootConnections)) { + all.push({ connection: conn }); + } + + // Collect from subsystems + const collectConnections = (nodes: Map | unknown[], parentId?: string) => { + const nodeList = nodes instanceof Map ? Array.from(nodes.values()) : nodes; + for (const node of nodeList as { id: string; graph?: { connections: Connection[]; nodes: unknown[] } }[]) { + if (node.graph) { + for (const conn of node.graph.connections) { + all.push({ connection: conn, subsystemId: node.id }); + } + collectConnections(node.graph.nodes, node.id); + } + } + }; + collectConnections(get(rootNodes)); + + return all; +} diff --git a/src/lib/stores/graph/helpers.ts b/src/lib/stores/graph/helpers.ts new file mode 100644 index 00000000..3d702cfb --- /dev/null +++ b/src/lib/stores/graph/helpers.ts @@ -0,0 +1,290 @@ +/** + * Graph store helper utilities + * Pure functions that don't depend on store state + */ + +import type { PortInstance, NodeInstance, SubsystemGraph, Connection } from '$lib/nodes/types'; +import { PORT_COLORS } from '$lib/utils/colors'; +import { NODE_TYPES } from '$lib/constants/nodeTypes'; + +// Import and re-export generateId from utils (canonical location) +import { generateId } from '$lib/stores/utils'; +export { generateId }; + +/** + * Create port instances from port definitions + */ +export function createPorts( + nodeId: string, + direction: 'input' | 'output', + portDefs: { name: string; color?: string }[] +): PortInstance[] { + return portDefs.map((def, index) => ({ + id: `${nodeId}-${direction}-${index}`, + nodeId, + name: def.name, + direction, + index, + color: def.color || PORT_COLORS.default + })); +} + +/** + * Get a subsystem node by following a path through the graph + */ +export function getSubsystemByPath( + rootNodes: Map, + path: string[] +): NodeInstance | null { + if (path.length === 0) return null; + + let currentNodes = rootNodes; + let subsystem: NodeInstance | null = null; + + for (let i = 0; i < path.length; i++) { + const node = currentNodes.get(path[i]); + if (!node || node.type !== NODE_TYPES.SUBSYSTEM) return null; + subsystem = node; + if (i < path.length - 1 && node.graph) { + currentNodes = new Map(node.graph.nodes.map((n) => [n.id, n])); + } + } + + return subsystem; +} + +/** + * Derive Interface node ports from parent Subsystem + * Interface outputs = Subsystem inputs (signals coming in) + * Interface inputs = Subsystem outputs (signals going out) + */ +export function deriveInterfaceNode( + interfaceNode: NodeInstance, + parentSubsystem: NodeInstance +): NodeInstance { + return { + ...interfaceNode, + name: parentSubsystem.name, + color: parentSubsystem.color, + outputs: parentSubsystem.inputs.map((port, i) => ({ + id: `${interfaceNode.id}-output-${i}`, + nodeId: interfaceNode.id, + name: `in ${i}`, + direction: 'output' as const, + index: i, + color: port.color + })), + inputs: parentSubsystem.outputs.map((port, i) => ({ + id: `${interfaceNode.id}-input-${i}`, + nodeId: interfaceNode.id, + name: `out ${i}`, + direction: 'input' as const, + index: i, + color: port.color + })) + }; +} + +/** + * Collect all nodes recursively from a subsystem hierarchy + */ +export function collectAllNodes(nodes: NodeInstance[]): NodeInstance[] { + const result: NodeInstance[] = []; + + function collect(nodeList: NodeInstance[]) { + for (const node of nodeList) { + result.push(node); + if (node.type === NODE_TYPES.SUBSYSTEM && node.graph) { + collect(node.graph.nodes); + } + } + } + + collect(nodes); + return result; +} + +/** + * Collect all connections recursively, including those in subsystems + */ +export function collectAllConnections( + rootConnections: Connection[], + rootNodes: NodeInstance[] +): { connection: Connection; subsystemId?: string }[] { + const result: { connection: Connection; subsystemId?: string }[] = []; + + // Add root connections + for (const conn of rootConnections) { + result.push({ connection: conn }); + } + + // Recursively collect from subsystems + function collectFromNodes(nodes: NodeInstance[], parentPath: string[]) { + for (const node of nodes) { + if (node.type === NODE_TYPES.SUBSYSTEM && node.graph) { + for (const conn of node.graph.connections) { + result.push({ connection: conn, subsystemId: node.id }); + } + collectFromNodes(node.graph.nodes, [...parentPath, node.id]); + } + } + } + + collectFromNodes(rootNodes, []); + return result; +} + +/** + * Check if a node is of type Subsystem + */ +export function isSubsystemNode(node: NodeInstance): boolean { + return node.type === NODE_TYPES.SUBSYSTEM; +} + +/** + * Check if a node is of type Interface + */ +export function isInterfaceNode(node: NodeInstance): boolean { + return node.type === NODE_TYPES.INTERFACE; +} + +/** + * Find the parent Subsystem that contains a given Interface node + */ +export function findParentSubsystem(nodes: NodeInstance[], interfaceId: string): NodeInstance | null { + for (const node of nodes) { + if (node.type === NODE_TYPES.SUBSYSTEM && node.graph) { + // Check if this subsystem contains the interface + const hasInterface = node.graph.nodes.some(n => n.id === interfaceId); + if (hasInterface) { + return node; + } + // Recursively check nested subsystems + const found = findParentSubsystem(node.graph.nodes, interfaceId); + if (found) return found; + } + } + return null; +} + +/** + * Clone a node for duplication + * Creates a deep copy to avoid shared references + */ +export function cloneNode( + node: NodeInstance, + newId: string, + positionOffset: { x: number; y: number } +): NodeInstance { + return { + id: newId, + type: node.type, + name: node.name, + position: { + x: node.position.x + positionOffset.x, + y: node.position.y + positionOffset.y + }, + inputs: createPorts( + newId, + 'input', + node.inputs.map((p) => ({ name: p.name, color: p.color })) + ), + outputs: createPorts( + newId, + 'output', + node.outputs.map((p) => ({ name: p.name, color: p.color })) + ), + // Deep clone params to avoid shared references + params: JSON.parse(JSON.stringify(node.params)), + // Copy pinnedParams array if present + pinnedParams: node.pinnedParams ? [...node.pinnedParams] : undefined, + color: node.color, + // Deep clone graph for subsystems + graph: node.graph ? JSON.parse(JSON.stringify(node.graph)) : undefined + }; +} + +/** + * Update ports array with new port count + */ +export function resizePorts( + nodeId: string, + currentPorts: PortInstance[], + newCount: number, + direction: 'input' | 'output' +): PortInstance[] { + if (newCount < currentPorts.length) { + // Remove ports from the end + return currentPorts.slice(0, newCount); + } else if (newCount > currentPorts.length) { + // Add new ports + const newPorts = [...currentPorts]; + for (let i = currentPorts.length; i < newCount; i++) { + newPorts.push({ + id: `${nodeId}-${direction}-${i}`, + nodeId, + name: direction === 'input' ? `in ${i}` : `out ${i}`, + direction, + index: i, + color: PORT_COLORS.default + }); + } + return newPorts; + } + return currentPorts; +} + +/** + * Recursively regenerate all IDs in a subsystem graph + * Handles nested subsystems at any depth + */ +export function regenerateGraphIds(graph: SubsystemGraph): SubsystemGraph { + const idMap = new Map(); + + // First pass: generate new IDs for all nodes + const newNodes = graph.nodes.map(node => { + const newId = generateId(); + idMap.set(node.id, newId); + + const newNode: NodeInstance = { + ...node, + id: newId, + inputs: node.inputs.map((p, i) => ({ ...p, id: `${newId}-input-${i}`, nodeId: newId })), + outputs: node.outputs.map((p, i) => ({ ...p, id: `${newId}-output-${i}`, nodeId: newId })) + }; + + // Recursively handle nested subsystems + if (newNode.graph) { + newNode.graph = regenerateGraphIds(newNode.graph); + } + + return newNode; + }); + + // Remap connections + const newConnections = graph.connections.map(c => ({ + ...c, + id: generateId(), + sourceNodeId: idMap.get(c.sourceNodeId) || c.sourceNodeId, + targetNodeId: idMap.get(c.targetNodeId) || c.targetNodeId + })); + + // Regenerate event IDs + const newEvents = graph.events?.map(e => ({ + ...e, + id: generateId() + })); + + // Regenerate annotation IDs + const newAnnotations = graph.annotations?.map(a => ({ + ...a, + id: generateId() + })); + + return { + nodes: newNodes, + connections: newConnections, + events: newEvents, + annotations: newAnnotations + }; +} diff --git a/src/lib/stores/graph/index.ts b/src/lib/stores/graph/index.ts new file mode 100644 index 00000000..313e7142 --- /dev/null +++ b/src/lib/stores/graph/index.ts @@ -0,0 +1,127 @@ +/** + * Graph store - Main entry point + * + * Nested structure with path-based navigation. + * Subsystems contain their own graph, Interface derives from parent. + */ + +// Re-export types +export type { SearchableNode } from './state'; + +// Import stores for subscriptions +import { + currentNodes, + currentConnections, + currentAnnotations, + currentSubsystemEvents, + selectedNodeIds, + nodesArray, + selectedNodes, + currentPath, + breadcrumbs +} from './state'; + +// Import all operations +import * as navigation from './navigation'; +import * as nodes from './nodes'; +import * as connections from './connections'; +import * as ports from './ports'; +import * as annotations from './annotations'; +import * as subsystemEvents from './subsystemEvents'; +import * as selection from './selection'; +import * as serialization from './serialization'; + +/** + * Graph store - unified API + */ +export const graphStore = { + // ==================== SUBSCRIPTIONS ==================== + nodes: { subscribe: currentNodes.subscribe }, + connections: { subscribe: currentConnections.subscribe }, + annotations: { subscribe: currentAnnotations.subscribe }, + subsystemEvents: { subscribe: currentSubsystemEvents.subscribe }, + selectedNodeIds: { subscribe: selectedNodeIds.subscribe }, + nodesArray: { subscribe: nodesArray.subscribe }, + selectedNodes: { subscribe: selectedNodes.subscribe }, + currentPath: { subscribe: currentPath.subscribe }, + breadcrumbs: { subscribe: breadcrumbs.subscribe }, + + // ==================== NAVIGATION ==================== + drillDown: navigation.drillDown, + drillUp: navigation.drillUp, + navigateTo: navigation.navigateTo, + navigateToPath: navigation.navigateToPath, + isAtRoot: navigation.isAtRoot, + getCurrentPath: navigation.getCurrentPath, + + // ==================== NODE OPERATIONS ==================== + addNode: nodes.addNode, + removeNode: nodes.removeNode, + updateNodePosition: nodes.updateNodePosition, + updateNodeName: nodes.updateNodeName, + updateNodeColor: nodes.updateNodeColor, + updateNodeParams: nodes.updateNodeParams, + updateNode: nodes.updateNode, + getNode: nodes.getNode, + getAllNodes: nodes.getAllNodes, + duplicateSelected: nodes.duplicateSelected, + nudgeSelectedNodes: nodes.nudgeSelectedNodes, + pasteNodes: nodes.pasteNodes, + + // ==================== CONNECTION OPERATIONS ==================== + addConnection: connections.addConnection, + removeConnection: connections.removeConnection, + getAllConnections: connections.getAllConnections, + + // ==================== PORT OPERATIONS ==================== + addInputPort: ports.addInputPort, + removeInputPort: ports.removeInputPort, + addOutputPort: ports.addOutputPort, + removeOutputPort: ports.removeOutputPort, + + // ==================== ANNOTATION OPERATIONS ==================== + addAnnotation: annotations.addAnnotation, + updateAnnotation: annotations.updateAnnotation, + updateAnnotationPosition: annotations.updateAnnotationPosition, + removeAnnotation: annotations.removeAnnotation, + getAnnotation: annotations.getAnnotation, + + // ==================== SUBSYSTEM EVENT OPERATIONS ==================== + addSubsystemEvent: subsystemEvents.addSubsystemEvent, + removeSubsystemEvent: subsystemEvents.removeSubsystemEvent, + updateSubsystemEventPosition: subsystemEvents.updateSubsystemEventPosition, + updateSubsystemEventName: subsystemEvents.updateSubsystemEventName, + updateSubsystemEventParams: subsystemEvents.updateSubsystemEventParams, + updateSubsystemEventColor: subsystemEvents.updateSubsystemEventColor, + getSubsystemEvent: subsystemEvents.getSubsystemEvent, + getSubsystemEvents: subsystemEvents.getSubsystemEvents, + + // ==================== SELECTION ==================== + selectNode: selection.selectNode, + deselectNode: selection.deselectNode, + clearSelection: selection.clearSelection, + hasSelection: selection.hasSelection, + selectAll: selection.selectAll, + + // ==================== SERIALIZATION ==================== + clear: serialization.clear, + toJSON: serialization.toJSON, + fromJSON: serialization.fromJSON, + getAllNodesWithPaths: serialization.getAllNodesWithPaths +}; + +// Export helper utilities (can be used independently) +export { + generateId, + createPorts, + getSubsystemByPath, + deriveInterfaceNode, + collectAllNodes, + collectAllConnections, + isSubsystemNode, + isInterfaceNode, + findParentSubsystem, + cloneNode, + resizePorts, + regenerateGraphIds +} from './helpers'; diff --git a/src/lib/stores/graph/navigation.ts b/src/lib/stores/graph/navigation.ts new file mode 100644 index 00000000..8fa3c526 --- /dev/null +++ b/src/lib/stores/graph/navigation.ts @@ -0,0 +1,59 @@ +/** + * Graph store - Navigation operations + */ + +import { get } from 'svelte/store'; +import { currentPath, selectedNodeIds } from './state'; +import { triggerFitView } from '../viewActions'; + +/** + * Drill down into a subsystem + */ +export function drillDown(subsystemId: string): void { + currentPath.update(p => [...p, subsystemId]); + selectedNodeIds.set(new Set()); + setTimeout(() => triggerFitView(), 0); +} + +/** + * Go up one level + */ +export function drillUp(): void { + currentPath.update(p => p.slice(0, -1)); + selectedNodeIds.set(new Set()); + setTimeout(() => triggerFitView(), 0); +} + +/** + * Go to specific level (0 = root) + */ +export function navigateTo(level: number): void { + const current = get(currentPath).length; + if (level === current) return; + currentPath.update(p => p.slice(0, level)); + selectedNodeIds.set(new Set()); + setTimeout(() => triggerFitView(), 0); +} + +/** + * Navigate directly to a specific path (for search) + */ +export function navigateToPath(path: string[]): void { + currentPath.set(path); + selectedNodeIds.set(new Set()); + setTimeout(() => triggerFitView(), 0); +} + +/** + * Check if at root level + */ +export function isAtRoot(): boolean { + return get(currentPath).length === 0; +} + +/** + * Get the current navigation path + */ +export function getCurrentPath(): string[] { + return get(currentPath); +} diff --git a/src/lib/stores/graph/nodes.ts b/src/lib/stores/graph/nodes.ts new file mode 100644 index 00000000..89ba7c72 --- /dev/null +++ b/src/lib/stores/graph/nodes.ts @@ -0,0 +1,406 @@ +/** + * Graph store - Node CRUD operations + */ + +import { get } from 'svelte/store'; +import type { NodeInstance, PortInstance, Connection } from '$lib/nodes/types'; +import type { Position } from '$lib/types'; +import { nodeRegistry } from '$lib/nodes/registry'; +import { NODE_TYPES } from '$lib/constants/nodeTypes'; +import { + rootNodes, + rootConnections, + currentPath, + selectedNodeIds, + generateId, + getCurrentGraph, + updateSubsystemGraph, + addNodeToCurrentLevel, + updateNodeById, + updateCurrentNodes, + updateCurrentConnections, + updateCurrentNodesAndConnections +} from './state'; +import { regenerateGraphIds, createPorts } from './helpers'; +import { triggerSelectNodes } from '$lib/stores/viewActions'; + +/** + * Add a new node to the current graph context + */ +export function addNode( + type: string, + position: Position, + name?: string +): NodeInstance | null { + const typeDef = nodeRegistry.get(type); + if (!typeDef) { + console.error(`Unknown node type: ${type}`); + return null; + } + + const id = generateId(); + const node: NodeInstance = { + id, + type, + name: name || typeDef.name, + position, + inputs: createPorts(id, 'input', typeDef.ports.inputs), + outputs: createPorts(id, 'output', typeDef.ports.outputs), + params: {} // Start empty - defaults shown as placeholders, code gen uses Python defaults + }; + + // If it's a Subsystem, initialize with empty graph and Interface + if (type === NODE_TYPES.SUBSYSTEM) { + const interfaceId = generateId(); + const interfaceNode: NodeInstance = { + id: interfaceId, + type: NODE_TYPES.INTERFACE, + name: node.name, + position: { x: 100, y: 100 }, + inputs: [], + outputs: [], + params: {} + }; + node.graph = { + nodes: [interfaceNode], + connections: [] + }; + } + + addNodeToCurrentLevel(node); + + return node; +} + +/** + * Remove a node from the current graph context + */ +export function removeNode(id: string): void { + const currentGraph = getCurrentGraph(); + const node = currentGraph.nodes.get(id); + if (!node) return; + + // Prevent deletion of Interface blocks + if (node.type === NODE_TYPES.INTERFACE) { + console.warn('Interface blocks cannot be deleted'); + return; + } + + // Remove node and its connections + updateCurrentNodesAndConnections( + // Map updater for nodes (root) + nodes => { + const newMap = new Map(nodes); + newMap.delete(id); + return newMap; + }, + // Array updater for nodes (subsystem) + nodes => nodes.filter(n => n.id !== id), + // Connection updater (same for both levels) + conns => conns.filter(c => c.sourceNodeId !== id && c.targetNodeId !== id) + ); + + selectedNodeIds.update(ids => { + const newSet = new Set(ids); + newSet.delete(id); + return newSet; + }); +} + +/** + * Update a node's position + */ +export function updateNodePosition(id: string, position: Position): void { + updateNodeById(id, node => ({ ...node, position: { ...position } })); +} + +/** + * Update a node's name + * For Interface, updates the parent Subsystem's name + */ +export function updateNodeName(id: string, name: string): void { + const currentGraph = getCurrentGraph(); + const node = currentGraph.nodes.get(id); + if (!node) return; + + const path = get(currentPath); + + // If editing Interface, update parent Subsystem instead + if (node.type === NODE_TYPES.INTERFACE && path.length > 0) { + const parentPath = path.slice(0, -1); + const parentId = path[path.length - 1]; + + if (parentPath.length === 0) { + // Parent is at root level + rootNodes.update(n => { + const parent = n.get(parentId); + if (parent) { + const newMap = new Map(n); + newMap.set(parentId, { ...parent, name }); + return newMap; + } + return n; + }); + } else { + // Parent is in a subsystem + updateSubsystemGraph(parentPath, graph => ({ + ...graph, + nodes: graph.nodes.map(n => n.id === parentId ? { ...n, name } : n) + })); + } + return; + } + + // Normal node name update + updateNodeById(id, existing => ({ ...existing, name })); +} + +/** + * Update a node's color + * For Interface, updates the parent Subsystem's color (Interface color is derived from parent) + */ +export function updateNodeColor(id: string, color: string | undefined): void { + const currentGraph = getCurrentGraph(); + const node = currentGraph.nodes.get(id); + if (!node) return; + + const path = get(currentPath); + + // If editing Interface, update parent Subsystem instead + // (Interface color is derived from parent in getCurrentGraph) + if (node.type === NODE_TYPES.INTERFACE && path.length > 0) { + const parentPath = path.slice(0, -1); + const parentId = path[path.length - 1]; + + if (parentPath.length === 0) { + // Parent is at root level + rootNodes.update(n => { + const parent = n.get(parentId); + if (parent) { + const newMap = new Map(n); + newMap.set(parentId, { ...parent, color }); + return newMap; + } + return n; + }); + } else { + // Parent is in a subsystem + updateSubsystemGraph(parentPath, graph => ({ + ...graph, + nodes: graph.nodes.map(n => n.id === parentId ? { ...n, color } : n) + })); + } + return; + } + + // Normal node color update (including Subsystem - Interface derives from it automatically) + updateNodeById(id, n => ({ ...n, color })); +} + +/** + * Update a node's parameters + */ +export function updateNodeParams(id: string, params: Record): void { + updateNodeById(id, node => ({ ...node, params: { ...node.params, ...params } })); +} + +/** + * Update a node's properties (generic update for any field) + */ +export function updateNode(id: string, updates: Partial): void { + updateNodeById(id, node => ({ ...node, ...updates })); +} + +/** + * Get a node by ID (searches current context) + */ +export function getNode(id: string): NodeInstance | undefined { + return getCurrentGraph().nodes.get(id); +} + +/** + * Get all nodes recursively (for code generation) + */ +export function getAllNodes(): NodeInstance[] { + const all: NodeInstance[] = []; + const collectNodes = (nodes: NodeInstance[]) => { + for (const node of nodes) { + all.push(node); + if (node.graph) { + collectNodes(node.graph.nodes); + } + } + }; + collectNodes(Array.from(get(rootNodes).values())); + return all; +} + +/** + * Duplicate selected nodes (and connections between them) + */ +export function duplicateSelected(): string[] { + const selected = get(selectedNodeIds); + if (selected.size === 0) return []; + + const currentGraph = getCurrentGraph(); + const newNodeIds: string[] = []; + const offset = { x: 50, y: 50 }; + + // Map from old node ID to new node ID (for connection remapping) + const nodeIdMap = new Map(); + + // Build list of new nodes to add + const nodesToAdd: NodeInstance[] = []; + + selected.forEach(id => { + const original = currentGraph.nodes.get(id); + if (!original) return; + if (original.type === NODE_TYPES.INTERFACE) return; // Don't duplicate Interface + + const newId = generateId(); + nodeIdMap.set(id, newId); + + const newNode: NodeInstance = { + id: newId, + type: original.type, + name: original.name, + position: { + x: original.position.x + offset.x, + y: original.position.y + offset.y + }, + inputs: original.inputs.map((port, index) => ({ + ...port, + id: `${newId}-input-${index}`, + nodeId: newId + })), + outputs: original.outputs.map((port, index) => ({ + ...port, + id: `${newId}-output-${index}`, + nodeId: newId + })), + // Deep clone params to avoid shared references + params: JSON.parse(JSON.stringify(original.params)), + // Copy pinnedParams array if present + pinnedParams: original.pinnedParams ? [...original.pinnedParams] : undefined, + color: original.color + }; + + // Deep copy and regenerate IDs for subsystem graphs (handles nested subsystems) + if (original.graph) { + const clonedGraph = JSON.parse(JSON.stringify(original.graph)); + newNode.graph = regenerateGraphIds(clonedGraph); + } + + nodesToAdd.push(newNode); + newNodeIds.push(newId); + }); + + // Build list of new connections + const connections = currentGraph.connections; + const newConnections: Connection[] = []; + + for (const conn of connections) { + // Only duplicate if BOTH source and target were in the selection + const newSourceId = nodeIdMap.get(conn.sourceNodeId); + const newTargetId = nodeIdMap.get(conn.targetNodeId); + + if (newSourceId && newTargetId) { + newConnections.push({ + id: generateId(), + sourceNodeId: newSourceId, + sourcePortIndex: conn.sourcePortIndex, + targetNodeId: newTargetId, + targetPortIndex: conn.targetPortIndex + }); + } + } + + // Add all nodes and connections in one update + updateCurrentNodesAndConnections( + // Map updater for nodes (root) + nodes => { + const newMap = new Map(nodes); + for (const node of nodesToAdd) { + newMap.set(node.id, node); + } + return newMap; + }, + // Array updater for nodes (subsystem) + nodes => [...nodes, ...nodesToAdd], + // Connection updater + conns => [...conns, ...newConnections] + ); + + if (newNodeIds.length > 0) { + triggerSelectNodes(newNodeIds, false); + } + + return newNodeIds; +} + +/** + * Nudge selected nodes by a delta + */ +export function nudgeSelectedNodes(delta: Position): void { + const selected = get(selectedNodeIds); + if (selected.size === 0) return; + + const nudgePosition = (node: NodeInstance) => ({ + ...node, + position: { + x: node.position.x + delta.x, + y: node.position.y + delta.y + } + }); + + updateCurrentNodes( + // Map updater (root) + nodes => { + const newMap = new Map(nodes); + selected.forEach(id => { + const node = newMap.get(id); + if (node) { + newMap.set(id, nudgePosition(node)); + } + }); + return newMap; + }, + // Array updater (subsystem) + nodes => nodes.map(n => selected.has(n.id) ? nudgePosition(n) : n) + ); +} + +/** + * Paste pre-built nodes and connections into the current graph context + * Used by clipboard paste operation + * @returns IDs of pasted nodes + */ +export function pasteNodes( + nodes: NodeInstance[], + connections: Connection[] +): string[] { + if (nodes.length === 0) return []; + + const newNodeIds = nodes.map(n => n.id); + + // Add all nodes and connections + updateCurrentNodesAndConnections( + // Map updater for nodes (root) + existingNodes => { + const newMap = new Map(existingNodes); + for (const node of nodes) { + newMap.set(node.id, node); + } + return newMap; + }, + // Array updater for nodes (subsystem) + existingNodes => [...existingNodes, ...nodes], + // Connection updater + existingConns => [...existingConns, ...connections] + ); + + // Select the pasted nodes + triggerSelectNodes(newNodeIds, false); + + return newNodeIds; +} diff --git a/src/lib/stores/graph/ports.ts b/src/lib/stores/graph/ports.ts new file mode 100644 index 00000000..1df9994c --- /dev/null +++ b/src/lib/stores/graph/ports.ts @@ -0,0 +1,232 @@ +/** + * Graph store - Port management operations + */ + +import { get } from 'svelte/store'; +import type { NodeInstance, PortInstance } from '$lib/nodes/types'; +import { nodeRegistry } from '$lib/nodes/registry'; +import { NODE_TYPES } from '$lib/constants/nodeTypes'; +import { PORT_COLORS } from '$lib/utils/colors'; +import { PORT_NAME, HANDLE_ID } from '$lib/constants/handles'; +import { + rootNodes, + currentPath, + getCurrentGraph, + updateSubsystemGraph, + updateNodeById, + updateCurrentNodesAndConnections +} from './state'; + +type PortDirection = 'input' | 'output'; + +/** + * Get port configuration based on direction + */ +function getPortConfig(direction: PortDirection) { + return { + portsKey: direction === 'input' ? 'inputs' : 'outputs', + oppositeKey: direction === 'input' ? 'outputs' : 'inputs', + minKey: direction === 'input' ? 'minInputs' : 'minOutputs', + maxKey: direction === 'input' ? 'maxInputs' : 'maxOutputs', + connectionKey: direction === 'input' ? 'targetNodeId' : 'sourceNodeId', + connectionIndexKey: direction === 'input' ? 'targetPortIndex' : 'sourcePortIndex', + defaultName: direction === 'input' ? 'in' : 'out' + } as const; +} + +/** + * Update parent subsystem when modifying Interface node ports + * For Interface: input ↔ output mapping is inverted + */ +function updateParentSubsystem( + parentPath: string[], + parentId: string, + updater: (parent: NodeInstance) => NodeInstance +): void { + const updateParentNodes = (nodes: Map) => { + const parent = nodes.get(parentId); + if (!parent) return nodes; + + const newMap = new Map(nodes); + newMap.set(parentId, updater(parent)); + return newMap; + }; + + if (parentPath.length === 0) { + rootNodes.update(updateParentNodes); + } else { + updateSubsystemGraph(parentPath, graph => ({ + ...graph, + nodes: Array.from( + updateParentNodes(new Map(graph.nodes.map(n => [n.id, n]))).values() + ) + })); + } +} + +/** + * Internal: Add a port to a node + */ +function addPort(nodeId: string, direction: PortDirection): boolean { + const currentGraph = getCurrentGraph(); + const node = currentGraph.nodes.get(nodeId); + if (!node) return false; + + const config = getPortConfig(direction); + const path = get(currentPath); + + // Interface node: add port to parent Subsystem instead (inverted direction) + if (node.type === NODE_TYPES.INTERFACE && path.length > 0) { + const parentId = path[path.length - 1]; + const parentPath = path.slice(0, -1); + + // Interface input → parent output, Interface output → parent input + const parentPortsKey = config.oppositeKey; + const parentDirection = direction === 'input' ? 'output' : 'input'; + + updateParentSubsystem(parentPath, parentId, parent => { + const ports = parent[parentPortsKey] as PortInstance[]; + const index = ports.length; + return { + ...parent, + [parentPortsKey]: [ + ...ports, + { + id: HANDLE_ID[parentDirection](parentId, index), + nodeId: parentId, + name: PORT_NAME[parentDirection](index), + direction: parentDirection, + index, + color: PORT_COLORS.default + } as PortInstance + ] + }; + }); + return true; + } + + // Normal node: check max ports limit + const typeDef = nodeRegistry.get(node.type); + const maxPorts = typeDef?.ports[config.maxKey]; + const currentPorts = node[config.portsKey] as PortInstance[]; + if (maxPorts != null && currentPorts.length >= maxPorts) return false; + + // Create new port + const index = currentPorts.length; + const portDef = typeDef?.ports[config.portsKey]?.[index]; + const newPort: PortInstance = { + id: HANDLE_ID[direction](nodeId, index), + nodeId, + name: portDef?.name || PORT_NAME[direction](index), + direction, + index, + color: portDef?.color || PORT_COLORS.default + }; + + updateNodeById(nodeId, n => ({ + ...n, + [config.portsKey]: [...(n[config.portsKey] as PortInstance[]), newPort] + })); + + return true; +} + +/** + * Internal: Remove the last port from a node + */ +function removePort(nodeId: string, direction: PortDirection): boolean { + const currentGraph = getCurrentGraph(); + const node = currentGraph.nodes.get(nodeId); + if (!node) return false; + + const config = getPortConfig(direction); + const typeDef = nodeRegistry.get(node.type); + const minPorts = typeDef?.ports[config.minKey] ?? 1; + const currentPorts = node[config.portsKey] as PortInstance[]; + + if (currentPorts.length <= minPorts) return false; + + const path = get(currentPath); + + // Interface node: also remove port from parent Subsystem (inverted direction) + if (node.type === NODE_TYPES.INTERFACE && path.length > 0) { + const parentId = path[path.length - 1]; + const parentPath = path.slice(0, -1); + const parentPortsKey = config.oppositeKey; + + updateParentSubsystem(parentPath, parentId, parent => { + const ports = parent[parentPortsKey] as PortInstance[]; + if (ports.length === 0) return parent; + return { + ...parent, + [parentPortsKey]: ports.slice(0, -1) + }; + }); + // Fall through to also update the Interface node itself + } + + // Remove port and affected connections + const removedIndex = currentPorts.length - 1; + + updateCurrentNodesAndConnections( + // Map updater for nodes (root) + nodes => { + const n = nodes.get(nodeId); + if (!n) return nodes; + const newMap = new Map(nodes); + newMap.set(nodeId, { + ...n, + [config.portsKey]: (n[config.portsKey] as PortInstance[]).slice(0, -1) + }); + return newMap; + }, + // Array updater for nodes (subsystem) + nodes => nodes.map(n => + n.id === nodeId + ? { ...n, [config.portsKey]: (n[config.portsKey] as PortInstance[]).slice(0, -1) } + : n + ), + // Connection updater - remove connections to/from the removed port + conns => conns.filter(c => { + const connNodeId = c[config.connectionKey]; + const connIndex = c[config.connectionIndexKey]; + return !(connNodeId === nodeId && connIndex >= removedIndex); + }) + ); + + return true; +} + +// ==================== PUBLIC API ==================== + +/** + * Add an input port to a node + * For Interface, adds an output to parent Subsystem + */ +export function addInputPort(nodeId: string): boolean { + return addPort(nodeId, 'input'); +} + +/** + * Add an output port to a node + * For Interface, adds an input to parent Subsystem + */ +export function addOutputPort(nodeId: string): boolean { + return addPort(nodeId, 'output'); +} + +/** + * Remove the last input port from a node + * For Interface, removes an output from parent Subsystem + */ +export function removeInputPort(nodeId: string): boolean { + return removePort(nodeId, 'input'); +} + +/** + * Remove the last output port from a node + * For Interface, removes an input from parent Subsystem + */ +export function removeOutputPort(nodeId: string): boolean { + return removePort(nodeId, 'output'); +} diff --git a/src/lib/stores/graph/selection.ts b/src/lib/stores/graph/selection.ts new file mode 100644 index 00000000..e905db62 --- /dev/null +++ b/src/lib/stores/graph/selection.ts @@ -0,0 +1,64 @@ +/** + * Graph store - Selection management + * + * Selection is managed by SvelteFlow as the source of truth. + * These functions trigger SvelteFlow updates, which then sync back to stores. + */ + +import { get } from 'svelte/store'; +import { selectedNodeIds, getCurrentGraph, isAtRootLevel } from './state'; +import { triggerSelectNodes, triggerClearSelection } from '$lib/stores/viewActions'; +import { eventStore } from '$lib/stores/events'; + +/** + * Select a node (triggers SvelteFlow update) + */ +export function selectNode(id: string, addToSelection = false): void { + if (addToSelection) { + // Get currently selected nodes and add the new one + const current = get(selectedNodeIds); + triggerSelectNodes([...current, id], true); + } else { + triggerSelectNodes([id], false); + } +} + +/** + * Deselect a node (triggers SvelteFlow update) + */ +export function deselectNode(id: string): void { + const current = get(selectedNodeIds); + const remaining = [...current].filter(nodeId => nodeId !== id); + triggerSelectNodes(remaining, false); +} + +/** + * Clear selection (triggers SvelteFlow update) + */ +export function clearSelection(): void { + triggerClearSelection(); +} + +/** + * Check if any nodes are currently selected + */ +export function hasSelection(): boolean { + return get(selectedNodeIds).size > 0; +} + +/** + * Select all nodes and events in current context (triggers SvelteFlow update) + */ +export function selectAll(): void { + const graph = getCurrentGraph(); + + // Get all node IDs + const allNodeIds = Array.from(graph.nodes.keys()); + + // Get all event IDs (from eventStore at root, from subsystem graph otherwise) + const allEventIds = isAtRootLevel() + ? Array.from(get(eventStore.events).keys()) + : Array.from(graph.events.keys()); + + triggerSelectNodes([...allNodeIds, ...allEventIds], false); +} diff --git a/src/lib/stores/graph/serialization.ts b/src/lib/stores/graph/serialization.ts new file mode 100644 index 00000000..e54d989a --- /dev/null +++ b/src/lib/stores/graph/serialization.ts @@ -0,0 +1,91 @@ +/** + * Graph store - Serialization (toJSON, fromJSON, clear) + */ + +import { get } from 'svelte/store'; +import type { NodeInstance, Connection, Annotation } from '$lib/nodes/types'; +import { NODE_TYPES } from '$lib/constants/nodeTypes'; +import { + rootNodes, + rootConnections, + rootAnnotations, + currentPath, + selectedNodeIds, + type SearchableNode +} from './state'; + +/** + * Clear the entire graph + */ +export function clear(): void { + rootNodes.set(new Map()); + rootConnections.set([]); + rootAnnotations.set(new Map()); + currentPath.set([]); + selectedNodeIds.set(new Set()); +} + +/** + * Get current state as JSON-serializable object (nested structure) + */ +export function toJSON(): { nodes: NodeInstance[]; connections: Connection[]; annotations: Annotation[] } { + return { + nodes: Array.from(get(rootNodes).values()), + connections: get(rootConnections), + annotations: Array.from(get(rootAnnotations).values()) + }; +} + +/** + * Load state from JSON + */ +export function fromJSON(nodeList: NodeInstance[], connectionList: Connection[], annotationList?: Annotation[]): void { + if (!nodeList || !Array.isArray(nodeList)) { + rootNodes.set(new Map()); + } else { + rootNodes.set(new Map(nodeList.map(n => [n.id, n]))); + } + rootConnections.set(connectionList || []); + rootAnnotations.set(new Map((annotationList || []).map(a => [a.id, a]))); + currentPath.set([]); + selectedNodeIds.set(new Set()); +} + +/** + * Get all nodes with their path information (for global search) + * Excludes Interface nodes as they're internal to subsystems + */ +export function getAllNodesWithPaths(): SearchableNode[] { + const results: SearchableNode[] = []; + + const collectNodes = ( + nodes: NodeInstance[], + path: string[], + pathNames: string[] + ) => { + for (const node of nodes) { + // Skip Interface nodes - they're internal + if (node.type === NODE_TYPES.INTERFACE) continue; + + results.push({ + node, + path: [...path], + pathNames: [...pathNames], + depth: path.length + }); + + // Recurse into subsystems + if (node.graph) { + collectNodes( + node.graph.nodes, + [...path, node.id], + [...pathNames, node.name] + ); + } + } + }; + + collectNodes(Array.from(get(rootNodes).values()), [], ['Root']); + return results; +} + diff --git a/src/lib/stores/graph/state.ts b/src/lib/stores/graph/state.ts new file mode 100644 index 00000000..b3ddf4c9 --- /dev/null +++ b/src/lib/stores/graph/state.ts @@ -0,0 +1,389 @@ +/** + * Graph store - Core state (writable and derived stores) + */ + +import { writable, derived, get } from 'svelte/store'; +import type { NodeInstance, Connection, SubsystemGraph, Annotation } from '$lib/nodes/types'; +import type { EventInstance } from '$lib/events/types'; +import { NODE_TYPES } from '$lib/constants/nodeTypes'; + +/** + * Node with path information for global search + */ +export interface SearchableNode { + node: NodeInstance; + path: string[]; // Subsystem IDs: [] = root, ['sub1'] = inside sub1 + pathNames: string[]; // Display names: ['Root'] or ['Root', 'SubsystemA'] + depth: number; // 0 = root level +} + +// ==================== WRITABLE STORES ==================== + +/** Root level nodes */ +export const rootNodes = writable>(new Map()); + +/** Root level connections */ +export const rootConnections = writable([]); + +/** Root level annotations */ +export const rootAnnotations = writable>(new Map()); + +/** Navigation path: [] = root, ['sub1'] = inside sub1, ['sub1', 'sub2'] = nested */ +export const currentPath = writable([]); + +/** Selected node IDs (within current context) */ +export const selectedNodeIds = writable>(new Set()); + +// ==================== RE-EXPORTS ==================== + +// Re-export generateId from utils for backwards compatibility +export { generateId } from '$lib/stores/utils'; + +// ==================== HELPER FUNCTIONS ==================== + +/** + * Get a subsystem node by following a path from root + */ +export function getSubsystemByPath(path: string[]): NodeInstance | null { + if (path.length === 0) return null; + + let currentNodes = get(rootNodes); + let subsystem: NodeInstance | null = null; + + for (let i = 0; i < path.length; i++) { + const node = currentNodes.get(path[i]); + if (!node || node.type !== NODE_TYPES.SUBSYSTEM) return null; + subsystem = node; + if (i < path.length - 1 && node.graph) { + currentNodes = new Map(node.graph.nodes.map(n => [n.id, n])); + } + } + + return subsystem; +} + +/** + * Get the current graph (nodes/connections/annotations/events) based on navigation path + */ +export function getCurrentGraph(): { + nodes: Map; + connections: Connection[]; + annotations: Map; + events: Map; +} { + const path = get(currentPath); + + if (path.length === 0) { + // Root level - events are managed by eventStore, return empty map here + return { + nodes: get(rootNodes), + connections: get(rootConnections), + annotations: get(rootAnnotations), + events: new Map() + }; + } + + const subsystem = getSubsystemByPath(path); + if (!subsystem || !subsystem.graph) { + return { nodes: new Map(), connections: [], annotations: new Map(), events: new Map() }; + } + + // For Interface node, derive name/color/ports from parent Subsystem + const nodesWithDerivedInterface = subsystem.graph.nodes.map(node => { + if (node.type === NODE_TYPES.INTERFACE) { + return { + ...node, + name: subsystem.name, + color: subsystem.color, + // Subsystem inputs → Interface outputs (signals coming in) + outputs: subsystem.inputs.map((port, i) => ({ + id: `${node.id}-output-${i}`, + nodeId: node.id, + name: `in ${i}`, + direction: 'output' as const, + index: i, + color: port.color + })), + // Subsystem outputs → Interface inputs (signals going out) + inputs: subsystem.outputs.map((port, i) => ({ + id: `${node.id}-input-${i}`, + nodeId: node.id, + name: `out ${i}`, + direction: 'input' as const, + index: i, + color: port.color + })) + }; + } + return node; + }); + + return { + nodes: new Map(nodesWithDerivedInterface.map(n => [n.id, n])), + connections: subsystem.graph.connections, + annotations: new Map((subsystem.graph.annotations || []).map(a => [a.id, a])), + events: new Map((subsystem.graph.events || []).map(e => [e.id, e])) + }; +} + +/** + * Update a subsystem's graph by path + */ +export function updateSubsystemGraph( + path: string[], + updater: (graph: SubsystemGraph) => SubsystemGraph +): void { + if (path.length === 0) return; + + rootNodes.update(root => { + const newRoot = new Map(root); + + // Navigate to parent and update the subsystem + const updateNested = (nodes: Map, remainingPath: string[]): Map => { + if (remainingPath.length === 0) return nodes; + + const [currentId, ...rest] = remainingPath; + const node = nodes.get(currentId); + + if (!node || node.type !== NODE_TYPES.SUBSYSTEM) return nodes; + + const newNodes = new Map(nodes); + + if (rest.length === 0) { + // This is the target subsystem + const newGraph = updater(node.graph || { nodes: [], connections: [] }); + newNodes.set(currentId, { ...node, graph: newGraph }); + } else { + // Need to go deeper - preserve all graph properties (annotations, events) + const childNodes = new Map((node.graph?.nodes || []).map(n => [n.id, n])); + const updatedChildNodes = updateNested(childNodes, rest); + newNodes.set(currentId, { + ...node, + graph: { + nodes: Array.from(updatedChildNodes.values()), + connections: node.graph?.connections || [], + annotations: node.graph?.annotations, + events: node.graph?.events + } + }); + } + + return newNodes; + }; + + return updateNested(newRoot, path); + }); +} + +/** + * Get the parent Subsystem node (for Interface operations) + */ +export function getParentSubsystem(): NodeInstance | null { + const path = get(currentPath); + if (path.length === 0) return null; + return getSubsystemByPath(path); +} + +// ==================== LEVEL-AWARE UPDATE HELPERS ==================== + +/** + * Update nodes at the current level (root or subsystem) + * Handles the Map vs Array difference between root and subsystem levels + */ +export function updateCurrentNodes( + mapUpdater: (nodes: Map) => Map, + arrayUpdater: (nodes: NodeInstance[]) => NodeInstance[] +): void { + const path = get(currentPath); + + if (path.length === 0) { + rootNodes.update(mapUpdater); + } else { + updateSubsystemGraph(path, graph => ({ + ...graph, + nodes: arrayUpdater(graph.nodes) + })); + } +} + +/** + * Update a single node by ID at the current level + * Returns true if the node was found and updated + */ +export function updateNodeById( + id: string, + transform: (node: NodeInstance) => NodeInstance +): void { + updateCurrentNodes( + // Map updater (root) + nodes => { + const node = nodes.get(id); + if (node) { + const newMap = new Map(nodes); + newMap.set(id, transform(node)); + return newMap; + } + return nodes; + }, + // Array updater (subsystem) + nodes => nodes.map(n => n.id === id ? transform(n) : n) + ); +} + +/** + * Add a node at the current level + */ +export function addNodeToCurrentLevel(node: NodeInstance): void { + updateCurrentNodes( + nodes => { + const newMap = new Map(nodes); + newMap.set(node.id, node); + return newMap; + }, + nodes => [...nodes, node] + ); +} + +/** + * Remove a node by ID at the current level + */ +export function removeNodeFromCurrentLevel(id: string): void { + updateCurrentNodes( + nodes => { + const newMap = new Map(nodes); + newMap.delete(id); + return newMap; + }, + nodes => nodes.filter(n => n.id !== id) + ); +} + +/** + * Update connections at the current level + * Both root and subsystem use arrays, so only one updater needed + */ +export function updateCurrentConnections( + updater: (connections: Connection[]) => Connection[] +): void { + const path = get(currentPath); + + if (path.length === 0) { + rootConnections.update(updater); + } else { + updateSubsystemGraph(path, graph => ({ + ...graph, + connections: updater(graph.connections) + })); + } +} + +/** + * Update both nodes and connections at the current level. + * + * Note: At root level, this performs two separate store updates (not truly atomic). + * Subscribers may briefly see intermediate state. This is acceptable because: + * 1. Each individual store update is atomic + * 2. Svelte batches synchronous updates before re-rendering + * 3. History snapshots are taken before this is called + * + * At subsystem level, the update is truly atomic (single store update). + */ +export function updateCurrentNodesAndConnections( + nodeMapUpdater: (nodes: Map) => Map, + nodeArrayUpdater: (nodes: NodeInstance[]) => NodeInstance[], + connectionUpdater: (connections: Connection[]) => Connection[] +): void { + const path = get(currentPath); + + if (path.length === 0) { + rootNodes.update(nodeMapUpdater); + rootConnections.update(connectionUpdater); + } else { + updateSubsystemGraph(path, graph => ({ + ...graph, + nodes: nodeArrayUpdater(graph.nodes), + connections: connectionUpdater(graph.connections) + })); + } +} + +/** + * Update annotations at the current level + * Handles the Map vs Array difference between root and subsystem levels + */ +export function updateCurrentAnnotations( + mapUpdater: (annotations: Map) => Map, + arrayUpdater: (annotations: Annotation[]) => Annotation[] +): void { + const path = get(currentPath); + + if (path.length === 0) { + rootAnnotations.update(mapUpdater); + } else { + updateSubsystemGraph(path, graph => ({ + ...graph, + annotations: arrayUpdater(graph.annotations || []) + })); + } +} + +/** + * Check if we're currently at root level + */ +export function isAtRootLevel(): boolean { + return get(currentPath).length === 0; +} + +// ==================== DERIVED STORES ==================== + +/** Current context nodes (based on navigation path) */ +export const currentNodes = derived( + [rootNodes, currentPath], + () => getCurrentGraph().nodes +); + +/** Current context connections */ +export const currentConnections = derived( + [rootNodes, rootConnections, currentPath], + () => getCurrentGraph().connections +); + +/** Current context annotations */ +export const currentAnnotations = derived( + [rootNodes, rootAnnotations, currentPath], + () => getCurrentGraph().annotations +); + +/** Subsystem events (root events are in eventStore) */ +export const currentSubsystemEvents = derived( + [rootNodes, currentPath], + () => getCurrentGraph().events +); + +/** Nodes as array */ +export const nodesArray = derived(currentNodes, ($nodes) => Array.from($nodes.values())); + +/** Selected nodes */ +export const selectedNodes = derived([currentNodes, selectedNodeIds], ([$nodes, $selectedIds]) => + Array.from($selectedIds) + .map((id) => $nodes.get(id)) + .filter((n): n is NodeInstance => n !== undefined) +); + +/** Breadcrumbs for navigation UI */ +export const breadcrumbs = derived([rootNodes, currentPath], ([$rootNodes, $path]) => { + const crumbs: { id: string; name: string }[] = [{ id: '', name: 'Root' }]; + + let currentNodes = $rootNodes; + for (const subsystemId of $path) { + const node = currentNodes.get(subsystemId); + if (node && node.type === NODE_TYPES.SUBSYSTEM) { + crumbs.push({ id: subsystemId, name: node.name }); + if (node.graph) { + currentNodes = new Map(node.graph.nodes.map(n => [n.id, n])); + } + } + } + + return crumbs; +}); diff --git a/src/lib/stores/graph/subsystemEvents.ts b/src/lib/stores/graph/subsystemEvents.ts new file mode 100644 index 00000000..6f6d0798 --- /dev/null +++ b/src/lib/stores/graph/subsystemEvents.ts @@ -0,0 +1,113 @@ +/** + * Graph store - Subsystem event operations + * These methods manage events within subsystems (not root level) + */ + +import { get } from 'svelte/store'; +import type { EventInstance } from '$lib/events/types'; +import { + currentPath, + getCurrentGraph, + updateSubsystemGraph +} from './state'; + +/** + * Add an event to the current subsystem (only works inside subsystems) + */ +export function addSubsystemEvent(event: EventInstance): boolean { + const path = get(currentPath); + if (path.length === 0) return false; // Root events use eventStore + + updateSubsystemGraph(path, graph => ({ + ...graph, + events: [...(graph.events || []), event] + })); + return true; +} + +/** + * Remove an event from the current subsystem + */ +export function removeSubsystemEvent(id: string): void { + const path = get(currentPath); + if (path.length === 0) return; + + updateSubsystemGraph(path, graph => ({ + ...graph, + events: (graph.events || []).filter(e => e.id !== id) + })); +} + +/** + * Update a subsystem event's position + */ +export function updateSubsystemEventPosition(id: string, position: { x: number; y: number }): void { + const path = get(currentPath); + if (path.length === 0) return; + + updateSubsystemGraph(path, graph => ({ + ...graph, + events: (graph.events || []).map(e => + e.id === id ? { ...e, position: { ...position } } : e + ) + })); +} + +/** + * Update a subsystem event's name + */ +export function updateSubsystemEventName(id: string, name: string): void { + const path = get(currentPath); + if (path.length === 0) return; + + updateSubsystemGraph(path, graph => ({ + ...graph, + events: (graph.events || []).map(e => + e.id === id ? { ...e, name } : e + ) + })); +} + +/** + * Update a subsystem event's parameters + */ +export function updateSubsystemEventParams(id: string, params: Record): void { + const path = get(currentPath); + if (path.length === 0) return; + + updateSubsystemGraph(path, graph => ({ + ...graph, + events: (graph.events || []).map(e => + e.id === id ? { ...e, params: { ...e.params, ...params } } : e + ) + })); +} + +/** + * Update a subsystem event's color + */ +export function updateSubsystemEventColor(id: string, color: string | undefined): void { + const path = get(currentPath); + if (path.length === 0) return; + + updateSubsystemGraph(path, graph => ({ + ...graph, + events: (graph.events || []).map(e => + e.id === id ? { ...e, color } : e + ) + })); +} + +/** + * Get a subsystem event by ID + */ +export function getSubsystemEvent(id: string): EventInstance | undefined { + return getCurrentGraph().events.get(id); +} + +/** + * Get all subsystem events as array + */ +export function getSubsystemEvents(): EventInstance[] { + return Array.from(getCurrentGraph().events.values()); +} diff --git a/src/lib/stores/history.ts b/src/lib/stores/history.ts new file mode 100644 index 00000000..dbc09410 --- /dev/null +++ b/src/lib/stores/history.ts @@ -0,0 +1,329 @@ +/** + * History store for undo/redo functionality + * + * Uses centralized mutation wrapper with state snapshots. + * All mutations should go through mutate() or mutateAsync(). + * Drag operations use beginDrag/endDrag for coalescing. + */ + +import { writable, get } from 'svelte/store'; +import type { NodeInstance, Connection, Annotation } from '$lib/nodes/types'; +import type { EventInstance } from '$lib/events/types'; +import { graphStore } from './graph'; +import { eventStore } from './events'; + +/** + * Complete state snapshot for undo/redo + */ +interface HistoryState { + graph: { + nodes: NodeInstance[]; + connections: Connection[]; + annotations: Annotation[]; + }; + events: EventInstance[]; +} + +/** + * Internal history state + */ +interface HistoryStore { + undoStack: HistoryState[]; + redoStack: HistoryState[]; + canUndo: boolean; + canRedo: boolean; +} + +const MAX_HISTORY = 50; + +// Internal state +let undoStack: HistoryState[] = []; +let redoStack: HistoryState[] = []; +let isDragging = false; +let dragStartState: HistoryState | null = null; +let isRestoring = false; + +// Mutation tracking - for nested mutate() coalescing +let mutationDepth = 0; +let pendingSnapshot: HistoryState | null = null; + +// Reactive store for UI binding +const store = writable({ + undoStack: [], + redoStack: [], + canUndo: false, + canRedo: false +}); + +/** + * Update the reactive store + */ +function updateStore(): void { + store.set({ + undoStack, + redoStack, + canUndo: undoStack.length > 0, + canRedo: redoStack.length > 0 + }); +} + +/** + * Capture current state from all stores + */ +function captureState(): HistoryState { + return { + graph: graphStore.toJSON(), + events: eventStore.toJSON() + }; +} + +/** + * Commit a snapshot to the undo stack + */ +function commitSnapshot(snapshot: HistoryState): void { + undoStack.push(snapshot); + + // Enforce history limit + if (undoStack.length > MAX_HISTORY) { + undoStack.shift(); + } + + // Clear redo stack on new action + redoStack = []; + + updateStore(); +} + +/** + * Restore state to all stores + */ +function restoreState(state: HistoryState): void { + // Signal that we're restoring (so FlowCanvas uses store positions) + isRestoring = true; + + try { + graphStore.fromJSON( + state.graph.nodes, + state.graph.connections, + state.graph.annotations + ); + eventStore.fromJSON(state.events); + } finally { + // Clear restoring flag after a microtask to let effects run + queueMicrotask(() => { + isRestoring = false; + }); + } +} + +/** + * Check if currently restoring state (used by FlowCanvas to know when to use store positions) + */ +function isRestoringState(): boolean { + return isRestoring; +} + +/** + * Execute a mutation with automatic undo support + * Captures state before the mutation and commits on success. + * Nested calls are coalesced - only the outermost captures/commits. + * + * @param fn - The mutation function to execute + * @returns The return value of fn + */ +function mutate(fn: () => T): T { + // Don't capture during drag (drag has its own handling) + if (isDragging) { + return fn(); + } + + const isOutermost = mutationDepth === 0; + + if (isOutermost) { + pendingSnapshot = captureState(); + } + mutationDepth++; + + try { + const result = fn(); + + // Commit on success (only outermost) + if (isOutermost && pendingSnapshot) { + commitSnapshot(pendingSnapshot); + } + + return result; + } catch (error) { + // Don't commit on error - mutation failed + throw error; + } finally { + mutationDepth--; + if (mutationDepth === 0) { + pendingSnapshot = null; + } + } +} + +/** + * Execute an async mutation with automatic undo support + * Same as mutate() but for async functions. + * + * @param fn - The async mutation function to execute + * @returns Promise resolving to the return value of fn + */ +async function mutateAsync(fn: () => Promise): Promise { + // Don't capture during drag (drag has its own handling) + if (isDragging) { + return fn(); + } + + const isOutermost = mutationDepth === 0; + + if (isOutermost) { + pendingSnapshot = captureState(); + } + mutationDepth++; + + try { + const result = await fn(); + + // Commit on success (only outermost) + if (isOutermost && pendingSnapshot) { + commitSnapshot(pendingSnapshot); + } + + return result; + } catch (error) { + // Don't commit on error - mutation failed + throw error; + } finally { + mutationDepth--; + if (mutationDepth === 0) { + pendingSnapshot = null; + } + } +} + +/** + * Check if currently inside a mutation + * Useful for store functions to warn about untracked mutations in dev mode + */ +function isInMutation(): boolean { + return mutationDepth > 0 || isDragging; +} + +/** + * Begin a drag operation + * Captures state and blocks further snapshots until endDrag() + */ +function beginDrag(): void { + if (isDragging) return; + + isDragging = true; + dragStartState = captureState(); +} + +/** + * End a drag operation + * Pushes the captured drag start state to the undo stack + */ +function endDrag(): void { + if (!isDragging) return; + + isDragging = false; + + if (dragStartState) { + commitSnapshot(dragStartState); + dragStartState = null; + } +} + +/** + * Cancel a drag operation without creating an undo entry + */ +function cancelDrag(): void { + isDragging = false; + dragStartState = null; +} + +/** + * Undo the last action + * @returns true if undo was performed, false if nothing to undo + */ +function undo(): boolean { + // Don't allow undo during drag or mutation + if (isDragging || mutationDepth > 0) return false; + + if (undoStack.length === 0) return false; + + // Capture current state for redo + const currentState = captureState(); + + // Pop and restore previous state + const previousState = undoStack.pop()!; + redoStack.push(currentState); + + restoreState(previousState); + updateStore(); + + return true; +} + +/** + * Redo the last undone action + * @returns true if redo was performed, false if nothing to redo + */ +function redo(): boolean { + // Don't allow redo during drag or mutation + if (isDragging || mutationDepth > 0) return false; + + if (redoStack.length === 0) return false; + + // Capture current state for undo + const currentState = captureState(); + + // Pop and restore next state + const nextState = redoStack.pop()!; + undoStack.push(currentState); + + restoreState(nextState); + updateStore(); + + return true; +} + +/** + * Clear all history + * Call this when loading a new file or creating a new graph + */ +function clear(): void { + undoStack = []; + redoStack = []; + isDragging = false; + dragStartState = null; + mutationDepth = 0; + pendingSnapshot = null; + updateStore(); +} + +/** + * Check if currently in a drag operation + */ +function isInDrag(): boolean { + return isDragging; +} + +export const historyStore = { + subscribe: store.subscribe, + mutate, + mutateAsync, + isInMutation, + beginDrag, + endDrag, + cancelDrag, + undo, + redo, + clear, + isInDrag, + isRestoringState +}; diff --git a/src/lib/stores/hoveredHandle.ts b/src/lib/stores/hoveredHandle.ts new file mode 100644 index 00000000..49106fab --- /dev/null +++ b/src/lib/stores/hoveredHandle.ts @@ -0,0 +1,7 @@ +import { writable } from 'svelte/store'; + +// Hovered handle (single handle hover) +export const hoveredHandle = writable<{ nodeId: string; handleId: string; color?: string } | null>(null); + +// Selected node for edge highlighting (separate from hover) +export const selectedNodeHighlight = writable<{ nodeId: string; color?: string } | null>(null); diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts new file mode 100644 index 00000000..74f1a2f1 --- /dev/null +++ b/src/lib/stores/index.ts @@ -0,0 +1,30 @@ +/** + * Store exports - convenience re-exports from store modules + */ + +// Core state stores +export { graphStore } from './graph'; +export { eventStore } from './events'; +export { settingsStore } from './settings'; +export { themeStore } from './theme'; +export { consoleStore } from './console'; +export { codeContextStore } from './codeContext'; + +// Dialog stores +export { nodeDialogStore, openNodeDialog, closeNodeDialog } from './nodeDialog'; +export { eventDialogStore, openEventDialog, closeEventDialog } from './eventDialog'; + +// Unified event facade (simplifies event operations across root/subsystem) +export { unifiedEvents } from './eventFacade'; + +// UI state stores +export { contextMenuStore } from './contextMenu'; +export { nodeUpdatesStore } from './nodeUpdates'; +export { pinnedPreviewsStore } from './pinnedPreviews'; +export { hoveredHandle, selectedNodeHighlight } from './hoveredHandle'; + +// View actions (re-exports triggers and utils) +export * from './viewActions'; + +// Drop target bridge (not a reactive store) +export { dropTargetBridge } from './dropTargetBridge'; diff --git a/src/lib/stores/nodeDialog.ts b/src/lib/stores/nodeDialog.ts new file mode 100644 index 00000000..0309faea --- /dev/null +++ b/src/lib/stores/nodeDialog.ts @@ -0,0 +1,30 @@ +/** + * Store for managing the block properties dialog + */ +import { writable, get } from 'svelte/store'; + +const internal = writable(null); + +export const nodeDialogStore = { + subscribe: internal.subscribe, + + open(nodeId: string): void { + internal.set(nodeId); + }, + + close(): void { + internal.set(null); + }, + + isOpen(): boolean { + return get(internal) !== null; + }, + + getOpenId(): string | null { + return get(internal); + } +}; + +// Convenience function exports for ergonomic usage +export const openNodeDialog = (nodeId: string) => nodeDialogStore.open(nodeId); +export const closeNodeDialog = () => nodeDialogStore.close(); diff --git a/src/lib/stores/nodeUpdates.ts b/src/lib/stores/nodeUpdates.ts new file mode 100644 index 00000000..f48de891 --- /dev/null +++ b/src/lib/stores/nodeUpdates.ts @@ -0,0 +1,33 @@ +/** + * Node updates store + * Triggers node re-renders when rotation or other internal changes happen + */ + +import { writable, get } from 'svelte/store'; + +const pendingUpdates = writable([]); + +export const nodeUpdatesStore = { + subscribe: pendingUpdates.subscribe, + + /** + * Queue nodes for update (e.g., after rotation) + */ + queueUpdate(nodeIds: string[]): void { + pendingUpdates.update(current => [...current, ...nodeIds]); + }, + + /** + * Clear pending updates after processing + */ + clear(): void { + pendingUpdates.set([]); + }, + + /** + * Get current pending updates + */ + get(): string[] { + return get(pendingUpdates); + } +}; diff --git a/src/lib/stores/pinnedPreviews.ts b/src/lib/stores/pinnedPreviews.ts new file mode 100644 index 00000000..f1097383 --- /dev/null +++ b/src/lib/stores/pinnedPreviews.ts @@ -0,0 +1,27 @@ +/** + * Pinned Previews store + * Controls whether plot previews are shown on all recording nodes + */ + +import { writable } from 'svelte/store'; + +// Create the store +const pinned = writable(false); + +export const pinnedPreviewsStore = { + subscribe: pinned.subscribe, + + /** + * Toggle pinned state + */ + toggle(): void { + pinned.update((current) => !current); + }, + + /** + * Set pinned state + */ + set(value: boolean): void { + pinned.set(value); + } +}; diff --git a/src/lib/stores/recordingData.svelte.ts b/src/lib/stores/recordingData.svelte.ts new file mode 100644 index 00000000..b32301ec --- /dev/null +++ b/src/lib/stores/recordingData.svelte.ts @@ -0,0 +1,120 @@ +/** + * Reactive recording node data helper + * Shared between BaseNode (plot preview) and BlockPropertiesDialog (CSV export) + */ + +import { simulationState, type SimulationResult, type SimulationPhase } from '$lib/pyodide/bridge'; +import { settingsStore } from '$lib/stores/settings'; + +export type DataSource = 'scope' | 'spectrum'; + +export interface ScopeData { + time: number[]; + signals: number[][]; + labels?: string[]; +} + +export interface SpectrumData { + frequency: number[]; + magnitude: number[][]; + labels?: string[]; +} + +/** + * Create reactive state for a recording node's data + * Must be called from component initialization (uses $state) + */ +export function createRecordingDataState() { + let simResult = $state(null); + let resultHistory = $state([]); + let simPhase = $state('idle'); + let ghostTraces = $state(0); + + const unsubscribeSim = simulationState.subscribe((s) => { + simResult = s.result; + resultHistory = s.resultHistory; + simPhase = s.phase; + }); + + const unsubscribeSettings = settingsStore.subscribe((s) => { + ghostTraces = s.ghostTraces ?? 0; + }); + + function getDataSource(nodeType: string): DataSource { + return nodeType === 'Scope' ? 'scope' : 'spectrum'; + } + + function hasData(nodeId: string, nodeType: string): boolean { + if (!simResult) return false; + const dataSource = getDataSource(nodeType); + + if (dataSource === 'scope') { + const data = simResult.scopeData?.[nodeId]; + return !!(data && data.time.length > 0); + } else { + const data = simResult.spectrumData?.[nodeId]; + return !!(data && data.frequency.length > 0); + } + } + + function getScopeData(nodeId: string): ScopeData | null { + return simResult?.scopeData?.[nodeId] || null; + } + + function getSpectrumData(nodeId: string): SpectrumData | null { + return simResult?.spectrumData?.[nodeId] || null; + } + + function getPlotData(nodeId: string, nodeType: string): { type: 'scope'; data: ScopeData } | { type: 'spectrum'; data: SpectrumData } | null { + const dataSource = getDataSource(nodeType); + + if (dataSource === 'scope') { + const data = getScopeData(nodeId); + // Return data if it exists (even with empty arrays - keeps preview mounted during rerun) + if (data) { + return { type: 'scope', data }; + } + } else { + const data = getSpectrumData(nodeId); + if (data) { + return { type: 'spectrum', data }; + } + } + return null; + } + + function getGhostData(nodeId: string, nodeType: string): (ScopeData | SpectrumData)[] { + if (ghostTraces === 0 || resultHistory.length === 0) return []; + + const dataSource = getDataSource(nodeType); + const history = resultHistory.slice(0, ghostTraces); + + return history + .map((result) => { + if (dataSource === 'scope') { + return result.scopeData?.[nodeId]; + } else { + return result.spectrumData?.[nodeId]; + } + }) + .filter((d): d is ScopeData | SpectrumData => d != null); + } + + function destroy() { + unsubscribeSim(); + unsubscribeSettings(); + } + + return { + get simResult() { return simResult; }, + get resultHistory() { return resultHistory; }, + get simPhase() { return simPhase; }, + get ghostTraces() { return ghostTraces; }, + hasData, + getScopeData, + getSpectrumData, + getPlotData, + getGhostData, + destroy + }; +} diff --git a/src/lib/stores/selection.ts b/src/lib/stores/selection.ts new file mode 100644 index 00000000..312d07df --- /dev/null +++ b/src/lib/stores/selection.ts @@ -0,0 +1,50 @@ +/** + * Selection utilities - shared operations on selected items + * + * Note: Selection STATE is managed by SvelteFlow (source of truth). + * These utilities operate on the current selection. + */ + +import { get } from 'svelte/store'; +import { graphStore } from '$lib/stores/graph'; +import { eventStore } from '$lib/stores/events'; +import { historyStore } from '$lib/stores/history'; + +/** + * Delete all selected nodes, annotations, and events as a single undoable operation. + * + * @returns true if any items were deleted + */ +export function deleteSelectedItems(): boolean { + const selectedNodeIds = get(graphStore.selectedNodeIds); + const selectedEventIds = get(eventStore.selectedEventIds); + + if (selectedNodeIds.size === 0 && selectedEventIds.size === 0) { + return false; + } + + return historyStore.mutate(() => { + // Delete selected nodes and annotations + for (const nodeId of selectedNodeIds) { + // Check if it's an annotation or a regular node + if (graphStore.getAnnotation(nodeId)) { + graphStore.removeAnnotation(nodeId); + } else { + graphStore.removeNode(nodeId); + } + } + + // Delete selected events (location depends on navigation context) + if (graphStore.isAtRoot()) { + for (const eventId of selectedEventIds) { + eventStore.removeEvent(eventId); + } + } else { + for (const eventId of selectedEventIds) { + graphStore.removeSubsystemEvent(eventId); + } + } + + return true; + }); +} diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts new file mode 100644 index 00000000..4bf5119f --- /dev/null +++ b/src/lib/stores/settings.ts @@ -0,0 +1,62 @@ +/** + * Simulation settings store + */ + +import { writable, get } from 'svelte/store'; +import type { SimulationSettings, SolverType } from '$lib/nodes/types'; +import { DEFAULT_SIMULATION_SETTINGS, INITIAL_SIMULATION_SETTINGS } from '$lib/nodes/types'; + +const settings = writable({ ...INITIAL_SIMULATION_SETTINGS }); + +export const settingsStore = { + subscribe: settings.subscribe, + + /** + * Update simulation settings + */ + update(newSettings: Partial): void { + settings.update((s) => ({ ...s, ...newSettings })); + }, + + /** + * Set duration (Python expression) + */ + setDuration(duration: string): void { + settings.update((s) => ({ ...s, duration })); + }, + + /** + * Set time step (Python expression) + */ + setDt(dt: string): void { + settings.update((s) => ({ ...s, dt })); + }, + + /** + * Set solver + */ + setSolver(solver: SolverType): void { + settings.update((s) => ({ ...s, solver })); + }, + + /** + * Reset to initial empty state (defaults shown as placeholders) + */ + reset(): void { + settings.set({ ...INITIAL_SIMULATION_SETTINGS }); + }, + + /** + * Get current settings + */ + get(): SimulationSettings { + return get(settings); + }, + + /** + * Set all settings at once + */ + set(newSettings: SimulationSettings): void { + settings.set(newSettings); + } +}; diff --git a/src/lib/stores/theme.ts b/src/lib/stores/theme.ts new file mode 100644 index 00000000..095a2ca8 --- /dev/null +++ b/src/lib/stores/theme.ts @@ -0,0 +1,68 @@ +/** + * Theme store + * Manages light/dark theme with localStorage persistence + */ + +import { writable } from 'svelte/store'; +import { browser } from '$app/environment'; + +export type Theme = 'light' | 'dark'; + +// Get initial theme from localStorage or system preference +function getInitialTheme(): Theme { + if (!browser) return 'dark'; + + const stored = localStorage.getItem('pathview-theme'); + if (stored === 'light' || stored === 'dark') { + return stored; + } + + // Check system preference + if (window.matchMedia('(prefers-color-scheme: light)').matches) { + return 'light'; + } + + return 'dark'; +} + +// Create the theme store +const theme = writable(getInitialTheme()); + +// Apply theme to document and persist +theme.subscribe((value) => { + if (!browser) return; + + // Set data-theme attribute on document + document.documentElement.setAttribute('data-theme', value); + + // Persist to localStorage + localStorage.setItem('pathview-theme', value); +}); + +// Theme store with actions +export const themeStore = { + subscribe: theme.subscribe, + + /** + * Toggle between light and dark + */ + toggle(): void { + theme.update((current) => (current === 'dark' ? 'light' : 'dark')); + }, + + /** + * Set specific theme + */ + set(newTheme: Theme): void { + theme.set(newTheme); + }, + + /** + * Get current theme value + */ + get(): Theme { + let current: Theme = 'dark'; + theme.subscribe((value) => (current = value))(); + return current; + } +}; diff --git a/src/lib/stores/utils.ts b/src/lib/stores/utils.ts new file mode 100644 index 00000000..77bd5da3 --- /dev/null +++ b/src/lib/stores/utils.ts @@ -0,0 +1,10 @@ +/** + * Store utility functions for common patterns + */ + +/** + * Generate a unique ID using crypto.randomUUID + */ +export function generateId(): string { + return crypto.randomUUID(); +} diff --git a/src/lib/stores/viewActions.ts b/src/lib/stores/viewActions.ts new file mode 100644 index 00000000..425aee17 --- /dev/null +++ b/src/lib/stores/viewActions.ts @@ -0,0 +1,38 @@ +/** + * View actions - re-exports from split modules for backwards compatibility + * + * Triggers (reactive stores): viewTriggers.ts + * Utilities (functions): $lib/utils/viewUtils.ts + */ + +// Re-export all triggers +export { + fitViewTrigger, + triggerFitView, + fitViewPadding, + setFitViewPadding, + type FitViewPadding, + zoomInTrigger, + zoomOutTrigger, + triggerZoomIn, + triggerZoomOut, + panTrigger, + triggerPan, + focusNodeTrigger, + triggerFocusNode, + clearSelectionTrigger, + triggerClearSelection, + nudgeTrigger, + triggerNudge, + selectNodeTrigger, + triggerSelectNodes +} from './viewTriggers'; + +// Re-export all utilities +export { + registerScreenToFlowConverter, + screenToFlow, + registerHasSelection, + hasAnySelection, + getViewportCenter +} from '$lib/utils/viewUtils'; diff --git a/src/lib/stores/viewTriggers.ts b/src/lib/stores/viewTriggers.ts new file mode 100644 index 00000000..11ebf0d7 --- /dev/null +++ b/src/lib/stores/viewTriggers.ts @@ -0,0 +1,84 @@ +/** + * View triggers - reactive stores for triggering view actions from outside SvelteFlow context + */ + +import { writable, get } from 'svelte/store'; + +// Fit view padding in pixels - accounts for open panels +export interface FitViewPadding { + top: number; + right: number; + bottom: number; + left: number; +} + +export const fitViewPadding = writable({ + top: 100, + right: 20, + bottom: 20, + left: 70 +}); + +export function setFitViewPadding(padding: FitViewPadding): void { + fitViewPadding.set(padding); +} + +// Fit view trigger - increment to trigger fit view +export const fitViewTrigger = writable(0); + +export function triggerFitView(): void { + fitViewTrigger.update((n) => n + 1); +} + +// Zoom triggers +export const zoomInTrigger = writable(0); +export const zoomOutTrigger = writable(0); + +export function triggerZoomIn(): void { + zoomInTrigger.update((n) => n + 1); +} + +export function triggerZoomOut(): void { + zoomOutTrigger.update((n) => n + 1); +} + +// Pan trigger - stores delta to pan by +export const panTrigger = writable<{ x: number; y: number; id: number }>({ x: 0, y: 0, id: 0 }); + +export function triggerPan(delta: { x: number; y: number }): void { + panTrigger.update((current) => ({ ...delta, id: current.id + 1 })); +} + +// Focus on specific node trigger +export const focusNodeTrigger = writable<{ nodeId: string; id: number }>({ nodeId: '', id: 0 }); + +export function triggerFocusNode(nodeId: string): void { + focusNodeTrigger.update((current) => ({ nodeId, id: current.id + 1 })); +} + +// Clear selection trigger - allows clearing SvelteFlow selection from outside +export const clearSelectionTrigger = writable(0); + +export function triggerClearSelection(): void { + clearSelectionTrigger.update((n) => n + 1); +} + +// Nudge trigger - nudges all selected nodes by delta +export const nudgeTrigger = writable<{ x: number; y: number; id: number }>({ x: 0, y: 0, id: 0 }); + +export function triggerNudge(delta: { x: number; y: number }): void { + nudgeTrigger.update((current) => ({ ...delta, id: current.id + 1 })); +} + +// Select node trigger - selects specific nodes in SvelteFlow +// nodeIds: array of node IDs to select +// addToSelection: if false, clears existing selection first +export const selectNodeTrigger = writable<{ nodeIds: string[]; addToSelection: boolean; id: number }>({ + nodeIds: [], + addToSelection: false, + id: 0 +}); + +export function triggerSelectNodes(nodeIds: string[], addToSelection = false): void { + selectNodeTrigger.update((current) => ({ nodeIds, addToSelection, id: current.id + 1 })); +} diff --git a/src/lib/types/common.ts b/src/lib/types/common.ts new file mode 100644 index 00000000..d41cc3e9 --- /dev/null +++ b/src/lib/types/common.ts @@ -0,0 +1,26 @@ +/** + * Common type definitions shared across the application + */ + +/** 2D position */ +export interface Position { + x: number; + y: number; +} + +/** Size dimensions */ +export interface Size { + width: number; + height: number; +} + +/** Bounding box */ +export interface Bounds { + x: number; + y: number; + width: number; + height: number; +} + +/** Rotation value (0-3 representing 0, 90, 180, 270 degrees) */ +export type RotationValue = 0 | 1 | 2 | 3; diff --git a/src/lib/types/component.ts b/src/lib/types/component.ts new file mode 100644 index 00000000..1f3d2826 --- /dev/null +++ b/src/lib/types/component.ts @@ -0,0 +1,52 @@ +/** + * Component file types for saving/loading individual blocks, subsystems, and models + */ + +import type { NodeInstance } from './nodes'; +import type { GraphContent } from './schema'; + +/** Component types that can be saved/loaded */ +export type ComponentType = 'block' | 'subsystem' | 'model'; + +/** Unified component file format */ +export interface ComponentFile { + version: string; + type: ComponentType; + metadata: { + name: string; + created: string; + modified: string; + description?: string; + }; + content: BlockContent | SubsystemContent | ModelContent; +} + +/** Single block (no connections) */ +export interface BlockContent { + node: NodeInstance; +} + +/** Subsystem (nested graph) */ +export interface SubsystemContent { + node: NodeInstance; // The subsystem node (includes .graph) +} + +/** Full model - uses shared GraphContent structure */ +export type ModelContent = GraphContent; + +/** File extension mapping */ +export const COMPONENT_EXTENSIONS: Record = { + block: '.blk', + subsystem: '.sub', + model: '.pvm' +}; + +/** MIME types for file dialogs */ +export const COMPONENT_MIME_TYPES = { + block: { 'application/json': ['.blk'] }, + subsystem: { 'application/json': ['.sub'] }, + model: { 'application/json': ['.pvm', '.json'] } // .json for legacy +}; + +/** All accepted component file extensions */ +export const ALL_COMPONENT_EXTENSIONS = ['.blk', '.sub', '.pvm', '.json']; diff --git a/src/lib/types/events.ts b/src/lib/types/events.ts new file mode 100644 index 00000000..144cdfb9 --- /dev/null +++ b/src/lib/types/events.ts @@ -0,0 +1,55 @@ +/** + * Core type definitions for PathView events + */ + +import type { Position } from './common'; + +/** Event type category */ +export type EventCategory = 'Events'; + +/** Parameter type for events (subset of node params + callable) */ +export type EventParamType = 'number' | 'string' | 'callable' | 'array'; + +/** Parameter definition for an event type */ +export interface EventParamDefinition { + name: string; + type: EventParamType; + default: unknown; + description?: string; + required?: boolean; +} + +/** Event type definition (static metadata - like Python class) */ +export interface EventTypeDefinition { + type: string; // Unique type ID: 'pathsim.events.ZeroCrossing' + name: string; // Display name: 'ZeroCrossing' + description: string; + + // Parameter definitions + params: EventParamDefinition[]; + + // PathSim event class name for export + eventClass: string; + + // Pre-converted HTML docstring (for documentation display) + docstringHtml?: string; +} + +/** Event instance (runtime - like Python object) */ +export interface EventInstance { + id: string; // Unique instance ID + type: string; // Reference to EventTypeDefinition.type + name: string; // User-defined name (e.g., 'bounce_event') + + // Position on canvas + position: Position; + + // Parameter values (user-provided Python expressions) + params: Record; + + // Optional color override + color?: string; + + // Index signature for SvelteFlow compatibility + [key: string]: unknown; +} diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts new file mode 100644 index 00000000..e7cb7072 --- /dev/null +++ b/src/lib/types/index.ts @@ -0,0 +1,86 @@ +/** + * Centralized type exports + * + * This module re-exports all types from the types directory. + * Import from '$lib/types' for cleaner imports. + */ + +// Common types +export type { Position, Size, Bounds, RotationValue } from './common'; + +// Node types +export type { + PortDirection, + PortDefinition, + PortInstance, + ParamType, + ParamDefinition, + NodeCategory, + NodeShape, + NodeTypeDefinition, + SubsystemGraph, + NodeInstance, + Connection, + Annotation +} from './nodes'; + +// Event types +export type { + EventCategory, + EventParamType, + EventParamDefinition, + EventTypeDefinition, + EventInstance +} from './events'; + +// Simulation types +export type { + ScopeData, + SpectrumData, + SimulationResult, + ValidationError, + ValidationResult, + SolverType, + SimulationSettings, + WorkerMessage, + WorkerResponse, + PyodideState, + SimulationState +} from './simulation'; + +// UI types +export type { + Theme, + LogEntry, + ContextMenuTarget, + ContextMenuState, + DialogState, + NodeDialogState, + EventDialogState, + SearchableNode +} from './ui'; + +// Registry types +export type { + ExtractedParam, + ExtractedBlock, + UIOverride, + BlockCategory, + ExtractedEventParam, + ExtractedEvent, + ExtractedSimulationParam +} from './registry'; + +// Schema types +export type { FileMetadata, GraphContent, GraphFile } from './schema'; +export { GRAPH_FILE_VERSION } from './schema'; + +// Component types +export type { + ComponentType, + ComponentFile, + BlockContent, + SubsystemContent, + ModelContent +} from './component'; +export { COMPONENT_EXTENSIONS, COMPONENT_MIME_TYPES, ALL_COMPONENT_EXTENSIONS } from './component'; diff --git a/src/lib/types/modules.d.ts b/src/lib/types/modules.d.ts new file mode 100644 index 00000000..8fa85d57 --- /dev/null +++ b/src/lib/types/modules.d.ts @@ -0,0 +1,37 @@ +// Type declarations for external modules + +// Plotly.js minified bundle +declare module 'plotly.js-dist-min' { + import Plotly from 'plotly.js'; + export = Plotly; +} + +// Pyodide CDN module +declare module 'https://cdn.jsdelivr.net/pyodide/v0.26.2/full/pyodide.mjs' { + interface PyProxy { + toJs(options?: { dict_converter?: typeof Object.fromEntries }): unknown; + destroy(): void; + } + + export interface PyodideInterface { + loadPackage(packages: string | string[]): Promise; + runPythonAsync(code: string): Promise; + runPython(code: string): unknown; + globals: { + get(name: string): PyProxy; + }; + FS: { + writeFile(path: string, data: string | Uint8Array): void; + readFile(path: string, options?: { encoding?: string }): string | Uint8Array; + mkdir(path: string): void; + }; + setStdout(options: { batched: (msg: string) => void }): void; + setStderr(options: { batched: (msg: string) => void }): void; + } + + export function loadPyodide(options?: { + indexURL?: string; + stdout?: (text: string) => void; + stderr?: (text: string) => void; + }): Promise; +} diff --git a/src/lib/types/nodes.ts b/src/lib/types/nodes.ts new file mode 100644 index 00000000..5af20129 --- /dev/null +++ b/src/lib/types/nodes.ts @@ -0,0 +1,140 @@ +/** + * Core type definitions for PathView nodes + */ + +import type { Position } from './common'; +import type { EventInstance } from './events'; + +/** Port direction */ +export type PortDirection = 'input' | 'output'; + +/** Port definition for a node type (static metadata) */ +export interface PortDefinition { + name: string; + direction: PortDirection; + color?: string; +} + +/** Port instance on a node (runtime) */ +export interface PortInstance { + id: string; // Unique port ID: `${nodeId}-${direction}-${index}` + nodeId: string; + name: string; + direction: PortDirection; + index: number; + color: string; +} + +/** Parameter type annotation (mirrors Python type hints) */ +export type ParamType = 'number' | 'integer' | 'boolean' | 'string' | 'array' | 'callable' | 'any'; + +/** Parameter definition for a node type */ +export interface ParamDefinition { + name: string; + type: ParamType; + default: unknown; + description?: string; + min?: number; // For numeric types + max?: number; // For numeric types + options?: string[]; // For enum-like strings +} + +/** Node type category */ +export type NodeCategory = + | 'Sources' + | 'Dynamic' + | 'Algebraic' + | 'Mixed' + | 'Recording' + | 'Subsystem'; + +/** Node shape override (defaults based on category if not specified) */ +export type NodeShape = 'pill' | 'rect' | 'circle' | 'diamond'; + +/** Node type definition (static metadata - like Python class) */ +export interface NodeTypeDefinition { + type: string; // Block class name: 'Constant', 'Integrator', etc. + name: string; // Display name: 'Constant' + category: NodeCategory; + description: string; + color?: string; // Optional custom color (defaults to pathsim-blue) + + // Port configuration + ports: { + inputs: PortDefinition[]; + outputs: PortDefinition[]; + minInputs: number; // minimum number of input ports (default 1) + minOutputs: number; // minimum number of output ports (default 1) + maxInputs: number | null; // null = unlimited + maxOutputs: number | null; + }; + + // Parameter definitions + params: ParamDefinition[]; + + // PathSim block class name for export + blockClass: string; + + // Full RST docstring from PathSim (for documentation display) + docstring?: string; + + // Shape override (defaults based on category if not specified) + shape?: NodeShape; +} + +/** Subsystem's internal graph (nested structure) */ +export interface SubsystemGraph { + nodes: NodeInstance[]; + connections: Connection[]; + annotations?: Annotation[]; + events?: EventInstance[]; +} + +/** Node instance (runtime - like Python object) */ +export interface NodeInstance { + id: string; // Unique instance ID + type: string; // References NodeTypeDefinition.type + name: string; // User-editable display name + position: Position; + + // Dynamic port instances + inputs: PortInstance[]; + outputs: PortInstance[]; + + // Parameter values (user-edited) + params: Record; + + // Parameter names to show as inline inputs on node + pinnedParams?: string[]; + + // Custom node color (optional - defaults to pathsim-blue) + color?: string; + + // Nested graph for Subsystem nodes + graph?: SubsystemGraph; + + // Index signature for SvelteFlow compatibility + [key: string]: unknown; +} + +/** Connection between ports */ +export interface Connection { + id: string; + sourceNodeId: string; + sourcePortIndex: number; + targetNodeId: string; + targetPortIndex: number; +} + +/** Canvas annotation (markdown/LaTeX text) */ +export interface Annotation { + id: string; + position: Position; + content: string; // Markdown with LaTeX ($...$, $$...$$) + width: number; + height: number; + color?: string; // Optional custom color (defaults to --pathsim-blue) + + // Index signature for SvelteFlow compatibility + [key: string]: unknown; +} diff --git a/src/lib/types/registry.ts b/src/lib/types/registry.ts new file mode 100644 index 00000000..2ff5851c --- /dev/null +++ b/src/lib/types/registry.ts @@ -0,0 +1,68 @@ +/** + * Registry-related type definitions + * Types for block and event registries + */ + +import type { NodeCategory } from './nodes'; + +/** Parameter extracted from PathSim block */ +export interface ExtractedParam { + type: string; + default: string | null; + description: string; + min?: number; + max?: number; + options?: string[]; +} + +/** Block definition extracted from PathSim */ +export interface ExtractedBlock { + blockClass: string; + description: string; + docstringHtml: string; + params: Record; + inputs: string[]; + outputs: string[]; +} + +/** UI overrides for block display */ +export interface UIOverride { + maxInputs?: number | null; + maxOutputs?: number | null; + defaultInputs?: string[]; + defaultOutputs?: string[]; + shape?: string; +} + +/** Block category with ordering info */ +export interface BlockCategory { + name: NodeCategory; + order: number; + blocks: string[]; +} + +/** Event parameter extracted from PathSim */ +export interface ExtractedEventParam { + type: string; + default: string | null; + description: string; + required?: boolean; +} + +/** Event definition extracted from PathSim */ +export interface ExtractedEvent { + eventClass: string; + description: string; + docstringHtml: string; + params: Record; +} + +/** Simulation parameter extracted from PathSim */ +export interface ExtractedSimulationParam { + type: string; + default: string; + description: string; + min?: number; + max?: number; + options?: string[]; +} diff --git a/src/lib/types/schema.ts b/src/lib/types/schema.ts new file mode 100644 index 00000000..6a2fac32 --- /dev/null +++ b/src/lib/types/schema.ts @@ -0,0 +1,38 @@ +/** + * Schema type definitions for file I/O + */ + +import type { NodeInstance, Connection, Annotation } from './nodes'; +import type { EventInstance } from './events'; +import type { SimulationSettings } from './simulation'; + +/** File metadata */ +export interface FileMetadata { + created: string; + modified: string; + name: string; + description?: string; +} + +/** Shared graph content structure (used by GraphFile and ModelContent) */ +export interface GraphContent { + graph: { + nodes: NodeInstance[]; + connections: Connection[]; + annotations?: Annotation[]; + }; + events?: EventInstance[]; + codeContext: { + code: string; + }; + simulationSettings: SimulationSettings; +} + +/** Graph file format */ +export interface GraphFile extends GraphContent { + version: string; + metadata: FileMetadata; +} + +/** Current graph file version */ +export const GRAPH_FILE_VERSION = '1.0.0'; diff --git a/src/lib/types/simulation.ts b/src/lib/types/simulation.ts new file mode 100644 index 00000000..8ba121d7 --- /dev/null +++ b/src/lib/types/simulation.ts @@ -0,0 +1,94 @@ +/** + * Simulation-related type definitions + */ + +/** Scope data from simulation results */ +export interface ScopeData { + time: number[]; + signals: number[][]; + labels?: string[]; +} + +/** Spectrum data from simulation results */ +export interface SpectrumData { + frequency: number[]; + magnitude: number[][]; + labels?: string[]; +} + +/** Complete simulation result */ +export interface SimulationResult { + scopeData: Record; + spectrumData: Record; + nodeNames: Record; +} + +/** Validation error for a specific parameter */ +export interface ValidationError { + nodeId: string; + param: string; + error: string; +} + +/** Result of code validation */ +export interface ValidationResult { + valid: boolean; + errors: ValidationError[]; +} + +/** Solver types available in PathSim */ +export type SolverType = + | 'SSPRK22' + | 'RK4' + | 'RKBS32' + | 'RKCK54' + | 'BDF2' + | 'GEAR52A' + | 'ESDIRK43'; + +/** Simulation settings configuration */ +export interface SimulationSettings { + duration: string; // Simulation duration (Python expression) + dt: string; // Initial/fixed time step (Python expression) + solver: SolverType; // Numerical integration method + adaptive: boolean; // Enable adaptive timestepping + atol: string; // Absolute LTE tolerance (Python expression) + rtol: string; // Relative LTE tolerance (Python expression) + ftol: string; // Fixed-point iteration tolerance (Python expression) + dt_min: string; // Minimum timestep for adaptive solvers (Python expression) + dt_max: string; // Maximum timestep for adaptive solvers (Python expression) + ghostTraces: number; // Number of previous runs to show as ghost traces (0-6) + plotResults: boolean; // UI: auto-open plot panel +} + +/** Worker message types */ +export interface WorkerMessage { + type: 'init' | 'run' | 'continue' | 'clear' | 'status' | 'validate'; + id?: string; + payload?: unknown; +} + +/** Worker response types */ +export interface WorkerResponse { + type: 'ready' | 'result' | 'error' | 'progress' | 'log' | 'stdout' | 'stderr'; + id?: string; + payload?: unknown; + error?: string; +} + +/** Pyodide runtime state */ +export interface PyodideState { + initialized: boolean; + loading: boolean; + error: string | null; + progress: string; +} + +/** Simulation runtime state */ +export interface SimulationState { + running: boolean; + progress: string; + error: string | null; + result: SimulationResult | null; + resultHistory: SimulationResult[]; +} diff --git a/src/lib/types/ui.ts b/src/lib/types/ui.ts new file mode 100644 index 00000000..977120df --- /dev/null +++ b/src/lib/types/ui.ts @@ -0,0 +1,58 @@ +/** + * UI-related type definitions + */ + +import type { Position } from './common'; + +/** Theme options */ +export type Theme = 'light' | 'dark'; + +/** Log entry for console output */ +export interface LogEntry { + id: number; + timestamp: Date; + level: 'info' | 'warning' | 'error' | 'output'; + message: string; +} + +/** Context menu target types */ +export type ContextMenuTarget = + | { type: 'node'; nodeId: string } + | { type: 'event'; eventId: string } + | { type: 'edge'; edgeId: string } + | { type: 'canvas' } + | { type: 'selection'; nodeIds: string[] } + | { type: 'annotation'; annotationId: string }; + +/** Context menu state */ +export interface ContextMenuState { + open: boolean; + position: Position; + target: ContextMenuTarget | null; +} + +/** Dialog state base interface */ +export interface DialogState { + open: boolean; + data: T | null; +} + +/** Node dialog state */ +export interface NodeDialogState { + open: boolean; + nodeId: string | null; +} + +/** Event dialog state */ +export interface EventDialogState { + open: boolean; + eventId: string | null; +} + +/** Search result item */ +export interface SearchableNode { + id: string; + name: string; + type: string; + path: string[]; +} diff --git a/src/lib/utils/codePreviewHeader.ts b/src/lib/utils/codePreviewHeader.ts new file mode 100644 index 00000000..d5fcf782 --- /dev/null +++ b/src/lib/utils/codePreviewHeader.ts @@ -0,0 +1,184 @@ +/** + * Utilities for generating self-contained code preview headers + * Used by block/event property dialogs to show copyable code snippets + */ + +import { nodeRegistry, type NodeInstance } from '$lib/nodes'; +import { eventRegistry, type EventInstance } from '$lib/events'; +import { NODE_TYPES } from '$lib/constants/nodeTypes'; + +/** + * Extract Python identifiers from a string (parameter values) + */ +function extractIdentifiers(code: string): Set { + const identifierPattern = /(?(); + let match; + while ((match = identifierPattern.exec(code)) !== null) { + identifiers.add(match[1]); + } + return identifiers; +} + +// Python keywords and builtins to ignore when extracting references +const PYTHON_BUILTINS = new Set([ + 'True', 'False', 'None', 'and', 'or', 'not', 'in', 'is', 'if', 'else', 'elif', + 'for', 'while', 'def', 'class', 'return', 'yield', 'import', 'from', 'as', + 'try', 'except', 'finally', 'with', 'lambda', 'pass', 'break', 'continue', + 'raise', 'global', 'nonlocal', 'assert', 'del', 'print', 'len', 'range', + 'list', 'dict', 'set', 'tuple', 'str', 'int', 'float', 'bool', 'type', + 'np', 'numpy', 'math', 'sin', 'cos', 'tan', 'exp', 'log', 'sqrt', 'pi', + 'abs', 'min', 'max', 'sum', 'any', 'all', 'map', 'filter', 'zip', 'enumerate' +]); + +/** + * Extract relevant code context - only definitions referenced by the given identifiers + */ +function extractRelevantCodeContext(codeContext: string, referencedIds: Set): string { + if (!codeContext.trim() || referencedIds.size === 0) return ''; + + const lines = codeContext.split('\n'); + const relevantLines: string[] = []; + let inRelevantBlock = false; + let blockIndent = 0; + + // Filter to only meaningful references + const meaningfulRefs = new Set([...referencedIds].filter(id => !PYTHON_BUILTINS.has(id))); + if (meaningfulRefs.size === 0) return ''; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // Check for function definition + const funcMatch = trimmed.match(/^def\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/); + if (funcMatch && meaningfulRefs.has(funcMatch[1])) { + inRelevantBlock = true; + blockIndent = line.search(/\S/); + relevantLines.push(line); + continue; + } + + // Check for variable assignment at top level + const varMatch = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*=/); + if (varMatch && meaningfulRefs.has(varMatch[1]) && !inRelevantBlock) { + relevantLines.push(line); + continue; + } + + // Continue collecting lines if inside a relevant function block + if (inRelevantBlock) { + const currentIndent = line.search(/\S/); + if (trimmed === '' || currentIndent > blockIndent) { + relevantLines.push(line); + } else { + inRelevantBlock = false; + } + } + } + + return relevantLines.join('\n').trim(); +} + +/** + * Collect all parameter values from a node and its nested subsystems (recursively) + */ +function collectAllParamValues(node: NodeInstance): string[] { + const values: string[] = []; + + for (const value of Object.values(node.params)) { + if (value != null && value !== '') { + values.push(String(value)); + } + } + + if (node.graph) { + for (const child of node.graph.nodes) { + values.push(...collectAllParamValues(child)); + } + } + + return values; +} + +/** + * Collect all block classes used within a subsystem (recursively) + */ +function collectSubsystemBlockClasses(node: NodeInstance, classes: Set): void { + if (!node.graph) return; + + for (const child of node.graph.nodes) { + const childTypeDef = nodeRegistry.get(child.type); + if (childTypeDef && childTypeDef.blockClass !== 'Subsystem' && childTypeDef.blockClass !== 'Interface') { + classes.add(childTypeDef.blockClass); + } + if (child.type === NODE_TYPES.SUBSYSTEM) { + collectSubsystemBlockClasses(child, classes); + } + } +} + +/** + * Generate imports and referenced code context header for a block + */ +export function generateBlockCodeHeader(node: NodeInstance, codeContext: string): string { + const typeDef = nodeRegistry.get(node.type); + if (!typeDef) return ''; + + const lines: string[] = []; + + // Imports + lines.push('import numpy as np'); + if (node.type === NODE_TYPES.SUBSYSTEM) { + lines.push('from pathsim import Subsystem, Interface, Connection'); + const blockClasses = new Set(); + collectSubsystemBlockClasses(node, blockClasses); + if (blockClasses.size > 0) { + lines.push(`from pathsim.blocks import ${[...blockClasses].sort().join(', ')}`); + } + } else { + lines.push(`from pathsim.blocks import ${typeDef.blockClass}`); + } + + // Extract referenced code context + const paramValues = node.type === NODE_TYPES.SUBSYSTEM + ? collectAllParamValues(node).join(' ') + : Object.values(node.params).filter(v => v != null && v !== '').join(' '); + const referencedIds = extractIdentifiers(paramValues); + const relevantContext = extractRelevantCodeContext(codeContext, referencedIds); + + if (relevantContext) { + lines.push(''); + lines.push(relevantContext); + } + + lines.push(''); + return lines.join('\n'); +} + +/** + * Generate imports and referenced code context header for an event + */ +export function generateEventCodeHeader(event: EventInstance, codeContext: string): string { + const typeDef = eventRegistry.get(event.type); + if (!typeDef) return ''; + + const lines: string[] = []; + + // Imports + lines.push('import numpy as np'); + lines.push(`from pathsim.events import ${typeDef.eventClass}`); + + // Extract referenced code context + const paramValues = Object.values(event.params).filter(v => v != null && v !== '').join(' '); + const referencedIds = extractIdentifiers(paramValues); + const relevantContext = extractRelevantCodeContext(codeContext, referencedIds); + + if (relevantContext) { + lines.push(''); + lines.push(relevantContext); + } + + lines.push(''); + return lines.join('\n'); +} diff --git a/src/lib/utils/codemirror.ts b/src/lib/utils/codemirror.ts new file mode 100644 index 00000000..47f40608 --- /dev/null +++ b/src/lib/utils/codemirror.ts @@ -0,0 +1,165 @@ +/** + * Shared CodeMirror utilities for consistent editor setup across the app. + */ + +// Syntax highlighting colors - aligned with node color palette +export const SYNTAX_COLORS = { + keyword: '#E57373', // Red - control flow, imports + operator: '#0070C0', // PathSim blue - symbols, operators + special: '#FFB74D', // Orange - classes, types, decorators + number: '#4DB6AC', // Teal - numeric literals + string: '#81C784', // Green - string literals + function: '#0070C0', // PathSim blue - function names + comment: { dark: '#505060', light: '#909098' }, // Same as line numbers + invalid: '#BA68C8', // Purple - errors + // Theme-specific values for variables and punctuation + variable: { dark: '#e0e0e0', light: '#383a42' }, + punctuation: { dark: '#abb2bf', light: '#505050' } +} as const; + +// Cached CodeMirror modules +let cachedModules: CodeMirrorModules | null = null; + +export interface CodeMirrorModules { + EditorView: typeof import('@codemirror/view').EditorView; + EditorState: typeof import('@codemirror/state').EditorState; + keymap: typeof import('@codemirror/view').keymap; + basicSetup: typeof import('codemirror').basicSetup; + python: typeof import('@codemirror/lang-python').python; + oneDark: typeof import('@codemirror/theme-one-dark').oneDark; + HighlightStyle: typeof import('@codemirror/language').HighlightStyle; + syntaxHighlighting: typeof import('@codemirror/language').syntaxHighlighting; + indentUnit: typeof import('@codemirror/language').indentUnit; + tags: typeof import('@lezer/highlight').tags; +} + +/** + * Dynamically load CodeMirror modules (cached after first load) + */ +export async function loadCodeMirrorModules(): Promise { + if (cachedModules) return cachedModules; + + const [viewModule, stateModule, cmModule, pythonModule, themeModule, langModule, highlightModule] = await Promise.all([ + import('@codemirror/view'), + import('@codemirror/state'), + import('codemirror'), + import('@codemirror/lang-python'), + import('@codemirror/theme-one-dark'), + import('@codemirror/language'), + import('@lezer/highlight') + ]); + + cachedModules = { + EditorView: viewModule.EditorView, + EditorState: stateModule.EditorState, + keymap: viewModule.keymap, + basicSetup: cmModule.basicSetup, + python: pythonModule.python, + oneDark: themeModule.oneDark, + HighlightStyle: langModule.HighlightStyle, + syntaxHighlighting: langModule.syntaxHighlighting, + indentUnit: langModule.indentUnit, + tags: highlightModule.tags + }; + + return cachedModules; +} + +/** + * Create syntax highlighting style for the given theme + */ +export function createHighlightStyle(modules: CodeMirrorModules, isDark: boolean) { + const { HighlightStyle, tags } = modules; + const varColor = isDark ? SYNTAX_COLORS.variable.dark : SYNTAX_COLORS.variable.light; + const punctColor = isDark ? SYNTAX_COLORS.punctuation.dark : SYNTAX_COLORS.punctuation.light; + const commentColor = isDark ? SYNTAX_COLORS.comment.dark : SYNTAX_COLORS.comment.light; + + return HighlightStyle.define([ + { tag: tags.keyword, color: SYNTAX_COLORS.keyword }, + { tag: tags.operator, color: SYNTAX_COLORS.operator }, + { tag: tags.special(tags.variableName), color: SYNTAX_COLORS.special }, + { tag: tags.typeName, color: SYNTAX_COLORS.special }, + { tag: tags.atom, color: SYNTAX_COLORS.number }, + { tag: tags.number, color: SYNTAX_COLORS.number }, + { tag: tags.bool, color: SYNTAX_COLORS.number }, + { tag: tags.string, color: SYNTAX_COLORS.string }, + { tag: tags.character, color: SYNTAX_COLORS.string }, + { tag: tags.regexp, color: SYNTAX_COLORS.string }, + { tag: tags.escape, color: SYNTAX_COLORS.operator }, + { tag: tags.variableName, color: varColor }, + { tag: tags.definition(tags.variableName), color: varColor }, + { tag: tags.propertyName, color: varColor }, + { tag: tags.function(tags.variableName), color: SYNTAX_COLORS.function }, + { tag: tags.function(tags.propertyName), color: SYNTAX_COLORS.function }, + { tag: tags.definition(tags.function(tags.variableName)), color: SYNTAX_COLORS.function }, + { tag: tags.labelName, color: varColor }, + { tag: tags.comment, color: commentColor, fontStyle: 'italic' }, + { tag: tags.blockComment, color: commentColor, fontStyle: 'italic' }, + { tag: tags.docComment, color: commentColor, fontStyle: 'italic' }, + { tag: tags.invalid, color: '#ffffff', backgroundColor: SYNTAX_COLORS.invalid }, + { tag: tags.punctuation, color: punctColor }, + { tag: tags.bracket, color: punctColor }, + { tag: tags.className, color: SYNTAX_COLORS.special }, + { tag: tags.attributeName, color: varColor }, + { tag: tags.attributeValue, color: SYNTAX_COLORS.string }, + { tag: tags.self, color: SYNTAX_COLORS.special } + ]); +} + +export interface EditorOptions { + /** Whether the editor is read-only */ + readOnly?: boolean; + /** Custom keybindings */ + keymaps?: { key: string; run: (view: import('@codemirror/view').EditorView) => boolean }[]; + /** Callback when document changes */ + onDocChange?: (doc: string) => void; +} + +/** + * Create CodeMirror extensions for a Python editor + */ +export function createEditorExtensions( + modules: CodeMirrorModules, + isDark: boolean, + options: EditorOptions = {} +): import('@codemirror/state').Extension[] { + const { EditorView, EditorState, keymap, basicSetup, python, oneDark, syntaxHighlighting, indentUnit } = modules; + + const extensions: import('@codemirror/state').Extension[] = []; + + // Custom keymaps must come BEFORE basicSetup to take precedence + if (options.keymaps && options.keymaps.length > 0) { + extensions.push(keymap.of(options.keymaps)); + } + + extensions.push( + basicSetup, + python(), + indentUnit.of(' '), // 4-space indentation for Python + syntaxHighlighting(createHighlightStyle(modules, isDark)) + ); + + // Read-only mode + if (options.readOnly) { + extensions.push(EditorState.readOnly.of(true)); + } + + // Document change listener + if (options.onDocChange) { + const callback = options.onDocChange; + extensions.push( + EditorView.updateListener.of((update) => { + if (update.docChanged) { + callback(update.state.doc.toString()); + } + }) + ); + } + + // Dark theme chrome (gutters, background, etc.) + if (isDark) { + extensions.push(oneDark); + } + + return extensions; +} diff --git a/src/lib/utils/colors.ts b/src/lib/utils/colors.ts new file mode 100644 index 00000000..25ed5fe2 --- /dev/null +++ b/src/lib/utils/colors.ts @@ -0,0 +1,30 @@ +/** + * Color definitions for PathView + */ + +// Default node/event color (matches --pathsim-blue CSS variable) +export const DEFAULT_NODE_COLOR = '#0070C0'; + +// Port colors +export const PORT_COLORS = { + default: '#969696', // Gray: rgb(150, 150, 150) + signal: '#64c8ff', // Blue: rgb(100, 200, 255) + control: '#ffc864', // Orange: rgb(255, 200, 100) + data: '#c864ff' // Purple: rgb(200, 100, 255) +}; + +// Color palette for dialogs (block/event properties) +export const DIALOG_COLOR_PALETTE = [ + DEFAULT_NODE_COLOR, // PathSim blue (default) + '#E57373', // Red + '#FFB74D', // Orange + '#FFF176', // Yellow + '#81C784', // Green + '#4DB6AC', // Teal + '#4DD0E1', // Cyan + '#64B5F6', // Blue + '#BA68C8', // Purple + '#F06292', // Pink + '#90A4AE', // Grey + '#FFFFFF' // White +]; diff --git a/src/lib/utils/csvExport.ts b/src/lib/utils/csvExport.ts new file mode 100644 index 00000000..35a5b275 --- /dev/null +++ b/src/lib/utils/csvExport.ts @@ -0,0 +1,131 @@ +/** + * CSV Export utility for recording nodes (Scope, Spectrum) + */ + +import { get } from 'svelte/store'; +import { simulationState } from '$lib/pyodide/bridge'; +import { downloadCsv } from './download'; + +/** Characters that are invalid in filenames */ +const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/g; + +/** Sanitize filename by removing invalid characters */ +function sanitizeFilename(name: string): string { + return name.replace(INVALID_FILENAME_CHARS, '').trim() || 'export'; +} + +/** Generate CSV content from scope data */ +function generateScopeCsv( + time: number[], + signals: number[][], + labels?: string[] +): string { + // Build header row + const headers = ['time']; + for (let i = 0; i < signals.length; i++) { + headers.push(labels?.[i] ?? `port_${i}`); + } + + // Build data rows + const rows = [headers.join(',')]; + for (let t = 0; t < time.length; t++) { + const row = [time[t].toString()]; + for (let s = 0; s < signals.length; s++) { + row.push((signals[s][t] ?? '').toString()); + } + rows.push(row.join(',')); + } + + return rows.join('\n'); +} + +/** Generate CSV content from spectrum data */ +function generateSpectrumCsv( + frequency: number[], + magnitude: number[][], + labels?: string[] +): string { + // Build header row + const headers = ['frequency']; + for (let i = 0; i < magnitude.length; i++) { + headers.push(labels?.[i] ?? `port_${i}`); + } + + // Build data rows + const rows = [headers.join(',')]; + for (let f = 0; f < frequency.length; f++) { + const row = [frequency[f].toString()]; + for (let m = 0; m < magnitude.length; m++) { + row.push((magnitude[m][f] ?? '').toString()); + } + rows.push(row.join(',')); + } + + return rows.join('\n'); +} + +/** + * Check if a node has exportable data + */ +export function hasExportableData(nodeId: string, dataSource: 'scope' | 'spectrum'): boolean { + const state = get(simulationState); + if (!state.result) return false; + + if (dataSource === 'scope') { + const data = state.result.scopeData[nodeId]; + return !!(data && data.time.length > 0); + } else { + const data = state.result.spectrumData[nodeId]; + return !!(data && data.frequency.length > 0); + } +} + +/** + * Export scope data to CSV + */ +export function exportScopeData(nodeId: string, nodeName: string): boolean { + const state = get(simulationState); + const data = state.result?.scopeData[nodeId]; + + if (!data || data.time.length === 0) { + return false; + } + + const csv = generateScopeCsv(data.time, data.signals, data.labels); + const filename = `${sanitizeFilename(nodeName || 'Scope')}.csv`; + downloadCsv(csv, filename); + return true; +} + +/** + * Export spectrum data to CSV + */ +export function exportSpectrumData(nodeId: string, nodeName: string): boolean { + const state = get(simulationState); + const data = state.result?.spectrumData[nodeId]; + + if (!data || data.frequency.length === 0) { + return false; + } + + const csv = generateSpectrumCsv(data.frequency, data.magnitude, data.labels); + const filename = `${sanitizeFilename(nodeName || 'Spectrum')}.csv`; + downloadCsv(csv, filename); + return true; +} + +/** + * Export recording node data to CSV based on type + */ +export function exportRecordingData( + nodeId: string, + nodeName: string, + nodeType: string +): boolean { + if (nodeType === 'Scope') { + return exportScopeData(nodeId, nodeName); + } else if (nodeType === 'Spectrum') { + return exportSpectrumData(nodeId, nodeName); + } + return false; +} diff --git a/src/lib/utils/download.ts b/src/lib/utils/download.ts new file mode 100644 index 00000000..64ebc23c --- /dev/null +++ b/src/lib/utils/download.ts @@ -0,0 +1,52 @@ +/** + * Browser file download utility + * + * Triggers a browser download for the given content. + */ + +/** Common MIME types for exports */ +export const MIME_TYPES = { + JSON: 'application/json', + CSV: 'text/csv;charset=utf-8;', + SVG: 'image/svg+xml', + TEXT: 'text/plain', + PYTHON: 'text/x-python' +} as const; + +/** + * Trigger a browser download for the given content + */ +export function downloadFile(content: string, filename: string, mimeType: string): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + URL.revokeObjectURL(url); +} + +/** Download JSON content */ +export function downloadJson(content: object | string, filename: string): void { + const json = typeof content === 'string' ? content : JSON.stringify(content, null, '\t'); + downloadFile(json, filename, MIME_TYPES.JSON); +} + +/** Download CSV content */ +export function downloadCsv(content: string, filename: string): void { + downloadFile(content, filename, MIME_TYPES.CSV); +} + +/** Download SVG content */ +export function downloadSvg(content: string, filename: string): void { + downloadFile(content, filename, MIME_TYPES.SVG); +} + +/** Download Python code */ +export function downloadPython(content: string, filename: string): void { + downloadFile(content, filename, MIME_TYPES.PYTHON); +} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts new file mode 100644 index 00000000..c3659865 --- /dev/null +++ b/src/lib/utils/index.ts @@ -0,0 +1,22 @@ +/** + * Utility exports - convenience re-exports from utils modules + */ + +// Colors +export { PORT_COLORS, DIALOG_COLOR_PALETTE } from './colors'; + +// KaTeX +export { loadKatex, getKatexCssUrl } from './katexLoader'; + +// Renderers +export { renderDocstring } from './rstRenderer'; +export { renderMarkdown } from './markdownRenderer'; + +// View utilities +export { + registerScreenToFlowConverter, + screenToFlow, + registerHasSelection, + hasAnySelection, + getViewportCenter +} from './viewUtils'; diff --git a/src/lib/utils/katexLoader.ts b/src/lib/utils/katexLoader.ts new file mode 100644 index 00000000..4ffa95cd --- /dev/null +++ b/src/lib/utils/katexLoader.ts @@ -0,0 +1,15 @@ +/** + * Shared KaTeX loader - lazy loads KaTeX library on first use + */ + +let katex: typeof import('katex') | null = null; + +export async function loadKatex(): Promise { + if (katex) return katex; + katex = await import('katex'); + return katex; +} + +export function getKatexCssUrl(): string { + return 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css'; +} diff --git a/src/lib/utils/markdownRenderer.ts b/src/lib/utils/markdownRenderer.ts new file mode 100644 index 00000000..721d82e2 --- /dev/null +++ b/src/lib/utils/markdownRenderer.ts @@ -0,0 +1,136 @@ +/** + * Markdown renderer with KaTeX math support + * Used for canvas annotations + */ + +import { loadKatex } from './katexLoader'; + +/** + * Render markdown with LaTeX math to HTML + * Supports: headers, bold, italic, code, links, and $...$ / $$...$$ math + */ +export async function renderMarkdown(content: string): Promise { + if (!content?.trim()) return ''; + + const k = await loadKatex(); + const mathBlocks: string[] = []; + + // Step 1: Extract and render math (preserve placeholders) + // Display math: $$...$$ + let processed = content.replace(/\$\$([\s\S]*?)\$\$/g, (_, latex) => { + const placeholder = `%%MATH_BLOCK_${mathBlocks.length}%%`; + try { + mathBlocks.push(k.default.renderToString(latex.trim(), { + displayMode: true, + throwOnError: false, + strict: false + })); + } catch { + mathBlocks.push(`${escapeHtml(latex)}`); + } + return placeholder; + }); + + // Inline math: $...$ (but not $$ which was already handled) + processed = processed.replace(/\$([^\$\n]+?)\$/g, (_, latex) => { + const placeholder = `%%MATH_BLOCK_${mathBlocks.length}%%`; + try { + mathBlocks.push(k.default.renderToString(latex.trim(), { + displayMode: false, + throwOnError: false, + strict: false + })); + } catch { + mathBlocks.push(`${escapeHtml(latex)}`); + } + return placeholder; + }); + + // Step 2: Render markdown + processed = renderBasicMarkdown(processed); + + // Step 3: Restore math blocks + mathBlocks.forEach((html, i) => { + processed = processed.replace(`%%MATH_BLOCK_${i}%%`, html); + }); + + return processed; +} + +/** + * Simple markdown to HTML conversion + */ +function renderBasicMarkdown(text: string): string { + // Escape HTML first (except for our placeholders) + let result = text + .replace(/&/g, '&') + .replace(//g, '>'); + + // Restore placeholders + result = result.replace(/%%MATH_BLOCK_(\d+)%%/g, '%%MATH_BLOCK_$1%%'); + + // Code blocks (``` ... ```) - must be before other processing + result = result.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, _lang, code) => { + return `
${code.trim()}
`; + }); + + // Headers (must be at start of line) + result = result.replace(/^### (.*)$/gm, '

$1

'); + result = result.replace(/^## (.*)$/gm, '

$1

'); + result = result.replace(/^# (.*)$/gm, '

$1

'); + + // Horizontal rules (---, ***, ___) + result = result.replace(/^([-*_]){3,}\s*$/gm, '
'); + + // Blockquotes (> text) + result = result.replace(/^> (.*)$/gm, '
$1
'); + // Merge consecutive blockquotes + result = result.replace(/<\/blockquote>\n
/g, '\n'); + + // Unordered lists (- item or * item) + result = result.replace(/^[-*] (.*)$/gm, '
  • $1
  • '); + // Wrap consecutive
  • in
      + result = result.replace(/((?:
    • .*<\/li>\n?)+)/g, '
        $1
      '); + + // Ordered lists (1. item) + result = result.replace(/^\d+\. (.*)$/gm, '$1'); + // Wrap consecutive in
        and convert back to
      1. + result = result.replace(/((?:.*<\/oli>\n?)+)/g, (match) => { + return '
          ' + match.replace(/<\/?oli>/g, (tag) => tag.replace('oli', 'li')) + '
        '; + }); + + // Bold and italic (order matters - bold first) + result = result.replace(/\*\*\*(.+?)\*\*\*/g, '$1'); + result = result.replace(/\*\*(.+?)\*\*/g, '$1'); + // Only match *text* if not part of a list marker we already processed + result = result.replace(/(?])/g, '$1'); + + // Inline code + result = result.replace(/`([^`]+)`/g, '$1'); + + // Links + result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Line breaks: double newline = paragraph break, single =
        + result = result + .split(/\n\n+/) + .map(para => para.trim()) + .filter(para => para.length > 0) + .map(para => { + // Don't wrap block elements in paragraphs + if (/^<(h[1-6]|ul|ol|blockquote|pre|hr)/.test(para)) return para; + return `

        ${para.replace(/\n/g, '
        ')}

        `; + }) + .join(''); + + return result; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/src/lib/utils/rstRenderer.ts b/src/lib/utils/rstRenderer.ts new file mode 100644 index 00000000..d4c8dbdc --- /dev/null +++ b/src/lib/utils/rstRenderer.ts @@ -0,0 +1,69 @@ +/** + * Docstring renderer - processes HTML from docutils and renders math with KaTeX + * + * The RST is converted to HTML by Python's docutils in the Pyodide worker. + * This module just applies KaTeX to any math elements and provides styling. + */ + +import { loadKatex } from './katexLoader'; + +/** + * Process HTML from docutils and render math with KaTeX + */ +export async function renderDocstring(html: string): Promise { + if (!html?.trim()) { + return '

        No documentation available.

        '; + } + + // Load KaTeX + const k = await loadKatex(); + + // Create a temporary div to parse the HTML + const temp = document.createElement('div'); + temp.innerHTML = html; + + // Find all math elements - docutils outputs math in or
        + const mathElements = temp.querySelectorAll('.math'); + + for (const el of mathElements) { + const latex = el.textContent || ''; + if (!latex.trim()) continue; + + try { + // Clean up the LaTeX + let cleaned = latex + .replace(/^\\\(|\\\)$/g, '') // Remove \( \) delimiters + .replace(/^\\\[|\\\]$/g, '') // Remove \[ \] delimiters + .trim(); + + // Convert unsupported environments + cleaned = cleaned + .replace(/\\begin\{eqnarray\*?\}/g, '\\begin{aligned}') + .replace(/\\end\{eqnarray\*?\}/g, '\\end{aligned}'); + + // Wrap multi-line equations in aligned environment if not already wrapped + if (cleaned.includes('\\\\') && !cleaned.includes('\\begin{')) { + cleaned = `\\begin{aligned}${cleaned}\\end{aligned}`; + } + + const isDisplay = el.tagName === 'DIV' || latex.includes('\\['); + + const rendered = k.default.renderToString(cleaned, { + displayMode: isDisplay, + throwOnError: false, + strict: false + }); + + el.innerHTML = rendered; + el.classList.add('katex-rendered'); + } catch (e) { + console.warn('KaTeX error for:', latex, e); + // Leave original content + } + } + + return temp.innerHTML; +} + +// Re-export for convenience +export { getKatexCssUrl } from './katexLoader'; diff --git a/src/lib/utils/svgExport.ts b/src/lib/utils/svgExport.ts new file mode 100644 index 00000000..7f18fca2 --- /dev/null +++ b/src/lib/utils/svgExport.ts @@ -0,0 +1,322 @@ +/** + * SVG Export Module + * + * Exports the current graph view as a clean SVG file. + * Uses a hybrid approach: + * - Extracts actual edge paths from SvelteFlow's rendered DOM + * - Renders simplified node/event shapes with text labels + * - Extracts handle positions from DOM for accurate placement + */ + +import { get } from 'svelte/store'; +import { graphStore } from '$lib/stores/graph'; +import { eventStore } from '$lib/stores/events'; +import { nodeRegistry } from '$lib/nodes'; +import { eventRegistry } from '$lib/events/registry'; +import type { NodeInstance } from '$lib/types/nodes'; +import type { EventInstance } from '$lib/types/events'; +import { downloadSvg } from './download'; + +// ============================================================================ +// CONFIGURATION +// ============================================================================ + +/** Layout constants */ +const PADDING = 40; +const EVENT_SIZE = 80; +const EVENT_CENTER = EVENT_SIZE / 2; // 40 +const EVENT_DIAMOND_SIZE = 56; +const EVENT_DIAMOND_OFFSET = EVENT_DIAMOND_SIZE / 2; // 28 + +/** Node dimension defaults */ +const NODE_BASE_WIDTH = 90; +const NODE_BASE_HEIGHT = 36; +const NODE_PORT_SPACING = 18; + +/** Handle arrow paths for each rotation direction (from BaseNode.svelte clip-paths) */ +const HANDLE_PATHS: Record = { + 0: { + path: 'M 1 0 L 5 0 Q 6 0 6.71 0.71 L 9.29 3.29 Q 10 4 9.29 4.71 L 6.71 7.29 Q 6 8 5 8 L 1 8 Q 0 8 0 7 L 0 1 Q 0 0 1 0 Z', + width: 10, + height: 8 + }, + 1: { + path: 'M 1 0 L 7 0 Q 8 0 8 1 L 8 5 Q 8 6 7.29 6.71 L 4.71 9.29 Q 4 10 3.29 9.29 L 0.71 6.71 Q 0 6 0 5 L 0 1 Q 0 0 1 0 Z', + width: 8, + height: 10 + }, + 2: { + path: 'M 5 0 L 9 0 Q 10 0 10 1 L 10 7 Q 10 8 9 8 L 5 8 Q 4 8 3.29 7.29 L 0.71 4.71 Q 0 4 0.71 3.29 L 3.29 0.71 Q 4 0 5 0 Z', + width: 10, + height: 8 + }, + 3: { + path: 'M 4.71 0.71 L 7.29 3.29 Q 8 4 8 5 L 8 9 Q 8 10 7 10 L 1 10 Q 0 10 0 9 L 0 5 Q 0 4 0.71 3.29 L 3.29 0.71 Q 4 0 4.71 0.71 Z', + width: 8, + height: 10 + } +}; + +/** Node border radius by category */ +const BORDER_RADIUS: Record = { + Sources: 20, + Recording: 16, // Will be overridden to create circle + Algebraic: 4, + default: 8 +}; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface ExportOptions { + filename?: string; + includeBackground?: boolean; +} + +interface Colors { + edge: string; + text: string; + textMuted: string; + accent: string; + surface: string; +} + +interface Bounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +// ============================================================================ +// UTILITIES +// ============================================================================ + +/** Get current theme colors from CSS variables */ +function getColors(): Colors { + const style = getComputedStyle(document.documentElement); + const get = (name: string, fallback: string) => style.getPropertyValue(name).trim() || fallback; + + return { + edge: get('--edge', '#7F7F7F'), + text: get('--text', '#f0f0f5'), + textMuted: get('--text-muted', '#808090'), + accent: get('--accent', '#0070C0'), + surface: get('--surface', '#08080c') + }; +} + +/** Get current viewport zoom level */ +function getZoom(): number { + const viewport = document.querySelector('.svelte-flow__viewport') as HTMLElement; + if (!viewport) return 1; + + const match = viewport.style.transform.match(/scale\(([^)]+)\)/); + return match ? parseFloat(match[1]) : 1; +} + +/** Escape special XML characters */ +function escapeXml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/** Get node dimensions from DOM or calculate fallback */ +function getNodeDimensions(node: NodeInstance): { width: number; height: number } { + const nodeEl = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement; + if (nodeEl) { + const rect = nodeEl.getBoundingClientRect(); + const zoom = getZoom(); + return { width: rect.width / zoom, height: rect.height / zoom }; + } + + // Fallback calculation + const rotation = (node.params?.['_rotation'] as number) || 0; + const isVertical = rotation === 1 || rotation === 3; + const maxPorts = Math.max(node.inputs.length, node.outputs.length); + + return { + width: isVertical ? Math.max(NODE_BASE_WIDTH, maxPorts * NODE_PORT_SPACING + 20) : NODE_BASE_WIDTH, + height: isVertical ? NODE_BASE_HEIGHT : Math.max(NODE_BASE_HEIGHT, maxPorts * NODE_PORT_SPACING + 10) + }; +} + +// ============================================================================ +// DOM EXTRACTION +// ============================================================================ + +/** Extract edge paths and arrows from SvelteFlow's rendered DOM */ +function extractEdges(colors: Colors): string { + const container = document.querySelector('.svelte-flow__edges'); + if (!container) return ''; + + let svg = ''; + + container.querySelectorAll('.svelte-flow__edge').forEach((edge) => { + // Main edge path + const pathEl = edge.querySelector('.svelte-flow__edge-path'); + if (pathEl) { + const d = pathEl.getAttribute('d'); + if (d) { + svg += `\n\t\t`; + } + } + + // Arrow head + const arrowGroup = edge.querySelector('g[transform*="rotate"]'); + if (arrowGroup) { + const transform = arrowGroup.getAttribute('transform'); + const arrowPath = arrowGroup.querySelector('path'); + if (arrowPath && transform) { + const d = arrowPath.getAttribute('d'); + if (d) { + svg += `\n\t\t`; + } + } + } + }); + + return svg; +} + +/** Extract handle positions and render them */ +function extractHandles(nodeId: string, nodeX: number, nodeY: number, colors: Colors): string { + const wrapper = document.querySelector(`[data-id="${nodeId}"]`); + if (!wrapper) return ''; + + const nodeEl = wrapper.querySelector('[data-rotation]') || wrapper; + const rotation = parseInt(nodeEl.getAttribute('data-rotation') || '0'); + const handleDef = HANDLE_PATHS[rotation] || HANDLE_PATHS[0]; + const zoom = getZoom(); + const nodeRect = wrapper.getBoundingClientRect(); + + let svg = ''; + + nodeEl.querySelectorAll('.svelte-flow__handle').forEach((handle) => { + const rect = handle.getBoundingClientRect(); + const cx = (rect.left + rect.width / 2 - nodeRect.left) / zoom; + const cy = (rect.top + rect.height / 2 - nodeRect.top) / zoom; + const x = nodeX + cx - handleDef.width / 2; + const y = nodeY + cy - handleDef.height / 2; + + svg += `\n\t\t`; + }); + + return svg; +} + +// ============================================================================ +// ELEMENT RENDERERS +// ============================================================================ + +function renderNode(node: NodeInstance, colors: Colors): string { + const { width, height } = getNodeDimensions(node); + const { x, y } = node.position; + const typeDef = nodeRegistry.get(node.type); + const color = node.color || colors.accent; + const isSubsystem = node.type === 'Subsystem' || node.type === 'Interface'; + + // Determine border radius + let rx = BORDER_RADIUS[typeDef?.category || 'default'] || BORDER_RADIUS.default; + if (typeDef?.category === 'Recording') rx = Math.min(width, height) / 2; + + const handles = extractHandles(node.id, x, y, colors); + + return ` + + + ${escapeXml(node.name)} + ${escapeXml(typeDef?.name || node.type)}${handles} + `; +} + +function renderEvent(event: EventInstance, colors: Colors): string { + const cx = event.position.x + EVENT_CENTER; + const cy = event.position.y + EVENT_CENTER; + const color = event.color || colors.accent; + const typeDef = eventRegistry.get(event.type); + + return ` + + + ${escapeXml(event.name)} + ${escapeXml(typeDef?.name || '')} + `; +} + +// ============================================================================ +// BOUNDS CALCULATION +// ============================================================================ + +function calculateBounds(nodes: NodeInstance[], events: EventInstance[]): Bounds { + const bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; + + for (const node of nodes) { + const { width, height } = getNodeDimensions(node); + bounds.minX = Math.min(bounds.minX, node.position.x); + bounds.minY = Math.min(bounds.minY, node.position.y); + bounds.maxX = Math.max(bounds.maxX, node.position.x + width); + bounds.maxY = Math.max(bounds.maxY, node.position.y + height); + } + + for (const event of events) { + bounds.minX = Math.min(bounds.minX, event.position.x); + bounds.minY = Math.min(bounds.minY, event.position.y); + bounds.maxX = Math.max(bounds.maxX, event.position.x + EVENT_SIZE); + bounds.maxY = Math.max(bounds.maxY, event.position.y + EVENT_SIZE); + } + + return isFinite(bounds.minX) ? bounds : { minX: 0, minY: 0, maxX: 200, maxY: 200 }; +} + +// ============================================================================ +// MAIN EXPORT +// ============================================================================ + +/** Export the current graph as an SVG file */ +export function exportGraphAsSvg(options: ExportOptions = {}): void { + const { filename = 'pathview-graph', includeBackground = false } = options; + + const colors = getColors(); + const nodes = get(graphStore.nodesArray); + const events = get(eventStore.eventsArray); + + // Calculate SVG dimensions + const bounds = calculateBounds(nodes, events); + const width = bounds.maxX - bounds.minX + PADDING * 2; + const height = bounds.maxY - bounds.minY + PADDING * 2; + const viewBox = `${bounds.minX - PADDING} ${bounds.minY - PADDING} ${width} ${height}`; + + // Build SVG content + const parts: string[] = [ + ``, + `` + ]; + + if (includeBackground) { + parts.push(`\t`); + } + + // Edges (extracted from DOM) + parts.push(`\n\t${extractEdges(colors)}\n\t`); + + // Events + if (events.length > 0) { + parts.push(`\n\t${events.map((e) => renderEvent(e, colors)).join('')}\n\t`); + } + + // Nodes + if (nodes.length > 0) { + parts.push(`\n\t${nodes.map((n) => renderNode(n, colors)).join('')}\n\t`); + } + + parts.push(''); + + downloadSvg(parts.join('\n'), `${filename}.svg`); +} diff --git a/src/lib/utils/viewUtils.ts b/src/lib/utils/viewUtils.ts new file mode 100644 index 00000000..ba8abb4a --- /dev/null +++ b/src/lib/utils/viewUtils.ts @@ -0,0 +1,47 @@ +/** + * View utilities - coordinate conversion and selection utilities for use outside SvelteFlow context + */ + +type ScreenToFlowConverter = (screenPos: { x: number; y: number }) => { x: number; y: number }; +type HasSelectionFn = () => boolean; + +let screenToFlowConverterFn: ScreenToFlowConverter | null = null; +let hasSelectionFn: HasSelectionFn | null = null; + +export function registerScreenToFlowConverter(converter: ScreenToFlowConverter): void { + screenToFlowConverterFn = converter; +} + +export function screenToFlow(screenPos: { x: number; y: number }): { x: number; y: number } { + if (screenToFlowConverterFn) { + return screenToFlowConverterFn(screenPos); + } + // Fallback - just return as-is (no zoom/pan adjustment) + return screenPos; +} + +export function registerHasSelection(fn: HasSelectionFn): void { + hasSelectionFn = fn; +} + +export function hasAnySelection(): boolean { + return hasSelectionFn ? hasSelectionFn() : false; +} + +export function getViewportCenter(): { x: number; y: number } { + // Get the canvas element to determine its screen dimensions + const canvasEl = document.querySelector('.svelte-flow') as HTMLElement; + if (!canvasEl) { + return { x: 400, y: 300 }; + } + + const bounds = canvasEl.getBoundingClientRect(); + // Calculate center of the canvas in screen coordinates + const screenCenter = { + x: bounds.left + bounds.width / 2, + y: bounds.top + bounds.height / 2 + }; + + // Convert to flow coordinates + return screenToFlow(screenCenter); +} diff --git a/src/main.jsx b/src/main.jsx deleted file mode 100644 index 19a23541..00000000 --- a/src/main.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './styles/index.css' -import { App } from './App.jsx' - -createRoot(document.getElementById('root')).render( - - - , -) diff --git a/src/nodeConfig.js b/src/nodeConfig.js deleted file mode 100644 index d2e8a8fa..00000000 --- a/src/nodeConfig.js +++ /dev/null @@ -1,203 +0,0 @@ -// Node type definitions and categorization -import { ProcessNode, ProcessNodeHorizontal } from './components/nodes/ProcessNode'; -import DelayNode from './components/nodes/DelayNode'; -import SourceNode from './components/nodes/ConstantNode'; -import { AmplifierNode, AmplifierNodeReverse } from './components/nodes/AmplifierNode'; -import IntegratorNode from './components/nodes/IntegratorNode'; -import AdderNode from './components/nodes/AdderNode'; -import AddSubNode from './components/nodes/AddSubNode'; -import ScopeNode from './components/nodes/ScopeNode'; -import StepSourceNode from './components/nodes/StepSourceNode'; -import { createFunctionNode } from './components/nodes/FunctionNode'; -import DefaultNode from './components/nodes/DefaultNode'; -import MultiplierNode from './components/nodes/MultiplierNode'; -import { Splitter2Node, Splitter3Node } from './components/nodes/Splitters'; -import BubblerNode from './components/nodes/BubblerNode'; -import WallNode from './components/nodes/WallNode'; -import { DynamicHandleNode } from './components/nodes/DynamicHandleNode'; -import SwitchNode from './components/nodes/SwitchNode'; - -// Node types mapping -export const nodeTypes = { - process: ProcessNode, - process_horizontal: ProcessNodeHorizontal, - delay: DelayNode, - constant: SourceNode, - source: SourceNode, - stepsource: StepSourceNode, - trianglewavesource: SourceNode, - sinusoidalsource: SourceNode, - gaussianpulsesource: SourceNode, - sinusoidalphasenoisesource: SourceNode, - chirpphasenoisesource: SourceNode, - chirpsource: SourceNode, - pulsesource: SourceNode, - clocksource: SourceNode, - squarewavesource: SourceNode, - amplifier: AmplifierNode, - amplifier_reverse: AmplifierNodeReverse, - integrator: IntegratorNode, - adder: AdderNode, - addsub: AddSubNode, - multiplier: MultiplierNode, - scope: ScopeNode, - function: DynamicHandleNode, - rng: SourceNode, - pid: DefaultNode, - antiwinduppid: DefaultNode, - splitter2: Splitter2Node, - splitter3: Splitter3Node, - wall: WallNode, - bubbler: BubblerNode, - white_noise: SourceNode, - pink_noise: SourceNode, - spectrum: ScopeNode, - differentiator: DefaultNode, - samplehold: DefaultNode, - comparator: DefaultNode, - allpassfilter: DefaultNode, - butterworthlowpass: DefaultNode, - butterworthhighpass: DefaultNode, - butterworthbandpass: DefaultNode, - butterworthbandstop: DefaultNode, - fir: DefaultNode, - ode: DynamicHandleNode, - interface: DynamicHandleNode, - switch: SwitchNode, -}; - -export const nodeMathTypes = { - sin: DefaultNode, - cos: DefaultNode, - sqrt: DefaultNode, - abs: DefaultNode, - pow: DefaultNode, - exp: DefaultNode, - log: DefaultNode, - log10: DefaultNode, - tan: DefaultNode, - sinh: DefaultNode, - cosh: DefaultNode, - tanh: DefaultNode, - atan: DefaultNode, - norm: DefaultNode, - mod: DefaultNode, - clip: DefaultNode, -} - -// add nodeMathTypes to nodeTypes -Object.keys(nodeMathTypes).forEach(type => { - if (!nodeTypes[type]) { - nodeTypes[type] = nodeMathTypes[type]; - } -}); - -export const nodeDynamicHandles = ['ode', 'function', 'interface', 'addsub']; - -// Node categories for better organization -export const nodeCategories = { - 'Sources': { - nodes: ['constant', 'stepsource', 'source', 'pulsesource', 'trianglewavesource', 'sinusoidalsource', 'gaussianpulsesource', 'sinusoidalphasenoisesource', 'chirpphasenoisesource', 'chirpsource', 'clocksource', 'squarewavesource', 'rng', 'white_noise', 'pink_noise'], - description: 'Signal and data source nodes' - }, - 'Processing': { - nodes: ['delay', 'amplifier', 'amplifier_reverse', 'integrator', 'differentiator', 'function', 'ode'], - description: 'Signal processing and transformation nodes' - }, - 'Math': { - nodes: ['adder', 'addsub', 'multiplier', 'splitter2', 'splitter3'].concat(Object.keys(nodeMathTypes)), - description: 'Mathematical operation nodes' - }, - 'Control': { - nodes: ['pid', 'antiwinduppid'], - description: 'Control system nodes' - }, - 'Filters': { - nodes: ['allpassfilter', 'butterworthlowpass', 'butterworthhighpass', 'butterworthbandpass', 'butterworthbandstop', 'fir'], - description: 'Filter and flow control nodes' - }, - 'Fuel Cycle': { - nodes: ['process', 'process_horizontal', 'bubbler', 'wall'], - description: 'Fuel cycle specific nodes' - }, - 'Others': { - nodes: ['samplehold', 'comparator', 'switch', 'interface'], - description: 'Miscellaneous nodes' - }, - 'Output': { - nodes: ['scope', 'spectrum'], - description: 'Output and visualization nodes' - } -}; - -// Utility function to get display name for a node type -export const getNodeDisplayName = (nodeType) => { - const displayNames = { - 'source': 'Source', - 'constant': 'Constant', - 'stepsource': 'Step', - 'pulsesource': 'Pulse', - 'trianglewavesource': 'Triangle Wave', - 'sinusoidalsource': 'Sinusoidal Source', - 'gaussianpulsesource': 'Gaussian Pulse Source', - 'sinusoidalphasenoisesource': 'Sinusoidal Alpha Noise Source', - 'chirpphasenoisesource': 'Chirp Phase Noise Source', - 'chirpsource': 'Chirp Source', - 'clocksource': 'Clock Source', - 'squarewavesource': 'Square Wave', - 'white_noise': 'White Noise', - 'pink_noise': 'Pink Noise', - 'process': 'Process', - 'process_horizontal': 'Process (Horizontal)', - 'delay': 'Delay', - 'amplifier': 'Amplifier', - 'amplifier_reverse': 'Amplifier (Reverse)', - 'integrator': 'Integrator', - 'function': 'Function', - 'adder': 'Adder', - 'addsub': 'Adder/Subtractor', - 'ode': 'ODE', - 'multiplier': 'Multiplier', - 'splitter2': 'Splitter (1→2)', - 'splitter3': 'Splitter (1→3)', - 'rng': 'Random Number Generator', - 'pid': 'PID Controller', - 'antiwinduppid': 'Anti-Windup PID', - 'bubbler': 'Bubbler', - 'wall': 'Wall', - 'scope': 'Scope', - 'spectrum': 'Spectrum', - 'differentiator': 'Differentiator', - 'sin': 'Sine', - 'cos': 'Cosine', - 'sqrt': 'Square Root', - 'abs': 'Absolute', - 'pow': 'Power', - 'exp': 'Exponential', - 'log': 'Logarithm', - 'log10': 'Logarithm (Base 10)', - 'tan': 'Tangent', - 'sinh': 'Hyperbolic Sine', - 'cosh': 'Hyperbolic Cosine', - 'tanh': 'Hyperbolic Tangent', - 'atan': 'Inverse Tangent', - 'norm': 'Normalization', - 'mod': 'Modulo', - 'clip': 'Clipping', - 'allpassfilter': 'All-Pass Filter', - 'butterworthlowpass': 'Butterworth Low-Pass Filter', - 'butterworthhighpass': 'Butterworth High-Pass Filter', - 'butterworthbandpass': 'Butterworth Band-Pass Filter', - 'butterworthbandstop': 'Butterworth Band-Stop Filter', - 'fir': 'FIR Filter', - 'switch': 'Switch', - 'samplehold': 'Sample Hold', - 'comparator': 'Comparator', - 'interface': 'Interface', - }; - - return displayNames[nodeType] || nodeType.charAt(0).toUpperCase() + nodeType.slice(1); -}; - -// Utility function to get all available node types -export const getAllNodeTypes = () => Object.keys(nodeTypes); diff --git a/src/python/__init__.py b/src/python/__init__.py deleted file mode 100644 index b902f18e..00000000 --- a/src/python/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from importlib import metadata - -try: - __version__ = metadata.version("pathview") -except Exception: - __version__ = "unknown" - -# Import main functions for easy access -from .pathsim_utils import make_pathsim_model, map_str_to_object -from .convert_to_python import convert_graph_to_python - -# Define what gets exported when someone does "from python import *" -__all__ = [ - "make_pathsim_model", - "map_str_to_object", - "convert_graph_to_python", -] diff --git a/src/python/convert_to_python.py b/src/python/convert_to_python.py deleted file mode 100644 index f0d51023..00000000 --- a/src/python/convert_to_python.py +++ /dev/null @@ -1,213 +0,0 @@ -from jinja2 import Environment, FileSystemLoader -import os -from inspect import signature - -from .pathsim_utils import ( - map_str_to_object, - map_str_to_event, - make_blocks, - make_global_variables, - get_input_index, - get_output_index, - find_block_by_id, - find_node_by_id, - make_var_name, -) - - -def convert_graph_to_python(graph_data: dict) -> str: - """Convert graph data to a Python script as a string.""" - # Get the directory of this file to properly reference templates - current_dir = os.path.dirname(os.path.abspath(__file__)) - templates_dir = os.path.join(current_dir, "templates") - - environment = Environment(loader=FileSystemLoader(templates_dir)) - template = environment.get_template("template_with_macros.py") - - # Process the graph data - context = process_graph_data_from_dict(graph_data) - - # Render the template - return template.render(context) - - -def process_node_data(nodes: list[dict]) -> list[dict]: - """ - Given a list of node and edge data as dictionaries, process the nodes to create - variable names, class names, and expected arguments for each node. - - Returns: - The processed node data with variable names, class names, and expected arguments. - """ - nodes = nodes.copy() - - for node in nodes: - node["var_name"] = make_var_name(node) - - # Add pathsim class name - block_class = map_str_to_object.get(node["type"]) - node["class_name"] = block_class.__name__ - node["module_name"] = block_class.__module__ - - # Add expected arguments - node["expected_arguments"] = signature(block_class).parameters - - return nodes - - -# TODO: this is effectively a duplicate of pathsim_utils.make_connections -# need to refactor -def make_edge_data(data: dict) -> list[dict]: - """ - Process edges to add source/target variable names and ports. - - Does it by creating pathsim.blocks and Connections from the data with - ``make_blocks`` and ``make_connections`` functions. - - Then, since the data (source/target blocks, ports) is already in the - connections, we can simply read the ports id from the actual pathsim - connections and add them to the edges. - - Args: - data: The graph data containing "nodes" and "edges". - - Returns: - The processed edges with source/target variable names and ports. - """ - data = data.copy() - - # we need the namespace since we call make_blocks - - global_vars = data.get("globalVariables", {}) - - # Get the global variables namespace to use in eval calls - global_namespace = make_global_variables(global_vars) - - # Create a combined namespace that includes built-in functions and global variables - eval_namespace = globals().copy() - eval_namespace.update(global_namespace) - - # Execute python code first to define any variables that blocks might need - python_code = data.get("pythonCode", "") - if python_code: - exec(python_code, eval_namespace) - - blocks, _ = make_blocks(data["nodes"], eval_namespace=eval_namespace) - - # Process each node and its sorted incoming edges to create connections - block_to_input_index = {b: 0 for b in blocks} - for node in data["nodes"]: - outgoing_edges = [ - edge for edge in data["edges"] if edge["source"] == node["id"] - ] - outgoing_edges.sort(key=lambda x: x["target"]) - - block = find_block_by_id(node["id"], blocks) - - for edge in outgoing_edges: - target_block = find_block_by_id(edge["target"], blocks) - target_node = find_node_by_id(edge["target"], data["nodes"]) - - output_index = get_output_index(block, edge) - input_index = get_input_index(target_block, edge, block_to_input_index) - - # if it's a scope, find labels - if target_node["type"] == "scope": - if target_node["data"]["labels"] == "": - target_node["data"]["labels"] = [] - - if isinstance(target_node["data"]["labels"], list): - label = node["data"]["label"] - if edge["sourceHandle"]: - label += f" ({edge['sourceHandle']})" - target_node["data"]["labels"].append(label) - - edge["source_var_name"] = node["var_name"] - edge["target_var_name"] = target_node["var_name"] - if isinstance(output_index, str): - edge["source_port"] = f"['{output_index}']" - else: - edge["source_port"] = f"[{output_index}]" - if isinstance(input_index, str): - edge["target_port"] = f"['{input_index}']" - else: - edge["target_port"] = f"[{input_index}]" - block_to_input_index[target_block] += 1 - - return data["edges"] - - -def make_events_data(data: dict) -> list[dict]: - """ - Process events data from the graph data. - - This function extracts event definitions from the graph data and prepares them - for use in the simulation. - - Args: - data: The graph data containing "events". - - Returns: - A list of processed events. - """ - for event in data["events"]: - # Add pathsim class name - event_class = map_str_to_event.get(event["type"]) - event["class_name"] = event_class.__name__ - event["module_name"] = event_class.__module__ - - # Add expected arguments - event["expected_arguments"] = signature(event_class).parameters - - if "func_evt" in event: - # if the whole function in defined in the event, make sure it has a unique identifier - if event["func_evt"].startswith("def"): - # replace the name of the function by something unique - func_evt = event["func_evt"] - func_evt = func_evt.replace( - "def func_evt", f"def {event['name']}_func_evt" - ) - event["func_evt"] = f"{event['name']}_func_evt" - event["func_evt_desc"] = func_evt - # otherwise assume it was defined in the global namespace - # and just copy the function identifier - else: - event["func_evt_desc"] = event["func_evt"] - - if "func_act" in event: - # if the whole function in defined in the event, make sure it has a unique identifier - if event["func_act"].startswith("def"): - # replace the name of the function by something unique - func_act = event["func_act"] - func_act = func_act.replace( - "def func_act", f"def {event['name']}_func_act" - ) - event["func_act"] = f"{event['name']}_func_act" - event["func_act_desc"] = func_act - # otherwise assume it was defined in the global namespace - # and just copy the function identifier - else: - event["func_act_desc"] = event["func_act"] - return data["events"] - - -def process_graph_data_from_dict(data: dict) -> dict: - """ - Process graph data from a dictionary. - - Adds variable names, class names, and expected arguments to nodes, - and processes edges to include source/target variable names and ports. - - This processed data can then be more easily used to generate Python code. - """ - data = data.copy() - - # Process nodes to create variable names and class names - data["nodes"] = process_node_data(data["nodes"]) - - # Process to add source/target variable names to edges + ports - data["edges"] = make_edge_data(data) - - data["events"] = make_events_data(data) - - return data diff --git a/src/python/custom_pathsim_blocks.py b/src/python/custom_pathsim_blocks.py deleted file mode 100644 index 223c1607..00000000 --- a/src/python/custom_pathsim_blocks.py +++ /dev/null @@ -1,238 +0,0 @@ -from pathsim.blocks import ODE, Wrapper -from pathsim_chem import Splitter -import pathsim.blocks -import pathsim.events -import numpy as np - - -class Process(ODE): - """ - A block that represents a process with a residence time and a source term. - - Args: - residence_time: Residence time of the process. - initial_value: Initial value of the process. - source_term: Source term of the process. - """ - - _port_map_out = {"inv": 0, "mass_flow_rate": 1} - - def __init__(self, residence_time=0, initial_value=0, source_term=0): - alpha = -1 / residence_time if residence_time != 0 else 0 - super().__init__( - func=lambda x, u, t: x * alpha + sum(u) + source_term, - initial_value=initial_value, - ) - self.residence_time = residence_time - self.initial_value = initial_value - self.source_term = source_term - - def update(self, t): - x = self.engine.get() - if self.residence_time == 0: - mass_rate = 0 - else: - mass_rate = x / self.residence_time - # first output is the inv, second is the mass_flow_rate - outputs = [None, None] - outputs[self._port_map_out["inv"]] = x - outputs[self._port_map_out["mass_flow_rate"]] = mass_rate - # update the outputs - self.outputs.update_from_array(outputs) - - -class Splitter2(Splitter): - _port_map_out = {"source1": 0, "source2": 1} - - def __init__(self, f1=0.5, f2=0.5): - """ - Splitter with two outputs, fractions are f1 and f2. - """ - super().__init__(fractions=[f1, f2]) - - -class Splitter3(Splitter): - _port_map_out = {"source1": 0, "source2": 1, "source3": 2} - - def __init__(self, f1=1 / 3, f2=1 / 3, f3=1 / 3): - """ - Splitter with three outputs, fractions are f1, f2 and f3. - """ - super().__init__(fractions=[f1, f2, f3]) - - -class Integrator(pathsim.blocks.Integrator): - """Integrates the input signal using a numerical integration engine like this: - - .. math:: - - y(t) = \\int_0^t u(\\tau) \\ d \\tau - - or in differential form like this: - - .. math:: - \\begin{eqnarray} - \\dot{x}(t) &= u(t) \\\\ - y(t) &= x(t) - \\end{eqnarray} - - The Integrator block is inherently MIMO capable, so `u` and `y` can be vectors. - - Example - ------- - This is how to initialize the integrator: - - .. code-block:: python - - #initial value 0.0 - i1 = Integrator() - - #initial value 2.5 - i2 = Integrator(2.5) - - - Parameters - ---------- - initial_value : float, array - initial value of integrator - reset_times : list, optional - List of times at which the integrator is reset. - """ - - def __init__(self, initial_value=0.0, reset_times=None): - """ - Args: - initial_value: Initial value of the integrator. - reset_times: List of times at which the integrator is reset. If None, no reset events are created. - """ - super().__init__(initial_value=initial_value) - self.reset_times = reset_times - - def create_reset_events(self) -> list[pathsim.events.ScheduleList]: - """Create reset events for the integrator based on the reset times. - - Raises: - ValueError: If reset_times is not valid. - - Returns: - list of reset events. - """ - if self.reset_times is None: - return [] - if isinstance(self.reset_times, (int, float)): - reset_times = [self.reset_times] - elif isinstance(self.reset_times, list) and all( - isinstance(t, (int, float)) for t in self.reset_times - ): - reset_times = self.reset_times - else: - raise ValueError("reset_times must be a single value or a list of times") - - def func_act(_): - self.reset() - - event = pathsim.events.ScheduleList(times_evt=reset_times, func_act=func_act) - return [event] - - -# FESTIM wall -from pathsim.utils.register import Register - - -class FestimWall(Wrapper): - _port_map_out = {"flux_0": 0, "flux_L": 1} - _port_map_in = {"c_0": 0, "c_L": 1} - - def __init__( - self, - thickness, - temperature, - D_0, - E_D, - T, - surface_area=1, - n_vertices=100, - tau=0, - ): - try: - import festim - except ImportError: - raise ImportError("festim is needed for FestimWall node.") - - self.inputs = Register(size=2, mapping=self._port_map_in) - self.outputs = Register(size=2, mapping=self._port_map_out) - - self.thickness = thickness - self.temperature = temperature - self.surface_area = surface_area - self.D_0 = D_0 - self.E_D = E_D - self.n_vertices = n_vertices - self.t = 0.0 - self.stepsize = T - - self.initialise_festim_model() - super().__init__(T=T, tau=tau, func=self.func) - - def initialise_festim_model(self): - import festim as F - - model = F.HydrogenTransportProblem() - - model.mesh = F.Mesh1D( - vertices=np.linspace(0, self.thickness, num=self.n_vertices) - ) - material = F.Material(D_0=self.D_0, E_D=self.E_D) - - vol = F.VolumeSubdomain1D(id=1, material=material, borders=[0, self.thickness]) - left_surf = F.SurfaceSubdomain1D(id=1, x=0) - right_surf = F.SurfaceSubdomain1D(id=2, x=self.thickness) - - model.subdomains = [vol, left_surf, right_surf] - - H = F.Species("H") - model.species = [H] - - model.boundary_conditions = [ - F.FixedConcentrationBC(left_surf, value=0.0, species=H), - F.FixedConcentrationBC(right_surf, value=0.0, species=H), - ] - - model.temperature = self.temperature - - model.settings = F.Settings( - atol=1e-10, rtol=1e-10, transient=True, final_time=1 - ) - - model.settings.stepsize = F.Stepsize(initial_value=self.stepsize) - - self.surface_flux_0 = F.SurfaceFlux(field=H, surface=left_surf) - self.surface_flux_L = F.SurfaceFlux(field=H, surface=right_surf) - model.exports = [self.surface_flux_0, self.surface_flux_L] - - model.show_progress_bar = False - - model.initialise() - - self.c_0 = model.boundary_conditions[0].value_fenics - self.c_L = model.boundary_conditions[1].value_fenics - - self.model = model - - def update_festim_model(self, c_0, c_L): - self.c_0.value = c_0 - self.c_L.value = c_L - - self.model.iterate() - - return self.surface_flux_0.data[-1], self.surface_flux_L.data[-1] - - def func(self, c_0, c_L): - flux_0, flux_L = self.update_festim_model(c_0=c_0, c_L=c_L) - - flux_0 *= self.surface_area - flux_L *= self.surface_area - - self.outputs["flux_0"] = flux_0 - self.outputs["flux_L"] = flux_L - return self.outputs diff --git a/src/python/pathsim_utils.py b/src/python/pathsim_utils.py deleted file mode 100644 index ab30c21e..00000000 --- a/src/python/pathsim_utils.py +++ /dev/null @@ -1,810 +0,0 @@ -""" -Utilities for converting graph-based representations to PathSim simulations. - -This module provides functionality to convert visual graph representations of simulation -models into executable PathSim simulations. It handles the creation of blocks, connections, -events, and solver configurations from JSON-like graph data structures. - -The main workflow involves: -1. Processing global variables and solver parameters -2. Creating blocks from node data -3. Establishing connections between blocks based on edges -4. Setting up events and custom Python code execution -5. Building the complete PathSim simulation model - -Key mappings are provided for: -- Block types (map_str_to_object): Maps string identifiers to PathSim block classes -- Event types (map_str_to_event): Maps string identifiers to PathSim event classes -- Solver types (NAME_TO_SOLVER): Maps string identifiers to PathSim solver classes -""" - -import math -import numpy as np -from pathsim import Simulation, Connection -from pathsim.events import Event -import pathsim.solvers -from pathsim.blocks import ( - Scope, - Block, - Constant, - Source, - StepSource, - PulseSource, - Amplifier, - Adder, - Multiplier, - Function, - Delay, - RNG, - PID, - Spectrum, - Differentiator, - ODE, - Schedule, -) -import pathsim.blocks -import pathsim.events -from pathsim.blocks.noise import WhiteNoise, PinkNoise -from .custom_pathsim_blocks import ( - Process, - Splitter2, - Splitter3, - FestimWall, - Integrator, -) -from pathsim_chem import Bubbler4, Splitter -import inspect - -NAME_TO_SOLVER = { - "RK4": pathsim.solvers.RK4, - "RKBS32": pathsim.solvers.RKBS32, - "RKCK54": pathsim.solvers.RKCK54, - "RKDP54": pathsim.solvers.RKDP54, - "RKDP87": pathsim.solvers.RKDP87, - "RKF45": pathsim.solvers.RKF45, - "RKF78": pathsim.solvers.RKF78, - "RKV65": pathsim.solvers.RKV65, - "BDF": pathsim.solvers.BDF, - "EUF": pathsim.solvers.EUF, - "EUB": pathsim.solvers.EUB, - "GEAR21": pathsim.solvers.GEAR21, - "GEAR32": pathsim.solvers.GEAR32, - "GEAR43": pathsim.solvers.GEAR43, - "GEAR54": pathsim.solvers.GEAR54, - "GEAR52A": pathsim.solvers.GEAR52A, - "DIRK2": pathsim.solvers.DIRK2, - "DIRK3": pathsim.solvers.DIRK3, - "ESDIRK32": pathsim.solvers.ESDIRK32, - "ESDIRK4": pathsim.solvers.ESDIRK4, - "ESDIRK43": pathsim.solvers.ESDIRK43, - "ESDIRK54": pathsim.solvers.ESDIRK54, - "ESDIRK85": pathsim.solvers.ESDIRK85, - "SteadyState": pathsim.solvers.SteadyState, - "SSPRK34": pathsim.solvers.SSPRK34, - "SSPRK22": pathsim.solvers.SSPRK22, - "SSPRK33": pathsim.solvers.SSPRK33, - "RKF21": pathsim.solvers.RKF21, -} -map_str_to_object = { - "constant": Constant, - "source": Source, - "stepsource": StepSource, - "trianglewavesource": pathsim.blocks.sources.TriangleWaveSource, - "sinusoidalsource": pathsim.blocks.sources.SinusoidalSource, - "gaussianpulsesource": pathsim.blocks.sources.GaussianPulseSource, - "sinusoidalphasenoisesource": pathsim.blocks.sources.SinusoidalPhaseNoiseSource, - "chirpphasenoisesource": pathsim.blocks.sources.ChirpPhaseNoiseSource, - "chirpsource": pathsim.blocks.sources.ChirpSource, - "clocksource": pathsim.blocks.sources.ClockSource, - "squarewavesource": pathsim.blocks.sources.SquareWaveSource, - "pulsesource": PulseSource, - "amplifier": Amplifier, - "amplifier_reverse": Amplifier, - "scope": Scope, - "splitter2": Splitter2, - "splitter3": Splitter3, - "adder": Adder, - "addsub": Adder, - "adder_reverse": Adder, - "multiplier": Multiplier, - "process": Process, - "process_horizontal": Process, - "rng": RNG, - "pid": PID, - "antiwinduppid": pathsim.blocks.AntiWindupPID, - "integrator": Integrator, - "differentiator": Differentiator, - "function": Function, - "function2to2": Function, - "delay": Delay, - "ode": ODE, - "bubbler": Bubbler4, - "wall": FestimWall, - "white_noise": WhiteNoise, - "pink_noise": PinkNoise, - "spectrum": Spectrum, - "samplehold": pathsim.blocks.SampleHold, - "comparator": pathsim.blocks.Comparator, - "allpassfilter": pathsim.blocks.AllpassFilter, - "butterworthlowpass": pathsim.blocks.ButterworthLowpassFilter, - "butterworthhighpass": pathsim.blocks.ButterworthHighpassFilter, - "butterworthbandpass": pathsim.blocks.ButterworthBandpassFilter, - "butterworthbandstop": pathsim.blocks.ButterworthBandstopFilter, - "fir": pathsim.blocks.FIR, - "interface": pathsim.subsystem.Interface, - "switch": pathsim.blocks.Switch, -} - -math_blocks = { - "sin": pathsim.blocks.Sin, - "cos": pathsim.blocks.Cos, - "sqrt": pathsim.blocks.Sqrt, - "abs": pathsim.blocks.Abs, - "pow": pathsim.blocks.Pow, - "exp": pathsim.blocks.Exp, - "log": pathsim.blocks.Log, - "log10": pathsim.blocks.Log10, - "tan": pathsim.blocks.Tan, - "sinh": pathsim.blocks.Sinh, - "cosh": pathsim.blocks.Cosh, - "tanh": pathsim.blocks.Tanh, - "atan": pathsim.blocks.Atan, - "norm": pathsim.blocks.Norm, - "mod": pathsim.blocks.Mod, - "clip": pathsim.blocks.Clip, -} - -map_str_to_object.update(math_blocks) - -map_str_to_event = { - "ZeroCrossingDown": pathsim.events.ZeroCrossingDown, - "ZeroCrossingUp": pathsim.events.ZeroCrossingUp, - "ZeroCrossing": pathsim.events.ZeroCrossing, - "Schedule": pathsim.events.Schedule, - "ScheduleList": pathsim.events.ScheduleList, - "Condition": pathsim.events.Condition, -} - - -def find_node_by_id(node_id: str, nodes: list[dict]) -> dict: - """ - Find a node by its ID in a list of nodes. - - Args: - node_id: The ID of the node to find. - nodes: A list of node dictionaries to search through. - - Returns: - The node dictionary with the matching ID, or None if not found. - """ - return next((node for node in nodes if node["id"] == node_id), None) - - -def find_block_by_id(block_id: str, blocks: list[Block]) -> Block: - """ - Find a block by its ID in a list of blocks. - - Args: - block_id: The ID of the block to find. - blocks: A list of Block objects to search through. - - Returns: - The Block object with the matching ID, or None if not found. - """ - return next((block for block in blocks if block.id == block_id), None) - - -def make_global_variables(global_vars): - """ - Validate and execute global variable definitions to make them usable in the simulation. - - Args: - global_vars: A list of dictionaries containing variable definitions, where each - dictionary has 'name' and 'value' keys. - - Returns: - dict: A namespace dictionary containing the global variables. - - Raises: - ValueError: If a variable name is invalid, is a Python keyword, or if there's - an error evaluating the variable value. - """ - # Validate and exec global variables so that they are usable later in this script. - # Return a namespace dictionary containing the global variables - global_namespace = globals().copy() - - for var in global_vars: - var_name = var.get("name", "").strip() - var_value = var.get("value", "") - - # Validate variable name - if not var_name: - continue # Skip empty names - - if not var_name.isidentifier(): - raise ValueError( - f"Invalid Python variable name: '{var_name}'. " - "Variable names must start with a letter or underscore, " - "and contain only letters, digits, and underscores." - ) - - # Check if it's a Python keyword - import keyword - - if keyword.iskeyword(var_name): - raise ValueError( - f"'{var_name}' is a Python keyword and cannot be used as a variable name." - ) - - try: - # Execute in global namespace for backwards compatibility - exec(f"{var_name} = {var_value}", global_namespace) - # Also store in local namespace for eval calls - global_namespace[var_name] = eval(var_value, global_namespace) - except Exception as e: - raise ValueError(f"Error setting global variable '{var_name}': {str(e)}") - - return global_namespace - - -def make_solver_params(solver_prms: dict, eval_namespace=None): - """ - Process and validate solver parameters from the graph data. - - Args: - solver_prms: Dictionary containing solver parameters including Solver type, - simulation_duration, and other solver-specific parameters. - eval_namespace: Optional namespace for evaluating parameter expressions. - - Returns: - tuple: A tuple containing: - - solver_prms (dict): Processed solver parameters - - extra_params (dict): Additional parameters for the solver - - duration (float): Simulation duration - - Raises: - ValueError: If invalid parameter values are provided or if solver type is unknown. - """ - prms = solver_prms.copy() - extra_params = prms.pop("extra_params", "") - if extra_params == "": - extra_params = {} - else: - extra_params = eval(extra_params, eval_namespace) - assert isinstance(extra_params, dict), "extra_params must be a dictionary" - - for k, v in prms.items(): - if k not in ["Solver", "log"]: - if v == "": - # TODO get the default from pathsim._constants - prms[k] = None - else: - print(v, type(v)) - prms[k] = eval(v, eval_namespace) - elif k == "log": - if v == "true": - prms[k] = True - elif v == "false": - prms[k] = False - else: - raise ValueError( - f"Invalid value for {k}: {v}. Must be 'true' or 'false'." - ) - elif k == "Solver": - if v not in NAME_TO_SOLVER: - raise ValueError( - f"Invalid solver: {v}. Must be one of {list(NAME_TO_SOLVER.keys())}." - ) - prms[k] = NAME_TO_SOLVER[v] - - # remove solver duration from solver parameters - duration = float(prms.pop("simulation_duration")) - - assert not isinstance(prms["Solver"], str), prms["Solver"] - - return prms, extra_params, duration - - -def auto_block_construction(node: dict, eval_namespace: dict = None) -> Block: - """ - Automatically constructs a block object from a node dictionary. - - Args: - node: The node dictionary containing block information. - eval_namespace: A namespace for evaluating expressions. Defaults to None. - - Raises: - ValueError: If the block type is unknown or if there are issues with evaluation. - - Returns: - The constructed block object. - """ - - if node["type"] not in map_str_to_object: - raise ValueError(f"Unknown block type: {node['type']}") - - block_class = map_str_to_object[node["type"]] - - parameters = get_parameters_for_block_class( - block_class, node, eval_namespace=eval_namespace - ) - - return block_class(**parameters) - - -def auto_event_construction(event_data: dict, eval_namespace: dict = None) -> Event: - """ - Automatically constructs an event object from an event data dictionary. - - Args: - event_data: The event data dictionary containing event information. - eval_namespace: A namespace for evaluating expressions. Defaults to None. - - Raises: - ValueError: If the event type is unknown or if there are issues with evaluation. - - Returns: - The constructed event object. - """ - - if event_data["type"] not in map_str_to_event: - raise ValueError(f"Unknown event type: {event_data['type']}") - - event_class = map_str_to_event[event_data["type"]] - - parameters = get_parameters_for_event_class( - event_class, event_data, eval_namespace=eval_namespace - ) - - return event_class(**parameters) - - -def get_parameters_for_event_class( - event_class: type, event_data: dict, eval_namespace: dict = None -): - """ - Extract and process parameters for an event class from event data. - - Args: - event_class: The event class type to create parameters for. - event_data: Dictionary containing the event configuration data. - eval_namespace: Optional namespace for evaluating expressions and executing functions. - - Returns: - dict: A dictionary of parameters ready to be passed to the event class constructor. - - Raises: - ValueError: If required parameters are missing, if function code execution fails, - or if parameter evaluation fails. - """ - parameters_for_class = inspect.signature(event_class.__init__).parameters - - # Create a local namespace for executing the event functions - # we make a copy so that event functions aren't overwritten - event_namespace = eval_namespace.copy() - - parameters = {} - for k, value in parameters_for_class.items(): - if k == "self": - continue - - user_input = event_data[k] - if user_input == "": - if value.default is inspect._empty: - raise ValueError( - f"expected parameter for {k} in {event_data['type']} ({event_data['name']})" - ) - - # make a copy of the default value - if isinstance(value.default, (list, dict)): - parameters[k] = value.default.copy() - else: - parameters[k] = value.default - else: - if k in ["func_evt", "func_act"]: - # Execute func code if provided - func_code = event_data[k] - if not func_code: - raise ValueError(f"{k} code is required but not provided") - - if func_code in event_namespace: - parameters[k] = event_namespace[func_code] - # parameters[f"{k}_identifier"] = func_code - continue - - try: - exec(func_code, event_namespace) - if k not in event_namespace: - raise ValueError(f"{k} function not found after execution") - except Exception as e: - raise ValueError(f"Error executing {k} code: {str(e)}") - - parameters[k] = event_namespace[k] - # parameters[f"{k}_identifier"] = k - else: - parameters[k] = eval(user_input, event_namespace) - return parameters - - -def get_parameters_for_block_class(block_class, node, eval_namespace): - """ - Extract and process parameters for a block class from node data. - - Args: - block_class: The block class type to create parameters for. - node: Dictionary containing the node configuration data. - eval_namespace: Namespace for evaluating parameter expressions. - - Returns: - dict: A dictionary of parameters ready to be passed to the block class constructor. - - Raises: - ValueError: If required parameters are missing or if parameter evaluation fails. - """ - parameters_for_class = inspect.signature(block_class.__init__).parameters - parameters = {} - for k, value in parameters_for_class.items(): - if k == "self": - continue - # Skip 'operations' for Adder, as it is handled separately - # https://github.com/festim-dev/pathview/issues/73 - if k in ["operations"] and node["type"] != "addsub": - continue - user_input = node["data"][k] - if user_input == "": - if value.default is inspect._empty: - raise ValueError( - f"expected parameter for {k} in {node['type']} ({node['label']})" - ) - - # make a copy of the default value - if isinstance(value.default, (list, dict)): - parameters[k] = value.default.copy() - else: - parameters[k] = value.default - else: - parameters[k] = eval(user_input, eval_namespace) - return parameters - - -def make_blocks( - nodes: list[dict], eval_namespace: dict = None -) -> tuple[list[Block], list[Event]]: - """ - Create Block objects from node data and collect any associated events. - - Args: - nodes: List of node dictionaries containing block configuration data. - eval_namespace: Optional namespace for evaluating expressions. - - Returns: - tuple: A tuple containing: - - blocks (list[Block]): List of created Block objects - - events (list[Event]): List of events created by blocks (e.g., reset events) - """ - blocks, events = [], [] - - for node in nodes: - block = auto_block_construction(node, eval_namespace) - if hasattr(block, "create_reset_events"): - events.extend(block.create_reset_events()) - - block.id = node["id"] - block.label = node["data"]["label"] - blocks.append(block) - - return blocks, events - - -def get_input_index(block: Block, edge: dict, block_to_input_index: dict) -> int: - """ - Get the input index for a block based on the edge data. - - Args: - block: The block object to get the input index for. - edge: The edge dictionary containing source and target information. - block_to_input_index: Dictionary mapping blocks to their current input index count. - - Returns: - int: The input index for the block. - - Raises: - AssertionError: If the target block has multiple input ports but the connection - method hasn't been implemented for that block type. - """ - - if edge["targetHandle"] is not None: - if block._port_map_in: - return edge["targetHandle"] - - # TODO maybe we could directly use the targetHandle as a port alias for these: - if type(block) in (Function, ODE, pathsim.blocks.Switch): - return int(edge["targetHandle"].replace("target-", "")) - if isinstance(block, Adder): - if block.operations: - return int(edge["targetHandle"].replace("target-", "")) - - # make sure that the target block has only one input port (ie. that targetHandle is None) - assert edge["targetHandle"] is None, ( - f"Target block {block.id} has multiple input ports, " - "but connection method hasn't been implemented." - ) - return block_to_input_index[block] - - -# TODO here we could only pass edge and not block -def get_output_index(block: Block, edge: dict) -> int: - """ - Get the output index for a block based on the edge data. - - Args: - block: The block object to get the output index for. - edge: The edge dictionary containing source and target information. - - Returns: - int: The output index for the block. - - Raises: - ValueError: If an invalid source handle is provided for a Splitter block. - AssertionError: If the source block has multiple output ports but the connection - method hasn't been implemented for that block type. - """ - if edge["sourceHandle"] is not None: - if block._port_map_out: - return edge["sourceHandle"] - - if isinstance(block, Splitter): - # Splitter outputs are always in order, so we can use the handle directly - assert edge["sourceHandle"], edge - output_index = int(edge["sourceHandle"].replace("source", "")) - 1 - if output_index >= block.n: - raise ValueError( - f"Invalid source handle '{edge['sourceHandle']}' for {edge}." - ) - return output_index - # TODO maybe we could directly use the targetHandle as a port alias for these: - if type(block) in (Function, ODE): - # Function and ODE outputs are always in order, so we can use the handle directly - assert edge["sourceHandle"], edge - return int(edge["sourceHandle"].replace("source-", "")) - - # make sure that the source block has only one output port (ie. that sourceHandle is None) - assert edge["sourceHandle"] is None, ( - f"Source block {block.id} has multiple output ports, " - "but connection method hasn't been implemented." - ) - return 0 - - -def make_connections(nodes, edges, blocks) -> list[Connection]: - """ - Create PathSim Connection objects from nodes, edges, and blocks data. - - This function processes the graph structure to create proper connections between blocks, - handling special cases for scopes and different block types with multiple inputs/outputs. - - Args: - nodes: List of node dictionaries containing block information. - edges: List of edge dictionaries defining connections between nodes. - blocks: List of Block objects that have been created from the nodes. - - Returns: - list[Connection]: List of PathSim Connection objects linking block inputs and outputs. - - Note: - This function also handles labeling for Scope and Spectrum blocks automatically. - """ - # Create connections based on the sorted edges to match beta order - connections_pathsim = [] - - # Process each node and its sorted incoming edges to create connections - block_to_input_index = {b: 0 for b in blocks} - - scopes_without_labels = [] - - for node in nodes: - outgoing_edges = [edge for edge in edges if edge["source"] == node["id"]] - outgoing_edges.sort(key=lambda x: x["target"]) - - incoming_edges = [edge for edge in edges if edge["target"] == node["id"]] - incoming_edges.sort(key=lambda x: x["source"]) - - source_block = find_block_by_id(node["id"], blocks=blocks) - - for edge in outgoing_edges: - target_block = find_block_by_id(edge["target"], blocks=blocks) - output_index = get_output_index(source_block, edge) - input_index = get_input_index(target_block, edge, block_to_input_index) - - # if it's a scope, add labels if not already present - if isinstance(target_block, (Scope, Spectrum)): - if target_block.labels == []: - scopes_without_labels.append(target_block) - if target_block in scopes_without_labels: - label = node["data"]["label"] - if edge["sourceHandle"]: - label += f" ({edge['sourceHandle']})" - target_block.labels.append(label) - - connection = Connection( - source_block[output_index], - target_block[input_index], - ) - connections_pathsim.append(connection) - block_to_input_index[target_block] += 1 - - return connections_pathsim - - -def make_events(events_data: list[dict], eval_namespace: dict = None) -> list[Event]: - """ - Create a list of Event objects from the provided event data. - - Args: - events_data: A list of dictionaries containing event information. - eval_namespace: A namespace for evaluating expressions. Defaults to None. - - Returns: - A list of Event objects. - """ - if eval_namespace is None: - eval_namespace = globals() - - events = [] - for event_data in events_data: - event_type = event_data.get("type") - event_class = map_str_to_event.get(event_type) - - if not event_class: - raise ValueError(f"Unknown event type: {event_type}") - - event = auto_event_construction(event_data, eval_namespace) - events.append(event) - eval_namespace[event_data["name"]] = event - return events - - -def make_default_scope(nodes, blocks) -> tuple[Scope, list[Connection]]: - """ - Create a default Scope block that connects to all other blocks in the simulation. - - This function creates a default scope when no explicit scope exists in the graph, - ensuring that all block outputs are captured for visualization. - - Args: - nodes: List of node dictionaries containing block information (used for labels). - blocks: List of Block objects to connect to the default scope. - - Returns: - tuple: A tuple containing: - - scope_default (Scope): The created default Scope block - - connections_pathsim (list[Connection]): List of connections from blocks to the scope - """ - scope_default = Scope( - labels=[node["data"]["label"] for node in nodes], - ) - scope_default.id = "scope_default" - scope_default.label = "Default Scope" - - # Add connections to scope - connections_pathsim = [] - input_index = 0 - for block in blocks: - if block != scope_default: - connection = Connection( - block[0], - scope_default[input_index], - ) - connections_pathsim.append(connection) - input_index += 1 - - return scope_default, connections_pathsim - - -def make_var_name(node: dict) -> str: - """ - Create a variable name from the node label, ensuring it is a valid Python identifier. - If the label contains invalid characters, they are replaced with underscores. - If the variable name is not unique, a number is appended to make it unique. - - This is supposed to match the logic in NodeSidebar.jsx makeVarName function. - """ - # Make a variable name from the label - invalid_chars = set("!@#$%^&*()+=[]{}|;:'\",.-<>?/\\`~") - base_var_name = node["data"]["label"].lower().replace(" ", "_") - for char in invalid_chars: - base_var_name = base_var_name.replace(char, "") - - # Make the variable name unique by appending a number if needed - var_name = base_var_name - var_name = f"{base_var_name}_{node['id']}" - - # Ensure the base variable name is a valid identifier - if not var_name.isidentifier(): - var_name = f"var_{var_name}" - if not var_name.isidentifier(): - raise ValueError( - f"Variable name must be a valid identifier. {node['data']['label']} to {var_name}" - ) - - return var_name - - -def make_pathsim_model(graph_data: dict) -> tuple[Simulation, float]: - """ - Create a complete PathSim simulation model from graph data. - - This is the main function that orchestrates the creation of a PathSim simulation - from a graph representation. It processes nodes, edges, solver parameters, global - variables, events, and custom Python code to build a complete simulation model. - - Args: - graph_data: Dictionary containing the complete graph representation with keys: - - nodes: List of node dictionaries representing blocks - - edges: List of edge dictionaries representing connections - - solverParams: Dictionary of solver configuration parameters - - globalVariables: Dictionary of global variable definitions - - events: List of event dictionaries (optional) - - pythonCode: Custom Python code to execute (optional) - - Returns: - tuple: A tuple containing: - - simulation (Simulation): The configured PathSim Simulation object - - duration (float): The simulation duration - - Raises: - ValueError: If there are errors in processing any component of the graph data. - Exception: If custom Python code execution fails. - """ - nodes = graph_data.get("nodes", []) - edges = graph_data.get("edges", []) - solver_prms = graph_data.get("solverParams", {}) - global_vars = graph_data.get("globalVariables", {}) - - # Get the global variables namespace to use in eval calls - global_namespace = make_global_variables(global_vars) - - # Create a combined namespace that includes built-in functions and global variables - eval_namespace = globals().copy() - eval_namespace.update(global_namespace) - - # Execute python code first to define any variables that blocks might need - python_code = graph_data.get("pythonCode", "") - if python_code: - try: - exec(python_code, eval_namespace) - except Exception as e: - raise ValueError(f"Error executing custom Python code: {str(e)}") - - solver_prms, extra_params, duration = make_solver_params( - solver_prms, eval_namespace - ) - - # Create blocks - blocks, events = make_blocks(nodes, eval_namespace) - - connections_pathsim = make_connections(nodes, edges, blocks) - - # Add a Scope block if none exists - # This ensures that there is always a scope to collect outputs - if not any(isinstance(block, Scope) for block in blocks): - scope_default, connections_scope_def = make_default_scope(nodes, blocks) - blocks.append(scope_default) - connections_pathsim.extend(connections_scope_def) - - # Create additional events - for node in nodes: - var_name = make_var_name(node) - eval_namespace[var_name] = find_block_by_id(node["id"], blocks) - - events += make_events(graph_data.get("events", []), eval_namespace) - - # Create the simulation - simulation = Simulation( - blocks, - connections_pathsim, - events=events, - **solver_prms, # Unpack solver parameters - **extra_params, # Unpack extra parameters - ) - return simulation, duration diff --git a/src/python/templates/__init__.py b/src/python/templates/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/python/templates/block_macros.py b/src/python/templates/block_macros.py deleted file mode 100644 index 523daa90..00000000 --- a/src/python/templates/block_macros.py +++ /dev/null @@ -1,63 +0,0 @@ -{# Macro-based approach for block creation #} -{% macro create_block(node) -%} -{{ node["var_name"] }} = {{ node["module_name"] }}.{{ node["class_name"] }}( - {%- for arg in node["expected_arguments"] %} - {%- if node["data"].get(arg) -%} - {{ arg }}={{ node["data"].get(arg) }}{% if not loop.last %}, {% endif %} - {%- endif -%} - {%- endfor %} -) -{%- endmacro -%} - - -{% macro create_integrator_block(node) -%} -{{ create_block(node) }} - -{%- if node["data"].get("reset_times") %} -events_{{ node["var_name"] }} = {{ node["var_name"] }}.create_reset_events() -events += events_{{ node["var_name"] }} -{%- endif %} - -{%- endmacro -%} - - -{% macro create_bubbler_block(node) -%} -{{ create_block(node) }} - -{%- if node["data"].get("replacement_times") %} -{{ node["var_name"] }}._create_reset_events() -events += {{ node["var_name"] }}.events -{%- endif %} - -{%- endmacro -%} - - -{% macro create_connections(edges) -%} -connections = [ - {% for edge in edges -%} - Connection({{ edge["source_var_name"] }}{{edge["source_port"]}}, {{ edge["target_var_name"] }}{{ edge["target_port"] }}), - {% endfor -%} -] -{%- endmacro -%} - -{% macro create_event(event) -%} -{% if "func_evt" in event %} -{% if event["func_evt_desc"].startswith("def") %} -{{ event["func_evt_desc"] }} -{% endif %} -{% endif %} - -{% if "func_act" in event %} -{% if event["func_act_desc"].startswith("def") %} -{{ event["func_act_desc"] }} -{% endif %} -{% endif %} - -{{ event["name"] }} = {{ event["module_name"] }}.{{ event["class_name"] }}( - {%- for arg in event["expected_arguments"] %} - {%- if event.get(arg) -%} - {{ arg }}={{ event.get(arg) }}{% if not loop.last %}, {% endif %} - {%- endif -%} - {%- endfor %} -) -{%- endmacro -%} \ No newline at end of file diff --git a/src/python/templates/template_with_macros.py b/src/python/templates/template_with_macros.py deleted file mode 100644 index c1b4db3f..00000000 --- a/src/python/templates/template_with_macros.py +++ /dev/null @@ -1,80 +0,0 @@ -import pathsim -from pathsim import Simulation, Connection -import numpy as np -import matplotlib.pyplot as plt -import pathview -import pathsim_chem -{# Import macros #} -{% from 'block_macros.py' import create_block, create_integrator_block, create_bubbler_block, create_connections, create_event -%} - -# Create global variables - -{%- if pythonCode %} -{{ pythonCode }} -{% endif -%} - -{% for var in globalVariables -%} -{{ var["name"] }} = {{ var["value"] }} -{% endfor %} -# Create blocks -blocks, events = [], [] - -{% for node in nodes -%} -{%- if node["type"] == "integrator" -%} -{{ create_integrator_block(node) }} -{%- elif node["type"] == "bubbler" -%} -{{ create_bubbler_block(node) }} -{%- else -%} -{{ create_block(node) }} -{%- endif %} -blocks.append({{ node["var_name"] }}) - -{% endfor %} - -# Create events -{% for event in events -%} -{{ create_event(event) }} -events.append({{ event["name"] }}) -{% endfor %} - -# Create connections - -{{ create_connections(edges) }} - -# Create simulation -my_simulation = Simulation( - blocks, - connections, - events=events, - Solver=pathsim.solvers.{{ solverParams["Solver"] }}, - dt={{ solverParams["dt"] }}, - {%- if solverParams["dt_max"] != '' -%} - dt_max={{ solverParams["dt_max"] }}, - {%- endif -%} - {%- if solverParams["dt_min"] != '' -%} - dt_min={{ solverParams["dt_min"] }}, - {%- endif -%} - iterations_max={{ solverParams["iterations_max"] }}, - log={{ solverParams["log"].capitalize() }}, - tolerance_fpi={{ solverParams["tolerance_fpi"] }}, - {%- if solverParams["extra_params"] != '' -%} - **{{ solverParams["extra_params"] }}, - {%- endif -%} -) - -if __name__ == "__main__": - my_simulation.run({{ solverParams["simulation_duration"] }}) - - # Optional: Plotting results - scopes = [block for block in blocks if isinstance(block, pathsim.blocks.Scope)] - fig, axs = plt.subplots(nrows=len(scopes), sharex=True, figsize=(10, 5 * len(scopes))) - for i, scope in enumerate(scopes): - plt.sca(axs[i] if len(scopes) > 1 else axs) - time, data = scope.read() - # plot the recorded data - for p, d in enumerate(data): - lb = scope.labels[p] if p < len(scope.labels) else f"port {p}" - plt.plot(time, d, label=lb) - plt.legend() - plt.xlabel("Time") - plt.show() diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 00000000..f1251cc6 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,23 @@ + + +
        + {@render children()} +
        + + diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts new file mode 100644 index 00000000..9667019c --- /dev/null +++ b/src/routes/+layout.ts @@ -0,0 +1,5 @@ +// Enable static prerendering for all pages +export const prerender = true; + +// Use client-side rendering +export const ssr = false; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 00000000..7f21790b --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,1400 @@ + + + mousePosition = { x: e.clientX, y: e.clientY }} /> + + + PathView + + + + + + +
        + + + + +
        + +
        + + +
        + +
        + + + +
        triggerClearSelection()}> + +
        + {#if simRunning} + + {:else} + + {/if} + +
        + + +
        + + + + +
        + + +
        + +
        +
        + + + +
        triggerClearSelection()}> + + +
        + + + +
        + + +
        + + +
        + + +
        + +
        + + +
        + + +
        +
        + + + {#if showProperties} + showProperties = false} + > +
        + +
        +
        + {/if} + + + {#if showNodeLibrary} + showNodeLibrary = false} + onWidthChange={(w) => nodeLibraryWidth = Math.min(500, Math.max(280, w))} + > + + + {/if} + + + {#if showEventsPanel} + showEventsPanel = false} + onWidthChange={(w) => eventsPanelWidth = Math.min(400, Math.max(200, w))} + > + + + {/if} + + + {#if showCodeEditor} + showCodeEditor = false} + onWidthChange={(w) => codeEditorWidth = Math.min(700, Math.max(300, w))} + > + + + {/if} + + + {#if showPlot} + showPlot = false} + > + {#snippet header()} + Results + {#if resultPlots.length > 0 && plotViewMode === 'tabs'} +
        + {#each resultPlots as plot, i} + + {/each} +
        + {/if} + {/snippet} + {#snippet actions()} + + {#if resultPlots.length > 1} + + {/if} + {/snippet} + +
        + {/if} + + + {#if showConsole} + showConsole = false} + > + {#snippet header()} + Console + {/snippet} + {#snippet actions()} + + {/snippet} + + + {/if} + + + {#if contextMenuOpen} + contextMenuStore.close()} + /> + {/if} + + + exportDialogOpen = false} /> + + + showKeyboardShortcuts = false} /> + showSearchDialog = false} /> + + + + + + + + + codePreviewStore.close()} + /> + + + + + + {#if showWelcomeModal} + newGraph()} + onOpen={handleOpen} + onLoadExample={handleLoadExample} + onClose={() => showWelcomeModal = false} + /> + {/if} +
        + + diff --git a/src/styles/App.css b/src/styles/App.css deleted file mode 100644 index 6505ab52..00000000 --- a/src/styles/App.css +++ /dev/null @@ -1,491 +0,0 @@ -/* For customizing the controls icons */ -.react-flow__controls { - padding: 6px; - display: flex; -} - -.react-flow__controls-button { - background-color: #007bff !important; - color: white !important; - border: none; -} - -.react-flow__controls-button:hover { - background-color: #0056b3 !important; -} - -/* Node hover effects */ -.react-flow__node:hover > div { - border: 2px solid #78A083 !important; - box-shadow: 0 0 8px #78a08366; -} - -/* Default border for divs */ -.react-flow__node > div { - border: 2px solid #ccc !important; -} - - -/* App container */ -.app { - width: 100vw; - height: 100vh; - display: flex; - flex-direction: column; -} - -/* Top bar */ -.top-bar { - height: 50px; - background: #2c2c2c; - display: flex; - align-items: center; - justify-content: space-between; - z-index: 15; - border-bottom: 1px solid #ccc; -} - -/* Tab button base */ -.tab-btn { - padding: 10px 20px; - margin: 5px; - background-color: #444; - color: #fff; - border: none; - border-radius: 5px; - cursor: pointer; -} - -/* Tab button active state */ -.tab-btn.active { - background-color: #78A083; -} - - -/* Context menu styles */ -.context-menu { - position: absolute; - background: rgb(73, 71, 71); - border: 1px solid #ccc; - border-radius: 4px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); - padding: 4px 0; - z-index: 1000; - min-width: 120px; -} - -.context-menu button { - display: block; - width: 100%; - padding: 8px 16px; - border: none; - background: transparent; - text-align: left; - cursor: pointer; - font-size: 14px; -} - -.context-menu button:hover { - background-color: #f0f0f06c; -} - -.context-menu p { - margin: 0.5em; - font-size: 12px; - color: #666; -} - -/* Documentation HTML rendering styles for dark theme */ -.documentation-content { - color: #e8e8e8; -} - -.documentation-content p { - margin: 0.5em 0; - line-height: 1.4; -} - -.documentation-content h1, -.documentation-content h2, -.documentation-content h3, -.documentation-content h4, -.documentation-content h5, -.documentation-content h6 { - color: #ffffff; - margin: 1em 0 0.5em 0; - font-weight: bold; -} - -.documentation-content h2 { - font-size: 1.1em; - border-bottom: 1px solid #555; - padding-bottom: 0.2em; -} - -.documentation-content h3 { - font-size: 1.05em; -} - -.documentation-content pre { - background-color: #1a1a2e; - border: 1px solid #444; - border-radius: 3px; - padding: 0.8em; - overflow-x: auto; - font-family: 'Courier New', Consolas, monospace; - font-size: 0.9em; - margin: 0.5em 0; -} - -.documentation-content code { - background-color: #1a1a2e; - padding: 0.1em 0.3em; - border-radius: 2px; - font-family: 'Courier New', Consolas, monospace; - font-size: 0.9em; - color: #ffd700; -} - -.documentation-content pre code { - background-color: transparent; - padding: 0; - color: #e8e8e8; -} - -.documentation-content ul, -.documentation-content ol { - margin: 0.5em 0; - padding-left: 1.5em; -} - -.documentation-content li { - margin: 0.2em 0; -} - -.documentation-content blockquote { - border-left: 3px solid #555; - padding-left: 1em; - margin: 0.5em 0; - font-style: italic; - color: #ccc; -} - -.documentation-content table { - border-collapse: collapse; - width: 100%; - margin: 0.5em 0; -} - -.documentation-content th, -.documentation-content td { - border: 1px solid #555; - padding: 0.4em; - text-align: left; -} - -.documentation-content th { - background-color: #333; - font-weight: bold; -} - -.documentation-content em { - font-style: italic; - color: #ddd; -} - -.documentation-content strong { - font-weight: bold; - color: #ffffff; -} - -.documentation-content a { - color: #78A083; - text-decoration: underline; -} - -.documentation-content a:hover { - color: #9bc49f; -} - -/* Docutils-specific classes */ -.documentation-content .field-list { - margin: 0.5em 0; -} - -.documentation-content .field-name { - font-weight: bold; - color: #ffffff; -} - -.documentation-content .field-body { - margin-left: 1em; -} - -.documentation-content .literal { - background-color: #1a1a2e; - padding: 0.1em 0.3em; - border-radius: 2px; - font-family: 'Courier New', Consolas, monospace; - color: #ffd700; -} - -.documentation-content .note, -.documentation-content .warning, -.documentation-content .tip { - background-color: #2a2a3e; - border-left: 4px solid #78A083; - padding: 0.8em; - margin: 0.5em 0; - border-radius: 0 3px 3px 0; -} - -.documentation-content .warning { - border-left-color: #e74c3c; -} - -.documentation-content .note .first, -.documentation-content .warning .first, -.documentation-content .tip .first { - margin-top: 0; -} - -.documentation-content .note .last, -.documentation-content .warning .last, -.documentation-content .tip .last { - margin-bottom: 0; -} - -/* Custom scrollbar styles for sidebar */ -.sidebar-scrollable { - scrollbar-width: thin; - scrollbar-color: #555 #1e1e2f; -} - -.sidebar-scrollable::-webkit-scrollbar { - width: 8px; -} - -.sidebar-scrollable::-webkit-scrollbar-track { - background: #1e1e2f; - border-radius: 4px; -} - -.sidebar-scrollable::-webkit-scrollbar-thumb { - background: #555; - border-radius: 4px; - border: 1px solid #1e1e2f; -} - -.sidebar-scrollable::-webkit-scrollbar-thumb:hover { - background: #666; -} - -/* Smooth scrolling for the sidebar */ -.sidebar-scrollable { - scroll-behavior: smooth; -} - -/* Sidebar and drag-and-drop styles */ -.dndflow { - display: flex; - height: 100%; -} - -.reactflow-wrapper { - flex: 1; - height: 100%; -} - -aside { - color: #ffffff; - font-size: 14px; -} - -aside .description { - margin-bottom: 20px; - padding: 10px; - background-color: #2c2c2c; - border-radius: 5px; - color: #ffffff; - text-align: center; - font-size: 12px; -} - -.dndnode { - height: 15px; - padding: 10px; - border: 1px solid #78A083; - border-radius: 5px; - margin-bottom: 10px; - display: flex; - justify-content: center; - align-items: center; - cursor: grab; - background-color: #444; - color: #ffffff; - font-weight: 500; - transition: background-color 0.2s ease; -} - -.dndnode:hover { - background-color: #78A083; -} - -.dndnode.input { - background-color: #3498db; -} - -.dndnode.input:hover { - background-color: #2980b9; -} - -.dndnode.output { - background-color: #e74c3c; -} - -.dndnode.output:hover { - background-color: #c0392b; -} - -/* Ensure draggable node text is always visible */ -.dndnode { - color: #ffffff !important; - font-weight: 500 !important; - text-align: left !important; - line-height: 1.4 !important; - background-color: #2a2a3e !important; - border: 1px solid #555 !important; -} - -/* Override any inherited styles that might hide text */ -.dndnode * { - color: inherit !important; -} - -/* Improve readability */ -.dndnode:hover { - color: #ffffff !important; - background-color: #3a3a4e !important; -} - -.dndnode:active { - color: #ffffff !important; -} - -/* Ensure category descriptions are readable */ -.sidebar-description { - color: #aaaaaa !important; - font-size: 11px !important; - margin-bottom: 8px !important; - font-style: italic !important; -} - -/* Sidebar node category styles */ -.dndnode.math { - border-left: 4px solid #17a2b8 !important; - background-color: #1a2a3a !important; -} - -.dndnode.control { - border-left: 4px solid #6f42c1 !important; - background-color: #2a1f3d !important; -} - -.dndnode.fuel-cycle { - border-left: 4px solid #fd7e14 !important; - background-color: #3a2a1a !important; -} - -.dndnode.filters { - border-left: 4px solid #20c997 !important; - background-color: #1a3a2f !important; -} - -.dndnode.others { - border-left: 4px solid #ffc107 !important; - background-color: #3a3a1a !important; -} - -.dndnode.input { - border-left: 4px solid #28a745 !important; - background-color: #1a2f1a !important; -} - -.dndnode.output { - border-left: 4px solid #dc3545 !important; - background-color: #3a1a1a !important; -} - -.dndnode.processing { - border-left: 4px solid #007bff !important; - background-color: #1a1a3a !important; -} - -/* Hover effects for category headers */ -.category-header:hover { - background-color: #3c3c64 !important; -} - -/* Scrollbar styling for sidebar */ -aside::-webkit-scrollbar { - width: 6px; -} - -aside::-webkit-scrollbar-track { - background: #2c2c54; - border-radius: 3px; -} - -aside::-webkit-scrollbar-thumb { - background: #555; - border-radius: 3px; -} - -aside::-webkit-scrollbar-thumb:hover { - background: #666; -} - -/* Sidebar transition styles - inspired by strudel-flow */ -.sidebar-container { - transition: width 0.8s ease; -} - -.sidebar-container[data-sidebar-state="collapsed"] { - width: 0 !important; -} - -.sidebar-container[data-sidebar-state="expanded"] { - width: 250px !important; -} - -/* Sidebar trigger button - strudel-flow style */ -.sidebar-trigger { - /* Ghost button variant */ - background-color: transparent !important; - border: none; - border-radius: 4px; - transition: background-color 0.2s ease, color 0.2s ease; - position: relative; -} - -.sidebar-trigger:hover { - background-color: rgba(255, 255, 255, 0.1) !important; -} - -.sidebar-trigger svg { - color: #ffffff; - transition: color 0.2s ease; -} - -.sidebar-trigger:hover svg { - color: #ffffff !important; -} - -.sidebar-trigger:focus { - outline: 2px solid #3b82f6; - outline-offset: 2px; -} - -.sidebar-trigger:active { - scale: 0.95; -} \ No newline at end of file diff --git a/src/styles/PythonCodeEditor.css b/src/styles/PythonCodeEditor.css deleted file mode 100644 index 8a753e25..00000000 --- a/src/styles/PythonCodeEditor.css +++ /dev/null @@ -1,138 +0,0 @@ -/* Python Code Editor Styles */ -.python-code-editor { - border: 1px solid #333; - border-radius: 8px; - background: #1e1e1e; - color: #d4d4d4; - margin: 10px 0; -} - -.editor-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px 15px; - background: #2d2d30; - border-bottom: 1px solid #333; - border-radius: 8px 8px 0 0; -} - -.editor-header h3 { - margin: 0; - color: #d4d4d4; - font-size: 16px; - font-weight: 500; -} - -.execute-btn { - background: #007acc; - color: white; - border: none; - padding: 8px 16px; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - transition: background-color 0.2s; -} - -.execute-btn:hover:not(:disabled) { - background: #005a9e; -} - -.execute-btn:disabled { - background: #555; - cursor: not-allowed; -} - -.editor-container { - border-radius: 0 0 8px 8px; - overflow: hidden; - position: relative; -} - -.editor-container .cm-editor { - height: 100% !important; - overflow: auto !important; -} - -.editor-container .cm-scroller { - overflow: auto !important; - max-height: 100% !important; -} - -.editor-container .cm-content { - padding: 8px 0; - min-height: 100% !important; -} - -.execution-result { - margin-top: 10px; - padding: 15px; - border-radius: 4px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; -} - -.execution-result.success { - background: #1e3a1e; - border: 1px solid #4caf50; -} - -.execution-result.error { - background: #3a1e1e; - border: 1px solid #f44336; -} - -.result-header { - margin-bottom: 10px; - font-weight: bold; -} - -.output pre { - background: #2d2d30; - padding: 10px; - border-radius: 4px; - margin: 5px 0; - white-space: pre-wrap; - word-wrap: break-word; -} - -.variables ul, -.functions ul { - list-style: none; - padding: 0; - margin: 5px 0; -} - -.variables li, -.functions li { - background: #2d2d30; - padding: 5px 10px; - margin: 2px 0; - border-radius: 3px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; -} - -.variables code, -.functions code { - color: #9cdcfe; - background: transparent; -} - -.error-message pre { - background: #3a1e1e; - padding: 10px; - border-radius: 4px; - margin: 5px 0; - white-space: pre-wrap; - word-wrap: break-word; - color: #ff9999; -} - -/* Dark theme adjustments */ -.python-code-editor .monaco-editor { - background: #1e1e1e !important; -} - -.python-code-editor .monaco-editor .margin { - background: #1e1e1e !important; -} diff --git a/src/styles/index.css b/src/styles/index.css deleted file mode 100644 index 08a3ac9e..00000000 --- a/src/styles/index.css +++ /dev/null @@ -1,68 +0,0 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index e511ffbf..00000000 --- a/src/utils.js +++ /dev/null @@ -1,25 +0,0 @@ -// Functions for managing global variables -const isValidPythonIdentifier = (name) => { - // Check if name is empty - if (!name) return false; - - // Python identifier rules: - // - Must start with letter or underscore - // - Can contain letters, digits, underscores - // - Cannot be a Python keyword - const pythonKeywords = [ - 'False', 'None', 'True', 'and', 'as', 'assert', 'break', 'class', 'continue', - 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', - 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', - 'raise', 'return', 'try', 'while', 'with', 'yield' - ]; - - // Check if it's a keyword - if (pythonKeywords.includes(name)) return false; - - // Check pattern: must start with letter or underscore, followed by letters, digits, or underscores - const pattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/; - return pattern.test(name); -}; - -export { isValidPythonIdentifier }; \ No newline at end of file diff --git a/src/utils/urlSharing.js b/src/utils/urlSharing.js deleted file mode 100644 index cc1d9454..00000000 --- a/src/utils/urlSharing.js +++ /dev/null @@ -1,186 +0,0 @@ -/** - * URL sharing utilities for PathView - * Handles encoding and decoding graph data in URLs - */ -import { compressToBase64, decompressFromBase64 } from 'lz-string'; - -// Maximum safe URL length for most servers (conservative estimate) -const MAX_SAFE_URL_LENGTH = 4000; - -/** - * Encode graph data to a compressed base64 URL parameter - * @param {Object} graphData - The complete graph data object - * @returns {string} - Compressed base64 encoded string - */ -export function encodeGraphData(graphData) { - try { - const jsonString = JSON.stringify(graphData); - // Use lz-string for much better compression than manual whitespace removal - return compressToBase64(jsonString); - } catch (error) { - console.error('Error encoding graph data:', error); - return null; - } -}/** - * Decode graph data from a compressed base64 URL parameter - * @param {string} encodedData - Compressed base64 encoded graph data - * @returns {Object|null} - Decoded graph data object or null if error - */ -export function decodeGraphData(encodedData) { - try { - // First try lz-string decompression (new format) - const jsonString = decompressFromBase64(encodedData); - if (jsonString) { - return JSON.parse(jsonString); - } - - // Fallback for old format (manual base64 encoding) - try { - const binaryString = atob(encodedData); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - const oldJsonString = new TextDecoder().decode(bytes); - return JSON.parse(oldJsonString); - } catch (oldFormatError) { - console.warn('Could not decode with old format either:', oldFormatError); - } - - return null; - } catch (error) { - console.error('Error decoding graph data:', error); - return null; - } -} - -/** - * Generate a shareable URL with the current graph data - * @param {Object} graphData - The complete graph data object - * @returns {Object} - Object with url and metadata about the URL - */ -export function generateShareableURL(graphData) { - try { - const encodedData = encodeGraphData(graphData); - if (!encodedData) { - throw new Error('Failed to encode graph data'); - } - - const baseURL = window.location.origin + window.location.pathname; - const url = new URL(baseURL); - url.searchParams.set('graph', encodedData); - - const finalURL = url.toString(); - - return { - url: finalURL, - length: finalURL.length, - isSafe: finalURL.length <= MAX_SAFE_URL_LENGTH, - maxLength: MAX_SAFE_URL_LENGTH - }; - } catch (error) { - console.error('Error generating shareable URL:', error); - return null; - } -}/** - * Extract graph data from current URL parameters - * @returns {Object|null} - Graph data object or null if not found/error - */ -export function getGraphDataFromURL() { - try { - const urlParams = new URLSearchParams(window.location.search); - const encodedData = urlParams.get('graph'); - - if (!encodedData) { - return null; - } - - return decodeGraphData(encodedData); - } catch (error) { - console.error('Error extracting graph data from URL:', error); - return null; - } -} - -/** - * Update the current URL with graph data without page reload - * @param {Object} graphData - The complete graph data object - * @param {boolean} replaceState - Whether to replace current history state (default: false) - */ -export function updateURLWithGraphData(graphData, replaceState = false) { - try { - const urlResult = generateShareableURL(graphData); - if (urlResult && urlResult.isSafe) { - if (replaceState) { - window.history.replaceState({}, '', urlResult.url); - } else { - window.history.pushState({}, '', urlResult.url); - } - } else if (urlResult) { - console.warn(`URL too long (${urlResult.length} chars), not updating browser URL`); - } - } catch (error) { - console.error('Error updating URL with graph data:', error); - } -}/** - * Clear graph data from URL without page reload - */ -export function clearGraphDataFromURL() { - try { - const baseURL = window.location.origin + window.location.pathname; - window.history.replaceState({}, '', baseURL); - } catch (error) { - console.error('Error clearing graph data from URL:', error); - } -} - -/** - * Copy shareable URL to clipboard - * @param {Object} graphData - The complete graph data object - * @returns {Promise} - Result object with success status and metadata - */ -export async function copyShareableURLToClipboard(graphData) { - try { - const urlResult = generateShareableURL(graphData); - if (!urlResult) { - throw new Error('Failed to generate shareable URL'); - } - - await navigator.clipboard.writeText(urlResult.url); - return { - success: true, - isSafe: urlResult.isSafe, - length: urlResult.length, - maxLength: urlResult.maxLength, - url: urlResult.url - }; - } catch (error) { - console.error('Error copying to clipboard:', error); - // Fallback for older browsers - try { - const urlResult = generateShareableURL(graphData); - const textArea = document.createElement('textarea'); - textArea.value = urlResult.url; - document.body.appendChild(textArea); - textArea.select(); - document.execCommand('copy'); - document.body.removeChild(textArea); - return { - success: true, - isSafe: urlResult.isSafe, - length: urlResult.length, - maxLength: urlResult.maxLength, - url: urlResult.url - }; - } catch (fallbackError) { - console.error('Clipboard fallback failed:', fallbackError); - return { - success: false, - isSafe: false, - length: 0, - maxLength: MAX_SAFE_URL_LENGTH, - url: null - }; - } - } -} diff --git a/static/examples/bouncing-ball.json b/static/examples/bouncing-ball.json new file mode 100644 index 00000000..9048c412 --- /dev/null +++ b/static/examples/bouncing-ball.json @@ -0,0 +1,291 @@ +{ + "version": "1.0.0", + "metadata": { + "created": "2025-12-22T23:21:53.588Z", + "modified": "2025-12-22T23:21:53.588Z", + "name": "Bouncing Ball", + "description": "Gravity and collision events" + }, + "graph": { + "nodes": [ + { + "id": "1642f372-0929-4a73-a786-11a5a9b2ce0b", + "type": "Integrator", + "name": "pos", + "position": { + "x": 705, + "y": 255 + }, + "inputs": [ + { + "id": "1642f372-0929-4a73-a786-11a5a9b2ce0b-input-0", + "nodeId": "1642f372-0929-4a73-a786-11a5a9b2ce0b", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "1642f372-0929-4a73-a786-11a5a9b2ce0b-output-0", + "nodeId": "1642f372-0929-4a73-a786-11a5a9b2ce0b", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "initial_value": "x0 " + } + }, + { + "id": "d6b8ebf1-173b-4f71-a160-d86545a80cb9", + "type": "Integrator", + "name": "vel", + "position": { + "x": 480, + "y": 255 + }, + "inputs": [ + { + "id": "d6b8ebf1-173b-4f71-a160-d86545a80cb9-input-0", + "nodeId": "d6b8ebf1-173b-4f71-a160-d86545a80cb9", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "d6b8ebf1-173b-4f71-a160-d86545a80cb9-output-0", + "nodeId": "d6b8ebf1-173b-4f71-a160-d86545a80cb9", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": {} + }, + { + "id": "c76c21f3-21cd-453e-9437-8e8af946d1c7", + "type": "Scope", + "name": "x(t)", + "position": { + "x": 825, + "y": 330 + }, + "inputs": [ + { + "id": "c76c21f3-21cd-453e-9437-8e8af946d1c7-input-0", + "nodeId": "c76c21f3-21cd-453e-9437-8e8af946d1c7", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "_rotation": 1 + } + }, + { + "id": "49a2f6d3-5327-485a-8f1b-b98a3d425a57", + "type": "Constant", + "name": "gravity", + "position": { + "x": 390, + "y": 150 + }, + "inputs": [], + "outputs": [ + { + "id": "49a2f6d3-5327-485a-8f1b-b98a3d425a57-output-0", + "nodeId": "49a2f6d3-5327-485a-8f1b-b98a3d425a57", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "value": "-g", + "_rotation": 1 + } + }, + { + "id": "d73713d6-eb61-442e-a69b-c4d168180ef4", + "type": "Scope", + "name": "v(t)", + "position": { + "x": 585, + "y": 330 + }, + "inputs": [ + { + "id": "d73713d6-eb61-442e-a69b-c4d168180ef4-input-0", + "nodeId": "d73713d6-eb61-442e-a69b-c4d168180ef4", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "_rotation": 1 + } + }, + { + "id": "db488fa8-9004-40f7-a724-bc9846dd2f85", + "type": "Function", + "name": "total energy", + "position": { + "x": 705, + "y": 150 + }, + "inputs": [ + { + "id": "db488fa8-9004-40f7-a724-bc9846dd2f85-input-0", + "nodeId": "db488fa8-9004-40f7-a724-bc9846dd2f85", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + }, + { + "id": "db488fa8-9004-40f7-a724-bc9846dd2f85-input-1", + "nodeId": "db488fa8-9004-40f7-a724-bc9846dd2f85", + "name": "in 1", + "direction": "input", + "index": 1, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "db488fa8-9004-40f7-a724-bc9846dd2f85-output-0", + "nodeId": "db488fa8-9004-40f7-a724-bc9846dd2f85", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "func": "lambda v, x: m*(v**2/2 + g*x)", + "_rotation": 0 + } + }, + { + "id": "78f720fe-8774-42c3-be6b-5a4ef30e00df", + "type": "Scope", + "name": "E(t)", + "position": { + "x": 1065, + "y": 330 + }, + "inputs": [ + { + "id": "78f720fe-8774-42c3-be6b-5a4ef30e00df-input-0", + "nodeId": "78f720fe-8774-42c3-be6b-5a4ef30e00df", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "_rotation": 1 + } + } + ], + "connections": [ + { + "id": "0d6afb9a-6b40-4876-8f3d-7581eada74b2", + "sourceNodeId": "49a2f6d3-5327-485a-8f1b-b98a3d425a57", + "sourcePortIndex": 0, + "targetNodeId": "d6b8ebf1-173b-4f71-a160-d86545a80cb9", + "targetPortIndex": 0 + }, + { + "id": "c7eb09ad-9d1e-474b-8b7b-c80d397bc6dd", + "sourceNodeId": "d6b8ebf1-173b-4f71-a160-d86545a80cb9", + "sourcePortIndex": 0, + "targetNodeId": "1642f372-0929-4a73-a786-11a5a9b2ce0b", + "targetPortIndex": 0 + }, + { + "id": "3c8a8e3c-794f-4c56-a6f8-334e03104190", + "sourceNodeId": "1642f372-0929-4a73-a786-11a5a9b2ce0b", + "sourcePortIndex": 0, + "targetNodeId": "c76c21f3-21cd-453e-9437-8e8af946d1c7", + "targetPortIndex": 0 + }, + { + "id": "a789aac8-e368-4894-b2a9-0637d7bef482", + "sourceNodeId": "d6b8ebf1-173b-4f71-a160-d86545a80cb9", + "sourcePortIndex": 0, + "targetNodeId": "d73713d6-eb61-442e-a69b-c4d168180ef4", + "targetPortIndex": 0 + }, + { + "id": "0a585f0c-93fb-4fb2-ade0-0843d5ad15a4", + "sourceNodeId": "d6b8ebf1-173b-4f71-a160-d86545a80cb9", + "sourcePortIndex": 0, + "targetNodeId": "db488fa8-9004-40f7-a724-bc9846dd2f85", + "targetPortIndex": 0 + }, + { + "id": "b7bb9b2f-06ea-4bfa-a739-1ded869eeef6", + "sourceNodeId": "1642f372-0929-4a73-a786-11a5a9b2ce0b", + "sourcePortIndex": 0, + "targetNodeId": "db488fa8-9004-40f7-a724-bc9846dd2f85", + "targetPortIndex": 1 + }, + { + "id": "e2c930e9-e3a1-46bb-8ac9-0061b4530440", + "sourceNodeId": "db488fa8-9004-40f7-a724-bc9846dd2f85", + "sourcePortIndex": 0, + "targetNodeId": "78f720fe-8774-42c3-be6b-5a4ef30e00df", + "targetPortIndex": 0 + } + ] + }, + "events": [ + { + "id": "1455e789-9e7d-4139-8a4e-896feecff3d3", + "type": "pathsim.events.ZeroCrossing", + "name": "Bounce", + "position": { + "x": 960, + "y": 225 + }, + "params": { + "func_evt": "bounce_detect", + "func_act": "bounce_resolve" + } + } + ], + "codeContext": { + "code": "# initial position \nx0 = 1\n\n# mass\nm = 1\n\n# gravity\ng = 9.81\n\n# bounce back coefficient (randomized)\nb = 0.85 + 0.1*np.random.rand()\n\n# event detection function\ndef bounce_detect(t):\n return pos.engine.get()\n\n# event resolution function\ndef bounce_resolve(t):\n pos.engine.set(-pos.engine.get())\n vel.engine.set(-b*vel.engine.get())" + }, + "simulationSettings": { + "duration": "5", + "dt": "", + "solver": "RKBS32", + "adaptive": true, + "atol": "", + "rtol": "", + "ftol": "", + "dt_min": "", + "dt_max": "0.05", + "ghostTraces": 6, + "plotResults": true + } +} diff --git a/static/examples/cascade-subsystem.json b/static/examples/cascade-subsystem.json new file mode 100644 index 00000000..81973bba --- /dev/null +++ b/static/examples/cascade-subsystem.json @@ -0,0 +1,734 @@ +{ + "version": "1.0.0", + "metadata": { + "created": "2025-12-23T14:57:43.382Z", + "modified": "2025-12-23T14:57:43.382Z", + "name": "Cascade Control", + "description": "Nested PID control loops" + }, + "graph": { + "nodes": [ + { + "id": "7c855194-3fcf-4d00-acbd-0e3051f5703c", + "type": "StepSource", + "name": "Setpoint", + "position": { + "x": 735, + "y": 435 + }, + "inputs": [], + "outputs": [ + { + "id": "7c855194-3fcf-4d00-acbd-0e3051f5703c-output-0", + "nodeId": "7c855194-3fcf-4d00-acbd-0e3051f5703c", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "amplitude": "[1.0, 0.5]", + "tau": "[20, 60]", + "_rotation": 0 + } + }, + { + "id": "f7ffd92f-16eb-4760-8285-8ed7fafc90fd", + "type": "PID", + "name": "PI-1", + "position": { + "x": 1065, + "y": 330 + }, + "inputs": [ + { + "id": "f7ffd92f-16eb-4760-8285-8ed7fafc90fd-input-0", + "nodeId": "f7ffd92f-16eb-4760-8285-8ed7fafc90fd", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "f7ffd92f-16eb-4760-8285-8ed7fafc90fd-output-0", + "nodeId": "f7ffd92f-16eb-4760-8285-8ed7fafc90fd", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "Kp": "0.015", + "Ki": "0.015/0.716", + "Kd": "0", + "f_max": "10" + } + }, + { + "id": "b449fb93-54e1-4cfd-862b-cbb66d919d91", + "type": "Adder", + "name": "Error-1", + "position": { + "x": 915, + "y": 330 + }, + "inputs": [ + { + "id": "b449fb93-54e1-4cfd-862b-cbb66d919d91-input-0", + "nodeId": "b449fb93-54e1-4cfd-862b-cbb66d919d91", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + }, + { + "id": "b449fb93-54e1-4cfd-862b-cbb66d919d91-input-1", + "nodeId": "b449fb93-54e1-4cfd-862b-cbb66d919d91", + "name": "in 1", + "direction": "input", + "index": 1, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "b449fb93-54e1-4cfd-862b-cbb66d919d91-output-0", + "nodeId": "b449fb93-54e1-4cfd-862b-cbb66d919d91", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "operations": "\"+-\"", + "_rotation": 0 + } + }, + { + "id": "c5fc3645-c55b-4478-8125-665418379764", + "type": "Adder", + "name": "Error-2", + "position": { + "x": 1245, + "y": 330 + }, + "inputs": [ + { + "id": "c5fc3645-c55b-4478-8125-665418379764-input-0", + "nodeId": "c5fc3645-c55b-4478-8125-665418379764", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + }, + { + "id": "c5fc3645-c55b-4478-8125-665418379764-input-1", + "nodeId": "c5fc3645-c55b-4478-8125-665418379764", + "name": "in 1", + "direction": "input", + "index": 1, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "c5fc3645-c55b-4478-8125-665418379764-output-0", + "nodeId": "c5fc3645-c55b-4478-8125-665418379764", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "operations": "\"+-\"", + "_rotation": 0 + } + }, + { + "id": "096ba23a-fd02-43ca-aabe-92a83db7b610", + "type": "PID", + "name": "PI-2", + "position": { + "x": 1395, + "y": 330 + }, + "inputs": [ + { + "id": "096ba23a-fd02-43ca-aabe-92a83db7b610-input-0", + "nodeId": "096ba23a-fd02-43ca-aabe-92a83db7b610", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "096ba23a-fd02-43ca-aabe-92a83db7b610-output-0", + "nodeId": "096ba23a-fd02-43ca-aabe-92a83db7b610", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "Kp": "0.244", + "Ki": "0.244/0.134", + "Kd": "0", + "f_max": "10" + } + }, + { + "id": "40ac8e75-a709-4434-9001-63bc1d73106c", + "type": "Subsystem", + "name": "Plant", + "position": { + "x": 1320, + "y": 405 + }, + "inputs": [ + { + "id": "40ac8e75-a709-4434-9001-63bc1d73106c-input-0", + "nodeId": "40ac8e75-a709-4434-9001-63bc1d73106c", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "40ac8e75-a709-4434-9001-63bc1d73106c-output-0", + "nodeId": "40ac8e75-a709-4434-9001-63bc1d73106c", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + }, + { + "id": "40ac8e75-a709-4434-9001-63bc1d73106c-output-1", + "nodeId": "40ac8e75-a709-4434-9001-63bc1d73106c", + "name": "out 1", + "direction": "output", + "index": 1, + "color": "#969696" + } + ], + "params": { + "_rotation": 2 + }, + "graph": { + "nodes": [ + { + "id": "518d7610-e488-4ff5-8591-86b7cff65e85", + "type": "Interface", + "name": "Subsystem", + "position": { + "x": -285, + "y": -360 + }, + "inputs": [], + "outputs": [], + "params": { + "_rotation": 3 + } + }, + { + "id": "4154466b-5a0b-42f7-bb32-27e649d59245", + "type": "TransferFunctionZPG", + "name": "TF-2", + "position": { + "x": -420, + "y": -105 + }, + "inputs": [ + { + "id": "4154466b-5a0b-42f7-bb32-27e649d59245-input-0", + "nodeId": "4154466b-5a0b-42f7-bb32-27e649d59245", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "4154466b-5a0b-42f7-bb32-27e649d59245-output-0", + "nodeId": "4154466b-5a0b-42f7-bb32-27e649d59245", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "Zeros": "[]", + "Poles": "[-1, -1, -1]", + "Gain": "10", + "_rotation": 1 + } + }, + { + "id": "ee1f9a36-e30e-47db-bd17-e81b7afa7f54", + "type": "TransferFunctionZPG", + "name": "TF-1", + "position": { + "x": -420, + "y": -285 + }, + "inputs": [ + { + "id": "ee1f9a36-e30e-47db-bd17-e81b7afa7f54-input-0", + "nodeId": "ee1f9a36-e30e-47db-bd17-e81b7afa7f54", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "ee1f9a36-e30e-47db-bd17-e81b7afa7f54-output-0", + "nodeId": "ee1f9a36-e30e-47db-bd17-e81b7afa7f54", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "Zeros": "[]", + "Poles": "[-2.0]", + "Gain": "3", + "_rotation": 1 + } + }, + { + "id": "f8e5d812-f712-4adf-898a-00236bd2e8b6", + "type": "Subsystem", + "name": "Disturbance", + "position": { + "x": -405, + "y": -195 + }, + "inputs": [ + { + "id": "f8e5d812-f712-4adf-898a-00236bd2e8b6-input-0", + "nodeId": "f8e5d812-f712-4adf-898a-00236bd2e8b6", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "f8e5d812-f712-4adf-898a-00236bd2e8b6-output-0", + "nodeId": "f8e5d812-f712-4adf-898a-00236bd2e8b6", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "_rotation": 0 + }, + "graph": { + "nodes": [ + { + "id": "102c4435-f834-44ab-977d-901bf535b780", + "type": "Interface", + "name": "Subsystem", + "position": { + "x": 105, + "y": 60 + }, + "inputs": [], + "outputs": [], + "params": {} + }, + { + "id": "09e7895b-f3f5-448a-a5c7-14f8c9225db4", + "type": "Adder", + "name": "Combiner", + "position": { + "x": 240, + "y": 120 + }, + "inputs": [ + { + "id": "09e7895b-f3f5-448a-a5c7-14f8c9225db4-input-0", + "nodeId": "09e7895b-f3f5-448a-a5c7-14f8c9225db4", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + }, + { + "id": "09e7895b-f3f5-448a-a5c7-14f8c9225db4-input-1", + "nodeId": "09e7895b-f3f5-448a-a5c7-14f8c9225db4", + "name": "in 1", + "direction": "input", + "index": 1, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "09e7895b-f3f5-448a-a5c7-14f8c9225db4-output-0", + "nodeId": "09e7895b-f3f5-448a-a5c7-14f8c9225db4", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "operations": null, + "_rotation": 1 + } + }, + { + "id": "be9bfd71-6059-4d41-be9d-faa2a73e9f61", + "type": "WhiteNoise", + "name": "Noise", + "position": { + "x": 255, + "y": 0 + }, + "inputs": [], + "outputs": [ + { + "id": "be9bfd71-6059-4d41-be9d-faa2a73e9f61-output-0", + "nodeId": "be9bfd71-6059-4d41-be9d-faa2a73e9f61", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "spectral_density": "noise_psd", + "sampling_rate": null, + "_rotation": 1 + } + }, + { + "id": "44861add-91bd-4091-bebe-f89b308c9c45", + "type": "Scope", + "name": "Disturbed Signal", + "position": { + "x": 345, + "y": 210 + }, + "inputs": [ + { + "id": "44861add-91bd-4091-bebe-f89b308c9c45-input-0", + "nodeId": "44861add-91bd-4091-bebe-f89b308c9c45", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "_rotation": 0 + } + } + ], + "connections": [ + { + "id": "dd03b251-27bd-4783-99eb-007bbc84ce6c", + "sourceNodeId": "102c4435-f834-44ab-977d-901bf535b780", + "sourcePortIndex": 0, + "targetNodeId": "09e7895b-f3f5-448a-a5c7-14f8c9225db4", + "targetPortIndex": 0 + }, + { + "id": "732ad759-e4cd-444e-8e88-50081ee92d74", + "sourceNodeId": "be9bfd71-6059-4d41-be9d-faa2a73e9f61", + "sourcePortIndex": 0, + "targetNodeId": "09e7895b-f3f5-448a-a5c7-14f8c9225db4", + "targetPortIndex": 1 + }, + { + "id": "81fb2ea3-046d-4a1d-976b-595943f8e61c", + "sourceNodeId": "09e7895b-f3f5-448a-a5c7-14f8c9225db4", + "sourcePortIndex": 0, + "targetNodeId": "102c4435-f834-44ab-977d-901bf535b780", + "targetPortIndex": 0 + }, + { + "id": "c6d9092b-bdc4-457c-bf85-b51644ea8a99", + "sourceNodeId": "09e7895b-f3f5-448a-a5c7-14f8c9225db4", + "sourcePortIndex": 0, + "targetNodeId": "44861add-91bd-4091-bebe-f89b308c9c45", + "targetPortIndex": 0 + } + ] + } + } + ], + "connections": [ + { + "id": "2613306d-017d-4b60-aced-7e49df05cf98", + "sourceNodeId": "518d7610-e488-4ff5-8591-86b7cff65e85", + "sourcePortIndex": 0, + "targetNodeId": "ee1f9a36-e30e-47db-bd17-e81b7afa7f54", + "targetPortIndex": 0 + }, + { + "id": "9c077028-ef13-4dbd-aae5-3d42e8dfafb0", + "sourceNodeId": "ee1f9a36-e30e-47db-bd17-e81b7afa7f54", + "sourcePortIndex": 0, + "targetNodeId": "518d7610-e488-4ff5-8591-86b7cff65e85", + "targetPortIndex": 0 + }, + { + "id": "a85d4c2e-4972-4134-9eaa-e4d36442c956", + "sourceNodeId": "4154466b-5a0b-42f7-bb32-27e649d59245", + "sourcePortIndex": 0, + "targetNodeId": "518d7610-e488-4ff5-8591-86b7cff65e85", + "targetPortIndex": 1 + }, + { + "id": "44b024d7-d0f0-4a53-aa41-7b4e8c1a4fa6", + "sourceNodeId": "ee1f9a36-e30e-47db-bd17-e81b7afa7f54", + "sourcePortIndex": 0, + "targetNodeId": "f8e5d812-f712-4adf-898a-00236bd2e8b6", + "targetPortIndex": 0 + }, + { + "id": "b4d7b644-e5c1-4710-9f9a-56f82a5bc9f2", + "sourceNodeId": "f8e5d812-f712-4adf-898a-00236bd2e8b6", + "sourcePortIndex": 0, + "targetNodeId": "4154466b-5a0b-42f7-bb32-27e649d59245", + "targetPortIndex": 0 + } + ] + } + }, + { + "id": "e13e27ba-96ec-4b02-a6d6-6cea244e49dc", + "type": "Scope", + "name": "Plant vs Setpoint", + "position": { + "x": 1170, + "y": 495 + }, + "inputs": [ + { + "id": "e13e27ba-96ec-4b02-a6d6-6cea244e49dc-input-0", + "nodeId": "e13e27ba-96ec-4b02-a6d6-6cea244e49dc", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + }, + { + "id": "e13e27ba-96ec-4b02-a6d6-6cea244e49dc-input-1", + "nodeId": "e13e27ba-96ec-4b02-a6d6-6cea244e49dc", + "name": "in 1", + "direction": "input", + "index": 1, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "sampling_rate": null, + "t_wait": "0.0", + "labels": null, + "_rotation": 1 + } + }, + { + "id": "802d8831-8960-498f-8fc5-61fbe945dda5", + "type": "Scope", + "name": "Err-1 and PI-1", + "position": { + "x": 1005, + "y": 240 + }, + "inputs": [ + { + "id": "802d8831-8960-498f-8fc5-61fbe945dda5-input-0", + "nodeId": "802d8831-8960-498f-8fc5-61fbe945dda5", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + }, + { + "id": "802d8831-8960-498f-8fc5-61fbe945dda5-input-1", + "nodeId": "802d8831-8960-498f-8fc5-61fbe945dda5", + "name": "in 1", + "direction": "input", + "index": 1, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "sampling_rate": null, + "t_wait": "0.0", + "labels": null, + "_rotation": 3 + } + }, + { + "id": "f038da46-8db9-4e89-b625-c392fabc1958", + "type": "Scope", + "name": "Err-2 and PI-2", + "position": { + "x": 1335, + "y": 240 + }, + "inputs": [ + { + "id": "f038da46-8db9-4e89-b625-c392fabc1958-input-0", + "nodeId": "f038da46-8db9-4e89-b625-c392fabc1958", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + }, + { + "id": "f038da46-8db9-4e89-b625-c392fabc1958-input-1", + "nodeId": "f038da46-8db9-4e89-b625-c392fabc1958", + "name": "in 1", + "direction": "input", + "index": 1, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "sampling_rate": null, + "t_wait": "0.0", + "labels": null, + "_rotation": 3 + } + } + ], + "connections": [ + { + "id": "4db103b6-e913-4886-8b42-733a71ef5355", + "sourceNodeId": "c5fc3645-c55b-4478-8125-665418379764", + "sourcePortIndex": 0, + "targetNodeId": "096ba23a-fd02-43ca-aabe-92a83db7b610", + "targetPortIndex": 0 + }, + { + "id": "b57990f5-3eef-4ec4-83db-02b632740790", + "sourceNodeId": "f7ffd92f-16eb-4760-8285-8ed7fafc90fd", + "sourcePortIndex": 0, + "targetNodeId": "c5fc3645-c55b-4478-8125-665418379764", + "targetPortIndex": 0 + }, + { + "id": "4a2ecc06-e8fb-4695-aac6-dc7fd2571b8e", + "sourceNodeId": "b449fb93-54e1-4cfd-862b-cbb66d919d91", + "sourcePortIndex": 0, + "targetNodeId": "f7ffd92f-16eb-4760-8285-8ed7fafc90fd", + "targetPortIndex": 0 + }, + { + "id": "0200ffc2-18de-4e24-b7f5-8cfbc83579d3", + "sourceNodeId": "7c855194-3fcf-4d00-acbd-0e3051f5703c", + "sourcePortIndex": 0, + "targetNodeId": "b449fb93-54e1-4cfd-862b-cbb66d919d91", + "targetPortIndex": 0 + }, + { + "id": "34847dfd-0593-4858-b003-78e051824022", + "sourceNodeId": "096ba23a-fd02-43ca-aabe-92a83db7b610", + "sourcePortIndex": 0, + "targetNodeId": "40ac8e75-a709-4434-9001-63bc1d73106c", + "targetPortIndex": 0 + }, + { + "id": "1bbb1f23-74cb-4cd2-9891-2f63b627fbbc", + "sourceNodeId": "40ac8e75-a709-4434-9001-63bc1d73106c", + "sourcePortIndex": 1, + "targetNodeId": "e13e27ba-96ec-4b02-a6d6-6cea244e49dc", + "targetPortIndex": 1 + }, + { + "id": "e696a0d1-caea-4945-a402-8b7eb0d6863a", + "sourceNodeId": "40ac8e75-a709-4434-9001-63bc1d73106c", + "sourcePortIndex": 0, + "targetNodeId": "c5fc3645-c55b-4478-8125-665418379764", + "targetPortIndex": 1 + }, + { + "id": "722f7bb1-ae25-4a32-a717-f497c137f185", + "sourceNodeId": "40ac8e75-a709-4434-9001-63bc1d73106c", + "sourcePortIndex": 1, + "targetNodeId": "b449fb93-54e1-4cfd-862b-cbb66d919d91", + "targetPortIndex": 1 + }, + { + "id": "f29dd606-4efc-480e-99b4-e1934cccbc8d", + "sourceNodeId": "b449fb93-54e1-4cfd-862b-cbb66d919d91", + "sourcePortIndex": 0, + "targetNodeId": "802d8831-8960-498f-8fc5-61fbe945dda5", + "targetPortIndex": 0 + }, + { + "id": "8978bfd5-2a1b-4447-a983-689d1753eac2", + "sourceNodeId": "f7ffd92f-16eb-4760-8285-8ed7fafc90fd", + "sourcePortIndex": 0, + "targetNodeId": "802d8831-8960-498f-8fc5-61fbe945dda5", + "targetPortIndex": 1 + }, + { + "id": "81ec083b-4f9b-459c-9c64-635ff6d053a7", + "sourceNodeId": "c5fc3645-c55b-4478-8125-665418379764", + "sourcePortIndex": 0, + "targetNodeId": "f038da46-8db9-4e89-b625-c392fabc1958", + "targetPortIndex": 0 + }, + { + "id": "1c5e38fc-6dc5-4f04-ad75-c9985c73c9d8", + "sourceNodeId": "096ba23a-fd02-43ca-aabe-92a83db7b610", + "sourcePortIndex": 0, + "targetNodeId": "f038da46-8db9-4e89-b625-c392fabc1958", + "targetPortIndex": 1 + }, + { + "id": "8cdabc5e-4344-45c0-8538-c02917eaffca", + "sourceNodeId": "7c855194-3fcf-4d00-acbd-0e3051f5703c", + "sourcePortIndex": 0, + "targetNodeId": "e13e27ba-96ec-4b02-a6d6-6cea244e49dc", + "targetPortIndex": 0 + } + ] + }, + "events": [], + "codeContext": { + "code": "# spectral density of noise source in plant\nnoise_psd = 1e-5" + }, + "simulationSettings": { + "duration": "100.0", + "dt": "0.1", + "solver": "RK4", + "adaptive": true, + "atol": "1e-6", + "rtol": "1e-4", + "ftol": "1e-9", + "dt_min": "", + "dt_max": "", + "ghostTraces": 6, + "plotResults": true + } +} diff --git a/static/examples/coupled-oscillators.json b/static/examples/coupled-oscillators.json new file mode 100644 index 00000000..459c1ecc --- /dev/null +++ b/static/examples/coupled-oscillators.json @@ -0,0 +1,829 @@ +{ + "version": "1.0.0", + "metadata": { + "created": "2025-12-24T13:54:56.773Z", + "modified": "2025-12-24T13:54:56.773Z", + "name": "Coupled Oscillators", + "description": "Connected spring-mass subsystems" + }, + "graph": { + "nodes": [ + { + "id": "37f363e1-de52-4ca1-b3b8-c781233be1e4", + "type": "Subsystem", + "name": "Oscillator-1", + "position": { + "x": 810, + "y": 480 + }, + "inputs": [ + { + "id": "37f363e1-de52-4ca1-b3b8-c781233be1e4-input-0", + "nodeId": "37f363e1-de52-4ca1-b3b8-c781233be1e4", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "37f363e1-de52-4ca1-b3b8-c781233be1e4-output-0", + "nodeId": "37f363e1-de52-4ca1-b3b8-c781233be1e4", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "_rotation": 1 + }, + "graph": { + "nodes": [ + { + "id": "54ce78ed-019a-496b-9cb5-cfa4ff8f79ff", + "type": "Interface", + "name": "Subsystem", + "position": { + "x": 360, + "y": 15 + }, + "inputs": [], + "outputs": [], + "params": { + "_rotation": 0 + } + }, + { + "id": "0c50f72a-1a1f-403d-9d35-346b0237f1f8", + "type": "Integrator", + "name": "Velocity", + "position": { + "x": 0, + "y": 15 + }, + "inputs": [ + { + "id": "0c50f72a-1a1f-403d-9d35-346b0237f1f8-input-0", + "nodeId": "0c50f72a-1a1f-403d-9d35-346b0237f1f8", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "0c50f72a-1a1f-403d-9d35-346b0237f1f8-output-0", + "nodeId": "0c50f72a-1a1f-403d-9d35-346b0237f1f8", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "initial_value": "0.0" + } + }, + { + "id": "a4869b33-b15e-4890-a0ef-1d83c5c4b6f4", + "type": "Integrator", + "name": "Position", + "position": { + "x": 180, + "y": 15 + }, + "inputs": [ + { + "id": "a4869b33-b15e-4890-a0ef-1d83c5c4b6f4-input-0", + "nodeId": "a4869b33-b15e-4890-a0ef-1d83c5c4b6f4", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "a4869b33-b15e-4890-a0ef-1d83c5c4b6f4-output-0", + "nodeId": "a4869b33-b15e-4890-a0ef-1d83c5c4b6f4", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "initial_value": "x0" + } + }, + { + "id": "d6e13522-dd6c-4de7-920c-f4a2fcd1b242", + "type": "Adder", + "name": "Adder", + "position": { + "x": 0, + "y": 195 + }, + "inputs": [ + { + "id": "d6e13522-dd6c-4de7-920c-f4a2fcd1b242-input-0", + "nodeId": "d6e13522-dd6c-4de7-920c-f4a2fcd1b242", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + }, + { + "id": "d6e13522-dd6c-4de7-920c-f4a2fcd1b242-input-1", + "nodeId": "d6e13522-dd6c-4de7-920c-f4a2fcd1b242", + "name": "in 1", + "direction": "input", + "index": 1, + "color": "#969696" + }, + { + "id": "d6e13522-dd6c-4de7-920c-f4a2fcd1b242-input-2", + "nodeId": "d6e13522-dd6c-4de7-920c-f4a2fcd1b242", + "name": "in 2", + "direction": "input", + "index": 2, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "d6e13522-dd6c-4de7-920c-f4a2fcd1b242-output-0", + "nodeId": "d6e13522-dd6c-4de7-920c-f4a2fcd1b242", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "operations": null, + "_rotation": 2 + } + }, + { + "id": "726576f9-e309-40ff-a5af-7e98d1e963fa", + "type": "Amplifier", + "name": "Damping", + "position": { + "x": 90, + "y": 105 + }, + "inputs": [ + { + "id": "726576f9-e309-40ff-a5af-7e98d1e963fa-input-0", + "nodeId": "726576f9-e309-40ff-a5af-7e98d1e963fa", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "726576f9-e309-40ff-a5af-7e98d1e963fa-output-0", + "nodeId": "726576f9-e309-40ff-a5af-7e98d1e963fa", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "gain": "d", + "_rotation": 1 + } + }, + { + "id": "6ca2158b-b036-474d-9188-1ac72e9de175", + "type": "Amplifier", + "name": "Spring", + "position": { + "x": 270, + "y": 105 + }, + "inputs": [ + { + "id": "6ca2158b-b036-474d-9188-1ac72e9de175-input-0", + "nodeId": "6ca2158b-b036-474d-9188-1ac72e9de175", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "6ca2158b-b036-474d-9188-1ac72e9de175-output-0", + "nodeId": "6ca2158b-b036-474d-9188-1ac72e9de175", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "gain": "k", + "_rotation": 1 + } + }, + { + "id": "970d6b38-3b01-4f5d-8f09-5a5c0b63ca8c", + "type": "Amplifier", + "name": "Mass", + "position": { + "x": -90, + "y": 105 + }, + "inputs": [ + { + "id": "970d6b38-3b01-4f5d-8f09-5a5c0b63ca8c-input-0", + "nodeId": "970d6b38-3b01-4f5d-8f09-5a5c0b63ca8c", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "970d6b38-3b01-4f5d-8f09-5a5c0b63ca8c-output-0", + "nodeId": "970d6b38-3b01-4f5d-8f09-5a5c0b63ca8c", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "gain": "-m", + "_rotation": 3 + } + } + ], + "connections": [ + { + "id": "90c24da9-36c9-4a32-ba70-4862742d129e", + "sourceNodeId": "0c50f72a-1a1f-403d-9d35-346b0237f1f8", + "sourcePortIndex": 0, + "targetNodeId": "a4869b33-b15e-4890-a0ef-1d83c5c4b6f4", + "targetPortIndex": 0 + }, + { + "id": "efba18e8-0834-4b06-8582-04d41975d55b", + "sourceNodeId": "0c50f72a-1a1f-403d-9d35-346b0237f1f8", + "sourcePortIndex": 0, + "targetNodeId": "726576f9-e309-40ff-a5af-7e98d1e963fa", + "targetPortIndex": 0 + }, + { + "id": "c4703c86-a54b-41e7-a192-04ed7e94c76d", + "sourceNodeId": "a4869b33-b15e-4890-a0ef-1d83c5c4b6f4", + "sourcePortIndex": 0, + "targetNodeId": "6ca2158b-b036-474d-9188-1ac72e9de175", + "targetPortIndex": 0 + }, + { + "id": "12b3e924-0877-4dfa-824c-15dacd620f0e", + "sourceNodeId": "970d6b38-3b01-4f5d-8f09-5a5c0b63ca8c", + "sourcePortIndex": 0, + "targetNodeId": "0c50f72a-1a1f-403d-9d35-346b0237f1f8", + "targetPortIndex": 0 + }, + { + "id": "ee7d05cb-406d-404c-b28c-3c37c0875240", + "sourceNodeId": "d6e13522-dd6c-4de7-920c-f4a2fcd1b242", + "sourcePortIndex": 0, + "targetNodeId": "970d6b38-3b01-4f5d-8f09-5a5c0b63ca8c", + "targetPortIndex": 0 + }, + { + "id": "daa296c6-92ca-46ab-b023-1680632e606b", + "sourceNodeId": "726576f9-e309-40ff-a5af-7e98d1e963fa", + "sourcePortIndex": 0, + "targetNodeId": "d6e13522-dd6c-4de7-920c-f4a2fcd1b242", + "targetPortIndex": 0 + }, + { + "id": "f91db5fd-eaf4-4126-8862-dc12d3bf36a3", + "sourceNodeId": "6ca2158b-b036-474d-9188-1ac72e9de175", + "sourcePortIndex": 0, + "targetNodeId": "d6e13522-dd6c-4de7-920c-f4a2fcd1b242", + "targetPortIndex": 1 + }, + { + "id": "21eeb0fc-c7fc-497c-b712-a724e5eeb7a8", + "sourceNodeId": "54ce78ed-019a-496b-9cb5-cfa4ff8f79ff", + "sourcePortIndex": 0, + "targetNodeId": "d6e13522-dd6c-4de7-920c-f4a2fcd1b242", + "targetPortIndex": 2 + }, + { + "id": "cf7a7d67-53ce-4140-b96c-964024716cc6", + "sourceNodeId": "a4869b33-b15e-4890-a0ef-1d83c5c4b6f4", + "sourcePortIndex": 0, + "targetNodeId": "54ce78ed-019a-496b-9cb5-cfa4ff8f79ff", + "targetPortIndex": 0 + } + ] + } + }, + { + "id": "e3f74e4f-a380-4148-b8a4-175a516e78ae", + "type": "Function", + "name": "Force Coupling", + "position": { + "x": 930, + "y": 480 + }, + "inputs": [ + { + "id": "e3f74e4f-a380-4148-b8a4-175a516e78ae-input-0", + "nodeId": "e3f74e4f-a380-4148-b8a4-175a516e78ae", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + }, + { + "id": "e3f74e4f-a380-4148-b8a4-175a516e78ae-input-1", + "nodeId": "e3f74e4f-a380-4148-b8a4-175a516e78ae", + "name": "in 1", + "direction": "input", + "index": 1, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "e3f74e4f-a380-4148-b8a4-175a516e78ae-output-0", + "nodeId": "e3f74e4f-a380-4148-b8a4-175a516e78ae", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + }, + { + "id": "e3f74e4f-a380-4148-b8a4-175a516e78ae-output-1", + "nodeId": "e3f74e4f-a380-4148-b8a4-175a516e78ae", + "name": "out 1", + "direction": "output", + "index": 1, + "color": "#969696" + } + ], + "params": { + "func": "coupling", + "_rotation": 3 + } + }, + { + "id": "793f4420-d7c5-4d9e-a389-9b5e47efbfa1", + "type": "Subsystem", + "name": "Oscillator-2", + "position": { + "x": 1065, + "y": 480 + }, + "inputs": [ + { + "id": "793f4420-d7c5-4d9e-a389-9b5e47efbfa1-input-0", + "nodeId": "793f4420-d7c5-4d9e-a389-9b5e47efbfa1", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "793f4420-d7c5-4d9e-a389-9b5e47efbfa1-output-0", + "nodeId": "793f4420-d7c5-4d9e-a389-9b5e47efbfa1", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "_rotation": 1 + }, + "graph": { + "nodes": [ + { + "id": "709b130b-9186-46cf-a041-721b80af12c5", + "type": "Interface", + "name": "Subsystem", + "position": { + "x": 360, + "y": 15 + }, + "inputs": [], + "outputs": [], + "params": { + "_rotation": 0 + } + }, + { + "id": "72fbcd89-7a7f-428f-88a5-c2997b071963", + "type": "Integrator", + "name": "Velocity", + "position": { + "x": 0, + "y": 15 + }, + "inputs": [ + { + "id": "72fbcd89-7a7f-428f-88a5-c2997b071963-input-0", + "nodeId": "72fbcd89-7a7f-428f-88a5-c2997b071963", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "72fbcd89-7a7f-428f-88a5-c2997b071963-output-0", + "nodeId": "72fbcd89-7a7f-428f-88a5-c2997b071963", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "initial_value": "0.0" + } + }, + { + "id": "e53fa548-6626-49cc-890d-6677be55d309", + "type": "Integrator", + "name": "Position", + "position": { + "x": 180, + "y": 15 + }, + "inputs": [ + { + "id": "e53fa548-6626-49cc-890d-6677be55d309-input-0", + "nodeId": "e53fa548-6626-49cc-890d-6677be55d309", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "e53fa548-6626-49cc-890d-6677be55d309-output-0", + "nodeId": "e53fa548-6626-49cc-890d-6677be55d309", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "initial_value": "0" + } + }, + { + "id": "90e5bb82-79d1-407d-8f15-a6d40bc3b430", + "type": "Adder", + "name": "Adder", + "position": { + "x": 0, + "y": 195 + }, + "inputs": [ + { + "id": "90e5bb82-79d1-407d-8f15-a6d40bc3b430-input-0", + "nodeId": "90e5bb82-79d1-407d-8f15-a6d40bc3b430", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + }, + { + "id": "90e5bb82-79d1-407d-8f15-a6d40bc3b430-input-1", + "nodeId": "90e5bb82-79d1-407d-8f15-a6d40bc3b430", + "name": "in 1", + "direction": "input", + "index": 1, + "color": "#969696" + }, + { + "id": "90e5bb82-79d1-407d-8f15-a6d40bc3b430-input-2", + "nodeId": "90e5bb82-79d1-407d-8f15-a6d40bc3b430", + "name": "in 2", + "direction": "input", + "index": 2, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "90e5bb82-79d1-407d-8f15-a6d40bc3b430-output-0", + "nodeId": "90e5bb82-79d1-407d-8f15-a6d40bc3b430", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "operations": null, + "_rotation": 2 + } + }, + { + "id": "26857351-45ff-49c3-b3c7-fcb066f34970", + "type": "Amplifier", + "name": "Damping", + "position": { + "x": 90, + "y": 105 + }, + "inputs": [ + { + "id": "26857351-45ff-49c3-b3c7-fcb066f34970-input-0", + "nodeId": "26857351-45ff-49c3-b3c7-fcb066f34970", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "26857351-45ff-49c3-b3c7-fcb066f34970-output-0", + "nodeId": "26857351-45ff-49c3-b3c7-fcb066f34970", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "gain": "d", + "_rotation": 1 + } + }, + { + "id": "e7244350-f0af-4a54-9a49-08a6dd8bd9d0", + "type": "Amplifier", + "name": "Spring", + "position": { + "x": 270, + "y": 105 + }, + "inputs": [ + { + "id": "e7244350-f0af-4a54-9a49-08a6dd8bd9d0-input-0", + "nodeId": "e7244350-f0af-4a54-9a49-08a6dd8bd9d0", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "e7244350-f0af-4a54-9a49-08a6dd8bd9d0-output-0", + "nodeId": "e7244350-f0af-4a54-9a49-08a6dd8bd9d0", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "gain": "k", + "_rotation": 1 + } + }, + { + "id": "b99d286a-98bc-41af-9831-73b75ebbcfce", + "type": "Amplifier", + "name": "Mass", + "position": { + "x": -90, + "y": 105 + }, + "inputs": [ + { + "id": "b99d286a-98bc-41af-9831-73b75ebbcfce-input-0", + "nodeId": "b99d286a-98bc-41af-9831-73b75ebbcfce", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "b99d286a-98bc-41af-9831-73b75ebbcfce-output-0", + "nodeId": "b99d286a-98bc-41af-9831-73b75ebbcfce", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "gain": "-m", + "_rotation": 3 + } + } + ], + "connections": [ + { + "id": "e9681204-191e-4b8a-adb6-ccd8f8958315", + "sourceNodeId": "72fbcd89-7a7f-428f-88a5-c2997b071963", + "sourcePortIndex": 0, + "targetNodeId": "e53fa548-6626-49cc-890d-6677be55d309", + "targetPortIndex": 0 + }, + { + "id": "520299da-af08-4f49-8bf6-eec12041b397", + "sourceNodeId": "72fbcd89-7a7f-428f-88a5-c2997b071963", + "sourcePortIndex": 0, + "targetNodeId": "26857351-45ff-49c3-b3c7-fcb066f34970", + "targetPortIndex": 0 + }, + { + "id": "08fd3221-c5d6-497b-a78e-3bc52bd97c1a", + "sourceNodeId": "e53fa548-6626-49cc-890d-6677be55d309", + "sourcePortIndex": 0, + "targetNodeId": "e7244350-f0af-4a54-9a49-08a6dd8bd9d0", + "targetPortIndex": 0 + }, + { + "id": "11235f31-ae35-4766-9d85-7b31d807a5db", + "sourceNodeId": "b99d286a-98bc-41af-9831-73b75ebbcfce", + "sourcePortIndex": 0, + "targetNodeId": "72fbcd89-7a7f-428f-88a5-c2997b071963", + "targetPortIndex": 0 + }, + { + "id": "2038e339-afbd-4b66-a006-4946647835ac", + "sourceNodeId": "90e5bb82-79d1-407d-8f15-a6d40bc3b430", + "sourcePortIndex": 0, + "targetNodeId": "b99d286a-98bc-41af-9831-73b75ebbcfce", + "targetPortIndex": 0 + }, + { + "id": "f4bfd4ad-c9ed-48e0-afe2-1a9a9e43bb07", + "sourceNodeId": "26857351-45ff-49c3-b3c7-fcb066f34970", + "sourcePortIndex": 0, + "targetNodeId": "90e5bb82-79d1-407d-8f15-a6d40bc3b430", + "targetPortIndex": 0 + }, + { + "id": "5cec644f-80fe-4353-bb87-149e92819f59", + "sourceNodeId": "e7244350-f0af-4a54-9a49-08a6dd8bd9d0", + "sourcePortIndex": 0, + "targetNodeId": "90e5bb82-79d1-407d-8f15-a6d40bc3b430", + "targetPortIndex": 1 + }, + { + "id": "39b5c4ea-6ba7-4d81-aebd-d70687e0828d", + "sourceNodeId": "709b130b-9186-46cf-a041-721b80af12c5", + "sourcePortIndex": 0, + "targetNodeId": "90e5bb82-79d1-407d-8f15-a6d40bc3b430", + "targetPortIndex": 2 + }, + { + "id": "0fdce9b3-4bf4-4bfb-be21-bcdbd6092a97", + "sourceNodeId": "e53fa548-6626-49cc-890d-6677be55d309", + "sourcePortIndex": 0, + "targetNodeId": "709b130b-9186-46cf-a041-721b80af12c5", + "targetPortIndex": 0 + } + ] + } + }, + { + "id": "109a8022-1e8f-4160-8c50-f0b566673799", + "type": "Scope", + "name": "Position-1", + "position": { + "x": 810, + "y": 600 + }, + "inputs": [ + { + "id": "109a8022-1e8f-4160-8c50-f0b566673799-input-0", + "nodeId": "109a8022-1e8f-4160-8c50-f0b566673799", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "sampling_rate": null, + "t_wait": "0.0", + "labels": null, + "_rotation": 1 + } + }, + { + "id": "69cabd50-9fb3-44d1-9b71-b1428b41392e", + "type": "Scope", + "name": "Position-2", + "position": { + "x": 1065, + "y": 600 + }, + "inputs": [ + { + "id": "69cabd50-9fb3-44d1-9b71-b1428b41392e-input-0", + "nodeId": "69cabd50-9fb3-44d1-9b71-b1428b41392e", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "sampling_rate": null, + "t_wait": "0.0", + "labels": null, + "_rotation": 1 + } + } + ], + "connections": [ + { + "id": "52f264e8-c26c-49d5-a354-0bdd9b5776f8", + "sourceNodeId": "e3f74e4f-a380-4148-b8a4-175a516e78ae", + "sourcePortIndex": 0, + "targetNodeId": "37f363e1-de52-4ca1-b3b8-c781233be1e4", + "targetPortIndex": 0 + }, + { + "id": "9086880d-ea0f-41f0-875e-0c52af77a996", + "sourceNodeId": "37f363e1-de52-4ca1-b3b8-c781233be1e4", + "sourcePortIndex": 0, + "targetNodeId": "e3f74e4f-a380-4148-b8a4-175a516e78ae", + "targetPortIndex": 0 + }, + { + "id": "0700c163-9e47-4dbf-93ae-055a29de8aa6", + "sourceNodeId": "e3f74e4f-a380-4148-b8a4-175a516e78ae", + "sourcePortIndex": 1, + "targetNodeId": "793f4420-d7c5-4d9e-a389-9b5e47efbfa1", + "targetPortIndex": 0 + }, + { + "id": "c1f06ee4-c96e-4281-97c1-eed153ece15b", + "sourceNodeId": "793f4420-d7c5-4d9e-a389-9b5e47efbfa1", + "sourcePortIndex": 0, + "targetNodeId": "e3f74e4f-a380-4148-b8a4-175a516e78ae", + "targetPortIndex": 1 + }, + { + "id": "7bbf9732-54a9-48b7-acd5-efbc84134879", + "sourceNodeId": "37f363e1-de52-4ca1-b3b8-c781233be1e4", + "sourcePortIndex": 0, + "targetNodeId": "109a8022-1e8f-4160-8c50-f0b566673799", + "targetPortIndex": 0 + }, + { + "id": "f581eee7-e0d8-4836-b392-3d2eca585085", + "sourceNodeId": "793f4420-d7c5-4d9e-a389-9b5e47efbfa1", + "sourcePortIndex": 0, + "targetNodeId": "69cabd50-9fb3-44d1-9b71-b1428b41392e", + "targetPortIndex": 0 + } + ] + }, + "events": [], + "codeContext": { + "code": "# initial position\nx0 = 2\n\n# oscillator parameters\nm = 1\nd = 0.05\nk = 2\n\n# force from spatial offset\ndef coupling(x1, x2):\n f = k * (x1 - x2)\n return f, -f" + }, + "simulationSettings": { + "duration": "30.0", + "dt": "0.02", + "solver": "RKBS32", + "adaptive": true, + "atol": "1e-6", + "rtol": "1e-3", + "ftol": "1e-9", + "dt_min": "", + "dt_max": "", + "ghostTraces": 0, + "plotResults": true + } +} diff --git a/static/examples/feedback-loop.json b/static/examples/feedback-loop.json new file mode 100644 index 00000000..e919f030 --- /dev/null +++ b/static/examples/feedback-loop.json @@ -0,0 +1,216 @@ +{ + "version": "1.0.0", + "metadata": { + "created": "2025-12-19T09:08:04.556Z", + "modified": "2025-12-19T09:08:04.556Z", + "name": "Feedback Loop", + "description": "Negative feedback with integrator" + }, + "graph": { + "nodes": [ + { + "id": "c03d0b76-28b8-4406-8f0a-ef81b732854d", + "type": "Adder", + "name": "Sum (+-)", + "position": { + "x": 825, + "y": 390 + }, + "inputs": [ + { + "id": "c03d0b76-28b8-4406-8f0a-ef81b732854d-input-0", + "nodeId": "c03d0b76-28b8-4406-8f0a-ef81b732854d", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + }, + { + "id": "c03d0b76-28b8-4406-8f0a-ef81b732854d-input-1", + "nodeId": "c03d0b76-28b8-4406-8f0a-ef81b732854d", + "name": "in 1", + "direction": "input", + "index": 1, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "c03d0b76-28b8-4406-8f0a-ef81b732854d-output-0", + "nodeId": "c03d0b76-28b8-4406-8f0a-ef81b732854d", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "operations": "'+-'" + } + }, + { + "id": "e0f91e8f-e0ec-48e5-a2eb-68bb72f6152f", + "type": "Amplifier", + "name": "Feedback", + "position": { + "x": 900, + "y": 480 + }, + "inputs": [ + { + "id": "e0f91e8f-e0ec-48e5-a2eb-68bb72f6152f-input-0", + "nodeId": "e0f91e8f-e0ec-48e5-a2eb-68bb72f6152f", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "e0f91e8f-e0ec-48e5-a2eb-68bb72f6152f-output-0", + "nodeId": "e0f91e8f-e0ec-48e5-a2eb-68bb72f6152f", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "gain": "1", + "_rotation": 2 + } + }, + { + "id": "353e895d-8dfa-4c17-a752-043d1fc38749", + "type": "Integrator", + "name": "Integrator", + "position": { + "x": 960, + "y": 390 + }, + "inputs": [ + { + "id": "353e895d-8dfa-4c17-a752-043d1fc38749-input-0", + "nodeId": "353e895d-8dfa-4c17-a752-043d1fc38749", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "353e895d-8dfa-4c17-a752-043d1fc38749-output-0", + "nodeId": "353e895d-8dfa-4c17-a752-043d1fc38749", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "initial_value": 0 + } + }, + { + "id": "c196290c-d86e-473b-b4a9-e1fac84cce98", + "type": "Scope", + "name": "Output", + "position": { + "x": 1110, + "y": 390 + }, + "inputs": [ + { + "id": "c196290c-d86e-473b-b4a9-e1fac84cce98-input-0", + "nodeId": "c196290c-d86e-473b-b4a9-e1fac84cce98", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "sampling_rate": null + } + }, + { + "id": "fa58ae1f-437a-4e9e-bf12-9c6b0ccb52e9", + "type": "StepSource", + "name": "Unit Step", + "position": { + "x": 660, + "y": 390 + }, + "inputs": [], + "outputs": [ + { + "id": "fa58ae1f-437a-4e9e-bf12-9c6b0ccb52e9-output-0", + "nodeId": "fa58ae1f-437a-4e9e-bf12-9c6b0ccb52e9", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "amplitude": 1, + "tau": "3", + "_rotation": 0 + } + } + ], + "connections": [ + { + "id": "c47a35bc-3030-4699-ab4a-d7aabca093d8", + "sourceNodeId": "c03d0b76-28b8-4406-8f0a-ef81b732854d", + "sourcePortIndex": 0, + "targetNodeId": "353e895d-8dfa-4c17-a752-043d1fc38749", + "targetPortIndex": 0 + }, + { + "id": "8bef47d0-b56f-4c2a-8ccf-50fbdc35c2f8", + "sourceNodeId": "353e895d-8dfa-4c17-a752-043d1fc38749", + "sourcePortIndex": 0, + "targetNodeId": "e0f91e8f-e0ec-48e5-a2eb-68bb72f6152f", + "targetPortIndex": 0 + }, + { + "id": "dbf00304-ed99-450f-b9eb-a74c8c838d7f", + "sourceNodeId": "e0f91e8f-e0ec-48e5-a2eb-68bb72f6152f", + "sourcePortIndex": 0, + "targetNodeId": "c03d0b76-28b8-4406-8f0a-ef81b732854d", + "targetPortIndex": 1 + }, + { + "id": "424c83ec-2f6e-40b5-8715-a2919677b1dd", + "sourceNodeId": "353e895d-8dfa-4c17-a752-043d1fc38749", + "sourcePortIndex": 0, + "targetNodeId": "c196290c-d86e-473b-b4a9-e1fac84cce98", + "targetPortIndex": 0 + }, + { + "id": "e69b73e0-344a-459d-9b5b-4bd5944fc824", + "sourceNodeId": "fa58ae1f-437a-4e9e-bf12-9c6b0ccb52e9", + "sourcePortIndex": 0, + "targetNodeId": "c03d0b76-28b8-4406-8f0a-ef81b732854d", + "targetPortIndex": 0 + } + ] + }, + "codeContext": { + "code": "" + }, + "simulationSettings": { + "duration": 10, + "dt": 0.01, + "solver": "SSPRK22", + "atol": 1e-8, + "rtol": 0.00001, + "ftol": 1e-9, + "reset": true, + "plotResults": true + } +} diff --git a/static/examples/fmcw.json b/static/examples/fmcw.json new file mode 100644 index 00000000..d34b7d16 --- /dev/null +++ b/static/examples/fmcw.json @@ -0,0 +1,346 @@ +{ + "version": "1.0.0", + "metadata": { + "created": "2025-12-25T09:44:15.576Z", + "modified": "2025-12-25T09:44:15.576Z", + "name": "FMCW Radar", + "description": "Frequency-modulated continuous-wave radar" + }, + "graph": { + "nodes": [ + { + "id": "fa979db8-e683-4aa0-a197-d02c3d61f4c6", + "type": "Multiplier", + "name": "Mixer", + "position": { + "x": 0, + "y": 375 + }, + "inputs": [ + { + "id": "fa979db8-e683-4aa0-a197-d02c3d61f4c6-input-0", + "nodeId": "fa979db8-e683-4aa0-a197-d02c3d61f4c6", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + }, + { + "id": "fa979db8-e683-4aa0-a197-d02c3d61f4c6-input-1", + "nodeId": "fa979db8-e683-4aa0-a197-d02c3d61f4c6", + "name": "in 1", + "direction": "input", + "index": 1, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "fa979db8-e683-4aa0-a197-d02c3d61f4c6-output-0", + "nodeId": "fa979db8-e683-4aa0-a197-d02c3d61f4c6", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "_rotation": 0 + } + }, + { + "id": "661af18b-ddc2-4fc8-8954-a88645711e7e", + "type": "ButterworthLowpassFilter", + "name": "LPF", + "position": { + "x": 180, + "y": 375 + }, + "inputs": [ + { + "id": "661af18b-ddc2-4fc8-8954-a88645711e7e-input-0", + "nodeId": "661af18b-ddc2-4fc8-8954-a88645711e7e", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "661af18b-ddc2-4fc8-8954-a88645711e7e-output-0", + "nodeId": "661af18b-ddc2-4fc8-8954-a88645711e7e", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "Fc": "f_trg*3", + "n": "2", + "_rotation": 0 + } + }, + { + "id": "7d36e8ff-a73f-4105-b908-fa4670cc96a9", + "type": "Spectrum", + "name": "Mixer", + "position": { + "x": 90, + "y": 300 + }, + "inputs": [ + { + "id": "7d36e8ff-a73f-4105-b908-fa4670cc96a9-input-0", + "nodeId": "7d36e8ff-a73f-4105-b908-fa4670cc96a9", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "freq": "np.logspace(6, 10.5, 500)", + "_rotation": 3 + } + }, + { + "id": "4b645485-b3ca-4fc7-9f46-c25664468bec", + "type": "Delay", + "name": "Freespace", + "position": { + "x": -150, + "y": 405 + }, + "inputs": [ + { + "id": "4b645485-b3ca-4fc7-9f46-c25664468bec-input-0", + "nodeId": "4b645485-b3ca-4fc7-9f46-c25664468bec", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "4b645485-b3ca-4fc7-9f46-c25664468bec-output-0", + "nodeId": "4b645485-b3ca-4fc7-9f46-c25664468bec", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "_rotation": 0, + "tau": "tau" + } + }, + { + "id": "f329e608-39f5-4ce8-b1fe-448abf3e8549", + "type": "ChirpPhaseNoiseSource", + "name": "Chirp", + "position": { + "x": -360, + "y": 300 + }, + "inputs": [], + "outputs": [ + { + "id": "f329e608-39f5-4ce8-b1fe-448abf3e8549-output-0", + "nodeId": "f329e608-39f5-4ce8-b1fe-448abf3e8549", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "f0": "f_min", + "BW": "B", + "T": "T", + "_rotation": 1 + } + }, + { + "id": "be80dd03-99b0-4e75-b26e-4fcc65965da4", + "type": "Spectrum", + "name": "Chirp", + "position": { + "x": -150, + "y": 300 + }, + "inputs": [ + { + "id": "be80dd03-99b0-4e75-b26e-4fcc65965da4-input-0", + "nodeId": "be80dd03-99b0-4e75-b26e-4fcc65965da4", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "freq": "np.logspace(6, 10.5, 500)", + "_rotation": 3 + } + }, + { + "id": "fe53f02d-f62f-40cf-8005-680c2432ccdb", + "type": "Spectrum", + "name": "LPF", + "position": { + "x": 330, + "y": 300 + }, + "inputs": [ + { + "id": "fe53f02d-f62f-40cf-8005-680c2432ccdb-input-0", + "nodeId": "fe53f02d-f62f-40cf-8005-680c2432ccdb", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "freq": "np.logspace(6, 10.5, 500)", + "_rotation": 3 + } + }, + { + "id": "36d93d54-1fa3-483b-868a-500badcea957", + "type": "Scope", + "name": "Mixer", + "position": { + "x": 90, + "y": 450 + }, + "inputs": [ + { + "id": "36d93d54-1fa3-483b-868a-500badcea957-input-0", + "nodeId": "36d93d54-1fa3-483b-868a-500badcea957", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "_rotation": 1 + } + }, + { + "id": "4a8118b9-67cb-4cd6-8146-d2b3a52aadfa", + "type": "Scope", + "name": "LPF", + "position": { + "x": 330, + "y": 450 + }, + "inputs": [ + { + "id": "4a8118b9-67cb-4cd6-8146-d2b3a52aadfa-input-0", + "nodeId": "4a8118b9-67cb-4cd6-8146-d2b3a52aadfa", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "_rotation": 1 + } + } + ], + "connections": [ + { + "id": "9ef15bbc-e5ab-4ae8-bd20-cf34ac561258", + "sourceNodeId": "fa979db8-e683-4aa0-a197-d02c3d61f4c6", + "sourcePortIndex": 0, + "targetNodeId": "661af18b-ddc2-4fc8-8954-a88645711e7e", + "targetPortIndex": 0 + }, + { + "id": "abbe6078-b117-461a-af09-65859b227e24", + "sourceNodeId": "f329e608-39f5-4ce8-b1fe-448abf3e8549", + "sourcePortIndex": 0, + "targetNodeId": "fa979db8-e683-4aa0-a197-d02c3d61f4c6", + "targetPortIndex": 0 + }, + { + "id": "b665399e-d1ab-415f-930e-8097c50b9293", + "sourceNodeId": "4b645485-b3ca-4fc7-9f46-c25664468bec", + "sourcePortIndex": 0, + "targetNodeId": "fa979db8-e683-4aa0-a197-d02c3d61f4c6", + "targetPortIndex": 1 + }, + { + "id": "4bb26e3e-41b0-49ff-a2be-dab0e2c2d8fc", + "sourceNodeId": "fa979db8-e683-4aa0-a197-d02c3d61f4c6", + "sourcePortIndex": 0, + "targetNodeId": "7d36e8ff-a73f-4105-b908-fa4670cc96a9", + "targetPortIndex": 0 + }, + { + "id": "dc3ecd40-b84e-4ab8-8038-13cd51628d53", + "sourceNodeId": "f329e608-39f5-4ce8-b1fe-448abf3e8549", + "sourcePortIndex": 0, + "targetNodeId": "be80dd03-99b0-4e75-b26e-4fcc65965da4", + "targetPortIndex": 0 + }, + { + "id": "b1bb8c52-143d-440d-a290-3b400e23761d", + "sourceNodeId": "f329e608-39f5-4ce8-b1fe-448abf3e8549", + "sourcePortIndex": 0, + "targetNodeId": "4b645485-b3ca-4fc7-9f46-c25664468bec", + "targetPortIndex": 0 + }, + { + "id": "5eb855ed-35dc-4062-ada8-9755b41196fd", + "sourceNodeId": "661af18b-ddc2-4fc8-8954-a88645711e7e", + "sourcePortIndex": 0, + "targetNodeId": "fe53f02d-f62f-40cf-8005-680c2432ccdb", + "targetPortIndex": 0 + }, + { + "id": "4b342c87-363b-4adb-a79d-287f1789d8ca", + "sourceNodeId": "fa979db8-e683-4aa0-a197-d02c3d61f4c6", + "sourcePortIndex": 0, + "targetNodeId": "36d93d54-1fa3-483b-868a-500badcea957", + "targetPortIndex": 0 + }, + { + "id": "fc97cfe0-acb4-44bb-9d5b-1f526132d299", + "sourceNodeId": "661af18b-ddc2-4fc8-8954-a88645711e7e", + "sourcePortIndex": 0, + "targetNodeId": "4a8118b9-67cb-4cd6-8146-d2b3a52aadfa", + "targetPortIndex": 0 + } + ] + }, + "events": [], + "codeContext": { + "code": "# Natural constants (approximately)\nc0 = 3e8\n\n# Chirp parameters\nB, T, f_min = 4e9, 3e-7, 1e9\n\n# Delay for target emulation\ntau = 2e-9\n\n# For reference, the expected target distance\nR = c0 * tau / 2\n\n# And the corresponding frequency\nf_trg = 4 * R * B / (T * c0)" + }, + "simulationSettings": { + "duration": "T", + "dt": "1e-11", + "solver": "SSPRK22", + "adaptive": true, + "atol": "", + "rtol": "", + "ftol": "", + "dt_min": "", + "dt_max": "", + "ghostTraces": 0, + "plotResults": true + } +} diff --git a/static/examples/harmonic-oscillator.json b/static/examples/harmonic-oscillator.json new file mode 100644 index 00000000..9f0302d6 --- /dev/null +++ b/static/examples/harmonic-oscillator.json @@ -0,0 +1,335 @@ +{ + "version": "1.0.0", + "metadata": { + "created": "2025-12-20T20:09:27.750Z", + "modified": "2025-12-20T20:09:27.750Z", + "name": "Harmonic Oscillator", + "description": "Second-order spring-mass-damper" + }, + "graph": { + "nodes": [ + { + "id": "int-vel", + "type": "Integrator", + "name": "Velocity", + "position": { + "x": 675, + "y": 315 + }, + "inputs": [ + { + "id": "int-vel-input-0", + "nodeId": "int-vel", + "name": "in 0", + "direction": "input", + "index": 0 + } + ], + "outputs": [ + { + "id": "int-vel-output-0", + "nodeId": "int-vel", + "name": "out 0", + "direction": "output", + "index": 0 + } + ], + "params": { + "initial_value": 5 + } + }, + { + "id": "int-pos", + "type": "Integrator", + "name": "Position", + "position": { + "x": 915, + "y": 315 + }, + "inputs": [ + { + "id": "int-pos-input-0", + "nodeId": "int-pos", + "name": "in 0", + "direction": "input", + "index": 0 + } + ], + "outputs": [ + { + "id": "int-pos-output-0", + "nodeId": "int-pos", + "name": "out 0", + "direction": "output", + "index": 0 + } + ], + "params": { + "initial_value": 2 + } + }, + { + "id": "amp-damping", + "type": "Amplifier", + "name": "Damping", + "position": { + "x": 795, + "y": 390 + }, + "inputs": [ + { + "id": "amp-damping-input-0", + "nodeId": "amp-damping", + "name": "in 0", + "direction": "input", + "index": 0 + } + ], + "outputs": [ + { + "id": "amp-damping-output-0", + "nodeId": "amp-damping", + "name": "out 0", + "direction": "output", + "index": 0 + } + ], + "params": { + "gain": "0.2", + "_rotation": 1 + } + }, + { + "id": "amp-spring", + "type": "Amplifier", + "name": "Spring", + "position": { + "x": 1035, + "y": 390 + }, + "inputs": [ + { + "id": "amp-spring-input-0", + "nodeId": "amp-spring", + "name": "in 0", + "direction": "input", + "index": 0 + } + ], + "outputs": [ + { + "id": "amp-spring-output-0", + "nodeId": "amp-spring", + "name": "out 0", + "direction": "output", + "index": 0 + } + ], + "params": { + "gain": "1.5", + "_rotation": 1 + } + }, + { + "id": "amp-mass", + "type": "Amplifier", + "name": "1/Mass", + "position": { + "x": 555, + "y": 390 + }, + "inputs": [ + { + "id": "amp-mass-input-0", + "nodeId": "amp-mass", + "name": "in 0", + "direction": "input", + "index": 0 + } + ], + "outputs": [ + { + "id": "amp-mass-output-0", + "nodeId": "amp-mass", + "name": "out 0", + "direction": "output", + "index": 0 + } + ], + "params": { + "gain": "-1.25", + "_rotation": 3 + } + }, + { + "id": "adder-forces", + "type": "Adder", + "name": "Forces", + "position": { + "x": 675, + "y": 450 + }, + "inputs": [ + { + "id": "adder-forces-input-0", + "nodeId": "adder-forces", + "name": "in 0", + "direction": "input", + "index": 0 + }, + { + "id": "adder-forces-input-1", + "nodeId": "adder-forces", + "name": "in 1", + "direction": "input", + "index": 1 + } + ], + "outputs": [ + { + "id": "adder-forces-output-0", + "nodeId": "adder-forces", + "name": "out 0", + "direction": "output", + "index": 0 + } + ], + "params": { + "operations": "'++'", + "_rotation": 2 + } + }, + { + "id": "f9218e45-2173-4f61-bab0-ee382529e284", + "type": "Scope", + "name": "Scope", + "position": { + "x": 795, + "y": 240 + }, + "inputs": [ + { + "id": "f9218e45-2173-4f61-bab0-ee382529e284-input-0", + "nodeId": "f9218e45-2173-4f61-bab0-ee382529e284", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "sampling_rate": null, + "t_wait": "0.0", + "labels": null, + "_rotation": 3 + } + }, + { + "id": "520462c6-afb1-4ba8-8fff-a8503e23bd98", + "type": "Scope", + "name": "Scope", + "position": { + "x": 1035, + "y": 240 + }, + "inputs": [ + { + "id": "520462c6-afb1-4ba8-8fff-a8503e23bd98-input-0", + "nodeId": "520462c6-afb1-4ba8-8fff-a8503e23bd98", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "sampling_rate": null, + "t_wait": "0.0", + "labels": null, + "_rotation": 3 + } + } + ], + "connections": [ + { + "id": "c1", + "sourceNodeId": "amp-mass", + "sourcePortIndex": 0, + "targetNodeId": "int-vel", + "targetPortIndex": 0 + }, + { + "id": "c2", + "sourceNodeId": "int-vel", + "sourcePortIndex": 0, + "targetNodeId": "int-pos", + "targetPortIndex": 0 + }, + { + "id": "c3", + "sourceNodeId": "int-vel", + "sourcePortIndex": 0, + "targetNodeId": "amp-damping", + "targetPortIndex": 0 + }, + { + "id": "c5", + "sourceNodeId": "int-pos", + "sourcePortIndex": 0, + "targetNodeId": "amp-spring", + "targetPortIndex": 0 + }, + { + "id": "c7", + "sourceNodeId": "amp-damping", + "sourcePortIndex": 0, + "targetNodeId": "adder-forces", + "targetPortIndex": 0 + }, + { + "id": "c8", + "sourceNodeId": "amp-spring", + "sourcePortIndex": 0, + "targetNodeId": "adder-forces", + "targetPortIndex": 1 + }, + { + "id": "c9", + "sourceNodeId": "adder-forces", + "sourcePortIndex": 0, + "targetNodeId": "amp-mass", + "targetPortIndex": 0 + }, + { + "id": "827a8b5f-b400-4009-9e53-f05dfef188e3", + "sourceNodeId": "int-vel", + "sourcePortIndex": 0, + "targetNodeId": "f9218e45-2173-4f61-bab0-ee382529e284", + "targetPortIndex": 0 + }, + { + "id": "621c650a-65c0-4c03-858b-750b20bf5119", + "sourceNodeId": "int-pos", + "sourcePortIndex": 0, + "targetNodeId": "520462c6-afb1-4ba8-8fff-a8503e23bd98", + "targetPortIndex": 0 + } + ] + }, + "codeContext": { + "code": "" + }, + "simulationSettings": { + "duration": "30", + "dt": "0.1", + "solver": "SSPRK22", + "adaptive": true, + "atol": "1e-8", + "rtol": "0.00001", + "ftol": "1e-9", + "ghostTraces": 0, + "plotResults": true + } +} diff --git a/static/examples/lowpass-filter.json b/static/examples/lowpass-filter.json new file mode 100644 index 00000000..a9ba2c2d --- /dev/null +++ b/static/examples/lowpass-filter.json @@ -0,0 +1,186 @@ +{ + "version": "1.0.0", + "metadata": { + "created": "2025-12-20T20:22:07.168Z", + "modified": "2025-12-20T20:22:07.168Z", + "name": "Lowpass Filter", + "description": "Butterworth filter with spectrum" + }, + "graph": { + "nodes": [ + { + "id": "source", + "type": "SquareWaveSource", + "name": "Square Wave", + "position": { + "x": 630, + "y": 465 + }, + "inputs": [], + "outputs": [ + { + "id": "source-output-0", + "nodeId": "source", + "name": "out 0", + "direction": "output", + "index": 0 + } + ], + "params": { + "frequency": 1, + "amplitude": 1, + "duty": 0.5, + "_rotation": 3, + "phase": "30" + } + }, + { + "id": "filter", + "type": "ButterworthLowpassFilter", + "name": "Lowpass", + "position": { + "x": 780, + "y": 465 + }, + "inputs": [ + { + "id": "filter-input-0", + "nodeId": "filter", + "name": "in 0", + "direction": "input", + "index": 0 + } + ], + "outputs": [ + { + "id": "filter-output-0", + "nodeId": "filter", + "name": "out 0", + "direction": "output", + "index": 0 + } + ], + "params": { + "B": 2, + "n": "3", + "Fc": "2" + } + }, + { + "id": "scope", + "type": "Scope", + "name": "Output", + "position": { + "x": 990, + "y": 345 + }, + "inputs": [ + { + "id": "scope-input-0", + "nodeId": "scope", + "name": "source", + "direction": "input", + "index": 0 + }, + { + "id": "scope-input-1", + "nodeId": "scope", + "name": "filtered", + "direction": "input", + "index": 1 + } + ], + "outputs": [], + "params": { + "sampling_rate": null, + "_rotation": 0 + } + }, + { + "id": "2db43190-56da-4841-939b-3ef151948f09", + "type": "Spectrum", + "name": "Output", + "position": { + "x": 990, + "y": 465 + }, + "inputs": [ + { + "id": "2db43190-56da-4841-939b-3ef151948f09-input-0", + "nodeId": "2db43190-56da-4841-939b-3ef151948f09", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + }, + { + "id": "2db43190-56da-4841-939b-3ef151948f09-input-1", + "nodeId": "2db43190-56da-4841-939b-3ef151948f09", + "name": "in 1", + "direction": "input", + "index": 1, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "freq": "np.linspace(0, 10, 200)", + "t_wait": "0.0", + "alpha": "0.0", + "labels": "[]" + } + } + ], + "connections": [ + { + "id": "c1", + "sourceNodeId": "source", + "sourcePortIndex": 0, + "targetNodeId": "filter", + "targetPortIndex": 0 + }, + { + "id": "c2", + "sourceNodeId": "source", + "sourcePortIndex": 0, + "targetNodeId": "scope", + "targetPortIndex": 0 + }, + { + "id": "c3", + "sourceNodeId": "filter", + "sourcePortIndex": 0, + "targetNodeId": "scope", + "targetPortIndex": 1 + }, + { + "id": "15b7c578-e036-4828-8dbb-4d4cfa3efecc", + "sourceNodeId": "source", + "sourcePortIndex": 0, + "targetNodeId": "2db43190-56da-4841-939b-3ef151948f09", + "targetPortIndex": 0 + }, + { + "id": "371f91f1-4792-4adb-b9e3-8933e93e80ed", + "sourceNodeId": "filter", + "sourcePortIndex": 0, + "targetNodeId": "2db43190-56da-4841-939b-3ef151948f09", + "targetPortIndex": 1 + } + ] + }, + "codeContext": { + "code": "" + }, + "simulationSettings": { + "duration": "10", + "dt": "0.01", + "solver": "SSPRK33", + "adaptive": true, + "atol": "1e-8", + "rtol": "0.00001", + "ftol": "1e-9", + "ghostTraces": 0, + "plotResults": true + } +} diff --git a/static/examples/manifest.json b/static/examples/manifest.json new file mode 100644 index 00000000..d623edbd --- /dev/null +++ b/static/examples/manifest.json @@ -0,0 +1,13 @@ +{ + "files": [ + "bouncing-ball.json", + "cascade-subsystem.json", + "coupled-oscillators.json", + "feedback-loop.json", + "fmcw.json", + "harmonic-oscillator.json", + "lowpass-filter.json", + "pid-subsystem.json", + "vanderpol.json" + ] +} diff --git a/static/examples/pid-subsystem.json b/static/examples/pid-subsystem.json new file mode 100644 index 00000000..628e1bc3 --- /dev/null +++ b/static/examples/pid-subsystem.json @@ -0,0 +1,470 @@ +{ + "version": "1.0.0", + "metadata": { + "created": "2025-12-22T23:03:32.436Z", + "modified": "2025-12-22T23:03:32.436Z", + "name": "PID Subsystem", + "description": "PID control with plant as subsystem" + }, + "graph": { + "nodes": [ + { + "id": "setpoint", + "type": "StepSource", + "name": "Setpoint", + "position": { + "x": 660, + "y": 450 + }, + "inputs": [], + "outputs": [ + { + "id": "setpoint-output-0", + "nodeId": "setpoint", + "name": "out 0", + "direction": "output", + "index": 0 + } + ], + "params": { + "amplitude": "[1, 0.5]", + "t_step": 1, + "tau": "[20+tau_1, 40+tau_2]", + "_rotation": 3 + } + }, + { + "id": "error", + "type": "Adder", + "name": "Error", + "position": { + "x": 795, + "y": 300 + }, + "inputs": [ + { + "id": "error-input-0", + "nodeId": "error", + "name": "in 0", + "direction": "input", + "index": 0 + }, + { + "id": "error-input-1", + "nodeId": "error", + "name": "in 1", + "direction": "input", + "index": 1 + } + ], + "outputs": [ + { + "id": "error-output-0", + "nodeId": "error", + "name": "out 0", + "direction": "output", + "index": 0 + } + ], + "params": { + "operations": "\"+-\"", + "_rotation": 0 + } + }, + { + "id": "pid", + "type": "PID", + "name": "PID", + "position": { + "x": 990, + "y": 300 + }, + "inputs": [ + { + "id": "pid-input-0", + "nodeId": "pid", + "name": "in 0", + "direction": "input", + "index": 0 + } + ], + "outputs": [ + { + "id": "pid-output-0", + "nodeId": "pid", + "name": "out 0", + "direction": "output", + "index": 0 + } + ], + "params": { + "Kp": "1.5", + "Ki": "0.5", + "Kd": "0.1", + "f_max": "10" + } + }, + { + "id": "scope", + "type": "Scope", + "name": "Setpoint vs Plant", + "position": { + "x": 855, + "y": 450 + }, + "inputs": [ + { + "id": "scope-input-0", + "nodeId": "scope", + "name": "setpoint", + "direction": "input", + "index": 0 + }, + { + "id": "scope-input-1", + "nodeId": "scope", + "name": "in 1", + "direction": "input", + "index": 1, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "sampling_rate": null, + "_rotation": 1 + } + }, + { + "id": "98bd8eae-5636-43fa-bdd8-0eace540da0b", + "type": "Scope", + "name": "Error", + "position": { + "x": 885, + "y": 240 + }, + "inputs": [ + { + "id": "98bd8eae-5636-43fa-bdd8-0eace540da0b-input-0", + "nodeId": "98bd8eae-5636-43fa-bdd8-0eace540da0b", + "name": "setpoint", + "direction": "input", + "index": 0 + } + ], + "outputs": [], + "params": { + "sampling_rate": null, + "_rotation": 3 + } + }, + { + "id": "367985a4-df42-46b1-97af-94302aaf3234", + "type": "Subsystem", + "name": "Plant", + "position": { + "x": 990, + "y": 375 + }, + "inputs": [ + { + "id": "367985a4-df42-46b1-97af-94302aaf3234-input-0", + "nodeId": "367985a4-df42-46b1-97af-94302aaf3234", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "367985a4-df42-46b1-97af-94302aaf3234-output-0", + "nodeId": "367985a4-df42-46b1-97af-94302aaf3234", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "_rotation": 2 + }, + "graph": { + "nodes": [ + { + "id": "160f6744-e691-44b9-aac4-49c0a65f76e1", + "type": "Interface", + "name": "Subsystem", + "position": { + "x": 165, + "y": -15 + }, + "inputs": [], + "outputs": [], + "params": {} + }, + { + "id": "3a36165c-154d-4dc2-9744-e20fa06c99b6", + "type": "Integrator", + "name": "Quantity", + "position": { + "x": 240, + "y": 150 + }, + "inputs": [ + { + "id": "3a36165c-154d-4dc2-9744-e20fa06c99b6-input-0", + "nodeId": "3a36165c-154d-4dc2-9744-e20fa06c99b6", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "3a36165c-154d-4dc2-9744-e20fa06c99b6-output-0", + "nodeId": "3a36165c-154d-4dc2-9744-e20fa06c99b6", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "initial_value": "0.0", + "_rotation": 1 + } + }, + { + "id": "5b62efdd-3988-462c-a240-7c45e938d15c", + "type": "Amplifier", + "name": "Gain", + "position": { + "x": 240, + "y": 270 + }, + "inputs": [ + { + "id": "5b62efdd-3988-462c-a240-7c45e938d15c-input-0", + "nodeId": "5b62efdd-3988-462c-a240-7c45e938d15c", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "5b62efdd-3988-462c-a240-7c45e938d15c-output-0", + "nodeId": "5b62efdd-3988-462c-a240-7c45e938d15c", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "gain": "g_rng", + "_rotation": 1 + } + }, + { + "id": "ec16ee5e-e904-47d0-b322-7098b2808e9e", + "type": "Scope", + "name": "Plant Quantity", + "position": { + "x": 345, + "y": 210 + }, + "inputs": [ + { + "id": "ec16ee5e-e904-47d0-b322-7098b2808e9e-input-0", + "nodeId": "ec16ee5e-e904-47d0-b322-7098b2808e9e", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "sampling_rate": null, + "t_wait": "0.0", + "labels": null, + "_rotation": 0 + } + }, + { + "id": "b77166e8-665a-4dca-8081-6947e445d0ba", + "type": "Tanh", + "name": "Saturation", + "position": { + "x": 240, + "y": 60 + }, + "inputs": [ + { + "id": "b77166e8-665a-4dca-8081-6947e445d0ba-input-0", + "nodeId": "b77166e8-665a-4dca-8081-6947e445d0ba", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "b77166e8-665a-4dca-8081-6947e445d0ba-output-0", + "nodeId": "b77166e8-665a-4dca-8081-6947e445d0ba", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "_rotation": 1 + } + } + ], + "connections": [ + { + "id": "4c86a8d3-a6d7-49ac-b24b-5488cb89dd1e", + "sourceNodeId": "3a36165c-154d-4dc2-9744-e20fa06c99b6", + "sourcePortIndex": 0, + "targetNodeId": "ec16ee5e-e904-47d0-b322-7098b2808e9e", + "targetPortIndex": 0 + }, + { + "id": "98694a13-71df-491b-aee9-77c6c75a8bb2", + "sourceNodeId": "3a36165c-154d-4dc2-9744-e20fa06c99b6", + "sourcePortIndex": 0, + "targetNodeId": "5b62efdd-3988-462c-a240-7c45e938d15c", + "targetPortIndex": 0 + }, + { + "id": "223ff17e-05f3-467b-816c-121896812807", + "sourceNodeId": "b77166e8-665a-4dca-8081-6947e445d0ba", + "sourcePortIndex": 0, + "targetNodeId": "3a36165c-154d-4dc2-9744-e20fa06c99b6", + "targetPortIndex": 0 + }, + { + "id": "55d2ccca-0e0a-4360-9be0-4f43638c0f68", + "sourceNodeId": "160f6744-e691-44b9-aac4-49c0a65f76e1", + "sourcePortIndex": 0, + "targetNodeId": "b77166e8-665a-4dca-8081-6947e445d0ba", + "targetPortIndex": 0 + }, + { + "id": "976f3788-0d0f-4133-a8b3-ee43000d5b61", + "sourceNodeId": "5b62efdd-3988-462c-a240-7c45e938d15c", + "sourcePortIndex": 0, + "targetNodeId": "160f6744-e691-44b9-aac4-49c0a65f76e1", + "targetPortIndex": 0 + } + ] + } + }, + { + "id": "4674b251-a882-45b9-baf1-01db4d0f7a22", + "type": "Scope", + "name": "Control Signal", + "position": { + "x": 1125, + "y": 240 + }, + "inputs": [ + { + "id": "4674b251-a882-45b9-baf1-01db4d0f7a22-input-0", + "nodeId": "4674b251-a882-45b9-baf1-01db4d0f7a22", + "name": "setpoint", + "direction": "input", + "index": 0 + } + ], + "outputs": [], + "params": { + "sampling_rate": null, + "_rotation": 3 + } + } + ], + "connections": [ + { + "id": "c1", + "sourceNodeId": "setpoint", + "sourcePortIndex": 0, + "targetNodeId": "error", + "targetPortIndex": 0 + }, + { + "id": "c5", + "sourceNodeId": "error", + "sourcePortIndex": 0, + "targetNodeId": "pid", + "targetPortIndex": 0 + }, + { + "id": "ec0a2ed4-9d00-4c64-abc0-3126ecee69c8", + "sourceNodeId": "error", + "sourcePortIndex": 0, + "targetNodeId": "98bd8eae-5636-43fa-bdd8-0eace540da0b", + "targetPortIndex": 0 + }, + { + "id": "d4dbaa0f-2b25-4612-b1e9-7173e1c2e7a4", + "sourceNodeId": "pid", + "sourcePortIndex": 0, + "targetNodeId": "367985a4-df42-46b1-97af-94302aaf3234", + "targetPortIndex": 0 + }, + { + "id": "fb1602fb-661a-49d5-ad24-c95bdea2f328", + "sourceNodeId": "367985a4-df42-46b1-97af-94302aaf3234", + "sourcePortIndex": 0, + "targetNodeId": "error", + "targetPortIndex": 1 + }, + { + "id": "73846b60-c98b-4933-9a79-02b17b439af7", + "sourceNodeId": "setpoint", + "sourcePortIndex": 0, + "targetNodeId": "scope", + "targetPortIndex": 0 + }, + { + "id": "b82afa70-4a3e-4b10-8adb-4c162bcafce8", + "sourceNodeId": "367985a4-df42-46b1-97af-94302aaf3234", + "sourcePortIndex": 0, + "targetNodeId": "scope", + "targetPortIndex": 1 + }, + { + "id": "bf75489a-0404-41ac-b709-bc0579e2a217", + "sourceNodeId": "pid", + "sourcePortIndex": 0, + "targetNodeId": "4674b251-a882-45b9-baf1-01db4d0f7a22", + "targetPortIndex": 0 + } + ] + }, + "events": [], + "codeContext": { + "code": "# randomized feedback gaing\ng_rng = 0.4 + 0.3*np.random.rand()\n\n# randomized step delay\ntau_1, tau_2 = 5*np.random.rand(2)" + }, + "simulationSettings": { + "duration": "60", + "dt": "0.1", + "solver": "SSPRK22", + "adaptive": true, + "atol": "1e-8", + "rtol": "1e-5", + "ftol": "1e-9", + "dt_min": "", + "dt_max": "", + "ghostTraces": 6, + "plotResults": true + } +} diff --git a/static/examples/vanderpol.json b/static/examples/vanderpol.json new file mode 100644 index 00000000..d4e7b3d3 --- /dev/null +++ b/static/examples/vanderpol.json @@ -0,0 +1,438 @@ +{ + "version": "1.0.0", + "metadata": { + "created": "2025-12-24T13:23:50.390Z", + "modified": "2025-12-24T13:23:50.390Z", + "name": "Van der Pol", + "description": "Nonlinear limit cycle oscillator" + }, + "graph": { + "nodes": [ + { + "id": "fb169c0f-3a05-42dc-8400-94b92be9cee0", + "type": "Integrator", + "name": "X2", + "position": { + "x": 405, + "y": 225 + }, + "inputs": [ + { + "id": "fb169c0f-3a05-42dc-8400-94b92be9cee0-input-0", + "nodeId": "fb169c0f-3a05-42dc-8400-94b92be9cee0", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "fb169c0f-3a05-42dc-8400-94b92be9cee0-output-0", + "nodeId": "fb169c0f-3a05-42dc-8400-94b92be9cee0", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "initial_value": "x20" + } + }, + { + "id": "440919f7-c63c-40d7-8f78-67871a0f89f6", + "type": "Integrator", + "name": "X1", + "position": { + "x": 615, + "y": 225 + }, + "inputs": [ + { + "id": "440919f7-c63c-40d7-8f78-67871a0f89f6-input-0", + "nodeId": "440919f7-c63c-40d7-8f78-67871a0f89f6", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "440919f7-c63c-40d7-8f78-67871a0f89f6-output-0", + "nodeId": "440919f7-c63c-40d7-8f78-67871a0f89f6", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "initial_value": "x10" + } + }, + { + "id": "03d10f15-3f0e-48f4-881b-281485faefd6", + "type": "Adder", + "name": "Difference", + "position": { + "x": 630, + "y": 405 + }, + "inputs": [ + { + "id": "03d10f15-3f0e-48f4-881b-281485faefd6-input-0", + "nodeId": "03d10f15-3f0e-48f4-881b-281485faefd6", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + }, + { + "id": "03d10f15-3f0e-48f4-881b-281485faefd6-input-1", + "nodeId": "03d10f15-3f0e-48f4-881b-281485faefd6", + "name": "in 1", + "direction": "input", + "index": 1, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "03d10f15-3f0e-48f4-881b-281485faefd6-output-0", + "nodeId": "03d10f15-3f0e-48f4-881b-281485faefd6", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "operations": "\"+-\"", + "_rotation": 1 + } + }, + { + "id": "9e7d89cc-bd31-4615-b34b-1b7a11850be3", + "type": "Pow", + "name": "Square", + "position": { + "x": 735, + "y": 300 + }, + "inputs": [ + { + "id": "9e7d89cc-bd31-4615-b34b-1b7a11850be3-input-0", + "nodeId": "9e7d89cc-bd31-4615-b34b-1b7a11850be3", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "9e7d89cc-bd31-4615-b34b-1b7a11850be3-output-0", + "nodeId": "9e7d89cc-bd31-4615-b34b-1b7a11850be3", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "exponent": "2", + "_rotation": 1 + } + }, + { + "id": "8db3f239-5418-4068-b92c-18ee1d42c427", + "type": "Amplifier", + "name": "Param-Mu", + "position": { + "x": 510, + "y": 585 + }, + "inputs": [ + { + "id": "8db3f239-5418-4068-b92c-18ee1d42c427-input-0", + "nodeId": "8db3f239-5418-4068-b92c-18ee1d42c427", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "8db3f239-5418-4068-b92c-18ee1d42c427-output-0", + "nodeId": "8db3f239-5418-4068-b92c-18ee1d42c427", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "gain": "mu", + "_rotation": 1 + } + }, + { + "id": "3c9f84cd-ee77-4232-9596-aaf057dd0254", + "type": "Constant", + "name": "One", + "position": { + "x": 615, + "y": 300 + }, + "inputs": [], + "outputs": [ + { + "id": "3c9f84cd-ee77-4232-9596-aaf057dd0254-output-0", + "nodeId": "3c9f84cd-ee77-4232-9596-aaf057dd0254", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "value": "1", + "_rotation": 1 + } + }, + { + "id": "f94de1eb-7d85-42ab-8af5-69e0bd3cbf7d", + "type": "Multiplier", + "name": "Multiplier", + "position": { + "x": 510, + "y": 510 + }, + "inputs": [ + { + "id": "f94de1eb-7d85-42ab-8af5-69e0bd3cbf7d-input-0", + "nodeId": "f94de1eb-7d85-42ab-8af5-69e0bd3cbf7d", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + }, + { + "id": "f94de1eb-7d85-42ab-8af5-69e0bd3cbf7d-input-1", + "nodeId": "f94de1eb-7d85-42ab-8af5-69e0bd3cbf7d", + "name": "in 1", + "direction": "input", + "index": 1, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "f94de1eb-7d85-42ab-8af5-69e0bd3cbf7d-output-0", + "nodeId": "f94de1eb-7d85-42ab-8af5-69e0bd3cbf7d", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "_rotation": 1 + } + }, + { + "id": "ee2391d1-5c61-45de-859b-ff98ad13f4a4", + "type": "Adder", + "name": "Difference", + "position": { + "x": 810, + "y": 690 + }, + "inputs": [ + { + "id": "ee2391d1-5c61-45de-859b-ff98ad13f4a4-input-0", + "nodeId": "ee2391d1-5c61-45de-859b-ff98ad13f4a4", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + }, + { + "id": "ee2391d1-5c61-45de-859b-ff98ad13f4a4-input-1", + "nodeId": "ee2391d1-5c61-45de-859b-ff98ad13f4a4", + "name": "in 1", + "direction": "input", + "index": 1, + "color": "#969696" + } + ], + "outputs": [ + { + "id": "ee2391d1-5c61-45de-859b-ff98ad13f4a4-output-0", + "nodeId": "ee2391d1-5c61-45de-859b-ff98ad13f4a4", + "name": "out 0", + "direction": "output", + "index": 0, + "color": "#969696" + } + ], + "params": { + "operations": "\"+-\"", + "_rotation": 1 + } + }, + { + "id": "34e172de-4aa4-4f33-9844-f78184aa1a19", + "type": "Scope", + "name": "X1(t)", + "position": { + "x": 735, + "y": 135 + }, + "inputs": [ + { + "id": "34e172de-4aa4-4f33-9844-f78184aa1a19-input-0", + "nodeId": "34e172de-4aa4-4f33-9844-f78184aa1a19", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "_rotation": 3 + } + }, + { + "id": "b2b48c31-985f-4917-bef9-6dcecdc2e928", + "type": "Scope", + "name": "X2(t)", + "position": { + "x": 495, + "y": 135 + }, + "inputs": [ + { + "id": "b2b48c31-985f-4917-bef9-6dcecdc2e928-input-0", + "nodeId": "b2b48c31-985f-4917-bef9-6dcecdc2e928", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" + } + ], + "outputs": [], + "params": { + "_rotation": 3 + } + } + ], + "connections": [ + { + "id": "48eef8a3-5625-4502-b8cd-ebfa3a257e5e", + "sourceNodeId": "fb169c0f-3a05-42dc-8400-94b92be9cee0", + "sourcePortIndex": 0, + "targetNodeId": "440919f7-c63c-40d7-8f78-67871a0f89f6", + "targetPortIndex": 0 + }, + { + "id": "0dda274c-5717-4299-b0b4-33a57d3e7a39", + "sourceNodeId": "440919f7-c63c-40d7-8f78-67871a0f89f6", + "sourcePortIndex": 0, + "targetNodeId": "9e7d89cc-bd31-4615-b34b-1b7a11850be3", + "targetPortIndex": 0 + }, + { + "id": "add1302b-0e10-4f52-8063-f00ebd9ffa96", + "sourceNodeId": "9e7d89cc-bd31-4615-b34b-1b7a11850be3", + "sourcePortIndex": 0, + "targetNodeId": "03d10f15-3f0e-48f4-881b-281485faefd6", + "targetPortIndex": 1 + }, + { + "id": "47f7860d-96e2-4b70-ac67-0f09bc1ca28c", + "sourceNodeId": "3c9f84cd-ee77-4232-9596-aaf057dd0254", + "sourcePortIndex": 0, + "targetNodeId": "03d10f15-3f0e-48f4-881b-281485faefd6", + "targetPortIndex": 0 + }, + { + "id": "0eff1dcf-d2fa-4f3a-a595-fb45dfa93495", + "sourceNodeId": "fb169c0f-3a05-42dc-8400-94b92be9cee0", + "sourcePortIndex": 0, + "targetNodeId": "f94de1eb-7d85-42ab-8af5-69e0bd3cbf7d", + "targetPortIndex": 0 + }, + { + "id": "bc74531b-f510-4083-91e5-cfeb344f59ed", + "sourceNodeId": "03d10f15-3f0e-48f4-881b-281485faefd6", + "sourcePortIndex": 0, + "targetNodeId": "f94de1eb-7d85-42ab-8af5-69e0bd3cbf7d", + "targetPortIndex": 1 + }, + { + "id": "52bd1f46-7dac-4b85-8df7-90f69d737551", + "sourceNodeId": "f94de1eb-7d85-42ab-8af5-69e0bd3cbf7d", + "sourcePortIndex": 0, + "targetNodeId": "8db3f239-5418-4068-b92c-18ee1d42c427", + "targetPortIndex": 0 + }, + { + "id": "b506a182-540d-40f5-9dca-4f375de4c1be", + "sourceNodeId": "8db3f239-5418-4068-b92c-18ee1d42c427", + "sourcePortIndex": 0, + "targetNodeId": "ee2391d1-5c61-45de-859b-ff98ad13f4a4", + "targetPortIndex": 0 + }, + { + "id": "197efb22-ce6a-456c-96a3-eab24b577df6", + "sourceNodeId": "440919f7-c63c-40d7-8f78-67871a0f89f6", + "sourcePortIndex": 0, + "targetNodeId": "ee2391d1-5c61-45de-859b-ff98ad13f4a4", + "targetPortIndex": 1 + }, + { + "id": "66ef60f3-ad5d-4867-a361-2433230846d5", + "sourceNodeId": "ee2391d1-5c61-45de-859b-ff98ad13f4a4", + "sourcePortIndex": 0, + "targetNodeId": "fb169c0f-3a05-42dc-8400-94b92be9cee0", + "targetPortIndex": 0 + }, + { + "id": "99b20bfb-e303-44cb-a0f4-ef0f4fd67cf0", + "sourceNodeId": "440919f7-c63c-40d7-8f78-67871a0f89f6", + "sourcePortIndex": 0, + "targetNodeId": "34e172de-4aa4-4f33-9844-f78184aa1a19", + "targetPortIndex": 0 + }, + { + "id": "63219387-af24-4b22-b7c4-f09c340ccb54", + "sourceNodeId": "fb169c0f-3a05-42dc-8400-94b92be9cee0", + "sourcePortIndex": 0, + "targetNodeId": "b2b48c31-985f-4917-bef9-6dcecdc2e928", + "targetPortIndex": 0 + } + ] + }, + "events": [], + "codeContext": { + "code": "# initial conditions\nx10, x20 = 2, 0\n\n# van der pol parameter\nmu = 1000 * (1 + 0.1*np.random.rand())" + }, + "simulationSettings": { + "duration": "3*mu", + "dt": "", + "solver": "GEAR52A", + "adaptive": true, + "atol": "1e-6", + "rtol": "1e-4", + "ftol": "1e-8", + "dt_min": "", + "dt_max": "", + "ghostTraces": 6, + "plotResults": true + } +} diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..815eeee072bf892255411da785f82a04145a0497 GIT binary patch literal 15399 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 PvWL>rzg2S6?yvs=VeBeP literal 0 HcmV?d00001 diff --git a/static/pathview_logo.png b/static/pathview_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ee2678ade083941088976c778ac021566504d832 GIT binary patch literal 21407 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�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 Date: Tue, 13 Jan 2026 21:58:17 +0100 Subject: [PATCH 004/656] Configure build step for production environment Set NODE_ENV to production during build step --- .github/workflows/deploy.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2cafa3ad..62838e76 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -41,6 +41,8 @@ jobs: - name: Build run: npm run build + env: + NODE_ENV: production - name: Setup Pages uses: actions/configure-pages@v4 From 3e57bbc6a632e0e80f341fd2801eb9b7fe8f0f43 Mon Sep 17 00:00:00 2001 From: Milan Rother <105657697+milanofthe@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:10:32 +0100 Subject: [PATCH 005/656] Update favicon and logo paths to use base variable --- src/routes/+page.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7f21790b..ca35dbd9 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -821,7 +821,7 @@ PathView - + @@ -830,7 +830,7 @@
        From 9efa48496c7e229ceef53c44d49d31f3c69678c7 Mon Sep 17 00:00:00 2001 From: Milan Rother <105657697+milanofthe@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:12:39 +0100 Subject: [PATCH 006/656] Update fetch URLs to use base path --- src/lib/components/WelcomeModal.svelte | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lib/components/WelcomeModal.svelte b/src/lib/components/WelcomeModal.svelte index 85bbcfe7..ff7a6776 100644 --- a/src/lib/components/WelcomeModal.svelte +++ b/src/lib/components/WelcomeModal.svelte @@ -1,5 +1,6 @@ + From a0443a382352402e2981ba4fec47e214e2223169 Mon Sep 17 00:00:00 2001 From: RemDelaporteMathurin Date: Wed, 14 Jan 2026 12:56:15 -0500 Subject: [PATCH 024/656] added splitter --- scripts/extract-blocks.py | 7 ++++++- src/lib/nodes/generated/blocks.ts | 22 +++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/scripts/extract-blocks.py b/scripts/extract-blocks.py index 60d50253..b100af59 100644 --- a/scripts/extract-blocks.py +++ b/scripts/extract-blocks.py @@ -102,7 +102,11 @@ "Spectrum", ], # Subsystem blocks are manually defined in subsystem.ts but we still extract their docstrings - "Chemical": ["Process", "Bubbler4"], + "Chemical": [ + "Process", + "Bubbler4", + "Splitter", + ], } # Extra blocks to extract docstrings from, but not auto-register @@ -171,6 +175,7 @@ # Recording - unlimited inputs, no outputs "Scope": {"maxInputs": None, "maxOutputs": 0}, "Spectrum": {"maxInputs": None, "maxOutputs": 0}, + "Splitter": {"maxInputs": 1, "maxOutputs": None}, } # Parameter overrides - PathSim handles all validation at runtime diff --git a/src/lib/nodes/generated/blocks.ts b/src/lib/nodes/generated/blocks.ts index 3c021928..e0f8ed8f 100644 --- a/src/lib/nodes/generated/blocks.ts +++ b/src/lib/nodes/generated/blocks.ts @@ -1233,6 +1233,22 @@ export const extractedBlocks: Record = "sample_out" ] }, + "Splitter": { + "blockClass": "Splitter", + "description": "Splitter block that splits the input signal into multiple", + "docstringHtml": "

        Splitter block that splits the input signal into multiple\noutputs weighted with the specified fractions.

        \n
        \n

        Note

        \n

        The output fractions must sum to one.

        \n
        \n
        \n

        Parameters

        \n
        \n
        fractions : np.ndarray | list
        \n
        fractions to split the input signal into,\nmust sum up to one
        \n
        \n
        \n", + "params": { + "fractions": { + "type": "any", + "default": null, + "description": "fractions to split the input signal into, must sum up to one" + } + }, + "inputs": [ + "in" + ], + "outputs": [] + }, "Subsystem": { "blockClass": "Subsystem", "description": "Subsystem class that holds its own blocks and connecions and", @@ -1278,7 +1294,7 @@ export const blockConfig: Record, string[]> = Algebraic: ["Adder", "Multiplier", "Amplifier", "Function", "Sin", "Cos", "Tan", "Tanh", "Abs", "Sqrt", "Exp", "Log", "Log10", "Mod", "Clip", "Pow", "Switch", "LUT", "LUT1D"], Mixed: ["SampleHold", "FIR", "ADC", "DAC", "Counter", "CounterUp", "CounterDown", "Relay"], Recording: ["Scope", "Spectrum"], - Chemical: ["Process", "Bubbler4"], + Chemical: ["Process", "Bubbler4", "Splitter"], }; export const uiOverrides: Record = @@ -1494,5 +1510,9 @@ export const uiOverrides: Record = "Spectrum": { "maxInputs": null, "maxOutputs": 0 + }, + "Splitter": { + "maxInputs": 1, + "maxOutputs": null } }; From 1019109182daeb9b158661d5795d904d79b25be3 Mon Sep 17 00:00:00 2001 From: RemDelaporteMathurin Date: Wed, 14 Jan 2026 16:58:24 -0500 Subject: [PATCH 025/656] new pathsim-chem version + override input ouput mappings --- scripts/extract-blocks.py | 26 +++++++++ src/lib/nodes/generated/blocks.ts | 64 ++++++++++++++++++++++- src/lib/pyodide/backend/pyodide/worker.ts | 2 +- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/scripts/extract-blocks.py b/scripts/extract-blocks.py index b100af59..cd7dfb34 100644 --- a/scripts/extract-blocks.py +++ b/scripts/extract-blocks.py @@ -106,6 +106,7 @@ "Process", "Bubbler4", "Splitter", + "GLC", ], } @@ -181,6 +182,23 @@ # Parameter overrides - PathSim handles all validation at runtime PARAM_OVERRIDES: dict[str, dict] = {} +# Input/output mapping overrides if can't be extracted automatically +IO_OVERRIDES: dict[str, dict] = { + "GLC": { + "inputs": ["c_T_in", "flow_l", "y_T2_inlet", "flow_g"], + "outputs": [ + "c_T_out", + "y_T2_out", + "eff", + "P_out", + "Q_l", + "Q_g_out", + "n_T_out_liquid", + "n_T_out_gas", + ], + } +} + def rst_to_html(rst_text: str) -> str: """Convert RST docstring to HTML, preserving LaTeX math for KaTeX rendering.""" @@ -436,6 +454,14 @@ def main(): if block_data: extracted_blocks[block_name] = block_data + # Override inputs/outputs from IO_OVERRIDES + for block_name, io_data in IO_OVERRIDES.items(): + if block_name in extracted_blocks: + if "inputs" in io_data: + extracted_blocks[block_name]["inputs"] = io_data["inputs"] + if "outputs" in io_data: + extracted_blocks[block_name]["outputs"] = io_data["outputs"] + print(f"Extracted {len(extracted_blocks)} blocks") # Generate TypeScript diff --git a/src/lib/nodes/generated/blocks.ts b/src/lib/nodes/generated/blocks.ts index e0f8ed8f..41140cd4 100644 --- a/src/lib/nodes/generated/blocks.ts +++ b/src/lib/nodes/generated/blocks.ts @@ -1247,7 +1247,67 @@ export const extractedBlocks: Record = "inputs": [ "in" ], - "outputs": [] + "outputs": [ + "out 1.0" + ] + }, + "GLC": { + "blockClass": "GLC", + "description": "Gas Liquid Contactor model block.", + "docstringHtml": "

        Gas Liquid Contactor model block. Inherits from Function block.

        \n

        More details about the model can be found in: https://doi.org/10.13182/FST95-A30485

        \n
        \n
        Args:
        \n
        P_in: Inlet operating pressure [Pa]\nL: Column height [m]\nD: Column diameter [m]\nT: Temperature [K]\ng: Gravitational acceleration [m/s^2], default is 9.81\ninitial_nb_of_elements: Initial number of elements for BVP solver\nBCs: Boundary conditions type, "C-C" (Closed-Closed) or "O-C" (Open-Closed), default is "C-C"
        \n
        \n", + "params": { + "P_in": { + "type": "any", + "default": null, + "description": "L: Column height [m]" + }, + "L": { + "type": "any", + "default": null, + "description": "D: Column diameter [m]" + }, + "D": { + "type": "any", + "default": null, + "description": "T: Temperature [K]" + }, + "T": { + "type": "any", + "default": null, + "description": "g: Gravitational acceleration [m/s^2], default is 9.81" + }, + "BCs": { + "type": "any", + "default": null, + "description": "" + }, + "g": { + "type": "number", + "default": "9.80665", + "description": "initial_nb_of_elements: Initial number of elements for BVP solver" + }, + "initial_nb_of_elements": { + "type": "integer", + "default": "20", + "description": "BCs: Boundary conditions type, \"C-C\" (Closed-Closed) or \"O-C\" (Open-Closed), default is \"C-C\"" + } + }, + "inputs": [ + "c_T_in", + "flow_l", + "y_T2_inlet", + "flow_g" + ], + "outputs": [ + "c_T_out", + "y_T2_out", + "eff", + "P_out", + "Q_l", + "Q_g_out", + "n_T_out_liquid", + "n_T_out_gas" + ] }, "Subsystem": { "blockClass": "Subsystem", @@ -1294,7 +1354,7 @@ export const blockConfig: Record, string[]> = Algebraic: ["Adder", "Multiplier", "Amplifier", "Function", "Sin", "Cos", "Tan", "Tanh", "Abs", "Sqrt", "Exp", "Log", "Log10", "Mod", "Clip", "Pow", "Switch", "LUT", "LUT1D"], Mixed: ["SampleHold", "FIR", "ADC", "DAC", "Counter", "CounterUp", "CounterDown", "Relay"], Recording: ["Scope", "Spectrum"], - Chemical: ["Process", "Bubbler4", "Splitter"], + Chemical: ["Process", "Bubbler4", "Splitter", "GLC"], }; export const uiOverrides: Record = diff --git a/src/lib/pyodide/backend/pyodide/worker.ts b/src/lib/pyodide/backend/pyodide/worker.ts index eac09398..55038818 100644 --- a/src/lib/pyodide/backend/pyodide/worker.ts +++ b/src/lib/pyodide/backend/pyodide/worker.ts @@ -60,7 +60,7 @@ await micropip.install('pathsim') send({ type: 'progress', value: PROGRESS_MESSAGES.INSTALLING_PATHSIM_CHEM }); await pyodide.runPythonAsync(` import micropip -await micropip.install('pathsim-chem') +await micropip.install('pathsim-chem>=0.2rc2', pre=True) `); // Verify and print version From a0b7c37f85257ac7d166fde716d1ec4f8a50fa42 Mon Sep 17 00:00:00 2001 From: Milan Rother <105657697+milanofthe@users.noreply.github.com> Date: Thu, 15 Jan 2026 10:15:09 +0100 Subject: [PATCH 026/656] Update deployment workflow to install additional packages install pathsim-chem for built data extraction --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7b14838a..b52a974c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -31,7 +31,7 @@ jobs: python-version: '3.11' - name: Install PathSim - run: pip install pathsim docutils + run: pip install pathsim pathsim-chem docutils - name: Install dependencies run: npm ci From e02a32833c626fe65b1f25d68caae05f63593b00 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Thu, 15 Jan 2026 10:42:42 +0100 Subject: [PATCH 027/656] Add URL parameter loading for ?model= and ?modelgh= parameters --- src/routes/+page.svelte | 130 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 76ea3699..029e2df9 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -64,6 +64,11 @@ let hasAutoOpenedPlot = $state(false); // Only auto-open once let hasAutoOpenedConsole = $state(false); // Only auto-open once let showWelcomeModal = $state(true); // Show on startup + let urlLoadingState = $state<{ loading: boolean; url: string | null; error: string | null }>({ + loading: false, + url: null, + error: null + }); // Track widths directly - initialized on first dual-panel open let consolePanelWidth = $state(undefined); @@ -433,6 +438,9 @@ window.addEventListener('run-simulation', handleRunSimulation); window.addEventListener('continue-simulation', handleContinueSimulation); + // Check for URL parameters to load model + loadFromUrlParam(); + return () => { // Cleanup store subscriptions unsubPinnedPreviews(); @@ -788,6 +796,71 @@ } } + /** + * Expand GitHub shorthand to raw.githubusercontent.com URL + * Format: owner/repo/path/to/file.pvm + * Expands to: https://raw.githubusercontent.com/owner/repo/main/path/to/file.pvm + */ + function expandGitHubShorthand(shorthand: string): string { + const parts = shorthand.split('/'); + if (parts.length < 3) { + throw new Error('Invalid GitHub shorthand. Use: owner/repo/path/to/file.pvm'); + } + const owner = parts[0]; + const repo = parts[1]; + const pathParts = parts.slice(2); + // Default to 'main' branch + const branch = 'main'; + const filePath = pathParts.join('/'); + return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${filePath}`; + } + + /** + * Load model from URL parameter on page load + */ + async function loadFromUrlParam(): Promise { + const params = new URLSearchParams(window.location.search); + const modelUrl = params.get('model'); + const modelGh = params.get('modelgh'); + + let url: string | null = null; + + if (modelUrl) { + url = modelUrl; + } else if (modelGh) { + try { + url = expandGitHubShorthand(modelGh); + } catch (e) { + consoleStore.error(`Invalid GitHub shorthand: ${modelGh}`); + consoleStore.error('Expected format: owner/repo/path/to/file.pvm'); + return; + } + } + + if (!url) return; + + // Hide welcome modal and show loading state + showWelcomeModal = false; + urlLoadingState = { loading: true, url, error: null }; + + try { + const file = await loadGraphFromUrl(url); + if (file) { + // Trigger fit view after nodes render + setTimeout(() => triggerFitView(), 100); + urlLoadingState = { loading: false, url: null, error: null }; + } else { + throw new Error('Failed to load model'); + } + } catch (e) { + const errorMsg = e instanceof Error ? e.message : 'Unknown error'; + urlLoadingState = { loading: false, url: null, error: errorMsg }; + consoleStore.error(`Failed to load model from URL: ${url}`); + consoleStore.error(errorMsg); + showConsole = true; + } + } + // Track placement offset for stacking prevention let placementOffset = 0; let lastPlacementTime = 0; @@ -1197,6 +1270,21 @@ onClose={() => showWelcomeModal = false} /> {/if} + + + {#if urlLoadingState.loading} +
        +
        + + Loading model... + {#if urlLoadingState.url} + + {urlLoadingState.url.length > 60 ? urlLoadingState.url.slice(0, 60) + '...' : urlLoadingState.url} + + {/if} +
        +
        + {/if}
        From 53ff584d735e51637a1d3f50d5922fd1c8be0701 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Thu, 15 Jan 2026 11:07:12 +0100 Subject: [PATCH 028/656] Make pathsim_chem optional in block extraction and regenerate blocks --- package-lock.json | 12 ++ scripts/extract-blocks.py | 16 +- src/lib/nodes/generated/blocks.ts | 267 ++++++++++-------------------- static/examples/manifest.json | 6 +- 4 files changed, 114 insertions(+), 187 deletions(-) diff --git a/package-lock.json b/package-lock.json index 68522769..5ea53ba9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1218,6 +1218,7 @@ "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1257,6 +1258,7 @@ "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -1365,6 +1367,7 @@ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1411,6 +1414,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1708,6 +1712,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -1858,6 +1863,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2590,6 +2596,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2623,6 +2630,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2746,6 +2754,7 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -2973,6 +2982,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.0.tgz", "integrity": "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3140,6 +3150,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3178,6 +3189,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/scripts/extract-blocks.py b/scripts/extract-blocks.py index cd7dfb34..716f9079 100644 --- a/scripts/extract-blocks.py +++ b/scripts/extract-blocks.py @@ -31,7 +31,12 @@ from pathsim.blocks import * from pathsim.subsystem import Subsystem, Interface -from pathsim_chem.tritium import * +try: + from pathsim_chem.tritium import * + HAS_PATHSIM_CHEM = True +except ImportError: + HAS_PATHSIM_CHEM = False + print("Warning: pathsim_chem not installed, Chemical blocks will be skipped") # Block configuration - defines which blocks to extract and their categories BLOCK_CONFIG = { @@ -441,6 +446,10 @@ def main(): # Extract all blocks extracted_blocks = {} for category, block_names in BLOCK_CONFIG.items(): + # Skip Chemical category if pathsim_chem not available + if category == "Chemical" and not HAS_PATHSIM_CHEM: + print(f" Skipping {category} category (pathsim_chem not installed)") + continue for block_name in block_names: print(f" Extracting {block_name}...") block_data = extract_block(block_name) @@ -464,8 +473,11 @@ def main(): print(f"Extracted {len(extracted_blocks)} blocks") + # Filter config to only include categories that were extracted + filtered_config = {k: v for k, v in BLOCK_CONFIG.items() if k != "Chemical" or HAS_PATHSIM_CHEM} + # Generate TypeScript - ts_content = generate_typescript(extracted_blocks, BLOCK_CONFIG, UI_OVERRIDES) + ts_content = generate_typescript(extracted_blocks, filtered_config, UI_OVERRIDES) # Write output file output_path = ( diff --git a/src/lib/nodes/generated/blocks.ts b/src/lib/nodes/generated/blocks.ts index 41140cd4..facc9ec2 100644 --- a/src/lib/nodes/generated/blocks.ts +++ b/src/lib/nodes/generated/blocks.ts @@ -50,7 +50,7 @@ export const extractedBlocks: Record = "Source": { "blockClass": "Source", "description": "Source that produces an arbitrary time dependent output,", - "docstringHtml": "

        Source that produces an arbitrary time dependent output,\ndefined by the func (callable).

        \n
        \n\\begin{equation*}\ny(t) = \\mathrm{func}(t)\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its internal function (func) will\nbe called multiple times per timestep, each time when Simulation._update(t)\nis called in the global simulation loop.

        \n
        \n
        \n

        Example

        \n

        For example a ramp:

        \n
        \nfrom pathsim.blocks import Source\n\nsrc = Source(lambda t : t)\n
        \n

        or a simple sinusoid with some frequency:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import Source\n\n#some parameter\nomega = 100\n\n#the function that gets evaluated\ndef f(t):\n    return np.sin(omega * t)\n\nsrc = Source(f)\n
        \n

        Because the Source block only has a single argument, it can be\nused to decorate a function and make it a PathSim block. This might\nbe handy in some cases to keep definitions concise and localized\nin the code:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import Source\n\n#does the same as the definition above\n\n@Source\ndef src(t):\n    omega = 100\n    return np.sin(omega * t)\n\n#'src' is now a PathSim block\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        function defining time dependent block output
        \n
        \n
        \n", + "docstringHtml": "

        Source that produces an arbitrary time dependent output,\ndefined by the func (callable).

        \n
        \n\\begin{equation*}\ny(t) = \\mathrm{func}(t)\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its internal function (func) will\nbe called multiple times per timestep, each time when Simulation._update(t)\nis called in the global simulation loop.

        \n
        \n
        \n

        Example

        \n

        For example a ramp:

        \n
        \nfrom pathsim.blocks import Source\n\nsrc = Source(lambda t : t)\n
        \n

        or a simple sinusoid with some frequency:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import Source\n\n#some parameter\nomega = 100\n\n#the function that gets evaluated\ndef f(t):\n    return np.sin(omega * t)\n\nsrc = Source(f)\n
        \n

        Because the Source block only has a single argument, it can be\nused to decorate a function and make it a PathSim block. This might\nbe handy in some cases to keep definitions concise and localized\nin the code:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import Source\n\n#does the same as the definition above\n\n@Source\ndef src(t):\n    omega = 100\n    return np.sin(omega * t)\n\n#'src' is now a PathSim block\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        function defining time dependent block output
        \n
        \n
        \n", "params": { "func": { "type": "callable", @@ -92,7 +92,7 @@ export const extractedBlocks: Record = "StepSource": { "blockClass": "StepSource", "description": "Discrete time unit step source block.", - "docstringHtml": "

        Discrete time unit step source block.

        \n

        Utilizes a scheduled event to set the block output\nto the specified output levels at the defined event times.

        \n

        The arguments can be vectorial and in that case, the output is set to the\namplitude that corresponds to the defined delay like a zero-order-hold stage.\nThis functionality enables adding external or time series measurement data\ninto the system.

        \n
        \n

        Examples

        \n

        This is how to use the source as a unit step source:

        \n
        \nfrom pathsim.blocks import StepSource\n\n#default, starts at 0, jumps to 1\nstp = StepSource()\n
        \n

        And this is how to configure it with multiple consecutive steps:

        \n
        \nfrom pathsim.blocks import StepSource\n\n#starts at 0, jumps to 1 at 1, jumps to -1 at 2 and jumps back to 0 at 3\nstp = StepSource(amplitude=[1, -1, 0], tau=[1, 2, 3])\n
        \n

        Similarly implementing measured time series data via zoh:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import StepSource\n\n#some random time series arrays\ntimes, data = np.linspace(0, 100, 1000), np.random.rand(1000)\n\n#pass them to the block\nstp = StepSource(amplitude=data, tau=times)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        amplitude : float | list[float]
        \n
        amplitude of the step signal, or amplitudes / output\nlevels of the multiple steps
        \n
        tau : float | list[float]
        \n
        delay of the step, or delays of the different steps
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        Evt : ScheduleList
        \n
        internal scheduled event directly accessible
        \n
        events : list[ScheduleList]
        \n
        list of interna events
        \n
        \n
        \n", + "docstringHtml": "

        Discrete time unit step source block.

        \n

        Utilizes a scheduled event to set the block output\nto the specified output levels at the defined event times.

        \n

        The arguments can be vectorial and in that case, the output is set to the\namplitude that corresponds to the defined delay like a zero-order-hold stage.\nThis functionality enables adding external or time series measurement data\ninto the system.

        \n
        \n

        Examples

        \n

        This is how to use the source as a unit step source:

        \n
        \nfrom pathsim.blocks import StepSource\n\n#default, starts at 0, jumps to 1\nstp = StepSource()\n
        \n

        And this is how to configure it with multiple consecutive steps:

        \n
        \nfrom pathsim.blocks import StepSource\n\n#starts at 0, jumps to 1 at 1, jumps to -1 at 2 and jumps back to 0 at 3\nstp = StepSource(amplitude=[1, -1, 0], tau=[1, 2, 3])\n
        \n

        Similarly implementing measured time series data via zoh:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import StepSource\n\n#some random time series arrays\ntimes, data = np.linspace(0, 100, 1000), np.random.rand(1000)\n\n#pass them to the block\nstp = StepSource(amplitude=data, tau=times)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        amplitude : float | list[float]
        \n
        amplitude of the step signal, or amplitudes / output\nlevels of the multiple steps
        \n
        tau : float | list[float]
        \n
        delay of the step, or delays of the different steps
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        Evt : ScheduleList
        \n
        internal scheduled event directly accessible
        \n
        events : list[ScheduleList]
        \n
        list of interna events
        \n
        \n
        \n", "params": { "amplitude": { "type": "integer", @@ -367,7 +367,7 @@ export const extractedBlocks: Record = "Integrator": { "blockClass": "Integrator", "description": "Integrates the input signal using a numerical integration engine like this:", - "docstringHtml": "

        Integrates the input signal using a numerical integration engine like this:

        \n
        \n\\begin{equation*}\ny(t) = \\int_0^t u(\\tau) \\ d \\tau\n\\end{equation*}\n
        \n

        or in differential form like this:

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x}(t) &= u(t) \\\\\n y(t) &= x(t)\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        The Integrator block is inherently MIMO capable, so u and y can be vectors.

        \n
        \n

        Example

        \n

        This is how to initialize the integrator:

        \n
        \n#initial value 0.0\ni1 = Integrator()\n\n#initial value 2.5\ni2 = Integrator(2.5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        initial_value : float, array
        \n
        initial value of integrator
        \n
        \n
        \n", + "docstringHtml": "

        Integrates the input signal using a numerical integration engine like this:

        \n
        \n\\begin{equation*}\ny(t) = \\int_0^t u(\\tau) \\ d \\tau\n\\end{equation*}\n
        \n

        or in differential form like this:

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x}(t) &= u(t) \\\\\n y(t) &= x(t)\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        The Integrator block is inherently MIMO capable, so u and y can be vectors.

        \n
        \n

        Example

        \n

        This is how to initialize the integrator:

        \n
        \n#initial value 0.0\ni1 = Integrator()\n\n#initial value 2.5\ni2 = Integrator(2.5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        initial_value : float, array
        \n
        initial value of integrator
        \n
        \n
        \n", "params": { "initial_value": { "type": "number", @@ -381,7 +381,7 @@ export const extractedBlocks: Record = "Differentiator": { "blockClass": "Differentiator", "description": "Differentiates the input signal (SISO) using a first order transfer function", - "docstringHtml": "

        Differentiates the input signal (SISO) using a first order transfer function\nwith a pole at the origin which implements a high pass filter.

        \n
        \n\\begin{equation*}\nH_\\mathrm{diff}(s) = \\frac{s}{1 + s / f_\\mathrm{max}}\n\\end{equation*}\n
        \n

        The approximation holds for signals up to a frequency of approximately f_max.

        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.

        \n
        \n
        \n

        Note

        \n

        Since this is an approximation of real differentiation, the approximation will not hold\nif there are high frequency components present in the signal. For example if you have\ndiscontinuities such as steps or squere waves.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz\nD = Differentiator(f_max=1e3)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", + "docstringHtml": "

        Differentiates the input signal (SISO) using a first order transfer function\nwith a pole at the origin which implements a high pass filter.

        \n
        \n\\begin{equation*}\nH_\\mathrm{diff}(s) = \\frac{s}{1 + s / f_\\mathrm{max}}\n\\end{equation*}\n
        \n

        The approximation holds for signals up to a frequency of approximately f_max.

        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.

        \n
        \n
        \n

        Note

        \n

        Since this is an approximation of real differentiation, the approximation will not hold\nif there are high frequency components present in the signal. For example if you have\ndiscontinuities such as steps or squere waves.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz\nD = Differentiator(f_max=1e3)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": { "f_max": { "type": "number", @@ -389,17 +389,13 @@ export const extractedBlocks: Record = "description": "highest expected signal frequency" } }, - "inputs": [ - "in" - ], - "outputs": [ - "out" - ] + "inputs": [], + "outputs": [] }, "Delay": { "blockClass": "Delay", "description": "Delays the input signal by a time constant 'tau' in seconds", - "docstringHtml": "

        Delays the input signal by a time constant 'tau' in seconds\nusing an adaptive rolling buffer.

        \n

        Mathematically this block creates a time delay of the input signal like this:

        \n
        \n\\begin{equation*}\ny(t) =\n\\begin{cases}\nx(t - \\tau) & , t \\geq \\tau \\\\\n0 & , t < \\tau\n\\end{cases}\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        The internal adaptive buffer uses interpolation for the evaluation. This is\nrequired to be compatible with variable step solvers. It has a drawback however.\nThe order of the ode solver used will degrade when this block is used, due to\nthe interpolation.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#5 time units delay\nD = Delay(tau=5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        tau : float
        \n
        delay time constant
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        _buffer : AdaptiveBuffer
        \n
        internal interpolatable adaptive rolling buffer
        \n
        \n
        \n", + "docstringHtml": "

        Delays the input signal by a time constant 'tau' in seconds\nusing an adaptive rolling buffer.

        \n

        Mathematically this block creates a time delay of the input signal like this:

        \n
        \n\\begin{equation*}\ny(t) =\n\\begin{cases}\nx(t - \\tau) & , t \\geq \\tau \\\\\n0 & , t < \\tau\n\\end{cases}\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        The internal adaptive buffer uses interpolation for the evaluation. This is\nrequired to be compatible with variable step solvers. It has a drawback however.\nThe order of the ode solver used will degrade when this block is used, due to\nthe interpolation.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#5 time units delay\nD = Delay(tau=5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        tau : float
        \n
        delay time constant
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        _buffer : AdaptiveBuffer
        \n
        internal interpolatable adaptive rolling buffer
        \n
        \n
        \n", "params": { "tau": { "type": "number", @@ -407,17 +403,13 @@ export const extractedBlocks: Record = "description": "delay time constant" } }, - "inputs": [ - "in" - ], - "outputs": [ - "out" - ] + "inputs": [], + "outputs": [] }, "ODE": { "blockClass": "ODE", "description": "This block implements an ordinary differential equation (ODE)", - "docstringHtml": "

        This block implements an ordinary differential equation (ODE)\ndefined by its right hand side

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x}(t) =& \\mathrm{func}(x(t), u(t), t) \\\\\n y(t) =& x(t)\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        with inhomogenity (input) u and state vector x. The function\ncan be nonlinear and the ODE can be of arbitrary order.\nThe block utilizes the integration engine to solve the ODE\nby integrating the func, which is the right hand side function.

        \n
        \n

        Example

        \n

        For example a linear 1st order ODE:

        \n
        \node = ODE(lambda x, u, t: -x)\n
        \n

        Or something more complex like the Van der Pol system, where it makes\nsense to also specify the jacobian, which improves convergence for\nimplicit solvers but is not needed in most cases:

        \n
        \nimport numpy as np\n\n#initial condition\nx0 = np.array([2, 0])\n\n#van der Pol parameter\nmu = 1000\n\ndef func(x, u, t):\n    return np.array([x[1], mu*(1 - x[0]**2)*x[1] - x[0]])\n\n#analytical jacobian (optional)\ndef jac(x, u, t):\n    return np.array(\n        [[0                , 1               ],\n         [-mu*2*x[0]*x[1]-1, mu*(1 - x[0]**2)]]\n         )\n\n#finally the block\nvdp = ODE(func, x0, jac)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        right hand side function of ODE
        \n
        initial_value : array[float]
        \n
        initial state / initial condition
        \n
        jac : callable, None
        \n
        jacobian of 'func' or 'None'
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE right hand side 'func'
        \n
        \n
        \n", + "docstringHtml": "

        This block implements an ordinary differential equation (ODE)\ndefined by its right hand side

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x}(t) =& \\mathrm{func}(x(t), u(t), t) \\\\\n y(t) =& x(t)\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        with inhomogenity (input) u and state vector x. The function\ncan be nonlinear and the ODE can be of arbitrary order.\nThe block utilizes the integration engine to solve the ODE\nby integrating the func, which is the right hand side function.

        \n
        \n

        Example

        \n

        For example a linear 1st order ODE:

        \n
        \node = ODE(lambda x, u, t: -x)\n
        \n

        Or something more complex like the Van der Pol system, where it makes\nsense to also specify the jacobian, which improves convergence for\nimplicit solvers but is not needed in most cases:

        \n
        \nimport numpy as np\n\n#initial condition\nx0 = np.array([2, 0])\n\n#van der Pol parameter\nmu = 1000\n\ndef func(x, u, t):\n    return np.array([x[1], mu*(1 - x[0]**2)*x[1] - x[0]])\n\n#analytical jacobian (optional)\ndef jac(x, u, t):\n    return np.array(\n        [[0                , 1               ],\n         [-mu*2*x[0]*x[1]-1, mu*(1 - x[0]**2)]]\n         )\n\n#finally the block\nvdp = ODE(func, x0, jac)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        right hand side function of ODE
        \n
        initial_value : array[float]
        \n
        initial state / initial condition
        \n
        jac : callable, None
        \n
        jacobian of 'func' or 'None'
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE right hand side 'func'
        \n
        \n
        \n", "params": { "func": { "type": "callable", @@ -470,7 +462,7 @@ export const extractedBlocks: Record = "StateSpace": { "blockClass": "StateSpace", "description": "This block defines a linear time invariant (LTI) multi input multi output (MIMO)", - "docstringHtml": "

        This block defines a linear time invariant (LTI) multi input multi output (MIMO)\nstate space model with the structure

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x} &= \\mathbf{A} x + \\mathbf{B} u \\\\\n y &= \\mathbf{C} x + \\mathbf{D} u\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        where A, B, C and D are the state space matrices, x is the state,\nu the input and y the output vector.

        \n
        \n

        Example

        \n

        A SISO state space block with two internal states can be initialized\nlike this:

        \n
        \nS = StateSpace(\n    A=-np.eye(2),\n    B=np.ones((2, 1)),\n    C=np.ones((1, 2)),\n    D=1.0\n    )\n
        \n

        and a MIMO (2 in, 2 out) state space block with three internal states\ncan be initialized like this:

        \n
        \nS = StateSpace(\n    A=-np.eye(3),\n    B=np.ones((3, 2)),\n    C=np.ones((2, 3)),\n    D=np.ones((2, 2))\n    )\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        A, B, C, D : array_like
        \n
        real valued state space matrices
        \n
        initial_value : array_like, None
        \n
        initial state / initial condition
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for state equation
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator for mapping to outputs
        \n
        \n
        \n", + "docstringHtml": "

        This block defines a linear time invariant (LTI) multi input multi output (MIMO)\nstate space model with the structure

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x} &= \\mathbf{A} x + \\mathbf{B} u \\\\\n y &= \\mathbf{C} x + \\mathbf{D} u\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        where A, B, C and D are the state space matrices, x is the state,\nu the input and y the output vector.

        \n
        \n

        Example

        \n

        A SISO state space block with two internal states can be initialized\nlike this:

        \n
        \nS = StateSpace(\n    A=-np.eye(2),\n    B=np.ones((2, 1)),\n    C=np.ones((1, 2)),\n    D=1.0\n    )\n
        \n

        and a MIMO (2 in, 2 out) state space block with three internal states\ncan be initialized like this:

        \n
        \nS = StateSpace(\n    A=-np.eye(3),\n    B=np.ones((3, 2)),\n    C=np.ones((2, 3)),\n    D=np.ones((2, 2))\n    )\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        A, B, C, D : array_like
        \n
        real valued state space matrices
        \n
        initial_value : array_like, None
        \n
        initial state / initial condition
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for state equation
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator for mapping to outputs
        \n
        \n
        \n", "params": { "A": { "type": "number", @@ -504,7 +496,7 @@ export const extractedBlocks: Record = "PID": { "blockClass": "PID", "description": "Proportional-Integral-Differntiation (PID) controller.", - "docstringHtml": "

        Proportional-Integral-Differntiation (PID) controller.

        \n

        The transfer function is defined as

        \n
        \n\\begin{equation*}\nH_\\mathrm{diff}(s) = K_p + K_i \\frac{1}{s} + K_d \\frac{s}{1 + s / f_\\mathrm{max}}\n\\end{equation*}\n
        \n

        where the differentiation is approximated by a high pass filter that holds\nfor signals up to a frequency of approximately f_max.

        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.\nSince this block uses an approximation of real differentiation, the approximation will\nnot hold if there are high frequency components present in the signal. For example if\nyou have discontinuities such as steps or square waves.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz\npid = PID(Kp=2, Ki=0.5, Kd=0.1, f_max=1e3)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        Kp : float
        \n
        poroportional controller coefficient
        \n
        Ki : float
        \n
        integral controller coefficient
        \n
        Kd : float
        \n
        differentiator controller coefficient
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", + "docstringHtml": "

        Proportional-Integral-Differntiation (PID) controller.

        \n

        The transfer function is defined as

        \n
        \n\\begin{equation*}\nH_\\mathrm{diff}(s) = K_p + K_i \\frac{1}{s} + K_d \\frac{s}{1 + s / f_\\mathrm{max}}\n\\end{equation*}\n
        \n

        where the differentiation is approximated by a high pass filter that holds\nfor signals up to a frequency of approximately f_max.

        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.\nSince this block uses an approximation of real differentiation, the approximation will\nnot hold if there are high frequency components present in the signal. For example if\nyou have discontinuities such as steps or square waves.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz\npid = PID(Kp=2, Ki=0.5, Kd=0.1, f_max=1e3)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        Kp : float
        \n
        poroportional controller coefficient
        \n
        Ki : float
        \n
        integral controller coefficient
        \n
        Kd : float
        \n
        differentiator controller coefficient
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": { "Kp": { "type": "integer", @@ -537,7 +529,7 @@ export const extractedBlocks: Record = "AntiWindupPID": { "blockClass": "AntiWindupPID", "description": "Proportional-Integral-Differntiation (PID) controller with tracking", - "docstringHtml": "

        Proportional-Integral-Differntiation (PID) controller with tracking\nanti-windup mechanism (back-calculation).

        \n

        Anti-windup mechanisms are needed when the magnitude of the control signal\nfrom the PID controller is limited by some real world saturation. In these cases,\nthe integrator will continue to acumulate the control error and "wind itself up".\nOnce the setpoint is reached, this can result in significant overshoots. This\nimplementation adds a conditional feedback term to the internal integrator that\n"unwinds" it when the PID output crosses some limits. This is pretty much a\ndeadzone feedback element for the integrator.

        \n

        Mathematically, this block implements the following set of ODEs

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n\\dot{x}_1 =& f_\\mathrm{max} (u - x_1) \\\\\n\\dot{x}_2 =& u - w \\\\\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        with the anti-windup feedback (depending on the pid output)

        \n
        \n\\begin{equation*}\nw = K_s (y - \\min(\\max(y, y_\\mathrm{min}), y_\\mathrm{max}))\n\\end{equation*}\n
        \n

        and the output itself

        \n
        \n\\begin{equation*}\ny = K_p u - K_d f_\\mathrm{max} x_1 + K_i x_2\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.\nSince this block uses an approximation of real differentiation, the approximation will\nnot hold if there are high frequency components present in the signal. For example if\nyou have discontinuities such as steps or squere waves.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz, windup limits at [-5, 5]\npid = AntiWindupPID(Kp=2, Ki=0.5, Kd=0.1, f_max=1e3, limits=[-5, 5])\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        Kp : float
        \n
        poroportional controller coefficient
        \n
        Ki : float
        \n
        integral controller coefficient
        \n
        Kd : float
        \n
        differentiator controller coefficient
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        Ks : float
        \n
        feedback term for back calculation for anti-windup control of integrator
        \n
        limits : array_like[float]
        \n
        lower and upper limit for PID output that triggers anti-windup of integrator
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", + "docstringHtml": "

        Proportional-Integral-Differntiation (PID) controller with tracking\nanti-windup mechanism (back-calculation).

        \n

        Anti-windup mechanisms are needed when the magnitude of the control signal\nfrom the PID controller is limited by some real world saturation. In these cases,\nthe integrator will continue to acumulate the control error and "wind itself up".\nOnce the setpoint is reached, this can result in significant overshoots. This\nimplementation adds a conditional feedback term to the internal integrator that\n"unwinds" it when the PID output crosses some limits. This is pretty much a\ndeadzone feedback element for the integrator.

        \n

        Mathematically, this block implements the following set of ODEs

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n\\dot{x}_1 =& f_\\mathrm{max} (u - x_1) \\\\\n\\dot{x}_2 =& u - w \\\\\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        with the anti-windup feedback (depending on the pid output)

        \n
        \n\\begin{equation*}\nw = K_s (y - \\min(\\max(y, y_\\mathrm{min}), y_\\mathrm{max}))\n\\end{equation*}\n
        \n

        and the output itself

        \n
        \n\\begin{equation*}\ny = K_p u - K_d f_\\mathrm{max} x_1 + K_i x_2\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.\nSince this block uses an approximation of real differentiation, the approximation will\nnot hold if there are high frequency components present in the signal. For example if\nyou have discontinuities such as steps or squere waves.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz, windup limits at [-5, 5]\npid = AntiWindupPID(Kp=2, Ki=0.5, Kd=0.1, f_max=1e3, limits=[-5, 5])\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        Kp : float
        \n
        poroportional controller coefficient
        \n
        Ki : float
        \n
        integral controller coefficient
        \n
        Kd : float
        \n
        differentiator controller coefficient
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        Ks : float
        \n
        feedback term for back calculation for anti-windup control of integrator
        \n
        limits : array_like[float]
        \n
        lower and upper limit for PID output that triggers anti-windup of integrator
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": { "Kp": { "type": "integer", @@ -636,8 +628,12 @@ export const extractedBlocks: Record = "description": "filter order" } }, - "inputs": [], - "outputs": [] + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] }, "ButterworthHighpassFilter": { "blockClass": "ButterworthHighpassFilter", @@ -655,8 +651,12 @@ export const extractedBlocks: Record = "description": "filter order" } }, - "inputs": [], - "outputs": [] + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] }, "ButterworthBandpassFilter": { "blockClass": "ButterworthBandpassFilter", @@ -674,8 +674,12 @@ export const extractedBlocks: Record = "description": "filter order" } }, - "inputs": [], - "outputs": [] + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] }, "ButterworthBandstopFilter": { "blockClass": "ButterworthBandstopFilter", @@ -693,13 +697,17 @@ export const extractedBlocks: Record = "description": "filter order" } }, - "inputs": [], - "outputs": [] + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] }, "Adder": { "blockClass": "Adder", "description": "Summs / adds up all input signals to a single output signal (MISO)", - "docstringHtml": "

        Summs / adds up all input signals to a single output signal (MISO)

        \n

        This is how it works in the default case

        \n
        \n\\begin{equation*}\ny(t) = \\sum_i u_i(t)\n\\end{equation*}\n
        \n

        and like this when additional operations are defined

        \n
        \n\\begin{equation*}\ny(t) = \\sum_i \\mathrm{op}_i \\cdot u_i(t)\n\\end{equation*}\n
        \n
        \n

        Example

        \n

        This is the default initialization that just adds up all the inputs:

        \n
        \nA = Adder()\n
        \n

        and this is the initialization with specific operations that subtracts\nthe second from first input and neglects all others:

        \n
        \nA = Adder('+-')\n
        \n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.

        \n
        \n
        \n

        Parameters

        \n
        \n
        operations : str, optional
        \n
        optional string of operations to be applied before\nsummation, i.e. '+-' will compute the difference,\n'None' will just perform regular sum
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        _ops : dict
        \n
        dict that maps string operations to numerical
        \n
        _ops_array : array_like
        \n
        operations converted to array
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", + "docstringHtml": "

        Summs / adds up all input signals to a single output signal (MISO)

        \n

        This is how it works in the default case

        \n
        \n\\begin{equation*}\ny(t) = \\sum_i u_i(t)\n\\end{equation*}\n
        \n

        and like this when additional operations are defined

        \n
        \n\\begin{equation*}\ny(t) = \\sum_i \\mathrm{op}_i \\cdot u_i(t)\n\\end{equation*}\n
        \n
        \n

        Example

        \n

        This is the default initialization that just adds up all the inputs:

        \n
        \nA = Adder()\n
        \n

        and this is the initialization with specific operations that subtracts\nthe second from first input and neglects all others:

        \n
        \nA = Adder('+-')\n
        \n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.

        \n
        \n
        \n

        Parameters

        \n
        \n
        operations : str, optional
        \n
        optional string of operations to be applied before\nsummation, i.e. '+-' will compute the difference,\n'None' will just perform regular sum
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        _ops : dict
        \n
        dict that maps string operations to numerical
        \n
        _ops_array : array_like
        \n
        operations converted to array
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": { "operations": { "type": "any", @@ -725,7 +733,7 @@ export const extractedBlocks: Record = "Amplifier": { "blockClass": "Amplifier", "description": "Amplifies the input signal by", - "docstringHtml": "

        Amplifies the input signal by\nmultiplication with a constant gain term like this:

        \n
        \n\\begin{equation*}\ny(t) = \\mathrm{gain} \\cdot u(t)\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#amplification by factor 5\nA = Amplifier(gain=5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        gain : float
        \n
        amplifier gain
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", + "docstringHtml": "

        Amplifies the input signal by\nmultiplication with a constant gain term like this:

        \n
        \n\\begin{equation*}\ny(t) = \\mathrm{gain} \\cdot u(t)\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#amplification by factor 5\nA = Amplifier(gain=5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        gain : float
        \n
        amplifier gain
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": { "gain": { "type": "number", @@ -733,17 +741,13 @@ export const extractedBlocks: Record = "description": "amplifier gain" } }, - "inputs": [ - "in" - ], - "outputs": [ - "out" - ] + "inputs": [], + "outputs": [] }, "Function": { "blockClass": "Function", "description": "Arbitrary MIMO function block, defined by a callable object,", - "docstringHtml": "

        Arbitrary MIMO function block, defined by a callable object,\ni.e. function or lambda expression.

        \n

        The function can have multiple arguments that are then provided\nby the input channels of the function block.

        \n

        Form multi input, the function has to specify multiple arguments\nand for multi output, the aoutputs have to be provided as a\ntuple or list.

        \n

        In the context of the global system, this block implements algebraic\ncomponents of the global system ODE/DAE.

        \n
        \n\\begin{equation*}\n\\vec{y} = \\mathrm{func}(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.\nTherefore func must be purely algebraic and not introduce states,\ndelay, etc. For interfacing with external stateful APIs, use the\nWrapper block.

        \n
        \n
        \n

        Note

        \n

        If the outputs are provided as a single numpy array, they are\nconsidered a single output. For MIMO, output has to be tuple.

        \n
        \n
        \n

        Example

        \n

        consider the function:

        \n
        \nfrom pathsim.blocks import Function\n\ndef f(a, b, c):\n    return a**2, a*b, b/c\n\nfn = Function(f)\n
        \n

        then, when the block is uldated, the input channels of the block are\nassigned to the function arguments following this scheme:

        \n
        \ninputs[0] -> a\ninputs[1] -> b\ninputs[2] -> c\n
        \n

        and the function outputs are assigned to the\noutput channels of the block in the same way:

        \n
        \na**2 -> outputs[0]\na*b  -> outputs[1]\nb/c  -> outputs[2]\n
        \n

        Because the Function block only has a single argument, it can be\nused to decorate a function and make it a PathSim block. This might\nbe handy in some cases to keep definitions concise and localized\nin the code:

        \n
        \nfrom pathsim.blocks import Function\n\n#does the same as the definition above\n\n@Function\ndef fn(a, b, c):\n    return a**2, a*b, b/c\n\n#'fn' is now a PathSim block\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        MIMO function that defines algebraic block IO behaviour, signature func(*tuple)
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator that wraps func
        \n
        \n
        \n", + "docstringHtml": "

        Arbitrary MIMO function block, defined by a callable object,\ni.e. function or lambda expression.

        \n

        The function can have multiple arguments that are then provided\nby the input channels of the function block.

        \n

        Form multi input, the function has to specify multiple arguments\nand for multi output, the aoutputs have to be provided as a\ntuple or list.

        \n

        In the context of the global system, this block implements algebraic\ncomponents of the global system ODE/DAE.

        \n
        \n\\begin{equation*}\n\\vec{y} = \\mathrm{func}(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.\nTherefore func must be purely algebraic and not introduce states,\ndelay, etc. For interfacing with external stateful APIs, use the\nWrapper block.

        \n
        \n
        \n

        Note

        \n

        If the outputs are provided as a single numpy array, they are\nconsidered a single output. For MIMO, output has to be tuple.

        \n
        \n
        \n

        Example

        \n

        consider the function:

        \n
        \nfrom pathsim.blocks import Function\n\ndef f(a, b, c):\n    return a**2, a*b, b/c\n\nfn = Function(f)\n
        \n

        then, when the block is uldated, the input channels of the block are\nassigned to the function arguments following this scheme:

        \n
        \ninputs[0] -> a\ninputs[1] -> b\ninputs[2] -> c\n
        \n

        and the function outputs are assigned to the\noutput channels of the block in the same way:

        \n
        \na**2 -> outputs[0]\na*b  -> outputs[1]\nb/c  -> outputs[2]\n
        \n

        Because the Function block only has a single argument, it can be\nused to decorate a function and make it a PathSim block. This might\nbe handy in some cases to keep definitions concise and localized\nin the code:

        \n
        \nfrom pathsim.blocks import Function\n\n#does the same as the definition above\n\n@Function\ndef fn(a, b, c):\n    return a**2, a*b, b/c\n\n#'fn' is now a PathSim block\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        MIMO function that defines algebraic block IO behaviour, signature func(*tuple)
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator that wraps func
        \n
        \n
        \n", "params": { "func": { "type": "callable", @@ -876,7 +880,7 @@ export const extractedBlocks: Record = "Switch": { "blockClass": "Switch", "description": "Switch block that selects between its inputs and copies", - "docstringHtml": "

        Switch block that selects between its inputs and copies\none of them to the output.

        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#default None -> no passthrough\ns1 = Switch()\n\n#selecting port 2 as passthrough\ns2 = Switch(2)\n\n#change the state of the switch to port 3\ns2.select(3)\n
        \n

        Sets block output depending on self.state like this:

        \n
        \nstate == None -> outputs[0] = 0\n\nstate == 0 -> outputs[0] = inputs[0]\n\nstate == 1 -> outputs[0] = inputs[1]\n\nstate == 2 -> outputs[0] = inputs[2]\n\n...\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        state : int, None
        \n
        state of the switch
        \n
        \n
        \n", + "docstringHtml": "

        Switch block that selects between its inputs and copies\none of them to the output.

        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#default None -> no passthrough\ns1 = Switch()\n\n#selecting port 2 as passthrough\ns2 = Switch(2)\n\n#change the state of the switch to port 3\ns2.select(3)\n
        \n

        Sets block output depending on self.state like this:

        \n
        \nstate == None -> outputs[0] = 0\n\nstate == 0 -> outputs[0] = inputs[0]\n\nstate == 1 -> outputs[0] = inputs[1]\n\nstate == 2 -> outputs[0] = inputs[2]\n\n...\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        state : int, None
        \n
        state of the switch
        \n
        \n
        \n", "params": { "state": { "type": "any", @@ -885,9 +889,7 @@ export const extractedBlocks: Record = } }, "inputs": [], - "outputs": [ - "out" - ] + "outputs": [] }, "LUT": { "blockClass": "LUT", @@ -1120,10 +1122,43 @@ export const extractedBlocks: Record = "out" ] }, + "Relay": { + "blockClass": "Relay", + "description": "Relay block with hysteresis (Schmitt trigger).", + "docstringHtml": "

        Relay block with hysteresis (Schmitt trigger).

        \n

        Switches output between two values based on input crossing upper and lower\nthresholds. The hysteresis prevents rapid switching when input is noisy.

        \n

        When input rises above threshold_up, output switches to value_up.\nWhen input falls below threshold_down, output switches to value_down.

        \n
        \n

        Examples

        \n

        Basic thermostat that turns heater on below 19°C, off above 21°C:

        \n
        \nfrom pathsim.blocks import Relay\n\nthermostat = Relay(\n    threshold_up=21.0,\n    threshold_down=19.0,\n    value_up=0.0,\n    value_down=1.0\n    )\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        threshold_up : float
        \n
        threshold for transitioning to upper relay state value_up (default: 1.0)
        \n
        threshold_down : float
        \n
        threshold for transitioning to lower relay state value_down (default: 0.0)
        \n
        value_up : float
        \n
        value for upper relay state (default: 1.0)
        \n
        value_down : float
        \n
        value for lower relay state (default: 0.0)
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        events : list[ZeroCrossingUp, ZeroCrossingDown]
        \n
        internal zero crossing events for relay state transitions
        \n
        \n
        \n", + "params": { + "threshold_up": { + "type": "number", + "default": "1.0", + "description": "threshold for transitioning to upper relay state `value_up` (default: 1.0)" + }, + "threshold_down": { + "type": "number", + "default": "0.0", + "description": "threshold for transitioning to lower relay state `value_down` (default: 0.0)" + }, + "value_up": { + "type": "number", + "default": "1.0", + "description": "value for upper relay state (default: 1.0)" + }, + "value_down": { + "type": "number", + "default": "0.0", + "description": "value for lower relay state (default: 0.0)" + } + }, + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] + }, "Scope": { "blockClass": "Scope", "description": "Block for recording time domain data with variable sampling rate.", - "docstringHtml": "

        Block for recording time domain data with variable sampling rate.

        \n

        A time threshold can be set by t_wait to start recording data after the simulation\ntime is larger then the specified waiting time, i.e. t - t_wait > 0.\nThis is useful for recording data only after all the transients have settled.

        \n

        The block uses an interal Schedule event, when sampling_rate is provided,\notherwise it just samples at every simulation timestep.

        \n
        \n

        Parameters

        \n
        \n
        sampling_rate : int, None
        \n
        number of samples per time unit, default is every timestep
        \n
        t_wait : float
        \n
        wait time before starting recording, optional
        \n
        labels : list[str]
        \n
        labels for the scope traces, and for the csv, optional
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        recording : dict
        \n
        recording, where key is time, and value the recorded values
        \n
        events : list[Schedule]
        \n
        internal scheduled event for periodic input sampling when sampling_rate is provided
        \n
        \n
        \n", + "docstringHtml": "

        Block for recording time domain data with variable sampling rate.

        \n

        A time threshold can be set by t_wait to start recording data after the simulation\ntime is larger then the specified waiting time, i.e. t - t_wait > 0.\nThis is useful for recording data only after all the transients have settled.

        \n

        The block uses an interal Schedule event, when sampling_rate is provided,\notherwise it just samples at every simulation timestep.

        \n
        \n

        Parameters

        \n
        \n
        sampling_rate : int, None
        \n
        number of samples per time unit, default is every timestep
        \n
        t_wait : float
        \n
        wait time before starting recording, optional
        \n
        labels : list[str]
        \n
        labels for the scope traces, and for the csv, optional
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        recording_time : list[float]
        \n
        recorded time points
        \n
        recording_data : list[float]
        \n
        regorded data points
        \n
        _incremental_idx : int
        \n
        index for incremental reading of accumulated data since last\ncall of incremental read
        \n
        _sample_next_timestep : bool
        \n
        flag to indicate this is a timestep to sample, only used for\nevent based sampling when sampling_rate is provided as an arg
        \n
        events : list[Schedule]
        \n
        internal scheduled event for periodic input sampling when\nsampling_rate is provided
        \n
        \n
        \n", "params": { "sampling_rate": { "type": "any", @@ -1147,7 +1182,7 @@ export const extractedBlocks: Record = "Spectrum": { "blockClass": "Spectrum", "description": "Block for fourier spectrum analysis (basically a spectrum analyzer), computes", - "docstringHtml": "

        Block for fourier spectrum analysis (basically a spectrum analyzer), computes\ncontinuous time running fourier transform (RFT) of the incoming signal.

        \n

        A time threshold can be set by 't_wait' to start recording data only after the\nsimulation time is larger then the specified waiting time, i.e. 't - t_wait > dt'.\nThis is useful for recording the steady state after all the transients have settled.

        \n

        An exponential forgetting factor 'alpha' can be specified for realtime spectral\nanalysis. It biases the spectral components exponentially to the most recent signal\nvalues by applying a single sided exponential window like this:

        \n
        \n\\begin{equation*}\n\\int_0^t u(\\tau) \\exp(\\alpha (t-\\tau)) \\exp(-j \\omega \\tau)\\ d \\tau\n\\end{equation*}\n
        \n

        It is also known as the 'exponentially forgetting transform' (EFT) and a form of\nshort time fourier transform (STFT). It is implemented as a 1st order statespace model

        \n
        \n\\begin{equation*}\n\\dot{x} = - \\alpha x + \\exp(-j \\omega t) u\n\\end{equation*}\n
        \n

        where 'u' is the input signal and 'x' is the state variable that represents the\ncomplex fourier coefficient to the frequency 'omega'. The ODE is integrated using the\nnumerical integration engine of the block.

        \n
        \n

        Example

        \n

        This is how to initialize it:

        \n
        \nimport numpy as np\n\n#linear frequencies (0Hz, DC -> 1kHz)\nsp1 = Spectrum(\n    freq=np.linspace(0, 1e3, 100),\n    labels=['x1', 'x2'] #labels for two inputs\n    )\n\n#log frequencies (1Hz -> 1kHz)\nsp2 = Spectrum(\n    freq=np.logspace(0, 3, 100)\n    )\n\n#log frequencies including DC (0Hz, DC + 1Hz -> 1kHz)\nsp3 = Spectrum(\n    freq=np.hstack([0.0, np.logspace(0, 3, 100)])\n    )\n\n#arbitrary frequencies\nsp4 = Spectrum(\n    freq=np.array([0, 0.5, 20, 1e3])\n    )\n
        \n
        \n
        \n

        Note

        \n

        This block is relatively slow! But it is valuable for long running simulations\nwith few evaluation frequencies, where just FFT'ing the time series data\nwouldnt be efficient OR if only the evaluation at weirdly spaced frequencies\nis required. Otherwise its more efficient to just do an FFT on the time\nseries recording after the simulation has finished.

        \n
        \n
        \n

        Parameters

        \n
        \n
        freq : array[float]
        \n
        list of evaluation frequencies for RFT, can be arbitrarily spaced
        \n
        t_wait : float
        \n
        wait time before starting RFT
        \n
        alpha : float
        \n
        exponential forgetting factor for realtime spectrum
        \n
        labels : list[str]
        \n
        labels for the inputs
        \n
        \n
        \n", + "docstringHtml": "

        Block for fourier spectrum analysis (basically a spectrum analyzer), computes\ncontinuous time running fourier transform (RFT) of the incoming signal.

        \n

        A time threshold can be set by 't_wait' to start recording data only after the\nsimulation time is larger then the specified waiting time, i.e. 't - t_wait > dt'.\nThis is useful for recording the steady state after all the transients have settled.

        \n

        An exponential forgetting factor 'alpha' can be specified for realtime spectral\nanalysis. It biases the spectral components exponentially to the most recent signal\nvalues by applying a single sided exponential window like this:

        \n
        \n\\begin{equation*}\n\\int_0^t u(\\tau) \\exp(\\alpha (t-\\tau)) \\exp(-j \\omega \\tau)\\ d \\tau\n\\end{equation*}\n
        \n

        It is also known as the 'exponentially forgetting transform' (EFT) and a form of\nshort time fourier transform (STFT). It is implemented as a 1st order statespace model

        \n
        \n\\begin{equation*}\n\\dot{x} = - \\alpha x + \\exp(-j \\omega t) u\n\\end{equation*}\n
        \n

        where 'u' is the input signal and 'x' is the state variable that represents the\ncomplex fourier coefficient to the frequency 'omega'. The ODE is integrated using the\nnumerical integration engine of the block.

        \n
        \n

        Example

        \n

        This is how to initialize it:

        \n
        \nimport numpy as np\n\n#linear frequencies (0Hz, DC -> 1kHz)\nsp1 = Spectrum(\n    freq=np.linspace(0, 1e3, 100),\n    labels=['x1', 'x2'] #labels for two inputs\n    )\n\n#log frequencies (1Hz -> 1kHz)\nsp2 = Spectrum(\n    freq=np.logspace(0, 3, 100)\n    )\n\n#log frequencies including DC (0Hz, DC + 1Hz -> 1kHz)\nsp3 = Spectrum(\n    freq=np.hstack([0.0, np.logspace(0, 3, 100)])\n    )\n\n#arbitrary frequencies\nsp4 = Spectrum(\n    freq=np.array([0, 0.5, 20, 1e3])\n    )\n
        \n
        \n
        \n

        Note

        \n

        This block is relatively slow! But it is valuable for long running simulations\nwith few evaluation frequencies, where just FFT'ing the time series data\nwouldnt be efficient OR if only the evaluation at weirdly spaced frequencies\nis required. Otherwise its more efficient to just do an FFT on the time\nseries recording after the simulation has finished.

        \n
        \n
        \n

        Parameters

        \n
        \n
        freq : array[float]
        \n
        list of evaluation frequencies for RFT, can be arbitrarily spaced
        \n
        t_wait : float
        \n
        wait time before starting RFT
        \n
        alpha : float
        \n
        exponential forgetting factor for realtime spectrum
        \n
        labels : list[str]
        \n
        labels for the inputs
        \n
        \n
        \n", "params": { "freq": { "type": "array", @@ -1173,146 +1208,10 @@ export const extractedBlocks: Record = "inputs": [], "outputs": [] }, - "Process": { - "blockClass": "Process", - "description": "Simplified version of the `ResidenceTime` model block", - "docstringHtml": "

        Simplified version of the ResidenceTime model block\nwith all inputs being summed equally and only the state\nand the flux being returned to the output

        \n

        This block implements an internal 1st order linear ode with\nmultiple inputs, outputs and no direct passthrough.

        \n

        The internal ODE with inputs \\(u_i\\) :

        \n
        \n\\begin{equation*}\n\\dot{x} = - x / \\tau + \\mathrm{src} + \\sum_i u_i\n\\end{equation*}\n
        \n

        And the output equations for output i=0 and i=1:

        \n
        \n\\begin{equation*}\ny_0 = x\n\\end{equation*}\n
        \n
        \n\\begin{equation*}\ny_1 = x / \\tau\n\\end{equation*}\n
        \n
        \n

        Parameters

        \n
        \n
        tau : float
        \n
        residence time, inverse natural frequency (eigenvalue)
        \n
        initial_value : float
        \n
        initial value of state / initial quantity of process
        \n
        source_term : float
        \n
        constant source term / generation term of the process
        \n
        \n
        \n", - "params": { - "tau": { - "type": "integer", - "default": "1", - "description": "residence time, inverse natural frequency (eigenvalue)" - }, - "initial_value": { - "type": "integer", - "default": "0", - "description": "initial value of state / initial quantity of process" - }, - "source_term": { - "type": "integer", - "default": "0", - "description": "constant source term / generation term of the process" - } - }, - "inputs": [], - "outputs": [ - "x", - "x/tau" - ] - }, - "Bubbler4": { - "blockClass": "Bubbler4", - "description": "Tritium bubbling system with sequential vial collection stages.", - "docstringHtml": "

        Tritium bubbling system with sequential vial collection stages.

        \n

        This block models a tritium collection system used in fusion reactor blanket\npurge gas processing. The system bubbles tritium-containing gas through a series\nof liquid-filled vials to capture and concentrate tritium for measurement and\ninventory tracking.

        \n
        \n

        Physical Description

        \n

        The bubbler consists of two parallel processing chains:

        \n

        Soluble Chain (Vials 1-2):\nTritium already in soluble forms (HTO, HT) flows sequentially through\nvials 1 and 2. Each vial has a collection efficiency \\(\\eta_{vial}\\),\nrepresenting the fraction of tritium that dissolves into the liquid phase\nand is retained.

        \n

        Insoluble Chain (Vials 3-4):\nTritium in insoluble forms (T₂, organically bound) first undergoes catalytic\nconversion to soluble forms with efficiency \\(\\alpha_{conv}\\). The\nconverted tritium, along with uncaptured soluble tritium from the first chain,\nthen flows through vials 3 and 4 with the same collection efficiency.

        \n
        \n
        \n

        Mathematical Formulation

        \n

        The system is governed by the following differential equations for the\nvial inventories \\(x_i\\):

        \n
        \n\\begin{equation*}\n\\frac{dx_1}{dt} &= \\eta_{vial} \\cdot u_{sol}\n\\end{equation*}\n
        \n
        \n\\begin{equation*}\n\\frac{dx_2}{dt} &= \\eta_{vial} \\cdot (1-\\eta_{vial}) \\cdot u_{sol}\n\\end{equation*}\n
        \n
        \n\\begin{equation*}\n\\frac{dx_3}{dt} &= \\eta_{vial} \\cdot [\\alpha_{conv} \\cdot u_{insol} + (1-\\eta_{vial})^2 \\cdot u_{sol}]\n\\end{equation*}\n
        \n
        \n\\begin{equation*}\n\\frac{dx_4}{dt} &= \\eta_{vial} \\cdot (1-\\eta_{vial}) \\cdot [\\alpha_{conv} \\cdot u_{insol} + (1-\\eta_{vial})^2 \\cdot u_{sol}]\n\\end{equation*}\n
        \n

        The sample output represents uncaptured tritium exiting the system:

        \n
        \n\\begin{equation*}\ny_{sample} = (1-\\alpha_{conv}) \\cdot u_{insol} + (1-\\eta_{vial})^2 \\cdot [\\alpha_{conv} \\cdot u_{insol} + (1-\\eta_{vial})^2 \\cdot u_{sol}]\n\\end{equation*}\n
        \n
        \n
        Where:
        \n
          \n
        • \\(u_{sol}\\) = soluble tritium input flow rate
        • \n
        • \\(u_{insol}\\) = insoluble tritium input flow rate
        • \n
        • \\(\\eta_{vial}\\) = vial collection efficiency
        • \n
        • \\(\\alpha_{conv}\\) = conversion efficiency from insoluble to soluble
        • \n
        • \\(x_i\\) = tritium inventory in vial i
        • \n
        \n
        \n
        \n
        \n
        \n

        Parameters

        \n
        \n
        conversion_efficiency : float
        \n
        Conversion efficiency from insoluble to soluble forms (\\(\\alpha_{conv}\\)),\nbetween 0 and 1.
        \n
        vial_efficiency : float
        \n
        Collection efficiency of each vial (\\(\\eta_{vial}\\)), between 0 and 1.
        \n
        replacement_times : float | list[float] | list[list[float]]
        \n
        Times at which each vial is replaced with a fresh one. If None, no\nreplacement events are created. If a single value is provided, it is\nused for all vials. If a single list of floats is provided, it will be\nused for all vials. If a list of lists is provided, each sublist\ncorresponds to the replacement times for each vial.
        \n
        \n
        \n
        \n

        Notes

        \n

        Vial replacement is modeled as instantaneous reset events that set the\ncorresponding vial inventory to zero, simulating the physical replacement\nof a full vial with an empty one.

        \n
        \n", - "params": { - "conversion_efficiency": { - "type": "number", - "default": "0.9", - "description": "Conversion efficiency from insoluble to soluble forms (:math:`\\alpha_{conv}`), between 0 and 1." - }, - "vial_efficiency": { - "type": "number", - "default": "0.9", - "description": "Collection efficiency of each vial (:math:`\\eta_{vial}`), between 0 and 1." - }, - "replacement_times": { - "type": "any", - "default": null, - "description": "Times at which each vial is replaced with a fresh one. If None, no replacement events are created. If a single value is provided, it is used for all vials. If a single list of floats is provided, it will be used for all vials. If a list of lists is provided, each sublist corresponds to the replacement times for each vial." - } - }, - "inputs": [ - "sample_in_soluble", - "sample_in_insoluble" - ], - "outputs": [ - "vial1", - "vial2", - "vial3", - "vial4", - "sample_out" - ] - }, - "Splitter": { - "blockClass": "Splitter", - "description": "Splitter block that splits the input signal into multiple", - "docstringHtml": "

        Splitter block that splits the input signal into multiple\noutputs weighted with the specified fractions.

        \n
        \n

        Note

        \n

        The output fractions must sum to one.

        \n
        \n
        \n

        Parameters

        \n
        \n
        fractions : np.ndarray | list
        \n
        fractions to split the input signal into,\nmust sum up to one
        \n
        \n
        \n", - "params": { - "fractions": { - "type": "any", - "default": null, - "description": "fractions to split the input signal into, must sum up to one" - } - }, - "inputs": [ - "in" - ], - "outputs": [ - "out 1.0" - ] - }, - "GLC": { - "blockClass": "GLC", - "description": "Gas Liquid Contactor model block.", - "docstringHtml": "

        Gas Liquid Contactor model block. Inherits from Function block.

        \n

        More details about the model can be found in: https://doi.org/10.13182/FST95-A30485

        \n
        \n
        Args:
        \n
        P_in: Inlet operating pressure [Pa]\nL: Column height [m]\nD: Column diameter [m]\nT: Temperature [K]\ng: Gravitational acceleration [m/s^2], default is 9.81\ninitial_nb_of_elements: Initial number of elements for BVP solver\nBCs: Boundary conditions type, "C-C" (Closed-Closed) or "O-C" (Open-Closed), default is "C-C"
        \n
        \n", - "params": { - "P_in": { - "type": "any", - "default": null, - "description": "L: Column height [m]" - }, - "L": { - "type": "any", - "default": null, - "description": "D: Column diameter [m]" - }, - "D": { - "type": "any", - "default": null, - "description": "T: Temperature [K]" - }, - "T": { - "type": "any", - "default": null, - "description": "g: Gravitational acceleration [m/s^2], default is 9.81" - }, - "BCs": { - "type": "any", - "default": null, - "description": "" - }, - "g": { - "type": "number", - "default": "9.80665", - "description": "initial_nb_of_elements: Initial number of elements for BVP solver" - }, - "initial_nb_of_elements": { - "type": "integer", - "default": "20", - "description": "BCs: Boundary conditions type, \"C-C\" (Closed-Closed) or \"O-C\" (Open-Closed), default is \"C-C\"" - } - }, - "inputs": [ - "c_T_in", - "flow_l", - "y_T2_inlet", - "flow_g" - ], - "outputs": [ - "c_T_out", - "y_T2_out", - "eff", - "P_out", - "Q_l", - "Q_g_out", - "n_T_out_liquid", - "n_T_out_gas" - ] - }, "Subsystem": { "blockClass": "Subsystem", "description": "Subsystem class that holds its own blocks and connecions and", - "docstringHtml": "

        Subsystem class that holds its own blocks and connecions and\ncan natively interface with the main simulation loop.

        \n

        IO interface is realized by a special 'Interface' block, that has extra\nmethods for setting and getting inputs and outputs and serves\nas the interface of the internal blocks to the outside.

        \n

        The subsystem doesnt use its 'inputs' and 'outputs' dicts directly.\nIt exclusively handles data transfer via the 'Interface' block.

        \n

        This class can be used just like any other block during the simulation,\nsince it implements the required methods 'update' for the fixed-point\niteration (resolving algebraic loops with instant time blocks),\nthe 'step' method that performs timestepping (especially for dynamic\nblocks with internal states) and the 'solve' method for solving the\nimplicit update equation for implicit solvers.

        \n
        \n

        Example

        \n

        This is how we can wrap up multiple blocks within a subsystem.\nIn this case vanderpol system built from discrete components\ninstead of using an ODE block (in practice you should use\na monolithic ODE whenever possible due to performance).

        \n
        \nfrom pathsim import Subsystem, Interface, Connection\nfrom pathsim.blocks import Integrator, Function\n\n#van der Pol parameter\nmu = 1000\n\n#blocks in the subsystem\nIf = Interface() # this is the interface to the outside\nI1 = Integrator(2)\nI2 = Integrator(0)\nFn = Function(lambda x1, x2: mu*(1 - x1**2)*x2 - x1)\n\nsub_blocks = [If, I1, I2, Fn]\n\n#connections in the subsystem\nsub_connections = [\n    Connection(I2, I1, Fn[1], If[1]),\n    Connection(I1, Fn, If),\n    Connection(Fn, I2)\n    ]\n\n#the subsystem acts just like a normal block\nvdp = Subsystem(sub_blocks, sub_connections)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        blocks : list[Block]
        \n
        internal blocks of the subsystem
        \n
        connections : list[Connection]
        \n
        internal connections of the subsystem
        \n
        tolerance_fpi : float
        \n
        absolute tolerance for convergence of algebraic loops\ndefault see ´SIM_TOLERANCE_FPI´ in ´_constants.py´
        \n
        iterations_max : int
        \n
        maximum allowed number of iterations for algebraic loop\nsolver, default see ´SIM_ITERATIONS_MAX´ in ´_constants.py´
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        interface : Interface
        \n
        internal interface block for data transfer to the outside
        \n
        graph : Graph
        \n
        internal graph representation for fast system funcion\nevluations using DAG with algebraic depths
        \n
        boosters : None | list[ConnectionBooster]
        \n
        list of boosters (fixed point accelerators) that wrap\nalgebraic loop closing connections assembled from the\nsystem graph
        \n
        \n
        \n", + "docstringHtml": "

        Subsystem class that holds its own blocks and connecions and\ncan natively interface with the main simulation loop.

        \n

        IO interface is realized by a special 'Interface' block, that has extra\nmethods for setting and getting inputs and outputs and serves\nas the interface of the internal blocks to the outside.

        \n

        The subsystem doesnt use its 'inputs' and 'outputs' dicts directly.\nIt exclusively handles data transfer via the 'Interface' block.

        \n

        This class can be used just like any other block during the simulation,\nsince it implements the required methods 'update' for the fixed-point\niteration (resolving algebraic loops with instant time blocks),\nthe 'step' method that performs timestepping (especially for dynamic\nblocks with internal states) and the 'solve' method for solving the\nimplicit update equation for implicit solvers.

        \n
        \n

        Example

        \n

        This is how we can wrap up multiple blocks within a subsystem.\nIn this case vanderpol system built from discrete components\ninstead of using an ODE block (in practice you should use\na monolithic ODE whenever possible due to performance).

        \n
        \nfrom pathsim import Subsystem, Interface, Connection\nfrom pathsim.blocks import Integrator, Function\n\n#van der Pol parameter\nmu = 1000\n\n#blocks in the subsystem\nIf = Interface() # this is the interface to the outside\nI1 = Integrator(2)\nI2 = Integrator(0)\nFn = Function(lambda x1, x2: mu*(1 - x1**2)*x2 - x1)\n\nsub_blocks = [If, I1, I2, Fn]\n\n#connections in the subsystem\nsub_connections = [\n    Connection(I2, I1, Fn[1], If[1]),\n    Connection(I1, Fn, If),\n    Connection(Fn, I2)\n    ]\n\n#the subsystem acts just like a normal block\nvdp = Subsystem(sub_blocks, sub_connections)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        blocks : list[Block] | None
        \n
        internal blocks of the subsystem
        \n
        connections : list[Connection] | None
        \n
        internal connections of the subsystem
        \n
        \n

        events : list[Event] | None\ntolerance_fpi : float

        \n
        \nabsolute tolerance for convergence of algebraic loops\ndefault see ´SIM_TOLERANCE_FPI´ in ´_constants.py´
        \n
        \n
        iterations_max : int
        \n
        maximum allowed number of iterations for algebraic loop\nsolver, default see ´SIM_ITERATIONS_MAX´ in ´_constants.py´
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        interface : Interface
        \n
        internal interface block for data transfer to the outside
        \n
        graph : Graph
        \n
        internal graph representation for fast system funcion\nevluations using DAG with algebraic depths
        \n
        boosters : None | list[ConnectionBooster]
        \n
        list of boosters (fixed point accelerators) that wrap\nalgebraic loop closing connections assembled from the\nsystem graph
        \n
        \n
        \n", "params": { "blocks": { "type": "any", @@ -1324,6 +1223,11 @@ export const extractedBlocks: Record = "default": null, "description": "internal connections of the subsystem" }, + "events": { + "type": "any", + "default": null, + "description": "" + }, "tolerance_fpi": { "type": "number", "default": "1e-10", @@ -1354,7 +1258,6 @@ export const blockConfig: Record, string[]> = Algebraic: ["Adder", "Multiplier", "Amplifier", "Function", "Sin", "Cos", "Tan", "Tanh", "Abs", "Sqrt", "Exp", "Log", "Log10", "Mod", "Clip", "Pow", "Switch", "LUT", "LUT1D"], Mixed: ["SampleHold", "FIR", "ADC", "DAC", "Counter", "CounterUp", "CounterDown", "Relay"], Recording: ["Scope", "Spectrum"], - Chemical: ["Process", "Bubbler4", "Splitter", "GLC"], }; export const uiOverrides: Record = diff --git a/static/examples/manifest.json b/static/examples/manifest.json index a54155ca..542108e5 100644 --- a/static/examples/manifest.json +++ b/static/examples/manifest.json @@ -1,11 +1,11 @@ { "files": [ + "bouncing-ball.json", + "cascade-subsystem.json", "feedback-system.json", + "fmcw-radar.json", "pid-subsystem.json", - "cascade-subsystem.json", - "bouncing-ball.json", "thermostat.json", - "fmcw-radar.json", "vanderpol.json" ] } From 41ac2af8d3eae10a4fd8b40ac33f0af37128b70c Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Thu, 15 Jan 2026 11:17:29 +0100 Subject: [PATCH 029/656] Simplify URL model loading and add documentation --- README.md | 37 ++++++++++++++ src/routes/+page.svelte | 111 +++++++++------------------------------- 2 files changed, 61 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 987736fa..32f234dc 100644 --- a/README.md +++ b/README.md @@ -428,6 +428,43 @@ PathView uses JSON-based file formats for saving and sharing: --- +## Sharing Models via URL + +Models can be loaded directly from a URL using query parameters: + +``` +https://view.pathsim.org/?model= +https://view.pathsim.org/?modelgh= +``` + +### Parameters + +| Parameter | Description | Example | +|-----------|-------------|---------| +| `model` | Direct URL to a `.pvm` or `.json` file | `?model=https://example.com/mymodel.pvm` | +| `modelgh` | GitHub shorthand (expands to raw.githubusercontent.com) | `?modelgh=user/repo/path/to/model.pvm` | + +### GitHub Shorthand + +The `modelgh` parameter expands to a raw GitHub URL: + +``` +modelgh=user/repo/examples/demo.pvm +→ https://raw.githubusercontent.com/user/repo/main/examples/demo.pvm +``` + +### Examples + +``` +# Load from any URL +https://view.pathsim.org/?model=https://mysite.com/models/feedback.pvm + +# Load from GitHub repository +https://view.pathsim.org/?modelgh=pathsim/pathview/static/examples/feedback-system.json +``` + +--- + ## Scripts | Script | Purpose | diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 029e2df9..3a9bef81 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -63,12 +63,19 @@ let showPinnedPreviews = $state(false); let hasAutoOpenedPlot = $state(false); // Only auto-open once let hasAutoOpenedConsole = $state(false); // Only auto-open once - let showWelcomeModal = $state(true); // Show on startup - let urlLoadingState = $state<{ loading: boolean; url: string | null; error: string | null }>({ - loading: false, - url: null, - error: null - }); + + // Parse URL model params once at init + function getUrlModelConfig(): { url: string; isGitHub: boolean } | null { + if (typeof window === 'undefined') return null; + const params = new URLSearchParams(window.location.search); + const model = params.get('model'); + const modelgh = params.get('modelgh'); + if (model) return { url: model, isGitHub: false }; + if (modelgh) return { url: modelgh, isGitHub: true }; + return null; + } + const urlModelConfig = getUrlModelConfig(); + let showWelcomeModal = $state(!urlModelConfig); // Hide if loading from URL // Track widths directly - initialized on first dual-panel open let consolePanelWidth = $state(undefined); @@ -819,42 +826,29 @@ * Load model from URL parameter on page load */ async function loadFromUrlParam(): Promise { - const params = new URLSearchParams(window.location.search); - const modelUrl = params.get('model'); - const modelGh = params.get('modelgh'); - - let url: string | null = null; + if (!urlModelConfig) return; - if (modelUrl) { - url = modelUrl; - } else if (modelGh) { - try { - url = expandGitHubShorthand(modelGh); - } catch (e) { - consoleStore.error(`Invalid GitHub shorthand: ${modelGh}`); - consoleStore.error('Expected format: owner/repo/path/to/file.pvm'); - return; - } + let url: string; + try { + url = urlModelConfig.isGitHub + ? expandGitHubShorthand(urlModelConfig.url) + : urlModelConfig.url; + } catch (e) { + consoleStore.error(`Invalid GitHub shorthand: ${urlModelConfig.url}`); + consoleStore.error('Expected format: owner/repo/path/to/file.pvm'); + showConsole = true; + return; } - if (!url) return; - - // Hide welcome modal and show loading state - showWelcomeModal = false; - urlLoadingState = { loading: true, url, error: null }; - try { const file = await loadGraphFromUrl(url); if (file) { - // Trigger fit view after nodes render setTimeout(() => triggerFitView(), 100); - urlLoadingState = { loading: false, url: null, error: null }; } else { throw new Error('Failed to load model'); } } catch (e) { const errorMsg = e instanceof Error ? e.message : 'Unknown error'; - urlLoadingState = { loading: false, url: null, error: errorMsg }; consoleStore.error(`Failed to load model from URL: ${url}`); consoleStore.error(errorMsg); showConsole = true; @@ -1270,21 +1264,6 @@ onClose={() => showWelcomeModal = false} /> {/if} - - - {#if urlLoadingState.loading} -
        -
        - - Loading model... - {#if urlLoadingState.url} - - {urlLoadingState.url.length > 60 ? urlLoadingState.url.slice(0, 60) + '...' : urlLoadingState.url} - - {/if} -
        -
        - {/if} From b26ce216f89bed149ed4fcc02297a1c134678c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Delaporte-Mathurin?= <40028739+RemDelaporteMathurin@users.noreply.github.com> Date: Thu, 15 Jan 2026 08:37:16 -0500 Subject: [PATCH 030/656] pathsim-chem 0.2rc2 in workflow --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b52a974c..7b98dfac 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -31,7 +31,7 @@ jobs: python-version: '3.11' - name: Install PathSim - run: pip install pathsim pathsim-chem docutils + run: pip install pathsim "pathsim-chem>=0.2rc2" docutils - name: Install dependencies run: npm ci From 7b613ee7c353d38d6853f4d4b67690422ebc999a Mon Sep 17 00:00:00 2001 From: RemDelaporteMathurin Date: Thu, 15 Jan 2026 08:52:00 -0500 Subject: [PATCH 031/656] port numbers for chem blocks --- scripts/extract-blocks.py | 3 +++ src/lib/nodes/generated/blocks.ts | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/scripts/extract-blocks.py b/scripts/extract-blocks.py index cd7dfb34..4241b3fc 100644 --- a/scripts/extract-blocks.py +++ b/scripts/extract-blocks.py @@ -177,6 +177,9 @@ "Scope": {"maxInputs": None, "maxOutputs": 0}, "Spectrum": {"maxInputs": None, "maxOutputs": 0}, "Splitter": {"maxInputs": 1, "maxOutputs": None}, + "Process": {"maxOutputs": 2}, + "GLC": {"maxInputs": 4, "maxOutputs": 8}, + "Bubbler4": {"maxInputs": 2, "maxOutputs": 5}, } # Parameter overrides - PathSim handles all validation at runtime diff --git a/src/lib/nodes/generated/blocks.ts b/src/lib/nodes/generated/blocks.ts index 41140cd4..7c69c400 100644 --- a/src/lib/nodes/generated/blocks.ts +++ b/src/lib/nodes/generated/blocks.ts @@ -1574,5 +1574,16 @@ export const uiOverrides: Record = "Splitter": { "maxInputs": 1, "maxOutputs": null + }, + "Process": { + "maxOutputs": 2 + }, + "GLC": { + "maxInputs": 4, + "maxOutputs": 8 + }, + "Bubbler4": { + "maxInputs": 2, + "maxOutputs": 5 } }; From d4bcc3aba556ff89f3d3b1a939805a6e94b2f50b Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 16 Jan 2026 18:07:29 +0100 Subject: [PATCH 032/656] Add global confirmation modal system --- src/lib/components/ConfirmationModal.svelte | 157 ++++++++++++++++++++ src/lib/stores/confirmation.ts | 78 ++++++++++ 2 files changed, 235 insertions(+) create mode 100644 src/lib/components/ConfirmationModal.svelte create mode 100644 src/lib/stores/confirmation.ts diff --git a/src/lib/components/ConfirmationModal.svelte b/src/lib/components/ConfirmationModal.svelte new file mode 100644 index 00000000..27b91ab8 --- /dev/null +++ b/src/lib/components/ConfirmationModal.svelte @@ -0,0 +1,157 @@ + + + + +{#if state.open && state.options} + +{/if} + + diff --git a/src/lib/stores/confirmation.ts b/src/lib/stores/confirmation.ts new file mode 100644 index 00000000..d56a35a2 --- /dev/null +++ b/src/lib/stores/confirmation.ts @@ -0,0 +1,78 @@ +/** + * Global confirmation modal store + * Provides a promise-based API for showing confirmation dialogs + */ + +import { writable } from 'svelte/store'; + +export interface ConfirmationOptions { + title: string; + message: string; + confirmText?: string; + cancelText?: string; + destructive?: boolean; // If true, confirm button is styled as destructive +} + +interface ConfirmationState { + open: boolean; + options: ConfirmationOptions | null; + resolve: ((value: boolean) => void) | null; +} + +const initialState: ConfirmationState = { + open: false, + options: null, + resolve: null +}; + +const store = writable(initialState); + +/** + * Show a confirmation dialog and wait for user response + * @returns Promise that resolves to true if confirmed, false if cancelled + */ +function show(options: ConfirmationOptions): Promise { + return new Promise((resolve) => { + store.set({ + open: true, + options: { + confirmText: 'Confirm', + cancelText: 'Cancel', + destructive: false, + ...options + }, + resolve + }); + }); +} + +/** + * Confirm the dialog (called by modal component) + */ +function confirm(): void { + store.update((state) => { + if (state.resolve) { + state.resolve(true); + } + return initialState; + }); +} + +/** + * Cancel the dialog (called by modal component) + */ +function cancel(): void { + store.update((state) => { + if (state.resolve) { + state.resolve(false); + } + return initialState; + }); +} + +export const confirmationStore = { + subscribe: store.subscribe, + show, + confirm, + cancel +}; From 92404012c9285a4ff9050f0af6e321f9d48a0842 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 16 Jan 2026 18:14:15 +0100 Subject: [PATCH 033/656] Add unified import system to fileOps.ts --- src/lib/schema/fileOps.ts | 442 +++++++++++++++++++++++++++++++++++++- 1 file changed, 440 insertions(+), 2 deletions(-) diff --git a/src/lib/schema/fileOps.ts b/src/lib/schema/fileOps.ts index 84db7518..dece15ab 100644 --- a/src/lib/schema/fileOps.ts +++ b/src/lib/schema/fileOps.ts @@ -1,13 +1,19 @@ /** * File operations for saving and loading graph files + * + * This is the single source of truth for all file import/export operations. + * Supports: .blk (block), .sub (subsystem), .pvm (model), .json (legacy model) */ import { tick } from 'svelte'; -import { writable } from 'svelte/store'; +import { writable, get } from 'svelte/store'; import type { NodeInstance, Connection, SimulationSettings, GraphFile, SolverType } from '$lib/nodes/types'; import { GRAPH_FILE_VERSION, INITIAL_SIMULATION_SETTINGS } from '$lib/nodes/types'; +import type { ComponentFile, ComponentType, BlockContent, SubsystemContent } from '$lib/types/component'; +import type { Position } from '$lib/types'; +import { ALL_COMPONENT_EXTENSIONS } from '$lib/types/component'; import { cleanNodeForExport } from './cleanParams'; -import { graphStore } from '$lib/stores/graph'; +import { graphStore, regenerateGraphIds } from '$lib/stores/graph'; import { eventStore } from '$lib/stores/events'; import { settingsStore } from '$lib/stores/settings'; import { codeContextStore } from '$lib/stores/codeContext'; @@ -16,6 +22,10 @@ import { historyStore } from '$lib/stores/history'; import { simulationState, resetSimulation } from '$lib/pyodide/bridge'; import { requestAssemblyAnimation } from '$lib/animation/assemblyAnimation'; import { downloadJson } from '$lib/utils/download'; +import { confirmationStore } from '$lib/stores/confirmation'; +import { nodeRegistry } from '$lib/nodes'; +import { NODE_TYPES } from '$lib/constants/nodeTypes'; +import { generateId } from '$lib/stores/utils'; const STORAGE_KEY = 'pathview_autosave'; const FILE_EXTENSION = '.pvm'; @@ -461,3 +471,431 @@ export function setupAutoSave(intervalMs: number = 30000): () => void { const timer = setInterval(autoSave, intervalMs); return () => clearInterval(timer); } + +// ============================================================================= +// UNIFIED IMPORT SYSTEM +// ============================================================================= + +const COMPONENT_VERSION = '1.0'; + +/** Import result type */ +export interface ImportResult { + success: boolean; + type: ComponentType | 'legacy-model'; + nodeIds?: string[]; // IDs of imported nodes (for components) + cancelled?: boolean; + error?: string; +} + +/** Import options */ +export interface ImportOptions { + position?: Position; // Where to place components (ignored for models) + fileHandle?: FileSystemFileHandle; // For native file picker (enables Save) + fileName?: string; // Display name (for URL imports or fallback) +} + +/** + * Detect file format from parsed JSON + */ +function detectFileFormat(json: unknown): 'component' | 'legacy-model' | 'unknown' { + if (typeof json !== 'object' || json === null) { + return 'unknown'; + } + + const obj = json as Record; + + // New component format has explicit type field + if ('type' in obj && ['block', 'subsystem', 'model'].includes(obj.type as string)) { + return 'component'; + } + + // Legacy model format has graph and version but no type + if ('graph' in obj && 'version' in obj) { + return 'legacy-model'; + } + + return 'unknown'; +} + +/** + * Parse file content and return normalized ComponentFile + */ +function parseFileContent(text: string, fileName: string): ComponentFile { + const json = JSON.parse(text); + const format = detectFileFormat(json); + + if (format === 'unknown') { + throw new Error('Invalid file format'); + } + + if (format === 'legacy-model') { + // Convert legacy model format to component format + return { + version: COMPONENT_VERSION, + type: 'model', + metadata: { + name: json.metadata?.name || fileName.replace(/\.(json|pvm)$/, ''), + created: json.metadata?.created || new Date().toISOString(), + modified: json.metadata?.modified || new Date().toISOString() + }, + content: { + graph: json.graph, + events: json.events, + codeContext: json.codeContext, + simulationSettings: json.simulationSettings + } + }; + } + + // Already in component format + return json as ComponentFile; +} + +/** + * Validate that all node types in a graph are registered + * @returns Array of invalid type names, empty if all valid + */ +function validateNodeTypes(nodes: NodeInstance[]): string[] { + const invalidTypes: string[] = []; + + for (const node of nodes) { + // Skip special types that are always valid + if (node.type === NODE_TYPES.SUBSYSTEM || node.type === NODE_TYPES.INTERFACE) { + // Recursively validate subsystem internal graphs + if (node.graph?.nodes) { + invalidTypes.push(...validateNodeTypes(node.graph.nodes)); + } + continue; + } + + if (!nodeRegistry.has(node.type)) { + invalidTypes.push(node.type); + } + + // Recursively validate subsystem internal graphs + if (node.graph?.nodes) { + invalidTypes.push(...validateNodeTypes(node.graph.nodes)); + } + } + + return [...new Set(invalidTypes)]; // Remove duplicates +} + +/** + * Import a block at the given position + */ +function importBlock(content: BlockContent, position: Position): string[] { + const node = content.node; + + // Validate node type is registered + const invalidTypes = validateNodeTypes([node]); + if (invalidTypes.length > 0) { + throw new Error(`Unknown block type(s): ${invalidTypes.join(', ')}`); + } + + // Generate new ID + const newId = generateId(); + + // Create new node with regenerated IDs + const newNode: NodeInstance = { + ...node, + id: newId, + position: { ...position }, + inputs: node.inputs.map((port, index) => ({ + ...port, + id: `${newId}-input-${index}`, + nodeId: newId + })), + outputs: node.outputs.map((port, index) => ({ + ...port, + id: `${newId}-output-${index}`, + nodeId: newId + })) + }; + + // Clear selection and add node + historyStore.mutate(() => { + graphStore.clearSelection(); + eventStore.clearSelection(); + graphStore.pasteNodes([newNode], []); + }); + + return [newId]; +} + +/** + * Import a subsystem at the given position + */ +function importSubsystem(content: SubsystemContent, position: Position): string[] { + const node = content.node; + + // Validate all node types in subsystem are registered + const invalidTypes = validateNodeTypes([node]); + if (invalidTypes.length > 0) { + throw new Error(`Unknown block type(s): ${invalidTypes.join(', ')}`); + } + + // Generate new ID for the subsystem node + const newId = generateId(); + + // Create new node with regenerated IDs + const newNode: NodeInstance = { + ...node, + id: newId, + position: { ...position }, + inputs: node.inputs.map((port, index) => ({ + ...port, + id: `${newId}-input-${index}`, + nodeId: newId + })), + outputs: node.outputs.map((port, index) => ({ + ...port, + id: `${newId}-output-${index}`, + nodeId: newId + })) + }; + + // Recursively regenerate IDs in the subsystem's internal graph + if (newNode.graph) { + newNode.graph = regenerateGraphIds(newNode.graph); + } + + // Clear selection and add node + historyStore.mutate(() => { + graphStore.clearSelection(); + eventStore.clearSelection(); + graphStore.pasteNodes([newNode], []); + }); + + return [newId]; +} + +/** + * Import a model (replaces entire graph) + */ +async function importModel( + componentFile: ComponentFile, + options: ImportOptions +): Promise { + // Check if we need to confirm with user + const nodeCount = get(graphStore.nodesArray).length; + + if (nodeCount > 0) { + const confirmed = await confirmationStore.show({ + title: 'Unsaved Changes', + message: 'Opening this file will discard your current work. Continue?', + confirmText: 'Discard & Open', + cancelText: 'Cancel', + destructive: true + }); + + if (!confirmed) { + return { success: false, type: 'model', cancelled: true }; + } + } + + // Convert component format back to GraphFile for loading + const content = componentFile.content as { + graph?: GraphFile['graph']; + events?: GraphFile['events']; + codeContext?: GraphFile['codeContext']; + simulationSettings?: GraphFile['simulationSettings']; + }; + + const graphFile: GraphFile = { + version: componentFile.version, + metadata: componentFile.metadata, + graph: content.graph || { nodes: [], connections: [] }, + events: content.events, + codeContext: content.codeContext || { code: '' }, + simulationSettings: content.simulationSettings || INITIAL_SIMULATION_SETTINGS + }; + + await loadGraphFile(graphFile); + + // Update current file tracking + currentFileHandle = options.fileHandle || null; + currentFileNameStore.set( + options.fileName?.replace(/\.(pvm|json)$/, '') || + componentFile.metadata.name || + null + ); + + return { success: true, type: 'model' }; +} + +/** + * Unified file import function + * Handles all file types: .blk, .sub, .pvm, .json + * + * @param file - The file to import + * @param options - Import options (position for components, file handle for models) + * @returns Import result with success status and type + */ +export async function importFile( + file: File, + options: ImportOptions = {} +): Promise { + try { + const text = await file.text(); + const componentFile = parseFileContent(text, file.name); + + switch (componentFile.type) { + case 'block': { + const position = options.position || { x: 100, y: 100 }; + const nodeIds = importBlock(componentFile.content as BlockContent, position); + return { success: true, type: 'block', nodeIds }; + } + + case 'subsystem': { + const position = options.position || { x: 100, y: 100 }; + const nodeIds = importSubsystem(componentFile.content as SubsystemContent, position); + return { success: true, type: 'subsystem', nodeIds }; + } + + case 'model': { + return importModel(componentFile, { + ...options, + fileName: options.fileName || file.name + }); + } + + default: + return { + success: false, + type: componentFile.type, + error: `Unknown component type: ${componentFile.type}` + }; + } + } catch (error) { + return { + success: false, + type: 'model', // Default type for errors + error: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +/** + * Import from URL (for examples and URL parameters) + * + * @param url - URL to fetch the file from + * @param options - Import options + * @returns Import result + */ +export async function importFromUrl( + url: string, + options: ImportOptions = {} +): Promise { + try { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to fetch file: ${res.status} ${res.statusText}`); + } + + const text = await res.text(); + + // Extract filename from URL if not provided + const fileName = options.fileName || url.split('/').pop() || 'model.pvm'; + + const componentFile = parseFileContent(text, fileName); + + switch (componentFile.type) { + case 'block': { + const position = options.position || { x: 100, y: 100 }; + const nodeIds = importBlock(componentFile.content as BlockContent, position); + return { success: true, type: 'block', nodeIds }; + } + + case 'subsystem': { + const position = options.position || { x: 100, y: 100 }; + const nodeIds = importSubsystem(componentFile.content as SubsystemContent, position); + return { success: true, type: 'subsystem', nodeIds }; + } + + case 'model': { + return importModel(componentFile, { + ...options, + fileName + }); + } + + default: + return { + success: false, + type: componentFile.type, + error: `Unknown component type: ${componentFile.type}` + }; + } + } catch (error) { + return { + success: false, + type: 'model', + error: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + +/** + * Open unified import dialog + * Supports all file types: .blk, .sub, .pvm, .json + * + * @param position - Position for component imports (optional) + * @returns Import result + */ +export async function openImportDialog( + position?: Position +): Promise { + if (hasFileSystemAccess()) { + try { + const [handle] = await (window as any).showOpenFilePicker({ + types: [{ + description: 'PathView Files', + accept: { 'application/json': ALL_COMPONENT_EXTENSIONS } + }], + multiple: false + }); + + const file = await handle.getFile(); + return importFile(file, { + position, + fileHandle: handle, + fileName: handle.name + }); + } catch (error: any) { + if (error.name === 'AbortError') { + return { success: false, type: 'model', cancelled: true }; + } + console.error('Failed to open file:', error); + return { + success: false, + type: 'model', + error: 'Failed to open file. Make sure it is a valid PathView file.' + }; + } + } + + // Fallback for browsers without File System Access API + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = ALL_COMPONENT_EXTENSIONS.join(','); + + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) { + resolve({ success: false, type: 'model', cancelled: true }); + return; + } + + const result = await importFile(file, { + position, + fileName: file.name + }); + resolve(result); + }; + + input.oncancel = () => resolve({ success: false, type: 'model', cancelled: true }); + input.click(); + }); +} From ec99bf2682e36071b306633f89f4621e7c977a0d Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 16 Jan 2026 18:15:05 +0100 Subject: [PATCH 034/656] Simplify componentOps.ts, move import logic to fileOps.ts --- src/lib/schema/componentOps.ts | 273 ++------------------------------- 1 file changed, 14 insertions(+), 259 deletions(-) diff --git a/src/lib/schema/componentOps.ts b/src/lib/schema/componentOps.ts index b720fa1e..c34d0904 100644 --- a/src/lib/schema/componentOps.ts +++ b/src/lib/schema/componentOps.ts @@ -1,58 +1,19 @@ /** - * Component operations for exporting and importing blocks, subsystems, and models + * Component export operations for blocks and subsystems + * + * Note: Import operations have been consolidated into fileOps.ts + * Use openImportDialog() or importFile() from fileOps.ts for imports. */ -import type { NodeInstance, Connection } from '$lib/nodes/types'; -import type { EventInstance } from '$lib/events/types'; -import type { Position } from '$lib/types'; -import type { - ComponentFile, - ComponentType, - BlockContent, - SubsystemContent, - COMPONENT_EXTENSIONS -} from '$lib/types/component'; -import { graphStore, regenerateGraphIds } from '$lib/stores/graph'; -import { eventStore } from '$lib/stores/events'; -import { historyStore } from '$lib/stores/history'; -import { generateId } from '$lib/stores/utils'; +import type { NodeInstance } from '$lib/nodes/types'; +import type { ComponentFile, ComponentType, BlockContent, SubsystemContent } from '$lib/types/component'; +import { graphStore } from '$lib/stores/graph'; import { NODE_TYPES } from '$lib/constants/nodeTypes'; -import { nodeRegistry } from '$lib/nodes'; import { downloadJson } from '$lib/utils/download'; import { cleanNodeForExport } from './cleanParams'; const COMPONENT_VERSION = '1.0'; -/** - * Validate that all node types in a graph are registered - * @returns Array of invalid type names, empty if all valid - */ -function validateNodeTypes(nodes: NodeInstance[]): string[] { - const invalidTypes: string[] = []; - - for (const node of nodes) { - // Skip special types that are always valid - if (node.type === NODE_TYPES.SUBSYSTEM || node.type === NODE_TYPES.INTERFACE) { - // Recursively validate subsystem internal graphs - if (node.graph?.nodes) { - invalidTypes.push(...validateNodeTypes(node.graph.nodes)); - } - continue; - } - - if (!nodeRegistry.has(node.type)) { - invalidTypes.push(node.type); - } - - // Recursively validate subsystem internal graphs - if (node.graph?.nodes) { - invalidTypes.push(...validateNodeTypes(node.graph.nodes)); - } - } - - return [...new Set(invalidTypes)]; // Remove duplicates -} - /** * Check if File System Access API is available */ @@ -135,6 +96,13 @@ function getFileTypeDescription(type: ComponentType): string { return descriptions[type]; } +/** + * Download component file (fallback for browsers without File System Access API) + */ +function downloadComponent(file: ComponentFile, filename: string): void { + downloadJson(file, filename); +} + /** * Export a component to file (opens save dialog) */ @@ -187,216 +155,3 @@ export async function exportComponent(type: ComponentType, nodeId: string): Prom downloadComponent(componentFile, suggestedName); return true; } - -/** - * Download component file (fallback for browsers without File System Access API) - */ -function downloadComponent(file: ComponentFile, filename: string): void { - downloadJson(file, filename); -} - -/** - * Detect file format from parsed JSON - */ -function detectFileFormat( - json: unknown -): 'component' | 'legacy-model' | 'unknown' { - if (typeof json !== 'object' || json === null) { - return 'unknown'; - } - - const obj = json as Record; - - // New component format has explicit type field - if ('type' in obj && ['block', 'subsystem', 'model'].includes(obj.type as string)) { - return 'component'; - } - - // Legacy model format has graph and version but no type - if ('graph' in obj && 'version' in obj) { - return 'legacy-model'; - } - - return 'unknown'; -} - -/** - * Load and validate a component file - */ -export async function loadComponentFile(file: File): Promise { - const text = await file.text(); - const json = JSON.parse(text); - - const format = detectFileFormat(json); - - if (format === 'unknown') { - throw new Error('Invalid file format'); - } - - if (format === 'legacy-model') { - // Convert legacy model format to component format - return { - version: COMPONENT_VERSION, - type: 'model', - metadata: { - name: json.metadata?.name || file.name.replace(/\.(json|pvm)$/, ''), - created: json.metadata?.created || new Date().toISOString(), - modified: json.metadata?.modified || new Date().toISOString() - }, - content: { - graph: json.graph, - events: json.events, - codeContext: json.codeContext, - simulationSettings: json.simulationSettings - } - }; - } - - // Already in component format - return json as ComponentFile; -} - -/** - * Import component into current graph at position - * @returns IDs of imported nodes - */ -export async function importComponent( - file: File, - position: Position -): Promise { - const componentFile = await loadComponentFile(file); - - switch (componentFile.type) { - case 'block': - return importBlock(componentFile.content as BlockContent, position); - case 'subsystem': - return importSubsystem(componentFile.content as SubsystemContent, position); - case 'model': - // For now, model import not supported via context menu - // Full models should be opened via File > Open - console.warn('Model import not supported. Use File > Open instead.'); - return []; - default: - throw new Error(`Unknown component type: ${componentFile.type}`); - } -} - -/** - * Import a block at the given position - */ -function importBlock(content: BlockContent, position: Position): string[] { - const node = content.node; - - // Validate node type is registered - const invalidTypes = validateNodeTypes([node]); - if (invalidTypes.length > 0) { - throw new Error(`Unknown block type(s): ${invalidTypes.join(', ')}`); - } - - // Generate new ID - const newId = generateId(); - - // Create new node with regenerated IDs - const newNode: NodeInstance = { - ...node, - id: newId, - position: { ...position }, - inputs: node.inputs.map((port, index) => ({ - ...port, - id: `${newId}-input-${index}`, - nodeId: newId - })), - outputs: node.outputs.map((port, index) => ({ - ...port, - id: `${newId}-output-${index}`, - nodeId: newId - })) - }; - - // Clear selection and add node - historyStore.mutate(() => { - graphStore.clearSelection(); - eventStore.clearSelection(); - graphStore.pasteNodes([newNode], []); - }); - - return [newId]; -} - -/** - * Import a subsystem at the given position - */ -function importSubsystem(content: SubsystemContent, position: Position): string[] { - const node = content.node; - - // Validate all node types in subsystem are registered - const invalidTypes = validateNodeTypes([node]); - if (invalidTypes.length > 0) { - throw new Error(`Unknown block type(s): ${invalidTypes.join(', ')}`); - } - - // Generate new ID for the subsystem node - const newId = generateId(); - - // Create new node with regenerated IDs - const newNode: NodeInstance = { - ...node, - id: newId, - position: { ...position }, - inputs: node.inputs.map((port, index) => ({ - ...port, - id: `${newId}-input-${index}`, - nodeId: newId - })), - outputs: node.outputs.map((port, index) => ({ - ...port, - id: `${newId}-output-${index}`, - nodeId: newId - })) - }; - - // Recursively regenerate IDs in the subsystem's internal graph - if (newNode.graph) { - newNode.graph = regenerateGraphIds(newNode.graph); - } - - // Clear selection and add node - historyStore.mutate(() => { - graphStore.clearSelection(); - eventStore.clearSelection(); - graphStore.pasteNodes([newNode], []); - }); - - return [newId]; -} - -/** - * Open file picker and import component at position - */ -export async function openComponentImportDialog( - position: Position -): Promise { - return new Promise((resolve) => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.blk,.sub,.pvm,.json'; - - input.onchange = async () => { - if (input.files?.[0]) { - try { - const ids = await importComponent(input.files[0], position); - resolve(ids); - } catch (error) { - console.error('Failed to import component:', error); - alert('Failed to import component. Make sure it is a valid PathView file.'); - resolve([]); - } - } else { - resolve([]); - } - }; - - input.oncancel = () => resolve([]); - input.click(); - }); -} From ef111afbc8301867f972a78dd453f06bc9b262f1 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 16 Jan 2026 18:16:31 +0100 Subject: [PATCH 035/656] Update context menu and drag/drop to use unified import --- src/lib/components/FlowUpdater.svelte | 18 ++++++++++-------- src/lib/components/contextMenuBuilders.ts | 7 ++++--- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/lib/components/FlowUpdater.svelte b/src/lib/components/FlowUpdater.svelte index c0809144..289ac9bb 100644 --- a/src/lib/components/FlowUpdater.svelte +++ b/src/lib/components/FlowUpdater.svelte @@ -9,7 +9,7 @@ import { get } from 'svelte/store'; import { dropTargetBridge } from '$lib/stores/dropTargetBridge'; import { assemblyAnimationTrigger, runAssemblyAnimation } from '$lib/animation/assemblyAnimation'; - import { importComponent } from '$lib/schema/componentOps'; + import { importFile } from '$lib/schema/fileOps'; import { ALL_COMPONENT_EXTENSIONS } from '$lib/types/component'; interface Props { @@ -101,21 +101,23 @@ y: event.clientY }); - // Check for file drop (component import) + // Check for file drop (import any supported file type) const files = event.dataTransfer?.files; if (files && files.length > 0) { const file = files[0]; const extension = '.' + file.name.split('.').pop()?.toLowerCase(); if (ALL_COMPONENT_EXTENSIONS.includes(extension)) { - try { - await importComponent(file, { + const result = await importFile(file, { + position: { x: position.x - 80, y: position.y - 30 - }); - } catch (error) { - console.error('Failed to import component:', error); - alert(`Failed to import component: ${error instanceof Error ? error.message : 'Unknown error'}`); + }, + fileName: file.name + }); + + if (!result.success && !result.cancelled && result.error) { + alert(`Failed to import: ${result.error}`); } return; } diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index effb8bdc..f76655ba 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -17,7 +17,8 @@ import { NODE_TYPES } from '$lib/constants/nodeTypes'; import { triggerFitView, screenToFlow } from '$lib/stores/viewActions'; import { generateBlockCode, generateSingleEventCode } from '$lib/pyodide/pathsimRunner'; import { generateBlockCodeHeader, generateEventCodeHeader } from '$lib/utils/codePreviewHeader'; -import { exportComponent, openComponentImportDialog } from '$lib/schema/componentOps'; +import { exportComponent } from '$lib/schema/componentOps'; +import { openImportDialog } from '$lib/schema/fileOps'; import { hasExportableData, exportRecordingData } from '$lib/utils/csvExport'; import { exportGraphAsSvg } from '$lib/utils/svgExport'; @@ -333,11 +334,11 @@ function buildCanvasMenu( }, DIVIDER, { - label: 'Import', + label: 'Open/Import', icon: 'download', action: () => { const flowPos = screenToFlow(screenPosition); - openComponentImportDialog(flowPos); + openImportDialog(flowPos); } }, { From ed65b398085138bbf8de49459e52b0fe4e1198a6 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 16 Jan 2026 18:18:19 +0100 Subject: [PATCH 036/656] Update +page.svelte to use unified import system --- src/routes/+page.svelte | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 3a9bef81..2d1f72c0 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -40,7 +40,8 @@ import { pyodideState, simulationState, initPyodide, stopSimulation, continueStreamingSimulation } from '$lib/pyodide/bridge'; import { runGraphStreamingSimulation, validateGraphSimulation } from '$lib/pyodide/pathsimRunner'; import { consoleStore } from '$lib/stores/console'; - import { newGraph, openFile, saveFile, saveAsFile, setupAutoSave, clearAutoSave, debouncedAutoSave, loadGraphFromUrl, currentFileName } from '$lib/schema/fileOps'; + import { newGraph, saveFile, saveAsFile, setupAutoSave, clearAutoSave, debouncedAutoSave, openImportDialog, importFromUrl, currentFileName } from '$lib/schema/fileOps'; + import ConfirmationModal from '$lib/components/ConfirmationModal.svelte'; import { triggerFitView, triggerZoomIn, triggerZoomOut, triggerPan, getViewportCenter, screenToFlow, triggerClearSelection, triggerNudge, hasAnySelection, setFitViewPadding } from '$lib/stores/viewActions'; import { nodeUpdatesStore } from '$lib/stores/nodeUpdates'; import { pinnedPreviewsStore } from '$lib/stores/pinnedPreviews'; @@ -786,9 +787,9 @@ } async function handleOpen() { - if (nodeCount > 0 && !confirm('Open file? Unsaved changes will be lost.')) return; - const file = await openFile(); - if (file) { + // Uses unified import system with built-in confirmation + const result = await openImportDialog(); + if (result.success && result.type === 'model') { // Trigger fit view after a brief delay to let nodes render setTimeout(() => triggerFitView(), 100); } @@ -796,8 +797,8 @@ // Load example file async function handleLoadExample(url: string) { - const file = await loadGraphFromUrl(url); - if (file) { + const result = await importFromUrl(url); + if (result.success) { // Trigger fit view after a brief delay to let nodes render setTimeout(() => triggerFitView(), 100); } @@ -840,17 +841,12 @@ return; } - try { - const file = await loadGraphFromUrl(url); - if (file) { - setTimeout(() => triggerFitView(), 100); - } else { - throw new Error('Failed to load model'); - } - } catch (e) { - const errorMsg = e instanceof Error ? e.message : 'Unknown error'; + const result = await importFromUrl(url); + if (result.success) { + setTimeout(() => triggerFitView(), 100); + } else if (result.error) { consoleStore.error(`Failed to load model from URL: ${url}`); - consoleStore.error(errorMsg); + consoleStore.error(result.error); showConsole = true; } } @@ -953,8 +949,8 @@ - diff --git a/src/lib/schema/fileOps.ts b/src/lib/schema/fileOps.ts index dece15ab..aed0b901 100644 --- a/src/lib/schema/fileOps.ts +++ b/src/lib/schema/fileOps.ts @@ -355,97 +355,6 @@ function downloadGraphFile(filename: string): void { currentFileNameStore.set(name); } -/** - * Open file dialog and load graph - */ -export async function openFile(): Promise { - if (hasFileSystemAccess()) { - try { - const [handle] = await (window as any).showOpenFilePicker({ - types: [{ - description: 'PathView Model', - accept: { 'application/json': ['.pvm', '.json'] } - }], - multiple: false - }); - - const file = await handle.getFile(); - const text = await file.text(); - const graphFile = JSON.parse(text) as GraphFile; - await loadGraphFile(graphFile); - - // Track the file handle for future saves - currentFileHandle = handle; - currentFileNameStore.set(handle.name.replace(/\.(pvm|json)$/, '')); - - return graphFile; - } catch (error: any) { - if (error.name === 'AbortError') { - return null; // User cancelled - } - console.error('Failed to open file:', error); - alert('Failed to open file. Make sure it is a valid PathView JSON file.'); - return null; - } - } - - // Fallback for browsers without File System Access API - return new Promise((resolve) => { - const input = document.createElement('input'); - input.type = 'file'; - input.accept = '.pvm,.json'; - - input.onchange = async () => { - const file = input.files?.[0]; - if (!file) { - resolve(null); - return; - } - - try { - const text = await file.text(); - const graphFile = JSON.parse(text) as GraphFile; - await loadGraphFile(graphFile); - - // Track file name (but no handle in fallback mode) - currentFileHandle = null; - currentFileNameStore.set(file.name.replace(/\.(pvm|json)$/, '')); - - resolve(graphFile); - } catch (error) { - console.error('Failed to open file:', error); - alert('Failed to open file. Make sure it is a valid PathView JSON file.'); - resolve(null); - } - }; - - input.oncancel = () => resolve(null); - input.click(); - }); -} - -/** - * Load graph from URL (e.g., example files) - */ -export async function loadGraphFromUrl(url: string): Promise { - try { - const res = await fetch(url); - if (!res.ok) throw new Error('Failed to fetch file'); - const graphFile = JSON.parse(await res.text()) as GraphFile; - await loadGraphFile(graphFile); - - // Extract name from URL - const name = url.split('/').pop()?.replace(/\.(pvm|json)$/, '') || null; - currentFileHandle = null; - currentFileNameStore.set(name); - - return graphFile; - } catch (error) { - console.error('Failed to load file from URL:', error); - return null; - } -} - /** * Create new empty graph */ From 8912884dd0b3f584ea3812293e8da2af5525c066 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 16 Jan 2026 18:27:17 +0100 Subject: [PATCH 038/656] Update ConfirmationModal to match app dialog styles --- src/lib/components/ConfirmationModal.svelte | 77 +++++++++------------ 1 file changed, 32 insertions(+), 45 deletions(-) diff --git a/src/lib/components/ConfirmationModal.svelte b/src/lib/components/ConfirmationModal.svelte index 27b91ab8..045bb8b8 100644 --- a/src/lib/components/ConfirmationModal.svelte +++ b/src/lib/components/ConfirmationModal.svelte @@ -2,6 +2,7 @@ import { fade, scale } from 'svelte/transition'; import { cubicOut } from 'svelte/easing'; import { confirmationStore } from '$lib/stores/confirmation'; + import Icon from '$lib/components/icons/Icon.svelte'; let state = $state<{ open: boolean; @@ -59,16 +60,23 @@ aria-labelledby="confirmation-title" aria-describedby="confirmation-message" > -
        -

        {state.options.title}

        +
        + {state.options.title} + +
        + +

        {state.options.message}

        -
        - -
        @@ -113,14 +109,4 @@ gap: var(--space-sm); padding: var(--space-sm) var(--space-md) var(--space-md); } - - /* Destructive action - ghost style with error color */ - .dialog-footer button.destructive { - background: var(--error-bg); - color: var(--error); - } - - .dialog-footer button.destructive:hover { - background: color-mix(in srgb, var(--error) 20%, transparent); - } From 928017e9fdac12aed6b4b2f2cb66a120ff280b96 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 16 Jan 2026 18:35:05 +0100 Subject: [PATCH 042/656] Remove unused button.primary styles --- src/app.css | 10 ---------- src/lib/components/ConfirmationModal.svelte | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/app.css b/src/app.css index 2aff0206..b7688cce 100644 --- a/src/app.css +++ b/src/app.css @@ -165,16 +165,6 @@ button:disabled { cursor: not-allowed; } -button.primary { - background: var(--accent); - color: white; -} - -button.primary:hover { - background: var(--accent-hover); - box-shadow: var(--shadow-glow); -} - button.ghost { background: transparent; color: var(--text-muted); diff --git a/src/lib/components/ConfirmationModal.svelte b/src/lib/components/ConfirmationModal.svelte index 33b8d39f..86451b0c 100644 --- a/src/lib/components/ConfirmationModal.svelte +++ b/src/lib/components/ConfirmationModal.svelte @@ -75,7 +75,7 @@ -
        From 68b6c6e7f48e64b88340e64ed6c405e7e3e0150b Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 16 Jan 2026 18:41:18 +0100 Subject: [PATCH 043/656] Remove unused destructive option from confirmation modal --- src/lib/components/ConfirmationModal.svelte | 1 - src/lib/schema/fileOps.ts | 3 +-- src/lib/stores/confirmation.ts | 2 -- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/lib/components/ConfirmationModal.svelte b/src/lib/components/ConfirmationModal.svelte index 86451b0c..a3fe2b97 100644 --- a/src/lib/components/ConfirmationModal.svelte +++ b/src/lib/components/ConfirmationModal.svelte @@ -11,7 +11,6 @@ message: string; confirmText?: string; cancelText?: string; - destructive?: boolean; } | null; }>({ open: false, options: null }); diff --git a/src/lib/schema/fileOps.ts b/src/lib/schema/fileOps.ts index aed0b901..6f5317e9 100644 --- a/src/lib/schema/fileOps.ts +++ b/src/lib/schema/fileOps.ts @@ -594,8 +594,7 @@ async function importModel( title: 'Unsaved Changes', message: 'Opening this file will discard your current work. Continue?', confirmText: 'Discard & Open', - cancelText: 'Cancel', - destructive: true + cancelText: 'Cancel' }); if (!confirmed) { diff --git a/src/lib/stores/confirmation.ts b/src/lib/stores/confirmation.ts index d56a35a2..78fafaa3 100644 --- a/src/lib/stores/confirmation.ts +++ b/src/lib/stores/confirmation.ts @@ -10,7 +10,6 @@ export interface ConfirmationOptions { message: string; confirmText?: string; cancelText?: string; - destructive?: boolean; // If true, confirm button is styled as destructive } interface ConfirmationState { @@ -38,7 +37,6 @@ function show(options: ConfirmationOptions): Promise { options: { confirmText: 'Confirm', cancelText: 'Cancel', - destructive: false, ...options }, resolve From 3fbfba16a5c65fa4466ddc6eb25afd327625607e Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 16 Jan 2026 18:51:48 +0100 Subject: [PATCH 044/656] Refactor: deduplicate shared constants and extract processImportContent --- src/lib/components/ConfirmationModal.svelte | 9 +- src/lib/schema/componentOps.ts | 11 +- src/lib/schema/fileOps.ts | 105 ++++++++------------ src/lib/types/component.ts | 3 + 4 files changed, 48 insertions(+), 80 deletions(-) diff --git a/src/lib/components/ConfirmationModal.svelte b/src/lib/components/ConfirmationModal.svelte index a3fe2b97..0260dbc1 100644 --- a/src/lib/components/ConfirmationModal.svelte +++ b/src/lib/components/ConfirmationModal.svelte @@ -1,17 +1,12 @@ + + + +{#if open} + + +{/if} + + diff --git a/src/lib/components/panels/SignalPlot.svelte b/src/lib/components/panels/SignalPlot.svelte index bbee8539..dfea4583 100644 --- a/src/lib/components/panels/SignalPlot.svelte +++ b/src/lib/components/panels/SignalPlot.svelte @@ -7,9 +7,12 @@ createSpectrumTrace, createGhostScopeTrace, createGhostSpectrumTrace, - plotConfig + plotConfig, + type TraceStyleOptions, + type LayoutStyleOptions } from '$lib/plotting/plotUtils'; import { themeStore, type Theme } from '$lib/stores/theme'; + import { plotSettingsStore, type PlotSettings } from '$lib/stores/plotSettings'; import { enqueuePlotUpdate, cancelPlotUpdate, isVisible } from './plotQueue'; interface ScopeData { @@ -63,6 +66,13 @@ let Plotly: typeof import('plotly.js-dist-min') | null = null; let resizeObserver: ResizeObserver | null = null; let currentTheme = $state('dark'); + let plotSettings = $state({ + lineStyle: 'solid', + showMarkers: false, + markerStyle: 'circle', + yAxisScale: 'linear', + showLegend: false + }); // Unique ID for this component in the shared render queue const componentId = Symbol(); @@ -77,6 +87,10 @@ currentTheme = theme; }); + const unsubscribePlotSettings = plotSettingsStore.subscribe((s) => { + plotSettings = s; + }); + onMount(async () => { Plotly = await import('plotly.js-dist-min'); scheduleUpdate(); @@ -117,6 +131,7 @@ const _type = type; const _showLegend = showLegend; const _isStreaming = isStreaming; + const _plotSettings = plotSettings; if (Plotly && plotDiv) { // Always update - handles empty data with ghosts, etc. scheduleUpdate(); @@ -143,6 +158,16 @@ const spectrumGhosts = ghostData as SpectrumData[]; const totalGhosts = spectrumGhosts.length; + // Style options from store + const traceStyle: TraceStyleOptions = { + lineStyle: plotSettings.lineStyle, + showMarkers: plotSettings.showMarkers, + markerStyle: plotSettings.markerStyle + }; + const layoutStyle: LayoutStyleOptions = { + yAxisScale: plotSettings.yAxisScale + }; + // Add ghost traces for (let ghostIdx = totalGhosts - 1; ghostIdx >= 0; ghostIdx--) { const ghost = spectrumGhosts[ghostIdx]; @@ -153,13 +178,13 @@ } // Add current data traces - let layout = getSpectrumLayout(plotTitle, undefined, showLegend); + let layout = getSpectrumLayout(plotTitle, undefined, showLegend, layoutStyle); if (hasData()) { const spectrumData = data as SpectrumData; for (let i = 0; i < spectrumData.magnitude.length; i++) { - traces.push(createSpectrumTrace(spectrumData.frequency, spectrumData.magnitude[i], i, getSignalLabel(i))); + traces.push(createSpectrumTrace(spectrumData.frequency, spectrumData.magnitude[i], i, getSignalLabel(i), traceStyle)); } - layout = getSpectrumLayout(plotTitle, spectrumData.frequency, showLegend); + layout = getSpectrumLayout(plotTitle, spectrumData.frequency, showLegend, layoutStyle); } if (traces.length === 0) { @@ -189,8 +214,18 @@ function fullScopeRender(scopeData: ScopeData | null, scopeGhosts: ScopeData[]) { if (!Plotly || !plotDiv) return; + // Style options from store + const traceStyle: TraceStyleOptions = { + lineStyle: plotSettings.lineStyle, + showMarkers: plotSettings.showMarkers, + markerStyle: plotSettings.markerStyle + }; + const layoutStyle: LayoutStyleOptions = { + yAxisScale: plotSettings.yAxisScale + }; + const traces: Partial[] = []; - const layout = getScopeLayout(plotTitle, showLegend); + const layout = getScopeLayout(plotTitle, showLegend, layoutStyle); const totalGhosts = scopeGhosts.length; // Add ghost traces (rendered once, won't change during streaming) @@ -208,7 +243,7 @@ // Add current data traces if (scopeData && scopeData.time?.length > 0) { for (let i = 0; i < scopeData.signals.length; i++) { - traces.push(createScopeTrace(scopeData.time, scopeData.signals[i], i, getSignalLabel(i))); + traces.push(createScopeTrace(scopeData.time, scopeData.signals[i], i, getSignalLabel(i), traceStyle)); } } @@ -269,6 +304,7 @@ onDestroy(() => { unsubscribeTheme(); + unsubscribePlotSettings(); cancelPlotUpdate(componentId); if (resizeObserver) { resizeObserver.disconnect(); diff --git a/src/lib/plotting/plotUtils.ts b/src/lib/plotting/plotUtils.ts index 009f9e30..dc5635f8 100644 --- a/src/lib/plotting/plotUtils.ts +++ b/src/lib/plotting/plotUtils.ts @@ -3,6 +3,38 @@ * Uses CSS variables from app.css for consistent theming */ +import type { LineStyle, MarkerStyle, AxisScale } from '$lib/stores/plotSettings'; + +/** Style options for trace rendering */ +export interface TraceStyleOptions { + lineStyle?: LineStyle; + showMarkers?: boolean; + markerStyle?: MarkerStyle; +} + +/** Style options for layout */ +export interface LayoutStyleOptions { + yAxisScale?: AxisScale; +} + +/** Map our line style names to Plotly dash values */ +const LINE_DASH_MAP: Record = { + solid: 'solid', + dash: 'dash', + dot: 'dot', + dashdot: 'dashdot' +}; + +/** Map our marker style names to Plotly symbol values */ +const MARKER_SYMBOL_MAP: Record = { + circle: 'circle', + square: 'square', + diamond: 'diamond', + 'triangle-up': 'triangle-up', + cross: 'cross', + x: 'x' +}; + /** * Read a CSS variable value from the document root * Automatically reflects current theme (light/dark) @@ -74,7 +106,11 @@ export function getBaseLayout(): Partial { } // Scope plot (time-domain) layout -export function getScopeLayout(title: string = 'Scope', showLegend: boolean = false): Partial { +export function getScopeLayout( + title: string = 'Scope', + showLegend: boolean = false, + styleOptions?: LayoutStyleOptions +): Partial { const baseLayout = getBaseLayout(); // Format y-axis label as "Scope: Name" or just "Scope" if no custom name const yAxisLabel = title.toLowerCase().startsWith('scope') ? title : `Scope: ${title}`; @@ -86,7 +122,8 @@ export function getScopeLayout(title: string = 'Scope', showLegend: boolean = fa }, yaxis: { ...baseLayout.yaxis, - title: { text: yAxisLabel, font: { size: 11 }, standoff: 5 } + title: { text: yAxisLabel, font: { size: 11 }, standoff: 5 }, + type: styleOptions?.yAxisScale ?? 'linear' }, showlegend: showLegend, legend: { @@ -105,7 +142,8 @@ export function getScopeLayout(title: string = 'Scope', showLegend: boolean = fa export function getSpectrumLayout( title: string = 'Spectrum', frequencies?: number[], - showLegend: boolean = false + showLegend: boolean = false, + styleOptions?: LayoutStyleOptions ): Partial { const baseLayout = getBaseLayout(); @@ -134,6 +172,9 @@ export function getSpectrumLayout( // Format y-axis label as "Spectrum: Name" or just "Spectrum" if no custom name const yAxisLabel = title.toLowerCase().startsWith('spectrum') ? title : `Spectrum: ${title}`; + // Spectrum defaults to log scale if not specified + const yAxisScale = styleOptions?.yAxisScale ?? 'log'; + return { ...baseLayout, xaxis: { @@ -146,7 +187,7 @@ export function getSpectrumLayout( yaxis: { ...baseLayout.yaxis, title: { text: yAxisLabel, font: { size: 11 }, standoff: 5 }, - type: 'log' + type: yAxisScale }, showlegend: showLegend, legend: { @@ -228,21 +269,39 @@ export function createScopeTrace( time: number[], signal: number[], index: number, - name?: string + name?: string, + styleOptions?: TraceStyleOptions ): Partial { const traceName = name || `port ${index}`; const color = getSignalColor(index); + + // Determine mode based on marker settings + const showMarkers = styleOptions?.showMarkers ?? false; + const mode = showMarkers ? 'lines+markers' : 'lines'; + + // Line dash style + const dash = LINE_DASH_MAP[styleOptions?.lineStyle ?? 'solid']; + + // Marker configuration + const marker = showMarkers ? { + symbol: MARKER_SYMBOL_MAP[styleOptions?.markerStyle ?? 'circle'], + size: 6, + color + } : undefined; + return { x: time, y: signal, type: TRACE_TYPE, - mode: 'lines', + mode, name: traceName, legendgroup: `signal-${index}`, line: { color, - width: 1.5 + width: 1.5, + dash }, + marker, hovertemplate: `${traceName}
        t = %{x:.4g} s
        y = %{y:.4g}` }; } @@ -281,23 +340,41 @@ export function createSpectrumTrace( frequency: number[], magnitude: number[], index: number, - name?: string + name?: string, + styleOptions?: TraceStyleOptions ): Partial { const traceName = name || `port ${index}`; const color = getSignalColor(index); // Use indices for x-axis (equal spacing) const indices = Array.from({ length: magnitude.length }, (_, i) => i); + + // Determine mode based on marker settings + const showMarkers = styleOptions?.showMarkers ?? false; + const mode = showMarkers ? 'lines+markers' : 'lines'; + + // Line dash style + const dash = LINE_DASH_MAP[styleOptions?.lineStyle ?? 'solid']; + + // Marker configuration + const marker = showMarkers ? { + symbol: MARKER_SYMBOL_MAP[styleOptions?.markerStyle ?? 'circle'], + size: 6, + color + } : undefined; + return { x: indices, y: magnitude, type: TRACE_TYPE, - mode: 'lines', + mode, name: traceName, legendgroup: `signal-${index}`, line: { color, - width: 1.5 + width: 1.5, + dash }, + marker, // Store frequency in customdata for hover customdata: frequency, hovertemplate: `${traceName}
        f = %{customdata:.2f} Hz
        mag = %{y:.4g}` diff --git a/src/lib/stores/plotSettings.ts b/src/lib/stores/plotSettings.ts new file mode 100644 index 00000000..311da7aa --- /dev/null +++ b/src/lib/stores/plotSettings.ts @@ -0,0 +1,64 @@ +/** + * Plot settings store + * Controls visual appearance of plots (line styles, markers, axis scaling) + */ + +import { writable, get } from 'svelte/store'; + +export type LineStyle = 'solid' | 'dash' | 'dot' | 'dashdot'; +export type MarkerStyle = 'circle' | 'square' | 'diamond' | 'cross' | 'x' | 'triangle-up'; +export type AxisScale = 'linear' | 'log'; + +export interface PlotSettings { + lineStyle: LineStyle; + showMarkers: boolean; + markerStyle: MarkerStyle; + yAxisScale: AxisScale; + showLegend: boolean; +} + +const DEFAULT_PLOT_SETTINGS: PlotSettings = { + lineStyle: 'solid', + showMarkers: false, + markerStyle: 'circle', + yAxisScale: 'linear', + showLegend: false +}; + +const settings = writable({ ...DEFAULT_PLOT_SETTINGS }); + +export const plotSettingsStore = { + subscribe: settings.subscribe, + + update(newSettings: Partial): void { + settings.update((s) => ({ ...s, ...newSettings })); + }, + + setLineStyle(lineStyle: LineStyle): void { + settings.update((s) => ({ ...s, lineStyle })); + }, + + setShowMarkers(showMarkers: boolean): void { + settings.update((s) => ({ ...s, showMarkers })); + }, + + setMarkerStyle(markerStyle: MarkerStyle): void { + settings.update((s) => ({ ...s, markerStyle })); + }, + + setYAxisScale(yAxisScale: AxisScale): void { + settings.update((s) => ({ ...s, yAxisScale })); + }, + + setShowLegend(showLegend: boolean): void { + settings.update((s) => ({ ...s, showLegend })); + }, + + reset(): void { + settings.set({ ...DEFAULT_PLOT_SETTINGS }); + }, + + get(): PlotSettings { + return get(settings); + } +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 19d38bbe..c74ef144 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -18,6 +18,8 @@ import { buildContextMenuItems, type ContextMenuCallbacks } from '$lib/components/contextMenuBuilders'; import ExportDialog from '$lib/components/dialogs/ExportDialog.svelte'; import KeyboardShortcutsDialog from '$lib/components/dialogs/KeyboardShortcutsDialog.svelte'; + import PlotOptionsDialog from '$lib/components/dialogs/PlotOptionsDialog.svelte'; + import { plotSettingsStore } from '$lib/stores/plotSettings'; import SearchDialog from '$lib/components/dialogs/SearchDialog.svelte'; import ResizablePanel from '$lib/components/ResizablePanel.svelte'; import WelcomeModal from '$lib/components/WelcomeModal.svelte'; @@ -271,6 +273,7 @@ let exportDialogOpen = $state(false); let showKeyboardShortcuts = $state(false); let showSearchDialog = $state(false); + let showPlotOptionsDialog = $state(false); // Context menu state let contextMenuOpen = $state(false); @@ -348,7 +351,7 @@ let consoleLogCount = $state(0); let plotActiveTab = $state(0); let plotViewMode = $state<'tabs' | 'tiles'>('tabs'); - let showPlotLegend = $state(false); + let showPlotLegend = $state(false); // Synced with plotSettingsStore let resultPlots = $state<{ id: string; type: 'scope' | 'spectrum'; title: string }[]>([]); // Tooltip for continue button - simple, disabled state shows availability @@ -423,6 +426,11 @@ const unsubConsole = consoleStore.subscribe((logs) => { consoleLogCount = logs.length; }); + + const unsubPlotSettings = plotSettingsStore.subscribe((s) => { + showPlotLegend = s.showLegend; + }); + // Always start with clean slate clearAutoSave(); @@ -459,6 +467,7 @@ unsubPyodide(); unsubSimulation(); unsubConsole(); + unsubPlotSettings(); // Cleanup autosave subscriptions cleanupAutoSave(); unsubNodes(); @@ -1176,7 +1185,15 @@ {#snippet actions()} + - {/each} - - {/if} +
        + Markers +
        + + {#each markerStyles as style} + + {/each} +
        - -
        -
        Y-Axis Scale
        -
        + +
        + X-Axis Scale +
        + +
        +
        + + +
        + Y-Axis Scale +
        +
        -
        -
        Display
        - +
        + Legend +
        + + +
        @@ -213,91 +240,68 @@ .dialog-body { padding: var(--space-md); overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--space-md); } - .section { - margin-bottom: var(--space-lg); - } - - .section:last-child { - margin-bottom: 0; + .setting-item { + display: flex; + flex-direction: column; + gap: var(--space-xs); } - .section-header { + .setting-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; - color: var(--text-disabled); - margin-bottom: var(--space-sm); + color: var(--text-muted); } - .button-group { + .pill-group { display: flex; gap: 4px; + flex-wrap: wrap; } - .style-btn { + .style-pill { display: flex; align-items: center; justify-content: center; - padding: 6px 10px; + padding: 4px 8px; + min-width: 40px; + height: 24px; + font-size: 10px; + font-weight: 500; background: var(--surface-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-muted); - font-size: 11px; cursor: pointer; transition: all var(--transition-fast); } - .style-btn:hover { + .style-pill:hover { background: var(--surface-hover); + border-color: var(--border-focus); color: var(--text); } - .style-btn.active { - background: var(--accent-bg); + .style-pill.active { + background: color-mix(in srgb, var(--accent) 15%, var(--surface-raised)); border-color: var(--accent); - color: var(--text); + color: var(--accent); } - .style-btn .preview { + .style-pill .preview { font-family: var(--font-mono); font-size: 10px; letter-spacing: -0.5px; } - .marker-btn { - padding: 6px 8px; - } - - .marker-group { - margin-top: var(--space-sm); - } - - .scale-btn { - flex: 1; - } - - .toggle-row { - display: flex; - align-items: center; - gap: var(--space-sm); - font-size: 11px; - color: var(--text-muted); - cursor: pointer; - } - - .toggle-row input[type="checkbox"] { - width: 14px; - height: 14px; - margin: 0; - accent-color: var(--accent); - cursor: pointer; - } - - .toggle-row:hover span { - color: var(--text); + .marker-pill { + padding: 4px 6px; + min-width: 28px; } diff --git a/src/lib/components/panels/SignalPlot.svelte b/src/lib/components/panels/SignalPlot.svelte index dfea4583..b1801e02 100644 --- a/src/lib/components/panels/SignalPlot.svelte +++ b/src/lib/components/panels/SignalPlot.svelte @@ -70,6 +70,7 @@ lineStyle: 'solid', showMarkers: false, markerStyle: 'circle', + xAxisScale: 'linear', yAxisScale: 'linear', showLegend: false }); @@ -165,6 +166,7 @@ markerStyle: plotSettings.markerStyle }; const layoutStyle: LayoutStyleOptions = { + xAxisScale: plotSettings.xAxisScale, yAxisScale: plotSettings.yAxisScale }; @@ -221,6 +223,7 @@ markerStyle: plotSettings.markerStyle }; const layoutStyle: LayoutStyleOptions = { + xAxisScale: plotSettings.xAxisScale, yAxisScale: plotSettings.yAxisScale }; diff --git a/src/lib/plotting/plotUtils.ts b/src/lib/plotting/plotUtils.ts index dc5635f8..d4b60941 100644 --- a/src/lib/plotting/plotUtils.ts +++ b/src/lib/plotting/plotUtils.ts @@ -14,6 +14,7 @@ export interface TraceStyleOptions { /** Style options for layout */ export interface LayoutStyleOptions { + xAxisScale?: AxisScale; yAxisScale?: AxisScale; } @@ -118,7 +119,8 @@ export function getScopeLayout( ...baseLayout, xaxis: { ...baseLayout.xaxis, - title: { text: 'Time (s)', font: { size: 11 }, standoff: 10 } + title: { text: 'Time (s)', font: { size: 11 }, standoff: 10 }, + type: styleOptions?.xAxisScale ?? 'linear' }, yaxis: { ...baseLayout.yaxis, @@ -172,7 +174,8 @@ export function getSpectrumLayout( // Format y-axis label as "Spectrum: Name" or just "Spectrum" if no custom name const yAxisLabel = title.toLowerCase().startsWith('spectrum') ? title : `Spectrum: ${title}`; - // Spectrum defaults to log scale if not specified + // Spectrum defaults to log scale for y-axis if not specified + const xAxisScale = styleOptions?.xAxisScale ?? 'linear'; const yAxisScale = styleOptions?.yAxisScale ?? 'log'; return { @@ -182,7 +185,8 @@ export function getSpectrumLayout( title: { text: 'Frequency (Hz)', font: { size: 11 }, standoff: 10 }, tickvals, ticktext, - tickangle: 0 + tickangle: 0, + type: xAxisScale }, yaxis: { ...baseLayout.yaxis, @@ -282,14 +286,7 @@ export function createScopeTrace( // Line dash style const dash = LINE_DASH_MAP[styleOptions?.lineStyle ?? 'solid']; - // Marker configuration - const marker = showMarkers ? { - symbol: MARKER_SYMBOL_MAP[styleOptions?.markerStyle ?? 'circle'], - size: 6, - color - } : undefined; - - return { + const trace: Partial = { x: time, y: signal, type: TRACE_TYPE, @@ -301,9 +298,19 @@ export function createScopeTrace( width: 1.5, dash }, - marker, hovertemplate: `${traceName}
        t = %{x:.4g} s
        y = %{y:.4g}` }; + + // Only add marker config when markers are shown + if (showMarkers) { + trace.marker = { + symbol: MARKER_SYMBOL_MAP[styleOptions?.markerStyle ?? 'circle'], + size: 6, + color + }; + } + + return trace; } // Create ghost scope trace (previous run) with reduced opacity @@ -355,14 +362,7 @@ export function createSpectrumTrace( // Line dash style const dash = LINE_DASH_MAP[styleOptions?.lineStyle ?? 'solid']; - // Marker configuration - const marker = showMarkers ? { - symbol: MARKER_SYMBOL_MAP[styleOptions?.markerStyle ?? 'circle'], - size: 6, - color - } : undefined; - - return { + const trace: Partial = { x: indices, y: magnitude, type: TRACE_TYPE, @@ -374,11 +374,21 @@ export function createSpectrumTrace( width: 1.5, dash }, - marker, // Store frequency in customdata for hover customdata: frequency, hovertemplate: `${traceName}
        f = %{customdata:.2f} Hz
        mag = %{y:.4g}` }; + + // Only add marker config when markers are shown + if (showMarkers) { + trace.marker = { + symbol: MARKER_SYMBOL_MAP[styleOptions?.markerStyle ?? 'circle'], + size: 6, + color + }; + } + + return trace; } // Create ghost spectrum trace (previous run) with reduced opacity diff --git a/src/lib/stores/plotSettings.ts b/src/lib/stores/plotSettings.ts index 311da7aa..99683a7d 100644 --- a/src/lib/stores/plotSettings.ts +++ b/src/lib/stores/plotSettings.ts @@ -13,6 +13,7 @@ export interface PlotSettings { lineStyle: LineStyle; showMarkers: boolean; markerStyle: MarkerStyle; + xAxisScale: AxisScale; yAxisScale: AxisScale; showLegend: boolean; } @@ -21,6 +22,7 @@ const DEFAULT_PLOT_SETTINGS: PlotSettings = { lineStyle: 'solid', showMarkers: false, markerStyle: 'circle', + xAxisScale: 'linear', yAxisScale: 'linear', showLegend: false }; @@ -46,6 +48,10 @@ export const plotSettingsStore = { settings.update((s) => ({ ...s, markerStyle })); }, + setXAxisScale(xAxisScale: AxisScale): void { + settings.update((s) => ({ ...s, xAxisScale })); + }, + setYAxisScale(yAxisScale: AxisScale): void { settings.update((s) => ({ ...s, yAxisScale })); }, diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index c74ef144..b8a5b1e0 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1183,22 +1183,6 @@ {/if} {/snippet} {#snippet actions()} - - {#if resultPlots.length > 1} {/snippet} From f16bd6c2aa1903a50ab50ff6e1f96e93e15f507d Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 17 Jan 2026 20:44:22 +0100 Subject: [PATCH 059/656] Add 'none' line style option for markers-only display --- .../dialogs/PlotOptionsDialog.svelte | 1 + src/lib/plotting/plotUtils.ts | 66 ++++++++++++------- src/lib/stores/plotSettings.ts | 2 +- 3 files changed, 46 insertions(+), 23 deletions(-) diff --git a/src/lib/components/dialogs/PlotOptionsDialog.svelte b/src/lib/components/dialogs/PlotOptionsDialog.svelte index 321fa05f..54bb3ee0 100644 --- a/src/lib/components/dialogs/PlotOptionsDialog.svelte +++ b/src/lib/components/dialogs/PlotOptionsDialog.svelte @@ -66,6 +66,7 @@ } const lineStyles: { value: LineStyle; label: string; preview: string }[] = [ + { value: 'none', label: 'None', preview: '—' }, { value: 'solid', label: 'Solid', preview: '━━━' }, { value: 'dash', label: 'Dashed', preview: '─ ─' }, { value: 'dot', label: 'Dotted', preview: '···' }, diff --git a/src/lib/plotting/plotUtils.ts b/src/lib/plotting/plotUtils.ts index d4b60941..f6c713e1 100644 --- a/src/lib/plotting/plotUtils.ts +++ b/src/lib/plotting/plotUtils.ts @@ -18,8 +18,8 @@ export interface LayoutStyleOptions { yAxisScale?: AxisScale; } -/** Map our line style names to Plotly dash values */ -const LINE_DASH_MAP: Record = { +/** Map our line style names to Plotly dash values (none excluded - handled separately) */ +const LINE_DASH_MAP: Record, Plotly.Dash> = { solid: 'solid', dash: 'dash', dot: 'dot', @@ -279,12 +279,19 @@ export function createScopeTrace( const traceName = name || `port ${index}`; const color = getSignalColor(index); - // Determine mode based on marker settings + const lineStyle = styleOptions?.lineStyle ?? 'solid'; const showMarkers = styleOptions?.showMarkers ?? false; - const mode = showMarkers ? 'lines+markers' : 'lines'; - - // Line dash style - const dash = LINE_DASH_MAP[styleOptions?.lineStyle ?? 'solid']; + const showLines = lineStyle !== 'none'; + + // Determine mode based on line and marker settings + let mode: 'lines' | 'markers' | 'lines+markers'; + if (showLines && showMarkers) { + mode = 'lines+markers'; + } else if (showMarkers) { + mode = 'markers'; + } else { + mode = 'lines'; + } const trace: Partial = { x: time, @@ -293,14 +300,18 @@ export function createScopeTrace( mode, name: traceName, legendgroup: `signal-${index}`, - line: { - color, - width: 1.5, - dash - }, hovertemplate: `${traceName}
        t = %{x:.4g} s
        y = %{y:.4g}` }; + // Only add line config when lines are shown + if (showLines) { + trace.line = { + color, + width: 1.5, + dash: LINE_DASH_MAP[lineStyle] + }; + } + // Only add marker config when markers are shown if (showMarkers) { trace.marker = { @@ -355,12 +366,19 @@ export function createSpectrumTrace( // Use indices for x-axis (equal spacing) const indices = Array.from({ length: magnitude.length }, (_, i) => i); - // Determine mode based on marker settings + const lineStyle = styleOptions?.lineStyle ?? 'solid'; const showMarkers = styleOptions?.showMarkers ?? false; - const mode = showMarkers ? 'lines+markers' : 'lines'; - - // Line dash style - const dash = LINE_DASH_MAP[styleOptions?.lineStyle ?? 'solid']; + const showLines = lineStyle !== 'none'; + + // Determine mode based on line and marker settings + let mode: 'lines' | 'markers' | 'lines+markers'; + if (showLines && showMarkers) { + mode = 'lines+markers'; + } else if (showMarkers) { + mode = 'markers'; + } else { + mode = 'lines'; + } const trace: Partial = { x: indices, @@ -369,16 +387,20 @@ export function createSpectrumTrace( mode, name: traceName, legendgroup: `signal-${index}`, - line: { - color, - width: 1.5, - dash - }, // Store frequency in customdata for hover customdata: frequency, hovertemplate: `${traceName}
        f = %{customdata:.2f} Hz
        mag = %{y:.4g}` }; + // Only add line config when lines are shown + if (showLines) { + trace.line = { + color, + width: 1.5, + dash: LINE_DASH_MAP[lineStyle] + }; + } + // Only add marker config when markers are shown if (showMarkers) { trace.marker = { diff --git a/src/lib/stores/plotSettings.ts b/src/lib/stores/plotSettings.ts index 99683a7d..e15f8169 100644 --- a/src/lib/stores/plotSettings.ts +++ b/src/lib/stores/plotSettings.ts @@ -5,7 +5,7 @@ import { writable, get } from 'svelte/store'; -export type LineStyle = 'solid' | 'dash' | 'dot' | 'dashdot'; +export type LineStyle = 'none' | 'solid' | 'dash' | 'dot' | 'dashdot'; export type MarkerStyle = 'circle' | 'square' | 'diamond' | 'cross' | 'x' | 'triangle-up'; export type AxisScale = 'linear' | 'log'; From fae1fabd8d446cb3d4ab3147996a5d0270285a01 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 17 Jan 2026 22:35:34 +0100 Subject: [PATCH 060/656] Refactor plot settings and extend customization to hover previews - Move legend toggle from global to per-block setting - Extract shared trace building helpers (resolveStyleOptions, applyLineConfig, applyMarkerConfig) - Export LINE_DASH_SVG for consistent dash patterns across components - Export DEFAULT_TRACE_SETTINGS and DEFAULT_BLOCK_SETTINGS from store - Remove unused global showLegend from PlotSettings - Add plot customization to hover previews (line styles, trace visibility) - Default spectrum blocks to log Y-axis scale - Simplify axis range handling (use Plotly autorange) - Consistent "No data" empty state message --- .../dialogs/PlotOptionsDialog.svelte | 522 ++++++++++++------ src/lib/components/nodes/BaseNode.svelte | 2 +- src/lib/components/nodes/PlotPreview.svelte | 56 +- src/lib/components/panels/PlotPanel.svelte | 7 +- src/lib/components/panels/SignalPlot.svelte | 104 ++-- src/lib/plotting/plotUtils.ts | 208 +++---- src/lib/stores/plotSettings.ts | 109 +++- src/routes/+page.svelte | 40 +- 8 files changed, 686 insertions(+), 362 deletions(-) diff --git a/src/lib/components/dialogs/PlotOptionsDialog.svelte b/src/lib/components/dialogs/PlotOptionsDialog.svelte index 54bb3ee0..e45590d8 100644 --- a/src/lib/components/dialogs/PlotOptionsDialog.svelte +++ b/src/lib/components/dialogs/PlotOptionsDialog.svelte @@ -4,83 +4,122 @@ import Icon from '$lib/components/icons/Icon.svelte'; import { plotSettingsStore, + createTraceId, + DEFAULT_TRACE_SETTINGS, + DEFAULT_BLOCK_SETTINGS, type LineStyle, type MarkerStyle, - type AxisScale + type PlotSettings } from '$lib/stores/plotSettings'; + import { getSignalColor, LINE_DASH_SVG } from '$lib/plotting/plotUtils'; + + interface TraceInfo { + nodeId: string; + nodeType: 'scope' | 'spectrum'; + nodeName: string; + signalIndex: number; + signalLabel: string; + } interface Props { open: boolean; onClose: () => void; + traces?: TraceInfo[]; } - let { open, onClose }: Props = $props(); + let { open, onClose, traces = [] }: Props = $props(); - // Local state bound to store - let lineStyle = $state('solid'); - let showMarkers = $state(false); - let markerStyle = $state('circle'); - let xAxisScale = $state('linear'); - let yAxisScale = $state('linear'); - let showLegend = $state(false); + // Store the entire settings for reactivity + let settings = $state({ traces: {}, blocks: {} }); - // Sync from store + // Sync from store - update entire settings object for reactivity const unsubscribe = plotSettingsStore.subscribe((s) => { - lineStyle = s.lineStyle; - showMarkers = s.showMarkers; - markerStyle = s.markerStyle; - xAxisScale = s.xAxisScale; - yAxisScale = s.yAxisScale; - showLegend = s.showLegend; + settings = s; }); - // Update store when values change - function updateLineStyle(value: LineStyle) { - plotSettingsStore.setLineStyle(value); + function handleKeydown(event: KeyboardEvent) { + if (event.key === 'Escape') { + onClose(); + } + } + + // Get trace settings reactively from local state + function getTraceSettings(traceId: string) { + return settings.traces[traceId] ?? { ...DEFAULT_TRACE_SETTINGS }; } - function updateShowMarkers(value: boolean) { - plotSettingsStore.setShowMarkers(value); + // Get block settings reactively from local state + function getBlockSettings(nodeId: string) { + return settings.blocks[nodeId] ?? { ...DEFAULT_BLOCK_SETTINGS }; } - function updateMarkerStyle(value: MarkerStyle) { - plotSettingsStore.setMarkerStyle(value); + // Line style options + const lineStyles: { value: LineStyle; label: string }[] = [ + { value: 'solid', label: 'Solid' }, + { value: 'dash', label: 'Dash' }, + { value: 'dot', label: 'Dot' } + ]; + + // Marker style options + const markerStyles: { value: MarkerStyle; label: string }[] = [ + { value: 'circle', label: 'Circle' }, + { value: 'square', label: 'Square' }, + { value: 'triangle-up', label: 'Triangle' } + ]; + + // Toggle line style for a trace (clicking same = deselect) + function toggleLineStyle(traceId: string, style: LineStyle) { + const current = getTraceSettings(traceId); + if (current.lineStyle === style) { + plotSettingsStore.setTraceLineStyle(traceId, null); + } else { + plotSettingsStore.setTraceLineStyle(traceId, style); + } } - function updateXAxisScale(value: AxisScale) { - plotSettingsStore.setXAxisScale(value); + // Toggle marker style for a trace (clicking same = deselect) + function toggleMarkerStyle(traceId: string, style: MarkerStyle) { + const current = getTraceSettings(traceId); + if (current.markerStyle === style) { + plotSettingsStore.setTraceMarkerStyle(traceId, null); + } else { + plotSettingsStore.setTraceMarkerStyle(traceId, style); + } } - function updateYAxisScale(value: AxisScale) { - plotSettingsStore.setYAxisScale(value); + // Block axis scale handlers + function toggleBlockXAxisScale(nodeId: string) { + const current = getBlockSettings(nodeId); + plotSettingsStore.setBlockXAxisScale(nodeId, current.xAxisScale === 'linear' ? 'log' : 'linear'); } - function updateShowLegend(value: boolean) { - plotSettingsStore.setShowLegend(value); + function toggleBlockYAxisScale(nodeId: string) { + const current = getBlockSettings(nodeId); + plotSettingsStore.setBlockYAxisScale(nodeId, current.yAxisScale === 'linear' ? 'log' : 'linear'); } - function handleKeydown(event: KeyboardEvent) { - if (event.key === 'Escape') { - onClose(); - } + function toggleBlockShowLegend(nodeId: string) { + const current = getBlockSettings(nodeId); + plotSettingsStore.setBlockShowLegend(nodeId, !current.showLegend); } - const lineStyles: { value: LineStyle; label: string; preview: string }[] = [ - { value: 'none', label: 'None', preview: '—' }, - { value: 'solid', label: 'Solid', preview: '━━━' }, - { value: 'dash', label: 'Dashed', preview: '─ ─' }, - { value: 'dot', label: 'Dotted', preview: '···' }, - { value: 'dashdot', label: 'Dash-dot', preview: '─·─' } - ]; + // Group traces by node for display + const tracesByNode = $derived(() => { + const grouped = new Map(); + for (const trace of traces) { + const key = trace.nodeId; + if (!grouped.has(key)) { + grouped.set(key, []); + } + grouped.get(key)!.push(trace); + } + return grouped; + }); - const markerStyles: { value: MarkerStyle; label: string }[] = [ - { value: 'circle', label: 'Circle' }, - { value: 'square', label: 'Square' }, - { value: 'diamond', label: 'Diamond' }, - { value: 'triangle-up', label: 'Triangle' }, - { value: 'cross', label: 'Cross' }, - { value: 'x', label: 'X' } - ]; + // Get dash pattern for SVG line + function getLineDash(style: LineStyle | null): string { + return style ? LINE_DASH_SVG[style] : ''; + } @@ -106,123 +145,139 @@
        - -
        - Line Style -
        - {#each lineStyles as style} - - {/each} -
        -
        - - -
        - Markers -
        - - {#each markerStyles as style} - - {/each} -
        -
        - - -
        - X-Axis Scale -
        - - -
        -
        - - -
        - Y-Axis Scale -
        - - + {#if traces.length === 0} +
        +

        Run simulation to see trace options

        -
        - - -
        - Legend -
        - - -
        -
        + {:else} + + {#each [...tracesByNode()] as [nodeId, nodeTraces]} + {@const firstTrace = nodeTraces[0]} + {@const blockSettings = getBlockSettings(nodeId)} +
        + +
        +
        + + {firstTrace.nodeName} +
        +
        + + + +
        +
        + + +
        +
        +
        Preview
        +
        +
        Line
        +
        +
        Marker
        +
        + + {#each nodeTraces as trace} + {@const traceId = createTraceId(trace.nodeId, trace.signalIndex)} + {@const settings = getTraceSettings(traceId)} + {@const color = getSignalColor(trace.signalIndex)} +
        +
        + + + {#if settings.lineStyle} + + {/if} + {#if settings.markerStyle} + {#if settings.markerStyle === 'circle'} + + {:else if settings.markerStyle === 'square'} + + {:else if settings.markerStyle === 'triangle-up'} + + {/if} + {/if} + {#if !settings.lineStyle && !settings.markerStyle} + + {/if} + +
        +
        + {trace.signalLabel} +
        +
        + {#each lineStyles as style} + + {/each} +
        +
        +
        + {#each markerStyles as style} + + {/each} +
        +
        + {/each} +
        +
        + {/each} + {/if}
        @@ -230,7 +285,7 @@ diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index 0a38e716..76942f4e 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -310,7 +310,7 @@ class="plot-preview-popup preview-{previewPosition()}" class:visible={showPreview || previewsPinned} > - + {/if} diff --git a/src/lib/components/nodes/PlotPreview.svelte b/src/lib/components/nodes/PlotPreview.svelte index 256dc441..83cf6fe6 100644 --- a/src/lib/components/nodes/PlotPreview.svelte +++ b/src/lib/components/nodes/PlotPreview.svelte @@ -1,22 +1,47 @@ @@ -279,6 +320,7 @@ fill="none" stroke={path.color} stroke-width={path.strokeWidth} + stroke-dasharray={path.dasharray} stroke-linecap="round" stroke-linejoin="round" opacity={path.opacity} diff --git a/src/lib/components/panels/PlotPanel.svelte b/src/lib/components/panels/PlotPanel.svelte index 71ddb9ac..63f8dea6 100644 --- a/src/lib/components/panels/PlotPanel.svelte +++ b/src/lib/components/panels/PlotPanel.svelte @@ -10,10 +10,9 @@ onToggle?: () => void; activeTab?: number; viewMode?: 'tabs' | 'tiles'; - showLegend?: boolean; } - let { collapsed = false, onToggle, activeTab = $bindable(0), viewMode = 'tabs', showLegend = false }: Props = $props(); + let { collapsed = false, onToggle, activeTab = $bindable(0), viewMode = 'tabs' }: Props = $props(); // Smart grid layout calculation let plotContent: HTMLDivElement | undefined = $state(); @@ -195,10 +194,10 @@
        @@ -211,10 +210,10 @@ {#if activeTab === i} {/if} diff --git a/src/lib/components/panels/SignalPlot.svelte b/src/lib/components/panels/SignalPlot.svelte index b1801e02..dcf34bce 100644 --- a/src/lib/components/panels/SignalPlot.svelte +++ b/src/lib/components/panels/SignalPlot.svelte @@ -12,7 +12,7 @@ type LayoutStyleOptions } from '$lib/plotting/plotUtils'; import { themeStore, type Theme } from '$lib/stores/theme'; - import { plotSettingsStore, type PlotSettings } from '$lib/stores/plotSettings'; + import { plotSettingsStore, createTraceId, type PlotSettings } from '$lib/stores/plotSettings'; import { enqueuePlotUpdate, cancelPlotUpdate, isVisible } from './plotQueue'; interface ScopeData { @@ -29,14 +29,14 @@ interface Props { type: 'scope' | 'spectrum'; + nodeId: string; data: ScopeData | SpectrumData | null; ghostData?: (ScopeData | SpectrumData)[]; title?: string; - showLegend?: boolean; isStreaming?: boolean; } - let { type, data, ghostData = [], title, showLegend = false, isStreaming = false }: Props = $props(); + let { type, nodeId, data, ghostData = [], title, isStreaming = false }: Props = $props(); // Check if data has actual content (not just empty arrays) const hasData = $derived(() => { @@ -67,14 +67,34 @@ let resizeObserver: ResizeObserver | null = null; let currentTheme = $state('dark'); let plotSettings = $state({ - lineStyle: 'solid', - showMarkers: false, - markerStyle: 'circle', - xAxisScale: 'linear', - yAxisScale: 'linear', - showLegend: false + traces: {}, + blocks: {} }); + // Get per-trace style options + function getTraceStyle(signalIndex: number): TraceStyleOptions { + const traceId = createTraceId(nodeId, signalIndex); + const settings = plotSettingsStore.getTraceSettings(traceId); + return { + lineStyle: settings.lineStyle, + markerStyle: settings.markerStyle + }; + } + + // Get per-block layout options + function getBlockLayoutStyle(): LayoutStyleOptions { + const blockSettings = plotSettingsStore.getBlockSettings(nodeId); + return { + xAxisScale: blockSettings.xAxisScale, + yAxisScale: blockSettings.yAxisScale + }; + } + + // Get per-block show legend setting + function getBlockShowLegend(): boolean { + return plotSettingsStore.getBlockSettings(nodeId).showLegend; + } + // Unique ID for this component in the shared render queue const componentId = Symbol(); @@ -94,6 +114,15 @@ onMount(async () => { Plotly = await import('plotly.js-dist-min'); + + // Initialize spectrum blocks with log Y-axis by default + if (type === 'spectrum') { + const currentSettings = plotSettingsStore.get().blocks[nodeId]; + if (!currentSettings) { + plotSettingsStore.setBlockYAxisScale(nodeId, 'log'); + } + } + scheduleUpdate(); resizeObserver = new ResizeObserver(() => { @@ -130,7 +159,6 @@ const _data = data; const _ghostData = ghostData; const _type = type; - const _showLegend = showLegend; const _isStreaming = isStreaming; const _plotSettings = plotSettings; if (Plotly && plotDiv) { @@ -159,34 +187,32 @@ const spectrumGhosts = ghostData as SpectrumData[]; const totalGhosts = spectrumGhosts.length; - // Style options from store - const traceStyle: TraceStyleOptions = { - lineStyle: plotSettings.lineStyle, - showMarkers: plotSettings.showMarkers, - markerStyle: plotSettings.markerStyle - }; - const layoutStyle: LayoutStyleOptions = { - xAxisScale: plotSettings.xAxisScale, - yAxisScale: plotSettings.yAxisScale - }; + const layoutStyle = getBlockLayoutStyle(); + const blockShowLegend = getBlockShowLegend(); - // Add ghost traces + // Add ghost traces with matching style for (let ghostIdx = totalGhosts - 1; ghostIdx >= 0; ghostIdx--) { const ghost = spectrumGhosts[ghostIdx]; if (!ghost?.magnitude || !ghost.frequency?.length) continue; for (let sigIdx = 0; sigIdx < ghost.magnitude.length; sigIdx++) { - traces.push(createGhostSpectrumTrace(ghost.frequency, ghost.magnitude[sigIdx], sigIdx, ghostIdx, totalGhosts)); + const traceStyle = getTraceStyle(sigIdx); + // Skip ghost traces if current trace is hidden + if (traceStyle.lineStyle === null && traceStyle.markerStyle === null) continue; + traces.push(createGhostSpectrumTrace(ghost.frequency, ghost.magnitude[sigIdx], sigIdx, ghostIdx, totalGhosts, traceStyle)); } } - // Add current data traces - let layout = getSpectrumLayout(plotTitle, undefined, showLegend, layoutStyle); + // Add current data traces with per-trace settings + let layout = getSpectrumLayout(plotTitle, undefined, blockShowLegend, layoutStyle); if (hasData()) { const spectrumData = data as SpectrumData; for (let i = 0; i < spectrumData.magnitude.length; i++) { + const traceStyle = getTraceStyle(i); + // Skip traces with no line and no marker + if (traceStyle.lineStyle === null && traceStyle.markerStyle === null) continue; traces.push(createSpectrumTrace(spectrumData.frequency, spectrumData.magnitude[i], i, getSignalLabel(i), traceStyle)); } - layout = getSpectrumLayout(plotTitle, spectrumData.frequency, showLegend, layoutStyle); + layout = getSpectrumLayout(plotTitle, spectrumData.frequency, blockShowLegend, layoutStyle); } if (traces.length === 0) { @@ -216,36 +242,34 @@ function fullScopeRender(scopeData: ScopeData | null, scopeGhosts: ScopeData[]) { if (!Plotly || !plotDiv) return; - // Style options from store - const traceStyle: TraceStyleOptions = { - lineStyle: plotSettings.lineStyle, - showMarkers: plotSettings.showMarkers, - markerStyle: plotSettings.markerStyle - }; - const layoutStyle: LayoutStyleOptions = { - xAxisScale: plotSettings.xAxisScale, - yAxisScale: plotSettings.yAxisScale - }; + const layoutStyle = getBlockLayoutStyle(); + const blockShowLegend = getBlockShowLegend(); const traces: Partial[] = []; - const layout = getScopeLayout(plotTitle, showLegend, layoutStyle); + const layout = getScopeLayout(plotTitle, blockShowLegend, layoutStyle); const totalGhosts = scopeGhosts.length; - // Add ghost traces (rendered once, won't change during streaming) + // Add ghost traces with matching style (rendered once, won't change during streaming) for (let ghostIdx = totalGhosts - 1; ghostIdx >= 0; ghostIdx--) { const ghost = scopeGhosts[ghostIdx]; if (!ghost?.signals || !ghost.time?.length) continue; for (let sigIdx = 0; sigIdx < ghost.signals.length; sigIdx++) { - traces.push(createGhostScopeTrace(ghost.time, ghost.signals[sigIdx], sigIdx, ghostIdx, totalGhosts)); + const traceStyle = getTraceStyle(sigIdx); + // Skip ghost traces if current trace is hidden + if (traceStyle.lineStyle === null && traceStyle.markerStyle === null) continue; + traces.push(createGhostScopeTrace(ghost.time, ghost.signals[sigIdx], sigIdx, ghostIdx, totalGhosts, traceStyle)); } } // Track ghost trace count for extendTraces indexing ghostTraceCount = traces.length; - // Add current data traces + // Add current data traces with per-trace settings if (scopeData && scopeData.time?.length > 0) { for (let i = 0; i < scopeData.signals.length; i++) { + const traceStyle = getTraceStyle(i); + // Skip traces with no line and no marker + if (traceStyle.lineStyle === null && traceStyle.markerStyle === null) continue; traces.push(createScopeTrace(scopeData.time, scopeData.signals[i], i, getSignalLabel(i), traceStyle)); } } @@ -293,7 +317,7 @@ const annotationColor = getComputedStyle(document.documentElement).getPropertyValue('--text-disabled').trim(); layout.annotations = [ { - text: 'Run simulation to see data', + text: 'No data', xref: 'paper', yref: 'paper', x: 0.5, diff --git a/src/lib/plotting/plotUtils.ts b/src/lib/plotting/plotUtils.ts index f6c713e1..38adff41 100644 --- a/src/lib/plotting/plotUtils.ts +++ b/src/lib/plotting/plotUtils.ts @@ -7,9 +7,8 @@ import type { LineStyle, MarkerStyle, AxisScale } from '$lib/stores/plotSettings /** Style options for trace rendering */ export interface TraceStyleOptions { - lineStyle?: LineStyle; - showMarkers?: boolean; - markerStyle?: MarkerStyle; + lineStyle?: LineStyle | null; // null = no line + markerStyle?: MarkerStyle | null; // null = no markers } /** Style options for layout */ @@ -18,24 +17,91 @@ export interface LayoutStyleOptions { yAxisScale?: AxisScale; } -/** Map our line style names to Plotly dash values (none excluded - handled separately) */ -const LINE_DASH_MAP: Record, Plotly.Dash> = { +/** Map our line style names to Plotly dash values */ +const LINE_DASH_MAP: Record = { solid: 'solid', dash: 'dash', - dot: 'dot', - dashdot: 'dashdot' + dot: 'dot' +}; + +/** Map our line style names to SVG stroke-dasharray values (for preview rendering) */ +export const LINE_DASH_SVG: Record = { + solid: '', + dash: '6,3', + dot: '2,2' }; /** Map our marker style names to Plotly symbol values */ const MARKER_SYMBOL_MAP: Record = { circle: 'circle', square: 'square', - diamond: 'diamond', - 'triangle-up': 'triangle-up', - cross: 'cross', - x: 'x' + 'triangle-up': 'triangle-up' }; +/** Resolved style options with non-null defaults applied */ +interface ResolvedStyle { + lineStyle: LineStyle | null; + markerStyle: MarkerStyle | null; + showLines: boolean; + showMarkers: boolean; + mode: 'lines' | 'markers' | 'lines+markers'; +} + +/** Resolve style options to concrete values with defaults */ +function resolveStyleOptions(styleOptions?: TraceStyleOptions): ResolvedStyle { + // null means no line/marker, undefined means use default + const lineStyle = styleOptions?.lineStyle === undefined ? 'solid' : styleOptions.lineStyle; + const markerStyle = styleOptions?.markerStyle === undefined ? null : styleOptions.markerStyle; + const showLines = lineStyle !== null; + const showMarkers = markerStyle !== null; + + // Determine mode based on line and marker settings + let mode: 'lines' | 'markers' | 'lines+markers'; + if (showLines && showMarkers) { + mode = 'lines+markers'; + } else if (showMarkers) { + mode = 'markers'; + } else { + mode = 'lines'; + } + + return { lineStyle, markerStyle, showLines, showMarkers, mode }; +} + +/** Apply line config to trace if lines are shown */ +function applyLineConfig( + trace: Partial, + style: ResolvedStyle, + color: string, + width: number = 1.5 +): void { + if (style.showLines && style.lineStyle) { + trace.line = { + color, + width, + dash: LINE_DASH_MAP[style.lineStyle] + }; + } +} + +/** Apply marker config to trace if markers are shown */ +function applyMarkerConfig( + trace: Partial, + style: ResolvedStyle, + color: string, + size: number = 6 +): void { + if (style.showMarkers && style.markerStyle) { + trace.marker = { + symbol: MARKER_SYMBOL_MAP[style.markerStyle], + size, + color + }; + // Allow markers to extend beyond axis without expanding range + trace.cliponaxis = false; + } +} + /** * Read a CSS variable value from the document root * Automatically reflects current theme (light/dark) @@ -115,17 +181,21 @@ export function getScopeLayout( const baseLayout = getBaseLayout(); // Format y-axis label as "Scope: Name" or just "Scope" if no custom name const yAxisLabel = title.toLowerCase().startsWith('scope') ? title : `Scope: ${title}`; + + const xAxisScale = styleOptions?.xAxisScale ?? 'linear'; + const yAxisScale = styleOptions?.yAxisScale ?? 'linear'; + return { ...baseLayout, xaxis: { ...baseLayout.xaxis, title: { text: 'Time (s)', font: { size: 11 }, standoff: 10 }, - type: styleOptions?.xAxisScale ?? 'linear' + type: xAxisScale }, yaxis: { ...baseLayout.yaxis, title: { text: yAxisLabel, font: { size: 11 }, standoff: 5 }, - type: styleOptions?.yAxisScale ?? 'linear' + type: yAxisScale }, showlegend: showLegend, legend: { @@ -278,48 +348,20 @@ export function createScopeTrace( ): Partial { const traceName = name || `port ${index}`; const color = getSignalColor(index); - - const lineStyle = styleOptions?.lineStyle ?? 'solid'; - const showMarkers = styleOptions?.showMarkers ?? false; - const showLines = lineStyle !== 'none'; - - // Determine mode based on line and marker settings - let mode: 'lines' | 'markers' | 'lines+markers'; - if (showLines && showMarkers) { - mode = 'lines+markers'; - } else if (showMarkers) { - mode = 'markers'; - } else { - mode = 'lines'; - } + const style = resolveStyleOptions(styleOptions); const trace: Partial = { x: time, y: signal, type: TRACE_TYPE, - mode, + mode: style.mode, name: traceName, legendgroup: `signal-${index}`, hovertemplate: `${traceName}
        t = %{x:.4g} s
        y = %{y:.4g}` }; - // Only add line config when lines are shown - if (showLines) { - trace.line = { - color, - width: 1.5, - dash: LINE_DASH_MAP[lineStyle] - }; - } - - // Only add marker config when markers are shown - if (showMarkers) { - trace.marker = { - symbol: MARKER_SYMBOL_MAP[styleOptions?.markerStyle ?? 'circle'], - size: 6, - color - }; - } + applyLineConfig(trace, style, color); + applyMarkerConfig(trace, style, color); return trace; } @@ -330,27 +372,31 @@ export function createGhostScopeTrace( signal: number[], signalIndex: number, ghostIndex: number, - totalGhosts: number + totalGhosts: number, + styleOptions?: TraceStyleOptions ): Partial { // Linear opacity: 50% for most recent ghost, 20% for oldest const opacity = totalGhosts === 1 ? 0.5 : 0.5 - (ghostIndex / (totalGhosts - 1)) * 0.3; - const baseColor = getSignalColor(signalIndex); - return { + const color = getSignalColor(signalIndex); + const style = resolveStyleOptions(styleOptions); + + const trace: Partial = { x: time, y: signal, type: TRACE_TYPE, - mode: 'lines', + mode: style.mode, showlegend: false, hoverinfo: 'skip', legendgroup: `signal-${signalIndex}`, - line: { - color: baseColor, - width: 1, - }, opacity }; + + applyLineConfig(trace, style, color, 1); // Thinner line for ghosts + applyMarkerConfig(trace, style, color, 5); // Smaller markers for ghosts + + return trace; } // Create spectrum trace - uses indices for equal spacing @@ -365,26 +411,13 @@ export function createSpectrumTrace( const color = getSignalColor(index); // Use indices for x-axis (equal spacing) const indices = Array.from({ length: magnitude.length }, (_, i) => i); - - const lineStyle = styleOptions?.lineStyle ?? 'solid'; - const showMarkers = styleOptions?.showMarkers ?? false; - const showLines = lineStyle !== 'none'; - - // Determine mode based on line and marker settings - let mode: 'lines' | 'markers' | 'lines+markers'; - if (showLines && showMarkers) { - mode = 'lines+markers'; - } else if (showMarkers) { - mode = 'markers'; - } else { - mode = 'lines'; - } + const style = resolveStyleOptions(styleOptions); const trace: Partial = { x: indices, y: magnitude, type: TRACE_TYPE, - mode, + mode: style.mode, name: traceName, legendgroup: `signal-${index}`, // Store frequency in customdata for hover @@ -392,23 +425,8 @@ export function createSpectrumTrace( hovertemplate: `${traceName}
        f = %{customdata:.2f} Hz
        mag = %{y:.4g}` }; - // Only add line config when lines are shown - if (showLines) { - trace.line = { - color, - width: 1.5, - dash: LINE_DASH_MAP[lineStyle] - }; - } - - // Only add marker config when markers are shown - if (showMarkers) { - trace.marker = { - symbol: MARKER_SYMBOL_MAP[styleOptions?.markerStyle ?? 'circle'], - size: 6, - color - }; - } + applyLineConfig(trace, style, color); + applyMarkerConfig(trace, style, color); return trace; } @@ -419,27 +437,31 @@ export function createGhostSpectrumTrace( magnitude: number[], signalIndex: number, ghostIndex: number, - totalGhosts: number + totalGhosts: number, + styleOptions?: TraceStyleOptions ): Partial { // Linear opacity: 50% for most recent ghost, 20% for oldest const opacity = totalGhosts === 1 ? 0.5 : 0.5 - (ghostIndex / (totalGhosts - 1)) * 0.3; - const baseColor = getSignalColor(signalIndex); + const color = getSignalColor(signalIndex); // Use indices for x-axis (equal spacing) const indices = Array.from({ length: magnitude.length }, (_, i) => i); - return { + const style = resolveStyleOptions(styleOptions); + + const trace: Partial = { x: indices, y: magnitude, type: TRACE_TYPE, - mode: 'lines', + mode: style.mode, showlegend: false, hoverinfo: 'skip', legendgroup: `signal-${signalIndex}`, - line: { - color: baseColor, - width: 1, - }, opacity }; + + applyLineConfig(trace, style, color, 1); // Thinner line for ghosts + applyMarkerConfig(trace, style, color, 5); // Smaller markers for ghosts + + return trace; } diff --git a/src/lib/stores/plotSettings.ts b/src/lib/stores/plotSettings.ts index e15f8169..1ce36e7f 100644 --- a/src/lib/stores/plotSettings.ts +++ b/src/lib/stores/plotSettings.ts @@ -1,63 +1,119 @@ /** * Plot settings store - * Controls visual appearance of plots (line styles, markers, axis scaling) + * Controls visual appearance of plots with per-trace and per-block customization */ import { writable, get } from 'svelte/store'; -export type LineStyle = 'none' | 'solid' | 'dash' | 'dot' | 'dashdot'; -export type MarkerStyle = 'circle' | 'square' | 'diamond' | 'cross' | 'x' | 'triangle-up'; +export type LineStyle = 'solid' | 'dash' | 'dot'; +export type MarkerStyle = 'circle' | 'square' | 'triangle-up'; export type AxisScale = 'linear' | 'log'; -export interface PlotSettings { - lineStyle: LineStyle; - showMarkers: boolean; - markerStyle: MarkerStyle; +/** Settings for an individual trace */ +export interface TraceSettings { + lineStyle: LineStyle | null; // null = no line + markerStyle: MarkerStyle | null; // null = no markers +} + +/** Settings for a recording block (node) */ +export interface BlockSettings { xAxisScale: AxisScale; yAxisScale: AxisScale; showLegend: boolean; } -const DEFAULT_PLOT_SETTINGS: PlotSettings = { +/** Global plot settings */ +export interface PlotSettings { + /** Per-trace settings keyed by traceId (nodeId-signalIndex) */ + traces: Record; + /** Per-block settings keyed by nodeId */ + blocks: Record; +} + +export const DEFAULT_TRACE_SETTINGS: TraceSettings = { lineStyle: 'solid', - showMarkers: false, - markerStyle: 'circle', + markerStyle: null +}; + +export const DEFAULT_BLOCK_SETTINGS: BlockSettings = { xAxisScale: 'linear', yAxisScale: 'linear', showLegend: false }; +const DEFAULT_PLOT_SETTINGS: PlotSettings = { + traces: {}, + blocks: {} +}; + const settings = writable({ ...DEFAULT_PLOT_SETTINGS }); export const plotSettingsStore = { subscribe: settings.subscribe, - update(newSettings: Partial): void { - settings.update((s) => ({ ...s, ...newSettings })); + /** Get settings for a trace, returning defaults if not set */ + getTraceSettings(traceId: string): TraceSettings { + const s = get(settings); + return s.traces[traceId] ?? { ...DEFAULT_TRACE_SETTINGS }; + }, + + /** Get settings for a block, returning defaults if not set */ + getBlockSettings(nodeId: string): BlockSettings { + const s = get(settings); + return s.blocks[nodeId] ?? { ...DEFAULT_BLOCK_SETTINGS }; }, - setLineStyle(lineStyle: LineStyle): void { - settings.update((s) => ({ ...s, lineStyle })); + /** Update settings for a specific trace */ + setTraceSettings(traceId: string, traceSettings: Partial): void { + settings.update((s) => { + const current = s.traces[traceId] ?? { ...DEFAULT_TRACE_SETTINGS }; + return { + ...s, + traces: { + ...s.traces, + [traceId]: { ...current, ...traceSettings } + } + }; + }); }, - setShowMarkers(showMarkers: boolean): void { - settings.update((s) => ({ ...s, showMarkers })); + /** Update settings for a specific block */ + setBlockSettings(nodeId: string, blockSettings: Partial): void { + settings.update((s) => { + const current = s.blocks[nodeId] ?? { ...DEFAULT_BLOCK_SETTINGS }; + return { + ...s, + blocks: { + ...s.blocks, + [nodeId]: { ...current, ...blockSettings } + } + }; + }); }, - setMarkerStyle(markerStyle: MarkerStyle): void { - settings.update((s) => ({ ...s, markerStyle })); + /** Set line style for a trace */ + setTraceLineStyle(traceId: string, lineStyle: LineStyle | null): void { + this.setTraceSettings(traceId, { lineStyle }); }, - setXAxisScale(xAxisScale: AxisScale): void { - settings.update((s) => ({ ...s, xAxisScale })); + /** Set marker style for a trace */ + setTraceMarkerStyle(traceId: string, markerStyle: MarkerStyle | null): void { + this.setTraceSettings(traceId, { markerStyle }); }, - setYAxisScale(yAxisScale: AxisScale): void { - settings.update((s) => ({ ...s, yAxisScale })); + /** Set X axis scale for a block */ + setBlockXAxisScale(nodeId: string, xAxisScale: AxisScale): void { + this.setBlockSettings(nodeId, { xAxisScale }); }, - setShowLegend(showLegend: boolean): void { - settings.update((s) => ({ ...s, showLegend })); + /** Set Y axis scale for a block */ + setBlockYAxisScale(nodeId: string, yAxisScale: AxisScale): void { + this.setBlockSettings(nodeId, { yAxisScale }); + }, + + /** Set show legend for a block */ + setBlockShowLegend(nodeId: string, showLegend: boolean): void { + this.setBlockSettings(nodeId, { showLegend }); }, reset(): void { @@ -68,3 +124,8 @@ export const plotSettingsStore = { return get(settings); } }; + +/** Helper to create a trace ID */ +export function createTraceId(nodeId: string, signalIndex: number): string { + return `${nodeId}-${signalIndex}`; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b8a5b1e0..956e20d5 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -19,7 +19,6 @@ import ExportDialog from '$lib/components/dialogs/ExportDialog.svelte'; import KeyboardShortcutsDialog from '$lib/components/dialogs/KeyboardShortcutsDialog.svelte'; import PlotOptionsDialog from '$lib/components/dialogs/PlotOptionsDialog.svelte'; - import { plotSettingsStore } from '$lib/stores/plotSettings'; import SearchDialog from '$lib/components/dialogs/SearchDialog.svelte'; import ResizablePanel from '$lib/components/ResizablePanel.svelte'; import WelcomeModal from '$lib/components/WelcomeModal.svelte'; @@ -351,8 +350,8 @@ let consoleLogCount = $state(0); let plotActiveTab = $state(0); let plotViewMode = $state<'tabs' | 'tiles'>('tabs'); - let showPlotLegend = $state(false); // Synced with plotSettingsStore let resultPlots = $state<{ id: string; type: 'scope' | 'spectrum'; title: string }[]>([]); + let resultTraces = $state<{ nodeId: string; nodeType: 'scope' | 'spectrum'; nodeName: string; signalIndex: number; signalLabel: string }[]>([]); // Tooltip for continue button - simple, disabled state shows availability const continueTooltip = { text: "Continue", shortcut: "Shift+Enter" }; @@ -401,21 +400,43 @@ statusText = 'Ready'; } - // Derive plots from result (use nodeNames from simulation result for subsystem support) + // Derive plots and traces from result (use nodeNames from simulation result for subsystem support) const plots: { id: string; type: 'scope' | 'spectrum'; title: string }[] = []; + const traces: typeof resultTraces = []; if (s.result?.scopeData) { - Object.entries(s.result.scopeData).forEach(([id], index) => { + Object.entries(s.result.scopeData).forEach(([id, data], index) => { const title = s.result?.nodeNames?.[id] || `Scope ${index + 1}`; plots.push({ id, type: 'scope', title }); + // Add traces for each signal in this scope + for (let i = 0; i < data.signals.length; i++) { + traces.push({ + nodeId: id, + nodeType: 'scope', + nodeName: title, + signalIndex: i, + signalLabel: data.labels?.[i] || `port ${i}` + }); + } }); } if (s.result?.spectrumData) { - Object.entries(s.result.spectrumData).forEach(([id], index) => { + Object.entries(s.result.spectrumData).forEach(([id, data], index) => { const title = s.result?.nodeNames?.[id] || `Spectrum ${index + 1}`; plots.push({ id, type: 'spectrum', title }); + // Add traces for each signal in this spectrum + for (let i = 0; i < data.magnitude.length; i++) { + traces.push({ + nodeId: id, + nodeType: 'spectrum', + nodeName: title, + signalIndex: i, + signalLabel: data.labels?.[i] || `port ${i}` + }); + } }); } resultPlots = plots; + resultTraces = traces; // Reset tab if out of bounds if (plotActiveTab >= plots.length && plots.length > 0) { @@ -427,10 +448,6 @@ consoleLogCount = logs.length; }); - const unsubPlotSettings = plotSettingsStore.subscribe((s) => { - showPlotLegend = s.showLegend; - }); - // Always start with clean slate clearAutoSave(); @@ -467,7 +484,6 @@ unsubPyodide(); unsubSimulation(); unsubConsole(); - unsubPlotSettings(); // Cleanup autosave subscriptions cleanupAutoSave(); unsubNodes(); @@ -1206,7 +1222,7 @@ {/snippet} - + {/if} @@ -1251,7 +1267,7 @@ showKeyboardShortcuts = false} /> showSearchDialog = false} /> - showPlotOptionsDialog = false} /> + showPlotOptionsDialog = false} traces={resultTraces} /> From 39019ca79140d089650a0943906f9293ba6d0d04 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 17 Jan 2026 22:52:54 +0100 Subject: [PATCH 061/656] Add plotting core module with types, constants, and utils --- PLOT_REFACTOR_PLAN.md | 1295 ++++++++++++++++++++++++++++ src/lib/plotting/core/constants.ts | 138 +++ src/lib/plotting/core/index.ts | 49 ++ src/lib/plotting/core/types.ts | 134 +++ src/lib/plotting/core/utils.ts | 152 ++++ 5 files changed, 1768 insertions(+) create mode 100644 PLOT_REFACTOR_PLAN.md create mode 100644 src/lib/plotting/core/constants.ts create mode 100644 src/lib/plotting/core/index.ts create mode 100644 src/lib/plotting/core/types.ts create mode 100644 src/lib/plotting/core/utils.ts diff --git a/PLOT_REFACTOR_PLAN.md b/PLOT_REFACTOR_PLAN.md new file mode 100644 index 00000000..c5e9de3a --- /dev/null +++ b/PLOT_REFACTOR_PLAN.md @@ -0,0 +1,1295 @@ +# Plot Infrastructure Refactoring Plan + +## Goal +Separate data processing from rendering to create a unified, maintainable, and high-performance plotting system. + +--- + +## Current Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SimulationState │ +│ result: { scopeData, spectrumData, nodeNames } │ +│ resultHistory: SimulationResult[] │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ PlotPanel.svelte │ +│ - Derives scopePlots[], spectrumPlots[] from result │ +│ - Derives ghostDataMaps from resultHistory │ +│ - Passes raw data to SignalPlot components │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────────┴───────────────────┐ + ▼ ▼ +┌─────────────────────────┐ ┌─────────────────────────┐ +│ SignalPlot.svelte │ │ PlotPreview.svelte │ +│ - Subscribes settings │ │ - Subscribes settings │ +│ - Resolves styles │ │ - Resolves styles │ +│ - Checks visibility │ │ - Checks visibility │ +│ - plotQueue (15fps) │ │ - previewQueue (10fps) │ +│ - Creates Plotly traces │ │ - Decimates data │ +│ - Renders via Plotly │ │ - Computes SVG paths │ +└─────────────────────────┘ └─────────────────────────┘ +``` + +### Problems +1. **Duplicated logic**: Style resolution, visibility checks, ghost opacity in both components +2. **Two queues**: Different tick rates, duplicated queue implementation +3. **Coupled processing & rendering**: Can't reuse processed data +4. **Inconsistencies**: Different ghost opacity ranges, color handling differences + +--- + +## Target Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SimulationState │ +│ result: { scopeData, spectrumData, nodeNames } │ +│ resultHistory: SimulationResult[] │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ plotDataStore (NEW) │ +│ - Subscribes to simulationState + plotSettingsStore │ +│ - Single renderQueue (configurable fps) │ +│ - Processes all plots: style resolution, visibility, decimation │ +│ - Outputs: Map │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────────┴───────────────────┐ + ▼ ▼ +┌─────────────────────────┐ ┌─────────────────────────┐ +│ SignalPlot.svelte │ │ PlotPreview.svelte │ +│ - Receives processed │ │ - Receives processed │ +│ data as prop │ │ data as prop │ +│ - Maps to Plotly format │ │ - Maps to SVG paths │ +│ - Renders (no queue) │ │ - Renders (no queue) │ +└─────────────────────────┘ └─────────────────────────┘ +``` + +--- + +## New File Structure + +``` +src/lib/plotting/ +├── core/ +│ ├── types.ts # All shared types and interfaces +│ ├── constants.ts # Colors, defaults, configuration +│ └── utils.ts # Pure utility functions +│ +├── processing/ +│ ├── renderQueue.ts # Single unified queue (factory) +│ ├── dataProcessor.ts # Data processing logic +│ └── plotDataStore.ts # Reactive store combining everything +│ +├── renderers/ +│ ├── plotly.ts # Plotly trace/layout builders +│ └── svg.ts # SVG path generation +│ +└── index.ts # Public API exports +``` + +--- + +## Phase 1: Core Types & Constants + +### 1.1 Create `src/lib/plotting/core/types.ts` + +```typescript +// ============================================================ +// STYLE TYPES (moved from plotSettings.ts) +// ============================================================ + +export type LineStyle = 'solid' | 'dash' | 'dot'; +export type MarkerStyle = 'circle' | 'square' | 'triangle-up'; +export type AxisScale = 'linear' | 'log'; + +export interface TraceStyle { + lineStyle: LineStyle | null; + markerStyle: MarkerStyle | null; + color: string; + visible: boolean; +} + +export interface LayoutStyle { + xAxisScale: AxisScale; + yAxisScale: AxisScale; + showLegend: boolean; +} + +// ============================================================ +// RAW DATA TYPES (input from simulation) +// ============================================================ + +export interface RawScopeData { + time: number[]; + signals: number[][]; + labels?: string[]; +} + +export interface RawSpectrumData { + frequency: number[]; + magnitude: number[][]; + labels?: string[]; +} + +export type RawPlotData = RawScopeData | RawSpectrumData; + +// ============================================================ +// PROCESSED DATA TYPES (output from processor) +// ============================================================ + +export interface ProcessedTrace { + // Identity + signalIndex: number; + label: string; + + // Data (full resolution for Plotly) + x: number[]; + y: number[]; + + // Data (decimated for previews) + xDecimated: number[]; + yDecimated: number[]; + + // Resolved style + style: TraceStyle; + + // Ghost properties (null for main traces) + ghost: { + index: number; // 0 = most recent ghost + total: number; // total ghost count + opacity: number; // pre-calculated opacity + } | null; +} + +export interface ProcessedPlot { + // Identity + nodeId: string; + type: 'scope' | 'spectrum'; + title: string; + + // All traces (ghosts first, then main) + traces: ProcessedTrace[]; + + // Layout settings + layout: LayoutStyle; + + // Pre-computed bounds (for consistent axis scaling) + bounds: { + xMin: number; + xMax: number; + yMin: number; + yMax: number; + }; + + // Spectrum-specific (for tick labels) + frequencies?: number[]; +} + +// ============================================================ +// STORE STATE TYPE +// ============================================================ + +export interface PlotDataState { + plots: Map; // keyed by nodeId + isStreaming: boolean; + lastUpdateTime: number; +} +``` + +### 1.2 Create `src/lib/plotting/core/constants.ts` + +```typescript +// ============================================================ +// RENDER QUEUE CONFIGURATION +// ============================================================ + +export const RENDER_QUEUE_FPS = 15; // Single tick rate for all rendering +export const RENDER_QUEUE_INTERVAL = 1000 / RENDER_QUEUE_FPS; + +// ============================================================ +// DECIMATION CONFIGURATION +// ============================================================ + +export const PREVIEW_TARGET_POINTS = 400; // ~800 points after min-max + +// ============================================================ +// GHOST TRACE CONFIGURATION +// ============================================================ + +export const GHOST_OPACITY_MAX = 0.5; // Most recent ghost +export const GHOST_OPACITY_MIN = 0.2; // Oldest ghost + +export function calculateGhostOpacity(ghostIndex: number, totalGhosts: number): number { + if (totalGhosts === 1) return GHOST_OPACITY_MAX; + const range = GHOST_OPACITY_MAX - GHOST_OPACITY_MIN; + return GHOST_OPACITY_MAX - (ghostIndex / (totalGhosts - 1)) * range; +} + +// ============================================================ +// COLORS +// ============================================================ + +export const TRACE_COLORS = [ + '#E57373', // Red + '#81C784', // Green + '#64B5F6', // Blue + '#BA68C8', // Purple + '#4DD0E1', // Cyan + '#FFB74D', // Orange + '#F06292', // Pink + '#4DB6AC', // Teal + '#90A4AE', // Grey +]; + +export function getTraceColor(index: number, accentColor: string): string { + if (index === 0) return accentColor; + return TRACE_COLORS[(index - 1) % TRACE_COLORS.length]; +} + +// ============================================================ +// LINE DASH PATTERNS +// ============================================================ + +import type { LineStyle } from './types'; + +export const LINE_DASH_PLOTLY: Record = { + solid: 'solid', + dash: 'dash', + dot: 'dot', +}; + +export const LINE_DASH_SVG: Record = { + solid: '', + dash: '6,3', + dot: '2,2', +}; + +// ============================================================ +// MARKER SYMBOLS +// ============================================================ + +import type { MarkerStyle } from './types'; + +export const MARKER_SYMBOL_PLOTLY: Record = { + circle: 'circle', + square: 'square', + 'triangle-up': 'triangle-up', +}; +``` + +### 1.3 Create `src/lib/plotting/core/utils.ts` + +```typescript +import type { TraceStyle } from './types'; + +/** + * Check if a trace should be rendered (has line or marker) + */ +export function isTraceVisible(style: TraceStyle): boolean { + return style.lineStyle !== null || style.markerStyle !== null; +} + +/** + * Min-max decimation: preserves peaks and valleys + * O(n) single pass, outputs ~2*buckets points + */ +export function decimateMinMax( + x: number[], + y: number[], + targetBuckets: number +): { x: number[]; y: number[]; xMin: number; xMax: number; yMin: number; yMax: number } { + const len = x.length; + + if (len === 0) { + return { x: [], y: [], xMin: 0, xMax: 1, yMin: 0, yMax: 1 }; + } + + // If data is small enough, return as-is with bounds + if (len <= targetBuckets * 2) { + let yMin = y[0], yMax = y[0]; + for (let i = 1; i < len; i++) { + if (y[i] < yMin) yMin = y[i]; + if (y[i] > yMax) yMax = y[i]; + } + return { x, y, xMin: x[0], xMax: x[len - 1], yMin, yMax }; + } + + const bucketSize = len / targetBuckets; + const outX: number[] = []; + const outY: number[] = []; + let globalYMin = Infinity, globalYMax = -Infinity; + + for (let bucket = 0; bucket < targetBuckets; bucket++) { + const startIdx = Math.floor(bucket * bucketSize); + const endIdx = Math.floor((bucket + 1) * bucketSize); + + let minIdx = startIdx, maxIdx = startIdx; + let minVal = y[startIdx], maxVal = y[startIdx]; + + for (let i = startIdx + 1; i < endIdx && i < len; i++) { + if (y[i] < minVal) { minVal = y[i]; minIdx = i; } + if (y[i] > maxVal) { maxVal = y[i]; maxIdx = i; } + } + + // Add in chronological order + if (minIdx <= maxIdx) { + outX.push(x[minIdx]); outY.push(y[minIdx]); + if (maxIdx !== minIdx) { outX.push(x[maxIdx]); outY.push(y[maxIdx]); } + } else { + outX.push(x[maxIdx]); outY.push(y[maxIdx]); + outX.push(x[minIdx]); outY.push(y[minIdx]); + } + + if (minVal < globalYMin) globalYMin = minVal; + if (maxVal > globalYMax) globalYMax = maxVal; + } + + // Always include last point + if (outX[outX.length - 1] !== x[len - 1]) { + outX.push(x[len - 1]); + outY.push(y[len - 1]); + } + + return { + x: outX, + y: outY, + xMin: x[0], + xMax: x[len - 1], + yMin: globalYMin, + yMax: globalYMax, + }; +} + +/** + * Compute bounds from multiple data arrays + */ +export function computeBounds( + dataArrays: { x: number[]; y: number[] }[] +): { xMin: number; xMax: number; yMin: number; yMax: number } { + let xMin = Infinity, xMax = -Infinity; + let yMin = Infinity, yMax = -Infinity; + + for (const { x, y } of dataArrays) { + for (let i = 0; i < x.length; i++) { + if (x[i] < xMin) xMin = x[i]; + if (x[i] > xMax) xMax = x[i]; + if (y[i] < yMin) yMin = y[i]; + if (y[i] > yMax) yMax = y[i]; + } + } + + // Handle empty/invalid bounds + if (!isFinite(xMin)) xMin = 0; + if (!isFinite(xMax)) xMax = 1; + if (!isFinite(yMin)) yMin = 0; + if (!isFinite(yMax)) yMax = 1; + + return { xMin, xMax, yMin, yMax }; +} +``` + +--- + +## Phase 2: Unified Render Queue + +### 2.1 Create `src/lib/plotting/processing/renderQueue.ts` + +```typescript +type RenderTask = () => void; + +interface RenderQueueOptions { + fps: number; + name?: string; // For debugging +} + +interface RenderQueue { + enqueue: (id: symbol, task: RenderTask) => void; + cancel: (id: symbol) => void; + isVisible: () => boolean; + destroy: () => void; +} + +/** + * Factory function to create a render queue with configurable FPS + */ +export function createRenderQueue(options: RenderQueueOptions): RenderQueue { + const { fps, name = 'RenderQueue' } = options; + const minInterval = 1000 / fps; + + const taskQueue = new Map(); + let rafId: number | null = null; + let lastProcessTime = 0; + let visible = true; + + function handleVisibilityChange() { + visible = document.visibilityState === 'visible'; + if (visible && taskQueue.size > 0 && rafId === null) { + scheduleProcess(); + } + } + + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', handleVisibilityChange); + } + + function scheduleProcess() { + if (rafId !== null) return; + rafId = requestAnimationFrame(process); + } + + function process(timestamp: number) { + rafId = null; + + if (!visible || taskQueue.size === 0) return; + + // Throttle to target FPS + if (timestamp - lastProcessTime < minInterval) { + scheduleProcess(); + return; + } + + lastProcessTime = timestamp; + + // Process all queued tasks in one batch + const tasks = Array.from(taskQueue.values()); + taskQueue.clear(); + + for (const task of tasks) { + try { + task(); + } catch (e) { + console.error(`[${name}] Task error:`, e); + } + } + + // If new tasks were added during processing, schedule again + if (taskQueue.size > 0) { + scheduleProcess(); + } + } + + return { + enqueue(id: symbol, task: RenderTask) { + taskQueue.set(id, task); + if (visible) scheduleProcess(); + }, + + cancel(id: symbol) { + taskQueue.delete(id); + }, + + isVisible() { + return visible; + }, + + destroy() { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + taskQueue.clear(); + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', handleVisibilityChange); + } + }, + }; +} + +// Singleton instance for the application +import { RENDER_QUEUE_FPS } from '../core/constants'; + +export const plotRenderQueue = createRenderQueue({ + fps: RENDER_QUEUE_FPS, + name: 'PlotRenderQueue', +}); +``` + +--- + +## Phase 3: Data Processor + +### 3.1 Create `src/lib/plotting/processing/dataProcessor.ts` + +```typescript +import type { + RawScopeData, + RawSpectrumData, + ProcessedPlot, + ProcessedTrace, + TraceStyle, + LayoutStyle, +} from '../core/types'; +import { + calculateGhostOpacity, + getTraceColor, + PREVIEW_TARGET_POINTS, +} from '../core/constants'; +import { decimateMinMax, computeBounds, isTraceVisible } from '../core/utils'; +import type { TraceSettings, BlockSettings } from '$lib/stores/plotSettings'; + +interface ProcessPlotOptions { + nodeId: string; + type: 'scope' | 'spectrum'; + title: string; + data: RawScopeData | RawSpectrumData | null; + ghostData: (RawScopeData | RawSpectrumData)[]; + traceSettings: (signalIndex: number) => TraceSettings; + blockSettings: BlockSettings; + accentColor: string; +} + +/** + * Process a single plot's data into render-ready format + */ +export function processPlot(options: ProcessPlotOptions): ProcessedPlot { + const { + nodeId, + type, + title, + data, + ghostData, + traceSettings, + blockSettings, + accentColor, + } = options; + + const traces: ProcessedTrace[] = []; + const allDataForBounds: { x: number[]; y: number[] }[] = []; + const totalGhosts = ghostData.length; + + // Helper to extract x/y arrays from raw data + function extractXY(raw: RawScopeData | RawSpectrumData): { x: number[]; ys: number[][]; labels?: string[] } { + if (type === 'scope') { + const d = raw as RawScopeData; + return { x: d.time || [], ys: d.signals || [], labels: d.labels }; + } else { + const d = raw as RawSpectrumData; + // Use indices for x-axis (equal spacing) + const x = d.frequency ? Array.from({ length: d.frequency.length }, (_, i) => i) : []; + return { x, ys: d.magnitude || [], labels: d.labels }; + } + } + + // Helper to create a processed trace + function createTrace( + signalIndex: number, + x: number[], + y: number[], + label: string, + ghostInfo: ProcessedTrace['ghost'] + ): ProcessedTrace | null { + const settings = traceSettings(signalIndex); + const color = getTraceColor(signalIndex, accentColor); + + const style: TraceStyle = { + lineStyle: settings.lineStyle, + markerStyle: settings.markerStyle, + color, + visible: settings.lineStyle !== null || settings.markerStyle !== null, + }; + + // Skip invisible traces + if (!style.visible) return null; + + // Decimate for preview + const decimated = decimateMinMax(x, y, PREVIEW_TARGET_POINTS); + + return { + signalIndex, + label, + x, + y, + xDecimated: decimated.x, + yDecimated: decimated.y, + style, + ghost: ghostInfo, + }; + } + + // Process ghost data (oldest to newest, so newest renders on top) + for (let ghostIdx = totalGhosts - 1; ghostIdx >= 0; ghostIdx--) { + const ghost = ghostData[ghostIdx]; + if (!ghost) continue; + + const { x, ys, labels } = extractXY(ghost); + if (x.length === 0) continue; + + const opacity = calculateGhostOpacity(ghostIdx, totalGhosts); + + for (let sigIdx = 0; sigIdx < ys.length; sigIdx++) { + const y = ys[sigIdx]; + if (!y || y.length === 0) continue; + + const label = labels?.[sigIdx] ?? `port ${sigIdx}`; + const trace = createTrace(sigIdx, x, y, label, { + index: ghostIdx, + total: totalGhosts, + opacity, + }); + + if (trace) { + traces.push(trace); + allDataForBounds.push({ x, y }); + } + } + } + + // Process main data + if (data) { + const { x, ys, labels } = extractXY(data); + if (x.length > 0) { + for (let sigIdx = 0; sigIdx < ys.length; sigIdx++) { + const y = ys[sigIdx]; + if (!y || y.length === 0) continue; + + const label = labels?.[sigIdx] ?? `port ${sigIdx}`; + const trace = createTrace(sigIdx, x, y, label, null); + + if (trace) { + traces.push(trace); + allDataForBounds.push({ x, y }); + } + } + } + } + + // Compute bounds from all data + const bounds = computeBounds(allDataForBounds); + + // Extract frequencies for spectrum tick labels + let frequencies: number[] | undefined; + if (type === 'spectrum' && data) { + frequencies = (data as RawSpectrumData).frequency; + } + + return { + nodeId, + type, + title, + traces, + layout: { + xAxisScale: blockSettings.xAxisScale, + yAxisScale: blockSettings.yAxisScale, + showLegend: blockSettings.showLegend, + }, + bounds, + frequencies, + }; +} +``` + +--- + +## Phase 4: Plot Data Store + +### 4.1 Create `src/lib/plotting/processing/plotDataStore.ts` + +```typescript +import { writable, derived, get } from 'svelte/store'; +import { simulationState, type SimulationResult } from '$lib/pyodide/bridge'; +import { plotSettingsStore } from '$lib/stores/plotSettings'; +import { settingsStore } from '$lib/stores/settings'; +import { processPlot } from './dataProcessor'; +import { plotRenderQueue } from './renderQueue'; +import type { ProcessedPlot, PlotDataState } from '../core/types'; + +// Internal state +const internal = writable({ + plots: new Map(), + isStreaming: false, + lastUpdateTime: 0, +}); + +// Queue ID for this store +const queueId = Symbol('plotDataStore'); + +// Cached CSS variable for accent color +let accentColor = '#0070C0'; +function updateAccentColor() { + if (typeof document !== 'undefined') { + accentColor = getComputedStyle(document.documentElement) + .getPropertyValue('--accent').trim() || '#0070C0'; + } +} + +// Process all plots from current simulation state +function processAllPlots( + result: SimulationResult | null, + resultHistory: SimulationResult[], + ghostTraceCount: number +): Map { + const plots = new Map(); + + if (!result) return plots; + + updateAccentColor(); + + // Helper to get node name + const getNodeName = (id: string, fallback: string) => + result.nodeNames?.[id] || fallback; + + // Helper to get ghost data for a node + const getGhostData = (nodeId: string, type: 'scope' | 'spectrum') => { + const history = resultHistory.slice(0, ghostTraceCount); + return history + .map(r => type === 'scope' ? r.scopeData?.[nodeId] : r.spectrumData?.[nodeId]) + .filter(Boolean); + }; + + // Process scope plots + if (result.scopeData) { + for (const [nodeId, data] of Object.entries(result.scopeData)) { + const processed = processPlot({ + nodeId, + type: 'scope', + title: getNodeName(nodeId, 'Scope'), + data, + ghostData: getGhostData(nodeId, 'scope'), + traceSettings: (idx) => plotSettingsStore.getTraceSettings(`${nodeId}-${idx}`), + blockSettings: plotSettingsStore.getBlockSettings(nodeId), + accentColor, + }); + plots.set(nodeId, processed); + } + } + + // Process spectrum plots + if (result.spectrumData) { + for (const [nodeId, data] of Object.entries(result.spectrumData)) { + // Initialize spectrum blocks with log Y-axis if not set + const existingSettings = get(plotSettingsStore).blocks[nodeId]; + if (!existingSettings) { + plotSettingsStore.setBlockYAxisScale(nodeId, 'log'); + } + + const processed = processPlot({ + nodeId, + type: 'spectrum', + title: getNodeName(nodeId, 'Spectrum'), + data, + ghostData: getGhostData(nodeId, 'spectrum'), + traceSettings: (idx) => plotSettingsStore.getTraceSettings(`${nodeId}-${idx}`), + blockSettings: plotSettingsStore.getBlockSettings(nodeId), + accentColor, + }); + plots.set(nodeId, processed); + } + } + + return plots; +} + +// Schedule processing when inputs change +let lastResult: SimulationResult | null = null; +let lastHistory: SimulationResult[] = []; +let lastGhostCount = 0; +let lastSettingsVersion = 0; + +function scheduleProcessing() { + plotRenderQueue.enqueue(queueId, () => { + const simState = get(simulationState); + const settings = get(settingsStore); + const ghostCount = settings.ghostTraces ?? 0; + + const plots = processAllPlots( + simState.result, + simState.resultHistory, + ghostCount + ); + + internal.set({ + plots, + isStreaming: simState.phase === 'running', + lastUpdateTime: Date.now(), + }); + }); +} + +// Subscribe to all input sources +simulationState.subscribe((state) => { + if (state.result !== lastResult || state.resultHistory !== lastHistory) { + lastResult = state.result; + lastHistory = state.resultHistory; + scheduleProcessing(); + } +}); + +settingsStore.subscribe((state) => { + if ((state.ghostTraces ?? 0) !== lastGhostCount) { + lastGhostCount = state.ghostTraces ?? 0; + scheduleProcessing(); + } +}); + +plotSettingsStore.subscribe(() => { + // Settings changed, reprocess + scheduleProcessing(); +}); + +// Public API +export const plotDataStore = { + subscribe: internal.subscribe, + + /** + * Get processed data for a specific plot + */ + getPlot(nodeId: string): ProcessedPlot | undefined { + return get(internal).plots.get(nodeId); + }, + + /** + * Get all processed plots as an array + */ + getAllPlots(): ProcessedPlot[] { + return Array.from(get(internal).plots.values()); + }, + + /** + * Check if currently streaming + */ + isStreaming(): boolean { + return get(internal).isStreaming; + }, +}; +``` + +--- + +## Phase 5: Renderers + +### 5.1 Create `src/lib/plotting/renderers/plotly.ts` + +```typescript +import type { ProcessedPlot, ProcessedTrace } from '../core/types'; +import { LINE_DASH_PLOTLY, MARKER_SYMBOL_PLOTLY } from '../core/constants'; + +/** + * Convert ProcessedTrace to Plotly ScatterData + */ +export function toPlotlyTrace( + trace: ProcessedTrace, + useDecimated: boolean = false +): Partial { + const { style, ghost, signalIndex, label } = trace; + const x = useDecimated ? trace.xDecimated : trace.x; + const y = useDecimated ? trace.yDecimated : trace.y; + + // Determine mode + const showLines = style.lineStyle !== null; + const showMarkers = style.markerStyle !== null; + let mode: 'lines' | 'markers' | 'lines+markers' = 'lines'; + if (showLines && showMarkers) mode = 'lines+markers'; + else if (showMarkers) mode = 'markers'; + + const plotlyTrace: Partial = { + x, + y, + type: 'scatter', + mode, + name: label, + legendgroup: `signal-${signalIndex}`, + }; + + // Ghost traces + if (ghost) { + plotlyTrace.opacity = ghost.opacity; + plotlyTrace.showlegend = false; + plotlyTrace.hoverinfo = 'skip'; + } else { + // Main trace hover template + plotlyTrace.hovertemplate = + `${label}
        ` + + `x = %{x:.4g}
        y = %{y:.4g}`; + } + + // Line config + if (showLines && style.lineStyle) { + plotlyTrace.line = { + color: style.color, + width: ghost ? 1 : 1.5, + dash: LINE_DASH_PLOTLY[style.lineStyle], + }; + } + + // Marker config + if (showMarkers && style.markerStyle) { + plotlyTrace.marker = { + symbol: MARKER_SYMBOL_PLOTLY[style.markerStyle], + size: ghost ? 5 : 6, + color: style.color, + }; + plotlyTrace.cliponaxis = false; + } + + return plotlyTrace; +} + +/** + * Build Plotly layout from ProcessedPlot + */ +export function toPlotlyLayout( + plot: ProcessedPlot, + baseLayout: Partial +): Partial { + const { type, title, layout, frequencies } = plot; + + const xAxisTitle = type === 'scope' ? 'Time (s)' : 'Frequency (Hz)'; + const yAxisTitle = title; + + const result: Partial = { + ...baseLayout, + xaxis: { + ...baseLayout.xaxis, + title: { text: xAxisTitle, font: { size: 11 }, standoff: 10 }, + type: layout.xAxisScale, + }, + yaxis: { + ...baseLayout.yaxis, + title: { text: yAxisTitle, font: { size: 11 }, standoff: 5 }, + type: layout.yAxisScale, + }, + showlegend: layout.showLegend, + hovermode: 'closest', + }; + + // Spectrum: add frequency tick labels + if (type === 'spectrum' && frequencies && frequencies.length > 0) { + const numTicks = Math.min(6, frequencies.length); + const step = Math.max(1, Math.floor((frequencies.length - 1) / (numTicks - 1))); + const tickvals: number[] = []; + const ticktext: string[] = []; + + for (let i = 0; i < frequencies.length; i += step) { + tickvals.push(i); + ticktext.push(formatFrequency(frequencies[i])); + } + if (tickvals[tickvals.length - 1] !== frequencies.length - 1) { + tickvals.push(frequencies.length - 1); + ticktext.push(formatFrequency(frequencies[frequencies.length - 1])); + } + + result.xaxis = { ...result.xaxis, tickvals, ticktext, tickangle: 0 }; + } + + return result; +} + +function formatFrequency(freq: number): string { + if (freq >= 1e6) return (freq / 1e6).toFixed(1) + 'M'; + if (freq >= 1e3) return (freq / 1e3).toFixed(1) + 'k'; + if (freq >= 1) return freq.toFixed(1); + return freq.toExponential(1); +} +``` + +### 5.2 Create `src/lib/plotting/renderers/svg.ts` + +```typescript +import type { ProcessedPlot, ProcessedTrace } from '../core/types'; +import { LINE_DASH_SVG } from '../core/constants'; + +export interface SVGPathData { + d: string; + color: string; + opacity: number; + strokeWidth: number; + dasharray: string; +} + +/** + * Convert ProcessedPlot to SVG path data for preview rendering + */ +export function toSVGPaths( + plot: ProcessedPlot, + width: number, + height: number, + padding: number +): SVGPathData[] { + const { traces, bounds } = plot; + + if (traces.length === 0) return []; + + const { xMin, xMax, yMin, yMax } = bounds; + const xRange = xMax - xMin || 1; + const yRange = yMax - yMin || 1; + const plotWidth = width - padding * 2; + const plotHeight = height - padding * 2; + + return traces.map((trace) => { + const { xDecimated, yDecimated, style, ghost } = trace; + + // Build SVG path + const pathPoints: string[] = []; + for (let i = 0; i < xDecimated.length; i++) { + const x = padding + ((xDecimated[i] - xMin) / xRange) * plotWidth; + const y = height - padding - ((yDecimated[i] - yMin) / yRange) * plotHeight; + pathPoints.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`); + } + + return { + d: pathPoints.join(' '), + color: style.color, + opacity: ghost?.opacity ?? 1, + strokeWidth: ghost ? 0.7 : 1, + dasharray: style.lineStyle ? LINE_DASH_SVG[style.lineStyle] : '', + }; + }); +} +``` + +--- + +## Phase 6: Update Components + +### 6.1 Simplify SignalPlot.svelte + +```svelte + + +
        +
        +
        +``` + +### 6.2 Simplify PlotPreview.svelte + +```svelte + + +
        + + + + {#if hasData} + {#each paths as path} + + {/each} + {:else} + + No data + + {/if} + +
        +``` + +--- + +## Phase 7: Migration Steps + +### Step 1: Create Core Module (non-breaking) +- [ ] Create `src/lib/plotting/core/types.ts` +- [ ] Create `src/lib/plotting/core/constants.ts` +- [ ] Create `src/lib/plotting/core/utils.ts` +- [ ] Create `src/lib/plotting/index.ts` with exports +- [ ] Verify build passes + +### Step 2: Create Unified Queue (non-breaking) +- [ ] Create `src/lib/plotting/processing/renderQueue.ts` +- [ ] Add tests for queue behavior +- [ ] Verify build passes + +### Step 3: Create Data Processor (non-breaking) +- [ ] Create `src/lib/plotting/processing/dataProcessor.ts` +- [ ] Add tests for data processing +- [ ] Verify build passes + +### Step 4: Create Plot Data Store (non-breaking) +- [ ] Create `src/lib/plotting/processing/plotDataStore.ts` +- [ ] Verify store reacts to simulation state changes +- [ ] Verify build passes + +### Step 5: Create Renderers (non-breaking) +- [ ] Create `src/lib/plotting/renderers/plotly.ts` +- [ ] Create `src/lib/plotting/renderers/svg.ts` +- [ ] Verify build passes + +### Step 6: Migrate SignalPlot (breaking) +- [ ] Update SignalPlot.svelte to use new architecture +- [ ] Remove old plotQueue.ts usage +- [ ] Test streaming behavior +- [ ] Test all plot types and settings +- [ ] Verify build passes + +### Step 7: Migrate PlotPreview (breaking) +- [ ] Update PlotPreview.svelte to use new architecture +- [ ] Remove old previewQueue.ts usage +- [ ] Test preview rendering +- [ ] Verify build passes + +### Step 8: Cleanup +- [ ] Delete old `plotQueue.ts` +- [ ] Delete old `previewQueue.ts` +- [ ] Remove unused code from `plotUtils.ts` +- [ ] Update imports throughout codebase +- [ ] Final verification and testing + +--- + +## Testing Checklist + +### Functional Tests +- [ ] Scope plots render correctly +- [ ] Spectrum plots render correctly +- [ ] Ghost traces appear with correct opacity +- [ ] Line styles (solid/dash/dot) work +- [ ] Marker styles (circle/square/triangle) work +- [ ] Hidden traces (both null) don't render +- [ ] Legend toggle works per-block +- [ ] Axis scale toggle (linear/log) works +- [ ] Spectrum defaults to log Y-axis +- [ ] Previews match main plot styling + +### Streaming Tests +- [ ] Streaming updates are smooth (no stuttering) +- [ ] extendTraces optimization still works for scope +- [ ] Preview updates during streaming +- [ ] Stopping/restarting stream works correctly + +### Performance Tests +- [ ] Memory usage is stable during long simulations +- [ ] CPU usage is reasonable during streaming +- [ ] No visible frame drops at 15 FPS target +- [ ] Tab visibility pausing works + +--- + +## Risks and Mitigations + +| Risk | Mitigation | +|------|------------| +| Breaking streaming optimization | Keep extendTraces logic in SignalPlot, fed by processed data | +| Performance regression | Benchmark before/after, keep old code as fallback | +| State synchronization bugs | Extensive testing of reactive updates | +| Migration takes too long | Do in phases, each phase independently deployable | + +--- + +## Success Criteria + +1. **Single queue**: One render queue with configurable FPS +2. **Separated concerns**: Data processing is independent of rendering +3. **No duplication**: Ghost opacity, visibility checks, etc. defined once +4. **Consistent visuals**: Previews exactly match main plot styling +5. **Maintained performance**: Streaming is as smooth as before +6. **Cleaner code**: Components are simpler, logic is centralized +7. **Testable**: Core functions can be unit tested diff --git a/src/lib/plotting/core/constants.ts b/src/lib/plotting/core/constants.ts new file mode 100644 index 00000000..4fd71893 --- /dev/null +++ b/src/lib/plotting/core/constants.ts @@ -0,0 +1,138 @@ +/** + * Constants for the plotting system + */ + +import type { LineStyle, MarkerStyle } from './types'; + +// ============================================================ +// RENDER QUEUE CONFIGURATION +// ============================================================ + +/** Target FPS for the unified render queue */ +export const RENDER_QUEUE_FPS = 15; + +/** Minimum interval between renders in ms */ +export const RENDER_QUEUE_INTERVAL = 1000 / RENDER_QUEUE_FPS; + +// ============================================================ +// DECIMATION CONFIGURATION +// ============================================================ + +/** Target number of buckets for min-max decimation (~2x points output) */ +export const PREVIEW_TARGET_BUCKETS = 400; + +// ============================================================ +// GHOST TRACE CONFIGURATION +// ============================================================ + +/** Opacity for most recent ghost trace */ +export const GHOST_OPACITY_MAX = 0.5; + +/** Opacity for oldest ghost trace */ +export const GHOST_OPACITY_MIN = 0.2; + +/** + * Calculate ghost trace opacity based on index + * @param ghostIndex - 0 = most recent, higher = older + * @param totalGhosts - Total number of ghost traces + */ +export function calculateGhostOpacity(ghostIndex: number, totalGhosts: number): number { + if (totalGhosts <= 1) return GHOST_OPACITY_MAX; + const range = GHOST_OPACITY_MAX - GHOST_OPACITY_MIN; + return GHOST_OPACITY_MAX - (ghostIndex / (totalGhosts - 1)) * range; +} + +// ============================================================ +// COLORS +// ============================================================ + +/** Supplementary trace colors (after accent) */ +export const TRACE_COLORS = [ + '#E57373', // Red + '#81C784', // Green + '#64B5F6', // Blue + '#BA68C8', // Purple + '#4DD0E1', // Cyan + '#FFB74D', // Orange + '#F06292', // Pink + '#4DB6AC', // Teal + '#90A4AE' // Grey +]; + +/** + * Get trace color for a signal index + * @param index - Signal index (0 = accent color) + * @param accentColor - CSS accent color value + */ +export function getTraceColor(index: number, accentColor: string): string { + if (index === 0) return accentColor; + return TRACE_COLORS[(index - 1) % TRACE_COLORS.length]; +} + +/** + * Read accent color from CSS variables + */ +export function getAccentColor(): string { + if (typeof document === 'undefined') return '#0070C0'; + return ( + getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#0070C0' + ); +} + +// ============================================================ +// LINE DASH PATTERNS +// ============================================================ + +/** Plotly dash values */ +export const LINE_DASH_PLOTLY: Record = { + solid: 'solid', + dash: 'dash', + dot: 'dot' +}; + +/** SVG stroke-dasharray values */ +export const LINE_DASH_SVG: Record = { + solid: '', + dash: '6,3', + dot: '2,2' +}; + +// ============================================================ +// MARKER SYMBOLS +// ============================================================ + +/** Plotly marker symbols */ +export const MARKER_SYMBOL_PLOTLY: Record = { + circle: 'circle', + square: 'square', + 'triangle-up': 'triangle-up' +}; + +// ============================================================ +// PLOTLY CONFIGURATION +// ============================================================ + +/** Common Plotly config for all plots */ +export const PLOTLY_CONFIG: Partial = { + responsive: true, + displaylogo: false, + displayModeBar: 'hover', + modeBarButtonsToRemove: ['lasso2d', 'select2d'], + modeBarButtonsToAdd: [], + toImageButtonOptions: { + format: 'svg', + filename: 'pathview_plot', + height: 600, + width: 1000, + scale: 2 + }, + scrollZoom: true +}; + +// ============================================================ +// SVG PREVIEW DIMENSIONS +// ============================================================ + +export const PREVIEW_WIDTH = 224; +export const PREVIEW_HEIGHT = 96; +export const PREVIEW_PADDING = 8; diff --git a/src/lib/plotting/core/index.ts b/src/lib/plotting/core/index.ts new file mode 100644 index 00000000..79933dd5 --- /dev/null +++ b/src/lib/plotting/core/index.ts @@ -0,0 +1,49 @@ +/** + * Core plotting module exports + */ + +// Types +export type { + LineStyle, + MarkerStyle, + AxisScale, + TraceStyle, + LayoutStyle, + RawScopeData, + RawSpectrumData, + RawPlotData, + ProcessedTrace, + ProcessedPlot, + PlotDataState, + DecimationResult, + DataBounds +} from './types'; + +// Constants +export { + RENDER_QUEUE_FPS, + RENDER_QUEUE_INTERVAL, + PREVIEW_TARGET_BUCKETS, + GHOST_OPACITY_MAX, + GHOST_OPACITY_MIN, + calculateGhostOpacity, + TRACE_COLORS, + getTraceColor, + getAccentColor, + LINE_DASH_PLOTLY, + LINE_DASH_SVG, + MARKER_SYMBOL_PLOTLY, + PLOTLY_CONFIG, + PREVIEW_WIDTH, + PREVIEW_HEIGHT, + PREVIEW_PADDING +} from './constants'; + +// Utils +export { + isTraceVisible, + decimateMinMax, + computeBounds, + formatFrequency, + getCssVar +} from './utils'; diff --git a/src/lib/plotting/core/types.ts b/src/lib/plotting/core/types.ts new file mode 100644 index 00000000..85d782d0 --- /dev/null +++ b/src/lib/plotting/core/types.ts @@ -0,0 +1,134 @@ +/** + * Core types for the plotting system + */ + +// ============================================================ +// STYLE TYPES +// ============================================================ + +export type LineStyle = 'solid' | 'dash' | 'dot'; +export type MarkerStyle = 'circle' | 'square' | 'triangle-up'; +export type AxisScale = 'linear' | 'log'; + +export interface TraceStyle { + lineStyle: LineStyle | null; + markerStyle: MarkerStyle | null; + color: string; + visible: boolean; +} + +export interface LayoutStyle { + xAxisScale: AxisScale; + yAxisScale: AxisScale; + showLegend: boolean; +} + +// ============================================================ +// RAW DATA TYPES (input from simulation) +// ============================================================ + +export interface RawScopeData { + time: number[]; + signals: number[][]; + labels?: string[]; +} + +export interface RawSpectrumData { + frequency: number[]; + magnitude: number[][]; + labels?: string[]; +} + +export type RawPlotData = RawScopeData | RawSpectrumData; + +// ============================================================ +// PROCESSED DATA TYPES (output from processor) +// ============================================================ + +export interface ProcessedTrace { + /** Signal index within the plot */ + signalIndex: number; + /** Display label for the trace */ + label: string; + + /** Full resolution data (for Plotly) */ + x: number[]; + y: number[]; + + /** Decimated data (for previews) */ + xDecimated: number[]; + yDecimated: number[]; + + /** Resolved style information */ + style: TraceStyle; + + /** Ghost trace properties (null for main traces) */ + ghost: { + index: number; // 0 = most recent ghost + total: number; // total ghost count + opacity: number; // pre-calculated opacity + } | null; +} + +export interface ProcessedPlot { + /** Node ID from the graph */ + nodeId: string; + /** Plot type */ + type: 'scope' | 'spectrum'; + /** Display title */ + title: string; + + /** All traces (ghosts first, then main) */ + traces: ProcessedTrace[]; + + /** Layout settings */ + layout: LayoutStyle; + + /** Pre-computed bounds (for consistent axis scaling) */ + bounds: { + xMin: number; + xMax: number; + yMin: number; + yMax: number; + }; + + /** Spectrum-specific: frequency values for tick labels */ + frequencies?: number[]; +} + +// ============================================================ +// STORE STATE TYPE +// ============================================================ + +export interface PlotDataState { + /** Processed plots keyed by nodeId */ + plots: Map; + /** Whether simulation is currently streaming */ + isStreaming: boolean; + /** Timestamp of last update */ + lastUpdateTime: number; +} + +// ============================================================ +// DECIMATION RESULT TYPE +// ============================================================ + +export interface DecimationResult { + x: number[]; + y: number[]; + xMin: number; + xMax: number; + yMin: number; + yMax: number; +} + +// ============================================================ +// BOUNDS TYPE +// ============================================================ + +export interface DataBounds { + xMin: number; + xMax: number; + yMin: number; + yMax: number; +} diff --git a/src/lib/plotting/core/utils.ts b/src/lib/plotting/core/utils.ts new file mode 100644 index 00000000..d9fe5373 --- /dev/null +++ b/src/lib/plotting/core/utils.ts @@ -0,0 +1,152 @@ +/** + * Utility functions for the plotting system + */ + +import type { TraceStyle, DecimationResult, DataBounds } from './types'; +import { PREVIEW_TARGET_BUCKETS } from './constants'; + +/** + * Check if a trace should be rendered (has line or marker) + */ +export function isTraceVisible(style: TraceStyle): boolean { + return style.lineStyle !== null || style.markerStyle !== null; +} + +/** + * Min-max decimation: preserves peaks and valleys + * O(n) single pass, outputs ~2*buckets points + * + * @param x - X data array + * @param y - Y data array + * @param targetBuckets - Number of buckets to divide data into + */ +export function decimateMinMax( + x: number[], + y: number[], + targetBuckets: number = PREVIEW_TARGET_BUCKETS +): DecimationResult { + const len = x.length; + + if (len === 0) { + return { x: [], y: [], xMin: 0, xMax: 1, yMin: 0, yMax: 1 }; + } + + // If data is small enough, return as-is with bounds + if (len <= targetBuckets * 2) { + let yMin = y[0], + yMax = y[0]; + for (let i = 1; i < len; i++) { + if (y[i] < yMin) yMin = y[i]; + if (y[i] > yMax) yMax = y[i]; + } + return { x, y, xMin: x[0], xMax: x[len - 1], yMin, yMax }; + } + + const bucketSize = len / targetBuckets; + const outX: number[] = []; + const outY: number[] = []; + let globalYMin = Infinity, + globalYMax = -Infinity; + + for (let bucket = 0; bucket < targetBuckets; bucket++) { + const startIdx = Math.floor(bucket * bucketSize); + const endIdx = Math.floor((bucket + 1) * bucketSize); + + let minIdx = startIdx, + maxIdx = startIdx; + let minVal = y[startIdx], + maxVal = y[startIdx]; + + // Find min and max in this bucket + for (let i = startIdx + 1; i < endIdx && i < len; i++) { + if (y[i] < minVal) { + minVal = y[i]; + minIdx = i; + } + if (y[i] > maxVal) { + maxVal = y[i]; + maxIdx = i; + } + } + + // Add points in chronological order + if (minIdx <= maxIdx) { + outX.push(x[minIdx]); + outY.push(y[minIdx]); + if (maxIdx !== minIdx) { + outX.push(x[maxIdx]); + outY.push(y[maxIdx]); + } + } else { + outX.push(x[maxIdx]); + outY.push(y[maxIdx]); + outX.push(x[minIdx]); + outY.push(y[minIdx]); + } + + if (minVal < globalYMin) globalYMin = minVal; + if (maxVal > globalYMax) globalYMax = maxVal; + } + + // Always include last point + const lastX = x[len - 1]; + const lastY = y[len - 1]; + if (outX[outX.length - 1] !== lastX) { + outX.push(lastX); + outY.push(lastY); + } + + return { + x: outX, + y: outY, + xMin: x[0], + xMax: x[len - 1], + yMin: globalYMin, + yMax: globalYMax + }; +} + +/** + * Compute bounds from multiple data arrays + */ +export function computeBounds(dataArrays: { x: number[]; y: number[] }[]): DataBounds { + let xMin = Infinity, + xMax = -Infinity; + let yMin = Infinity, + yMax = -Infinity; + + for (const { x, y } of dataArrays) { + for (let i = 0; i < x.length; i++) { + if (x[i] < xMin) xMin = x[i]; + if (x[i] > xMax) xMax = x[i]; + if (y[i] < yMin) yMin = y[i]; + if (y[i] > yMax) yMax = y[i]; + } + } + + // Handle empty/invalid bounds + if (!isFinite(xMin)) xMin = 0; + if (!isFinite(xMax)) xMax = 1; + if (!isFinite(yMin)) yMin = 0; + if (!isFinite(yMax)) yMax = 1; + + return { xMin, xMax, yMin, yMax }; +} + +/** + * Format frequency for display (compact notation) + */ +export function formatFrequency(freq: number): string { + if (freq >= 1e6) return (freq / 1e6).toFixed(1) + 'M'; + if (freq >= 1e3) return (freq / 1e3).toFixed(1) + 'k'; + if (freq >= 1) return freq.toFixed(1); + return freq.toExponential(1); +} + +/** + * Read a CSS variable value from the document root + */ +export function getCssVar(name: string): string { + if (typeof document === 'undefined') return ''; + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +} From c0de8d75587b1b1356bdf8fdba0f79fe4531a568 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 17 Jan 2026 22:54:53 +0100 Subject: [PATCH 062/656] Add unified render queue with configurable FPS --- src/lib/plotting/processing/index.ts | 15 +++ src/lib/plotting/processing/renderQueue.ts | 116 +++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 src/lib/plotting/processing/index.ts create mode 100644 src/lib/plotting/processing/renderQueue.ts diff --git a/src/lib/plotting/processing/index.ts b/src/lib/plotting/processing/index.ts new file mode 100644 index 00000000..6d18e8b4 --- /dev/null +++ b/src/lib/plotting/processing/index.ts @@ -0,0 +1,15 @@ +/** + * Processing module exports + */ + +export { createRenderQueue, type RenderQueue } from './renderQueue'; + +// Singleton instance for the application +import { createRenderQueue } from './renderQueue'; +import { RENDER_QUEUE_FPS } from '../core/constants'; + +/** Global render queue for all plot updates */ +export const plotRenderQueue = createRenderQueue({ + fps: RENDER_QUEUE_FPS, + name: 'PlotRenderQueue' +}); diff --git a/src/lib/plotting/processing/renderQueue.ts b/src/lib/plotting/processing/renderQueue.ts new file mode 100644 index 00000000..faf7eaed --- /dev/null +++ b/src/lib/plotting/processing/renderQueue.ts @@ -0,0 +1,116 @@ +/** + * Unified render queue for batching and throttling plot updates + */ + +type RenderTask = () => void; + +interface RenderQueueOptions { + /** Target frames per second */ + fps: number; + /** Name for debugging */ + name?: string; +} + +export interface RenderQueue { + /** Add or replace a task in the queue */ + enqueue: (id: symbol, task: RenderTask) => void; + /** Cancel a pending task */ + cancel: (id: symbol) => void; + /** Check if the page is visible */ + isVisible: () => boolean; + /** Clean up the queue (remove event listeners) */ + destroy: () => void; +} + +/** + * Factory function to create a render queue with configurable FPS + * + * Features: + * - Symbol-based task deduplication (one task per component) + * - Configurable FPS throttling + * - Visibility API integration (pauses when tab is hidden) + * - Batches all queued tasks in one animation frame + */ +export function createRenderQueue(options: RenderQueueOptions): RenderQueue { + const { fps, name = 'RenderQueue' } = options; + const minInterval = 1000 / fps; + + const taskQueue = new Map(); + let rafId: number | null = null; + let lastProcessTime = 0; + let visible = true; + + function handleVisibilityChange() { + visible = document.visibilityState === 'visible'; + if (visible && taskQueue.size > 0 && rafId === null) { + scheduleProcess(); + } + } + + // Only add listener in browser environment + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', handleVisibilityChange); + } + + function scheduleProcess() { + if (rafId !== null) return; + rafId = requestAnimationFrame(process); + } + + function process(timestamp: number) { + rafId = null; + + if (!visible || taskQueue.size === 0) return; + + // Throttle to target FPS + if (timestamp - lastProcessTime < minInterval) { + scheduleProcess(); + return; + } + + lastProcessTime = timestamp; + + // Process all queued tasks in one batch + const tasks = Array.from(taskQueue.values()); + taskQueue.clear(); + + for (const task of tasks) { + try { + task(); + } catch (e) { + console.error(`[${name}] Task error:`, e); + } + } + + // If new tasks were added during processing, schedule again + if (taskQueue.size > 0) { + scheduleProcess(); + } + } + + return { + enqueue(id: symbol, task: RenderTask) { + taskQueue.set(id, task); + if (visible) scheduleProcess(); + }, + + cancel(id: symbol) { + taskQueue.delete(id); + }, + + isVisible() { + return visible; + }, + + destroy() { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + taskQueue.clear(); + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', handleVisibilityChange); + } + } + }; +} From 51c48cb890e2f5ef7ed6963b3574cb357c884ced Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 17 Jan 2026 22:56:30 +0100 Subject: [PATCH 063/656] Add data processor for converting raw data to render-ready format --- src/lib/plotting/processing/dataProcessor.ts | 197 +++++++++++++++++++ src/lib/plotting/processing/index.ts | 1 + 2 files changed, 198 insertions(+) create mode 100644 src/lib/plotting/processing/dataProcessor.ts diff --git a/src/lib/plotting/processing/dataProcessor.ts b/src/lib/plotting/processing/dataProcessor.ts new file mode 100644 index 00000000..c5f2686b --- /dev/null +++ b/src/lib/plotting/processing/dataProcessor.ts @@ -0,0 +1,197 @@ +/** + * Data processor for converting raw simulation data to render-ready format + */ + +import type { + RawScopeData, + RawSpectrumData, + ProcessedPlot, + ProcessedTrace, + TraceStyle, + DataBounds +} from '../core/types'; +import { calculateGhostOpacity, getTraceColor } from '../core/constants'; +import { decimateMinMax, computeBounds } from '../core/utils'; +import type { TraceSettings, BlockSettings } from '$lib/stores/plotSettings'; + +// ============================================================ +// PROCESS PLOT OPTIONS +// ============================================================ + +export interface ProcessPlotOptions { + /** Node ID from the graph */ + nodeId: string; + /** Plot type */ + type: 'scope' | 'spectrum'; + /** Display title */ + title: string; + /** Current simulation data (null if no data yet) */ + data: RawScopeData | RawSpectrumData | null; + /** Ghost data from previous runs */ + ghostData: (RawScopeData | RawSpectrumData)[]; + /** Function to get trace settings for a signal index */ + getTraceSettings: (signalIndex: number) => TraceSettings; + /** Block settings for this node */ + blockSettings: BlockSettings; + /** CSS accent color value */ + accentColor: string; +} + +// ============================================================ +// HELPER FUNCTIONS +// ============================================================ + +/** + * Extract x/y arrays from raw plot data + */ +function extractXY( + raw: RawScopeData | RawSpectrumData, + type: 'scope' | 'spectrum' +): { x: number[]; ys: number[][]; labels?: string[] } { + if (type === 'scope') { + const d = raw as RawScopeData; + return { x: d.time || [], ys: d.signals || [], labels: d.labels }; + } else { + const d = raw as RawSpectrumData; + // Use indices for x-axis (equal spacing) + const x = d.frequency ? Array.from({ length: d.frequency.length }, (_, i) => i) : []; + return { x, ys: d.magnitude || [], labels: d.labels }; + } +} + +/** + * Create a processed trace from raw data + */ +function createProcessedTrace( + signalIndex: number, + x: number[], + y: number[], + label: string, + traceSettings: TraceSettings, + accentColor: string, + ghostInfo: ProcessedTrace['ghost'] +): ProcessedTrace | null { + const color = getTraceColor(signalIndex, accentColor); + + const style: TraceStyle = { + lineStyle: traceSettings.lineStyle, + markerStyle: traceSettings.markerStyle, + color, + visible: traceSettings.lineStyle !== null || traceSettings.markerStyle !== null + }; + + // Skip invisible traces + if (!style.visible) return null; + + // Decimate for preview + const decimated = decimateMinMax(x, y); + + return { + signalIndex, + label, + x, + y, + xDecimated: decimated.x, + yDecimated: decimated.y, + style, + ghost: ghostInfo + }; +} + +// ============================================================ +// MAIN PROCESSOR FUNCTION +// ============================================================ + +/** + * Process a single plot's data into render-ready format + * + * This function: + * 1. Extracts x/y data from raw simulation data + * 2. Resolves trace styles from settings + * 3. Filters out invisible traces + * 4. Decimates data for previews + * 5. Computes bounds for axis scaling + * 6. Returns a ProcessedPlot ready for rendering + */ +export function processPlot(options: ProcessPlotOptions): ProcessedPlot { + const { nodeId, type, title, data, ghostData, getTraceSettings, blockSettings, accentColor } = + options; + + const traces: ProcessedTrace[] = []; + const allDataForBounds: { x: number[]; y: number[] }[] = []; + const totalGhosts = ghostData.length; + + // Process ghost data (oldest to newest, so newest renders on top) + for (let ghostIdx = totalGhosts - 1; ghostIdx >= 0; ghostIdx--) { + const ghost = ghostData[ghostIdx]; + if (!ghost) continue; + + const { x, ys, labels } = extractXY(ghost, type); + if (x.length === 0) continue; + + const opacity = calculateGhostOpacity(ghostIdx, totalGhosts); + + for (let sigIdx = 0; sigIdx < ys.length; sigIdx++) { + const y = ys[sigIdx]; + if (!y || y.length === 0) continue; + + const label = labels?.[sigIdx] ?? `port ${sigIdx}`; + const settings = getTraceSettings(sigIdx); + + const trace = createProcessedTrace(sigIdx, x, y, label, settings, accentColor, { + index: ghostIdx, + total: totalGhosts, + opacity + }); + + if (trace) { + traces.push(trace); + allDataForBounds.push({ x, y }); + } + } + } + + // Process main data + if (data) { + const { x, ys, labels } = extractXY(data, type); + if (x.length > 0) { + for (let sigIdx = 0; sigIdx < ys.length; sigIdx++) { + const y = ys[sigIdx]; + if (!y || y.length === 0) continue; + + const label = labels?.[sigIdx] ?? `port ${sigIdx}`; + const settings = getTraceSettings(sigIdx); + + const trace = createProcessedTrace(sigIdx, x, y, label, settings, accentColor, null); + + if (trace) { + traces.push(trace); + allDataForBounds.push({ x, y }); + } + } + } + } + + // Compute bounds from all data + const bounds: DataBounds = computeBounds(allDataForBounds); + + // Extract frequencies for spectrum tick labels + let frequencies: number[] | undefined; + if (type === 'spectrum' && data) { + frequencies = (data as RawSpectrumData).frequency; + } + + return { + nodeId, + type, + title, + traces, + layout: { + xAxisScale: blockSettings.xAxisScale, + yAxisScale: blockSettings.yAxisScale, + showLegend: blockSettings.showLegend + }, + bounds, + frequencies + }; +} diff --git a/src/lib/plotting/processing/index.ts b/src/lib/plotting/processing/index.ts index 6d18e8b4..fc41f3da 100644 --- a/src/lib/plotting/processing/index.ts +++ b/src/lib/plotting/processing/index.ts @@ -3,6 +3,7 @@ */ export { createRenderQueue, type RenderQueue } from './renderQueue'; +export { processPlot, type ProcessPlotOptions } from './dataProcessor'; // Singleton instance for the application import { createRenderQueue } from './renderQueue'; From 9c2b7986cfa41702b68595964e5a176a5261a022 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 17 Jan 2026 23:00:01 +0100 Subject: [PATCH 064/656] Add plot data store for centralized reactive plot processing --- src/lib/plotting/processing/index.ts | 16 +- src/lib/plotting/processing/plotDataStore.ts | 186 +++++++++++++++++++ src/lib/plotting/processing/renderQueue.ts | 12 ++ 3 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 src/lib/plotting/processing/plotDataStore.ts diff --git a/src/lib/plotting/processing/index.ts b/src/lib/plotting/processing/index.ts index fc41f3da..b9830ff1 100644 --- a/src/lib/plotting/processing/index.ts +++ b/src/lib/plotting/processing/index.ts @@ -2,15 +2,11 @@ * Processing module exports */ -export { createRenderQueue, type RenderQueue } from './renderQueue'; -export { processPlot, type ProcessPlotOptions } from './dataProcessor'; +// Render queue +export { createRenderQueue, plotRenderQueue, type RenderQueue } from './renderQueue'; -// Singleton instance for the application -import { createRenderQueue } from './renderQueue'; -import { RENDER_QUEUE_FPS } from '../core/constants'; +// Data processor +export { processPlot, type ProcessPlotOptions } from './dataProcessor'; -/** Global render queue for all plot updates */ -export const plotRenderQueue = createRenderQueue({ - fps: RENDER_QUEUE_FPS, - name: 'PlotRenderQueue' -}); +// Plot data store +export { plotDataStore } from './plotDataStore'; diff --git a/src/lib/plotting/processing/plotDataStore.ts b/src/lib/plotting/processing/plotDataStore.ts new file mode 100644 index 00000000..79dbdc4e --- /dev/null +++ b/src/lib/plotting/processing/plotDataStore.ts @@ -0,0 +1,186 @@ +/** + * Plot data store - central reactive store for processed plot data + * + * This store: + * 1. Subscribes to simulationState, plotSettingsStore, and settingsStore + * 2. Uses the unified renderQueue for throttling + * 3. Calls processPlot() for each plot + * 4. Outputs a Map + */ + +import { writable, get } from 'svelte/store'; +import { simulationState, type SimulationResult } from '$lib/pyodide/bridge'; +import { plotSettingsStore, createTraceId } from '$lib/stores/plotSettings'; +import { settingsStore } from '$lib/stores/settings'; +import { processPlot } from './dataProcessor'; +import { plotRenderQueue } from './renderQueue'; +import { getAccentColor } from '../core/constants'; +import type { ProcessedPlot, PlotDataState, RawScopeData, RawSpectrumData } from '../core/types'; + +// ============================================================ +// INTERNAL STATE +// ============================================================ + +const internal = writable({ + plots: new Map(), + isStreaming: false, + lastUpdateTime: 0 +}); + +// Queue ID for this store +const queueId = Symbol('plotDataStore'); + +// ============================================================ +// HELPER FUNCTIONS +// ============================================================ + +/** + * Process all plots from current simulation state + */ +function processAllPlots( + result: SimulationResult | null, + resultHistory: SimulationResult[], + ghostTraceCount: number +): Map { + const plots = new Map(); + + if (!result) return plots; + + const accentColor = getAccentColor(); + + // Helper to get node name + const getNodeName = (id: string, fallback: string) => result.nodeNames?.[id] || fallback; + + // Helper to get ghost data for a node + const getGhostData = ( + nodeId: string, + type: 'scope' | 'spectrum' + ): (RawScopeData | RawSpectrumData)[] => { + const history = resultHistory.slice(0, ghostTraceCount); + return history + .map((r) => (type === 'scope' ? r.scopeData?.[nodeId] : r.spectrumData?.[nodeId])) + .filter((d): d is RawScopeData | RawSpectrumData => d != null); + }; + + // Process scope plots + if (result.scopeData) { + for (const [nodeId, data] of Object.entries(result.scopeData)) { + const processed = processPlot({ + nodeId, + type: 'scope', + title: getNodeName(nodeId, 'Scope'), + data, + ghostData: getGhostData(nodeId, 'scope'), + getTraceSettings: (idx) => plotSettingsStore.getTraceSettings(createTraceId(nodeId, idx)), + blockSettings: plotSettingsStore.getBlockSettings(nodeId), + accentColor + }); + plots.set(nodeId, processed); + } + } + + // Process spectrum plots + if (result.spectrumData) { + for (const [nodeId, data] of Object.entries(result.spectrumData)) { + // Initialize spectrum blocks with log Y-axis if not set + const existingSettings = get(plotSettingsStore).blocks[nodeId]; + if (!existingSettings) { + plotSettingsStore.setBlockYAxisScale(nodeId, 'log'); + } + + const processed = processPlot({ + nodeId, + type: 'spectrum', + title: getNodeName(nodeId, 'Spectrum'), + data, + ghostData: getGhostData(nodeId, 'spectrum'), + getTraceSettings: (idx) => plotSettingsStore.getTraceSettings(createTraceId(nodeId, idx)), + blockSettings: plotSettingsStore.getBlockSettings(nodeId), + accentColor + }); + plots.set(nodeId, processed); + } + } + + return plots; +} + +// ============================================================ +// SCHEDULING & SUBSCRIPTIONS +// ============================================================ + +// Track last values to avoid unnecessary processing +let lastResult: SimulationResult | null = null; +let lastHistory: SimulationResult[] = []; +let lastGhostCount = 0; + +/** + * Schedule processing via the render queue + */ +function scheduleProcessing() { + plotRenderQueue.enqueue(queueId, () => { + const simState = get(simulationState); + const settings = get(settingsStore); + const ghostCount = settings.ghostTraces ?? 0; + + const plots = processAllPlots(simState.result, simState.resultHistory, ghostCount); + + internal.set({ + plots, + isStreaming: simState.phase === 'running', + lastUpdateTime: Date.now() + }); + }); +} + +// Subscribe to simulation state changes +simulationState.subscribe((state) => { + if (state.result !== lastResult || state.resultHistory !== lastHistory) { + lastResult = state.result; + lastHistory = state.resultHistory; + scheduleProcessing(); + } +}); + +// Subscribe to ghost trace count changes +settingsStore.subscribe((state) => { + if ((state.ghostTraces ?? 0) !== lastGhostCount) { + lastGhostCount = state.ghostTraces ?? 0; + scheduleProcessing(); + } +}); + +// Subscribe to plot settings changes +plotSettingsStore.subscribe(() => { + // Settings changed, reprocess + scheduleProcessing(); +}); + +// ============================================================ +// PUBLIC API +// ============================================================ + +export const plotDataStore = { + subscribe: internal.subscribe, + + /** + * Get processed data for a specific plot + */ + getPlot(nodeId: string): ProcessedPlot | undefined { + return get(internal).plots.get(nodeId); + }, + + /** + * Get all processed plots as an array + */ + getAllPlots(): ProcessedPlot[] { + return Array.from(get(internal).plots.values()); + }, + + /** + * Check if currently streaming + */ + isStreaming(): boolean { + return get(internal).isStreaming; + } +}; diff --git a/src/lib/plotting/processing/renderQueue.ts b/src/lib/plotting/processing/renderQueue.ts index faf7eaed..664072bf 100644 --- a/src/lib/plotting/processing/renderQueue.ts +++ b/src/lib/plotting/processing/renderQueue.ts @@ -114,3 +114,15 @@ export function createRenderQueue(options: RenderQueueOptions): RenderQueue { } }; } + +// ============================================================ +// SINGLETON INSTANCE +// ============================================================ + +import { RENDER_QUEUE_FPS } from '../core/constants'; + +/** Global render queue for all plot updates */ +export const plotRenderQueue = createRenderQueue({ + fps: RENDER_QUEUE_FPS, + name: 'PlotRenderQueue' +}); From a4650572c47d7ed7b8e10ace33fe6c9e77da691b Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 17 Jan 2026 23:02:00 +0100 Subject: [PATCH 065/656] Add Plotly and SVG renderers for plot visualization --- src/lib/plotting/renderers/index.ts | 16 ++ src/lib/plotting/renderers/plotly.ts | 259 +++++++++++++++++++++++++++ src/lib/plotting/renderers/svg.ts | 137 ++++++++++++++ 3 files changed, 412 insertions(+) create mode 100644 src/lib/plotting/renderers/index.ts create mode 100644 src/lib/plotting/renderers/plotly.ts create mode 100644 src/lib/plotting/renderers/svg.ts diff --git a/src/lib/plotting/renderers/index.ts b/src/lib/plotting/renderers/index.ts new file mode 100644 index 00000000..c59acc3b --- /dev/null +++ b/src/lib/plotting/renderers/index.ts @@ -0,0 +1,16 @@ +/** + * Renderers module exports + */ + +// Plotly renderer +export { + toPlotlyTrace, + toPlotlySpectrumTrace, + toPlotlyLayout, + getBaseLayout, + createEmptyLayout, + PLOTLY_CONFIG +} from './plotly'; + +// SVG renderer +export { toSVGPaths, toSVGPathsLinear, type SVGPathData } from './svg'; diff --git a/src/lib/plotting/renderers/plotly.ts b/src/lib/plotting/renderers/plotly.ts new file mode 100644 index 00000000..5288fc47 --- /dev/null +++ b/src/lib/plotting/renderers/plotly.ts @@ -0,0 +1,259 @@ +/** + * Plotly renderer - converts ProcessedPlot/ProcessedTrace to Plotly format + */ + +import type { ProcessedPlot, ProcessedTrace } from '../core/types'; +import { LINE_DASH_PLOTLY, MARKER_SYMBOL_PLOTLY } from '../core/constants'; +import { getCssVar, formatFrequency } from '../core/utils'; + +// ============================================================ +// PLOTLY CONFIGURATION +// ============================================================ + +/** Common plot configuration */ +export const PLOTLY_CONFIG: Partial = { + responsive: true, + displaylogo: false, + displayModeBar: 'hover', + modeBarButtonsToRemove: ['lasso2d', 'select2d'], + modeBarButtonsToAdd: [], + toImageButtonOptions: { + format: 'svg', + filename: 'pathview_plot', + height: 600, + width: 1000, + scale: 2 + }, + scrollZoom: true +}; + +// ============================================================ +// TRACE CONVERSION +// ============================================================ + +/** + * Convert ProcessedTrace to Plotly ScatterData + * @param trace - Processed trace data + * @param useDecimated - Whether to use decimated data (for previews) + */ +export function toPlotlyTrace( + trace: ProcessedTrace, + useDecimated: boolean = false +): Partial { + const { style, ghost, signalIndex, label } = trace; + const x = useDecimated ? trace.xDecimated : trace.x; + const y = useDecimated ? trace.yDecimated : trace.y; + + // Determine mode based on line/marker settings + const showLines = style.lineStyle !== null; + const showMarkers = style.markerStyle !== null; + let mode: 'lines' | 'markers' | 'lines+markers' = 'lines'; + if (showLines && showMarkers) mode = 'lines+markers'; + else if (showMarkers) mode = 'markers'; + + const plotlyTrace: Partial = { + x, + y, + type: 'scatter', + mode, + name: label, + legendgroup: `signal-${signalIndex}` + }; + + // Ghost trace settings + if (ghost) { + plotlyTrace.opacity = ghost.opacity; + plotlyTrace.showlegend = false; + plotlyTrace.hoverinfo = 'skip'; + } else { + // Main trace hover template + plotlyTrace.hovertemplate = + `${label}
        ` + + `x = %{x:.4g}
        y = %{y:.4g}`; + } + + // Line configuration + if (showLines && style.lineStyle) { + plotlyTrace.line = { + color: style.color, + width: ghost ? 1 : 1.5, + dash: LINE_DASH_PLOTLY[style.lineStyle] as Plotly.Dash + }; + } + + // Marker configuration + if (showMarkers && style.markerStyle) { + plotlyTrace.marker = { + symbol: MARKER_SYMBOL_PLOTLY[style.markerStyle], + size: ghost ? 5 : 6, + color: style.color + }; + plotlyTrace.cliponaxis = false; + } + + return plotlyTrace; +} + +/** + * Convert ProcessedTrace to Plotly ScatterData for spectrum (with custom hover) + * @param trace - Processed trace data + * @param frequencies - Original frequency values for hover labels + */ +export function toPlotlySpectrumTrace( + trace: ProcessedTrace, + frequencies?: number[] +): Partial { + const plotlyTrace = toPlotlyTrace(trace, false); + + // Add frequency customdata for hover + if (!trace.ghost && frequencies) { + plotlyTrace.customdata = frequencies; + plotlyTrace.hovertemplate = + `${trace.label}
        ` + + `f = %{customdata:.2f} Hz
        mag = %{y:.4g}`; + } + + return plotlyTrace; +} + +// ============================================================ +// LAYOUT BUILDING +// ============================================================ + +/** + * Build base layout using CSS variables + * Reads current theme automatically from computed styles + */ +export function getBaseLayout(): Partial { + const textMuted = getCssVar('--text-muted'); + const textDisabled = getCssVar('--text-disabled'); + const surfaceRaised = getCssVar('--surface-raised'); + const border = getCssVar('--border'); + const accent = getCssVar('--accent'); + + return { + paper_bgcolor: 'transparent', + plot_bgcolor: 'transparent', + font: { + color: textMuted, + family: 'Inter, system-ui, sans-serif', + size: 11 + }, + margin: { + l: 60, + r: 15, + t: 10, + b: 45 + }, + xaxis: { + gridcolor: border, + gridwidth: 0.5, + zeroline: false, + linecolor: border, + linewidth: 1.5, + tickfont: { size: 10, color: textDisabled }, + title: { standoff: 10 } + }, + yaxis: { + gridcolor: border, + gridwidth: 0.5, + zeroline: false, + linecolor: border, + linewidth: 1.5, + tickfont: { size: 10, color: textDisabled }, + title: { standoff: 5 } + }, + legend: { + bgcolor: surfaceRaised, + bordercolor: border, + borderwidth: 1, + font: { size: 10, color: textMuted }, + x: 0.01, + xanchor: 'left', + y: 0.99, + yanchor: 'top' + }, + modebar: { + bgcolor: 'transparent', + color: textDisabled, + activecolor: accent + }, + hoverlabel: { + bgcolor: surfaceRaised, + bordercolor: border, + font: { color: textMuted, size: 11 } + } + }; +} + +/** + * Build Plotly layout from ProcessedPlot + */ +export function toPlotlyLayout(plot: ProcessedPlot): Partial { + const { type, title, layout, frequencies } = plot; + const baseLayout = getBaseLayout(); + + // Axis labels + const xAxisTitle = type === 'scope' ? 'Time (s)' : 'Frequency (Hz)'; + const yAxisLabel = type === 'scope' + ? (title.toLowerCase().startsWith('scope') ? title : `Scope: ${title}`) + : (title.toLowerCase().startsWith('spectrum') ? title : `Spectrum: ${title}`); + + const result: Partial = { + ...baseLayout, + xaxis: { + ...baseLayout.xaxis, + title: { text: xAxisTitle, font: { size: 11 }, standoff: 10 }, + type: layout.xAxisScale + }, + yaxis: { + ...baseLayout.yaxis, + title: { text: yAxisLabel, font: { size: 11 }, standoff: 5 }, + type: layout.yAxisScale + }, + showlegend: layout.showLegend, + hovermode: 'closest' as const + }; + + // Spectrum: add frequency tick labels + if (type === 'spectrum' && frequencies && frequencies.length > 0) { + const numTicks = Math.min(6, frequencies.length); + const step = Math.max(1, Math.floor((frequencies.length - 1) / (numTicks - 1))); + const tickvals: number[] = []; + const ticktext: string[] = []; + + for (let i = 0; i < frequencies.length; i += step) { + tickvals.push(i); + ticktext.push(formatFrequency(frequencies[i])); + } + if (tickvals[tickvals.length - 1] !== frequencies.length - 1) { + tickvals.push(frequencies.length - 1); + ticktext.push(formatFrequency(frequencies[frequencies.length - 1])); + } + + result.xaxis = { ...result.xaxis, tickvals, ticktext, tickangle: 0 }; + } + + return result; +} + +/** + * Create an empty plot layout with "No data" annotation + */ +export function createEmptyLayout(baseLayout: Partial): Partial { + const annotationColor = getCssVar('--text-disabled'); + return { + ...baseLayout, + annotations: [ + { + text: 'No data', + xref: 'paper', + yref: 'paper', + x: 0.5, + y: 0.5, + showarrow: false, + font: { size: 14, color: annotationColor } + } + ] + }; +} diff --git a/src/lib/plotting/renderers/svg.ts b/src/lib/plotting/renderers/svg.ts new file mode 100644 index 00000000..d997242c --- /dev/null +++ b/src/lib/plotting/renderers/svg.ts @@ -0,0 +1,137 @@ +/** + * SVG renderer - converts ProcessedPlot to SVG path data for previews + */ + +import type { ProcessedPlot } from '../core/types'; +import { LINE_DASH_SVG, PREVIEW_WIDTH, PREVIEW_HEIGHT, PREVIEW_PADDING } from '../core/constants'; + +// ============================================================ +// SVG PATH DATA TYPE +// ============================================================ + +export interface SVGPathData { + /** SVG path d attribute */ + d: string; + /** Stroke color */ + color: string; + /** Opacity (1 for main traces, <1 for ghosts) */ + opacity: number; + /** Stroke width */ + strokeWidth: number; + /** Stroke dasharray for line style */ + dasharray: string; +} + +// ============================================================ +// SVG PATH GENERATION +// ============================================================ + +/** + * Convert ProcessedPlot to SVG path data for preview rendering + * + * Uses decimated data and pre-computed bounds from the ProcessedPlot + * + * @param plot - Processed plot data + * @param width - SVG width (default: PREVIEW_WIDTH) + * @param height - SVG height (default: PREVIEW_HEIGHT) + * @param padding - Padding inside SVG (default: PREVIEW_PADDING) + */ +export function toSVGPaths( + plot: ProcessedPlot, + width: number = PREVIEW_WIDTH, + height: number = PREVIEW_HEIGHT, + padding: number = PREVIEW_PADDING +): SVGPathData[] { + const { traces, bounds, type } = plot; + + if (traces.length === 0) return []; + + const { xMin, xMax, yMin, yMax } = bounds; + const xRange = xMax - xMin || 1; + const yRange = yMax - yMin || 1; + const plotWidth = width - padding * 2; + const plotHeight = height - padding * 2; + + // For spectrum previews, apply log transform to y values + const isSpectrum = type === 'spectrum'; + + return traces.map((trace) => { + const { xDecimated, yDecimated, style, ghost } = trace; + + // Build SVG path string + const pathPoints: string[] = []; + + for (let i = 0; i < xDecimated.length; i++) { + const xVal = xDecimated[i]; + // For spectrum, apply log transform to y values for preview + let yVal = yDecimated[i]; + if (isSpectrum && yVal > 0) { + // Apply log scale for visualization + const logYMin = yMin > 0 ? Math.log10(yMin) : -10; + const logYMax = yMax > 0 ? Math.log10(yMax) : 0; + const logYVal = Math.log10(yVal); + const logRange = logYMax - logYMin || 1; + yVal = logYMin + ((logYVal - logYMin) / logRange) * (yMax - yMin) + yMin; + } + + const x = padding + ((xVal - xMin) / xRange) * plotWidth; + const y = height - padding - ((yVal - yMin) / yRange) * plotHeight; + + // Clamp to visible area + const clampedX = Math.max(padding, Math.min(width - padding, x)); + const clampedY = Math.max(padding, Math.min(height - padding, y)); + + pathPoints.push(`${i === 0 ? 'M' : 'L'}${clampedX.toFixed(1)},${clampedY.toFixed(1)}`); + } + + return { + d: pathPoints.join(' '), + color: style.color, + opacity: ghost?.opacity ?? 1, + strokeWidth: ghost ? 0.7 : 1, + dasharray: style.lineStyle ? LINE_DASH_SVG[style.lineStyle] : '' + }; + }); +} + +/** + * Convert ProcessedPlot to SVG paths using linear y-scale + * (Simpler version without log transform for scope data) + */ +export function toSVGPathsLinear( + plot: ProcessedPlot, + width: number = PREVIEW_WIDTH, + height: number = PREVIEW_HEIGHT, + padding: number = PREVIEW_PADDING +): SVGPathData[] { + const { traces, bounds } = plot; + + if (traces.length === 0) return []; + + const { xMin, xMax, yMin, yMax } = bounds; + const xRange = xMax - xMin || 1; + const yRange = yMax - yMin || 1; + const plotWidth = width - padding * 2; + const plotHeight = height - padding * 2; + + return traces.map((trace) => { + const { xDecimated, yDecimated, style, ghost } = trace; + + // Build SVG path string + const pathPoints: string[] = []; + + for (let i = 0; i < xDecimated.length; i++) { + const x = padding + ((xDecimated[i] - xMin) / xRange) * plotWidth; + const y = height - padding - ((yDecimated[i] - yMin) / yRange) * plotHeight; + pathPoints.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`); + } + + return { + d: pathPoints.join(' '), + color: style.color, + opacity: ghost?.opacity ?? 1, + strokeWidth: ghost ? 0.7 : 1, + dasharray: style.lineStyle ? LINE_DASH_SVG[style.lineStyle] : '' + }; + }); +} From 18a29eeb333b83ef78f5746d6fd056be602cbb11 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 17 Jan 2026 23:03:55 +0100 Subject: [PATCH 066/656] Migrate SignalPlot and PlotPanel to use centralized plot data store --- src/lib/components/panels/PlotPanel.svelte | 134 +------- src/lib/components/panels/SignalPlot.svelte | 362 ++++++-------------- 2 files changed, 122 insertions(+), 374 deletions(-) diff --git a/src/lib/components/panels/PlotPanel.svelte b/src/lib/components/panels/PlotPanel.svelte index 63f8dea6..1afa9bae 100644 --- a/src/lib/components/panels/PlotPanel.svelte +++ b/src/lib/components/panels/PlotPanel.svelte @@ -1,9 +1,8 @@
        From deff53c5386b4251acda4dda907d9165365c525b Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 17 Jan 2026 23:05:13 +0100 Subject: [PATCH 067/656] Migrate PlotPreview and BaseNode to use centralized plot data store --- src/lib/components/nodes/BaseNode.svelte | 33 +-- src/lib/components/nodes/PlotPreview.svelte | 312 ++------------------ 2 files changed, 32 insertions(+), 313 deletions(-) diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index 76942f4e..de8c8c15 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -10,7 +10,7 @@ import { hoveredHandle, selectedNodeHighlight } from '$lib/stores/hoveredHandle'; import { showTooltip, hideTooltip } from '$lib/components/Tooltip.svelte'; import { paramInput } from '$lib/actions/paramInput'; - import { createRecordingDataState } from '$lib/stores/recordingData.svelte'; + import { plotDataStore } from '$lib/plotting/processing/plotDataStore'; import PlotPreview from './PlotPreview.svelte'; interface Props { @@ -32,39 +32,26 @@ let hasPreloaded = $state(false); // Keep mounted once preloaded let showPreview = $state(false); // Control visibility let previewsPinned = $state(false); - + let hasPlotData = $state(false); const unsubscribePinned = pinnedPreviewsStore.subscribe((pinned) => { previewsPinned = pinned; }); - // Shared recording data state (simulation results, ghost traces, etc.) - const recordingData = createRecordingDataState(); - - // Preview is loading when simulation is starting/running - const previewLoading = $derived(recordingData.simPhase === 'starting' || recordingData.simPhase === 'running'); + // Check if this node has plot data (from centralized store) + const unsubscribePlotData = plotDataStore.subscribe((state) => { + hasPlotData = state.plots.has(id); + }); onDestroy(() => { unsubscribePinned(); - recordingData.destroy(); + unsubscribePlotData(); if (hoverTimeout) clearTimeout(hoverTimeout); }); - // Get plot data for this recording node - const plotData = $derived(() => { - if (!isRecordingNode) return null; - return recordingData.getPlotData(id, data.type); - }); - - // Get ghost data for this recording node - const ghostPlotData = $derived(() => { - if (!isRecordingNode) return []; - return recordingData.getGhostData(id, data.type); - }); - // Sync hasPreloaded when pinned (so unpinning keeps cache) $effect(() => { - if (previewsPinned && plotData()) { + if (previewsPinned && hasPlotData) { hasPreloaded = true; } }); @@ -305,12 +292,12 @@ onmouseleave={handleMouseLeave} > - {#if (hasPreloaded || previewsPinned) && plotData()} + {#if (hasPreloaded || previewsPinned) && hasPlotData}
        - +
        {/if} diff --git a/src/lib/components/nodes/PlotPreview.svelte b/src/lib/components/nodes/PlotPreview.svelte index 83cf6fe6..6f638cbf 100644 --- a/src/lib/components/nodes/PlotPreview.svelte +++ b/src/lib/components/nodes/PlotPreview.svelte @@ -1,307 +1,39 @@
        @@ -314,7 +46,7 @@ /> {#if hasData()} - {#each cachedPaths as path} + {#each paths as path} Date: Sat, 17 Jan 2026 23:07:38 +0100 Subject: [PATCH 068/656] Clean up old plot files and add unified plotting module index --- PLOT_REFACTOR_PLAN.md | 1295 ----------------- .../dialogs/PlotOptionsDialog.svelte | 4 +- src/lib/components/nodes/previewQueue.ts | 67 - src/lib/components/panels/plotQueue.ts | 80 - src/lib/plotting/index.ts | 62 + src/lib/plotting/plotUtils.ts | 467 ------ 6 files changed, 64 insertions(+), 1911 deletions(-) delete mode 100644 PLOT_REFACTOR_PLAN.md delete mode 100644 src/lib/components/nodes/previewQueue.ts delete mode 100644 src/lib/components/panels/plotQueue.ts create mode 100644 src/lib/plotting/index.ts delete mode 100644 src/lib/plotting/plotUtils.ts diff --git a/PLOT_REFACTOR_PLAN.md b/PLOT_REFACTOR_PLAN.md deleted file mode 100644 index c5e9de3a..00000000 --- a/PLOT_REFACTOR_PLAN.md +++ /dev/null @@ -1,1295 +0,0 @@ -# Plot Infrastructure Refactoring Plan - -## Goal -Separate data processing from rendering to create a unified, maintainable, and high-performance plotting system. - ---- - -## Current Architecture - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ SimulationState │ -│ result: { scopeData, spectrumData, nodeNames } │ -│ resultHistory: SimulationResult[] │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ PlotPanel.svelte │ -│ - Derives scopePlots[], spectrumPlots[] from result │ -│ - Derives ghostDataMaps from resultHistory │ -│ - Passes raw data to SignalPlot components │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ┌───────────────────┴───────────────────┐ - ▼ ▼ -┌─────────────────────────┐ ┌─────────────────────────┐ -│ SignalPlot.svelte │ │ PlotPreview.svelte │ -│ - Subscribes settings │ │ - Subscribes settings │ -│ - Resolves styles │ │ - Resolves styles │ -│ - Checks visibility │ │ - Checks visibility │ -│ - plotQueue (15fps) │ │ - previewQueue (10fps) │ -│ - Creates Plotly traces │ │ - Decimates data │ -│ - Renders via Plotly │ │ - Computes SVG paths │ -└─────────────────────────┘ └─────────────────────────┘ -``` - -### Problems -1. **Duplicated logic**: Style resolution, visibility checks, ghost opacity in both components -2. **Two queues**: Different tick rates, duplicated queue implementation -3. **Coupled processing & rendering**: Can't reuse processed data -4. **Inconsistencies**: Different ghost opacity ranges, color handling differences - ---- - -## Target Architecture - -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ SimulationState │ -│ result: { scopeData, spectrumData, nodeNames } │ -│ resultHistory: SimulationResult[] │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ plotDataStore (NEW) │ -│ - Subscribes to simulationState + plotSettingsStore │ -│ - Single renderQueue (configurable fps) │ -│ - Processes all plots: style resolution, visibility, decimation │ -│ - Outputs: Map │ -└─────────────────────────────────────────────────────────────────────────┘ - │ - ┌───────────────────┴───────────────────┐ - ▼ ▼ -┌─────────────────────────┐ ┌─────────────────────────┐ -│ SignalPlot.svelte │ │ PlotPreview.svelte │ -│ - Receives processed │ │ - Receives processed │ -│ data as prop │ │ data as prop │ -│ - Maps to Plotly format │ │ - Maps to SVG paths │ -│ - Renders (no queue) │ │ - Renders (no queue) │ -└─────────────────────────┘ └─────────────────────────┘ -``` - ---- - -## New File Structure - -``` -src/lib/plotting/ -├── core/ -│ ├── types.ts # All shared types and interfaces -│ ├── constants.ts # Colors, defaults, configuration -│ └── utils.ts # Pure utility functions -│ -├── processing/ -│ ├── renderQueue.ts # Single unified queue (factory) -│ ├── dataProcessor.ts # Data processing logic -│ └── plotDataStore.ts # Reactive store combining everything -│ -├── renderers/ -│ ├── plotly.ts # Plotly trace/layout builders -│ └── svg.ts # SVG path generation -│ -└── index.ts # Public API exports -``` - ---- - -## Phase 1: Core Types & Constants - -### 1.1 Create `src/lib/plotting/core/types.ts` - -```typescript -// ============================================================ -// STYLE TYPES (moved from plotSettings.ts) -// ============================================================ - -export type LineStyle = 'solid' | 'dash' | 'dot'; -export type MarkerStyle = 'circle' | 'square' | 'triangle-up'; -export type AxisScale = 'linear' | 'log'; - -export interface TraceStyle { - lineStyle: LineStyle | null; - markerStyle: MarkerStyle | null; - color: string; - visible: boolean; -} - -export interface LayoutStyle { - xAxisScale: AxisScale; - yAxisScale: AxisScale; - showLegend: boolean; -} - -// ============================================================ -// RAW DATA TYPES (input from simulation) -// ============================================================ - -export interface RawScopeData { - time: number[]; - signals: number[][]; - labels?: string[]; -} - -export interface RawSpectrumData { - frequency: number[]; - magnitude: number[][]; - labels?: string[]; -} - -export type RawPlotData = RawScopeData | RawSpectrumData; - -// ============================================================ -// PROCESSED DATA TYPES (output from processor) -// ============================================================ - -export interface ProcessedTrace { - // Identity - signalIndex: number; - label: string; - - // Data (full resolution for Plotly) - x: number[]; - y: number[]; - - // Data (decimated for previews) - xDecimated: number[]; - yDecimated: number[]; - - // Resolved style - style: TraceStyle; - - // Ghost properties (null for main traces) - ghost: { - index: number; // 0 = most recent ghost - total: number; // total ghost count - opacity: number; // pre-calculated opacity - } | null; -} - -export interface ProcessedPlot { - // Identity - nodeId: string; - type: 'scope' | 'spectrum'; - title: string; - - // All traces (ghosts first, then main) - traces: ProcessedTrace[]; - - // Layout settings - layout: LayoutStyle; - - // Pre-computed bounds (for consistent axis scaling) - bounds: { - xMin: number; - xMax: number; - yMin: number; - yMax: number; - }; - - // Spectrum-specific (for tick labels) - frequencies?: number[]; -} - -// ============================================================ -// STORE STATE TYPE -// ============================================================ - -export interface PlotDataState { - plots: Map; // keyed by nodeId - isStreaming: boolean; - lastUpdateTime: number; -} -``` - -### 1.2 Create `src/lib/plotting/core/constants.ts` - -```typescript -// ============================================================ -// RENDER QUEUE CONFIGURATION -// ============================================================ - -export const RENDER_QUEUE_FPS = 15; // Single tick rate for all rendering -export const RENDER_QUEUE_INTERVAL = 1000 / RENDER_QUEUE_FPS; - -// ============================================================ -// DECIMATION CONFIGURATION -// ============================================================ - -export const PREVIEW_TARGET_POINTS = 400; // ~800 points after min-max - -// ============================================================ -// GHOST TRACE CONFIGURATION -// ============================================================ - -export const GHOST_OPACITY_MAX = 0.5; // Most recent ghost -export const GHOST_OPACITY_MIN = 0.2; // Oldest ghost - -export function calculateGhostOpacity(ghostIndex: number, totalGhosts: number): number { - if (totalGhosts === 1) return GHOST_OPACITY_MAX; - const range = GHOST_OPACITY_MAX - GHOST_OPACITY_MIN; - return GHOST_OPACITY_MAX - (ghostIndex / (totalGhosts - 1)) * range; -} - -// ============================================================ -// COLORS -// ============================================================ - -export const TRACE_COLORS = [ - '#E57373', // Red - '#81C784', // Green - '#64B5F6', // Blue - '#BA68C8', // Purple - '#4DD0E1', // Cyan - '#FFB74D', // Orange - '#F06292', // Pink - '#4DB6AC', // Teal - '#90A4AE', // Grey -]; - -export function getTraceColor(index: number, accentColor: string): string { - if (index === 0) return accentColor; - return TRACE_COLORS[(index - 1) % TRACE_COLORS.length]; -} - -// ============================================================ -// LINE DASH PATTERNS -// ============================================================ - -import type { LineStyle } from './types'; - -export const LINE_DASH_PLOTLY: Record = { - solid: 'solid', - dash: 'dash', - dot: 'dot', -}; - -export const LINE_DASH_SVG: Record = { - solid: '', - dash: '6,3', - dot: '2,2', -}; - -// ============================================================ -// MARKER SYMBOLS -// ============================================================ - -import type { MarkerStyle } from './types'; - -export const MARKER_SYMBOL_PLOTLY: Record = { - circle: 'circle', - square: 'square', - 'triangle-up': 'triangle-up', -}; -``` - -### 1.3 Create `src/lib/plotting/core/utils.ts` - -```typescript -import type { TraceStyle } from './types'; - -/** - * Check if a trace should be rendered (has line or marker) - */ -export function isTraceVisible(style: TraceStyle): boolean { - return style.lineStyle !== null || style.markerStyle !== null; -} - -/** - * Min-max decimation: preserves peaks and valleys - * O(n) single pass, outputs ~2*buckets points - */ -export function decimateMinMax( - x: number[], - y: number[], - targetBuckets: number -): { x: number[]; y: number[]; xMin: number; xMax: number; yMin: number; yMax: number } { - const len = x.length; - - if (len === 0) { - return { x: [], y: [], xMin: 0, xMax: 1, yMin: 0, yMax: 1 }; - } - - // If data is small enough, return as-is with bounds - if (len <= targetBuckets * 2) { - let yMin = y[0], yMax = y[0]; - for (let i = 1; i < len; i++) { - if (y[i] < yMin) yMin = y[i]; - if (y[i] > yMax) yMax = y[i]; - } - return { x, y, xMin: x[0], xMax: x[len - 1], yMin, yMax }; - } - - const bucketSize = len / targetBuckets; - const outX: number[] = []; - const outY: number[] = []; - let globalYMin = Infinity, globalYMax = -Infinity; - - for (let bucket = 0; bucket < targetBuckets; bucket++) { - const startIdx = Math.floor(bucket * bucketSize); - const endIdx = Math.floor((bucket + 1) * bucketSize); - - let minIdx = startIdx, maxIdx = startIdx; - let minVal = y[startIdx], maxVal = y[startIdx]; - - for (let i = startIdx + 1; i < endIdx && i < len; i++) { - if (y[i] < minVal) { minVal = y[i]; minIdx = i; } - if (y[i] > maxVal) { maxVal = y[i]; maxIdx = i; } - } - - // Add in chronological order - if (minIdx <= maxIdx) { - outX.push(x[minIdx]); outY.push(y[minIdx]); - if (maxIdx !== minIdx) { outX.push(x[maxIdx]); outY.push(y[maxIdx]); } - } else { - outX.push(x[maxIdx]); outY.push(y[maxIdx]); - outX.push(x[minIdx]); outY.push(y[minIdx]); - } - - if (minVal < globalYMin) globalYMin = minVal; - if (maxVal > globalYMax) globalYMax = maxVal; - } - - // Always include last point - if (outX[outX.length - 1] !== x[len - 1]) { - outX.push(x[len - 1]); - outY.push(y[len - 1]); - } - - return { - x: outX, - y: outY, - xMin: x[0], - xMax: x[len - 1], - yMin: globalYMin, - yMax: globalYMax, - }; -} - -/** - * Compute bounds from multiple data arrays - */ -export function computeBounds( - dataArrays: { x: number[]; y: number[] }[] -): { xMin: number; xMax: number; yMin: number; yMax: number } { - let xMin = Infinity, xMax = -Infinity; - let yMin = Infinity, yMax = -Infinity; - - for (const { x, y } of dataArrays) { - for (let i = 0; i < x.length; i++) { - if (x[i] < xMin) xMin = x[i]; - if (x[i] > xMax) xMax = x[i]; - if (y[i] < yMin) yMin = y[i]; - if (y[i] > yMax) yMax = y[i]; - } - } - - // Handle empty/invalid bounds - if (!isFinite(xMin)) xMin = 0; - if (!isFinite(xMax)) xMax = 1; - if (!isFinite(yMin)) yMin = 0; - if (!isFinite(yMax)) yMax = 1; - - return { xMin, xMax, yMin, yMax }; -} -``` - ---- - -## Phase 2: Unified Render Queue - -### 2.1 Create `src/lib/plotting/processing/renderQueue.ts` - -```typescript -type RenderTask = () => void; - -interface RenderQueueOptions { - fps: number; - name?: string; // For debugging -} - -interface RenderQueue { - enqueue: (id: symbol, task: RenderTask) => void; - cancel: (id: symbol) => void; - isVisible: () => boolean; - destroy: () => void; -} - -/** - * Factory function to create a render queue with configurable FPS - */ -export function createRenderQueue(options: RenderQueueOptions): RenderQueue { - const { fps, name = 'RenderQueue' } = options; - const minInterval = 1000 / fps; - - const taskQueue = new Map(); - let rafId: number | null = null; - let lastProcessTime = 0; - let visible = true; - - function handleVisibilityChange() { - visible = document.visibilityState === 'visible'; - if (visible && taskQueue.size > 0 && rafId === null) { - scheduleProcess(); - } - } - - if (typeof document !== 'undefined') { - document.addEventListener('visibilitychange', handleVisibilityChange); - } - - function scheduleProcess() { - if (rafId !== null) return; - rafId = requestAnimationFrame(process); - } - - function process(timestamp: number) { - rafId = null; - - if (!visible || taskQueue.size === 0) return; - - // Throttle to target FPS - if (timestamp - lastProcessTime < minInterval) { - scheduleProcess(); - return; - } - - lastProcessTime = timestamp; - - // Process all queued tasks in one batch - const tasks = Array.from(taskQueue.values()); - taskQueue.clear(); - - for (const task of tasks) { - try { - task(); - } catch (e) { - console.error(`[${name}] Task error:`, e); - } - } - - // If new tasks were added during processing, schedule again - if (taskQueue.size > 0) { - scheduleProcess(); - } - } - - return { - enqueue(id: symbol, task: RenderTask) { - taskQueue.set(id, task); - if (visible) scheduleProcess(); - }, - - cancel(id: symbol) { - taskQueue.delete(id); - }, - - isVisible() { - return visible; - }, - - destroy() { - if (rafId !== null) { - cancelAnimationFrame(rafId); - rafId = null; - } - taskQueue.clear(); - if (typeof document !== 'undefined') { - document.removeEventListener('visibilitychange', handleVisibilityChange); - } - }, - }; -} - -// Singleton instance for the application -import { RENDER_QUEUE_FPS } from '../core/constants'; - -export const plotRenderQueue = createRenderQueue({ - fps: RENDER_QUEUE_FPS, - name: 'PlotRenderQueue', -}); -``` - ---- - -## Phase 3: Data Processor - -### 3.1 Create `src/lib/plotting/processing/dataProcessor.ts` - -```typescript -import type { - RawScopeData, - RawSpectrumData, - ProcessedPlot, - ProcessedTrace, - TraceStyle, - LayoutStyle, -} from '../core/types'; -import { - calculateGhostOpacity, - getTraceColor, - PREVIEW_TARGET_POINTS, -} from '../core/constants'; -import { decimateMinMax, computeBounds, isTraceVisible } from '../core/utils'; -import type { TraceSettings, BlockSettings } from '$lib/stores/plotSettings'; - -interface ProcessPlotOptions { - nodeId: string; - type: 'scope' | 'spectrum'; - title: string; - data: RawScopeData | RawSpectrumData | null; - ghostData: (RawScopeData | RawSpectrumData)[]; - traceSettings: (signalIndex: number) => TraceSettings; - blockSettings: BlockSettings; - accentColor: string; -} - -/** - * Process a single plot's data into render-ready format - */ -export function processPlot(options: ProcessPlotOptions): ProcessedPlot { - const { - nodeId, - type, - title, - data, - ghostData, - traceSettings, - blockSettings, - accentColor, - } = options; - - const traces: ProcessedTrace[] = []; - const allDataForBounds: { x: number[]; y: number[] }[] = []; - const totalGhosts = ghostData.length; - - // Helper to extract x/y arrays from raw data - function extractXY(raw: RawScopeData | RawSpectrumData): { x: number[]; ys: number[][]; labels?: string[] } { - if (type === 'scope') { - const d = raw as RawScopeData; - return { x: d.time || [], ys: d.signals || [], labels: d.labels }; - } else { - const d = raw as RawSpectrumData; - // Use indices for x-axis (equal spacing) - const x = d.frequency ? Array.from({ length: d.frequency.length }, (_, i) => i) : []; - return { x, ys: d.magnitude || [], labels: d.labels }; - } - } - - // Helper to create a processed trace - function createTrace( - signalIndex: number, - x: number[], - y: number[], - label: string, - ghostInfo: ProcessedTrace['ghost'] - ): ProcessedTrace | null { - const settings = traceSettings(signalIndex); - const color = getTraceColor(signalIndex, accentColor); - - const style: TraceStyle = { - lineStyle: settings.lineStyle, - markerStyle: settings.markerStyle, - color, - visible: settings.lineStyle !== null || settings.markerStyle !== null, - }; - - // Skip invisible traces - if (!style.visible) return null; - - // Decimate for preview - const decimated = decimateMinMax(x, y, PREVIEW_TARGET_POINTS); - - return { - signalIndex, - label, - x, - y, - xDecimated: decimated.x, - yDecimated: decimated.y, - style, - ghost: ghostInfo, - }; - } - - // Process ghost data (oldest to newest, so newest renders on top) - for (let ghostIdx = totalGhosts - 1; ghostIdx >= 0; ghostIdx--) { - const ghost = ghostData[ghostIdx]; - if (!ghost) continue; - - const { x, ys, labels } = extractXY(ghost); - if (x.length === 0) continue; - - const opacity = calculateGhostOpacity(ghostIdx, totalGhosts); - - for (let sigIdx = 0; sigIdx < ys.length; sigIdx++) { - const y = ys[sigIdx]; - if (!y || y.length === 0) continue; - - const label = labels?.[sigIdx] ?? `port ${sigIdx}`; - const trace = createTrace(sigIdx, x, y, label, { - index: ghostIdx, - total: totalGhosts, - opacity, - }); - - if (trace) { - traces.push(trace); - allDataForBounds.push({ x, y }); - } - } - } - - // Process main data - if (data) { - const { x, ys, labels } = extractXY(data); - if (x.length > 0) { - for (let sigIdx = 0; sigIdx < ys.length; sigIdx++) { - const y = ys[sigIdx]; - if (!y || y.length === 0) continue; - - const label = labels?.[sigIdx] ?? `port ${sigIdx}`; - const trace = createTrace(sigIdx, x, y, label, null); - - if (trace) { - traces.push(trace); - allDataForBounds.push({ x, y }); - } - } - } - } - - // Compute bounds from all data - const bounds = computeBounds(allDataForBounds); - - // Extract frequencies for spectrum tick labels - let frequencies: number[] | undefined; - if (type === 'spectrum' && data) { - frequencies = (data as RawSpectrumData).frequency; - } - - return { - nodeId, - type, - title, - traces, - layout: { - xAxisScale: blockSettings.xAxisScale, - yAxisScale: blockSettings.yAxisScale, - showLegend: blockSettings.showLegend, - }, - bounds, - frequencies, - }; -} -``` - ---- - -## Phase 4: Plot Data Store - -### 4.1 Create `src/lib/plotting/processing/plotDataStore.ts` - -```typescript -import { writable, derived, get } from 'svelte/store'; -import { simulationState, type SimulationResult } from '$lib/pyodide/bridge'; -import { plotSettingsStore } from '$lib/stores/plotSettings'; -import { settingsStore } from '$lib/stores/settings'; -import { processPlot } from './dataProcessor'; -import { plotRenderQueue } from './renderQueue'; -import type { ProcessedPlot, PlotDataState } from '../core/types'; - -// Internal state -const internal = writable({ - plots: new Map(), - isStreaming: false, - lastUpdateTime: 0, -}); - -// Queue ID for this store -const queueId = Symbol('plotDataStore'); - -// Cached CSS variable for accent color -let accentColor = '#0070C0'; -function updateAccentColor() { - if (typeof document !== 'undefined') { - accentColor = getComputedStyle(document.documentElement) - .getPropertyValue('--accent').trim() || '#0070C0'; - } -} - -// Process all plots from current simulation state -function processAllPlots( - result: SimulationResult | null, - resultHistory: SimulationResult[], - ghostTraceCount: number -): Map { - const plots = new Map(); - - if (!result) return plots; - - updateAccentColor(); - - // Helper to get node name - const getNodeName = (id: string, fallback: string) => - result.nodeNames?.[id] || fallback; - - // Helper to get ghost data for a node - const getGhostData = (nodeId: string, type: 'scope' | 'spectrum') => { - const history = resultHistory.slice(0, ghostTraceCount); - return history - .map(r => type === 'scope' ? r.scopeData?.[nodeId] : r.spectrumData?.[nodeId]) - .filter(Boolean); - }; - - // Process scope plots - if (result.scopeData) { - for (const [nodeId, data] of Object.entries(result.scopeData)) { - const processed = processPlot({ - nodeId, - type: 'scope', - title: getNodeName(nodeId, 'Scope'), - data, - ghostData: getGhostData(nodeId, 'scope'), - traceSettings: (idx) => plotSettingsStore.getTraceSettings(`${nodeId}-${idx}`), - blockSettings: plotSettingsStore.getBlockSettings(nodeId), - accentColor, - }); - plots.set(nodeId, processed); - } - } - - // Process spectrum plots - if (result.spectrumData) { - for (const [nodeId, data] of Object.entries(result.spectrumData)) { - // Initialize spectrum blocks with log Y-axis if not set - const existingSettings = get(plotSettingsStore).blocks[nodeId]; - if (!existingSettings) { - plotSettingsStore.setBlockYAxisScale(nodeId, 'log'); - } - - const processed = processPlot({ - nodeId, - type: 'spectrum', - title: getNodeName(nodeId, 'Spectrum'), - data, - ghostData: getGhostData(nodeId, 'spectrum'), - traceSettings: (idx) => plotSettingsStore.getTraceSettings(`${nodeId}-${idx}`), - blockSettings: plotSettingsStore.getBlockSettings(nodeId), - accentColor, - }); - plots.set(nodeId, processed); - } - } - - return plots; -} - -// Schedule processing when inputs change -let lastResult: SimulationResult | null = null; -let lastHistory: SimulationResult[] = []; -let lastGhostCount = 0; -let lastSettingsVersion = 0; - -function scheduleProcessing() { - plotRenderQueue.enqueue(queueId, () => { - const simState = get(simulationState); - const settings = get(settingsStore); - const ghostCount = settings.ghostTraces ?? 0; - - const plots = processAllPlots( - simState.result, - simState.resultHistory, - ghostCount - ); - - internal.set({ - plots, - isStreaming: simState.phase === 'running', - lastUpdateTime: Date.now(), - }); - }); -} - -// Subscribe to all input sources -simulationState.subscribe((state) => { - if (state.result !== lastResult || state.resultHistory !== lastHistory) { - lastResult = state.result; - lastHistory = state.resultHistory; - scheduleProcessing(); - } -}); - -settingsStore.subscribe((state) => { - if ((state.ghostTraces ?? 0) !== lastGhostCount) { - lastGhostCount = state.ghostTraces ?? 0; - scheduleProcessing(); - } -}); - -plotSettingsStore.subscribe(() => { - // Settings changed, reprocess - scheduleProcessing(); -}); - -// Public API -export const plotDataStore = { - subscribe: internal.subscribe, - - /** - * Get processed data for a specific plot - */ - getPlot(nodeId: string): ProcessedPlot | undefined { - return get(internal).plots.get(nodeId); - }, - - /** - * Get all processed plots as an array - */ - getAllPlots(): ProcessedPlot[] { - return Array.from(get(internal).plots.values()); - }, - - /** - * Check if currently streaming - */ - isStreaming(): boolean { - return get(internal).isStreaming; - }, -}; -``` - ---- - -## Phase 5: Renderers - -### 5.1 Create `src/lib/plotting/renderers/plotly.ts` - -```typescript -import type { ProcessedPlot, ProcessedTrace } from '../core/types'; -import { LINE_DASH_PLOTLY, MARKER_SYMBOL_PLOTLY } from '../core/constants'; - -/** - * Convert ProcessedTrace to Plotly ScatterData - */ -export function toPlotlyTrace( - trace: ProcessedTrace, - useDecimated: boolean = false -): Partial { - const { style, ghost, signalIndex, label } = trace; - const x = useDecimated ? trace.xDecimated : trace.x; - const y = useDecimated ? trace.yDecimated : trace.y; - - // Determine mode - const showLines = style.lineStyle !== null; - const showMarkers = style.markerStyle !== null; - let mode: 'lines' | 'markers' | 'lines+markers' = 'lines'; - if (showLines && showMarkers) mode = 'lines+markers'; - else if (showMarkers) mode = 'markers'; - - const plotlyTrace: Partial = { - x, - y, - type: 'scatter', - mode, - name: label, - legendgroup: `signal-${signalIndex}`, - }; - - // Ghost traces - if (ghost) { - plotlyTrace.opacity = ghost.opacity; - plotlyTrace.showlegend = false; - plotlyTrace.hoverinfo = 'skip'; - } else { - // Main trace hover template - plotlyTrace.hovertemplate = - `${label}
        ` + - `x = %{x:.4g}
        y = %{y:.4g}`; - } - - // Line config - if (showLines && style.lineStyle) { - plotlyTrace.line = { - color: style.color, - width: ghost ? 1 : 1.5, - dash: LINE_DASH_PLOTLY[style.lineStyle], - }; - } - - // Marker config - if (showMarkers && style.markerStyle) { - plotlyTrace.marker = { - symbol: MARKER_SYMBOL_PLOTLY[style.markerStyle], - size: ghost ? 5 : 6, - color: style.color, - }; - plotlyTrace.cliponaxis = false; - } - - return plotlyTrace; -} - -/** - * Build Plotly layout from ProcessedPlot - */ -export function toPlotlyLayout( - plot: ProcessedPlot, - baseLayout: Partial -): Partial { - const { type, title, layout, frequencies } = plot; - - const xAxisTitle = type === 'scope' ? 'Time (s)' : 'Frequency (Hz)'; - const yAxisTitle = title; - - const result: Partial = { - ...baseLayout, - xaxis: { - ...baseLayout.xaxis, - title: { text: xAxisTitle, font: { size: 11 }, standoff: 10 }, - type: layout.xAxisScale, - }, - yaxis: { - ...baseLayout.yaxis, - title: { text: yAxisTitle, font: { size: 11 }, standoff: 5 }, - type: layout.yAxisScale, - }, - showlegend: layout.showLegend, - hovermode: 'closest', - }; - - // Spectrum: add frequency tick labels - if (type === 'spectrum' && frequencies && frequencies.length > 0) { - const numTicks = Math.min(6, frequencies.length); - const step = Math.max(1, Math.floor((frequencies.length - 1) / (numTicks - 1))); - const tickvals: number[] = []; - const ticktext: string[] = []; - - for (let i = 0; i < frequencies.length; i += step) { - tickvals.push(i); - ticktext.push(formatFrequency(frequencies[i])); - } - if (tickvals[tickvals.length - 1] !== frequencies.length - 1) { - tickvals.push(frequencies.length - 1); - ticktext.push(formatFrequency(frequencies[frequencies.length - 1])); - } - - result.xaxis = { ...result.xaxis, tickvals, ticktext, tickangle: 0 }; - } - - return result; -} - -function formatFrequency(freq: number): string { - if (freq >= 1e6) return (freq / 1e6).toFixed(1) + 'M'; - if (freq >= 1e3) return (freq / 1e3).toFixed(1) + 'k'; - if (freq >= 1) return freq.toFixed(1); - return freq.toExponential(1); -} -``` - -### 5.2 Create `src/lib/plotting/renderers/svg.ts` - -```typescript -import type { ProcessedPlot, ProcessedTrace } from '../core/types'; -import { LINE_DASH_SVG } from '../core/constants'; - -export interface SVGPathData { - d: string; - color: string; - opacity: number; - strokeWidth: number; - dasharray: string; -} - -/** - * Convert ProcessedPlot to SVG path data for preview rendering - */ -export function toSVGPaths( - plot: ProcessedPlot, - width: number, - height: number, - padding: number -): SVGPathData[] { - const { traces, bounds } = plot; - - if (traces.length === 0) return []; - - const { xMin, xMax, yMin, yMax } = bounds; - const xRange = xMax - xMin || 1; - const yRange = yMax - yMin || 1; - const plotWidth = width - padding * 2; - const plotHeight = height - padding * 2; - - return traces.map((trace) => { - const { xDecimated, yDecimated, style, ghost } = trace; - - // Build SVG path - const pathPoints: string[] = []; - for (let i = 0; i < xDecimated.length; i++) { - const x = padding + ((xDecimated[i] - xMin) / xRange) * plotWidth; - const y = height - padding - ((yDecimated[i] - yMin) / yRange) * plotHeight; - pathPoints.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`); - } - - return { - d: pathPoints.join(' '), - color: style.color, - opacity: ghost?.opacity ?? 1, - strokeWidth: ghost ? 0.7 : 1, - dasharray: style.lineStyle ? LINE_DASH_SVG[style.lineStyle] : '', - }; - }); -} -``` - ---- - -## Phase 6: Update Components - -### 6.1 Simplify SignalPlot.svelte - -```svelte - - -
        -
        -
        -``` - -### 6.2 Simplify PlotPreview.svelte - -```svelte - - -
        - - - - {#if hasData} - {#each paths as path} - - {/each} - {:else} - - No data - - {/if} - -
        -``` - ---- - -## Phase 7: Migration Steps - -### Step 1: Create Core Module (non-breaking) -- [ ] Create `src/lib/plotting/core/types.ts` -- [ ] Create `src/lib/plotting/core/constants.ts` -- [ ] Create `src/lib/plotting/core/utils.ts` -- [ ] Create `src/lib/plotting/index.ts` with exports -- [ ] Verify build passes - -### Step 2: Create Unified Queue (non-breaking) -- [ ] Create `src/lib/plotting/processing/renderQueue.ts` -- [ ] Add tests for queue behavior -- [ ] Verify build passes - -### Step 3: Create Data Processor (non-breaking) -- [ ] Create `src/lib/plotting/processing/dataProcessor.ts` -- [ ] Add tests for data processing -- [ ] Verify build passes - -### Step 4: Create Plot Data Store (non-breaking) -- [ ] Create `src/lib/plotting/processing/plotDataStore.ts` -- [ ] Verify store reacts to simulation state changes -- [ ] Verify build passes - -### Step 5: Create Renderers (non-breaking) -- [ ] Create `src/lib/plotting/renderers/plotly.ts` -- [ ] Create `src/lib/plotting/renderers/svg.ts` -- [ ] Verify build passes - -### Step 6: Migrate SignalPlot (breaking) -- [ ] Update SignalPlot.svelte to use new architecture -- [ ] Remove old plotQueue.ts usage -- [ ] Test streaming behavior -- [ ] Test all plot types and settings -- [ ] Verify build passes - -### Step 7: Migrate PlotPreview (breaking) -- [ ] Update PlotPreview.svelte to use new architecture -- [ ] Remove old previewQueue.ts usage -- [ ] Test preview rendering -- [ ] Verify build passes - -### Step 8: Cleanup -- [ ] Delete old `plotQueue.ts` -- [ ] Delete old `previewQueue.ts` -- [ ] Remove unused code from `plotUtils.ts` -- [ ] Update imports throughout codebase -- [ ] Final verification and testing - ---- - -## Testing Checklist - -### Functional Tests -- [ ] Scope plots render correctly -- [ ] Spectrum plots render correctly -- [ ] Ghost traces appear with correct opacity -- [ ] Line styles (solid/dash/dot) work -- [ ] Marker styles (circle/square/triangle) work -- [ ] Hidden traces (both null) don't render -- [ ] Legend toggle works per-block -- [ ] Axis scale toggle (linear/log) works -- [ ] Spectrum defaults to log Y-axis -- [ ] Previews match main plot styling - -### Streaming Tests -- [ ] Streaming updates are smooth (no stuttering) -- [ ] extendTraces optimization still works for scope -- [ ] Preview updates during streaming -- [ ] Stopping/restarting stream works correctly - -### Performance Tests -- [ ] Memory usage is stable during long simulations -- [ ] CPU usage is reasonable during streaming -- [ ] No visible frame drops at 15 FPS target -- [ ] Tab visibility pausing works - ---- - -## Risks and Mitigations - -| Risk | Mitigation | -|------|------------| -| Breaking streaming optimization | Keep extendTraces logic in SignalPlot, fed by processed data | -| Performance regression | Benchmark before/after, keep old code as fallback | -| State synchronization bugs | Extensive testing of reactive updates | -| Migration takes too long | Do in phases, each phase independently deployable | - ---- - -## Success Criteria - -1. **Single queue**: One render queue with configurable FPS -2. **Separated concerns**: Data processing is independent of rendering -3. **No duplication**: Ghost opacity, visibility checks, etc. defined once -4. **Consistent visuals**: Previews exactly match main plot styling -5. **Maintained performance**: Streaming is as smooth as before -6. **Cleaner code**: Components are simpler, logic is centralized -7. **Testable**: Core functions can be unit tested diff --git a/src/lib/components/dialogs/PlotOptionsDialog.svelte b/src/lib/components/dialogs/PlotOptionsDialog.svelte index e45590d8..84e521dc 100644 --- a/src/lib/components/dialogs/PlotOptionsDialog.svelte +++ b/src/lib/components/dialogs/PlotOptionsDialog.svelte @@ -11,7 +11,7 @@ type MarkerStyle, type PlotSettings } from '$lib/stores/plotSettings'; - import { getSignalColor, LINE_DASH_SVG } from '$lib/plotting/plotUtils'; + import { getTraceColor, getAccentColor, LINE_DASH_SVG } from '$lib/plotting/core/constants'; interface TraceInfo { nodeId: string; @@ -202,7 +202,7 @@ {#each nodeTraces as trace} {@const traceId = createTraceId(trace.nodeId, trace.signalIndex)} {@const settings = getTraceSettings(traceId)} - {@const color = getSignalColor(trace.signalIndex)} + {@const color = getTraceColor(trace.signalIndex, getAccentColor())}
        diff --git a/src/lib/components/nodes/previewQueue.ts b/src/lib/components/nodes/previewQueue.ts deleted file mode 100644 index 90d3ffe3..00000000 --- a/src/lib/components/nodes/previewQueue.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Shared render queue for PlotPreview components. - * Processes previews at a throttled rate to prevent UI freezes during streaming. - * Pauses processing when page is hidden to save CPU. - */ - -type RenderTask = () => void; - -const queue = new Map(); -let rafId: number | null = null; -let lastProcessTime = 0; -const MIN_PROCESS_INTERVAL = 1000 / 10; // Max 10fps for preview updates - -// Visibility API - pause processing when tab is hidden -let isPageVisible = typeof document !== 'undefined' ? !document.hidden : true; - -function handleVisibilityChange() { - isPageVisible = !document.hidden; - // Resume processing if there are queued tasks - if (isPageVisible && queue.size > 0 && rafId === null) { - rafId = requestAnimationFrame(processQueue); - } -} - -if (typeof document !== 'undefined') { - document.addEventListener('visibilitychange', handleVisibilityChange); -} - -function processQueue(timestamp: number) { - rafId = null; - - // Skip processing when page is hidden - if (!isPageVisible) return; - - if (queue.size === 0) return; - - // Throttle processing rate - if (timestamp - lastProcessTime < MIN_PROCESS_INTERVAL) { - rafId = requestAnimationFrame(processQueue); - return; - } - lastProcessTime = timestamp; - - // Process all queued tasks in one batch - const tasks = Array.from(queue.values()); - queue.clear(); - - for (const task of tasks) { - task(); - } - - // If more tasks were added during processing, schedule next batch - if (queue.size > 0) { - rafId = requestAnimationFrame(processQueue); - } -} - -export function enqueueRender(id: symbol, task: RenderTask) { - queue.set(id, task); - if (rafId === null && isPageVisible) { - rafId = requestAnimationFrame(processQueue); - } -} - -export function cancelRender(id: symbol) { - queue.delete(id); -} diff --git a/src/lib/components/panels/plotQueue.ts b/src/lib/components/panels/plotQueue.ts deleted file mode 100644 index facda7d8..00000000 --- a/src/lib/components/panels/plotQueue.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Shared render queue for Plotly plots in tiles mode. - * Batches and throttles plot updates to prevent UI freezes during streaming. - * Pauses processing when page is hidden to save CPU. - */ - -type RenderTask = () => void; - -const queue = new Map(); -let rafId: number | null = null; -let lastProcessTime = 0; -const MIN_PROCESS_INTERVAL = 1000 / 15; // Max 15fps for plot updates (slightly higher than previews) - -// Visibility API - pause processing when tab is hidden -let isPageVisible = typeof document !== 'undefined' ? !document.hidden : true; - -function handleVisibilityChange() { - isPageVisible = !document.hidden; - // Resume processing if there are queued tasks - if (isPageVisible && queue.size > 0 && rafId === null) { - rafId = requestAnimationFrame(processQueue); - } -} - -if (typeof document !== 'undefined') { - document.addEventListener('visibilitychange', handleVisibilityChange); -} - -function processQueue(timestamp: number) { - rafId = null; - - // Skip processing when page is hidden - if (!isPageVisible) return; - - if (queue.size === 0) return; - - // Throttle processing rate - if (timestamp - lastProcessTime < MIN_PROCESS_INTERVAL) { - rafId = requestAnimationFrame(processQueue); - return; - } - lastProcessTime = timestamp; - - // Process all queued tasks in one batch - const tasks = Array.from(queue.values()); - queue.clear(); - - for (const task of tasks) { - task(); - } - - // If more tasks were added during processing, schedule next batch - if (queue.size > 0) { - rafId = requestAnimationFrame(processQueue); - } -} - -/** - * Enqueue a plot update. Replaces any existing task for the same component. - */ -export function enqueuePlotUpdate(id: symbol, task: RenderTask) { - queue.set(id, task); - if (rafId === null && isPageVisible) { - rafId = requestAnimationFrame(processQueue); - } -} - -/** - * Cancel a pending plot update. - */ -export function cancelPlotUpdate(id: symbol) { - queue.delete(id); -} - -/** - * Check if page is currently visible (for components that need to know) - */ -export function isVisible(): boolean { - return isPageVisible; -} diff --git a/src/lib/plotting/index.ts b/src/lib/plotting/index.ts new file mode 100644 index 00000000..17a72c83 --- /dev/null +++ b/src/lib/plotting/index.ts @@ -0,0 +1,62 @@ +/** + * Plotting module - public API + * + * This module provides a unified, modular plotting system with: + * - Centralized data processing + * - Single render queue + * - Pluggable renderers (Plotly + SVG) + */ + +// Core types and constants +export type { + LineStyle, + MarkerStyle, + AxisScale, + TraceStyle, + LayoutStyle, + RawScopeData, + RawSpectrumData, + RawPlotData, + ProcessedTrace, + ProcessedPlot, + PlotDataState, + DecimationResult, + DataBounds +} from './core/types'; + +export { + RENDER_QUEUE_FPS, + PREVIEW_TARGET_BUCKETS, + GHOST_OPACITY_MAX, + GHOST_OPACITY_MIN, + calculateGhostOpacity, + TRACE_COLORS, + getTraceColor, + getAccentColor, + LINE_DASH_PLOTLY, + LINE_DASH_SVG, + MARKER_SYMBOL_PLOTLY, + PLOTLY_CONFIG, + PREVIEW_WIDTH, + PREVIEW_HEIGHT, + PREVIEW_PADDING +} from './core/constants'; + +export { isTraceVisible, decimateMinMax, computeBounds, formatFrequency, getCssVar } from './core/utils'; + +// Processing +export { createRenderQueue, plotRenderQueue, type RenderQueue } from './processing/renderQueue'; +export { processPlot, type ProcessPlotOptions } from './processing/dataProcessor'; +export { plotDataStore } from './processing/plotDataStore'; + +// Renderers +export { + toPlotlyTrace, + toPlotlySpectrumTrace, + toPlotlyLayout, + getBaseLayout, + createEmptyLayout, + PLOTLY_CONFIG as PLOTLY_RENDER_CONFIG +} from './renderers/plotly'; + +export { toSVGPaths, toSVGPathsLinear, type SVGPathData } from './renderers/svg'; diff --git a/src/lib/plotting/plotUtils.ts b/src/lib/plotting/plotUtils.ts deleted file mode 100644 index 38adff41..00000000 --- a/src/lib/plotting/plotUtils.ts +++ /dev/null @@ -1,467 +0,0 @@ -/** - * Plotly.js configuration utilities - * Uses CSS variables from app.css for consistent theming - */ - -import type { LineStyle, MarkerStyle, AxisScale } from '$lib/stores/plotSettings'; - -/** Style options for trace rendering */ -export interface TraceStyleOptions { - lineStyle?: LineStyle | null; // null = no line - markerStyle?: MarkerStyle | null; // null = no markers -} - -/** Style options for layout */ -export interface LayoutStyleOptions { - xAxisScale?: AxisScale; - yAxisScale?: AxisScale; -} - -/** Map our line style names to Plotly dash values */ -const LINE_DASH_MAP: Record = { - solid: 'solid', - dash: 'dash', - dot: 'dot' -}; - -/** Map our line style names to SVG stroke-dasharray values (for preview rendering) */ -export const LINE_DASH_SVG: Record = { - solid: '', - dash: '6,3', - dot: '2,2' -}; - -/** Map our marker style names to Plotly symbol values */ -const MARKER_SYMBOL_MAP: Record = { - circle: 'circle', - square: 'square', - 'triangle-up': 'triangle-up' -}; - -/** Resolved style options with non-null defaults applied */ -interface ResolvedStyle { - lineStyle: LineStyle | null; - markerStyle: MarkerStyle | null; - showLines: boolean; - showMarkers: boolean; - mode: 'lines' | 'markers' | 'lines+markers'; -} - -/** Resolve style options to concrete values with defaults */ -function resolveStyleOptions(styleOptions?: TraceStyleOptions): ResolvedStyle { - // null means no line/marker, undefined means use default - const lineStyle = styleOptions?.lineStyle === undefined ? 'solid' : styleOptions.lineStyle; - const markerStyle = styleOptions?.markerStyle === undefined ? null : styleOptions.markerStyle; - const showLines = lineStyle !== null; - const showMarkers = markerStyle !== null; - - // Determine mode based on line and marker settings - let mode: 'lines' | 'markers' | 'lines+markers'; - if (showLines && showMarkers) { - mode = 'lines+markers'; - } else if (showMarkers) { - mode = 'markers'; - } else { - mode = 'lines'; - } - - return { lineStyle, markerStyle, showLines, showMarkers, mode }; -} - -/** Apply line config to trace if lines are shown */ -function applyLineConfig( - trace: Partial, - style: ResolvedStyle, - color: string, - width: number = 1.5 -): void { - if (style.showLines && style.lineStyle) { - trace.line = { - color, - width, - dash: LINE_DASH_MAP[style.lineStyle] - }; - } -} - -/** Apply marker config to trace if markers are shown */ -function applyMarkerConfig( - trace: Partial, - style: ResolvedStyle, - color: string, - size: number = 6 -): void { - if (style.showMarkers && style.markerStyle) { - trace.marker = { - symbol: MARKER_SYMBOL_MAP[style.markerStyle], - size, - color - }; - // Allow markers to extend beyond axis without expanding range - trace.cliponaxis = false; - } -} - -/** - * Read a CSS variable value from the document root - * Automatically reflects current theme (light/dark) - */ -function getCssVar(name: string): string { - return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); -} - -/** - * Build base layout using CSS variables - * Reads current theme automatically from computed styles - */ -export function getBaseLayout(): Partial { - const textMuted = getCssVar('--text-muted'); - const textDisabled = getCssVar('--text-disabled'); - const surfaceRaised = getCssVar('--surface-raised'); - const border = getCssVar('--border'); - const accent = getCssVar('--accent'); - - return { - paper_bgcolor: 'transparent', - plot_bgcolor: 'transparent', - font: { - color: textMuted, - family: 'Inter, system-ui, sans-serif', - size: 11 - }, - margin: { - l: 60, - r: 15, - t: 10, - b: 45 - }, - xaxis: { - gridcolor: border, - gridwidth: 0.5, - zeroline: false, - linecolor: border, - linewidth: 1.5, - tickfont: { size: 10, color: textDisabled }, - title: { standoff: 10 } - }, - yaxis: { - gridcolor: border, - gridwidth: 0.5, - zeroline: false, - linecolor: border, - linewidth: 1.5, - tickfont: { size: 10, color: textDisabled }, - title: { standoff: 5 } - }, - legend: { - bgcolor: surfaceRaised, - bordercolor: border, - borderwidth: 1, - font: { size: 10, color: textMuted } - }, - modebar: { - bgcolor: 'transparent', - color: textDisabled, - activecolor: accent - }, - hoverlabel: { - bgcolor: surfaceRaised, - bordercolor: border, - font: { color: textMuted, size: 11 } - } - }; -} - -// Scope plot (time-domain) layout -export function getScopeLayout( - title: string = 'Scope', - showLegend: boolean = false, - styleOptions?: LayoutStyleOptions -): Partial { - const baseLayout = getBaseLayout(); - // Format y-axis label as "Scope: Name" or just "Scope" if no custom name - const yAxisLabel = title.toLowerCase().startsWith('scope') ? title : `Scope: ${title}`; - - const xAxisScale = styleOptions?.xAxisScale ?? 'linear'; - const yAxisScale = styleOptions?.yAxisScale ?? 'linear'; - - return { - ...baseLayout, - xaxis: { - ...baseLayout.xaxis, - title: { text: 'Time (s)', font: { size: 11 }, standoff: 10 }, - type: xAxisScale - }, - yaxis: { - ...baseLayout.yaxis, - title: { text: yAxisLabel, font: { size: 11 }, standoff: 5 }, - type: yAxisScale - }, - showlegend: showLegend, - legend: { - ...baseLayout.legend, - x: 0.01, - xanchor: 'left', - y: 0.99, - yanchor: 'top' - }, - hovermode: 'closest' - }; -} - -// Spectrum plot (frequency-domain) layout -// Uses index-based x-axis with frequency tick labels for equal spacing -export function getSpectrumLayout( - title: string = 'Spectrum', - frequencies?: number[], - showLegend: boolean = false, - styleOptions?: LayoutStyleOptions -): Partial { - const baseLayout = getBaseLayout(); - - // Generate tick labels from frequency values if provided - let tickvals: number[] | undefined; - let ticktext: string[] | undefined; - - if (frequencies && frequencies.length > 0) { - // Pick ~6 evenly spaced tick positions - const numTicks = Math.min(6, frequencies.length); - const step = Math.max(1, Math.floor((frequencies.length - 1) / (numTicks - 1))); - tickvals = []; - ticktext = []; - - for (let i = 0; i < frequencies.length; i += step) { - tickvals.push(i); - ticktext.push(formatFrequency(frequencies[i])); - } - // Always include the last point - if (tickvals[tickvals.length - 1] !== frequencies.length - 1) { - tickvals.push(frequencies.length - 1); - ticktext.push(formatFrequency(frequencies[frequencies.length - 1])); - } - } - - // Format y-axis label as "Spectrum: Name" or just "Spectrum" if no custom name - const yAxisLabel = title.toLowerCase().startsWith('spectrum') ? title : `Spectrum: ${title}`; - - // Spectrum defaults to log scale for y-axis if not specified - const xAxisScale = styleOptions?.xAxisScale ?? 'linear'; - const yAxisScale = styleOptions?.yAxisScale ?? 'log'; - - return { - ...baseLayout, - xaxis: { - ...baseLayout.xaxis, - title: { text: 'Frequency (Hz)', font: { size: 11 }, standoff: 10 }, - tickvals, - ticktext, - tickangle: 0, - type: xAxisScale - }, - yaxis: { - ...baseLayout.yaxis, - title: { text: yAxisLabel, font: { size: 11 }, standoff: 5 }, - type: yAxisScale - }, - showlegend: showLegend, - legend: { - ...baseLayout.legend, - x: 0.01, - xanchor: 'left', - y: 0.99, - yanchor: 'top' - }, - hovermode: 'closest' - }; -} - -// Format frequency for display (compact notation) -function formatFrequency(freq: number): string { - if (freq >= 1e6) return (freq / 1e6).toFixed(1) + 'M'; - if (freq >= 1e3) return (freq / 1e3).toFixed(1) + 'k'; - if (freq >= 1) return freq.toFixed(1); - return freq.toExponential(1); -} - -// Signal colors for traces (after accent) - matches node color palette -const TRACE_COLORS = [ - '#E57373', // Red - '#81C784', // Green - '#64B5F6', // Blue - '#BA68C8', // Purple - '#4DD0E1', // Cyan - '#FFB74D', // Orange - '#F06292', // Pink - '#4DB6AC', // Teal - '#90A4AE' // Grey -]; - -/** - * Get signal color for a trace index - * First trace uses accent color, last uses edge color - */ -export function getSignalColor(index: number): string { - if (index === 0) { - return getCssVar('--accent'); - } - const traceIndex = (index - 1) % (TRACE_COLORS.length + 1); - if (traceIndex < TRACE_COLORS.length) { - return TRACE_COLORS[traceIndex]; - } - return getCssVar('--edge'); -} - -// Legacy export for backwards compatibility -export const SIGNAL_COLORS = [ - '#0070C0', // PathSim blue (accent) - ...TRACE_COLORS -]; - -// Common plot configuration -export const plotConfig: Partial = { - responsive: true, - displaylogo: false, - displayModeBar: 'hover', - modeBarButtonsToRemove: ['lasso2d', 'select2d'], - modeBarButtonsToAdd: [], - toImageButtonOptions: { - format: 'svg', - filename: 'pathview_plot', - height: 600, - width: 1000, - scale: 2 - }, - scrollZoom: true -}; - -// Always use SVG scatter - WebGL (scattergl) causes warnings during streaming updates -// SVG performance is sufficient for typical simulation datasets -const TRACE_TYPE = 'scatter' as const; - -// Create scope trace -export function createScopeTrace( - time: number[], - signal: number[], - index: number, - name?: string, - styleOptions?: TraceStyleOptions -): Partial { - const traceName = name || `port ${index}`; - const color = getSignalColor(index); - const style = resolveStyleOptions(styleOptions); - - const trace: Partial = { - x: time, - y: signal, - type: TRACE_TYPE, - mode: style.mode, - name: traceName, - legendgroup: `signal-${index}`, - hovertemplate: `${traceName}
        t = %{x:.4g} s
        y = %{y:.4g}` - }; - - applyLineConfig(trace, style, color); - applyMarkerConfig(trace, style, color); - - return trace; -} - -// Create ghost scope trace (previous run) with reduced opacity -export function createGhostScopeTrace( - time: number[], - signal: number[], - signalIndex: number, - ghostIndex: number, - totalGhosts: number, - styleOptions?: TraceStyleOptions -): Partial { - // Linear opacity: 50% for most recent ghost, 20% for oldest - const opacity = totalGhosts === 1 - ? 0.5 - : 0.5 - (ghostIndex / (totalGhosts - 1)) * 0.3; - const color = getSignalColor(signalIndex); - const style = resolveStyleOptions(styleOptions); - - const trace: Partial = { - x: time, - y: signal, - type: TRACE_TYPE, - mode: style.mode, - showlegend: false, - hoverinfo: 'skip', - legendgroup: `signal-${signalIndex}`, - opacity - }; - - applyLineConfig(trace, style, color, 1); // Thinner line for ghosts - applyMarkerConfig(trace, style, color, 5); // Smaller markers for ghosts - - return trace; -} - -// Create spectrum trace - uses indices for equal spacing -export function createSpectrumTrace( - frequency: number[], - magnitude: number[], - index: number, - name?: string, - styleOptions?: TraceStyleOptions -): Partial { - const traceName = name || `port ${index}`; - const color = getSignalColor(index); - // Use indices for x-axis (equal spacing) - const indices = Array.from({ length: magnitude.length }, (_, i) => i); - const style = resolveStyleOptions(styleOptions); - - const trace: Partial = { - x: indices, - y: magnitude, - type: TRACE_TYPE, - mode: style.mode, - name: traceName, - legendgroup: `signal-${index}`, - // Store frequency in customdata for hover - customdata: frequency, - hovertemplate: `${traceName}
        f = %{customdata:.2f} Hz
        mag = %{y:.4g}` - }; - - applyLineConfig(trace, style, color); - applyMarkerConfig(trace, style, color); - - return trace; -} - -// Create ghost spectrum trace (previous run) with reduced opacity -export function createGhostSpectrumTrace( - frequency: number[], - magnitude: number[], - signalIndex: number, - ghostIndex: number, - totalGhosts: number, - styleOptions?: TraceStyleOptions -): Partial { - // Linear opacity: 50% for most recent ghost, 20% for oldest - const opacity = totalGhosts === 1 - ? 0.5 - : 0.5 - (ghostIndex / (totalGhosts - 1)) * 0.3; - const color = getSignalColor(signalIndex); - // Use indices for x-axis (equal spacing) - const indices = Array.from({ length: magnitude.length }, (_, i) => i); - const style = resolveStyleOptions(styleOptions); - - const trace: Partial = { - x: indices, - y: magnitude, - type: TRACE_TYPE, - mode: style.mode, - showlegend: false, - hoverinfo: 'skip', - legendgroup: `signal-${signalIndex}`, - opacity - }; - - applyLineConfig(trace, style, color, 1); // Thinner line for ghosts - applyMarkerConfig(trace, style, color, 5); // Smaller markers for ghosts - - return trace; -} From f54c3f7214fabe13a100dc30f5cda7abdd9d1aba Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 17 Jan 2026 23:14:01 +0100 Subject: [PATCH 069/656] Add axis scaling support (linear/log) to plot previews --- src/lib/components/nodes/PlotPreview.svelte | 6 +- src/lib/plotting/renderers/svg.ts | 94 +++++++++++++++------ 2 files changed, 72 insertions(+), 28 deletions(-) diff --git a/src/lib/components/nodes/PlotPreview.svelte b/src/lib/components/nodes/PlotPreview.svelte index 6f638cbf..8771f372 100644 --- a/src/lib/components/nodes/PlotPreview.svelte +++ b/src/lib/components/nodes/PlotPreview.svelte @@ -1,7 +1,7 @@ + + +``` + +Or use inline styles set via JS that read from constants. + +--- + +### Phase 4: Implement Pure Edge Path Algorithm + +Create `src/lib/export/edgePath.ts`: + +```typescript +interface Point { x: number; y: number; } + +interface EdgePathOptions { + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + sourcePosition: 'left' | 'right' | 'top' | 'bottom'; + targetPosition: 'left' | 'right' | 'top' | 'bottom'; + borderRadius?: number; +} + +/** + * Pure implementation of smooth step path (same algorithm as SvelteFlow) + * No DOM required. + */ +export function getSmoothStepPath(options: EdgePathOptions): string { + // Implement the smooth step algorithm + // Returns SVG path string +} + +/** + * Calculate arrow position and rotation at end of path + */ +export function getArrowTransform(path: string): { x: number; y: number; angle: number } { + // Use path math, not DOM +} +``` + +--- + +### Phase 5: Build New SVG Renderer + +Create `src/lib/export/svg/`: + +``` +src/lib/export/svg/ +├── types.ts # ExportOptions, RenderContext +├── renderer.ts # Main render function +├── nodes.ts # renderNode, renderHandle +├── edges.ts # renderEdge, renderArrow +├── events.ts # renderEvent +└── index.ts # Public API +``` + +#### `types.ts` +```typescript +export interface ExportOptions { + theme?: 'light' | 'dark' | 'auto'; + background?: 'transparent' | 'solid'; + padding?: number; + showLabels?: boolean; + showHandles?: boolean; + showTypeLabels?: boolean; + selectedOnly?: boolean; + scale?: number; +} + +export interface RenderContext { + theme: Theme; + options: ExportOptions; +} +``` + +#### `renderer.ts` +```typescript +import { get } from 'svelte/store'; +import { graphStore } from '$lib/stores/graph'; +import { eventStore } from '$lib/stores/events'; +import { renderNode } from './nodes'; +import { renderEdge } from './edges'; +import { renderEvent } from './events'; +import { THEMES, getCurrentTheme } from '$lib/constants/theme'; + +export function exportToSVG(options: ExportOptions = {}): string { + const theme = options.theme === 'auto' + ? THEMES[getCurrentTheme()] + : THEMES[options.theme || 'dark']; + + const ctx: RenderContext = { theme, options }; + + const nodes = get(graphStore.nodesArray); + const edges = get(graphStore.edgesArray); + const events = get(eventStore.eventsArray); + + // Calculate bounds + const bounds = calculateBounds(nodes, events); + + // Build SVG + const parts: string[] = []; + + // Header + parts.push(renderHeader(bounds, options)); + + // Background + if (options.background === 'solid') { + parts.push(renderBackground(bounds, ctx)); + } + + // Edges (below nodes) + parts.push(''); + for (const edge of edges) { + parts.push(renderEdge(edge, nodes, ctx)); + } + parts.push(''); + + // Events + parts.push(''); + for (const event of events) { + parts.push(renderEvent(event, ctx)); + } + parts.push(''); + + // Nodes (with handles) + parts.push(''); + for (const node of nodes) { + parts.push(renderNode(node, ctx)); + } + parts.push(''); + + parts.push(''); + + return parts.join('\n'); +} +``` + +#### `nodes.ts` +```typescript +import { HANDLE_PATHS } from '$lib/constants/handles'; +import { NODE } from '$lib/constants/dimensions'; +import { getShape, getShapeForCategory } from '$lib/nodes/shapes'; +import { nodeRegistry } from '$lib/nodes'; + +export function renderNode(node: NodeInstance, ctx: RenderContext): string { + const { x, y } = node.position; + const { width, height } = calculateNodeDimensions(node); + const typeDef = nodeRegistry.get(node.type); + const shape = getShape(getShapeForCategory(typeDef?.category || 'default')); + const color = node.color || ctx.theme.accent; + + const parts: string[] = []; + + // Node rectangle + parts.push(renderNodeShape(x, y, width, height, shape, node, ctx)); + + // Labels + if (ctx.options.showLabels !== false) { + parts.push(renderNodeLabels(x, y, width, height, node, typeDef, color, ctx)); + } + + // Handles + if (ctx.options.showHandles !== false) { + parts.push(renderNodeHandles(node, x, y, width, height, ctx)); + } + + return `${parts.join('')}`; +} + +function renderNodeHandles(node, x, y, width, height, ctx): string { + const rotation = (node.params?.['_rotation'] as number) || 0; + const paths = HANDLE_PATHS[rotation]; + + const handles: string[] = []; + + // Render each input/output handle + for (let i = 0; i < node.inputs.length; i++) { + const pos = calculateHandlePosition('input', i, node.inputs.length, rotation, width, height); + handles.push(renderHandle(x + pos.x, y + pos.y, paths, ctx)); + } + + for (let i = 0; i < node.outputs.length; i++) { + const pos = calculateHandlePosition('output', i, node.outputs.length, rotation, width, height); + handles.push(renderHandle(x + pos.x, y + pos.y, paths, ctx)); + } + + return handles.join(''); +} + +function renderHandle(x: number, y: number, paths, ctx: RenderContext): string { + // Render two-layer hollow handle + return ` + + + + `; +} +``` + +--- + +### Phase 6: Wire Up Export UI + +Update export button/menu to use new renderer: + +```typescript +import { exportToSVG } from '$lib/export/svg'; +import { downloadSvg } from '$lib/utils/download'; + +function handleExport() { + const svg = exportToSVG({ + theme: 'auto', + background: 'transparent', + showLabels: true, + showHandles: true + }); + downloadSvg(svg, 'pathview-graph.svg'); +} +``` + +--- + +### Phase 7: Cleanup + +1. Delete old `src/lib/utils/svgExport.ts` +2. Remove duplicated handle paths from BaseNode CSS (use constants) +3. Update any other references + +--- + +## Migration Steps + +| Phase | Description | Files Changed | +|-------|-------------|---------------| +| 1 | Extract constants | NEW: `src/lib/constants/*` | +| 2 | Update shape registry | `src/lib/nodes/shapes/registry.ts` | +| 3 | Update BaseNode | `src/lib/components/nodes/BaseNode.svelte` | +| 4 | Implement edge path | NEW: `src/lib/export/edgePath.ts` | +| 5 | Build SVG renderer | NEW: `src/lib/export/svg/*` | +| 6 | Wire up UI | `src/routes/+page.svelte` or menu component | +| 7 | Cleanup | DELETE: `src/lib/utils/svgExport.ts` | + +--- + +## Testing Checklist + +- [ ] Export with dark theme matches live canvas +- [ ] Export with light theme has correct colors +- [ ] All node shapes render correctly (pill, rect, circle, diamond, mixed) +- [ ] Handles render as hollow pentagons in all 4 rotations +- [ ] Edge paths match live canvas curves +- [ ] Arrow heads positioned and rotated correctly +- [ ] Events render as diamonds with labels +- [ ] Subsystem nodes have dashed borders +- [ ] Labels are centered and readable +- [ ] Transparent background works +- [ ] Solid background works +- [ ] Export opens correctly in browser, Figma, PowerPoint +- [ ] No DOM access in renderer (works headless) + +--- + +## Future Enhancements + +- Export selection only +- Export specific subsystem +- PNG export (via canvas rasterization) +- Thumbnail generation for file browser +- Copy SVG to clipboard From 256ee7669ffbc2ace98986e08fa8a6d2a91e511e Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 18 Jan 2026 09:23:49 +0100 Subject: [PATCH 074/656] Add dimension, handle path, and theme constants for SVG export --- src/lib/constants/dimensions.ts | 63 +++++++++++++++++++++++++ src/lib/constants/handlePaths.ts | 81 ++++++++++++++++++++++++++++++++ src/lib/constants/theme.ts | 64 +++++++++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 src/lib/constants/dimensions.ts create mode 100644 src/lib/constants/handlePaths.ts create mode 100644 src/lib/constants/theme.ts diff --git a/src/lib/constants/dimensions.ts b/src/lib/constants/dimensions.ts new file mode 100644 index 00000000..94ceb85d --- /dev/null +++ b/src/lib/constants/dimensions.ts @@ -0,0 +1,63 @@ +/** + * Dimension constants for nodes, handles, and events + * Single source of truth - used by both live canvas (CSS) and SVG export + */ + +/** Node dimension constants */ +export const NODE = { + /** Base width in pixels */ + baseWidth: 90, + /** Base height in pixels */ + baseHeight: 36, + /** Spacing between ports in pixels */ + portSpacing: 18, + /** Border width in pixels */ + borderWidth: 1 +} as const; + +/** Handle (port connector) dimensions */ +export const HANDLE = { + /** Width of horizontal handles (rotation 0, 2) */ + width: 10, + /** Height of horizontal handles (rotation 0, 2) */ + height: 8, + /** Inset from outer to inner path for hollow effect */ + hollowInset: 1.5 +} as const; + +/** Event node dimensions */ +export const EVENT = { + /** Total bounding box size */ + size: 80, + /** Center point (size / 2) */ + center: 40, + /** Diamond shape size (rotated square) */ + diamondSize: 56, + /** Diamond offset from center (diamondSize / 2) */ + diamondOffset: 28 +} as const; + +/** Export padding */ +export const EXPORT_PADDING = 40; + +/** Calculate node dimensions based on ports and rotation */ +export function calculateNodeDimensions( + inputCount: number, + outputCount: number, + rotation: number +): { width: number; height: number } { + const isVertical = rotation === 1 || rotation === 3; + const maxPorts = Math.max(inputCount, outputCount); + + if (isVertical) { + return { + width: Math.max(NODE.baseWidth, maxPorts * NODE.portSpacing + 20), + height: NODE.baseHeight + }; + } + + return { + width: NODE.baseWidth, + height: Math.max(NODE.baseHeight, maxPorts * NODE.portSpacing + 10) + }; +} diff --git a/src/lib/constants/handlePaths.ts b/src/lib/constants/handlePaths.ts new file mode 100644 index 00000000..7240c4a8 --- /dev/null +++ b/src/lib/constants/handlePaths.ts @@ -0,0 +1,81 @@ +/** + * Handle path definitions for all rotations + * Single source of truth - used by both CSS clip-paths and SVG export + * + * Each handle has: + * - outer: The border/fill path (pentagon shape with rounded corners) + * - inner: The cutout path (makes it hollow) + * - width/height: Dimensions of the handle bounding box + * + * The hollow effect is created by layering: + * 1. Outer path filled with border color + * 2. Inner path filled with background color, offset by ~1.5px + */ + +export interface HandlePathDef { + /** Outer shape path (the border) */ + outer: string; + /** Inner shape path (the hollow cutout) */ + inner: string; + /** Handle width in pixels */ + width: number; + /** Handle height in pixels */ + height: number; +} + +/** + * Handle paths for each rotation direction + * - 0: Arrow pointing right (inputs left, outputs right) + * - 1: Arrow pointing down (inputs top, outputs bottom) + * - 2: Arrow pointing left (inputs right, outputs left) + * - 3: Arrow pointing up (inputs bottom, outputs top) + */ +export const HANDLE_PATHS: Record<0 | 1 | 2 | 3, HandlePathDef> = { + // Right-pointing (default) + 0: { + outer: + 'M 1.00 0.00 L 5.00 0.00 Q 6.00 0.00 6.71 0.71 L 9.29 3.29 Q 10.00 4.00 9.29 4.71 L 6.71 7.29 Q 6.00 8.00 5.00 8.00 L 1.00 8.00 Q 0.00 8.00 0.00 7.00 L 0.00 1.00 Q 0.00 0.00 1.00 0.00 Z', + inner: + 'M 0.80 0.00 L 3.79 0.00 Q 4.59 0.00 5.15 0.57 L 7.02 2.43 Q 7.59 3.00 7.02 3.57 L 5.15 5.43 Q 4.59 6.00 3.79 6.00 L 0.80 6.00 Q 0.00 6.00 0.00 5.20 L 0.00 0.80 Q 0.00 0.00 0.80 0.00 Z', + width: 10, + height: 8 + }, + + // Down-pointing + 1: { + outer: + 'M 1.00 0.00 L 7.00 0.00 Q 8.00 0.00 8.00 1.00 L 8.00 5.00 Q 8.00 6.00 7.29 6.71 L 4.71 9.29 Q 4.00 10.00 3.29 9.29 L 0.71 6.71 Q 0.00 6.00 0.00 5.00 L 0.00 1.00 Q 0.00 0.00 1.00 0.00 Z', + inner: + 'M 0.80 0.00 L 5.20 0.00 Q 6.00 0.00 6.00 0.80 L 6.00 3.79 Q 6.00 4.59 5.43 5.15 L 3.57 7.02 Q 3.00 7.59 2.43 7.02 L 0.57 5.15 Q 0.00 4.59 0.00 3.79 L 0.00 0.80 Q 0.00 0.00 0.80 0.00 Z', + width: 8, + height: 10 + }, + + // Left-pointing + 2: { + outer: + 'M 5.00 0.00 L 9.00 0.00 Q 10.00 0.00 10.00 1.00 L 10.00 7.00 Q 10.00 8.00 9.00 8.00 L 5.00 8.00 Q 4.00 8.00 3.29 7.29 L 0.71 4.71 Q 0.00 4.00 0.71 3.29 L 3.29 0.71 Q 4.00 0.00 5.00 0.00 Z', + inner: + 'M 4.21 0.00 L 7.20 0.00 Q 8.00 0.00 8.00 0.80 L 8.00 5.20 Q 8.00 6.00 7.20 6.00 L 4.21 6.00 Q 3.41 6.00 2.85 5.43 L 0.98 3.57 Q 0.41 3.00 0.98 2.43 L 2.85 0.57 Q 3.41 0.00 4.21 0.00 Z', + width: 10, + height: 8 + }, + + // Up-pointing + 3: { + outer: + 'M 4.71 0.71 L 7.29 3.29 Q 8.00 4.00 8.00 5.00 L 8.00 9.00 Q 8.00 10.00 7.00 10.00 L 1.00 10.00 Q 0.00 10.00 0.00 9.00 L 0.00 5.00 Q 0.00 4.00 0.71 3.29 L 3.29 0.71 Q 4.00 0.00 4.71 0.71 Z', + inner: + 'M 3.57 0.98 L 5.43 2.85 Q 6.00 3.41 6.00 4.21 L 6.00 7.20 Q 6.00 8.00 5.20 8.00 L 0.80 8.00 Q 0.00 8.00 0.00 7.20 L 0.00 4.21 Q 0.00 3.41 0.57 2.85 L 2.43 0.98 Q 3.00 0.41 3.57 0.98 Z', + width: 8, + height: 10 + } +} as const; + +export type HandleRotation = keyof typeof HANDLE_PATHS; + +/** Get handle path definition for a rotation */ +export function getHandlePath(rotation: number): HandlePathDef { + const normalizedRotation = ((rotation % 4) + 4) % 4; + return HANDLE_PATHS[normalizedRotation as HandleRotation]; +} diff --git a/src/lib/constants/theme.ts b/src/lib/constants/theme.ts new file mode 100644 index 00000000..fd58fa02 --- /dev/null +++ b/src/lib/constants/theme.ts @@ -0,0 +1,64 @@ +/** + * Theme color definitions + * Single source of truth for SVG export - matches CSS variables in app.css + */ + +/** Theme color interface */ +export interface ThemeColors { + /** Main background color */ + surface: string; + /** Raised surface (cards, panels) */ + surfaceRaised: string; + /** Border/edge color */ + border: string; + /** Connection edge color */ + edge: string; + /** Primary text color */ + text: string; + /** Muted text color */ + textMuted: string; + /** Accent color (default node color) */ + accent: string; +} + +/** Theme color definitions matching app.css */ +export const THEMES: Record<'light' | 'dark', ThemeColors> = { + dark: { + surface: '#08080c', + surfaceRaised: '#1c1c26', + border: 'rgba(255, 255, 255, 0.08)', + edge: '#7F7F7F', + text: '#f0f0f5', + textMuted: '#808090', + accent: '#0070C0' + }, + light: { + surface: '#f0f0f4', + surfaceRaised: '#ffffff', + border: 'rgba(0, 0, 0, 0.10)', + edge: '#7F7F7F', + text: '#1a1a1f', + textMuted: '#606068', + accent: '#0070C0' + } +} as const; + +export type ThemeName = keyof typeof THEMES; + +/** + * Get current theme name from document (for browser context) + * Returns 'dark' as default for SSR/headless + */ +export function getCurrentThemeName(): ThemeName { + if (typeof document === 'undefined') return 'dark'; + return (document.documentElement.getAttribute('data-theme') as ThemeName) || 'dark'; +} + +/** + * Get current theme colors + * @param theme - Theme name, or 'auto' to detect from document + */ +export function getThemeColors(theme: ThemeName | 'auto' = 'auto'): ThemeColors { + const themeName = theme === 'auto' ? getCurrentThemeName() : theme; + return THEMES[themeName]; +} From 5569de580d8560c47cbb1a53b3d4a1f77ed78526 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 18 Jan 2026 09:24:13 +0100 Subject: [PATCH 075/656] Add svgRadius property to shape definitions --- src/lib/nodes/shapes/registry.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/lib/nodes/shapes/registry.ts b/src/lib/nodes/shapes/registry.ts index 39a4dd0c..e000d82e 100644 --- a/src/lib/nodes/shapes/registry.ts +++ b/src/lib/nodes/shapes/registry.ts @@ -11,6 +11,8 @@ export interface ShapeDefinition { name: string; cssClass: string; borderRadius: string; + /** Numeric border radius for SVG export (single value or [TL, TR, BR, BL]) */ + svgRadius: number | [number, number, number, number]; } /** Shape registry - maps shape IDs to definitions */ @@ -36,42 +38,48 @@ registerShape({ id: 'pill', name: 'Pill', cssClass: 'shape-pill', - borderRadius: '20px' + borderRadius: '20px', + svgRadius: 20 }); registerShape({ id: 'rect', name: 'Rectangle', cssClass: 'shape-rect', - borderRadius: '4px' + borderRadius: '4px', + svgRadius: 4 }); registerShape({ id: 'circle', name: 'Circle', cssClass: 'shape-circle', - borderRadius: '16px' + borderRadius: '16px', + svgRadius: 16 }); registerShape({ id: 'diamond', name: 'Diamond', cssClass: 'shape-diamond', - borderRadius: '4px' + borderRadius: '4px', + svgRadius: 4 }); registerShape({ id: 'mixed', name: 'Mixed', cssClass: 'shape-mixed', - borderRadius: '12px 4px 12px 4px' + borderRadius: '12px 4px 12px 4px', + svgRadius: [12, 4, 12, 4] }); registerShape({ id: 'default', name: 'Default', cssClass: 'shape-default', - borderRadius: '8px' + borderRadius: '8px', + svgRadius: 8 }); /** Category to shape mapping */ From dcc79f7c45f5a3842aac64534198f7cbf4845419 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 18 Jan 2026 09:26:47 +0100 Subject: [PATCH 076/656] Add pure SVG renderer module (no DOM scraping) --- src/lib/export/index.ts | 6 + src/lib/export/svg/edgePath.ts | 149 ++++++++++++++++++++ src/lib/export/svg/edges.ts | 99 +++++++++++++ src/lib/export/svg/events.ts | 52 +++++++ src/lib/export/svg/index.ts | 10 ++ src/lib/export/svg/nodes.ts | 250 +++++++++++++++++++++++++++++++++ src/lib/export/svg/renderer.ts | 156 ++++++++++++++++++++ src/lib/export/svg/types.ts | 47 +++++++ 8 files changed, 769 insertions(+) create mode 100644 src/lib/export/index.ts create mode 100644 src/lib/export/svg/edgePath.ts create mode 100644 src/lib/export/svg/edges.ts create mode 100644 src/lib/export/svg/events.ts create mode 100644 src/lib/export/svg/index.ts create mode 100644 src/lib/export/svg/nodes.ts create mode 100644 src/lib/export/svg/renderer.ts create mode 100644 src/lib/export/svg/types.ts diff --git a/src/lib/export/index.ts b/src/lib/export/index.ts new file mode 100644 index 00000000..10383868 --- /dev/null +++ b/src/lib/export/index.ts @@ -0,0 +1,6 @@ +/** + * Export module + */ + +export { exportToSVG } from './svg'; +export type { ExportOptions } from './svg'; diff --git a/src/lib/export/svg/edgePath.ts b/src/lib/export/svg/edgePath.ts new file mode 100644 index 00000000..419bdc60 --- /dev/null +++ b/src/lib/export/svg/edgePath.ts @@ -0,0 +1,149 @@ +/** + * Edge path utilities for SVG export + * + * Uses @xyflow/svelte's getSmoothStepPath for path generation + * and provides pure functions for arrow positioning + */ + +import { getSmoothStepPath, type Position } from '@xyflow/svelte'; + +/** Edge endpoint offsets to align with handle tips */ +export const EDGE_OFFSETS = { + source: 0.5, + target: 4.5 +} as const; + +/** Arrow head path (same as ArrowEdge.svelte) */ +export const ARROW_PATH = + 'M -5 -2.5 L -1 -0.5 Q 0 0 -1 0.5 L -5 2.5 Q -6 3 -6 2 L -6 -2 Q -6 -3 -5 -2.5 Z'; + +/** Arrow offset to reach target handle tip */ +export const ARROW_FORWARD_OFFSET = 5; + +export interface EdgePathOptions { + sourceX: number; + sourceY: number; + sourcePosition: Position; + targetX: number; + targetY: number; + targetPosition: Position; + borderRadius?: number; +} + +export interface EdgePathResult { + path: string; + arrow: { + x: number; + y: number; + angle: number; + }; +} + +/** + * Adjust source position to start at handle tip + */ +function adjustSource(x: number, y: number, position: Position): { x: number; y: number } { + const offset = EDGE_OFFSETS.source; + switch (position) { + case 'right': + return { x: x - offset, y }; + case 'left': + return { x: x + offset, y }; + case 'bottom': + return { x, y: y - offset }; + case 'top': + return { x, y: y + offset }; + default: + return { x, y }; + } +} + +/** + * Adjust target position to end at handle tip + */ +function adjustTarget(x: number, y: number, position: Position): { x: number; y: number } { + const offset = EDGE_OFFSETS.target; + switch (position) { + case 'right': + return { x: x + offset, y }; + case 'left': + return { x: x - offset, y }; + case 'bottom': + return { x, y: y + offset }; + case 'top': + return { x, y: y - offset }; + default: + return { x, y }; + } +} + +/** + * Calculate arrow position and angle from target position + * Pure implementation without DOM + */ +function calculateArrowTransform( + targetX: number, + targetY: number, + targetPosition: Position +): { x: number; y: number; angle: number } { + // Arrow points in direction of flow (towards target handle) + let angle: number; + let offsetX = 0; + let offsetY = 0; + + switch (targetPosition) { + case 'left': + angle = 180; + offsetX = -ARROW_FORWARD_OFFSET; + break; + case 'right': + angle = 0; + offsetX = ARROW_FORWARD_OFFSET; + break; + case 'top': + angle = -90; + offsetY = -ARROW_FORWARD_OFFSET; + break; + case 'bottom': + angle = 90; + offsetY = ARROW_FORWARD_OFFSET; + break; + default: + angle = 0; + } + + return { + x: targetX + offsetX, + y: targetY + offsetY, + angle + }; +} + +/** + * Generate edge path and arrow transform + * Pure function - no DOM required + */ +export function getEdgePath(options: EdgePathOptions): EdgePathResult { + const { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, borderRadius = 8 } = + options; + + // Adjust endpoints to handle tips + const source = adjustSource(sourceX, sourceY, sourcePosition); + const target = adjustTarget(targetX, targetY, targetPosition); + + // Get smooth step path from @xyflow + const [path] = getSmoothStepPath({ + sourceX: source.x, + sourceY: source.y, + sourcePosition, + targetX: target.x, + targetY: target.y, + targetPosition, + borderRadius + }); + + // Calculate arrow position and angle + const arrow = calculateArrowTransform(target.x, target.y, targetPosition); + + return { path, arrow }; +} diff --git a/src/lib/export/svg/edges.ts b/src/lib/export/svg/edges.ts new file mode 100644 index 00000000..641633e0 --- /dev/null +++ b/src/lib/export/svg/edges.ts @@ -0,0 +1,99 @@ +/** + * Edge rendering for SVG export + */ + +import type { Edge, Position } from '@xyflow/svelte'; +import type { NodeInstance } from '$lib/types/nodes'; +import type { RenderContext } from './types'; +import { getEdgePath, ARROW_PATH } from './edgePath'; +import { getNodeDimensions } from './nodes'; +import { NODE } from '$lib/constants/dimensions'; +import { getHandlePath } from '$lib/constants/handlePaths'; + +/** Map Position values to handle positions */ +function getHandleCenter( + node: NodeInstance, + handleId: string, + isSource: boolean +): { x: number; y: number; position: Position } { + const { x, y } = node.position; + const { width, height } = getNodeDimensions(node); + const rotation = (node.params?.['_rotation'] as number) || 0; + const isVertical = rotation === 1 || rotation === 3; + + // Determine if this is an input or output handle + const inputIndex = node.inputs.findIndex((p) => p.id === handleId); + const outputIndex = node.outputs.findIndex((p) => p.id === handleId); + const isInput = inputIndex >= 0; + const index = isInput ? inputIndex : outputIndex; + const total = isInput ? node.inputs.length : node.outputs.length; + + // Calculate position along edge + const percent = total === 1 ? 0.5 : (index + 1) / (total + 1); + + // Determine which side based on rotation + let position: Position; + if (isInput) { + position = (['left', 'top', 'right', 'bottom'] as Position[])[rotation]; + } else { + position = (['right', 'bottom', 'left', 'top'] as Position[])[rotation]; + } + + const handlePath = getHandlePath(rotation); + let hx: number, hy: number; + + if (isVertical) { + // Handles on top/bottom + hx = x + width * percent; + if (position === 'top') { + hy = y; + } else { + hy = y + height; + } + } else { + // Handles on left/right + hy = y + height * percent; + if (position === 'left') { + hx = x; + } else { + hx = x + width; + } + } + + return { x: hx, y: hy, position }; +} + +/** Render an edge to SVG */ +export function renderEdge( + edge: Edge, + nodesMap: Map, + ctx: RenderContext +): string { + const sourceNode = nodesMap.get(edge.source); + const targetNode = nodesMap.get(edge.target); + + if (!sourceNode || !targetNode) return ''; + + const sourceHandleId = edge.sourceHandle || `${edge.source}-output-0`; + const targetHandleId = edge.targetHandle || `${edge.target}-input-0`; + + const source = getHandleCenter(sourceNode, sourceHandleId, true); + const target = getHandleCenter(targetNode, targetHandleId, false); + + const { path, arrow } = getEdgePath({ + sourceX: source.x, + sourceY: source.y, + sourcePosition: source.position, + targetX: target.x, + targetY: target.y, + targetPosition: target.position, + borderRadius: 8 + }); + + return ` + + + + +`; +} diff --git a/src/lib/export/svg/events.ts b/src/lib/export/svg/events.ts new file mode 100644 index 00000000..39e586b7 --- /dev/null +++ b/src/lib/export/svg/events.ts @@ -0,0 +1,52 @@ +/** + * Event rendering for SVG export + */ + +import type { EventInstance } from '$lib/types/events'; +import type { RenderContext } from './types'; +import { eventRegistry } from '$lib/events/registry'; +import { EVENT } from '$lib/constants/dimensions'; + +/** Escape XML special characters */ +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** Render an event to SVG */ +export function renderEvent(event: EventInstance, ctx: RenderContext): string { + const cx = event.position.x + EVENT.center; + const cy = event.position.y + EVENT.center; + const color = event.color || ctx.theme.accent; + const typeDef = eventRegistry.get(event.type); + + const parts: string[] = []; + + // Diamond shape (rotated square) + parts.push( + `` + ); + + // Labels + if (ctx.options.showLabels) { + // Event name + const nameY = ctx.options.showTypeLabels ? cy - 4 : cy; + parts.push( + `${escapeXml(event.name)}` + ); + + // Type label + if (ctx.options.showTypeLabels && typeDef) { + parts.push( + `${escapeXml(typeDef.name)}` + ); + } + } + + return ` + ${parts.join('\n\t')} +`; +} diff --git a/src/lib/export/svg/index.ts b/src/lib/export/svg/index.ts new file mode 100644 index 00000000..e4a37f7a --- /dev/null +++ b/src/lib/export/svg/index.ts @@ -0,0 +1,10 @@ +/** + * SVG Export Module + * + * Pure SVG rendering from graph state - no DOM scraping. + * Single source of truth for dimensions, handle paths, and theme colors. + */ + +export { exportToSVG } from './renderer'; +export type { ExportOptions, RenderContext, Bounds } from './types'; +export { DEFAULT_OPTIONS } from './types'; diff --git a/src/lib/export/svg/nodes.ts b/src/lib/export/svg/nodes.ts new file mode 100644 index 00000000..898cff69 --- /dev/null +++ b/src/lib/export/svg/nodes.ts @@ -0,0 +1,250 @@ +/** + * Node rendering for SVG export + */ + +import type { NodeInstance } from '$lib/types/nodes'; +import type { RenderContext } from './types'; +import { nodeRegistry } from '$lib/nodes'; +import { getShape, getShapeForCategory } from '$lib/nodes/shapes'; +import { calculateNodeDimensions, NODE } from '$lib/constants/dimensions'; +import { getHandlePath } from '$lib/constants/handlePaths'; +import { Position } from '@xyflow/svelte'; + +/** Escape XML special characters */ +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** Get node dimensions */ +export function getNodeDimensions(node: NodeInstance): { width: number; height: number } { + const rotation = (node.params?.['_rotation'] as number) || 0; + return calculateNodeDimensions(node.inputs.length, node.outputs.length, rotation); +} + +/** Calculate handle position on node edge */ +function getHandlePosition( + index: number, + total: number, + side: 'input' | 'output', + rotation: number, + nodeWidth: number, + nodeHeight: number +): { x: number; y: number; position: Position } { + const isVertical = rotation === 1 || rotation === 3; + + // Calculate percent position (centered distribution) + const percent = total === 1 ? 0.5 : (index + 1) / (total + 1); + + // Determine which side based on rotation and input/output + let position: Position; + if (side === 'input') { + position = + rotation === 0 + ? Position.Left + : rotation === 1 + ? Position.Top + : rotation === 2 + ? Position.Right + : Position.Bottom; + } else { + position = + rotation === 0 + ? Position.Right + : rotation === 1 + ? Position.Bottom + : rotation === 2 + ? Position.Left + : Position.Top; + } + + // Calculate position + let x: number, y: number; + const handlePath = getHandlePath(rotation); + + if (isVertical) { + // Handles on top/bottom - position along width + x = nodeWidth * percent - handlePath.width / 2; + y = position === Position.Top ? -handlePath.height / 2 : nodeHeight - handlePath.height / 2; + } else { + // Handles on left/right - position along height + x = position === Position.Left ? -handlePath.width / 2 : nodeWidth - handlePath.width / 2; + y = nodeHeight * percent - handlePath.height / 2; + } + + return { x, y, position }; +} + +/** Render a single handle */ +function renderHandle( + handleX: number, + handleY: number, + rotation: number, + ctx: RenderContext +): string { + const paths = getHandlePath(rotation); + + // Render two-layer hollow handle (outer border + inner cutout) + return ` + + + `; +} + +/** Render node handles */ +function renderHandles( + node: NodeInstance, + nodeWidth: number, + nodeHeight: number, + ctx: RenderContext +): string { + if (!ctx.options.showHandles) return ''; + + const rotation = (node.params?.['_rotation'] as number) || 0; + const handles: string[] = []; + + // Input handles + for (let i = 0; i < node.inputs.length; i++) { + const pos = getHandlePosition(i, node.inputs.length, 'input', rotation, nodeWidth, nodeHeight); + handles.push(renderHandle(pos.x, pos.y, rotation, ctx)); + } + + // Output handles + for (let i = 0; i < node.outputs.length; i++) { + const pos = getHandlePosition( + i, + node.outputs.length, + 'output', + rotation, + nodeWidth, + nodeHeight + ); + handles.push(renderHandle(pos.x, pos.y, rotation, ctx)); + } + + return handles.join('\n\t\t'); +} + +/** Get SVG path for a rounded rectangle with potentially different corner radii */ +function getRoundedRectPath( + x: number, + y: number, + width: number, + height: number, + radius: number | [number, number, number, number] +): string { + let tl: number, tr: number, br: number, bl: number; + + if (Array.isArray(radius)) { + [tl, tr, br, bl] = radius; + } else { + tl = tr = br = bl = radius; + } + + // Clamp radii to half of smallest dimension + const maxRadius = Math.min(width, height) / 2; + tl = Math.min(tl, maxRadius); + tr = Math.min(tr, maxRadius); + br = Math.min(br, maxRadius); + bl = Math.min(bl, maxRadius); + + return `M ${x + tl} ${y} + L ${x + width - tr} ${y} + Q ${x + width} ${y} ${x + width} ${y + tr} + L ${x + width} ${y + height - br} + Q ${x + width} ${y + height} ${x + width - br} ${y + height} + L ${x + bl} ${y + height} + Q ${x} ${y + height} ${x} ${y + height - bl} + L ${x} ${y + tl} + Q ${x} ${y} ${x + tl} ${y} + Z`; +} + +/** Render node shape (rectangle with border radius) */ +function renderNodeShape( + x: number, + y: number, + width: number, + height: number, + node: NodeInstance, + ctx: RenderContext +): string { + const typeDef = nodeRegistry.get(node.type); + const category = typeDef?.category || 'default'; + const shapeId = getShapeForCategory(category); + const shape = getShape(shapeId); + + const isSubsystem = node.type === 'Subsystem' || node.type === 'Interface'; + const strokeDasharray = isSubsystem ? 'stroke-dasharray="4 2"' : ''; + + // For Recording nodes, use min dimension / 2 for circular shape + let radius = shape?.svgRadius ?? 8; + if (category === 'Recording') { + radius = Math.min(width, height) / 2; + } + + // Use path for mixed radius, rect for uniform + if (Array.isArray(radius)) { + const path = getRoundedRectPath(x, y, width, height, radius); + return ``; + } + + return ``; +} + +/** Render node labels */ +function renderNodeLabels( + x: number, + y: number, + width: number, + height: number, + node: NodeInstance, + ctx: RenderContext +): string { + if (!ctx.options.showLabels) return ''; + + const typeDef = nodeRegistry.get(node.type); + const color = node.color || ctx.theme.accent; + + const parts: string[] = []; + + // Node name + const nameY = ctx.options.showTypeLabels ? y + height / 2 - 3 : y + height / 2; + parts.push( + `${escapeXml(node.name)}` + ); + + // Type label + if (ctx.options.showTypeLabels && typeDef) { + parts.push( + `${escapeXml(typeDef.name)}` + ); + } + + return parts.join('\n\t\t'); +} + +/** Render a node to SVG */ +export function renderNode(node: NodeInstance, ctx: RenderContext): string { + const { x, y } = node.position; + const { width, height } = getNodeDimensions(node); + + const parts: string[] = []; + + // Node shape (rendered at 0,0 within group) + parts.push(renderNodeShape(0, 0, width, height, node, ctx)); + + // Labels (rendered at 0,0 within group) + parts.push(renderNodeLabels(0, 0, width, height, node, ctx)); + + // Handles (already relative to 0,0) + const handles = renderHandles(node, width, height, ctx); + if (handles) parts.push(handles); + + return ` + ${parts.filter(Boolean).join('\n\t')} +`; +} diff --git a/src/lib/export/svg/renderer.ts b/src/lib/export/svg/renderer.ts new file mode 100644 index 00000000..c960a3f2 --- /dev/null +++ b/src/lib/export/svg/renderer.ts @@ -0,0 +1,156 @@ +/** + * SVG Renderer - Main export function + * + * Pure rendering from graph state - no DOM scraping required. + * Uses centralized constants for dimensions, handle paths, and theme colors. + */ + +import { get } from 'svelte/store'; +import { graphStore } from '$lib/stores/graph'; +import { eventStore } from '$lib/stores/events'; +import { getThemeColors } from '$lib/constants/theme'; +import { EXPORT_PADDING, EVENT } from '$lib/constants/dimensions'; +import { renderNode, getNodeDimensions } from './nodes'; +import { renderEdge } from './edges'; +import { renderEvent } from './events'; +import type { ExportOptions, RenderContext, Bounds, DEFAULT_OPTIONS } from './types'; +import type { NodeInstance } from '$lib/types/nodes'; +import type { EventInstance } from '$lib/types/events'; + +/** Calculate bounding box for all elements */ +function calculateBounds(nodes: NodeInstance[], events: EventInstance[]): Bounds { + const bounds: Bounds = { + minX: Infinity, + minY: Infinity, + maxX: -Infinity, + maxY: -Infinity + }; + + for (const node of nodes) { + const { width, height } = getNodeDimensions(node); + bounds.minX = Math.min(bounds.minX, node.position.x); + bounds.minY = Math.min(bounds.minY, node.position.y); + bounds.maxX = Math.max(bounds.maxX, node.position.x + width); + bounds.maxY = Math.max(bounds.maxY, node.position.y + height); + } + + for (const event of events) { + bounds.minX = Math.min(bounds.minX, event.position.x); + bounds.minY = Math.min(bounds.minY, event.position.y); + bounds.maxX = Math.max(bounds.maxX, event.position.x + EVENT.size); + bounds.maxY = Math.max(bounds.maxY, event.position.y + EVENT.size); + } + + // Return default bounds if no elements + if (!isFinite(bounds.minX)) { + return { minX: 0, minY: 0, maxX: 200, maxY: 200 }; + } + + return bounds; +} + +/** Render SVG header */ +function renderHeader(bounds: Bounds, padding: number): string { + const width = bounds.maxX - bounds.minX + padding * 2; + const height = bounds.maxY - bounds.minY + padding * 2; + const viewBox = `${bounds.minX - padding} ${bounds.minY - padding} ${width} ${height}`; + + return ` +`; +} + +/** Render background rectangle */ +function renderBackground(bounds: Bounds, padding: number, ctx: RenderContext): string { + const x = bounds.minX - padding; + const y = bounds.minY - padding; + const width = bounds.maxX - bounds.minX + padding * 2; + const height = bounds.maxY - bounds.minY + padding * 2; + + return ``; +} + +/** Default export options */ +const DEFAULTS: Required = { + theme: 'auto', + background: 'transparent', + padding: EXPORT_PADDING, + showLabels: true, + showTypeLabels: true, + showHandles: true +}; + +/** + * Export the current graph as SVG string + * + * @param options - Export options + * @returns SVG string + */ +export function exportToSVG(options: ExportOptions = {}): string { + // Merge options with defaults + const opts: Required = { ...DEFAULTS, ...options }; + + // Resolve theme + const themeColors = getThemeColors(opts.theme); + + // Create render context + const ctx: RenderContext = { + theme: themeColors, + options: opts + }; + + // Get graph data + const nodes = get(graphStore.nodesArray); + const edges = get(graphStore.edgesArray); + const events = get(eventStore.eventsArray); + + // Create nodes map for edge lookups + const nodesMap = new Map(); + for (const node of nodes) { + nodesMap.set(node.id, node); + } + + // Calculate bounds + const bounds = calculateBounds(nodes, events); + + // Build SVG + const parts: string[] = []; + + // Header + parts.push(renderHeader(bounds, opts.padding)); + + // Background + if (opts.background === 'solid') { + parts.push(renderBackground(bounds, opts.padding, ctx)); + } + + // Edges (rendered below nodes) + if (edges.length > 0) { + parts.push(''); + for (const edge of edges) { + parts.push(renderEdge(edge, nodesMap, ctx)); + } + parts.push(''); + } + + // Events + if (events.length > 0) { + parts.push(''); + for (const event of events) { + parts.push(renderEvent(event, ctx)); + } + parts.push(''); + } + + // Nodes (rendered above edges) + if (nodes.length > 0) { + parts.push(''); + for (const node of nodes) { + parts.push(renderNode(node, ctx)); + } + parts.push(''); + } + + parts.push(''); + + return parts.join('\n'); +} diff --git a/src/lib/export/svg/types.ts b/src/lib/export/svg/types.ts new file mode 100644 index 00000000..85451e03 --- /dev/null +++ b/src/lib/export/svg/types.ts @@ -0,0 +1,47 @@ +/** + * Types for SVG export + */ + +import type { ThemeColors, ThemeName } from '$lib/constants/theme'; + +/** SVG export options */ +export interface ExportOptions { + /** Theme to use: 'light', 'dark', or 'auto' (detects from document) */ + theme?: ThemeName | 'auto'; + /** Background style: 'transparent' or 'solid' */ + background?: 'transparent' | 'solid'; + /** Padding around content in pixels */ + padding?: number; + /** Whether to render node labels (default: true) */ + showLabels?: boolean; + /** Whether to render type labels below node names (default: true) */ + showTypeLabels?: boolean; + /** Whether to render handle shapes (default: true) */ + showHandles?: boolean; +} + +/** Render context passed to all renderers */ +export interface RenderContext { + /** Resolved theme colors */ + theme: ThemeColors; + /** Export options */ + options: Required; +} + +/** Bounding box */ +export interface Bounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +/** Default export options */ +export const DEFAULT_OPTIONS: Required = { + theme: 'auto', + background: 'transparent', + padding: 40, + showLabels: true, + showTypeLabels: true, + showHandles: true +}; From 644165c2584a7589344f4b1167ae331edda801dd Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 18 Jan 2026 09:29:16 +0100 Subject: [PATCH 077/656] Wire up new SVG renderer to export UI --- src/lib/components/contextMenuBuilders.ts | 5 ++- src/lib/export/svg/edges.ts | 45 +++++++++-------------- src/lib/export/svg/renderer.ts | 18 ++++----- 3 files changed, 30 insertions(+), 38 deletions(-) diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index f76655ba..eb5ecc62 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -20,7 +20,8 @@ import { generateBlockCodeHeader, generateEventCodeHeader } from '$lib/utils/cod import { exportComponent } from '$lib/schema/componentOps'; import { openImportDialog } from '$lib/schema/fileOps'; import { hasExportableData, exportRecordingData } from '$lib/utils/csvExport'; -import { exportGraphAsSvg } from '$lib/utils/svgExport'; +import { exportToSVG } from '$lib/export/svg'; +import { downloadSvg } from '$lib/utils/download'; /** Divider menu item */ const DIVIDER: MenuItemType = { label: '', action: () => {}, divider: true }; @@ -344,7 +345,7 @@ function buildCanvasMenu( { label: 'Export SVG', icon: 'image', - action: () => exportGraphAsSvg() + action: () => downloadSvg(exportToSVG(), 'pathview-graph.svg') } ]; diff --git a/src/lib/export/svg/edges.ts b/src/lib/export/svg/edges.ts index 641633e0..52bcaaf0 100644 --- a/src/lib/export/svg/edges.ts +++ b/src/lib/export/svg/edges.ts @@ -2,44 +2,38 @@ * Edge rendering for SVG export */ -import type { Edge, Position } from '@xyflow/svelte'; -import type { NodeInstance } from '$lib/types/nodes'; +import type { Position } from '@xyflow/svelte'; +import type { NodeInstance, Connection } from '$lib/types/nodes'; import type { RenderContext } from './types'; import { getEdgePath, ARROW_PATH } from './edgePath'; import { getNodeDimensions } from './nodes'; -import { NODE } from '$lib/constants/dimensions'; -import { getHandlePath } from '$lib/constants/handlePaths'; -/** Map Position values to handle positions */ +/** Calculate handle center position for a node */ function getHandleCenter( node: NodeInstance, - handleId: string, - isSource: boolean + portIndex: number, + isOutput: boolean ): { x: number; y: number; position: Position } { const { x, y } = node.position; const { width, height } = getNodeDimensions(node); const rotation = (node.params?.['_rotation'] as number) || 0; const isVertical = rotation === 1 || rotation === 3; - // Determine if this is an input or output handle - const inputIndex = node.inputs.findIndex((p) => p.id === handleId); - const outputIndex = node.outputs.findIndex((p) => p.id === handleId); - const isInput = inputIndex >= 0; - const index = isInput ? inputIndex : outputIndex; - const total = isInput ? node.inputs.length : node.outputs.length; + const total = isOutput ? node.outputs.length : node.inputs.length; // Calculate position along edge - const percent = total === 1 ? 0.5 : (index + 1) / (total + 1); + const percent = total === 1 ? 0.5 : (portIndex + 1) / (total + 1); // Determine which side based on rotation + // Inputs: left(0), top(1), right(2), bottom(3) + // Outputs: right(0), bottom(1), left(2), top(3) let position: Position; - if (isInput) { + if (!isOutput) { position = (['left', 'top', 'right', 'bottom'] as Position[])[rotation]; } else { position = (['right', 'bottom', 'left', 'top'] as Position[])[rotation]; } - const handlePath = getHandlePath(rotation); let hx: number, hy: number; if (isVertical) { @@ -63,22 +57,19 @@ function getHandleCenter( return { x: hx, y: hy, position }; } -/** Render an edge to SVG */ -export function renderEdge( - edge: Edge, +/** Render a connection to SVG */ +export function renderConnection( + connection: Connection, nodesMap: Map, ctx: RenderContext ): string { - const sourceNode = nodesMap.get(edge.source); - const targetNode = nodesMap.get(edge.target); + const sourceNode = nodesMap.get(connection.sourceNodeId); + const targetNode = nodesMap.get(connection.targetNodeId); if (!sourceNode || !targetNode) return ''; - const sourceHandleId = edge.sourceHandle || `${edge.source}-output-0`; - const targetHandleId = edge.targetHandle || `${edge.target}-input-0`; - - const source = getHandleCenter(sourceNode, sourceHandleId, true); - const target = getHandleCenter(targetNode, targetHandleId, false); + const source = getHandleCenter(sourceNode, connection.sourcePortIndex, true); + const target = getHandleCenter(targetNode, connection.targetPortIndex, false); const { path, arrow } = getEdgePath({ sourceX: source.x, @@ -90,7 +81,7 @@ export function renderEdge( borderRadius: 8 }); - return ` + return ` diff --git a/src/lib/export/svg/renderer.ts b/src/lib/export/svg/renderer.ts index c960a3f2..fa68f4dd 100644 --- a/src/lib/export/svg/renderer.ts +++ b/src/lib/export/svg/renderer.ts @@ -11,10 +11,10 @@ import { eventStore } from '$lib/stores/events'; import { getThemeColors } from '$lib/constants/theme'; import { EXPORT_PADDING, EVENT } from '$lib/constants/dimensions'; import { renderNode, getNodeDimensions } from './nodes'; -import { renderEdge } from './edges'; +import { renderConnection } from './edges'; import { renderEvent } from './events'; -import type { ExportOptions, RenderContext, Bounds, DEFAULT_OPTIONS } from './types'; -import type { NodeInstance } from '$lib/types/nodes'; +import type { ExportOptions, RenderContext, Bounds } from './types'; +import type { NodeInstance, Connection } from '$lib/types/nodes'; import type { EventInstance } from '$lib/types/events'; /** Calculate bounding box for all elements */ @@ -100,10 +100,10 @@ export function exportToSVG(options: ExportOptions = {}): string { // Get graph data const nodes = get(graphStore.nodesArray); - const edges = get(graphStore.edgesArray); + const connections = get(graphStore.connections); const events = get(eventStore.eventsArray); - // Create nodes map for edge lookups + // Create nodes map for connection lookups const nodesMap = new Map(); for (const node of nodes) { nodesMap.set(node.id, node); @@ -123,11 +123,11 @@ export function exportToSVG(options: ExportOptions = {}): string { parts.push(renderBackground(bounds, opts.padding, ctx)); } - // Edges (rendered below nodes) - if (edges.length > 0) { + // Connections (rendered below nodes) + if (connections.length > 0) { parts.push(''); - for (const edge of edges) { - parts.push(renderEdge(edge, nodesMap, ctx)); + for (const connection of connections) { + parts.push(renderConnection(connection, nodesMap, ctx)); } parts.push(''); } From ae9f7b30a61a486f96bd26475b25284d90eeaccf Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 18 Jan 2026 09:30:58 +0100 Subject: [PATCH 078/656] Remove old DOM-scraping svgExport.ts --- src/lib/utils/svgExport.ts | 322 ------------------------------------- 1 file changed, 322 deletions(-) delete mode 100644 src/lib/utils/svgExport.ts diff --git a/src/lib/utils/svgExport.ts b/src/lib/utils/svgExport.ts deleted file mode 100644 index 7f18fca2..00000000 --- a/src/lib/utils/svgExport.ts +++ /dev/null @@ -1,322 +0,0 @@ -/** - * SVG Export Module - * - * Exports the current graph view as a clean SVG file. - * Uses a hybrid approach: - * - Extracts actual edge paths from SvelteFlow's rendered DOM - * - Renders simplified node/event shapes with text labels - * - Extracts handle positions from DOM for accurate placement - */ - -import { get } from 'svelte/store'; -import { graphStore } from '$lib/stores/graph'; -import { eventStore } from '$lib/stores/events'; -import { nodeRegistry } from '$lib/nodes'; -import { eventRegistry } from '$lib/events/registry'; -import type { NodeInstance } from '$lib/types/nodes'; -import type { EventInstance } from '$lib/types/events'; -import { downloadSvg } from './download'; - -// ============================================================================ -// CONFIGURATION -// ============================================================================ - -/** Layout constants */ -const PADDING = 40; -const EVENT_SIZE = 80; -const EVENT_CENTER = EVENT_SIZE / 2; // 40 -const EVENT_DIAMOND_SIZE = 56; -const EVENT_DIAMOND_OFFSET = EVENT_DIAMOND_SIZE / 2; // 28 - -/** Node dimension defaults */ -const NODE_BASE_WIDTH = 90; -const NODE_BASE_HEIGHT = 36; -const NODE_PORT_SPACING = 18; - -/** Handle arrow paths for each rotation direction (from BaseNode.svelte clip-paths) */ -const HANDLE_PATHS: Record = { - 0: { - path: 'M 1 0 L 5 0 Q 6 0 6.71 0.71 L 9.29 3.29 Q 10 4 9.29 4.71 L 6.71 7.29 Q 6 8 5 8 L 1 8 Q 0 8 0 7 L 0 1 Q 0 0 1 0 Z', - width: 10, - height: 8 - }, - 1: { - path: 'M 1 0 L 7 0 Q 8 0 8 1 L 8 5 Q 8 6 7.29 6.71 L 4.71 9.29 Q 4 10 3.29 9.29 L 0.71 6.71 Q 0 6 0 5 L 0 1 Q 0 0 1 0 Z', - width: 8, - height: 10 - }, - 2: { - path: 'M 5 0 L 9 0 Q 10 0 10 1 L 10 7 Q 10 8 9 8 L 5 8 Q 4 8 3.29 7.29 L 0.71 4.71 Q 0 4 0.71 3.29 L 3.29 0.71 Q 4 0 5 0 Z', - width: 10, - height: 8 - }, - 3: { - path: 'M 4.71 0.71 L 7.29 3.29 Q 8 4 8 5 L 8 9 Q 8 10 7 10 L 1 10 Q 0 10 0 9 L 0 5 Q 0 4 0.71 3.29 L 3.29 0.71 Q 4 0 4.71 0.71 Z', - width: 8, - height: 10 - } -}; - -/** Node border radius by category */ -const BORDER_RADIUS: Record = { - Sources: 20, - Recording: 16, // Will be overridden to create circle - Algebraic: 4, - default: 8 -}; - -// ============================================================================ -// TYPES -// ============================================================================ - -export interface ExportOptions { - filename?: string; - includeBackground?: boolean; -} - -interface Colors { - edge: string; - text: string; - textMuted: string; - accent: string; - surface: string; -} - -interface Bounds { - minX: number; - minY: number; - maxX: number; - maxY: number; -} - -// ============================================================================ -// UTILITIES -// ============================================================================ - -/** Get current theme colors from CSS variables */ -function getColors(): Colors { - const style = getComputedStyle(document.documentElement); - const get = (name: string, fallback: string) => style.getPropertyValue(name).trim() || fallback; - - return { - edge: get('--edge', '#7F7F7F'), - text: get('--text', '#f0f0f5'), - textMuted: get('--text-muted', '#808090'), - accent: get('--accent', '#0070C0'), - surface: get('--surface', '#08080c') - }; -} - -/** Get current viewport zoom level */ -function getZoom(): number { - const viewport = document.querySelector('.svelte-flow__viewport') as HTMLElement; - if (!viewport) return 1; - - const match = viewport.style.transform.match(/scale\(([^)]+)\)/); - return match ? parseFloat(match[1]) : 1; -} - -/** Escape special XML characters */ -function escapeXml(str: string): string { - return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} - -/** Get node dimensions from DOM or calculate fallback */ -function getNodeDimensions(node: NodeInstance): { width: number; height: number } { - const nodeEl = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement; - if (nodeEl) { - const rect = nodeEl.getBoundingClientRect(); - const zoom = getZoom(); - return { width: rect.width / zoom, height: rect.height / zoom }; - } - - // Fallback calculation - const rotation = (node.params?.['_rotation'] as number) || 0; - const isVertical = rotation === 1 || rotation === 3; - const maxPorts = Math.max(node.inputs.length, node.outputs.length); - - return { - width: isVertical ? Math.max(NODE_BASE_WIDTH, maxPorts * NODE_PORT_SPACING + 20) : NODE_BASE_WIDTH, - height: isVertical ? NODE_BASE_HEIGHT : Math.max(NODE_BASE_HEIGHT, maxPorts * NODE_PORT_SPACING + 10) - }; -} - -// ============================================================================ -// DOM EXTRACTION -// ============================================================================ - -/** Extract edge paths and arrows from SvelteFlow's rendered DOM */ -function extractEdges(colors: Colors): string { - const container = document.querySelector('.svelte-flow__edges'); - if (!container) return ''; - - let svg = ''; - - container.querySelectorAll('.svelte-flow__edge').forEach((edge) => { - // Main edge path - const pathEl = edge.querySelector('.svelte-flow__edge-path'); - if (pathEl) { - const d = pathEl.getAttribute('d'); - if (d) { - svg += `\n\t\t`; - } - } - - // Arrow head - const arrowGroup = edge.querySelector('g[transform*="rotate"]'); - if (arrowGroup) { - const transform = arrowGroup.getAttribute('transform'); - const arrowPath = arrowGroup.querySelector('path'); - if (arrowPath && transform) { - const d = arrowPath.getAttribute('d'); - if (d) { - svg += `\n\t\t`; - } - } - } - }); - - return svg; -} - -/** Extract handle positions and render them */ -function extractHandles(nodeId: string, nodeX: number, nodeY: number, colors: Colors): string { - const wrapper = document.querySelector(`[data-id="${nodeId}"]`); - if (!wrapper) return ''; - - const nodeEl = wrapper.querySelector('[data-rotation]') || wrapper; - const rotation = parseInt(nodeEl.getAttribute('data-rotation') || '0'); - const handleDef = HANDLE_PATHS[rotation] || HANDLE_PATHS[0]; - const zoom = getZoom(); - const nodeRect = wrapper.getBoundingClientRect(); - - let svg = ''; - - nodeEl.querySelectorAll('.svelte-flow__handle').forEach((handle) => { - const rect = handle.getBoundingClientRect(); - const cx = (rect.left + rect.width / 2 - nodeRect.left) / zoom; - const cy = (rect.top + rect.height / 2 - nodeRect.top) / zoom; - const x = nodeX + cx - handleDef.width / 2; - const y = nodeY + cy - handleDef.height / 2; - - svg += `\n\t\t`; - }); - - return svg; -} - -// ============================================================================ -// ELEMENT RENDERERS -// ============================================================================ - -function renderNode(node: NodeInstance, colors: Colors): string { - const { width, height } = getNodeDimensions(node); - const { x, y } = node.position; - const typeDef = nodeRegistry.get(node.type); - const color = node.color || colors.accent; - const isSubsystem = node.type === 'Subsystem' || node.type === 'Interface'; - - // Determine border radius - let rx = BORDER_RADIUS[typeDef?.category || 'default'] || BORDER_RADIUS.default; - if (typeDef?.category === 'Recording') rx = Math.min(width, height) / 2; - - const handles = extractHandles(node.id, x, y, colors); - - return ` - - - ${escapeXml(node.name)} - ${escapeXml(typeDef?.name || node.type)}${handles} - `; -} - -function renderEvent(event: EventInstance, colors: Colors): string { - const cx = event.position.x + EVENT_CENTER; - const cy = event.position.y + EVENT_CENTER; - const color = event.color || colors.accent; - const typeDef = eventRegistry.get(event.type); - - return ` - - - ${escapeXml(event.name)} - ${escapeXml(typeDef?.name || '')} - `; -} - -// ============================================================================ -// BOUNDS CALCULATION -// ============================================================================ - -function calculateBounds(nodes: NodeInstance[], events: EventInstance[]): Bounds { - const bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; - - for (const node of nodes) { - const { width, height } = getNodeDimensions(node); - bounds.minX = Math.min(bounds.minX, node.position.x); - bounds.minY = Math.min(bounds.minY, node.position.y); - bounds.maxX = Math.max(bounds.maxX, node.position.x + width); - bounds.maxY = Math.max(bounds.maxY, node.position.y + height); - } - - for (const event of events) { - bounds.minX = Math.min(bounds.minX, event.position.x); - bounds.minY = Math.min(bounds.minY, event.position.y); - bounds.maxX = Math.max(bounds.maxX, event.position.x + EVENT_SIZE); - bounds.maxY = Math.max(bounds.maxY, event.position.y + EVENT_SIZE); - } - - return isFinite(bounds.minX) ? bounds : { minX: 0, minY: 0, maxX: 200, maxY: 200 }; -} - -// ============================================================================ -// MAIN EXPORT -// ============================================================================ - -/** Export the current graph as an SVG file */ -export function exportGraphAsSvg(options: ExportOptions = {}): void { - const { filename = 'pathview-graph', includeBackground = false } = options; - - const colors = getColors(); - const nodes = get(graphStore.nodesArray); - const events = get(eventStore.eventsArray); - - // Calculate SVG dimensions - const bounds = calculateBounds(nodes, events); - const width = bounds.maxX - bounds.minX + PADDING * 2; - const height = bounds.maxY - bounds.minY + PADDING * 2; - const viewBox = `${bounds.minX - PADDING} ${bounds.minY - PADDING} ${width} ${height}`; - - // Build SVG content - const parts: string[] = [ - ``, - `` - ]; - - if (includeBackground) { - parts.push(`\t`); - } - - // Edges (extracted from DOM) - parts.push(`\n\t${extractEdges(colors)}\n\t`); - - // Events - if (events.length > 0) { - parts.push(`\n\t${events.map((e) => renderEvent(e, colors)).join('')}\n\t`); - } - - // Nodes - if (nodes.length > 0) { - parts.push(`\n\t${nodes.map((n) => renderNode(n, colors)).join('')}\n\t`); - } - - parts.push(''); - - downloadSvg(parts.join('\n'), `${filename}.svg`); -} From db2478bb6c37e8ce98470118f648a075fd6e11f9 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 18 Jan 2026 09:31:32 +0100 Subject: [PATCH 079/656] Use centralized NODE constants in BaseNode --- src/lib/components/nodes/BaseNode.svelte | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index de8c8c15..b4ebf7ac 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -11,6 +11,7 @@ import { showTooltip, hideTooltip } from '$lib/components/Tooltip.svelte'; import { paramInput } from '$lib/actions/paramInput'; import { plotDataStore } from '$lib/plotting/processing/plotDataStore'; + import { NODE } from '$lib/constants/dimensions'; import PlotPreview from './PlotPreview.svelte'; interface Props { @@ -124,16 +125,11 @@ } }); - // Node size constants - const NODE_PORT_SPACING = 18; // pixels per port - const NODE_BASE_HEIGHT = 36; - const NODE_BASE_WIDTH = 90; - const maxPortsOnSide = $derived(Math.max(data.inputs.length, data.outputs.length)); // For horizontal layout: height grows with ports; for vertical: width grows - const nodeHeight = $derived(isVertical ? NODE_BASE_HEIGHT : Math.max(NODE_BASE_HEIGHT, maxPortsOnSide * NODE_PORT_SPACING + 10)); - const nodeWidth = $derived(isVertical ? Math.max(NODE_BASE_WIDTH, maxPortsOnSide * NODE_PORT_SPACING + 20) : NODE_BASE_WIDTH); + const nodeHeight = $derived(isVertical ? NODE.baseHeight : Math.max(NODE.baseHeight, maxPortsOnSide * NODE.portSpacing + 10)); + const nodeWidth = $derived(isVertical ? Math.max(NODE.baseWidth, maxPortsOnSide * NODE.portSpacing + 20) : NODE.baseWidth); // Calculate port positions using percentages for proper centering function getPortPosition(index: number, total: number): string { From 8a2ff3989d35e56e8f6f05bcbf66e4474e81d3dc Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 18 Jan 2026 09:32:27 +0100 Subject: [PATCH 080/656] Remove SVG export refactor plan (completed) --- SVG_EXPORT_REFACTOR_PLAN.md | 463 ------------------------------------ 1 file changed, 463 deletions(-) delete mode 100644 SVG_EXPORT_REFACTOR_PLAN.md diff --git a/SVG_EXPORT_REFACTOR_PLAN.md b/SVG_EXPORT_REFACTOR_PLAN.md deleted file mode 100644 index 6bbc452c..00000000 --- a/SVG_EXPORT_REFACTOR_PLAN.md +++ /dev/null @@ -1,463 +0,0 @@ -# SVG Export Refactoring Plan - -## Goals - -1. **Single source of truth** - No duplication between live canvas and SVG export -2. **Pure rendering** - No DOM scraping, works from graph state alone -3. **Professional output** - Clean, standalone SVGs for docs/presentations -4. **Reuse existing systems** - Shape registry, color definitions, etc. - ---- - -## Current State - -### What exists: -- `src/lib/utils/svgExport.ts` - Current exporter (DOM scraping approach) -- `src/lib/nodes/shapes/registry.ts` - Shape definitions (borderRadius, cssClass) -- `src/lib/utils/colors.ts` - Color palette definitions -- Handle paths in `BaseNode.svelte` CSS (4 rotations × 2 layers) - -### Problems: -1. **Handle paths duplicated** - CSS clip-paths in BaseNode + HANDLE_PATHS in svgExport -2. **Edge paths from DOM** - Requires rendered canvas, fragile -3. **Node dimensions from DOM** - Falls back to calculation but prefers DOM -4. **Colors from CSS variables** - Works but could be more explicit -5. **Shape borderRadius is CSS string** - Can't easily parse for SVG rx/ry - ---- - -## New Architecture - -### Phase 1: Extract Constants (Single Source of Truth) - -Create `src/lib/constants/` with shared definitions: - -``` -src/lib/constants/ -├── dimensions.ts # Node sizes, handle sizes, spacing -├── handles.ts # Handle path data for all rotations -├── theme.ts # Theme color objects (light/dark) -└── index.ts # Re-exports -``` - -#### `dimensions.ts` -```typescript -export const NODE = { - baseWidth: 90, - baseHeight: 36, - portSpacing: 18, - borderWidth: 1 -}; - -export const HANDLE = { - width: 10, - height: 8, - hollowInset: 1.5 // Inner shape offset -}; - -export const EVENT = { - size: 80, - diamondSize: 56 -}; - -export const PADDING = 40; -``` - -#### `handles.ts` -```typescript -// Outer and inner paths for hollow pentagon effect -// Each rotation has both paths for the layered rendering -export const HANDLE_PATHS = { - 0: { // Right-pointing (default) - outer: 'M 1 0 L 5 0 Q 6 0 6.71 0.71 L 9.29 3.29 Q 10 4 9.29 4.71 L 6.71 7.29 Q 6 8 5 8 L 1 8 Q 0 8 0 7 L 0 1 Q 0 0 1 0 Z', - inner: 'M 0.8 0 L 3.79 0 Q 4.59 0 5.15 0.57 L 7.02 2.43 Q 7.59 3 7.02 3.57 L 5.15 5.43 Q 4.59 6 3.79 6 L 0.8 6 Q 0 6 0 5.2 L 0 0.8 Q 0 0 0.8 0 Z', - width: 10, - height: 8 - }, - 1: { // Down-pointing - outer: '...', - inner: '...', - width: 8, - height: 10 - }, - 2: { // Left-pointing - outer: '...', - inner: '...', - width: 10, - height: 8 - }, - 3: { // Up-pointing - outer: '...', - inner: '...', - width: 8, - height: 10 - } -} as const; - -export type HandleRotation = keyof typeof HANDLE_PATHS; -``` - -#### `theme.ts` -```typescript -export interface Theme { - surface: string; - surfaceRaised: string; - border: string; - edge: string; - text: string; - textMuted: string; - accent: string; -} - -export const THEMES: Record<'light' | 'dark', Theme> = { - dark: { - surface: '#08080c', - surfaceRaised: '#12121a', - border: '#2a2a35', - edge: '#7F7F7F', - text: '#f0f0f5', - textMuted: '#808090', - accent: '#0070C0' - }, - light: { - surface: '#ffffff', - surfaceRaised: '#f5f5f7', - border: '#e0e0e5', - edge: '#7F7F7F', - text: '#1a1a1a', - textMuted: '#666666', - accent: '#0070C0' - } -}; - -// Helper to get current theme from CSS (for live canvas) -export function getCurrentTheme(): 'light' | 'dark' { - if (typeof document === 'undefined') return 'dark'; - return document.documentElement.getAttribute('data-theme') as 'light' | 'dark' || 'dark'; -} -``` - ---- - -### Phase 2: Update Shape Registry - -Extend `ShapeDefinition` to include numeric border radius for SVG: - -```typescript -export interface ShapeDefinition { - id: string; - name: string; - cssClass: string; - borderRadius: string; // CSS value (existing) - svgRadius: number | number[]; // NEW: numeric for SVG (single or [rx, ry, rx, ry]) -} -``` - -Update registrations: -```typescript -registerShape({ - id: 'pill', - name: 'Pill', - cssClass: 'shape-pill', - borderRadius: '20px', - svgRadius: 20 -}); - -registerShape({ - id: 'mixed', - name: 'Mixed', - cssClass: 'shape-mixed', - borderRadius: '12px 4px 12px 4px', - svgRadius: [12, 4, 12, 4] // TL, TR, BR, BL -}); -``` - ---- - -### Phase 3: Update BaseNode to Use Constants - -Replace hardcoded CSS clip-paths with references to constants: - -```svelte - - - -``` - -Or use inline styles set via JS that read from constants. - ---- - -### Phase 4: Implement Pure Edge Path Algorithm - -Create `src/lib/export/edgePath.ts`: - -```typescript -interface Point { x: number; y: number; } - -interface EdgePathOptions { - sourceX: number; - sourceY: number; - targetX: number; - targetY: number; - sourcePosition: 'left' | 'right' | 'top' | 'bottom'; - targetPosition: 'left' | 'right' | 'top' | 'bottom'; - borderRadius?: number; -} - -/** - * Pure implementation of smooth step path (same algorithm as SvelteFlow) - * No DOM required. - */ -export function getSmoothStepPath(options: EdgePathOptions): string { - // Implement the smooth step algorithm - // Returns SVG path string -} - -/** - * Calculate arrow position and rotation at end of path - */ -export function getArrowTransform(path: string): { x: number; y: number; angle: number } { - // Use path math, not DOM -} -``` - ---- - -### Phase 5: Build New SVG Renderer - -Create `src/lib/export/svg/`: - -``` -src/lib/export/svg/ -├── types.ts # ExportOptions, RenderContext -├── renderer.ts # Main render function -├── nodes.ts # renderNode, renderHandle -├── edges.ts # renderEdge, renderArrow -├── events.ts # renderEvent -└── index.ts # Public API -``` - -#### `types.ts` -```typescript -export interface ExportOptions { - theme?: 'light' | 'dark' | 'auto'; - background?: 'transparent' | 'solid'; - padding?: number; - showLabels?: boolean; - showHandles?: boolean; - showTypeLabels?: boolean; - selectedOnly?: boolean; - scale?: number; -} - -export interface RenderContext { - theme: Theme; - options: ExportOptions; -} -``` - -#### `renderer.ts` -```typescript -import { get } from 'svelte/store'; -import { graphStore } from '$lib/stores/graph'; -import { eventStore } from '$lib/stores/events'; -import { renderNode } from './nodes'; -import { renderEdge } from './edges'; -import { renderEvent } from './events'; -import { THEMES, getCurrentTheme } from '$lib/constants/theme'; - -export function exportToSVG(options: ExportOptions = {}): string { - const theme = options.theme === 'auto' - ? THEMES[getCurrentTheme()] - : THEMES[options.theme || 'dark']; - - const ctx: RenderContext = { theme, options }; - - const nodes = get(graphStore.nodesArray); - const edges = get(graphStore.edgesArray); - const events = get(eventStore.eventsArray); - - // Calculate bounds - const bounds = calculateBounds(nodes, events); - - // Build SVG - const parts: string[] = []; - - // Header - parts.push(renderHeader(bounds, options)); - - // Background - if (options.background === 'solid') { - parts.push(renderBackground(bounds, ctx)); - } - - // Edges (below nodes) - parts.push(''); - for (const edge of edges) { - parts.push(renderEdge(edge, nodes, ctx)); - } - parts.push(''); - - // Events - parts.push(''); - for (const event of events) { - parts.push(renderEvent(event, ctx)); - } - parts.push(''); - - // Nodes (with handles) - parts.push(''); - for (const node of nodes) { - parts.push(renderNode(node, ctx)); - } - parts.push(''); - - parts.push(''); - - return parts.join('\n'); -} -``` - -#### `nodes.ts` -```typescript -import { HANDLE_PATHS } from '$lib/constants/handles'; -import { NODE } from '$lib/constants/dimensions'; -import { getShape, getShapeForCategory } from '$lib/nodes/shapes'; -import { nodeRegistry } from '$lib/nodes'; - -export function renderNode(node: NodeInstance, ctx: RenderContext): string { - const { x, y } = node.position; - const { width, height } = calculateNodeDimensions(node); - const typeDef = nodeRegistry.get(node.type); - const shape = getShape(getShapeForCategory(typeDef?.category || 'default')); - const color = node.color || ctx.theme.accent; - - const parts: string[] = []; - - // Node rectangle - parts.push(renderNodeShape(x, y, width, height, shape, node, ctx)); - - // Labels - if (ctx.options.showLabels !== false) { - parts.push(renderNodeLabels(x, y, width, height, node, typeDef, color, ctx)); - } - - // Handles - if (ctx.options.showHandles !== false) { - parts.push(renderNodeHandles(node, x, y, width, height, ctx)); - } - - return `${parts.join('')}`; -} - -function renderNodeHandles(node, x, y, width, height, ctx): string { - const rotation = (node.params?.['_rotation'] as number) || 0; - const paths = HANDLE_PATHS[rotation]; - - const handles: string[] = []; - - // Render each input/output handle - for (let i = 0; i < node.inputs.length; i++) { - const pos = calculateHandlePosition('input', i, node.inputs.length, rotation, width, height); - handles.push(renderHandle(x + pos.x, y + pos.y, paths, ctx)); - } - - for (let i = 0; i < node.outputs.length; i++) { - const pos = calculateHandlePosition('output', i, node.outputs.length, rotation, width, height); - handles.push(renderHandle(x + pos.x, y + pos.y, paths, ctx)); - } - - return handles.join(''); -} - -function renderHandle(x: number, y: number, paths, ctx: RenderContext): string { - // Render two-layer hollow handle - return ` - - - - `; -} -``` - ---- - -### Phase 6: Wire Up Export UI - -Update export button/menu to use new renderer: - -```typescript -import { exportToSVG } from '$lib/export/svg'; -import { downloadSvg } from '$lib/utils/download'; - -function handleExport() { - const svg = exportToSVG({ - theme: 'auto', - background: 'transparent', - showLabels: true, - showHandles: true - }); - downloadSvg(svg, 'pathview-graph.svg'); -} -``` - ---- - -### Phase 7: Cleanup - -1. Delete old `src/lib/utils/svgExport.ts` -2. Remove duplicated handle paths from BaseNode CSS (use constants) -3. Update any other references - ---- - -## Migration Steps - -| Phase | Description | Files Changed | -|-------|-------------|---------------| -| 1 | Extract constants | NEW: `src/lib/constants/*` | -| 2 | Update shape registry | `src/lib/nodes/shapes/registry.ts` | -| 3 | Update BaseNode | `src/lib/components/nodes/BaseNode.svelte` | -| 4 | Implement edge path | NEW: `src/lib/export/edgePath.ts` | -| 5 | Build SVG renderer | NEW: `src/lib/export/svg/*` | -| 6 | Wire up UI | `src/routes/+page.svelte` or menu component | -| 7 | Cleanup | DELETE: `src/lib/utils/svgExport.ts` | - ---- - -## Testing Checklist - -- [ ] Export with dark theme matches live canvas -- [ ] Export with light theme has correct colors -- [ ] All node shapes render correctly (pill, rect, circle, diamond, mixed) -- [ ] Handles render as hollow pentagons in all 4 rotations -- [ ] Edge paths match live canvas curves -- [ ] Arrow heads positioned and rotated correctly -- [ ] Events render as diamonds with labels -- [ ] Subsystem nodes have dashed borders -- [ ] Labels are centered and readable -- [ ] Transparent background works -- [ ] Solid background works -- [ ] Export opens correctly in browser, Figma, PowerPoint -- [ ] No DOM access in renderer (works headless) - ---- - -## Future Enhancements - -- Export selection only -- Export specific subsystem -- PNG export (via canvas rasterization) -- Thumbnail generation for file browser -- Copy SVG to clipboard From 2664f88e7c6318568b3562653730c6bc1800c065 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 18 Jan 2026 09:48:22 +0100 Subject: [PATCH 081/656] Fix handle inner path offset and arrow direction --- src/lib/export/svg/edgePath.ts | 18 +++++++++++++----- src/lib/export/svg/nodes.ts | 3 ++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/lib/export/svg/edgePath.ts b/src/lib/export/svg/edgePath.ts index 419bdc60..2a000740 100644 --- a/src/lib/export/svg/edgePath.ts +++ b/src/lib/export/svg/edgePath.ts @@ -80,32 +80,40 @@ function adjustTarget(x: number, y: number, position: Position): { x: number; y: /** * Calculate arrow position and angle from target position * Pure implementation without DOM + * + * The arrow points INTO the target handle (direction of flow entering the node) + * targetPosition indicates which side of the node the handle is on */ function calculateArrowTransform( targetX: number, targetY: number, targetPosition: Position ): { x: number; y: number; angle: number } { - // Arrow points in direction of flow (towards target handle) + // Arrow points INTO the handle (opposite direction of targetPosition) + // Arrow path shape points right by default, so angle=0 means pointing right let angle: number; let offsetX = 0; let offsetY = 0; switch (targetPosition) { case 'left': - angle = 180; + // Handle on left side, edge enters from left → arrow points RIGHT (into node) + angle = 0; offsetX = -ARROW_FORWARD_OFFSET; break; case 'right': - angle = 0; + // Handle on right side, edge enters from right → arrow points LEFT (into node) + angle = 180; offsetX = ARROW_FORWARD_OFFSET; break; case 'top': - angle = -90; + // Handle on top, edge enters from above → arrow points DOWN (into node) + angle = 90; offsetY = -ARROW_FORWARD_OFFSET; break; case 'bottom': - angle = 90; + // Handle on bottom, edge enters from below → arrow points UP (into node) + angle = -90; offsetY = ARROW_FORWARD_OFFSET; break; default: diff --git a/src/lib/export/svg/nodes.ts b/src/lib/export/svg/nodes.ts index 898cff69..bf602134 100644 --- a/src/lib/export/svg/nodes.ts +++ b/src/lib/export/svg/nodes.ts @@ -88,9 +88,10 @@ function renderHandle( const paths = getHandlePath(rotation); // Render two-layer hollow handle (outer border + inner cutout) + // Inner path is offset by 1px on all sides (matches CSS inset: 1px) return ` - + `; } From 06fe97c42b1e0ff643d688e9b57a3f6f0d978751 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 18 Jan 2026 09:49:42 +0100 Subject: [PATCH 082/656] Use explicit shape override from type definition in SVG renderer --- src/lib/export/svg/nodes.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/lib/export/svg/nodes.ts b/src/lib/export/svg/nodes.ts index bf602134..37edfc13 100644 --- a/src/lib/export/svg/nodes.ts +++ b/src/lib/export/svg/nodes.ts @@ -164,6 +164,18 @@ function getRoundedRectPath( Z`; } +/** Get shape for a node (matches getShapeCssClass logic) */ +function getNodeShape(typeDef: ReturnType) { + // First check if type definition has explicit shape override + if (typeDef?.shape) { + const shape = getShape(typeDef.shape); + if (shape) return shape; + } + // Fall back to category-based shape + const shapeId = getShapeForCategory(typeDef?.category || 'default'); + return getShape(shapeId); +} + /** Render node shape (rectangle with border radius) */ function renderNodeShape( x: number, @@ -175,8 +187,7 @@ function renderNodeShape( ): string { const typeDef = nodeRegistry.get(node.type); const category = typeDef?.category || 'default'; - const shapeId = getShapeForCategory(category); - const shape = getShape(shapeId); + const shape = getNodeShape(typeDef); const isSubsystem = node.type === 'Subsystem' || node.type === 'Interface'; const strokeDasharray = isSubsystem ? 'stroke-dasharray="4 2"' : ''; From ec9c435da022eb915f6731355a9942e0f120a1dd Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 18 Jan 2026 09:58:16 +0100 Subject: [PATCH 083/656] Simplify SVG renderer to use DOM for dimensions, edges, and border-radius --- src/lib/export/svg/edgePath.ts | 157 ----------------- src/lib/export/svg/edges.ts | 90 ---------- src/lib/export/svg/events.ts | 52 ------ src/lib/export/svg/nodes.ts | 262 --------------------------- src/lib/export/svg/renderer.ts | 311 ++++++++++++++++++++++++--------- 5 files changed, 232 insertions(+), 640 deletions(-) delete mode 100644 src/lib/export/svg/edgePath.ts delete mode 100644 src/lib/export/svg/edges.ts delete mode 100644 src/lib/export/svg/events.ts delete mode 100644 src/lib/export/svg/nodes.ts diff --git a/src/lib/export/svg/edgePath.ts b/src/lib/export/svg/edgePath.ts deleted file mode 100644 index 2a000740..00000000 --- a/src/lib/export/svg/edgePath.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Edge path utilities for SVG export - * - * Uses @xyflow/svelte's getSmoothStepPath for path generation - * and provides pure functions for arrow positioning - */ - -import { getSmoothStepPath, type Position } from '@xyflow/svelte'; - -/** Edge endpoint offsets to align with handle tips */ -export const EDGE_OFFSETS = { - source: 0.5, - target: 4.5 -} as const; - -/** Arrow head path (same as ArrowEdge.svelte) */ -export const ARROW_PATH = - 'M -5 -2.5 L -1 -0.5 Q 0 0 -1 0.5 L -5 2.5 Q -6 3 -6 2 L -6 -2 Q -6 -3 -5 -2.5 Z'; - -/** Arrow offset to reach target handle tip */ -export const ARROW_FORWARD_OFFSET = 5; - -export interface EdgePathOptions { - sourceX: number; - sourceY: number; - sourcePosition: Position; - targetX: number; - targetY: number; - targetPosition: Position; - borderRadius?: number; -} - -export interface EdgePathResult { - path: string; - arrow: { - x: number; - y: number; - angle: number; - }; -} - -/** - * Adjust source position to start at handle tip - */ -function adjustSource(x: number, y: number, position: Position): { x: number; y: number } { - const offset = EDGE_OFFSETS.source; - switch (position) { - case 'right': - return { x: x - offset, y }; - case 'left': - return { x: x + offset, y }; - case 'bottom': - return { x, y: y - offset }; - case 'top': - return { x, y: y + offset }; - default: - return { x, y }; - } -} - -/** - * Adjust target position to end at handle tip - */ -function adjustTarget(x: number, y: number, position: Position): { x: number; y: number } { - const offset = EDGE_OFFSETS.target; - switch (position) { - case 'right': - return { x: x + offset, y }; - case 'left': - return { x: x - offset, y }; - case 'bottom': - return { x, y: y + offset }; - case 'top': - return { x, y: y - offset }; - default: - return { x, y }; - } -} - -/** - * Calculate arrow position and angle from target position - * Pure implementation without DOM - * - * The arrow points INTO the target handle (direction of flow entering the node) - * targetPosition indicates which side of the node the handle is on - */ -function calculateArrowTransform( - targetX: number, - targetY: number, - targetPosition: Position -): { x: number; y: number; angle: number } { - // Arrow points INTO the handle (opposite direction of targetPosition) - // Arrow path shape points right by default, so angle=0 means pointing right - let angle: number; - let offsetX = 0; - let offsetY = 0; - - switch (targetPosition) { - case 'left': - // Handle on left side, edge enters from left → arrow points RIGHT (into node) - angle = 0; - offsetX = -ARROW_FORWARD_OFFSET; - break; - case 'right': - // Handle on right side, edge enters from right → arrow points LEFT (into node) - angle = 180; - offsetX = ARROW_FORWARD_OFFSET; - break; - case 'top': - // Handle on top, edge enters from above → arrow points DOWN (into node) - angle = 90; - offsetY = -ARROW_FORWARD_OFFSET; - break; - case 'bottom': - // Handle on bottom, edge enters from below → arrow points UP (into node) - angle = -90; - offsetY = ARROW_FORWARD_OFFSET; - break; - default: - angle = 0; - } - - return { - x: targetX + offsetX, - y: targetY + offsetY, - angle - }; -} - -/** - * Generate edge path and arrow transform - * Pure function - no DOM required - */ -export function getEdgePath(options: EdgePathOptions): EdgePathResult { - const { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, borderRadius = 8 } = - options; - - // Adjust endpoints to handle tips - const source = adjustSource(sourceX, sourceY, sourcePosition); - const target = adjustTarget(targetX, targetY, targetPosition); - - // Get smooth step path from @xyflow - const [path] = getSmoothStepPath({ - sourceX: source.x, - sourceY: source.y, - sourcePosition, - targetX: target.x, - targetY: target.y, - targetPosition, - borderRadius - }); - - // Calculate arrow position and angle - const arrow = calculateArrowTransform(target.x, target.y, targetPosition); - - return { path, arrow }; -} diff --git a/src/lib/export/svg/edges.ts b/src/lib/export/svg/edges.ts deleted file mode 100644 index 52bcaaf0..00000000 --- a/src/lib/export/svg/edges.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Edge rendering for SVG export - */ - -import type { Position } from '@xyflow/svelte'; -import type { NodeInstance, Connection } from '$lib/types/nodes'; -import type { RenderContext } from './types'; -import { getEdgePath, ARROW_PATH } from './edgePath'; -import { getNodeDimensions } from './nodes'; - -/** Calculate handle center position for a node */ -function getHandleCenter( - node: NodeInstance, - portIndex: number, - isOutput: boolean -): { x: number; y: number; position: Position } { - const { x, y } = node.position; - const { width, height } = getNodeDimensions(node); - const rotation = (node.params?.['_rotation'] as number) || 0; - const isVertical = rotation === 1 || rotation === 3; - - const total = isOutput ? node.outputs.length : node.inputs.length; - - // Calculate position along edge - const percent = total === 1 ? 0.5 : (portIndex + 1) / (total + 1); - - // Determine which side based on rotation - // Inputs: left(0), top(1), right(2), bottom(3) - // Outputs: right(0), bottom(1), left(2), top(3) - let position: Position; - if (!isOutput) { - position = (['left', 'top', 'right', 'bottom'] as Position[])[rotation]; - } else { - position = (['right', 'bottom', 'left', 'top'] as Position[])[rotation]; - } - - let hx: number, hy: number; - - if (isVertical) { - // Handles on top/bottom - hx = x + width * percent; - if (position === 'top') { - hy = y; - } else { - hy = y + height; - } - } else { - // Handles on left/right - hy = y + height * percent; - if (position === 'left') { - hx = x; - } else { - hx = x + width; - } - } - - return { x: hx, y: hy, position }; -} - -/** Render a connection to SVG */ -export function renderConnection( - connection: Connection, - nodesMap: Map, - ctx: RenderContext -): string { - const sourceNode = nodesMap.get(connection.sourceNodeId); - const targetNode = nodesMap.get(connection.targetNodeId); - - if (!sourceNode || !targetNode) return ''; - - const source = getHandleCenter(sourceNode, connection.sourcePortIndex, true); - const target = getHandleCenter(targetNode, connection.targetPortIndex, false); - - const { path, arrow } = getEdgePath({ - sourceX: source.x, - sourceY: source.y, - sourcePosition: source.position, - targetX: target.x, - targetY: target.y, - targetPosition: target.position, - borderRadius: 8 - }); - - return ` - - - - -`; -} diff --git a/src/lib/export/svg/events.ts b/src/lib/export/svg/events.ts deleted file mode 100644 index 39e586b7..00000000 --- a/src/lib/export/svg/events.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Event rendering for SVG export - */ - -import type { EventInstance } from '$lib/types/events'; -import type { RenderContext } from './types'; -import { eventRegistry } from '$lib/events/registry'; -import { EVENT } from '$lib/constants/dimensions'; - -/** Escape XML special characters */ -function escapeXml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -/** Render an event to SVG */ -export function renderEvent(event: EventInstance, ctx: RenderContext): string { - const cx = event.position.x + EVENT.center; - const cy = event.position.y + EVENT.center; - const color = event.color || ctx.theme.accent; - const typeDef = eventRegistry.get(event.type); - - const parts: string[] = []; - - // Diamond shape (rotated square) - parts.push( - `` - ); - - // Labels - if (ctx.options.showLabels) { - // Event name - const nameY = ctx.options.showTypeLabels ? cy - 4 : cy; - parts.push( - `${escapeXml(event.name)}` - ); - - // Type label - if (ctx.options.showTypeLabels && typeDef) { - parts.push( - `${escapeXml(typeDef.name)}` - ); - } - } - - return ` - ${parts.join('\n\t')} -`; -} diff --git a/src/lib/export/svg/nodes.ts b/src/lib/export/svg/nodes.ts deleted file mode 100644 index 37edfc13..00000000 --- a/src/lib/export/svg/nodes.ts +++ /dev/null @@ -1,262 +0,0 @@ -/** - * Node rendering for SVG export - */ - -import type { NodeInstance } from '$lib/types/nodes'; -import type { RenderContext } from './types'; -import { nodeRegistry } from '$lib/nodes'; -import { getShape, getShapeForCategory } from '$lib/nodes/shapes'; -import { calculateNodeDimensions, NODE } from '$lib/constants/dimensions'; -import { getHandlePath } from '$lib/constants/handlePaths'; -import { Position } from '@xyflow/svelte'; - -/** Escape XML special characters */ -function escapeXml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -/** Get node dimensions */ -export function getNodeDimensions(node: NodeInstance): { width: number; height: number } { - const rotation = (node.params?.['_rotation'] as number) || 0; - return calculateNodeDimensions(node.inputs.length, node.outputs.length, rotation); -} - -/** Calculate handle position on node edge */ -function getHandlePosition( - index: number, - total: number, - side: 'input' | 'output', - rotation: number, - nodeWidth: number, - nodeHeight: number -): { x: number; y: number; position: Position } { - const isVertical = rotation === 1 || rotation === 3; - - // Calculate percent position (centered distribution) - const percent = total === 1 ? 0.5 : (index + 1) / (total + 1); - - // Determine which side based on rotation and input/output - let position: Position; - if (side === 'input') { - position = - rotation === 0 - ? Position.Left - : rotation === 1 - ? Position.Top - : rotation === 2 - ? Position.Right - : Position.Bottom; - } else { - position = - rotation === 0 - ? Position.Right - : rotation === 1 - ? Position.Bottom - : rotation === 2 - ? Position.Left - : Position.Top; - } - - // Calculate position - let x: number, y: number; - const handlePath = getHandlePath(rotation); - - if (isVertical) { - // Handles on top/bottom - position along width - x = nodeWidth * percent - handlePath.width / 2; - y = position === Position.Top ? -handlePath.height / 2 : nodeHeight - handlePath.height / 2; - } else { - // Handles on left/right - position along height - x = position === Position.Left ? -handlePath.width / 2 : nodeWidth - handlePath.width / 2; - y = nodeHeight * percent - handlePath.height / 2; - } - - return { x, y, position }; -} - -/** Render a single handle */ -function renderHandle( - handleX: number, - handleY: number, - rotation: number, - ctx: RenderContext -): string { - const paths = getHandlePath(rotation); - - // Render two-layer hollow handle (outer border + inner cutout) - // Inner path is offset by 1px on all sides (matches CSS inset: 1px) - return ` - - - `; -} - -/** Render node handles */ -function renderHandles( - node: NodeInstance, - nodeWidth: number, - nodeHeight: number, - ctx: RenderContext -): string { - if (!ctx.options.showHandles) return ''; - - const rotation = (node.params?.['_rotation'] as number) || 0; - const handles: string[] = []; - - // Input handles - for (let i = 0; i < node.inputs.length; i++) { - const pos = getHandlePosition(i, node.inputs.length, 'input', rotation, nodeWidth, nodeHeight); - handles.push(renderHandle(pos.x, pos.y, rotation, ctx)); - } - - // Output handles - for (let i = 0; i < node.outputs.length; i++) { - const pos = getHandlePosition( - i, - node.outputs.length, - 'output', - rotation, - nodeWidth, - nodeHeight - ); - handles.push(renderHandle(pos.x, pos.y, rotation, ctx)); - } - - return handles.join('\n\t\t'); -} - -/** Get SVG path for a rounded rectangle with potentially different corner radii */ -function getRoundedRectPath( - x: number, - y: number, - width: number, - height: number, - radius: number | [number, number, number, number] -): string { - let tl: number, tr: number, br: number, bl: number; - - if (Array.isArray(radius)) { - [tl, tr, br, bl] = radius; - } else { - tl = tr = br = bl = radius; - } - - // Clamp radii to half of smallest dimension - const maxRadius = Math.min(width, height) / 2; - tl = Math.min(tl, maxRadius); - tr = Math.min(tr, maxRadius); - br = Math.min(br, maxRadius); - bl = Math.min(bl, maxRadius); - - return `M ${x + tl} ${y} - L ${x + width - tr} ${y} - Q ${x + width} ${y} ${x + width} ${y + tr} - L ${x + width} ${y + height - br} - Q ${x + width} ${y + height} ${x + width - br} ${y + height} - L ${x + bl} ${y + height} - Q ${x} ${y + height} ${x} ${y + height - bl} - L ${x} ${y + tl} - Q ${x} ${y} ${x + tl} ${y} - Z`; -} - -/** Get shape for a node (matches getShapeCssClass logic) */ -function getNodeShape(typeDef: ReturnType) { - // First check if type definition has explicit shape override - if (typeDef?.shape) { - const shape = getShape(typeDef.shape); - if (shape) return shape; - } - // Fall back to category-based shape - const shapeId = getShapeForCategory(typeDef?.category || 'default'); - return getShape(shapeId); -} - -/** Render node shape (rectangle with border radius) */ -function renderNodeShape( - x: number, - y: number, - width: number, - height: number, - node: NodeInstance, - ctx: RenderContext -): string { - const typeDef = nodeRegistry.get(node.type); - const category = typeDef?.category || 'default'; - const shape = getNodeShape(typeDef); - - const isSubsystem = node.type === 'Subsystem' || node.type === 'Interface'; - const strokeDasharray = isSubsystem ? 'stroke-dasharray="4 2"' : ''; - - // For Recording nodes, use min dimension / 2 for circular shape - let radius = shape?.svgRadius ?? 8; - if (category === 'Recording') { - radius = Math.min(width, height) / 2; - } - - // Use path for mixed radius, rect for uniform - if (Array.isArray(radius)) { - const path = getRoundedRectPath(x, y, width, height, radius); - return ``; - } - - return ``; -} - -/** Render node labels */ -function renderNodeLabels( - x: number, - y: number, - width: number, - height: number, - node: NodeInstance, - ctx: RenderContext -): string { - if (!ctx.options.showLabels) return ''; - - const typeDef = nodeRegistry.get(node.type); - const color = node.color || ctx.theme.accent; - - const parts: string[] = []; - - // Node name - const nameY = ctx.options.showTypeLabels ? y + height / 2 - 3 : y + height / 2; - parts.push( - `${escapeXml(node.name)}` - ); - - // Type label - if (ctx.options.showTypeLabels && typeDef) { - parts.push( - `${escapeXml(typeDef.name)}` - ); - } - - return parts.join('\n\t\t'); -} - -/** Render a node to SVG */ -export function renderNode(node: NodeInstance, ctx: RenderContext): string { - const { x, y } = node.position; - const { width, height } = getNodeDimensions(node); - - const parts: string[] = []; - - // Node shape (rendered at 0,0 within group) - parts.push(renderNodeShape(0, 0, width, height, node, ctx)); - - // Labels (rendered at 0,0 within group) - parts.push(renderNodeLabels(0, 0, width, height, node, ctx)); - - // Handles (already relative to 0,0) - const handles = renderHandles(node, width, height, ctx); - if (handles) parts.push(handles); - - return ` - ${parts.filter(Boolean).join('\n\t')} -`; -} diff --git a/src/lib/export/svg/renderer.ts b/src/lib/export/svg/renderer.ts index fa68f4dd..6690349c 100644 --- a/src/lib/export/svg/renderer.ts +++ b/src/lib/export/svg/renderer.ts @@ -1,8 +1,8 @@ /** - * SVG Renderer - Main export function + * SVG Renderer * - * Pure rendering from graph state - no DOM scraping required. - * Uses centralized constants for dimensions, handle paths, and theme colors. + * Renders the current graph view as SVG by reading from the DOM. + * Uses centralized constants for theme colors and handle paths. */ import { get } from 'svelte/store'; @@ -10,24 +10,219 @@ import { graphStore } from '$lib/stores/graph'; import { eventStore } from '$lib/stores/events'; import { getThemeColors } from '$lib/constants/theme'; import { EXPORT_PADDING, EVENT } from '$lib/constants/dimensions'; -import { renderNode, getNodeDimensions } from './nodes'; -import { renderConnection } from './edges'; -import { renderEvent } from './events'; +import { getHandlePath } from '$lib/constants/handlePaths'; +import { nodeRegistry } from '$lib/nodes'; +import { eventRegistry } from '$lib/events/registry'; import type { ExportOptions, RenderContext, Bounds } from './types'; -import type { NodeInstance, Connection } from '$lib/types/nodes'; +import type { NodeInstance } from '$lib/types/nodes'; import type { EventInstance } from '$lib/types/events'; -/** Calculate bounding box for all elements */ +// ============================================================================ +// DOM UTILITIES +// ============================================================================ + +/** Get current viewport zoom level */ +function getZoom(): number { + const viewport = document.querySelector('.svelte-flow__viewport') as HTMLElement; + if (!viewport) return 1; + const match = viewport.style.transform.match(/scale\(([^)]+)\)/); + return match ? parseFloat(match[1]) : 1; +} + +/** Escape XML special characters */ +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** Get node dimensions from DOM */ +function getNodeDimensions(nodeId: string): { width: number; height: number } | null { + const wrapper = document.querySelector(`[data-id="${nodeId}"]`) as HTMLElement; + if (!wrapper) return null; + const rect = wrapper.getBoundingClientRect(); + const zoom = getZoom(); + return { width: rect.width / zoom, height: rect.height / zoom }; +} + +// ============================================================================ +// EDGE RENDERING (from DOM) +// ============================================================================ + +/** Extract and render edges from SvelteFlow's DOM */ +function renderEdges(ctx: RenderContext): string { + const container = document.querySelector('.svelte-flow__edges'); + if (!container) return ''; + + const parts: string[] = []; + + container.querySelectorAll('.svelte-flow__edge').forEach((edge) => { + // Main edge path + const pathEl = edge.querySelector('.svelte-flow__edge-path'); + if (pathEl) { + const d = pathEl.getAttribute('d'); + if (d) { + parts.push(``); + } + } + + // Arrow head + const arrowGroup = edge.querySelector('g[transform*="rotate"]'); + if (arrowGroup) { + const transform = arrowGroup.getAttribute('transform'); + const arrowPath = arrowGroup.querySelector('path'); + if (arrowPath && transform) { + const d = arrowPath.getAttribute('d'); + if (d) { + parts.push(``); + } + } + } + }); + + return parts.length > 0 ? `\n\t${parts.join('\n\t')}\n` : ''; +} + +// ============================================================================ +// HANDLE RENDERING (from DOM positions + constants for shape) +// ============================================================================ + +/** Extract and render handles for a node */ +function renderHandles(nodeId: string, nodeX: number, nodeY: number, ctx: RenderContext): string { + const wrapper = document.querySelector(`[data-id="${nodeId}"]`); + if (!wrapper) return ''; + + const nodeEl = wrapper.querySelector('[data-rotation]') || wrapper; + const rotation = parseInt(nodeEl.getAttribute('data-rotation') || '0'); + const paths = getHandlePath(rotation); + const zoom = getZoom(); + const nodeRect = wrapper.getBoundingClientRect(); + + const handles: string[] = []; + + nodeEl.querySelectorAll('.svelte-flow__handle').forEach((handle) => { + const rect = handle.getBoundingClientRect(); + // Get center of handle relative to node + const cx = (rect.left + rect.width / 2 - nodeRect.left) / zoom; + const cy = (rect.top + rect.height / 2 - nodeRect.top) / zoom; + // Position handle path centered on this point + const x = nodeX + cx - paths.width / 2; + const y = nodeY + cy - paths.height / 2; + + // Two-layer hollow handle + handles.push(` + + + `); + }); + + return handles.join('\n\t'); +} + +// ============================================================================ +// NODE RENDERING +// ============================================================================ + +/** Render a node */ +function renderNode(node: NodeInstance, ctx: RenderContext): string { + const { x, y } = node.position; + const dims = getNodeDimensions(node.id); + if (!dims) return ''; + + const { width, height } = dims; + const typeDef = nodeRegistry.get(node.type); + const color = node.color || ctx.theme.accent; + const isSubsystem = node.type === 'Subsystem' || node.type === 'Interface'; + + // Get border radius from DOM element's computed style + const wrapper = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement; + const nodeEl = wrapper?.querySelector('.node') as HTMLElement; + let rx = 8; + if (nodeEl) { + const computed = getComputedStyle(nodeEl); + rx = parseFloat(computed.borderRadius) || 8; + } + + const parts: string[] = []; + + // Node rectangle + const strokeDasharray = isSubsystem ? ' stroke-dasharray="4 2"' : ''; + parts.push( + `` + ); + + // Labels + if (ctx.options.showLabels) { + const nameY = ctx.options.showTypeLabels ? y + height / 2 - 3 : y + height / 2; + parts.push( + `${escapeXml(node.name)}` + ); + + if (ctx.options.showTypeLabels && typeDef) { + parts.push( + `${escapeXml(typeDef.name)}` + ); + } + } + + // Handles + if (ctx.options.showHandles) { + const handles = renderHandles(node.id, x, y, ctx); + if (handles) parts.push(handles); + } + + return `\n\t${parts.join('\n\t')}\n`; +} + +// ============================================================================ +// EVENT RENDERING +// ============================================================================ + +/** Render an event */ +function renderEvent(event: EventInstance, ctx: RenderContext): string { + const cx = event.position.x + EVENT.center; + const cy = event.position.y + EVENT.center; + const color = event.color || ctx.theme.accent; + const typeDef = eventRegistry.get(event.type); + + const parts: string[] = []; + + // Diamond shape + parts.push( + `` + ); + + // Labels + if (ctx.options.showLabels) { + const nameY = ctx.options.showTypeLabels ? cy - 4 : cy; + parts.push( + `${escapeXml(event.name)}` + ); + + if (ctx.options.showTypeLabels && typeDef) { + parts.push( + `${escapeXml(typeDef.name)}` + ); + } + } + + return `\n\t${parts.join('\n\t')}\n`; +} + +// ============================================================================ +// BOUNDS CALCULATION +// ============================================================================ + +/** Calculate bounds from nodes and events */ function calculateBounds(nodes: NodeInstance[], events: EventInstance[]): Bounds { - const bounds: Bounds = { - minX: Infinity, - minY: Infinity, - maxX: -Infinity, - maxY: -Infinity - }; + const bounds: Bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; for (const node of nodes) { - const { width, height } = getNodeDimensions(node); + const dims = getNodeDimensions(node.id); + const width = dims?.width ?? 90; + const height = dims?.height ?? 36; bounds.minX = Math.min(bounds.minX, node.position.x); bounds.minY = Math.min(bounds.minY, node.position.y); bounds.maxX = Math.max(bounds.maxX, node.position.x + width); @@ -41,35 +236,14 @@ function calculateBounds(nodes: NodeInstance[], events: EventInstance[]): Bounds bounds.maxY = Math.max(bounds.maxY, event.position.y + EVENT.size); } - // Return default bounds if no elements - if (!isFinite(bounds.minX)) { - return { minX: 0, minY: 0, maxX: 200, maxY: 200 }; - } - - return bounds; + return isFinite(bounds.minX) ? bounds : { minX: 0, minY: 0, maxX: 200, maxY: 200 }; } -/** Render SVG header */ -function renderHeader(bounds: Bounds, padding: number): string { - const width = bounds.maxX - bounds.minX + padding * 2; - const height = bounds.maxY - bounds.minY + padding * 2; - const viewBox = `${bounds.minX - padding} ${bounds.minY - padding} ${width} ${height}`; - - return ` -`; -} +// ============================================================================ +// MAIN EXPORT +// ============================================================================ -/** Render background rectangle */ -function renderBackground(bounds: Bounds, padding: number, ctx: RenderContext): string { - const x = bounds.minX - padding; - const y = bounds.minY - padding; - const width = bounds.maxX - bounds.minX + padding * 2; - const height = bounds.maxY - bounds.minY + padding * 2; - - return ``; -} - -/** Default export options */ +/** Default options */ const DEFAULTS: Required = { theme: 'auto', background: 'transparent', @@ -81,56 +255,35 @@ const DEFAULTS: Required = { /** * Export the current graph as SVG string - * - * @param options - Export options - * @returns SVG string */ export function exportToSVG(options: ExportOptions = {}): string { - // Merge options with defaults const opts: Required = { ...DEFAULTS, ...options }; - - // Resolve theme const themeColors = getThemeColors(opts.theme); + const ctx: RenderContext = { theme: themeColors, options: opts }; - // Create render context - const ctx: RenderContext = { - theme: themeColors, - options: opts - }; - - // Get graph data const nodes = get(graphStore.nodesArray); - const connections = get(graphStore.connections); const events = get(eventStore.eventsArray); - - // Create nodes map for connection lookups - const nodesMap = new Map(); - for (const node of nodes) { - nodesMap.set(node.id, node); - } - - // Calculate bounds const bounds = calculateBounds(nodes, events); - // Build SVG - const parts: string[] = []; + const width = bounds.maxX - bounds.minX + opts.padding * 2; + const height = bounds.maxY - bounds.minY + opts.padding * 2; + const viewBox = `${bounds.minX - opts.padding} ${bounds.minY - opts.padding} ${width} ${height}`; - // Header - parts.push(renderHeader(bounds, opts.padding)); + const parts: string[] = [ + ``, + `` + ]; // Background if (opts.background === 'solid') { - parts.push(renderBackground(bounds, opts.padding, ctx)); + parts.push( + `` + ); } - // Connections (rendered below nodes) - if (connections.length > 0) { - parts.push(''); - for (const connection of connections) { - parts.push(renderConnection(connection, nodesMap, ctx)); - } - parts.push(''); - } + // Edges (from DOM) + const edges = renderEdges(ctx); + if (edges) parts.push(edges); // Events if (events.length > 0) { @@ -141,16 +294,16 @@ export function exportToSVG(options: ExportOptions = {}): string { parts.push(''); } - // Nodes (rendered above edges) + // Nodes if (nodes.length > 0) { parts.push(''); for (const node of nodes) { - parts.push(renderNode(node, ctx)); + const rendered = renderNode(node, ctx); + if (rendered) parts.push(rendered); } parts.push(''); } parts.push(''); - return parts.join('\n'); } From 4bab23dcd781ba8298440274d788c71ac918576f Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 18 Jan 2026 10:05:44 +0100 Subject: [PATCH 084/656] Use foreignObject to embed cloned HTML for nodes and events --- src/lib/export/svg/renderer.ts | 194 ++++++++++++++++----------------- 1 file changed, 96 insertions(+), 98 deletions(-) diff --git a/src/lib/export/svg/renderer.ts b/src/lib/export/svg/renderer.ts index 6690349c..dc15c084 100644 --- a/src/lib/export/svg/renderer.ts +++ b/src/lib/export/svg/renderer.ts @@ -11,8 +11,6 @@ import { eventStore } from '$lib/stores/events'; import { getThemeColors } from '$lib/constants/theme'; import { EXPORT_PADDING, EVENT } from '$lib/constants/dimensions'; import { getHandlePath } from '$lib/constants/handlePaths'; -import { nodeRegistry } from '$lib/nodes'; -import { eventRegistry } from '$lib/events/registry'; import type { ExportOptions, RenderContext, Bounds } from './types'; import type { NodeInstance } from '$lib/types/nodes'; import type { EventInstance } from '$lib/types/events'; @@ -29,15 +27,6 @@ function getZoom(): number { return match ? parseFloat(match[1]) : 1; } -/** Escape XML special characters */ -function escapeXml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - /** Get node dimensions from DOM */ function getNodeDimensions(nodeId: string): { width: number; height: number } | null { const wrapper = document.querySelector(`[data-id="${nodeId}"]`) as HTMLElement; @@ -48,41 +37,40 @@ function getNodeDimensions(nodeId: string): { width: number; height: number } | } // ============================================================================ -// EDGE RENDERING (from DOM) +// EDGE RENDERING (clone from DOM) // ============================================================================ -/** Extract and render edges from SvelteFlow's DOM */ +/** Clone edges SVG from SvelteFlow and restyle */ function renderEdges(ctx: RenderContext): string { - const container = document.querySelector('.svelte-flow__edges'); - if (!container) return ''; - - const parts: string[] = []; - - container.querySelectorAll('.svelte-flow__edge').forEach((edge) => { - // Main edge path - const pathEl = edge.querySelector('.svelte-flow__edge-path'); - if (pathEl) { - const d = pathEl.getAttribute('d'); - if (d) { - parts.push(``); - } + const edgesSvg = document.querySelector('.svelte-flow__edges') as SVGElement; + if (!edgesSvg) return ''; + + // Clone the entire edges SVG content + const clone = edgesSvg.cloneNode(true) as SVGElement; + + // Restyle all paths to use theme colors (remove any inline styles/classes) + clone.querySelectorAll('path').forEach((path) => { + // Edge paths have class svelte-flow__edge-path + if (path.classList.contains('svelte-flow__edge-path')) { + path.setAttribute('stroke', ctx.theme.edge); + path.setAttribute('stroke-width', '1.5'); + path.setAttribute('fill', 'none'); + } else { + // Arrow paths + path.setAttribute('fill', ctx.theme.edge); } + path.removeAttribute('class'); + path.removeAttribute('style'); + }); - // Arrow head - const arrowGroup = edge.querySelector('g[transform*="rotate"]'); - if (arrowGroup) { - const transform = arrowGroup.getAttribute('transform'); - const arrowPath = arrowGroup.querySelector('path'); - if (arrowPath && transform) { - const d = arrowPath.getAttribute('d'); - if (d) { - parts.push(``); - } - } - } + // Remove wrapper classes/styles + clone.querySelectorAll('g').forEach((g) => { + g.removeAttribute('class'); + g.removeAttribute('style'); }); - return parts.length > 0 ? `\n\t${parts.join('\n\t')}\n` : ''; + // Get inner content (skip the outer wrapper) + return `${clone.innerHTML}`; } // ============================================================================ @@ -122,52 +110,60 @@ function renderHandles(nodeId: string, nodeX: number, nodeY: number, ctx: Render } // ============================================================================ -// NODE RENDERING +// NODE RENDERING (clone HTML into foreignObject) // ============================================================================ -/** Render a node */ +/** Clone a node's HTML and embed in SVG foreignObject */ function renderNode(node: NodeInstance, ctx: RenderContext): string { const { x, y } = node.position; + const wrapper = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement; + if (!wrapper) return ''; + const dims = getNodeDimensions(node.id); if (!dims) return ''; - const { width, height } = dims; - const typeDef = nodeRegistry.get(node.type); - const color = node.color || ctx.theme.accent; - const isSubsystem = node.type === 'Subsystem' || node.type === 'Interface'; - // Get border radius from DOM element's computed style - const wrapper = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement; - const nodeEl = wrapper?.querySelector('.node') as HTMLElement; - let rx = 8; - if (nodeEl) { - const computed = getComputedStyle(nodeEl); - rx = parseFloat(computed.borderRadius) || 8; - } + // Clone the node's inner .node element + const nodeEl = wrapper.querySelector('.node') as HTMLElement; + if (!nodeEl) return ''; - const parts: string[] = []; + const clone = nodeEl.cloneNode(true) as HTMLElement; - // Node rectangle - const strokeDasharray = isSubsystem ? ' stroke-dasharray="4 2"' : ''; - parts.push( - `` - ); + // Inline critical styles since CSS won't apply in standalone SVG + const computed = getComputedStyle(nodeEl); + clone.style.cssText = ` + background: ${computed.backgroundColor}; + border: ${computed.border}; + border-radius: ${computed.borderRadius}; + font-size: ${computed.fontSize}; + min-width: ${width}px; + min-height: ${height}px; + color: ${computed.color}; + --node-color: ${node.color || ctx.theme.accent}; + --edge: ${ctx.theme.edge}; + --surface-raised: ${ctx.theme.surfaceRaised}; + --text-muted: ${ctx.theme.textMuted}; + `; - // Labels - if (ctx.options.showLabels) { - const nameY = ctx.options.showTypeLabels ? y + height / 2 - 3 : y + height / 2; - parts.push( - `${escapeXml(node.name)}` - ); + // Remove any hover/preview related classes + clone.classList.remove('preview-hovered', 'selected'); - if (ctx.options.showTypeLabels && typeDef) { - parts.push( - `${escapeXml(typeDef.name)}` - ); - } - } + // Remove plot preview popups + clone.querySelectorAll('.plot-preview-popup').forEach((el) => el.remove()); - // Handles + // Remove handles (we'll render them separately as SVG) + clone.querySelectorAll('.svelte-flow__handle').forEach((el) => el.remove()); + + const html = clone.outerHTML; + + // Build foreignObject with embedded HTML + const parts: string[] = [ + ``, + `
        ${html}
        `, + `
        ` + ]; + + // Add handles as SVG paths if (ctx.options.showHandles) { const handles = renderHandles(node.id, x, y, ctx); if (handles) parts.push(handles); @@ -177,38 +173,40 @@ function renderNode(node: NodeInstance, ctx: RenderContext): string { } // ============================================================================ -// EVENT RENDERING +// EVENT RENDERING (clone HTML into foreignObject) // ============================================================================ -/** Render an event */ +/** Clone an event's HTML and embed in SVG foreignObject */ function renderEvent(event: EventInstance, ctx: RenderContext): string { - const cx = event.position.x + EVENT.center; - const cy = event.position.y + EVENT.center; - const color = event.color || ctx.theme.accent; - const typeDef = eventRegistry.get(event.type); - - const parts: string[] = []; + const { x, y } = event.position; + const wrapper = document.querySelector(`[data-id="${event.id}"]`) as HTMLElement; + if (!wrapper) return ''; - // Diamond shape - parts.push( - `` - ); + // Clone the event-node element + const eventEl = wrapper.querySelector('.event-node') as HTMLElement; + if (!eventEl) return ''; - // Labels - if (ctx.options.showLabels) { - const nameY = ctx.options.showTypeLabels ? cy - 4 : cy; - parts.push( - `${escapeXml(event.name)}` - ); + const clone = eventEl.cloneNode(true) as HTMLElement; - if (ctx.options.showTypeLabels && typeDef) { - parts.push( - `${escapeXml(typeDef.name)}` - ); - } - } - - return `\n\t${parts.join('\n\t')}\n`; + // Inline critical styles + const color = event.color || ctx.theme.accent; + clone.style.cssText = ` + --event-color: ${color}; + --edge: ${ctx.theme.edge}; + --surface-raised: ${ctx.theme.surfaceRaised}; + --text-muted: ${ctx.theme.textMuted}; + `; + + // Remove selection state + clone.classList.remove('selected'); + + const html = clone.outerHTML; + + return ` + +
        ${html}
        +
        +
        `; } // ============================================================================ From 8bfbebc2f20e518569e8987843f203304a5716e2 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 18 Jan 2026 10:17:33 +0100 Subject: [PATCH 085/656] Refactor SVG export to hybrid DOM approach with cleanup --- src/lib/constants/handlePaths.ts | 2 +- src/lib/export/svg/index.ts | 5 +- src/lib/export/svg/renderer.ts | 281 +++++++++++++++++-------------- src/lib/export/svg/types.ts | 3 +- 4 files changed, 159 insertions(+), 132 deletions(-) diff --git a/src/lib/constants/handlePaths.ts b/src/lib/constants/handlePaths.ts index 7240c4a8..1029d5d2 100644 --- a/src/lib/constants/handlePaths.ts +++ b/src/lib/constants/handlePaths.ts @@ -9,7 +9,7 @@ * * The hollow effect is created by layering: * 1. Outer path filled with border color - * 2. Inner path filled with background color, offset by ~1.5px + * 2. Inner path filled with background color, offset by 1px (matches CSS inset: 1px) */ export interface HandlePathDef { diff --git a/src/lib/export/svg/index.ts b/src/lib/export/svg/index.ts index e4a37f7a..a87cc9b3 100644 --- a/src/lib/export/svg/index.ts +++ b/src/lib/export/svg/index.ts @@ -1,8 +1,9 @@ /** * SVG Export Module * - * Pure SVG rendering from graph state - no DOM scraping. - * Single source of truth for dimensions, handle paths, and theme colors. + * Hybrid approach for accurate SVG export: + * - Edges: cloned from SvelteFlow's SVG (already vector graphics) + * - Nodes/Events: pure SVG with dimensions and styles read from DOM */ export { exportToSVG } from './renderer'; diff --git a/src/lib/export/svg/renderer.ts b/src/lib/export/svg/renderer.ts index dc15c084..28befcca 100644 --- a/src/lib/export/svg/renderer.ts +++ b/src/lib/export/svg/renderer.ts @@ -1,17 +1,21 @@ /** * SVG Renderer * - * Renders the current graph view as SVG by reading from the DOM. - * Uses centralized constants for theme colors and handle paths. + * Renders the current graph view as SVG using a hybrid approach: + * - Edges: cloned directly from SvelteFlow's SVG (already vector graphics) + * - Nodes/Events: pure SVG with dimensions and styles read from DOM + * + * This approach ensures pixel-perfect accuracy while producing clean SVG output. */ import { get } from 'svelte/store'; import { graphStore } from '$lib/stores/graph'; import { eventStore } from '$lib/stores/events'; import { getThemeColors } from '$lib/constants/theme'; -import { EXPORT_PADDING, EVENT } from '$lib/constants/dimensions'; +import { EVENT } from '$lib/constants/dimensions'; import { getHandlePath } from '$lib/constants/handlePaths'; import type { ExportOptions, RenderContext, Bounds } from './types'; +import { DEFAULT_OPTIONS } from './types'; import type { NodeInstance } from '$lib/types/nodes'; import type { EventInstance } from '$lib/types/events'; @@ -19,7 +23,6 @@ import type { EventInstance } from '$lib/types/events'; // DOM UTILITIES // ============================================================================ -/** Get current viewport zoom level */ function getZoom(): number { const viewport = document.querySelector('.svelte-flow__viewport') as HTMLElement; if (!viewport) return 1; @@ -27,7 +30,15 @@ function getZoom(): number { return match ? parseFloat(match[1]) : 1; } -/** Get node dimensions from DOM */ +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + function getNodeDimensions(nodeId: string): { width: number; height: number } | null { const wrapper = document.querySelector(`[data-id="${nodeId}"]`) as HTMLElement; if (!wrapper) return null; @@ -37,47 +48,57 @@ function getNodeDimensions(nodeId: string): { width: number; height: number } | } // ============================================================================ -// EDGE RENDERING (clone from DOM) +// EDGE RENDERING - Clone from DOM // ============================================================================ -/** Clone edges SVG from SvelteFlow and restyle */ function renderEdges(ctx: RenderContext): string { - const edgesSvg = document.querySelector('.svelte-flow__edges') as SVGElement; - if (!edgesSvg) return ''; - - // Clone the entire edges SVG content - const clone = edgesSvg.cloneNode(true) as SVGElement; - - // Restyle all paths to use theme colors (remove any inline styles/classes) - clone.querySelectorAll('path').forEach((path) => { - // Edge paths have class svelte-flow__edge-path - if (path.classList.contains('svelte-flow__edge-path')) { - path.setAttribute('stroke', ctx.theme.edge); - path.setAttribute('stroke-width', '1.5'); - path.setAttribute('fill', 'none'); - } else { - // Arrow paths - path.setAttribute('fill', ctx.theme.edge); + const container = document.querySelector('.svelte-flow__edges'); + if (!container) return ''; + + const parts: string[] = []; + + container.querySelectorAll('.svelte-flow__edge').forEach((edge) => { + const edgeParts: string[] = []; + + // Get all paths and groups within this edge + edge.querySelectorAll('path').forEach((pathEl) => { + const d = pathEl.getAttribute('d'); + if (!d) return; + + // Check if it's the main edge path or arrow + if (pathEl.classList.contains('svelte-flow__edge-path')) { + edgeParts.push( + `` + ); + } + }); + + // Find arrow groups (have transform with rotate) + edge.querySelectorAll('g').forEach((g) => { + const transform = g.getAttribute('transform'); + if (transform && transform.includes('rotate')) { + const arrowPath = g.querySelector('path'); + if (arrowPath) { + const d = arrowPath.getAttribute('d'); + if (d) { + edgeParts.push(``); + } + } + } + }); + + if (edgeParts.length > 0) { + parts.push(`${edgeParts.join('')}`); } - path.removeAttribute('class'); - path.removeAttribute('style'); }); - // Remove wrapper classes/styles - clone.querySelectorAll('g').forEach((g) => { - g.removeAttribute('class'); - g.removeAttribute('style'); - }); - - // Get inner content (skip the outer wrapper) - return `${clone.innerHTML}`; + return parts.length > 0 ? `\n${parts.join('\n')}\n` : ''; } // ============================================================================ -// HANDLE RENDERING (from DOM positions + constants for shape) +// HANDLE RENDERING // ============================================================================ -/** Extract and render handles for a node */ function renderHandles(nodeId: string, nodeX: number, nodeY: number, ctx: RenderContext): string { const wrapper = document.querySelector(`[data-id="${nodeId}"]`); if (!wrapper) return ''; @@ -92,28 +113,24 @@ function renderHandles(nodeId: string, nodeX: number, nodeY: number, ctx: Render nodeEl.querySelectorAll('.svelte-flow__handle').forEach((handle) => { const rect = handle.getBoundingClientRect(); - // Get center of handle relative to node const cx = (rect.left + rect.width / 2 - nodeRect.left) / zoom; const cy = (rect.top + rect.height / 2 - nodeRect.top) / zoom; - // Position handle path centered on this point const x = nodeX + cx - paths.width / 2; const y = nodeY + cy - paths.height / 2; - // Two-layer hollow handle handles.push(` - - - `); + + +
        `); }); - return handles.join('\n\t'); + return handles.join('\n'); } // ============================================================================ -// NODE RENDERING (clone HTML into foreignObject) +// NODE RENDERING - Pure SVG with DOM-read styles // ============================================================================ -/** Clone a node's HTML and embed in SVG foreignObject */ function renderNode(node: NodeInstance, ctx: RenderContext): string { const { x, y } = node.position; const wrapper = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement; @@ -123,97 +140,122 @@ function renderNode(node: NodeInstance, ctx: RenderContext): string { if (!dims) return ''; const { width, height } = dims; - // Clone the node's inner .node element const nodeEl = wrapper.querySelector('.node') as HTMLElement; if (!nodeEl) return ''; - const clone = nodeEl.cloneNode(true) as HTMLElement; - - // Inline critical styles since CSS won't apply in standalone SVG + // Read styles from DOM const computed = getComputedStyle(nodeEl); - clone.style.cssText = ` - background: ${computed.backgroundColor}; - border: ${computed.border}; - border-radius: ${computed.borderRadius}; - font-size: ${computed.fontSize}; - min-width: ${width}px; - min-height: ${height}px; - color: ${computed.color}; - --node-color: ${node.color || ctx.theme.accent}; - --edge: ${ctx.theme.edge}; - --surface-raised: ${ctx.theme.surfaceRaised}; - --text-muted: ${ctx.theme.textMuted}; - `; - - // Remove any hover/preview related classes - clone.classList.remove('preview-hovered', 'selected'); - - // Remove plot preview popups - clone.querySelectorAll('.plot-preview-popup').forEach((el) => el.remove()); - - // Remove handles (we'll render them separately as SVG) - clone.querySelectorAll('.svelte-flow__handle').forEach((el) => el.remove()); - - const html = clone.outerHTML; - - // Build foreignObject with embedded HTML - const parts: string[] = [ - ``, - `
        ${html}
        `, - `
        ` - ]; + const borderRadius = parseFloat(computed.borderRadius) || 8; + const isSubsystem = node.type === 'Subsystem' || node.type === 'Interface'; + const color = node.color || ctx.theme.accent; + + // Get text content + const nameEl = nodeEl.querySelector('.node-name'); + const typeEl = nodeEl.querySelector('.node-type'); + const nodeName = nameEl?.textContent || node.name; + const nodeType = typeEl?.textContent || ''; + + const parts: string[] = []; + + // Background fill + parts.push( + `` + ); + + // Border + const strokeDasharray = isSubsystem ? ' stroke-dasharray="4 2"' : ''; + parts.push( + `` + ); + + // Labels + if (ctx.options.showLabels) { + const centerX = x + width / 2; + const centerY = y + height / 2; + + if (ctx.options.showTypeLabels && nodeType) { + // Name above center + parts.push( + `${escapeXml(nodeName)}` + ); + // Type below center + parts.push( + `${escapeXml(nodeType)}` + ); + } else { + // Just name, centered + parts.push( + `${escapeXml(nodeName)}` + ); + } + } - // Add handles as SVG paths + // Handles if (ctx.options.showHandles) { const handles = renderHandles(node.id, x, y, ctx); if (handles) parts.push(handles); } - return `\n\t${parts.join('\n\t')}\n`; + return `\n${parts.join('\n')}\n`; } // ============================================================================ -// EVENT RENDERING (clone HTML into foreignObject) +// EVENT RENDERING - Pure SVG // ============================================================================ -/** Clone an event's HTML and embed in SVG foreignObject */ function renderEvent(event: EventInstance, ctx: RenderContext): string { - const { x, y } = event.position; const wrapper = document.querySelector(`[data-id="${event.id}"]`) as HTMLElement; - if (!wrapper) return ''; - // Clone the event-node element - const eventEl = wrapper.querySelector('.event-node') as HTMLElement; - if (!eventEl) return ''; + // Get text from DOM or fallback to data + let eventName = event.name; + let eventType = ''; - const clone = eventEl.cloneNode(true) as HTMLElement; + if (wrapper) { + const nameEl = wrapper.querySelector('.event-name'); + const typeEl = wrapper.querySelector('.event-type'); + eventName = nameEl?.textContent || event.name; + eventType = typeEl?.textContent || ''; + } - // Inline critical styles + const cx = event.position.x + EVENT.center; + const cy = event.position.y + EVENT.center; const color = event.color || ctx.theme.accent; - clone.style.cssText = ` - --event-color: ${color}; - --edge: ${ctx.theme.edge}; - --surface-raised: ${ctx.theme.surfaceRaised}; - --text-muted: ${ctx.theme.textMuted}; - `; - - // Remove selection state - clone.classList.remove('selected'); - - const html = clone.outerHTML; - - return ` - -
        ${html}
        -
        -
        `; + + const parts: string[] = []; + + // Diamond background + parts.push( + `` + ); + + // Diamond border + parts.push( + `` + ); + + // Labels + if (ctx.options.showLabels) { + if (ctx.options.showTypeLabels && eventType) { + parts.push( + `${escapeXml(eventName)}` + ); + parts.push( + `${escapeXml(eventType)}` + ); + } else { + parts.push( + `${escapeXml(eventName)}` + ); + } + } + + return `\n${parts.join('\n')}\n`; } // ============================================================================ -// BOUNDS CALCULATION +// BOUNDS & MAIN EXPORT // ============================================================================ -/** Calculate bounds from nodes and events */ function calculateBounds(nodes: NodeInstance[], events: EventInstance[]): Bounds { const bounds: Bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; @@ -237,25 +279,8 @@ function calculateBounds(nodes: NodeInstance[], events: EventInstance[]): Bounds return isFinite(bounds.minX) ? bounds : { minX: 0, minY: 0, maxX: 200, maxY: 200 }; } -// ============================================================================ -// MAIN EXPORT -// ============================================================================ - -/** Default options */ -const DEFAULTS: Required = { - theme: 'auto', - background: 'transparent', - padding: EXPORT_PADDING, - showLabels: true, - showTypeLabels: true, - showHandles: true -}; - -/** - * Export the current graph as SVG string - */ export function exportToSVG(options: ExportOptions = {}): string { - const opts: Required = { ...DEFAULTS, ...options }; + const opts: Required = { ...DEFAULT_OPTIONS, ...options }; const themeColors = getThemeColors(opts.theme); const ctx: RenderContext = { theme: themeColors, options: opts }; @@ -279,7 +304,7 @@ export function exportToSVG(options: ExportOptions = {}): string { ); } - // Edges (from DOM) + // Edges const edges = renderEdges(ctx); if (edges) parts.push(edges); diff --git a/src/lib/export/svg/types.ts b/src/lib/export/svg/types.ts index 85451e03..9b54a7cb 100644 --- a/src/lib/export/svg/types.ts +++ b/src/lib/export/svg/types.ts @@ -3,6 +3,7 @@ */ import type { ThemeColors, ThemeName } from '$lib/constants/theme'; +import { EXPORT_PADDING } from '$lib/constants/dimensions'; /** SVG export options */ export interface ExportOptions { @@ -40,7 +41,7 @@ export interface Bounds { export const DEFAULT_OPTIONS: Required = { theme: 'auto', background: 'transparent', - padding: 40, + padding: EXPORT_PADDING, showLabels: true, showTypeLabels: true, showHandles: true From 9a0b489ee6f1be45788c272db591492a3f4e642d Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 18 Jan 2026 11:05:56 +0100 Subject: [PATCH 086/656] Redesign welcome modal with SVG example previews and improved layout --- scripts/generate-examples-manifest.js | 139 +++++++++++++++- src/lib/components/WelcomeModal.svelte | 173 ++++++++++++-------- static/examples/bouncing-ball-dark.svg | 114 +++++++++++++ static/examples/bouncing-ball-light.svg | 114 +++++++++++++ static/examples/cascade-subsystem-dark.svg | 154 +++++++++++++++++ static/examples/cascade-subsystem-light.svg | 154 +++++++++++++++++ static/examples/feedback-system-dark.svg | 83 ++++++++++ static/examples/feedback-system-light.svg | 83 ++++++++++ static/examples/fmcw-radar-dark.svg | 122 ++++++++++++++ static/examples/fmcw-radar-light.svg | 122 ++++++++++++++ static/examples/pid-subsystem-dark.svg | 105 ++++++++++++ static/examples/pid-subsystem-light.svg | 105 ++++++++++++ static/examples/thermostat-dark.svg | 109 ++++++++++++ static/examples/thermostat-light.svg | 109 ++++++++++++ static/examples/vanderpol-dark.svg | 159 ++++++++++++++++++ static/examples/vanderpol-light.svg | 159 ++++++++++++++++++ 16 files changed, 1933 insertions(+), 71 deletions(-) create mode 100644 static/examples/bouncing-ball-dark.svg create mode 100644 static/examples/bouncing-ball-light.svg create mode 100644 static/examples/cascade-subsystem-dark.svg create mode 100644 static/examples/cascade-subsystem-light.svg create mode 100644 static/examples/feedback-system-dark.svg create mode 100644 static/examples/feedback-system-light.svg create mode 100644 static/examples/fmcw-radar-dark.svg create mode 100644 static/examples/fmcw-radar-light.svg create mode 100644 static/examples/pid-subsystem-dark.svg create mode 100644 static/examples/pid-subsystem-light.svg create mode 100644 static/examples/thermostat-dark.svg create mode 100644 static/examples/thermostat-light.svg create mode 100644 static/examples/vanderpol-dark.svg create mode 100644 static/examples/vanderpol-light.svg diff --git a/scripts/generate-examples-manifest.js b/scripts/generate-examples-manifest.js index 3cad1ad1..9305b50c 100644 --- a/scripts/generate-examples-manifest.js +++ b/scripts/generate-examples-manifest.js @@ -1,10 +1,10 @@ #!/usr/bin/env node /** - * Generates manifest.json from all example .json files in static/examples/ + * Generates manifest.json and SVG previews from example .json files * Run this before build or as part of the dev process */ -import { readdirSync, writeFileSync } from 'fs'; +import { readdirSync, readFileSync, writeFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; @@ -12,13 +12,144 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const examplesDir = join(__dirname, '..', 'static', 'examples'); const manifestPath = join(examplesDir, 'manifest.json'); -// Find all .json files (excluding manifest.json itself) +// Constants matching src/lib/constants/dimensions.ts +const NODE_WIDTH = 90; +const NODE_HEIGHT = 36; +const EVENT_SIZE = 80; +const EVENT_CENTER = 40; +const EVENT_DIAMOND_SIZE = 56; + +// Theme colors matching src/lib/constants/theme.ts +const THEMES = { + dark: { + surfaceRaised: '#1c1c26', + edge: '#7F7F7F' + }, + light: { + surfaceRaised: '#ffffff', + edge: '#7F7F7F' + } +}; + +/** + * Generate SVG preview from graph data + */ +function generatePreview(graph, events = [], options = {}) { + const { width = 240, height = 140, padding = 16, theme = 'dark' } = options; + const colors = THEMES[theme]; + const nodes = graph.nodes || []; + const connections = graph.connections || []; + + if (nodes.length === 0 && events.length === 0) { + return ``; + } + + // Calculate bounds + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + + for (const node of nodes) { + minX = Math.min(minX, node.position.x); + minY = Math.min(minY, node.position.y); + maxX = Math.max(maxX, node.position.x + NODE_WIDTH); + maxY = Math.max(maxY, node.position.y + NODE_HEIGHT); + } + + for (const event of events) { + minX = Math.min(minX, event.position.x); + minY = Math.min(minY, event.position.y); + maxX = Math.max(maxX, event.position.x + EVENT_SIZE); + maxY = Math.max(maxY, event.position.y + EVENT_SIZE); + } + + // Calculate scale to fit + const contentWidth = maxX - minX; + const contentHeight = maxY - minY; + const availableWidth = width - padding * 2; + const availableHeight = height - padding * 2; + const scale = Math.min(availableWidth / contentWidth, availableHeight / contentHeight, 1); + + // Center offset + const scaledWidth = contentWidth * scale; + const scaledHeight = contentHeight * scale; + const offsetX = padding + (availableWidth - scaledWidth) / 2; + const offsetY = padding + (availableHeight - scaledHeight) / 2; + + // Build node lookup for connections + const nodeMap = new Map(); + for (const node of nodes) { + nodeMap.set(node.id, node); + } + + const parts = []; + + // Render connections as simple curves + for (const conn of connections) { + const source = nodeMap.get(conn.sourceNodeId); + const target = nodeMap.get(conn.targetNodeId); + if (source && target) { + const x1 = offsetX + (source.position.x + NODE_WIDTH - minX) * scale; + const y1 = offsetY + (source.position.y + NODE_HEIGHT / 2 - minY) * scale; + const x2 = offsetX + (target.position.x - minX) * scale; + const y2 = offsetY + (target.position.y + NODE_HEIGHT / 2 - minY) * scale; + const midX = (x1 + x2) / 2; + parts.push( + `` + ); + } + } + + // Render nodes as rectangles + for (const node of nodes) { + const x = offsetX + (node.position.x - minX) * scale; + const y = offsetY + (node.position.y - minY) * scale; + const w = NODE_WIDTH * scale; + const h = NODE_HEIGHT * scale; + const isSubsystem = node.type === 'Subsystem' || node.type === 'Interface'; + const dashArray = isSubsystem ? ' stroke-dasharray="3 1"' : ''; + parts.push( + `` + ); + } + + // Render events as diamonds + for (const event of events) { + const cx = offsetX + (event.position.x + EVENT_CENTER - minX) * scale; + const cy = offsetY + (event.position.y + EVENT_CENTER - minY) * scale; + const size = EVENT_DIAMOND_SIZE * scale * 0.5; + parts.push( + `` + ); + } + + return `${parts.join('')}`; +} + +// Find all .json files (excluding manifest.json) const files = readdirSync(examplesDir) .filter(f => f.endsWith('.json') && f !== 'manifest.json') .sort(); +// Generate SVG previews for each example (both themes) +for (const filename of files) { + try { + const jsonPath = join(examplesDir, filename); + const data = JSON.parse(readFileSync(jsonPath, 'utf-8')); + const baseName = filename.replace('.json', ''); + + // Generate dark theme preview + const darkSvg = generatePreview(data.graph || {}, data.events || [], { theme: 'dark' }); + writeFileSync(join(examplesDir, `${baseName}-dark.svg`), darkSvg); + + // Generate light theme preview + const lightSvg = generatePreview(data.graph || {}, data.events || [], { theme: 'light' }); + writeFileSync(join(examplesDir, `${baseName}-light.svg`), lightSvg); + } catch (e) { + console.warn(`Could not generate preview for ${filename}:`, e.message); + } +} + // Write manifest const manifest = { files }; writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); -console.log(`Generated manifest with ${files.length} example(s): ${files.join(', ')}`); +console.log(`Generated manifest and ${files.length * 2} SVG previews (light + dark)`); diff --git a/src/lib/components/WelcomeModal.svelte b/src/lib/components/WelcomeModal.svelte index 9ae87934..15791990 100644 --- a/src/lib/components/WelcomeModal.svelte +++ b/src/lib/components/WelcomeModal.svelte @@ -1,6 +1,6 @@ @@ -100,7 +136,7 @@ -
        +
        {#if selected}
        + + {:else} -
        +
        {#if content} {@html renderedHtml} {:else} @@ -179,18 +231,42 @@ cursor: default; } - /* Toolbar */ .toolbar { position: absolute; - top: -20px; + top: -26px; left: 0; display: flex; align-items: center; - gap: 4px; + gap: 1px; z-index: 10; } + /* Toolbar buttons */ + .toolbar-btn { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: var(--annotation-color); + cursor: pointer; + transition: opacity 0.15s ease; + } + + .toolbar-btn:hover:not(:disabled) { + opacity: 0.7; + } + + .toolbar-btn:disabled { + opacity: 0.3; + cursor: not-allowed; + } + /* Textarea */ textarea { width: 100%; @@ -199,12 +275,17 @@ border: none; resize: none; font-family: var(--font-mono); - font-size: 11px; + font-size: var(--annotation-font-size); line-height: 1.5; color: var(--annotation-color); outline: none; padding: 8px; box-sizing: border-box; + box-shadow: none; + } + + textarea:focus { + box-shadow: none; } textarea::placeholder { @@ -218,28 +299,28 @@ height: 100%; padding: 8px; box-sizing: border-box; - font-size: 11px; + font-size: var(--annotation-font-size); line-height: 1.5; color: var(--annotation-color); overflow: auto; } .rendered :global(h1) { - font-size: 14px; + font-size: calc(var(--annotation-font-size) * 1.3); font-weight: 600; margin: 0 0 6px; color: var(--annotation-color); } .rendered :global(h2) { - font-size: 12px; + font-size: calc(var(--annotation-font-size) * 1.1); font-weight: 600; margin: 0 0 4px; color: var(--annotation-color); } .rendered :global(h3) { - font-size: 11px; + font-size: var(--annotation-font-size); font-weight: 600; margin: 0 0 3px; color: var(--annotation-color); diff --git a/src/lib/stores/clipboard.ts b/src/lib/stores/clipboard.ts index 8768be32..349cf5fe 100644 --- a/src/lib/stores/clipboard.ts +++ b/src/lib/stores/clipboard.ts @@ -4,7 +4,7 @@ */ import { writable, get } from 'svelte/store'; -import type { NodeInstance, Connection } from '$lib/nodes/types'; +import type { NodeInstance, Connection, Annotation } from '$lib/nodes/types'; import type { EventInstance } from '$lib/events/types'; import type { Position } from '$lib/types'; import { graphStore, cloneNodeForPaste } from '$lib/stores/graph'; @@ -22,6 +22,7 @@ interface ClipboardContent { nodes: NodeInstance[]; connections: Connection[]; events: EventInstance[]; + annotations: Annotation[]; // Center of the copied selection (for positioning on paste) center: Position; } @@ -78,7 +79,7 @@ function isValidPayload(data: unknown): data is SystemClipboardPayload { const content = obj.content as Record | null; if (!content || typeof content !== 'object') return false; - // Validate content structure + // Validate content structure (annotations optional for backward compatibility) return ( Array.isArray(content.nodes) && Array.isArray(content.connections) && @@ -140,13 +141,30 @@ async function readFromSystemClipboard(): Promise { // ==================== MAIN FUNCTIONS ==================== /** - * Copy selected nodes and events to clipboard (in-memory + system) + * Copy selected nodes, events, and annotations to clipboard (in-memory + system) */ async function copy(): Promise { const selectedNodeIds = get(graphStore.selectedNodeIds); const selectedEventIds = get(eventStore.selectedEventIds); - if (selectedNodeIds.size === 0 && selectedEventIds.size === 0) { + // Check for selected annotations + const copiedAnnotations: Annotation[] = []; + for (const id of selectedNodeIds) { + const annotation = graphStore.getAnnotation(id); + if (annotation) { + copiedAnnotations.push(JSON.parse(JSON.stringify(annotation))); + } + } + + // Filter out annotation IDs from node selection check + const actualNodeIds = new Set(); + for (const id of selectedNodeIds) { + if (!graphStore.getAnnotation(id)) { + actualNodeIds.add(id); + } + } + + if (actualNodeIds.size === 0 && selectedEventIds.size === 0 && copiedAnnotations.length === 0) { return false; } @@ -155,7 +173,7 @@ async function copy(): Promise { // Deep clone selected nodes (excluding Interface blocks) const copiedNodes: NodeInstance[] = []; - for (const id of selectedNodeIds) { + for (const id of actualNodeIds) { const node = nodesMap.get(id); if (node && node.type !== NODE_TYPES.INTERFACE) { copiedNodes.push(JSON.parse(JSON.stringify(node))); @@ -165,7 +183,7 @@ async function copy(): Promise { // Find connections where BOTH source and target are in the selection const copiedConnections: Connection[] = []; for (const conn of connections) { - if (selectedNodeIds.has(conn.sourceNodeId) && selectedNodeIds.has(conn.targetNodeId)) { + if (actualNodeIds.has(conn.sourceNodeId) && actualNodeIds.has(conn.targetNodeId)) { copiedConnections.push(JSON.parse(JSON.stringify(conn))); } } @@ -192,7 +210,8 @@ async function copy(): Promise { // Calculate center of all copied items const allItems = [ ...copiedNodes.map(n => ({ position: n.position })), - ...copiedEvents.map(e => ({ position: e.position })) + ...copiedEvents.map(e => ({ position: e.position })), + ...copiedAnnotations.map(a => ({ position: a.position })) ]; const center = calculateCenter(allItems); @@ -200,6 +219,7 @@ async function copy(): Promise { nodes: copiedNodes, connections: copiedConnections, events: copiedEvents, + annotations: copiedAnnotations, center }; @@ -216,9 +236,9 @@ async function copy(): Promise { * Paste clipboard contents at target position * Tries system clipboard first (enables cross-instance paste), falls back to in-memory * @param targetPosition - Position to center the pasted items at (flow coordinates) - * @returns IDs of pasted nodes and events + * @returns IDs of pasted nodes, events, and annotations */ -async function paste(targetPosition: Position): Promise<{ nodeIds: string[]; eventIds: string[] }> { +async function paste(targetPosition: Position): Promise<{ nodeIds: string[]; eventIds: string[]; annotationIds: string[] }> { // Try system clipboard first (enables cross-instance paste) let content = await readFromSystemClipboard(); @@ -227,8 +247,13 @@ async function paste(targetPosition: Position): Promise<{ nodeIds: string[]; eve content = get(clipboard); } - if (!content || (content.nodes.length === 0 && content.events.length === 0)) { - return { nodeIds: [], eventIds: [] }; + // Ensure annotations array exists (backward compatibility) + if (!content?.annotations) { + content = content ? { ...content, annotations: [] } : null; + } + + if (!content || (content.nodes.length === 0 && content.events.length === 0 && content.annotations.length === 0)) { + return { nodeIds: [], eventIds: [], annotationIds: [] }; } // Validate node types and filter out unknown types (for cross-instance paste safety) @@ -257,9 +282,9 @@ async function paste(targetPosition: Position): Promise<{ nodeIds: string[]; eve connections: validConnections }; - // If all nodes were filtered out, nothing to paste - if (content.nodes.length === 0 && content.events.length === 0) { - return { nodeIds: [], eventIds: [] }; + // If all nodes were filtered out, check if there's still something to paste + if (content.nodes.length === 0 && content.events.length === 0 && content.annotations.length === 0) { + return { nodeIds: [], eventIds: [], annotationIds: [] }; } } @@ -348,7 +373,26 @@ async function paste(targetPosition: Position): Promise<{ nodeIds: string[]; eve } } - return { nodeIds, eventIds }; + // Paste annotations + const annotationIds: string[] = []; + for (const annotation of content.annotations) { + const newPosition = { + x: annotation.position.x + offset.x, + y: annotation.position.y + offset.y + }; + + const newId = graphStore.addAnnotation(newPosition); + graphStore.updateAnnotation(newId, { + content: annotation.content, + width: annotation.width, + height: annotation.height, + color: annotation.color, + fontSize: annotation.fontSize + }); + annotationIds.push(newId); + } + + return { nodeIds, eventIds, annotationIds }; }); } @@ -372,7 +416,7 @@ async function cut(): Promise { */ function hasContent(): boolean { const content = get(clipboard); - return content !== null && (content.nodes.length > 0 || content.events.length > 0); + return content !== null && (content.nodes.length > 0 || content.events.length > 0 || content.annotations.length > 0); } /** @@ -385,13 +429,14 @@ function clear(): void { /** * Get clipboard content summary (for UI feedback) */ -function getSummary(): { nodes: number; connections: number; events: number } | null { +function getSummary(): { nodes: number; connections: number; events: number; annotations: number } | null { const content = get(clipboard); if (!content) return null; return { nodes: content.nodes.length, connections: content.connections.length, - events: content.events.length + events: content.events.length, + annotations: content.annotations.length }; } diff --git a/src/lib/stores/graph/annotations.ts b/src/lib/stores/graph/annotations.ts index b1ebc6d9..424fda0f 100644 --- a/src/lib/stores/graph/annotations.ts +++ b/src/lib/stores/graph/annotations.ts @@ -12,6 +12,14 @@ import { updateCurrentAnnotations } from './state'; +// Font size constants for annotations +export const ANNOTATION_FONT_SIZE = { + DEFAULT: 11, + MIN: 8, + MAX: 24, + STEP: 1 +} as const; + /** * Add an annotation to the current graph context */ @@ -22,7 +30,8 @@ export function addAnnotation(position: Position): string { position, content: '', width: 200, - height: 100 + height: 100, + fontSize: ANNOTATION_FONT_SIZE.DEFAULT }; updateCurrentAnnotations( diff --git a/src/lib/stores/graph/index.ts b/src/lib/stores/graph/index.ts index b62f853e..95e7fae5 100644 --- a/src/lib/stores/graph/index.ts +++ b/src/lib/stores/graph/index.ts @@ -8,6 +8,9 @@ // Re-export types export type { SearchableNode } from './state'; +// Re-export constants +export { ANNOTATION_FONT_SIZE } from './annotations'; + // Import stores for subscriptions import { currentNodes, diff --git a/src/lib/stores/graph/nodes.ts b/src/lib/stores/graph/nodes.ts index 89ba7c72..5121ed00 100644 --- a/src/lib/stores/graph/nodes.ts +++ b/src/lib/stores/graph/nodes.ts @@ -3,7 +3,7 @@ */ import { get } from 'svelte/store'; -import type { NodeInstance, PortInstance, Connection } from '$lib/nodes/types'; +import type { NodeInstance, PortInstance, Connection, Annotation } from '$lib/nodes/types'; import type { Position } from '$lib/types'; import { nodeRegistry } from '$lib/nodes/registry'; import { NODE_TYPES } from '$lib/constants/nodeTypes'; @@ -19,7 +19,8 @@ import { updateNodeById, updateCurrentNodes, updateCurrentConnections, - updateCurrentNodesAndConnections + updateCurrentNodesAndConnections, + updateCurrentAnnotations } from './state'; import { regenerateGraphIds, createPorts } from './helpers'; import { triggerSelectNodes } from '$lib/stores/viewActions'; @@ -236,7 +237,7 @@ export function getAllNodes(): NodeInstance[] { } /** - * Duplicate selected nodes (and connections between them) + * Duplicate selected nodes, annotations, and connections between them */ export function duplicateSelected(): string[] { const selected = get(selectedNodeIds); @@ -244,6 +245,7 @@ export function duplicateSelected(): string[] { const currentGraph = getCurrentGraph(); const newNodeIds: string[] = []; + const newAnnotationIds: string[] = []; const offset = { x: 50, y: 50 }; // Map from old node ID to new node ID (for connection remapping) @@ -252,7 +254,27 @@ export function duplicateSelected(): string[] { // Build list of new nodes to add const nodesToAdd: NodeInstance[] = []; + // Build list of new annotations to add + const annotationsToAdd: Annotation[] = []; + selected.forEach(id => { + // Check if this is an annotation + const annotation = currentGraph.annotations.get(id); + if (annotation) { + const newId = generateId(); + const newAnnotation: Annotation = { + ...annotation, + id: newId, + position: { + x: annotation.position.x + offset.x, + y: annotation.position.y + offset.y + } + }; + annotationsToAdd.push(newAnnotation); + newAnnotationIds.push(newId); + return; + } + const original = currentGraph.nodes.get(id); if (!original) return; if (original.type === NODE_TYPES.INTERFACE) return; // Don't duplicate Interface @@ -316,26 +338,46 @@ export function duplicateSelected(): string[] { } // Add all nodes and connections in one update - updateCurrentNodesAndConnections( - // Map updater for nodes (root) - nodes => { - const newMap = new Map(nodes); - for (const node of nodesToAdd) { - newMap.set(node.id, node); - } - return newMap; - }, - // Array updater for nodes (subsystem) - nodes => [...nodes, ...nodesToAdd], - // Connection updater - conns => [...conns, ...newConnections] - ); + if (nodesToAdd.length > 0 || newConnections.length > 0) { + updateCurrentNodesAndConnections( + // Map updater for nodes (root) + nodes => { + const newMap = new Map(nodes); + for (const node of nodesToAdd) { + newMap.set(node.id, node); + } + return newMap; + }, + // Array updater for nodes (subsystem) + nodes => [...nodes, ...nodesToAdd], + // Connection updater + conns => [...conns, ...newConnections] + ); + } - if (newNodeIds.length > 0) { - triggerSelectNodes(newNodeIds, false); + // Add annotations + if (annotationsToAdd.length > 0) { + updateCurrentAnnotations( + // Map updater (root) + a => { + const newMap = new Map(a); + for (const annotation of annotationsToAdd) { + newMap.set(annotation.id, annotation); + } + return newMap; + }, + // Array updater (subsystem) + a => [...a, ...annotationsToAdd] + ); } - return newNodeIds; + // Select all new items + const allNewIds = [...newNodeIds, ...newAnnotationIds]; + if (allNewIds.length > 0) { + triggerSelectNodes(allNewIds, false); + } + + return allNewIds; } /** diff --git a/src/lib/stores/viewActions.ts b/src/lib/stores/viewActions.ts index 425aee17..f706e20f 100644 --- a/src/lib/stores/viewActions.ts +++ b/src/lib/stores/viewActions.ts @@ -25,7 +25,9 @@ export { nudgeTrigger, triggerNudge, selectNodeTrigger, - triggerSelectNodes + triggerSelectNodes, + editAnnotationTrigger, + triggerEditAnnotation } from './viewTriggers'; // Re-export all utilities diff --git a/src/lib/stores/viewTriggers.ts b/src/lib/stores/viewTriggers.ts index 11ebf0d7..ee439019 100644 --- a/src/lib/stores/viewTriggers.ts +++ b/src/lib/stores/viewTriggers.ts @@ -82,3 +82,13 @@ export const selectNodeTrigger = writable<{ nodeIds: string[]; addToSelection: b export function triggerSelectNodes(nodeIds: string[], addToSelection = false): void { selectNodeTrigger.update((current) => ({ nodeIds, addToSelection, id: current.id + 1 })); } + +// Edit annotation trigger - triggers edit mode on a specific annotation +export const editAnnotationTrigger = writable<{ annotationId: string; id: number }>({ + annotationId: '', + id: 0 +}); + +export function triggerEditAnnotation(annotationId: string): void { + editAnnotationTrigger.update((current) => ({ annotationId, id: current.id + 1 })); +} diff --git a/src/lib/types/nodes.ts b/src/lib/types/nodes.ts index 5af20129..cd2c7059 100644 --- a/src/lib/types/nodes.ts +++ b/src/lib/types/nodes.ts @@ -134,6 +134,7 @@ export interface Annotation { width: number; height: number; color?: string; // Optional custom color (defaults to --pathsim-blue) + fontSize?: number; // Font size in pixels (default: 11) // Index signature for SvelteFlow compatibility [key: string]: unknown; From 712c1fda4b31c3587edf388e63901bcac7702c97 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 18 Jan 2026 21:13:38 +0100 Subject: [PATCH 094/656] Fix deploy workflow action name and branch --- .github/workflows/deploy.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8bb8a558..83917d13 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -88,9 +88,10 @@ jobs: - name: Deploy to /dev if: steps.deploy-type.outputs.type == 'dev' - uses: peaceiris/actions-deployment@v4 + uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: deployment publish_dir: ./build destination_dir: dev keep_files: true @@ -98,9 +99,10 @@ jobs: - name: Deploy release to root if: steps.deploy-type.outputs.type == 'release' - uses: peaceiris/actions-deployment@v4 + uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: deployment publish_dir: ./build keep_files: true cname: view.pathsim.org From c7853f6cb50015f88dfd1fc30218c365e1a1fbe3 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 18 Jan 2026 21:58:15 +0100 Subject: [PATCH 095/656] Add context menu for Plotly plots with export and legend toggle --- src/lib/components/contextMenuBuilders.ts | 66 +++++++++++++++++++++ src/lib/components/panels/SignalPlot.svelte | 9 ++- src/lib/stores/contextMenu.ts | 14 ++++- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index 174971fe..e52c65d6 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -22,6 +22,7 @@ import { openImportDialog } from '$lib/schema/fileOps'; import { hasExportableData, exportRecordingData } from '$lib/utils/csvExport'; import { exportToSVG } from '$lib/export/svg'; import { downloadSvg } from '$lib/utils/download'; +import { plotSettingsStore, DEFAULT_BLOCK_SETTINGS } from '$lib/stores/plotSettings'; /** Divider menu item */ const DIVIDER: MenuItemType = { label: '', action: () => {}, divider: true }; @@ -442,6 +443,69 @@ function buildAnnotationMenu(annotationId: string): MenuItemType[] { ]; } +/** + * Build context menu items for a plot + */ +async function downloadPlotImage(plotEl: HTMLDivElement, format: 'png' | 'svg', filename: string): Promise { + const Plotly = await import('plotly.js-dist-min'); + await Plotly.downloadImage(plotEl, { + format, + filename, + width: 1200, + height: 800 + }); +} + +function buildPlotMenu(nodeId: string, plotEl: HTMLDivElement): MenuItemType[] { + const node = graphStore.getNode(nodeId); + const nodeName = node?.name || 'Plot'; + const nodeType = node?.type || 'Scope'; + const dataSource = nodeType === 'Spectrum' ? 'spectrum' : 'scope'; + const canExportCsv = hasExportableData(nodeId, dataSource as 'scope' | 'spectrum'); + + // Get legend visibility from plotSettingsStore (same source as PlotOptionsDialog) + const settings = get(plotSettingsStore); + const blockSettings = settings.blocks[nodeId] ?? DEFAULT_BLOCK_SETTINGS; + const showLegend = blockSettings.showLegend; + + return [ + { + label: 'Download PNG', + icon: 'image', + action: () => downloadPlotImage(plotEl, 'png', nodeName) + }, + { + label: 'Download SVG', + icon: 'image', + action: () => downloadPlotImage(plotEl, 'svg', nodeName) + }, + DIVIDER, + { + label: 'Export CSV', + icon: 'table', + action: () => exportRecordingData(nodeId, nodeName, nodeType), + disabled: !canExportCsv + }, + DIVIDER, + { + label: showLegend ? 'Hide Legend' : 'Show Legend', + icon: 'list', + action: () => plotSettingsStore.setBlockShowLegend(nodeId, !showLegend) + }, + { + label: 'Reset View', + icon: 'maximize', + action: async () => { + const Plotly = await import('plotly.js-dist-min'); + Plotly.relayout(plotEl, { + 'xaxis.autorange': true, + 'yaxis.autorange': true + }); + } + } + ]; +} + /** * Build context menu items based on target */ @@ -465,6 +529,8 @@ export function buildContextMenuItems( return buildCanvasMenu(screenPosition, callbacks); case 'annotation': return buildAnnotationMenu(target.annotationId); + case 'plot': + return buildPlotMenu(target.nodeId, target.plotEl); default: return []; } diff --git a/src/lib/components/panels/SignalPlot.svelte b/src/lib/components/panels/SignalPlot.svelte index 15a38b58..f0d99e79 100644 --- a/src/lib/components/panels/SignalPlot.svelte +++ b/src/lib/components/panels/SignalPlot.svelte @@ -2,6 +2,7 @@ import { onMount, onDestroy } from 'svelte'; import { plotDataStore } from '$lib/plotting/processing/plotDataStore'; import { themeStore } from '$lib/stores/theme'; + import { contextMenuStore } from '$lib/stores/contextMenu'; import { toPlotlyTrace, toPlotlySpectrumTrace, @@ -221,10 +222,16 @@ const emptyLayout = createEmptyLayout(baseLayout); Plotly.newPlot(plotDiv, [], emptyLayout, PLOTLY_CONFIG); } + + function handleContextMenu(e: MouseEvent) { + e.preventDefault(); + contextMenuStore.openForPlot(nodeId, plotDiv, { x: e.clientX, y: e.clientY }); + }
        -
        + +
        From f115022e647236aa005771125faa3716d9d4247d Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Mon, 19 Jan 2026 10:04:49 +0100 Subject: [PATCH 119/656] Update confirmation button styles and add Enter hint --- src/lib/components/ConfirmationModal.svelte | 27 ++++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/lib/components/ConfirmationModal.svelte b/src/lib/components/ConfirmationModal.svelte index 9eab7f63..fb52d97d 100644 --- a/src/lib/components/ConfirmationModal.svelte +++ b/src/lib/components/ConfirmationModal.svelte @@ -66,6 +66,7 @@
        +
        + Press to confirm +
        + -
        - Press to confirm -
        - -
        {/if} @@ -98,35 +101,14 @@ line-height: 1.5; } - .dialog-footer { + .dialog-actions { display: flex; - align-items: center; + justify-content: flex-end; gap: var(--space-sm); - padding: var(--space-xs) var(--space-md); - background: var(--surface-raised); - border-top: 1px solid var(--border); - border-radius: 0 0 var(--radius-lg) var(--radius-lg); - } - - .dialog-footer .hint { - font-size: 10px; - color: var(--text-disabled); - margin-right: auto; - } - - .dialog-footer kbd { - display: inline-block; - padding: 1px 4px; - font-family: inherit; - font-size: 9px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: 3px; - color: var(--text-muted); - margin: 0 2px; + padding: var(--space-sm) var(--space-md); } - .dialog-footer button { + .dialog-actions button { padding: var(--space-sm) var(--space-md); border: 1px solid var(--border); border-radius: var(--radius-md); @@ -136,24 +118,46 @@ transition: all var(--transition-fast); } - .dialog-footer button.ghost { + .dialog-actions button.ghost { background: transparent; color: var(--text-muted); } - .dialog-footer button.ghost:hover { + .dialog-actions button.ghost:hover { background: var(--surface-hover); color: var(--text); border-color: var(--border-focus); } - .dialog-footer button:not(.ghost) { + .dialog-actions button:not(.ghost) { background: var(--surface-raised); color: var(--text); } - .dialog-footer button:not(.ghost):hover { + .dialog-actions button:not(.ghost):hover { background: var(--surface-hover); border-color: var(--border-focus); } + + .dialog-footer { + padding: var(--space-xs) var(--space-md); + background: var(--surface-raised); + border-top: 1px solid var(--border); + border-radius: 0 0 var(--radius-lg) var(--radius-lg); + font-size: 10px; + color: var(--text-disabled); + text-align: center; + } + + .dialog-footer kbd { + display: inline-block; + padding: 1px 4px; + font-family: inherit; + font-size: 9px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 3px; + color: var(--text-muted); + margin: 0 2px; + } From f17f10429e479e47e14fb042b41856a336f0e5bf Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Mon, 19 Jan 2026 10:08:18 +0100 Subject: [PATCH 124/656] Center the action buttons --- src/lib/components/ConfirmationModal.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/ConfirmationModal.svelte b/src/lib/components/ConfirmationModal.svelte index bf0bc6ec..65e8e049 100644 --- a/src/lib/components/ConfirmationModal.svelte +++ b/src/lib/components/ConfirmationModal.svelte @@ -103,7 +103,7 @@ .dialog-actions { display: flex; - justify-content: flex-end; + justify-content: center; gap: var(--space-sm); padding: var(--space-sm) var(--space-md); } From eb15abe62baf707b47937cd67faee6c9fbb1e602 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Mon, 19 Jan 2026 10:08:49 +0100 Subject: [PATCH 125/656] Reduce modal width to 320px --- src/lib/components/ConfirmationModal.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/ConfirmationModal.svelte b/src/lib/components/ConfirmationModal.svelte index 65e8e049..e4d298ad 100644 --- a/src/lib/components/ConfirmationModal.svelte +++ b/src/lib/components/ConfirmationModal.svelte @@ -84,7 +84,7 @@ diff --git a/src/lib/utils/rstRenderer.ts b/src/lib/utils/rstRenderer.ts index d4c8dbdc..2bd60297 100644 --- a/src/lib/utils/rstRenderer.ts +++ b/src/lib/utils/rstRenderer.ts @@ -1,11 +1,19 @@ /** * Docstring renderer - processes HTML from docutils and renders math with KaTeX + * Also transforms definition lists to tables and applies CodeMirror to code blocks. * * The RST is converted to HTML by Python's docutils in the Pyodide worker. - * This module just applies KaTeX to any math elements and provides styling. + * This module applies KaTeX to math elements, transforms definition lists to + * parameter tables, and optionally renders code blocks with CodeMirror. */ import { loadKatex } from './katexLoader'; +import { loadCodeMirrorModules, createEditorExtensions, type CodeMirrorModules } from './codemirror'; + +// Track CodeMirror editor instances for cleanup +let editorViews: import('@codemirror/view').EditorView[] = []; +let codeBlocks: { wrapper: HTMLElement; code: string }[] = []; +let cmModules: CodeMirrorModules | null = null; /** * Process HTML from docutils and render math with KaTeX @@ -65,5 +73,241 @@ export async function renderDocstring(html: string): Promise { return temp.innerHTML; } +/** + * Transform docutils definition lists (
        ) to styled parameter tables. + * This makes Parameters, Returns, Attributes sections look much better. + */ +export function transformDefinitionListsToTables(container: HTMLElement): void { + const dlElements = container.querySelectorAll('dl.docutils'); + + for (const dl of dlElements) { + // Skip if already transformed + if (dl.classList.contains('table-transformed')) continue; + + // Create wrapper for panel styling + const wrapper = document.createElement('div'); + wrapper.className = 'param-table-wrapper'; + + const table = document.createElement('table'); + table.className = 'param-table'; + + // Add header row + const thead = document.createElement('thead'); + const headerRow = document.createElement('tr'); + ['Name', 'Type', 'Description'].forEach(text => { + const th = document.createElement('th'); + th.textContent = text; + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + + const tbody = document.createElement('tbody'); + + // Get all dt/dd pairs + const dts = dl.querySelectorAll(':scope > dt'); + + for (const dt of dts) { + const row = document.createElement('tr'); + + // Extract name (text before classifier-delimiter) + const nameCell = document.createElement('td'); + nameCell.className = 'param-name'; + const nameCode = document.createElement('code'); + + // Get the name - it's the first text node or text before classifier-delimiter + let name = ''; + for (const node of dt.childNodes) { + if (node.nodeType === Node.TEXT_NODE) { + name = node.textContent?.trim() || ''; + if (name) break; + } else if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as Element; + if (el.classList.contains('classifier-delimiter')) break; + name = el.textContent?.trim() || ''; + if (name) break; + } + } + nameCode.textContent = name; + nameCell.appendChild(nameCode); + row.appendChild(nameCell); + + // Extract type from classifier span + const typeCell = document.createElement('td'); + typeCell.className = 'param-type'; + const classifier = dt.querySelector('.classifier'); + if (classifier) { + const typeCode = document.createElement('code'); + typeCode.textContent = classifier.textContent || ''; + typeCell.appendChild(typeCode); + } + row.appendChild(typeCell); + + // Get description from following dd + const descCell = document.createElement('td'); + descCell.className = 'param-desc'; + const dd = dt.nextElementSibling; + if (dd && dd.tagName === 'DD') { + descCell.innerHTML = dd.innerHTML; + } + row.appendChild(descCell); + + tbody.appendChild(row); + } + + table.appendChild(tbody); + wrapper.appendChild(table); + + // Replace dl with wrapped table + dl.parentNode?.replaceChild(wrapper, dl); + } +} + +/** + * Detect language from code content + */ +function detectLanguage(code: string): 'python' | 'console' { + // Check for Python REPL prompts + if (code.includes('>>>') || code.includes('...')) { + return 'console'; + } + return 'python'; +} + +/** + * Create a copy button for code blocks + */ +function createCopyButton(code: string): HTMLButtonElement { + const button = document.createElement('button'); + button.className = 'copy-btn'; + button.innerHTML = ``; + button.title = 'Copy code'; + + button.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(code); + button.innerHTML = ``; + setTimeout(() => { + button.innerHTML = ``; + }, 2000); + } catch (e) { + console.error('Failed to copy:', e); + } + }); + + return button; +} + +/** + * Render code blocks with CodeMirror syntax highlighting. + * Call this after the HTML is inserted into the DOM. + */ +export async function renderCodeBlocks(container: HTMLElement): Promise { + // Clean up existing editors + cleanupCodeBlocks(); + + cmModules = await loadCodeMirrorModules(); + const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; + + // Find all pre elements that contain code + const preElements = container.querySelectorAll('pre'); + + for (const preEl of preElements) { + // Skip if already processed + if (preEl.classList.contains('cm-processed')) continue; + + // Get code content - either from nested code element or directly from pre + const codeEl = preEl.querySelector('code'); + const code = codeEl ? codeEl.textContent : preEl.textContent; + if (!code?.trim()) continue; + + // Mark as processed + preEl.classList.add('cm-processed'); + + const trimmedCode = code.trim(); + const language = detectLanguage(trimmedCode); + + // Create wrapper div + const wrapper = document.createElement('div'); + wrapper.className = 'code-block-wrapper'; + + // Create header + const header = document.createElement('div'); + header.className = 'code-block-header'; + + const label = document.createElement('span'); + label.className = 'code-label'; + label.textContent = language === 'console' ? 'CONSOLE' : 'PYTHON'; + header.appendChild(label); + + header.appendChild(createCopyButton(trimmedCode)); + wrapper.appendChild(header); + + // Create editor container + const editorDiv = document.createElement('div'); + editorDiv.className = 'cm-container'; + wrapper.appendChild(editorDiv); + + // Replace pre with wrapper + preEl.parentNode?.replaceChild(wrapper, preEl); + + // Store code and wrapper for theme switching + codeBlocks.push({ wrapper, code: trimmedCode }); + + // Create CodeMirror editor + const view = new cmModules.EditorView({ + doc: trimmedCode, + extensions: createEditorExtensions(cmModules, isDark, { readOnly: true }), + parent: editorDiv + }); + + editorViews.push(view); + } +} + +/** + * Update code block themes (call when theme changes) + */ +export async function updateCodeBlockTheme(): Promise { + if (!cmModules || codeBlocks.length === 0) return; + + const isDark = document.documentElement.getAttribute('data-theme') !== 'light'; + + // Destroy old editors + for (const view of editorViews) { + view.destroy(); + } + editorViews = []; + + // Recreate editors with new theme + for (const { wrapper, code } of codeBlocks) { + const editorDiv = wrapper.querySelector('.cm-container'); + if (!editorDiv) continue; + + // Clear old editor content + editorDiv.innerHTML = ''; + + // Create new editor with updated theme + const view = new cmModules.EditorView({ + doc: code, + extensions: createEditorExtensions(cmModules, isDark, { readOnly: true }), + parent: editorDiv as HTMLElement + }); + + editorViews.push(view); + } +} + +/** + * Clean up CodeMirror editors (call on component destroy) + */ +export function cleanupCodeBlocks(): void { + for (const view of editorViews) { + view.destroy(); + } + editorViews = []; + codeBlocks = []; +} + // Re-export for convenience export { getKatexCssUrl } from './katexLoader'; From a0d65234dfe2937b530f2def4e2634dc7cb273aa Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 07:17:39 +0100 Subject: [PATCH 172/656] Simplify docstring section headers and fix math container sizing --- .../shared/DocumentationSection.svelte | 48 ++----------------- 1 file changed, 5 insertions(+), 43 deletions(-) diff --git a/src/lib/components/dialogs/shared/DocumentationSection.svelte b/src/lib/components/dialogs/shared/DocumentationSection.svelte index ed4b19dd..2533dde2 100644 --- a/src/lib/components/dialogs/shared/DocumentationSection.svelte +++ b/src/lib/components/dialogs/shared/DocumentationSection.svelte @@ -384,62 +384,26 @@ line-height: 1.5; } - /* Section separators (RST sections) */ + /* RST sections */ .docs-content :global(.section) { - position: relative; - margin-top: var(--space-md); - padding-top: var(--space-md); - } - - .docs-content :global(.section::before) { - content: ''; - position: absolute; - top: 0; - left: calc(-1 * var(--space-md)); - width: calc(100% + 2 * var(--space-md)); - height: 1px; - background: var(--border); + margin-top: var(--space-sm); } .docs-content :global(.section:first-child) { margin-top: 0; - padding-top: 0; - } - - .docs-content :global(.section:first-child::before) { - display: none; } /* NumPy-style section headers converted to

        */ .docs-content :global(p:has(> strong:only-child)) { - position: relative; font-size: 10px; font-weight: 600; color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.5px; margin-top: var(--space-md); margin-bottom: var(--space-xs); - padding-top: var(--space-md); - } - - .docs-content :global(p:has(> strong:only-child)::before) { - content: ''; - position: absolute; - top: 0; - left: calc(-1 * var(--space-md)); - width: calc(100% + 2 * var(--space-md)); - height: 1px; - background: var(--border); } .docs-content :global(p:first-child:has(> strong:only-child)) { margin-top: 0; - padding-top: 0; - } - - .docs-content :global(p:first-child:has(> strong:only-child)::before) { - display: none; } .docs-content :global(p:has(> strong:only-child) > strong) { @@ -450,19 +414,17 @@ /* Math blocks from docutils */ .docs-content :global(.math), .docs-content :global(div.math) { - margin: 0.75em 0; - overflow-x: auto; + margin: 0.5em 0; text-align: center; } /* KaTeX styling */ .docs-content :global(.katex) { - font-size: 1.1em; + font-size: 1em; } .docs-content :global(.katex-display) { - margin: 0.75em 0; - overflow-x: auto; + margin: 0.5em 0; } /* Generic definition lists (fallback if not transformed) */ From 63b70ee8f3415b5d183c249422db7d610993abec Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 07:18:39 +0100 Subject: [PATCH 173/656] Style section headers smaller/uppercase, increase math font to 1.3em --- .../components/dialogs/shared/DocumentationSection.svelte | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/components/dialogs/shared/DocumentationSection.svelte b/src/lib/components/dialogs/shared/DocumentationSection.svelte index 2533dde2..38655b42 100644 --- a/src/lib/components/dialogs/shared/DocumentationSection.svelte +++ b/src/lib/components/dialogs/shared/DocumentationSection.svelte @@ -395,9 +395,11 @@ /* NumPy-style section headers converted to

        */ .docs-content :global(p:has(> strong:only-child)) { - font-size: 10px; + font-size: 9px; font-weight: 600; color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; margin-top: var(--space-md); margin-bottom: var(--space-xs); } @@ -420,7 +422,7 @@ /* KaTeX styling */ .docs-content :global(.katex) { - font-size: 1em; + font-size: 1.3em; } .docs-content :global(.katex-display) { From 1ab4ac7406dd90346b9cfae3e246a1eb8ab3c054 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 07:19:16 +0100 Subject: [PATCH 174/656] Fix section header text-transform inheritance --- src/lib/components/dialogs/shared/DocumentationSection.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/components/dialogs/shared/DocumentationSection.svelte b/src/lib/components/dialogs/shared/DocumentationSection.svelte index 38655b42..0d5f57f5 100644 --- a/src/lib/components/dialogs/shared/DocumentationSection.svelte +++ b/src/lib/components/dialogs/shared/DocumentationSection.svelte @@ -411,6 +411,7 @@ .docs-content :global(p:has(> strong:only-child) > strong) { font-weight: inherit; color: inherit; + text-transform: inherit; } /* Math blocks from docutils */ From 8851d3a1d29a81739eb08374167f911e04320625 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 07:21:21 +0100 Subject: [PATCH 175/656] Fix section headers to match modal section-title style (uppercase, 10px) --- .../shared/DocumentationSection.svelte | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/lib/components/dialogs/shared/DocumentationSection.svelte b/src/lib/components/dialogs/shared/DocumentationSection.svelte index 0d5f57f5..d8e60bf7 100644 --- a/src/lib/components/dialogs/shared/DocumentationSection.svelte +++ b/src/lib/components/dialogs/shared/DocumentationSection.svelte @@ -393,25 +393,15 @@ margin-top: 0; } - /* NumPy-style section headers converted to

        */ - .docs-content :global(p:has(> strong:only-child)) { - font-size: 9px; + /* Section headers (h3, h4 inside .section) - match .section-title style */ + .docs-content :global(.section h3), + .docs-content :global(.section h4) { + font-size: 10px; font-weight: 600; - color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; - margin-top: var(--space-md); - margin-bottom: var(--space-xs); - } - - .docs-content :global(p:first-child:has(> strong:only-child)) { - margin-top: 0; - } - - .docs-content :global(p:has(> strong:only-child) > strong) { - font-weight: inherit; - color: inherit; - text-transform: inherit; + color: var(--text-disabled); + margin: 0 0 var(--space-xs) 0; } /* Math blocks from docutils */ From 5cc8549187e4fa890267e370935baf7729587b20 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 07:22:32 +0100 Subject: [PATCH 176/656] Fix section header color to text-muted, use node-color for param names --- src/app.css | 2 +- src/lib/components/dialogs/shared/DocumentationSection.svelte | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app.css b/src/app.css index 2e4441e0..a595691c 100644 --- a/src/app.css +++ b/src/app.css @@ -372,7 +372,7 @@ input::placeholder { font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; - color: var(--text-disabled); + color: var(--text-muted); margin-bottom: var(--space-sm); } diff --git a/src/lib/components/dialogs/shared/DocumentationSection.svelte b/src/lib/components/dialogs/shared/DocumentationSection.svelte index d8e60bf7..b8830c41 100644 --- a/src/lib/components/dialogs/shared/DocumentationSection.svelte +++ b/src/lib/components/dialogs/shared/DocumentationSection.svelte @@ -365,7 +365,7 @@ font-family: var(--font-mono); font-size: 10px; font-weight: 500; - color: var(--accent); + color: var(--node-color, var(--accent)); } .docs-content :global(.param-table .param-type) { @@ -400,7 +400,7 @@ font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; - color: var(--text-disabled); + color: var(--text-muted); margin: 0 0 var(--space-xs) 0; } From 80d4a5e1a6fedd0057e8e3b4d4d89c42e2488a76 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 07:27:10 +0100 Subject: [PATCH 177/656] Add viewport-aware tooltip positioning --- src/lib/components/Tooltip.svelte | 51 +++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/src/lib/components/Tooltip.svelte b/src/lib/components/Tooltip.svelte index 89acf527..245179ef 100644 --- a/src/lib/components/Tooltip.svelte +++ b/src/lib/components/Tooltip.svelte @@ -36,29 +36,68 @@ showTimeout = setTimeout(() => { const rect = element.getBoundingClientRect(); + const tooltipMaxWidth = maxWidth ?? 240; + const tooltipHeight = 28; // Estimated height for single-line tooltip + const margin = 8; // Gap between element and tooltip + const padding = 8; // Minimum distance from viewport edge + + // Calculate position and check viewport bounds + let finalPosition = position; let x: number, y: number; - switch (position) { + // Check if preferred position would overflow, flip if needed + if (position === 'bottom' && rect.bottom + margin + tooltipHeight > window.innerHeight - padding) { + finalPosition = 'top'; + } else if (position === 'top' && rect.top - margin - tooltipHeight < padding) { + finalPosition = 'bottom'; + } else if (position === 'right' && rect.right + margin + tooltipMaxWidth > window.innerWidth - padding) { + finalPosition = 'left'; + } else if (position === 'left' && rect.left - margin - tooltipMaxWidth < padding) { + finalPosition = 'right'; + } + + // Calculate coordinates based on final position + switch (finalPosition) { case 'left': - x = rect.left - 8; + x = rect.left - margin; y = rect.top + rect.height / 2; break; case 'right': - x = rect.right + 8; + x = rect.right + margin; y = rect.top + rect.height / 2; break; case 'top': x = rect.left + rect.width / 2; - y = rect.top - 8; + y = rect.top - margin; break; case 'bottom': default: x = rect.left + rect.width / 2; - y = rect.bottom + 8; + y = rect.bottom + margin; break; } - tooltipStore.set({ text, shortcut, maxWidth, x, y, visible: true, position }); + // Clamp horizontal position to keep tooltip within viewport + if (finalPosition === 'bottom' || finalPosition === 'top') { + const halfWidth = tooltipMaxWidth / 2; + if (x - halfWidth < padding) { + x = padding + halfWidth; + } else if (x + halfWidth > window.innerWidth - padding) { + x = window.innerWidth - padding - halfWidth; + } + } + + // Clamp vertical position for left/right tooltips + if (finalPosition === 'left' || finalPosition === 'right') { + const halfHeight = tooltipHeight / 2; + if (y - halfHeight < padding) { + y = padding + halfHeight; + } else if (y + halfHeight > window.innerHeight - padding) { + y = window.innerHeight - padding - halfHeight; + } + } + + tooltipStore.set({ text, shortcut, maxWidth, x, y, visible: true, position: finalPosition }); }, 50); } From 403db6cfd5e4196418a47c53fa6ead8304d210cf Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 07:48:53 +0100 Subject: [PATCH 178/656] Fix search input clipping by resetting border-radius --- src/lib/components/dialogs/SearchDialog.svelte | 1 + src/lib/components/panels/NodeLibrary.svelte | 1 + 2 files changed, 2 insertions(+) diff --git a/src/lib/components/dialogs/SearchDialog.svelte b/src/lib/components/dialogs/SearchDialog.svelte index 6329fae4..6481a3d9 100644 --- a/src/lib/components/dialogs/SearchDialog.svelte +++ b/src/lib/components/dialogs/SearchDialog.svelte @@ -219,6 +219,7 @@ flex: 1; background: transparent; border: none; + border-radius: 0; font-size: var(--font-base); color: var(--text); outline: none; diff --git a/src/lib/components/panels/NodeLibrary.svelte b/src/lib/components/panels/NodeLibrary.svelte index fb04e497..ff5b49ec 100644 --- a/src/lib/components/panels/NodeLibrary.svelte +++ b/src/lib/components/panels/NodeLibrary.svelte @@ -248,6 +248,7 @@ flex: 1; background: transparent; border: none; + border-radius: 0; font-size: var(--font-base); color: var(--text); outline: none; From f35b964f83ef185df44c50d1cd58e52b464c5941 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 17:25:11 +0100 Subject: [PATCH 179/656] Fix docstring extraction to use inspect.cleandoc for proper indentation --- scripts/extract.py | 5 +- src/lib/events/generated/events.ts | 8 +- src/lib/nodes/generated/blocks.ts | 403 +++++++++------------ src/lib/simulation/generated/simulation.ts | 4 +- 4 files changed, 182 insertions(+), 238 deletions(-) diff --git a/scripts/extract.py b/scripts/extract.py index d49836b4..467fee94 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -50,9 +50,12 @@ def rst_to_html(rst_text: str) -> str: if not rst_text or not HAS_DOCUTILS: return "" + # Clean the docstring - removes common leading whitespace from indented docstrings + cleaned = inspect.cleandoc(rst_text) + try: parts = publish_parts( - rst_text, + cleaned, writer_name="html", settings_overrides={ "report_level": 5, diff --git a/src/lib/events/generated/events.ts b/src/lib/events/generated/events.ts index b0a8cafb..bb876115 100644 --- a/src/lib/events/generated/events.ts +++ b/src/lib/events/generated/events.ts @@ -10,7 +10,7 @@ export const extractedEvents: EventTypeDefinition[] = "name": "ZeroCrossing", "eventClass": "ZeroCrossing", "description": "Subclass of base 'Event' that triggers if the event function crosses zero.", - "docstringHtml": "

        Subclass of base 'Event' that triggers if the event function crosses zero.\nThis is a bidirectional zero-crossing detector.

        \n

        Monitors system state by evaluating an event function (func_evt) with scalar output and\ntesting for zero crossings (sign changes).

        \n
        \nfunc_evt(time) -> event?\n
        \n

        If an event is detected, some action (func_act) is performed on the system state.

        \n
        \nfunc_evt(time) == 0 -> event -> func_act(time)\n
        \n
        \n

        Example

        \n

        Initialize a zero-crossing event handler like this:

        \n
        \n# define the event function\ndef evt(t):\n    # here we have a zero-crossing at 't==10'\n    return t - 10\n\n# define the action function (callback)\ndef act(t):\n    # do something at event resolution\n    pass\n\n# initialize the event manager\nE = ZeroCrossing(\n    func_evt=evt,  # the event function\n    func_act=act   # the action function\n    )\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func_evt : callable
        \n
        event function, where zeros are events
        \n
        func_act : callable
        \n
        action function for event resolution
        \n
        tolerance : float
        \n
        tolerance to check if detection is close to actual event
        \n
        \n
        \n", + "docstringHtml": "

        Subclass of base 'Event' that triggers if the event function crosses zero.\nThis is a bidirectional zero-crossing detector.

        \n

        Monitors system state by evaluating an event function (func_evt) with scalar output and\ntesting for zero crossings (sign changes).

        \n
        \nfunc_evt(time) -> event?\n
        \n

        If an event is detected, some action (func_act) is performed on the system state.

        \n
        \nfunc_evt(time) == 0 -> event -> func_act(time)\n
        \n
        \n

        Example

        \n

        Initialize a zero-crossing event handler like this:

        \n
        \n#define the event function\ndef evt(t):\n    #here we have a zero-crossing at 't==10'\n    return t - 10\n\n#define the action function (callback)\ndef act(t):\n    #do something at event resolution\n    pass\n\n#initialize the event manager\nE = ZeroCrossing(\n    func_evt=evt,  #the event function\n    func_act=act   #the action function\n    )\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func_evt : callable
        \n
        event function, where zeros are events
        \n
        func_act : callable
        \n
        action function for event resolution
        \n
        tolerance : float
        \n
        tolerance to check if detection is close to actual event
        \n
        \n
        \n", "params": [ { "name": "func_evt", @@ -91,7 +91,7 @@ export const extractedEvents: EventTypeDefinition[] = "name": "Schedule", "eventClass": "Schedule", "description": "Subclass of base 'Event' that triggers dependent on the evaluation time.", - "docstringHtml": "

        Subclass of base 'Event' that triggers dependent on the evaluation time.

        \n

        Monitors time in every timestep and triggers periodically (period). This event\ndoes not have an event function as the event condition only depends on time.

        \n
        \ntime == next_schedule_time -> event\n
        \n
        \n

        Example

        \n

        Initialize a scheduled event handler like this:

        \n
        \n#define the action function (callback)\ndef act(t):\n    #do something at event resolution\n    pass\n\n#initialize the event manager\nE = Schedule(\n    t_start=0,    #starting at t=0\n    t_end=None,   #never ending\n    t_period=3,   #triggering every 3 time units\n    func_act=act  #resulting in a callback\n    )\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        t_start : float
        \n
        starting time for schedule
        \n
        t_end : float
        \n
        termination time for schedule
        \n
        t_period : float
        \n
        time period of schedule, when events are triggered
        \n
        func_act : callable
        \n
        action function for event resolution
        \n
        tolerance : float
        \n
        tolerance to check if detection is close to actual event
        \n
        \n
        \n", + "docstringHtml": "

        Subclass of base 'Event' that triggers dependent on the evaluation time.

        \n

        Monitors time in every timestep and triggers periodically (period). This event\ndoes not have an event function as the event condition only depends on time.

        \n
        \ntime == next_schedule_time -> event\n
        \n
        \n

        Example

        \n

        Initialize a scheduled event handler like this:

        \n
        \n#define the action function (callback)\ndef act(t):\n    #do something at event resolution\n    pass\n\n#initialize the event manager\nE = Schedule(\n    t_start=0,    #starting at t=0\n    t_end=None,   #never ending\n    t_period=3,   #triggering every 3 time units\n    func_act=act  #resulting in a callback\n    )\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        t_start : float
        \n
        starting time for schedule
        \n
        t_end : float
        \n
        termination time for schedule
        \n
        t_period : float
        \n
        time period of schedule, when events are triggered
        \n
        func_act : callable
        \n
        action function for event resolution
        \n
        tolerance : float
        \n
        tolerance to check if detection is close to actual event
        \n
        \n
        \n", "params": [ { "name": "t_start", @@ -130,7 +130,7 @@ export const extractedEvents: EventTypeDefinition[] = "name": "ScheduleList", "eventClass": "ScheduleList", "description": "Subclass of base 'Schedule' that triggers dependent on the evaluation time.", - "docstringHtml": "

        Subclass of base 'Schedule' that triggers dependent on the evaluation time.

        \n

        Monitors time in every timestep and triggers at the next event time from the\ntime list. This event does not have an event function as the event condition\nonly depends on time.

        \n
        \ntime == next_scheduled_time -> event\n
        \n
        \n

        Example

        \n

        Initialize a scheduled event handler like this:

        \n
        \n#define the action function (callback)\ndef act(t):\n    #do something at event resolution\n    pass\n\n#initialize the event manager\nE = ScheduleList(\n    times_evt=[1, 5, 12, 300],  #event times where to trigger\n    func_act=act                #resulting in a callback\n    )\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        times_evt : list[float]
        \n
        list of event times in ascending order
        \n
        func_act : callable
        \n
        action function for event resolution
        \n
        tolerance : float
        \n
        tolerance to check if detection is close to actual event
        \n
        \n
        \n", + "docstringHtml": "

        Subclass of base 'Schedule' that triggers dependent on the evaluation time.

        \n

        Monitors time in every timestep and triggers at the next event time from the\ntime list. This event does not have an event function as the event condition\nonly depends on time.

        \n
        \ntime == next_scheduled_time -> event\n
        \n
        \n

        Example

        \n

        Initialize a scheduled event handler like this:

        \n
        \n#define the action function (callback)\ndef act(t):\n    #do something at event resolution\n    pass\n\n#initialize the event manager\nE = ScheduleList(\n    times_evt=[1, 5, 12, 300],  #event times where to trigger\n    func_act=act                #resulting in a callback\n    )\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        times_evt : list[float]
        \n
        list of event times in ascending order
        \n
        func_act : callable
        \n
        action function for event resolution
        \n
        tolerance : float
        \n
        tolerance to check if detection is close to actual event
        \n
        \n
        \n", "params": [ { "name": "times_evt", @@ -157,7 +157,7 @@ export const extractedEvents: EventTypeDefinition[] = "name": "Condition", "eventClass": "Condition", "description": "Subclass of base 'Event' that triggers if the event function evaluates to 'True',", - "docstringHtml": "

        Subclass of base 'Event' that triggers if the event function evaluates to 'True',\ni.e. the condition is satisfied.

        \n

        Monitors system state by evaluating an event function (func_evt) with boolean output.\nThe event is considered detected when the event function evaluates to 'True' for the\nfirst time. Subsequent evaluations to 'True' are not considered unless the event is reset.

        \n
        \nfunc_evt(time) -> event?\n
        \n

        If an event is detected, some action (func_act) is performed on the system state.

        \n
        \nfunc_evt(time) == True -> event -> func_act(time)\n
        \n
        \n

        Note

        \n

        Condition event functions evaluate to boolean and are therefore not smooth.\nTherefore uses bisection method for event location instead of secant method.

        \n
        \n
        \n

        Example

        \n

        Initialize a conditional event handler like this:

        \n
        \n#define the event function\ndef evt(t):\n    return t > 10\n\n#define the action function (callback)\ndef act(t):\n    #do something at event resolution\n    pass\n\n#initialize the event manager\nE = Condition(\n    func_evt=evt,  #the event function\n    func_act=act   #the action function\n    )\n
        \n
        \n", + "docstringHtml": "

        Subclass of base 'Event' that triggers if the event function evaluates to 'True',\ni.e. the condition is satisfied.

        \n

        Monitors system state by evaluating an event function (func_evt) with boolean output.\nThe event is considered detected when the event function evaluates to 'True' for the\nfirst time. Subsequent evaluations to 'True' are not considered unless the event is reset.

        \n
        \nfunc_evt(time) -> event?\n
        \n

        If an event is detected, some action (func_act) is performed on the system state.

        \n
        \nfunc_evt(time) == True -> event -> func_act(time)\n
        \n
        \n

        Note

        \n

        Condition event functions evaluate to boolean and are therefore not smooth.\nTherefore uses bisection method for event location instead of secant method.

        \n
        \n
        \n

        Example

        \n

        Initialize a conditional event handler like this:

        \n
        \n#define the event function\ndef evt(t):\n    return t > 10\n\n#define the action function (callback)\ndef act(t):\n    #do something at event resolution\n    pass\n\n#initialize the event manager\nE = Condition(\n    func_evt=evt,  #the event function\n    func_act=act   #the action function\n    )\n
        \n
        \n", "params": [ { "name": "func_evt", diff --git a/src/lib/nodes/generated/blocks.ts b/src/lib/nodes/generated/blocks.ts index 55d35c47..dc207261 100644 --- a/src/lib/nodes/generated/blocks.ts +++ b/src/lib/nodes/generated/blocks.ts @@ -23,8 +23,8 @@ export const extractedBlocks: Record = { "Constant": { "blockClass": "Constant", - "description": "Produces a constant output signal (SISO).", - "docstringHtml": "

        Produces a constant output signal (SISO).

        \n
        \n\\begin{equation*}\ny(t) = const.\n\\end{equation*}\n
        \n
        \n

        Parameters

        \n
        \n
        value : float
        \n
        constant defining block output
        \n
        \n
        \n", + "description": "Produces a constant output signal (SISO)", + "docstringHtml": "

        Produces a constant output signal (SISO)

        \n
        \n

        Parameters

        \n
        \n
        value : float
        \n
        constant defining block output
        \n
        \n
        \n", "params": { "value": { "type": "integer", @@ -39,8 +39,8 @@ export const extractedBlocks: Record = }, "Source": { "blockClass": "Source", - "description": "Source that produces an arbitrary time dependent output defined by `func` (callable).", - "docstringHtml": "

        Source that produces an arbitrary time dependent output defined by func (callable).

        \n
        \n\\begin{equation*}\ny(t) = \\mathrm{func}(t)\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its internal function (func) will\nbe called multiple times per timestep, each time when Simulation._update(t)\nis called in the global simulation loop.

        \n
        \n
        \n

        Example

        \n

        For example a ramp:

        \n
        \nfrom pathsim.blocks import Source\n\nsrc = Source(lambda t : t)\n
        \n

        or a simple sinusoid with some frequency:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import Source\n\n#some parameter\nomega = 100\n\n#the function that gets evaluated\ndef f(t):\n    return np.sin(omega * t)\n\nsrc = Source(f)\n
        \n

        Because the Source block only has a single argument, it can be\nused to decorate a function and make it a PathSim block. This might\nbe handy in some cases to keep definitions concise and localized\nin the code:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import Source\n\n#does the same as the definition above\n\n@Source\ndef src(t):\n    omega = 100\n    return np.sin(omega * t)\n\n#'src' is now a PathSim block\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        function defining time dependent block output
        \n
        \n
        \n", + "description": "Source that produces an arbitrary time dependent output,", + "docstringHtml": "

        Source that produces an arbitrary time dependent output,\ndefined by the func (callable).

        \n
        \n\\begin{equation*}\ny(t) = \\mathrm{func}(t)\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its internal function (func) will\nbe called multiple times per timestep, each time when Simulation._update(t)\nis called in the global simulation loop.

        \n
        \n
        \n

        Example

        \n

        For example a ramp:

        \n
        \nfrom pathsim.blocks import Source\n\nsrc = Source(lambda t : t)\n
        \n

        or a simple sinusoid with some frequency:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import Source\n\n#some parameter\nomega = 100\n\n#the function that gets evaluated\ndef f(t):\n    return np.sin(omega * t)\n\nsrc = Source(f)\n
        \n

        Because the Source block only has a single argument, it can be\nused to decorate a function and make it a PathSim block. This might\nbe handy in some cases to keep definitions concise and localized\nin the code:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import Source\n\n#does the same as the definition above\n\n@Source\ndef src(t):\n    omega = 100\n    return np.sin(omega * t)\n\n#'src' is now a PathSim block\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        function defining time dependent block output
        \n
        \n
        \n", "params": { "func": { "type": "callable", @@ -81,8 +81,8 @@ export const extractedBlocks: Record = }, "StepSource": { "blockClass": "StepSource", - "description": "Discrete time unit step (or multi step) source block.", - "docstringHtml": "

        Discrete time unit step (or multi step) source block.

        \n

        Utilizes a scheduled event to set the block output\nto the specified output levels at the defined event times.

        \n

        The arguments can be vectorial and in that case, the output is set to the\namplitude that corresponds to the defined delay like a zero-order-hold stage.\nThis functionality enables adding external or time series measurement data\ninto the system.

        \n
        \n

        Examples

        \n

        This is how to use the source as a unit step source:

        \n
        \nfrom pathsim.blocks import StepSource\n\n#default, starts at 0, jumps to 1\nstp = StepSource()\n
        \n

        And this is how to configure it with multiple consecutive steps:

        \n
        \nfrom pathsim.blocks import StepSource\n\n#starts at 0, jumps to 1 at 1, jumps to -1 at 2 and jumps back to 0 at 3\nstp = StepSource(amplitude=[1, -1, 0], tau=[1, 2, 3])\n
        \n

        Similarly implementing measured time series data via zoh:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import StepSource\n\n#some random time series arrays\ntimes, data = np.linspace(0, 100, 1000), np.random.rand(1000)\n\n#pass them to the block\nstp = StepSource(amplitude=data, tau=times)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        amplitude : float | list[float]
        \n
        amplitude of the step signal, or amplitudes / output\nlevels of the multiple steps
        \n
        tau : float | list[float]
        \n
        delay of the step, or delays of the different steps
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        Evt : ScheduleList
        \n
        internal scheduled event directly accessible
        \n
        events : list[ScheduleList]
        \n
        list of interna events
        \n
        \n
        \n", + "description": "Discrete time unit step source block.", + "docstringHtml": "

        Discrete time unit step source block.

        \n

        Utilizes a scheduled event to set the block output\nto the specified output levels at the defined event times.

        \n

        The arguments can be vectorial and in that case, the output is set to the\namplitude that corresponds to the defined delay like a zero-order-hold stage.\nThis functionality enables adding external or time series measurement data\ninto the system.

        \n
        \n

        Examples

        \n

        This is how to use the source as a unit step source:

        \n
        \nfrom pathsim.blocks import StepSource\n\n#default, starts at 0, jumps to 1\nstp = StepSource()\n
        \n

        And this is how to configure it with multiple consecutive steps:

        \n
        \nfrom pathsim.blocks import StepSource\n\n#starts at 0, jumps to 1 at 1, jumps to -1 at 2 and jumps back to 0 at 3\nstp = StepSource(amplitude=[1, -1, 0], tau=[1, 2, 3])\n
        \n

        Similarly implementing measured time series data via zoh:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import StepSource\n\n#some random time series arrays\ntimes, data = np.linspace(0, 100, 1000), np.random.rand(1000)\n\n#pass them to the block\nstp = StepSource(amplitude=data, tau=times)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        amplitude : float | list[float]
        \n
        amplitude of the step signal, or amplitudes / output\nlevels of the multiple steps
        \n
        tau : float | list[float]
        \n
        delay of the step, or delays of the different steps
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        Evt : ScheduleList
        \n
        internal scheduled event directly accessible
        \n
        events : list[ScheduleList]
        \n
        list of interna events
        \n
        \n
        \n", "params": { "amplitude": { "type": "integer", @@ -102,8 +102,8 @@ export const extractedBlocks: Record = }, "PulseSource": { "blockClass": "PulseSource", - "description": "Generates a periodic pulse waveform with defined rise and fall times.", - "docstringHtml": "

        Generates a periodic pulse waveform with defined rise and fall times.

        \n

        Scheduled events trigger phase changes (low, rising, high, falling),\nand the update method calculates the output value based on the\ncurrent phase, performing linear interpolation during rise and fall.

        \n
        \n

        Parameters

        \n
        \n
        amplitude : float, optional
        \n
        Peak amplitude of the pulse. Default is 1.0.
        \n
        T : float, optional
        \n
        Period of the pulse train. Must be positive. Default is 1.0.
        \n
        t_rise : float, optional
        \n
        Duration of the rising edge. Default is 0.0.
        \n
        t_fall : float, optional
        \n
        Duration of the falling edge. Default is 0.0.
        \n
        tau : float, optional
        \n
        Initial delay before the first pulse cycle begins. Default is 0.0.
        \n
        duty : float, optional
        \n
        Duty cycle, ratio of the pulse ON duration (plateau time only)\nto the total period T (must be between 0 and 1). Default is 0.5.\nThe high plateau duration is T * duty.
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        events : list[Schedule]
        \n
        Internal scheduled events triggering phase transitions.
        \n
        _phase : str
        \n
        Current phase of the pulse ('low', 'rising', 'high', 'falling').
        \n
        _phase_start_time : float
        \n
        Simulation time when the current phase began.
        \n
        \n
        \n", + "description": "Generates a periodic pulse waveform with defined rise and fall times", + "docstringHtml": "

        Generates a periodic pulse waveform with defined rise and fall times\nusing a hybrid approach with scheduled events and continuous updates.

        \n

        Scheduled events trigger phase changes (low, rising, high, falling),\nand the update method calculates the output value based on the\ncurrent phase, performing linear interpolation during rise and fall.

        \n
        \n

        Parameters

        \n
        \n
        amplitude : float, optional
        \n
        Peak amplitude of the pulse. Default is 1.0.
        \n
        T : float, optional
        \n
        Period of the pulse train. Must be positive. Default is 1.0.
        \n
        t_rise : float, optional
        \n
        Duration of the rising edge. Default is 0.0.
        \n
        t_fall : float, optional
        \n
        Duration of the falling edge. Default is 0.0.
        \n
        tau : float, optional
        \n
        Initial delay before the first pulse cycle begins. Default is 0.0.
        \n
        duty : float, optional
        \n
        Duty cycle, ratio of the pulse ON duration (plateau time only)\nto the total period T (must be between 0 and 1). Default is 0.5.\nThe high plateau duration is T * duty.
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        events : list[Schedule]
        \n
        Internal scheduled events triggering phase transitions.
        \n
        _phase : str
        \n
        Current phase of the pulse ('low', 'rising', 'high', 'falling').
        \n
        _phase_start_time : float
        \n
        Simulation time when the current phase began.
        \n
        \n
        \n", "params": { "amplitude": { "type": "number", @@ -222,7 +222,7 @@ export const extractedBlocks: Record = "ChirpPhaseNoiseSource": { "blockClass": "ChirpPhaseNoiseSource", "description": "Chirp source, sinusoid with frequency ramp up and ramp down, plus phase noise.", - "docstringHtml": "

        Chirp source, sinusoid with frequency ramp up and ramp down, plus phase noise.

        \n

        This works by using a time dependent triangle wave for the frequency\nand integrating it with a numerical integration engine to get a\ncontinuous phase. This phase is then used to evaluate a sinusoid.

        \n

        Additionally the chirp source can have white and cumulative phase noise.\nMathematically it looks like this for the contributions to the phase from\nthe triangular wave:

        \n
        \n\\begin{equation*}\n\\varphi_t(t) = \\int_0^t \\mathrm{tri}_{f_0, B, T}(\\tau) \\, d\\tau\n\\end{equation*}\n
        \n

        And from the white (w) and cumulative (c) noise:

        \n
        \n\\begin{equation*}\n\\varphi_n(t) = \\sigma_w \\, n_w(t) + \\sigma_c \\int_0^t n_c(\\tau) \\, d\\tau\n\\end{equation*}\n
        \n

        The phase contributions are then used to evaluate a sinusoid to get the final chirp signal:

        \n
        \n\\begin{equation*}\ny(t) = A \\sin(\\varphi_t(t) + \\varphi_n(t) + \\varphi_0)\n\\end{equation*}\n
        \n
        \n

        Parameters

        \n
        \n
        amplitude : float
        \n
        amplitude of the chirp signal
        \n
        f0 : float
        \n
        start frequency of the chirp signal
        \n
        BW : float
        \n
        bandwidth of the frequency ramp of the chirp signal
        \n
        T : float
        \n
        period of the frequency ramp of the chirp signal
        \n
        phase : float
        \n
        phase of sinusoid (initial, radians)
        \n
        sig_cum : float
        \n
        weight for cumulative phase noise contribution
        \n
        sig_white : float
        \n
        weight for white phase noise contribution
        \n
        sampling_period : float, None
        \n
        time between phase noise samples. If None,\nnoise is sampled every timestep (default is 0.1)
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        noise_1 : float
        \n
        internal noise value for white phase noise
        \n
        noise_2 : float
        \n
        internal noise value for cumulative phase noise
        \n
        events : list[Schedule]
        \n
        scheduled event for periodic sampling (only if sampling_period is set)
        \n
        \n
        \n", + "docstringHtml": "

        Chirp source, sinusoid with frequency ramp up and ramp down, plus phase noise.

        \n

        This works by using a time dependent triangle wave for the frequency\nand integrating it with a numerical integration engine to get a\ncontinuous phase. This phase is then used to evaluate a sinusoid.

        \n

        Additionally the chirp source can have white and cumulative phase noise.\nMathematically it looks like this for the contributions to the phase from\nthe triangular wave:

        \n
        \n\\begin{equation*}\n\\varphi_t(t) = \\int_0^t \\mathrm{tri}_{f_0, B, T}(\\tau) \\, d\\tau\n\\end{equation*}\n
        \n

        And from the white (w) and cumulative (c) noise:

        \n
        \n\\begin{equation*}\n\\varphi_n(t) = \\sigma_w \\, n_w(t) + \\sigma_c \\int_0^t n_c(\\tau) \\, d\\tau\n\\end{equation*}\n
        \n

        The phase contributions are then used to evaluate a sinusoid to get the final chirp signal:

        \n
        \n\\begin{equation*}\ny(t) = A \\sin(\\varphi_t(t) + \\varphi_n(t) + \\varphi_0)\n\\end{equation*}\n
        \n
        \n

        Parameters

        \n
        \n
        amplitude : float
        \n
        amplitude of the chirp signal
        \n
        f0 : float
        \n
        start frequency of the chirp signal
        \n
        BW : float
        \n
        bandwidth of the frequency ramp of the chirp signal
        \n
        T : float
        \n
        period of the frequency ramp of the chirp signal
        \n
        phase : float
        \n
        phase of sinusoid (initial, radians)
        \n
        sig_cum : float
        \n
        weight for cumulative phase noise contribution
        \n
        sig_white : float
        \n
        weight for white phase noise contribution
        \n
        sampling_rate : float, None
        \n
        frequency with which phase noise is sampled (Hz). If None,\nnoise is sampled every timestep (default is 10 Hz)
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        noise_1 : float
        \n
        internal noise value for white phase noise
        \n
        noise_2 : float
        \n
        internal noise value for cumulative phase noise
        \n
        events : list[Schedule]
        \n
        scheduled event for periodic sampling (only if sampling_rate is set)
        \n
        \n
        \n", "params": { "amplitude": { "type": "integer", @@ -259,10 +259,10 @@ export const extractedBlocks: Record = "default": "0", "description": "weight for white phase noise contribution" }, - "sampling_period": { - "type": "number", - "default": "0.1", - "description": "time between phase noise samples. If None, noise is sampled every timestep (default is 0.1)" + "sampling_rate": { + "type": "integer", + "default": "10", + "description": "frequency with which phase noise is sampled (Hz). If None, noise is sampled every timestep (default is 10 Hz) Attributes ----------" } }, "inputs": [], @@ -293,28 +293,18 @@ export const extractedBlocks: Record = }, "WhiteNoise": { "blockClass": "WhiteNoise", - "description": "White noise source with Gaussian distribution.", - "docstringHtml": "

        White noise source with Gaussian distribution.

        \n

        Generates uncorrelated random samples with either constant amplitude\n(standard_deviation mode) or timestep-scaled amplitude for stochastic\nintegration (spectral_density mode).

        \n

        In spectral density mode, output is scaled as √(S₀/dt) so that integrating\nthe noise yields correct statistical properties (Wiener process).

        \n
        \n

        Note

        \n

        If spectral_density is provided, it takes precedence over standard_deviation.\nIf sampling_period is set, noise is sampled at fixed intervals (zero-order hold).

        \n
        \n
        \n

        Parameters

        \n
        \n
        standard_deviation : float
        \n
        output standard deviation for constant-amplitude mode (default: 1.0)
        \n
        spectral_density : float, optional
        \n
        power spectral density S₀ in [signal²/Hz]
        \n
        sampling_period : float, optional
        \n
        time between samples, if None samples every timestep
        \n
        seed : int, optional
        \n
        random seed for reproducibility
        \n
        \n
        \n", + "description": "White noise source with uniform spectral density.", + "docstringHtml": "

        White noise source with uniform spectral density. Samples from\ndistribution with 'sampling_rate' and holds noise values constant\nfor time bins (zero-order-hold).

        \n

        If no 'sampling_rate' (None) is specified, every simulation timestep\ngets a new noise value. This is the default setting.

        \n
        \n

        Parameters

        \n
        \n
        spectral_density : float
        \n
        noise spectral density
        \n
        noise : float
        \n
        internal noise value
        \n
        sampling_rate : float, None
        \n
        frequency with which the noise is sampled
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        events : list[Schedule]
        \n
        scheduled event for periodic sampling
        \n
        \n
        \n", "params": { - "standard_deviation": { - "type": "number", - "default": "1.0", - "description": "output standard deviation for constant-amplitude mode (default: 1.0)" - }, "spectral_density": { - "type": "any", - "default": null, - "description": "power spectral density S₀ in [signal²/Hz]" - }, - "sampling_period": { - "type": "any", - "default": null, - "description": "time between samples, if None samples every timestep" + "type": "integer", + "default": "1", + "description": "noise spectral density" }, - "seed": { + "sampling_rate": { "type": "any", "default": null, - "description": "random seed for reproducibility" + "description": "frequency with which the noise is sampled" } }, "inputs": [], @@ -324,33 +314,23 @@ export const extractedBlocks: Record = }, "PinkNoise": { "blockClass": "PinkNoise", - "description": "Pink noise (1/f noise) source using the Voss-McCartney algorithm.", - "docstringHtml": "

        Pink noise (1/f noise) source using the Voss-McCartney algorithm.

        \n

        Generates noise with power spectral density proportional to 1/f, where\nlower frequencies have more power than higher frequencies.

        \n

        The algorithm maintains num_octaves independent random values representing\ndifferent frequency bands. At each sample, one octave is updated based on the\nbinary representation of the sample counter, creating the characteristic 1/f\nspectrum through the superposition of different update rates.

        \n
        \n

        Note

        \n

        If spectral_density is provided, it takes precedence over standard_deviation.\nIf sampling_period is set, noise is sampled at fixed intervals (zero-order hold).

        \n
        \n
        \n

        Parameters

        \n
        \n
        standard_deviation : float
        \n
        approximate output standard deviation (default: 1.0)
        \n
        spectral_density : float, optional
        \n
        power spectral density, output scaled as √(S₀/(N·dt))
        \n
        num_octaves : int
        \n
        number of frequency bands in algorithm (default: 16)
        \n
        sampling_period : float, optional
        \n
        time between samples, if None samples every timestep
        \n
        seed : int, optional
        \n
        random seed for reproducibility
        \n
        \n
        \n", + "description": "Pink noise (1/f) source using the Voss-McCartney algorithm.", + "docstringHtml": "

        Pink noise (1/f) source using the Voss-McCartney algorithm.

        \n

        Generates noise with power spectral density inversely proportional to\nfrequency. Samples from distribution with 'sampling_rate' and holds\nnoise values constant for time bins (zero-order-hold).

        \n

        The Voss-McCartney algorithm maintains num_octaves independent\nrandom values. At each sample n, octaves are selectively updated based\non the binary representation of n:

        \n
          \n
        • Octave 0: updated every sample (when n & 1 == 1)
        • \n
        • Octave 1: updated every 2nd sample (when n & 2 == 2)
        • \n
        • Octave 2: updated every 4th sample (when n & 4 == 4)
        • \n
        • Octave k: updated every \\(2^k\\) samples
        • \n
        \n

        The pink noise output is the sum of all octaves, scaled to achieve the\ndesired spectral density:

        \n
        \n\\begin{equation*}\ny[n] = \\sqrt{\\frac{S_0}{N \\cdot dt}} \\sum_{k=0}^{N-1} x_k[n]\n\\end{equation*}\n
        \n

        where \\(S_0\\) is the spectral density, \\(N\\) is num_octaves,\n\\(dt\\) is the sampling timestep, and \\(x_k[n]\\) are the octave\nvalues (each drawn from \\(\\mathcal{N}(0, 1)\\) when updated).

        \n
        \n

        Note

        \n

        If no 'sampling_rate' (None) is specified, every simulation timestep\ngets a new noise value. This is the default setting.

        \n
        \n
        \n

        Parameters

        \n
        \n
        spectral_density : float
        \n
        noise spectral density \\(S_0\\)
        \n
        num_octaves : int
        \n
        number of octaves (levels of randomness), default is 16
        \n
        sampling_rate : float, None
        \n
        frequency with which the noise is sampled
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        n_samples : int
        \n
        internal sample counter
        \n
        octave_values : array[float]
        \n
        internal random numbers for octaves in the Voss-McCartney algorithm
        \n
        events : list[Schedule]
        \n
        scheduled event for periodic sampling
        \n
        \n
        \n
        \n

        References

        \n\n\n\n\n\n
        [1]Voss, R. F., & Clarke, J. (1978). "1/f noise" in music: Music from\n1/f noise. The Journal of the Acoustical Society of America, 63(1),\n258-263.
        \n
        \n", "params": { - "standard_deviation": { - "type": "number", - "default": "1.0", - "description": "approximate output standard deviation (default: 1.0)" - }, "spectral_density": { - "type": "any", - "default": null, - "description": "power spectral density, output scaled as √(S₀/(N·dt))" + "type": "integer", + "default": "1", + "description": "noise spectral density :math:`S_0`" }, "num_octaves": { "type": "integer", "default": "16", - "description": "number of frequency bands in algorithm (default: 16)" - }, - "sampling_period": { - "type": "any", - "default": null, - "description": "time between samples, if None samples every timestep" + "description": "number of octaves (levels of randomness), default is 16" }, - "seed": { + "sampling_rate": { "type": "any", "default": null, - "description": "random seed for reproducibility" + "description": "frequency with which the noise is sampled" } }, "inputs": [], @@ -361,12 +341,12 @@ export const extractedBlocks: Record = "RandomNumberGenerator": { "blockClass": "RandomNumberGenerator", "description": "Generates a random output value using `numpy.random.rand`.", - "docstringHtml": "

        Generates a random output value using numpy.random.rand.

        \n

        If no sampling_period (None) is specified, every simulation timestep gets\na random value. Otherwise an internal Schedule event is used to periodically\nsample a random value and set the output like a zero-order-hold stage.

        \n
        \n

        Parameters

        \n
        \n
        sampling_period : float, None
        \n
        time between random samples
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        _sample : float
        \n
        internal random number state in case that\nno sampling_period is provided
        \n
        Evt : Schedule
        \n
        internal event that periodically samples a random\nvalue in case sampling_period is provided
        \n
        \n
        \n", + "docstringHtml": "

        Generates a random output value using numpy.random.rand.

        \n

        If no sampling_rate (None) is specified, every simulation timestep gets\na random value. Otherwise an internal Schedule event is used to periodically\nsample a random value and set the output like a sero-order-hold stage.

        \n
        \n

        Parameters

        \n
        \n
        sampling_rate : float, None
        \n
        number of random samples per time unit
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        _sample : float
        \n
        internal random number state in case that\nno samplingrate is provided
        \n
        Evt : Schedule
        \n
        internal event that periodically samples a random\nvalue in case samplingrate is provided
        \n
        \n
        \n", "params": { - "sampling_period": { + "sampling_rate": { "type": "any", "default": null, - "description": "time between random samples" + "description": "number of random samples per time unit" } }, "inputs": [], @@ -376,8 +356,8 @@ export const extractedBlocks: Record = }, "Integrator": { "blockClass": "Integrator", - "description": "Integrates the input signal.", - "docstringHtml": "

        Integrates the input signal.

        \n

        Uses a numerical integration engine like this:

        \n
        \n\\begin{equation*}\ny(t) = \\int_0^t u(\\tau) \\ d \\tau\n\\end{equation*}\n
        \n

        or in differential form like this:

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x}(t) &= u(t) \\\\\n y(t) &= x(t)\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        The Integrator block is inherently MIMO capable, so u\nand y can be vectors.

        \n
        \n

        Example

        \n

        This is how to initialize the integrator:

        \n
        \n#initial value 0.0\ni1 = Integrator()\n\n#initial value 2.5\ni2 = Integrator(2.5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        initial_value : float, array
        \n
        initial value of integrator
        \n
        \n
        \n", + "description": "Integrates the input signal using a numerical integration engine like this:", + "docstringHtml": "

        Integrates the input signal using a numerical integration engine like this:

        \n
        \n\\begin{equation*}\ny(t) = \\int_0^t u(\\tau) \\ d \\tau\n\\end{equation*}\n
        \n

        or in differential form like this:

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x}(t) &= u(t) \\\\\n y(t) &= x(t)\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        The Integrator block is inherently MIMO capable, so u and y can be vectors.

        \n
        \n

        Example

        \n

        This is how to initialize the integrator:

        \n
        \n#initial value 0.0\ni1 = Integrator()\n\n#initial value 2.5\ni2 = Integrator(2.5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        initial_value : float, array
        \n
        initial value of integrator
        \n
        \n
        \n", "params": { "initial_value": { "type": "number", @@ -385,13 +365,13 @@ export const extractedBlocks: Record = "description": "initial value of integrator" } }, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "Differentiator": { "blockClass": "Differentiator", - "description": "Differentiates the input signal.", - "docstringHtml": "

        Differentiates the input signal.

        \n

        Uses a first order transfer function with a pole at the origin which implements\na high pass filter. Supports vector input.

        \n
        \n\\begin{equation*}\nH_\\mathrm{diff}(s) = \\frac{s}{1 + s / f_\\mathrm{max}}\n\\end{equation*}\n
        \n

        The approximation holds for signals up to a frequency of approximately f_max.

        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.

        \n
        \n
        \n

        Note

        \n

        Since this is an approximation of real differentiation, the approximation will not hold\nif there are high frequency components present in the signal. For example if you have\ndiscontinuities such as steps or squere waves.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz\nD = Differentiator(f_max=1e3)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", + "description": "Differentiates the input signal (SISO) using a first order transfer function", + "docstringHtml": "

        Differentiates the input signal (SISO) using a first order transfer function\nwith a pole at the origin which implements a high pass filter.

        \n
        \n\\begin{equation*}\nH_\\mathrm{diff}(s) = \\frac{s}{1 + s / f_\\mathrm{max}}\n\\end{equation*}\n
        \n

        The approximation holds for signals up to a frequency of approximately f_max.

        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.

        \n
        \n
        \n

        Note

        \n

        Since this is an approximation of real differentiation, the approximation will not hold\nif there are high frequency components present in the signal. For example if you have\ndiscontinuities such as steps or squere waves.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz\nD = Differentiator(f_max=1e3)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": { "f_max": { "type": "number", @@ -399,13 +379,17 @@ export const extractedBlocks: Record = "description": "highest expected signal frequency" } }, - "inputs": null, - "outputs": null + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] }, "Delay": { "blockClass": "Delay", - "description": "Delays the input signal by a time constant 'tau' in seconds.", - "docstringHtml": "

        Delays the input signal by a time constant 'tau' in seconds.

        \n

        Mathematically this block creates a time delay of the input signal like this:

        \n
        \n\\begin{equation*}\ny(t) =\n\\begin{cases}\nx(t - \\tau) & , t \\geq \\tau \\\\\n0 & , t < \\tau\n\\end{cases}\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        The internal adaptive buffer uses interpolation for the evaluation. This is\nrequired to be compatible with variable step solvers. It has a drawback however.\nThe order of the ode solver used will degrade when this block is used, due to\nthe interpolation.

        \n
        \n
        \n

        Note

        \n

        This block supports vector input, meaning we can have multiple parallel\ndelay paths through this block.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#5 time units delay\nD = Delay(tau=5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        tau : float
        \n
        delay time constant
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        _buffer : AdaptiveBuffer
        \n
        internal interpolatable adaptive rolling buffer
        \n
        \n
        \n", + "description": "Delays the input signal by a time constant 'tau' in seconds", + "docstringHtml": "

        Delays the input signal by a time constant 'tau' in seconds\nusing an adaptive rolling buffer.

        \n

        Mathematically this block creates a time delay of the input signal like this:

        \n
        \n\\begin{equation*}\ny(t) =\n\\begin{cases}\nx(t - \\tau) & , t \\geq \\tau \\\\\n0 & , t < \\tau\n\\end{cases}\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        The internal adaptive buffer uses interpolation for the evaluation. This is\nrequired to be compatible with variable step solvers. It has a drawback however.\nThe order of the ode solver used will degrade when this block is used, due to\nthe interpolation.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#5 time units delay\nD = Delay(tau=5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        tau : float
        \n
        delay time constant
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        _buffer : AdaptiveBuffer
        \n
        internal interpolatable adaptive rolling buffer
        \n
        \n
        \n", "params": { "tau": { "type": "number", @@ -413,13 +397,17 @@ export const extractedBlocks: Record = "description": "delay time constant" } }, - "inputs": null, - "outputs": null + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] }, "ODE": { "blockClass": "ODE", - "description": "Ordinary differential equation (ODE) defined by its right hand side function.", - "docstringHtml": "

        Ordinary differential equation (ODE) defined by its right hand side function.

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x}(t) =& \\mathrm{func}(x(t), u(t), t) \\\\\n y(t) =& x(t)\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        with inhomogenity (input) u and state vector x. The function can be nonlinear\nand the ODE can be of arbitrary order. The block utilizes the integration engine\nto solve the ODE by integrating the func, which is the right hand side function.

        \n
        \n

        Example

        \n

        For example a linear 1st order ODE:

        \n
        \node = ODE(lambda x, u, t: -x)\n
        \n

        Or something more complex like the Van der Pol system, where it makes sense to\nalso specify the jacobian, which improves convergence for implicit solvers but is\nnot needed in most cases:

        \n
        \nimport numpy as np\n\n#initial condition\nx0 = np.array([2, 0])\n\n#van der Pol parameter\nmu = 1000\n\ndef func(x, u, t):\n    return np.array([x[1], mu*(1 - x[0]**2)*x[1] - x[0]])\n\n#analytical jacobian (optional)\ndef jac(x, u, t):\n    return np.array(\n        [[0                , 1               ],\n         [-mu*2*x[0]*x[1]-1, mu*(1 - x[0]**2)]]\n         )\n\n#finally the block\nvdp = ODE(func, x0, jac)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        right hand side function of ODE
        \n
        initial_value : array[float]
        \n
        initial state / initial condition
        \n
        jac : callable, None
        \n
        jacobian of 'func' or 'None'
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE right hand side 'func'
        \n
        \n
        \n", + "description": "This block implements an ordinary differential equation (ODE)", + "docstringHtml": "

        This block implements an ordinary differential equation (ODE)\ndefined by its right hand side

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x}(t) =& \\mathrm{func}(x(t), u(t), t) \\\\\n y(t) =& x(t)\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        with inhomogenity (input) u and state vector x. The function\ncan be nonlinear and the ODE can be of arbitrary order.\nThe block utilizes the integration engine to solve the ODE\nby integrating the func, which is the right hand side function.

        \n
        \n

        Example

        \n

        For example a linear 1st order ODE:

        \n
        \node = ODE(lambda x, u, t: -x)\n
        \n

        Or something more complex like the Van der Pol system, where it makes\nsense to also specify the jacobian, which improves convergence for\nimplicit solvers but is not needed in most cases:

        \n
        \nimport numpy as np\n\n#initial condition\nx0 = np.array([2, 0])\n\n#van der Pol parameter\nmu = 1000\n\ndef func(x, u, t):\n    return np.array([x[1], mu*(1 - x[0]**2)*x[1] - x[0]])\n\n#analytical jacobian (optional)\ndef jac(x, u, t):\n    return np.array(\n        [[0                , 1               ],\n         [-mu*2*x[0]*x[1]-1, mu*(1 - x[0]**2)]]\n         )\n\n#finally the block\nvdp = ODE(func, x0, jac)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        right hand side function of ODE
        \n
        initial_value : array[float]
        \n
        initial state / initial condition
        \n
        jac : callable, None
        \n
        jacobian of 'func' or 'None'
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE right hand side 'func'
        \n
        \n
        \n", "params": { "func": { "type": "callable", @@ -437,8 +425,8 @@ export const extractedBlocks: Record = "description": "jacobian of 'func' or 'None'" } }, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "DynamicalSystem": { "blockClass": "DynamicalSystem", @@ -466,13 +454,13 @@ export const extractedBlocks: Record = "description": "optional jacobian of `func_dyn` to improve convergence for implicit ode solvers" } }, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "StateSpace": { "blockClass": "StateSpace", - "description": "Linear time invariant (LTI) multi input multi output (MIMO) state space model.", - "docstringHtml": "

        Linear time invariant (LTI) multi input multi output (MIMO) state space model.

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x} &= \\mathbf{A} x + \\mathbf{B} u \\\\\n y &= \\mathbf{C} x + \\mathbf{D} u\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        where A, B, C and D are the state space matrices, x is the state,\nu the input and y the output vector.

        \n
        \n

        Example

        \n

        A SISO state space block with two internal states can be initialized\nlike this:

        \n
        \nS = StateSpace(\n    A=-np.eye(2),\n    B=np.ones((2, 1)),\n    C=np.ones((1, 2)),\n    D=1.0\n    )\n
        \n

        and a MIMO (2 in, 2 out) state space block with three internal states\ncan be initialized like this:

        \n
        \nS = StateSpace(\n    A=-np.eye(3),\n    B=np.ones((3, 2)),\n    C=np.ones((2, 3)),\n    D=np.ones((2, 2))\n    )\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        A, B, C, D : array_like
        \n
        real valued state space matrices
        \n
        initial_value : array_like, None
        \n
        initial state / initial condition
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for state equation
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator for mapping to outputs
        \n
        \n
        \n", + "description": "This block defines a linear time invariant (LTI) multi input multi output (MIMO)", + "docstringHtml": "

        This block defines a linear time invariant (LTI) multi input multi output (MIMO)\nstate space model with the structure

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x} &= \\mathbf{A} x + \\mathbf{B} u \\\\\n y &= \\mathbf{C} x + \\mathbf{D} u\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        where A, B, C and D are the state space matrices, x is the state,\nu the input and y the output vector.

        \n
        \n

        Example

        \n

        A SISO state space block with two internal states can be initialized\nlike this:

        \n
        \nS = StateSpace(\n    A=-np.eye(2),\n    B=np.ones((2, 1)),\n    C=np.ones((1, 2)),\n    D=1.0\n    )\n
        \n

        and a MIMO (2 in, 2 out) state space block with three internal states\ncan be initialized like this:

        \n
        \nS = StateSpace(\n    A=-np.eye(3),\n    B=np.ones((3, 2)),\n    C=np.ones((2, 3)),\n    D=np.ones((2, 2))\n    )\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        A, B, C, D : array_like
        \n
        real valued state space matrices
        \n
        initial_value : array_like, None
        \n
        initial state / initial condition
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for state equation
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator for mapping to outputs
        \n
        \n
        \n", "params": { "A": { "type": "number", @@ -500,13 +488,13 @@ export const extractedBlocks: Record = "description": "initial state / initial condition" } }, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "PID": { "blockClass": "PID", "description": "Proportional-Integral-Differntiation (PID) controller.", - "docstringHtml": "

        Proportional-Integral-Differntiation (PID) controller.

        \n

        The transfer function is defined as

        \n
        \n\\begin{equation*}\nH_\\mathrm{diff}(s) = K_p + K_i \\frac{1}{s} + K_d \\frac{s}{1 + s / f_\\mathrm{max}}\n\\end{equation*}\n
        \n

        where the differentiation is approximated by a high pass filter that holds\nfor signals up to a frequency of approximately f_max.

        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.\nSince this block uses an approximation of real differentiation, the approximation will\nnot hold if there are high frequency components present in the signal. For example if\nyou have discontinuities such as steps or square waves.

        \n
        \n
        \n

        Note

        \n

        This block supports vector input, meaning we can have multiple parallel\nPID paths through this block.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz\npid = PID(Kp=2, Ki=0.5, Kd=0.1, f_max=1e3)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        Kp : float
        \n
        poroportional controller coefficient
        \n
        Ki : float
        \n
        integral controller coefficient
        \n
        Kd : float
        \n
        differentiator controller coefficient
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", + "docstringHtml": "

        Proportional-Integral-Differntiation (PID) controller.

        \n

        The transfer function is defined as

        \n
        \n\\begin{equation*}\nH_\\mathrm{diff}(s) = K_p + K_i \\frac{1}{s} + K_d \\frac{s}{1 + s / f_\\mathrm{max}}\n\\end{equation*}\n
        \n

        where the differentiation is approximated by a high pass filter that holds\nfor signals up to a frequency of approximately f_max.

        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.\nSince this block uses an approximation of real differentiation, the approximation will\nnot hold if there are high frequency components present in the signal. For example if\nyou have discontinuities such as steps or square waves.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz\npid = PID(Kp=2, Ki=0.5, Kd=0.1, f_max=1e3)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        Kp : float
        \n
        poroportional controller coefficient
        \n
        Ki : float
        \n
        integral controller coefficient
        \n
        Kd : float
        \n
        differentiator controller coefficient
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": { "Kp": { "type": "integer", @@ -529,13 +517,17 @@ export const extractedBlocks: Record = "description": "highest expected signal frequency" } }, - "inputs": null, - "outputs": null + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] }, "AntiWindupPID": { "blockClass": "AntiWindupPID", - "description": "Proportional-Integral-Differntiation (PID) controller with anti-windup mechanism (back-calculation).", - "docstringHtml": "

        Proportional-Integral-Differntiation (PID) controller with anti-windup mechanism (back-calculation).

        \n

        Anti-windup mechanisms are needed when the magnitude of the control signal\nfrom the PID controller is limited by some real world saturation. In these cases,\nthe integrator will continue to acumulate the control error and "wind itself up".\nOnce the setpoint is reached, this can result in significant overshoots. This\nimplementation adds a conditional feedback term to the internal integrator that\n"unwinds" it when the PID output crosses some limits. This is pretty much a\ndeadzone feedback element for the integrator.

        \n

        Mathematically, this block implements the following set of ODEs

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n\\dot{x}_1 =& f_\\mathrm{max} (u - x_1) \\\\\n\\dot{x}_2 =& u - w \\\\\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        with the anti-windup feedback (depending on the pid output)

        \n
        \n\\begin{equation*}\nw = K_s (y - \\min(\\max(y, y_\\mathrm{min}), y_\\mathrm{max}))\n\\end{equation*}\n
        \n

        and the output itself

        \n
        \n\\begin{equation*}\ny = K_p u - K_d f_\\mathrm{max} x_1 + K_i x_2\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.\nSince this block uses an approximation of real differentiation, the approximation will\nnot hold if there are high frequency components present in the signal. For example if\nyou have discontinuities such as steps or squere waves.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz, windup limits at [-5, 5]\npid = AntiWindupPID(Kp=2, Ki=0.5, Kd=0.1, f_max=1e3, limits=[-5, 5])\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        Kp : float
        \n
        poroportional controller coefficient
        \n
        Ki : float
        \n
        integral controller coefficient
        \n
        Kd : float
        \n
        differentiator controller coefficient
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        Ks : float
        \n
        feedback term for back calculation for anti-windup control of integrator
        \n
        limits : array_like[float]
        \n
        lower and upper limit for PID output that triggers anti-windup of integrator
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", + "description": "Proportional-Integral-Differntiation (PID) controller with tracking", + "docstringHtml": "

        Proportional-Integral-Differntiation (PID) controller with tracking\nanti-windup mechanism (back-calculation).

        \n

        Anti-windup mechanisms are needed when the magnitude of the control signal\nfrom the PID controller is limited by some real world saturation. In these cases,\nthe integrator will continue to acumulate the control error and "wind itself up".\nOnce the setpoint is reached, this can result in significant overshoots. This\nimplementation adds a conditional feedback term to the internal integrator that\n"unwinds" it when the PID output crosses some limits. This is pretty much a\ndeadzone feedback element for the integrator.

        \n

        Mathematically, this block implements the following set of ODEs

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n\\dot{x}_1 =& f_\\mathrm{max} (u - x_1) \\\\\n\\dot{x}_2 =& u - w \\\\\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        with the anti-windup feedback (depending on the pid output)

        \n
        \n\\begin{equation*}\nw = K_s (y - \\min(\\max(y, y_\\mathrm{min}), y_\\mathrm{max}))\n\\end{equation*}\n
        \n

        and the output itself

        \n
        \n\\begin{equation*}\ny = K_p u - K_d f_\\mathrm{max} x_1 + K_i x_2\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.\nSince this block uses an approximation of real differentiation, the approximation will\nnot hold if there are high frequency components present in the signal. For example if\nyou have discontinuities such as steps or squere waves.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz, windup limits at [-5, 5]\npid = AntiWindupPID(Kp=2, Ki=0.5, Kd=0.1, f_max=1e3, limits=[-5, 5])\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        Kp : float
        \n
        poroportional controller coefficient
        \n
        Ki : float
        \n
        integral controller coefficient
        \n
        Kd : float
        \n
        differentiator controller coefficient
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        Ks : float
        \n
        feedback term for back calculation for anti-windup control of integrator
        \n
        limits : array_like[float]
        \n
        lower and upper limit for PID output that triggers anti-windup of integrator
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": { "Kp": { "type": "integer", @@ -568,8 +560,12 @@ export const extractedBlocks: Record = "description": "lower and upper limit for PID output that triggers anti-windup of integrator" } }, - "inputs": null, - "outputs": null + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] }, "TransferFunctionNumDen": { "blockClass": "TransferFunctionNumDen", @@ -587,12 +583,8 @@ export const extractedBlocks: Record = "description": "denominator polynomial coefficients" } }, - "inputs": [ - "in" - ], - "outputs": [ - "out" - ] + "inputs": [], + "outputs": [] }, "TransferFunctionZPG": { "blockClass": "TransferFunctionZPG", @@ -615,12 +607,8 @@ export const extractedBlocks: Record = "description": "gain term of transfer function" } }, - "inputs": [ - "in" - ], - "outputs": [ - "out" - ] + "inputs": [], + "outputs": [] }, "ButterworthLowpassFilter": { "blockClass": "ButterworthLowpassFilter", @@ -638,12 +626,8 @@ export const extractedBlocks: Record = "description": "filter order" } }, - "inputs": [ - "in" - ], - "outputs": [ - "out" - ] + "inputs": [], + "outputs": [] }, "ButterworthHighpassFilter": { "blockClass": "ButterworthHighpassFilter", @@ -661,12 +645,8 @@ export const extractedBlocks: Record = "description": "filter order" } }, - "inputs": [ - "in" - ], - "outputs": [ - "out" - ] + "inputs": [], + "outputs": [] }, "ButterworthBandpassFilter": { "blockClass": "ButterworthBandpassFilter", @@ -684,12 +664,8 @@ export const extractedBlocks: Record = "description": "filter order" } }, - "inputs": [ - "in" - ], - "outputs": [ - "out" - ] + "inputs": [], + "outputs": [] }, "ButterworthBandstopFilter": { "blockClass": "ButterworthBandstopFilter", @@ -707,17 +683,13 @@ export const extractedBlocks: Record = "description": "filter order" } }, - "inputs": [ - "in" - ], - "outputs": [ - "out" - ] + "inputs": [], + "outputs": [] }, "Adder": { "blockClass": "Adder", "description": "Summs / adds up all input signals to a single output signal (MISO)", - "docstringHtml": "

        Summs / adds up all input signals to a single output signal (MISO)

        \n

        This is how it works in the default case

        \n
        \n\\begin{equation*}\ny(t) = \\sum_i u_i(t)\n\\end{equation*}\n
        \n

        and like this when additional operations are defined

        \n
        \n\\begin{equation*}\ny(t) = \\sum_i \\mathrm{op}_i \\cdot u_i(t)\n\\end{equation*}\n
        \n
        \n

        Example

        \n

        This is the default initialization that just adds up all the inputs:

        \n
        \nA = Adder()\n
        \n

        and this is the initialization with specific operations that subtracts\nthe second from first input and neglects all others:

        \n
        \nA = Adder('+-')\n
        \n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.

        \n
        \n
        \n

        Parameters

        \n
        \n
        operations : str, optional
        \n
        optional string of operations to be applied before\nsummation, i.e. '+-' will compute the difference,\n'None' will just perform regular sum
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        _ops : dict
        \n
        dict that maps string operations to numerical
        \n
        _ops_array : array_like
        \n
        operations converted to array
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", + "docstringHtml": "

        Summs / adds up all input signals to a single output signal (MISO)

        \n

        This is how it works in the default case

        \n
        \n\\begin{equation*}\ny(t) = \\sum_i u_i(t)\n\\end{equation*}\n
        \n

        and like this when additional operations are defined

        \n
        \n\\begin{equation*}\ny(t) = \\sum_i \\mathrm{op}_i \\cdot u_i(t)\n\\end{equation*}\n
        \n
        \n

        Example

        \n

        This is the default initialization that just adds up all the inputs:

        \n
        \nA = Adder()\n
        \n

        and this is the initialization with specific operations that subtracts\nthe second from first input and neglects all others:

        \n
        \nA = Adder('+-')\n
        \n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.

        \n
        \n
        \n

        Parameters

        \n
        \n
        operations : str, optional
        \n
        optional string of operations to be applied before\nsummation, i.e. '+-' will compute the difference,\n'None' will just perform regular sum
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        _ops : dict
        \n
        dict that maps string operations to numerical
        \n
        _ops_array : array_like
        \n
        operations converted to array
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": { "operations": { "type": "any", @@ -725,7 +697,7 @@ export const extractedBlocks: Record = "description": "optional string of operations to be applied before summation, i.e. '+-' will compute the difference, 'None' will just perform regular sum" } }, - "inputs": null, + "inputs": [], "outputs": [ "out" ] @@ -735,15 +707,15 @@ export const extractedBlocks: Record = "description": "Multiplies all signals from all input ports (MISO).", "docstringHtml": "

        Multiplies all signals from all input ports (MISO).

        \n
        \n\\begin{equation*}\ny(t) = \\prod_i u_i(t)\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.

        \n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator that wraps 'prod'
        \n
        \n
        \n", "params": {}, - "inputs": null, + "inputs": [], "outputs": [ "out" ] }, "Amplifier": { "blockClass": "Amplifier", - "description": "Amplifies the input signal by multiplication with a constant gain term.", - "docstringHtml": "

        Amplifies the input signal by multiplication with a constant gain term.

        \n

        Like this:

        \n
        \n\\begin{equation*}\ny(t) = \\mathrm{gain} \\cdot u(t)\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#amplification by factor 5\nA = Amplifier(gain=5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        gain : float
        \n
        amplifier gain
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", + "description": "Amplifies the input signal by", + "docstringHtml": "

        Amplifies the input signal by\nmultiplication with a constant gain term like this:

        \n
        \n\\begin{equation*}\ny(t) = \\mathrm{gain} \\cdot u(t)\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#amplification by factor 5\nA = Amplifier(gain=5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        gain : float
        \n
        amplifier gain
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": { "gain": { "type": "number", @@ -751,13 +723,17 @@ export const extractedBlocks: Record = "description": "amplifier gain" } }, - "inputs": null, - "outputs": null + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] }, "Function": { "blockClass": "Function", - "description": "Arbitrary MIMO function block, defined by a function or `lambda` expression.", - "docstringHtml": "

        Arbitrary MIMO function block, defined by a function or lambda expression.

        \n

        The function can have multiple arguments that are then provided\nby the input channels of the function block.

        \n

        Form multi input, the function has to specify multiple arguments\nand for multi output, the aoutputs have to be provided as a\ntuple or list.

        \n

        In the context of the global system, this block implements algebraic\ncomponents of the global system ODE/DAE.

        \n
        \n\\begin{equation*}\n\\vec{y} = \\mathrm{func}(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.\nTherefore func must be purely algebraic and not introduce states,\ndelay, etc. For interfacing with external stateful APIs, use the\nWrapper block.

        \n
        \n
        \n

        Note

        \n

        If the outputs are provided as a single numpy array, they are\nconsidered a single output. For MIMO, output has to be tuple.

        \n
        \n
        \n

        Example

        \n

        consider the function:

        \n
        \nfrom pathsim.blocks import Function\n\ndef f(a, b, c):\n    return a**2, a*b, b/c\n\nfn = Function(f)\n
        \n

        then, when the block is uldated, the input channels of the block are\nassigned to the function arguments following this scheme:

        \n
        \ninputs[0] -> a\ninputs[1] -> b\ninputs[2] -> c\n
        \n

        and the function outputs are assigned to the\noutput channels of the block in the same way:

        \n
        \na**2 -> outputs[0]\na*b  -> outputs[1]\nb/c  -> outputs[2]\n
        \n

        Because the Function block only has a single argument, it can be\nused to decorate a function and make it a PathSim block. This might\nbe handy in some cases to keep definitions concise and localized\nin the code:

        \n
        \nfrom pathsim.blocks import Function\n\n#does the same as the definition above\n\n@Function\ndef fn(a, b, c):\n    return a**2, a*b, b/c\n\n#'fn' is now a PathSim block\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        MIMO function that defines algebraic block IO behaviour, signature func(*tuple)
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator that wraps func
        \n
        \n
        \n", + "description": "Arbitrary MIMO function block, defined by a callable object,", + "docstringHtml": "

        Arbitrary MIMO function block, defined by a callable object,\ni.e. function or lambda expression.

        \n

        The function can have multiple arguments that are then provided\nby the input channels of the function block.

        \n

        Form multi input, the function has to specify multiple arguments\nand for multi output, the aoutputs have to be provided as a\ntuple or list.

        \n

        In the context of the global system, this block implements algebraic\ncomponents of the global system ODE/DAE.

        \n
        \n\\begin{equation*}\n\\vec{y} = \\mathrm{func}(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.\nTherefore func must be purely algebraic and not introduce states,\ndelay, etc. For interfacing with external stateful APIs, use the\nWrapper block.

        \n
        \n
        \n

        Note

        \n

        If the outputs are provided as a single numpy array, they are\nconsidered a single output. For MIMO, output has to be tuple.

        \n
        \n
        \n

        Example

        \n

        consider the function:

        \n
        \nfrom pathsim.blocks import Function\n\ndef f(a, b, c):\n    return a**2, a*b, b/c\n\nfn = Function(f)\n
        \n

        then, when the block is uldated, the input channels of the block are\nassigned to the function arguments following this scheme:

        \n
        \ninputs[0] -> a\ninputs[1] -> b\ninputs[2] -> c\n
        \n

        and the function outputs are assigned to the\noutput channels of the block in the same way:

        \n
        \na**2 -> outputs[0]\na*b  -> outputs[1]\nb/c  -> outputs[2]\n
        \n

        Because the Function block only has a single argument, it can be\nused to decorate a function and make it a PathSim block. This might\nbe handy in some cases to keep definitions concise and localized\nin the code:

        \n
        \nfrom pathsim.blocks import Function\n\n#does the same as the definition above\n\n@Function\ndef fn(a, b, c):\n    return a**2, a*b, b/c\n\n#'fn' is now a PathSim block\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        MIMO function that defines algebraic block IO behaviour, signature func(*tuple)
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator that wraps func
        \n
        \n
        \n", "params": { "func": { "type": "callable", @@ -765,80 +741,80 @@ export const extractedBlocks: Record = "description": "MIMO function that defines algebraic block IO behaviour, signature `func(*tuple)`" } }, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "Sin": { "blockClass": "Sin", "description": "Sine operator block.", "docstringHtml": "

        Sine operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = \\sin(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "Cos": { "blockClass": "Cos", "description": "Cosine operator block.", "docstringHtml": "

        Cosine operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = \\cos(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "Tan": { "blockClass": "Tan", "description": "Tangent operator block.", "docstringHtml": "

        Tangent operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = \\tan(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "Tanh": { "blockClass": "Tanh", "description": "Hyperbolic tangent operator block.", "docstringHtml": "

        Hyperbolic tangent operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = \\tanh(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "Abs": { "blockClass": "Abs", "description": "Absolute value operator block.", "docstringHtml": "

        Absolute value operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = \\vert| \\vec{u} \\vert|\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "Sqrt": { "blockClass": "Sqrt", "description": "Square root operator block.", "docstringHtml": "

        Square root operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = \\sqrt{|\\vec{u}|}\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "Exp": { "blockClass": "Exp", "description": "Exponential operator block.", "docstringHtml": "

        Exponential operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = e^{\\vec{u}}\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "Log": { "blockClass": "Log", "description": "Natural logarithm operator block.", "docstringHtml": "

        Natural logarithm operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = \\ln(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "Log10": { "blockClass": "Log10", "description": "Base-10 logarithm operator block.", "docstringHtml": "

        Base-10 logarithm operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = \\log_{10}(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "Mod": { "blockClass": "Mod", @@ -851,8 +827,8 @@ export const extractedBlocks: Record = "description": "modulus value Attributes ----------" } }, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "Clip": { "blockClass": "Clip", @@ -870,8 +846,8 @@ export const extractedBlocks: Record = "description": "maximum clipping value Attributes ----------" } }, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "Pow": { "blockClass": "Pow", @@ -884,13 +860,13 @@ export const extractedBlocks: Record = "description": "exponent to raise the input to the power of Attributes ----------" } }, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "Switch": { "blockClass": "Switch", - "description": "Switch block that selects between its inputs.", - "docstringHtml": "

        Switch block that selects between its inputs.

        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#default None -> no passthrough\ns1 = Switch()\n\n#selecting port 2 as passthrough\ns2 = Switch(2)\n\n#change the state of the switch to port 3\ns2.select(3)\n
        \n

        Sets block output depending on self.state like this:

        \n
        \nstate == None -> outputs[0] = 0\n\nstate == 0 -> outputs[0] = inputs[0]\n\nstate == 1 -> outputs[0] = inputs[1]\n\nstate == 2 -> outputs[0] = inputs[2]\n\n...\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        state : int, None
        \n
        state of the switch
        \n
        \n
        \n", + "description": "Switch block that selects between its inputs and copies", + "docstringHtml": "

        Switch block that selects between its inputs and copies\none of them to the output.

        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#default None -> no passthrough\ns1 = Switch()\n\n#selecting port 2 as passthrough\ns2 = Switch(2)\n\n#change the state of the switch to port 3\ns2.select(3)\n
        \n

        Sets block output depending on self.state like this:

        \n
        \nstate == None -> outputs[0] = 0\n\nstate == 0 -> outputs[0] = inputs[0]\n\nstate == 1 -> outputs[0] = inputs[1]\n\nstate == 2 -> outputs[0] = inputs[2]\n\n...\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        state : int, None
        \n
        state of the switch
        \n
        \n
        \n", "params": { "state": { "type": "any", @@ -898,7 +874,7 @@ export const extractedBlocks: Record = "description": "state of the switch" } }, - "inputs": null, + "inputs": [], "outputs": [ "out" ] @@ -919,8 +895,8 @@ export const extractedBlocks: Record = "description": "N-D array of data values at the corresponding points. If 1-D, represents scalar values at each point. If 2-D, each column represents a different output dimension (m output values per input point)." } }, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "LUT1D": { "blockClass": "LUT1D", @@ -943,13 +919,13 @@ export const extractedBlocks: Record = "description": "The value to use for points outside the interpolation range. If \"extrapolate\", the interpolator will use linear extrapolation. Default is \"extrapolate\". See https://docs.scipy.org/doc/scipy-1.16.1/reference/generated/scipy.interpolate.interp1d.html for more details" } }, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "SampleHold": { "blockClass": "SampleHold", - "description": "Samples the inputs periodically and produces them at the output.", - "docstringHtml": "

        Samples the inputs periodically and produces them at the output.

        \n
        \n

        Parameters

        \n
        \n
        T : float
        \n
        sampling period
        \n
        tau : float
        \n
        delay
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        events : list[Schedule]
        \n
        internal scheduled event for periodic sampling
        \n
        \n
        \n", + "description": "Sample and hold stage that samples the inputs", + "docstringHtml": "

        Sample and hold stage that samples the inputs\nperiodically using scheduled events and produces\nthem at the output.

        \n
        \n

        Parameters

        \n
        \n
        T : float
        \n
        sampling period
        \n
        tau : float
        \n
        delay
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        events : list[Schedule]
        \n
        internal scheduled event for periodic sampling
        \n
        \n
        \n", "params": { "T": { "type": "integer", @@ -962,8 +938,8 @@ export const extractedBlocks: Record = "description": "delay Attributes ----------" } }, - "inputs": null, - "outputs": null + "inputs": [], + "outputs": [] }, "FIR": { "blockClass": "FIR", @@ -1022,7 +998,12 @@ export const extractedBlocks: Record = "inputs": [ "in" ], - "outputs": null + "outputs": [ + "b4", + "b3", + "b2", + "b1" + ] }, "DAC": { "blockClass": "DAC", @@ -1050,15 +1031,20 @@ export const extractedBlocks: Record = "description": "Initial delay before the first output update. Default is 0." } }, - "inputs": null, + "inputs": [ + "b4", + "b3", + "b2", + "b1" + ], "outputs": [ "out" ] }, "Counter": { "blockClass": "Counter", - "description": "Counts the number of detected bidirectional threshold crossings.", - "docstringHtml": "

        Counts the number of detected bidirectional threshold crossings.

        \n

        Uses zero-crossing events for the detection and and sets the output\naccordingly.

        \n
        \n

        Parameters

        \n
        \n
        start : int
        \n
        counter start (initial condition)
        \n
        threshold : float
        \n
        threshold for zero crossing
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        E : ZeroCrossing
        \n
        internal event manager
        \n
        events : list[ZeroCrossing]
        \n
        internal zero crossing event
        \n
        \n
        \n", + "description": "Counter block that counts the number of detected bidirectional", + "docstringHtml": "

        Counter block that counts the number of detected bidirectional\nzero-crossing events and sets the output accordingly.

        \n
        \n

        Parameters

        \n
        \n
        start : int
        \n
        counter start (initial condition)
        \n
        threshold : float
        \n
        threshold for zero crossing
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        E : ZeroCrossing
        \n
        internal event manager
        \n
        events : list[ZeroCrossing]
        \n
        internal zero crossing event
        \n
        \n
        \n", "params": { "start": { "type": "integer", @@ -1080,8 +1066,8 @@ export const extractedBlocks: Record = }, "CounterUp": { "blockClass": "CounterUp", - "description": "Counts the number of detected unidirectional (lo->hi) threshold crossings.", - "docstringHtml": "

        Counts the number of detected unidirectional (lo->hi) threshold crossings.

        \n
        \n

        Note

        \n

        This is a modification of 'Counter' which only counts\nunidirectional zero-crossings (low -> high)

        \n
        \n
        \n

        Parameters

        \n
        \n
        start : int
        \n
        counter start (initial condition)
        \n
        threshold : float
        \n
        threshold for zero crossing
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        E : ZeroCrossingUp
        \n
        internal event manager
        \n
        events : list[ZeroCrossing]
        \n
        internal zero crossing event
        \n
        \n
        \n", + "description": "Counter block that counts the number of detected unidirectional", + "docstringHtml": "

        Counter block that counts the number of detected unidirectional\nzero-crossing events and sets the output accordingly.

        \n
        \n

        Note

        \n

        This is a modification of 'Counter' which only counts\nunidirectional zero-crossings (low -> high)

        \n
        \n
        \n

        Parameters

        \n
        \n
        start : int
        \n
        counter start (initial condition)
        \n
        threshold : float
        \n
        threshold for zero crossing
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        E : ZeroCrossingUp
        \n
        internal event manager
        \n
        events : list[ZeroCrossing]
        \n
        internal zero crossing event
        \n
        \n
        \n", "params": { "start": { "type": "integer", @@ -1103,8 +1089,8 @@ export const extractedBlocks: Record = }, "CounterDown": { "blockClass": "CounterDown", - "description": "Counts the number of detected unidirectional (hi->lo) threshold crossings.", - "docstringHtml": "

        Counts the number of detected unidirectional (hi->lo) threshold crossings.

        \n
        \n

        Note

        \n

        This is a modification of 'Counter' which only counts\nunidirectional zero-crossings (high -> low)

        \n
        \n
        \n

        Parameters

        \n
        \n
        start : int
        \n
        counter start (initial condition)
        \n
        threshold : float
        \n
        threshold for zero crossing
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        E : ZeroCrossingDown
        \n
        internal event manager
        \n
        events : list[ZeroCrossing]
        \n
        internal zero crossing event
        \n
        \n
        \n", + "description": "Counter block that counts the number of detected unidirectional", + "docstringHtml": "

        Counter block that counts the number of detected unidirectional\nzero-crossing events and sets the output accordingly.

        \n
        \n

        Note

        \n

        This is a modification of 'Counter' which only counts\nunidirectional zero-crossings (high -> low)

        \n
        \n
        \n

        Parameters

        \n
        \n
        start : int
        \n
        counter start (initial condition)
        \n
        threshold : float
        \n
        threshold for zero crossing
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        E : ZeroCrossingDown
        \n
        internal event manager
        \n
        events : list[ZeroCrossing]
        \n
        internal zero crossing event
        \n
        \n
        \n", "params": { "start": { "type": "integer", @@ -1124,48 +1110,15 @@ export const extractedBlocks: Record = "out" ] }, - "Relay": { - "blockClass": "Relay", - "description": "Relay block with hysteresis (Schmitt trigger).", - "docstringHtml": "

        Relay block with hysteresis (Schmitt trigger).

        \n

        Switches output between two values based on input crossing upper and lower\nthresholds. The hysteresis prevents rapid switching when input is noisy.

        \n

        When input rises above threshold_up, output switches to value_up.\nWhen input falls below threshold_down, output switches to value_down.

        \n
        \n

        Examples

        \n

        Basic thermostat that turns heater on below 19°C, off above 21°C:

        \n
        \nfrom pathsim.blocks import Relay\n\nthermostat = Relay(\n    threshold_up=21.0,\n    threshold_down=19.0,\n    value_up=0.0,\n    value_down=1.0\n    )\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        threshold_up : float
        \n
        threshold for transitioning to upper relay state value_up (default: 1.0)
        \n
        threshold_down : float
        \n
        threshold for transitioning to lower relay state value_down (default: 0.0)
        \n
        value_up : float
        \n
        value for upper relay state (default: 1.0)
        \n
        value_down : float
        \n
        value for lower relay state (default: 0.0)
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        events : list[ZeroCrossingUp, ZeroCrossingDown]
        \n
        internal zero crossing events for relay state transitions
        \n
        \n
        \n", - "params": { - "threshold_up": { - "type": "number", - "default": "1.0", - "description": "threshold for transitioning to upper relay state `value_up` (default: 1.0)" - }, - "threshold_down": { - "type": "number", - "default": "0.0", - "description": "threshold for transitioning to lower relay state `value_down` (default: 0.0)" - }, - "value_up": { - "type": "number", - "default": "1.0", - "description": "value for upper relay state (default: 1.0)" - }, - "value_down": { - "type": "number", - "default": "0.0", - "description": "value for lower relay state (default: 0.0)" - } - }, - "inputs": [ - "in" - ], - "outputs": [ - "out" - ] - }, "Scope": { "blockClass": "Scope", - "description": "Block for recording time domain data with variable sampling period.", - "docstringHtml": "

        Block for recording time domain data with variable sampling period.

        \n

        A time threshold can be set by t_wait to start recording data after the simulation\ntime is larger then the specified waiting time, i.e. t - t_wait > 0.\nThis is useful for recording data only after all the transients have settled.

        \n

        The block uses an internal Schedule event, when sampling_period is provided,\notherwise it just samples at every simulation timestep.

        \n
        \n

        Parameters

        \n
        \n
        sampling_period : float, None
        \n
        time between samples, default is every timestep
        \n
        t_wait : float
        \n
        wait time before starting recording, optional
        \n
        labels : list[str]
        \n
        labels for the scope traces, and for the csv, optional
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        recording_time : list[float]
        \n
        recorded time points
        \n
        recording_data : list[float]
        \n
        recorded data points
        \n
        _incremental_idx : int
        \n
        index for incremental reading of accumulated data since last\ncall of incremental read
        \n
        _sample_next_timestep : bool
        \n
        flag to indicate this is a timestep to sample, only used for\nevent based sampling when sampling_period is provided as an arg
        \n
        events : list[Schedule]
        \n
        internal scheduled event for periodic input sampling when\nsampling_period is provided
        \n
        \n
        \n", + "description": "Block for recording time domain data with variable sampling rate.", + "docstringHtml": "

        Block for recording time domain data with variable sampling rate.

        \n

        A time threshold can be set by t_wait to start recording data after the simulation\ntime is larger then the specified waiting time, i.e. t - t_wait > 0.\nThis is useful for recording data only after all the transients have settled.

        \n

        The block uses an interal Schedule event, when sampling_rate is provided,\notherwise it just samples at every simulation timestep.

        \n
        \n

        Parameters

        \n
        \n
        sampling_rate : int, None
        \n
        number of samples per time unit, default is every timestep
        \n
        t_wait : float
        \n
        wait time before starting recording, optional
        \n
        labels : list[str]
        \n
        labels for the scope traces, and for the csv, optional
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        recording : dict
        \n
        recording, where key is time, and value the recorded values
        \n
        events : list[Schedule]
        \n
        internal scheduled event for periodic input sampling when sampling_rate is provided
        \n
        \n
        \n", "params": { - "sampling_period": { + "sampling_rate": { "type": "any", "default": null, - "description": "time between samples, default is every timestep" + "description": "number of samples per time unit, default is every timestep" }, "t_wait": { "type": "number", @@ -1178,13 +1131,13 @@ export const extractedBlocks: Record = "description": "labels for the scope traces, and for the csv, optional" } }, - "inputs": null, + "inputs": [], "outputs": [] }, "Spectrum": { "blockClass": "Spectrum", - "description": "Block for fourier spectrum analysis (spectrum analyzer).", - "docstringHtml": "

        Block for fourier spectrum analysis (spectrum analyzer).

        \n

        Computes continuous time running fourier transform (RFT) of the incoming signal.

        \n

        A time threshold can be set by 't_wait' to start recording data only after the\nsimulation time is larger then the specified waiting time, i.e. 't - t_wait > dt'.\nThis is useful for recording the steady state after all the transients have settled.

        \n

        An exponential forgetting factor 'alpha' can be specified for realtime spectral\nanalysis. It biases the spectral components exponentially to the most recent signal\nvalues by applying a single sided exponential window like this:

        \n
        \n\\begin{equation*}\n\\int_0^t u(\\tau) \\exp(\\alpha (t-\\tau)) \\exp(-j \\omega \\tau)\\ d \\tau\n\\end{equation*}\n
        \n

        It is also known as the 'exponentially forgetting transform' (EFT) and a form of\nshort time fourier transform (STFT). It is implemented as a 1st order statespace model

        \n
        \n\\begin{equation*}\n\\dot{x} = - \\alpha x + \\exp(-j \\omega t) u\n\\end{equation*}\n
        \n

        where 'u' is the input signal and 'x' is the state variable that represents the\ncomplex fourier coefficient to the frequency 'omega'. The ODE is integrated using the\nnumerical integration engine of the block.

        \n
        \n

        Example

        \n

        This is how to initialize it:

        \n
        \nimport numpy as np\n\n#linear frequencies (0Hz, DC -> 1kHz)\nsp1 = Spectrum(\n    freq=np.linspace(0, 1e3, 100),\n    labels=['x1', 'x2'] #labels for two inputs\n    )\n\n#log frequencies (1Hz -> 1kHz)\nsp2 = Spectrum(\n    freq=np.logspace(0, 3, 100)\n    )\n\n#log frequencies including DC (0Hz, DC + 1Hz -> 1kHz)\nsp3 = Spectrum(\n    freq=np.hstack([0.0, np.logspace(0, 3, 100)])\n    )\n\n#arbitrary frequencies\nsp4 = Spectrum(\n    freq=np.array([0, 0.5, 20, 1e3])\n    )\n
        \n
        \n
        \n

        Note

        \n

        This block is relatively slow! But it is valuable for long running simulations\nwith few evaluation frequencies, where just FFT'ing the time series data\nwouldnt be efficient OR if only the evaluation at weirdly spaced frequencies\nis required. Otherwise its more efficient to just do an FFT on the time\nseries recording after the simulation has finished.

        \n
        \n
        \n

        Parameters

        \n
        \n
        freq : array[float]
        \n
        list of evaluation frequencies for RFT, can be arbitrarily spaced
        \n
        t_wait : float
        \n
        wait time before starting RFT
        \n
        alpha : float
        \n
        exponential forgetting factor for realtime spectrum
        \n
        labels : list[str]
        \n
        labels for the inputs
        \n
        \n
        \n", + "description": "Block for fourier spectrum analysis (basically a spectrum analyzer), computes", + "docstringHtml": "

        Block for fourier spectrum analysis (basically a spectrum analyzer), computes\ncontinuous time running fourier transform (RFT) of the incoming signal.

        \n

        A time threshold can be set by 't_wait' to start recording data only after the\nsimulation time is larger then the specified waiting time, i.e. 't - t_wait > dt'.\nThis is useful for recording the steady state after all the transients have settled.

        \n

        An exponential forgetting factor 'alpha' can be specified for realtime spectral\nanalysis. It biases the spectral components exponentially to the most recent signal\nvalues by applying a single sided exponential window like this:

        \n
        \n\\begin{equation*}\n\\int_0^t u(\\tau) \\exp(\\alpha (t-\\tau)) \\exp(-j \\omega \\tau)\\ d \\tau\n\\end{equation*}\n
        \n

        It is also known as the 'exponentially forgetting transform' (EFT) and a form of\nshort time fourier transform (STFT). It is implemented as a 1st order statespace model

        \n
        \n\\begin{equation*}\n\\dot{x} = - \\alpha x + \\exp(-j \\omega t) u\n\\end{equation*}\n
        \n

        where 'u' is the input signal and 'x' is the state variable that represents the\ncomplex fourier coefficient to the frequency 'omega'. The ODE is integrated using the\nnumerical integration engine of the block.

        \n
        \n

        Example

        \n

        This is how to initialize it:

        \n
        \nimport numpy as np\n\n#linear frequencies (0Hz, DC -> 1kHz)\nsp1 = Spectrum(\n    freq=np.linspace(0, 1e3, 100),\n    labels=['x1', 'x2'] #labels for two inputs\n    )\n\n#log frequencies (1Hz -> 1kHz)\nsp2 = Spectrum(\n    freq=np.logspace(0, 3, 100)\n    )\n\n#log frequencies including DC (0Hz, DC + 1Hz -> 1kHz)\nsp3 = Spectrum(\n    freq=np.hstack([0.0, np.logspace(0, 3, 100)])\n    )\n\n#arbitrary frequencies\nsp4 = Spectrum(\n    freq=np.array([0, 0.5, 20, 1e3])\n    )\n
        \n
        \n
        \n

        Note

        \n

        This block is relatively slow! But it is valuable for long running simulations\nwith few evaluation frequencies, where just FFT'ing the time series data\nwouldnt be efficient OR if only the evaluation at weirdly spaced frequencies\nis required. Otherwise its more efficient to just do an FFT on the time\nseries recording after the simulation has finished.

        \n
        \n
        \n

        Parameters

        \n
        \n
        freq : array[float]
        \n
        list of evaluation frequencies for RFT, can be arbitrarily spaced
        \n
        t_wait : float
        \n
        wait time before starting RFT
        \n
        alpha : float
        \n
        exponential forgetting factor for realtime spectrum
        \n
        labels : list[str]
        \n
        labels for the inputs
        \n
        \n
        \n", "params": { "freq": { "type": "array", @@ -1207,13 +1160,13 @@ export const extractedBlocks: Record = "description": "labels for the inputs" } }, - "inputs": null, + "inputs": [], "outputs": [] }, "Subsystem": { "blockClass": "Subsystem", "description": "Subsystem class that holds its own blocks and connecions and", - "docstringHtml": "

        Subsystem class that holds its own blocks and connecions and\ncan natively interface with the main simulation loop.

        \n

        IO interface is realized by a special 'Interface' block, that has extra\nmethods for setting and getting inputs and outputs and serves\nas the interface of the internal blocks to the outside.

        \n

        The subsystem doesnt use its 'inputs' and 'outputs' dicts directly.\nIt exclusively handles data transfer via the 'Interface' block.

        \n

        This class can be used just like any other block during the simulation,\nsince it implements the required methods 'update' for the fixed-point\niteration (resolving algebraic loops with instant time blocks),\nthe 'step' method that performs timestepping (especially for dynamic\nblocks with internal states) and the 'solve' method for solving the\nimplicit update equation for implicit solvers.

        \n
        \n

        Example

        \n

        This is how we can wrap up multiple blocks within a subsystem.\nIn this case vanderpol system built from discrete components\ninstead of using an ODE block (in practice you should use\na monolithic ODE whenever possible due to performance).

        \n
        \nfrom pathsim import Subsystem, Interface, Connection\nfrom pathsim.blocks import Integrator, Function\n\n#van der Pol parameter\nmu = 1000\n\n#blocks in the subsystem\nIf = Interface() # this is the interface to the outside\nI1 = Integrator(2)\nI2 = Integrator(0)\nFn = Function(lambda x1, x2: mu*(1 - x1**2)*x2 - x1)\n\nsub_blocks = [If, I1, I2, Fn]\n\n#connections in the subsystem\nsub_connections = [\n    Connection(I2, I1, Fn[1], If[1]),\n    Connection(I1, Fn, If),\n    Connection(Fn, I2)\n    ]\n\n#the subsystem acts just like a normal block\nvdp = Subsystem(sub_blocks, sub_connections)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        blocks : list[Block] | None
        \n
        internal blocks of the subsystem
        \n
        connections : list[Connection] | None
        \n
        internal connections of the subsystem
        \n
        \n

        events : list[Event] | None\ntolerance_fpi : float

        \n
        \nabsolute tolerance for convergence of algebraic loops\ndefault see ´SIM_TOLERANCE_FPI´ in ´_constants.py´
        \n
        \n
        iterations_max : int
        \n
        maximum allowed number of iterations for algebraic loop\nsolver, default see ´SIM_ITERATIONS_MAX´ in ´_constants.py´
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        interface : Interface
        \n
        internal interface block for data transfer to the outside
        \n
        graph : Graph
        \n
        internal graph representation for fast system funcion\nevluations using DAG with algebraic depths
        \n
        boosters : None | list[ConnectionBooster]
        \n
        list of boosters (fixed point accelerators) that wrap\nalgebraic loop closing connections assembled from the\nsystem graph
        \n
        \n
        \n", + "docstringHtml": "

        Subsystem class that holds its own blocks and connecions and\ncan natively interface with the main simulation loop.

        \n

        IO interface is realized by a special 'Interface' block, that has extra\nmethods for setting and getting inputs and outputs and serves\nas the interface of the internal blocks to the outside.

        \n

        The subsystem doesnt use its 'inputs' and 'outputs' dicts directly.\nIt exclusively handles data transfer via the 'Interface' block.

        \n

        This class can be used just like any other block during the simulation,\nsince it implements the required methods 'update' for the fixed-point\niteration (resolving algebraic loops with instant time blocks),\nthe 'step' method that performs timestepping (especially for dynamic\nblocks with internal states) and the 'solve' method for solving the\nimplicit update equation for implicit solvers.

        \n
        \n

        Example

        \n

        This is how we can wrap up multiple blocks within a subsystem.\nIn this case vanderpol system built from discrete components\ninstead of using an ODE block (in practice you should use\na monolithic ODE whenever possible due to performance).

        \n
        \nfrom pathsim import Subsystem, Interface, Connection\nfrom pathsim.blocks import Integrator, Function\n\n#van der Pol parameter\nmu = 1000\n\n#blocks in the subsystem\nIf = Interface() # this is the interface to the outside\nI1 = Integrator(2)\nI2 = Integrator(0)\nFn = Function(lambda x1, x2: mu*(1 - x1**2)*x2 - x1)\n\nsub_blocks = [If, I1, I2, Fn]\n\n#connections in the subsystem\nsub_connections = [\n    Connection(I2, I1, Fn[1], If[1]),\n    Connection(I1, Fn, If),\n    Connection(Fn, I2)\n    ]\n\n#the subsystem acts just like a normal block\nvdp = Subsystem(sub_blocks, sub_connections)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        blocks : list[Block]
        \n
        internal blocks of the subsystem
        \n
        connections : list[Connection]
        \n
        internal connections of the subsystem
        \n
        tolerance_fpi : float
        \n
        absolute tolerance for convergence of algebraic loops\ndefault see ´SIM_TOLERANCE_FPI´ in ´_constants.py´
        \n
        iterations_max : int
        \n
        maximum allowed number of iterations for algebraic loop\nsolver, default see ´SIM_ITERATIONS_MAX´ in ´_constants.py´
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        interface : Interface
        \n
        internal interface block for data transfer to the outside
        \n
        graph : Graph
        \n
        internal graph representation for fast system funcion\nevluations using DAG with algebraic depths
        \n
        boosters : None | list[ConnectionBooster]
        \n
        list of boosters (fixed point accelerators) that wrap\nalgebraic loop closing connections assembled from the\nsystem graph
        \n
        \n
        \n", "params": {}, "inputs": [], "outputs": [] @@ -1247,7 +1200,7 @@ export const extractedBlocks: Record = "description": "constant source term / generation term of the process" } }, - "inputs": null, + "inputs": [], "outputs": [ "x", "x/tau" @@ -1300,7 +1253,9 @@ export const extractedBlocks: Record = "inputs": [ "in" ], - "outputs": null + "outputs": [ + "out 1.0" + ] }, "GLC": { "blockClass": "GLC", @@ -1343,22 +1298,8 @@ export const extractedBlocks: Record = "description": "BCs: Boundary conditions type, \"C-C\" (Closed-Closed) or \"O-C\" (Open-Closed), default is \"C-C\"" } }, - "inputs": [ - "c_T_in", - "flow_l", - "y_T2_inlet", - "flow_g" - ], - "outputs": [ - "c_T_out", - "y_T2_out", - "eff", - "P_out", - "Q_l", - "Q_g_out", - "n_T_out_liquid", - "n_T_out_gas" - ] + "inputs": [], + "outputs": [] } }; @@ -1366,7 +1307,7 @@ export const blockConfig: Record = { Sources: ["Constant", "Source", "SinusoidalSource", "StepSource", "PulseSource", "TriangleWaveSource", "SquareWaveSource", "GaussianPulseSource", "ChirpPhaseNoiseSource", "ClockSource", "WhiteNoise", "PinkNoise", "RandomNumberGenerator"], Dynamic: ["Integrator", "Differentiator", "Delay", "ODE", "DynamicalSystem", "StateSpace", "PID", "AntiWindupPID", "TransferFunctionNumDen", "TransferFunctionZPG", "ButterworthLowpassFilter", "ButterworthHighpassFilter", "ButterworthBandpassFilter", "ButterworthBandstopFilter"], Algebraic: ["Adder", "Multiplier", "Amplifier", "Function", "Sin", "Cos", "Tan", "Tanh", "Abs", "Sqrt", "Exp", "Log", "Log10", "Mod", "Clip", "Pow", "Switch", "LUT", "LUT1D"], - Mixed: ["SampleHold", "FIR", "ADC", "DAC", "Counter", "CounterUp", "CounterDown", "Relay"], + Mixed: ["SampleHold", "FIR", "ADC", "DAC", "Counter", "CounterUp", "CounterDown"], Recording: ["Scope", "Spectrum"], Chemical: ["Process", "Bubbler4", "Splitter", "GLC"], }; diff --git a/src/lib/simulation/generated/simulation.ts b/src/lib/simulation/generated/simulation.ts index ce51345e..f06cb784 100644 --- a/src/lib/simulation/generated/simulation.ts +++ b/src/lib/simulation/generated/simulation.ts @@ -14,7 +14,7 @@ export interface ExtractedSimulationParam { export const simulationDescription = "Class that performs transient analysis of the dynamical system, defined by the"; -export const simulationDocstringHtml = "

        Class that performs transient analysis of the dynamical system, defined by the\nblocks and connecions. It manages all the blocks and connections and the timestep update.

        \n

        The global system equation is evaluated by fixed point iteration, so the information from\neach timestep gets distributed within the entire system and is available for all blocks at\nall times.

        \n

        The minimum number of fixed-point iterations 'iterations_min' is set to 'None' by default\nand then the length of the longest internal signal path (with passthrough) is used as the\nestimate for minimum number of iterations needed for the information to reach all instant\ntime blocks in each timestep. Dont change this unless you know that the actual path is\nshorter or something similar that prohibits instant time information flow.

        \n

        Convergence check for the fixed-point iteration loop with 'tolerance_fpi' is based on\nmax absolute error (max-norm) to previous iteration and should not be touched.

        \n

        Multiple numerical integrators are implemented in the 'pathsim.solvers' module.\nThe default solver is a fixed timestep 2nd order Strong Stability Preserving Runge Kutta\n(SSPRK22) method which is quite fast and has ok accuracy, especially if you are forced to\ntake small steps to cover the behaviour of forcing functions. Adaptive timestepping and\nimplicit integrators are also available.

        \n

        Manages an event handling system based on zero crossing detection. Uses 'Event' objects\nto monitor solver states of stateful blocks and applys transformations on the state in\ncase an event is detected.

        \n
        \n

        Example

        \n

        This is how to setup a simple system simulation using the 'Simulation' class:

        \n
        \nimport numpy as np\n\nfrom pathsim import Simulation, Connection\nfrom pathsim.blocks import Source, Integrator, Scope\n\nsrc = Source(lambda t: np.cos(2*np.pi*t))\nitg = Integrator()\nsco = Scope(labels=["source", "integrator"])\n\nsim = Simulation(\n    blocks=[src, itg, sco],\n    connections=[\n        Connection(src[0], itg[0], sco[0]),\n        Connection(itg[0], sco[1])\n        ],\n    dt=0.01\n    )\n\nsim.run(4)\nsim.plot()\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        blocks : list[Block]
        \n
        blocks that define the system
        \n
        connections : list[Connection]
        \n
        connections that connect the blocks
        \n
        events : list[Event]
        \n
        list of event trackers (zero crossing detection, schedule, etc.)
        \n
        dt : float
        \n
        transient simulation timestep in time units,\ndefault see \u00b4SIM_TIMESTEP\u00b4 in \u00b4_constants.py\u00b4
        \n
        dt_min : float
        \n
        lower bound for transient simulation timestep,\ndefault see \u00b4SIM_TIMESTEP_MIN\u00b4 in \u00b4_constants.py\u00b4
        \n
        dt_max : float
        \n
        upper bound for transient simulation timestep,\ndefault see \u00b4SIM_TIMESTEP_MAX\u00b4 in \u00b4_constants.py\u00b4
        \n
        Solver : Solver
        \n
        ODE solver class for numerical integration from \u00b4pathsim.solvers\u00b4,\ndefault is \u00b4pathsim.solvers.ssprk22.SSPRK22\u00b4 (2nd order expl. Runge Kutta)
        \n
        tolerance_fpi : float
        \n
        absolute tolerance for convergence of algebraic loops\nand internal optimizers of implicit ODE solvers,\ndefault see \u00b4SIM_TOLERANCE_FPI\u00b4 in \u00b4_constants.py\u00b4
        \n
        iterations_max : int
        \n
        maximum allowed number of iterations for implicit ODE\nsolver optimizers and algebraic loop solver,\ndefault see \u00b4SIM_ITERATIONS_MAX\u00b4 in \u00b4_constants.py\u00b4
        \n
        log : bool | string
        \n
        flag to enable logging, default see \u00b4LOG_ENABLE\u00b4 in \u00b4_constants.py\u00b4\n(alternatively a path to a log file can be specified)
        \n
        solver_kwargs : dict
        \n
        additional parameters for numerical solvers such as absolute\n(\u00b4tolerance_lte_abs\u00b4) and relative (\u00b4tolerance_lte_rel\u00b4) tolerance,\ndefaults are defined in \u00b4_constants.py\u00b4
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        time : float
        \n
        global simulation time, starting at \u00b40.0\u00b4
        \n
        graph : Graph
        \n
        internal graph representation for fast system funcion evluations\nusing DAG with algebraic depths
        \n
        boosters : None | list[ConnectionBooster]
        \n
        list of boosters (fixed point accelerators) that wrap algebraic\nloop closing connections assembled from the system graph
        \n
        engine : Solver
        \n
        global integrator (ODE solver) instance serving as a dummy to\nget attributes and access to intermediate evaluation stages
        \n
        logger : logging.Logger
        \n
        global simulation logger
        \n
        _blocks_dyn : set[Block]
        \n
        blocks with internal \u00b4Solver\u00b4 instances (stateful)
        \n
        _blocks_evt : set[Block]
        \n
        blocks with internal events (discrete time, eventful)
        \n
        _active : bool
        \n
        flag for setting the simulation as active, used for interrupts
        \n
        \n
        \n"; +export const simulationDocstringHtml = "

        Class that performs transient analysis of the dynamical system, defined by the\nblocks and connecions. It manages all the blocks and connections and the timestep update.

        \n

        The global system equation is evaluated by fixed point iteration, so the information from\neach timestep gets distributed within the entire system and is available for all blocks at\nall times.

        \n

        The minimum number of fixed-point iterations 'iterations_min' is set to 'None' by default\nand then the length of the longest internal signal path (with passthrough) is used as the\nestimate for minimum number of iterations needed for the information to reach all instant\ntime blocks in each timestep. Dont change this unless you know that the actual path is\nshorter or something similar that prohibits instant time information flow.

        \n

        Convergence check for the fixed-point iteration loop with 'tolerance_fpi' is based on\nmax absolute error (max-norm) to previous iteration and should not be touched.

        \n

        Multiple numerical integrators are implemented in the 'pathsim.solvers' module.\nThe default solver is a fixed timestep 2nd order Strong Stability Preserving Runge Kutta\n(SSPRK22) method which is quite fast and has ok accuracy, especially if you are forced to\ntake small steps to cover the behaviour of forcing functions. Adaptive timestepping and\nimplicit integrators are also available.

        \n

        Manages an event handling system based on zero crossing detection. Uses 'Event' objects\nto monitor solver states of stateful blocks and applys transformations on the state in\ncase an event is detected.

        \n
        \n

        Example

        \n

        This is how to setup a simple system simulation using the 'Simulation' class:

        \n
        \nimport numpy as np\n\nfrom pathsim import Simulation, Connection\nfrom pathsim.blocks import Source, Integrator, Scope\n\nsrc = Source(lambda t: np.cos(2*np.pi*t))\nitg = Integrator()\nsco = Scope(labels=["source", "integrator"])\n\nsim = Simulation(\n    blocks=[src, itg, sco],\n    connections=[\n        Connection(src[0], itg[0], sco[0]),\n        Connection(itg[0], sco[1])\n        ],\n    dt=0.01\n    )\n\nsim.run(4)\nsim.plot()\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        blocks : list[Block]
        \n
        blocks that define the system
        \n
        connections : list[Connection]
        \n
        connections that connect the blocks
        \n
        events : list[Event]
        \n
        list of event trackers (zero crossing detection, schedule, etc.)
        \n
        dt : float
        \n
        transient simulation timestep in time units,\ndefault see \u00b4SIM_TIMESTEP\u00b4 in \u00b4_constants.py\u00b4
        \n
        dt_min : float
        \n
        lower bound for transient simulation timestep,\ndefault see \u00b4SIM_TIMESTEP_MIN\u00b4 in \u00b4_constants.py\u00b4
        \n
        dt_max : float
        \n
        upper bound for transient simulation timestep,\ndefault see \u00b4SIM_TIMESTEP_MAX\u00b4 in \u00b4_constants.py\u00b4
        \n
        Solver : Solver
        \n
        ODE solver class for numerical integration from \u00b4pathsim.solvers\u00b4,\ndefault is \u00b4pathsim.solvers.ssprk22.SSPRK22\u00b4 (2nd order expl. Runge Kutta)
        \n
        tolerance_fpi : float
        \n
        absolute tolerance for convergence of algebraic loops\nand internal optimizers of implicit ODE solvers,\ndefault see \u00b4SIM_TOLERANCE_FPI\u00b4 in \u00b4_constants.py\u00b4
        \n
        iterations_max : int
        \n
        maximum allowed number of iterations for implicit ODE\nsolver optimizers and algebraic loop solver,\ndefault see \u00b4SIM_ITERATIONS_MAX\u00b4 in \u00b4_constants.py\u00b4
        \n
        log : bool | string
        \n
        flag to enable logging, default see \u00b4LOG_ENABLE\u00b4 in \u00b4_constants.py\u00b4\n(alternatively a path to a log file can be specified)
        \n
        solver_kwargs : dict
        \n
        additional parameters for numerical solvers such as absolute\n(\u00b4tolerance_lte_abs\u00b4) and relative (\u00b4tolerance_lte_rel\u00b4) tolerance,\ndefaults are defined in \u00b4_constants.py\u00b4
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        time : float
        \n
        global simulation time, starting at \u00b40.0\u00b4
        \n
        graph : Graph
        \n
        internal graph representation for fast system funcion evluations\nusing DAG with algebraic depths
        \n
        boosters : None | list[ConnectionBooster]
        \n
        list of boosters (fixed point accelerators) that wrap algebraic\nloop closing connections assembled from the system graph
        \n
        engine : Solver
        \n
        global integrator (ODE solver) instance serving as a dummy to\nget attributes and access to intermediate evaluation stages
        \n
        logger : logging.Logger
        \n
        global simulation logger
        \n
        _blocks_dyn : set[Block]
        \n
        blocks with internal \u00b4Solver\u00b4 instances (stateful)
        \n
        _blocks_evt : set[Block]
        \n
        blocks with internal events (discrete time, eventful)
        \n
        _active : bool
        \n
        flag for setting the simulation as active, used for interrupts
        \n
        \n
        \n"; export const extractedSimulationParams: Record = { @@ -63,7 +63,7 @@ export const extractedSimulationParams: Record "rtol": { "pathsim_name": "tolerance_lte_rel", "type": "number", - "default": "0.0001", + "default": "1e-05", "description": "Relative error tolerance for adaptive timestep control" }, "atol": { From 85b6c0634b4fc20ddc423acea58e6d5f2dfb6cac Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 17:28:45 +0100 Subject: [PATCH 180/656] Remove footer and right-align buttons in confirmation modal --- src/lib/components/ConfirmationModal.svelte | 30 ++------------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/src/lib/components/ConfirmationModal.svelte b/src/lib/components/ConfirmationModal.svelte index e4d298ad..25372564 100644 --- a/src/lib/components/ConfirmationModal.svelte +++ b/src/lib/components/ConfirmationModal.svelte @@ -73,10 +73,6 @@ {state.options.confirmText}
        - -
        {/if} @@ -103,9 +99,9 @@ .dialog-actions { display: flex; - justify-content: center; + justify-content: flex-end; gap: var(--space-sm); - padding: var(--space-sm) var(--space-md); + padding: var(--space-sm) var(--space-md) var(--space-md); } .dialog-actions button { @@ -138,26 +134,4 @@ background: var(--surface-hover); border-color: var(--border-focus); } - - .dialog-footer { - padding: var(--space-xs) var(--space-md); - background: var(--surface-raised); - border-top: 1px solid var(--border); - border-radius: 0 0 var(--radius-lg) var(--radius-lg); - font-size: 10px; - color: var(--text-disabled); - text-align: center; - } - - .dialog-footer kbd { - display: inline-block; - padding: 1px 4px; - font-family: inherit; - font-size: 9px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: 3px; - color: var(--text-muted); - margin: 0 2px; - } From 5295f060daedcabdf703d32a744ceab008ccd87f Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 17:37:17 +0100 Subject: [PATCH 181/656] Fix animation cancellation when loading new graph mid-animation --- src/lib/animation/assemblyAnimation.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/lib/animation/assemblyAnimation.ts b/src/lib/animation/assemblyAnimation.ts index cecc1ec9..9b3d3da8 100644 --- a/src/lib/animation/assemblyAnimation.ts +++ b/src/lib/animation/assemblyAnimation.ts @@ -172,6 +172,20 @@ export function runAssemblyAnimation( return; } + // Cancel any previous animation (important when loading new graph mid-animation) + if (isActive || cleanupTimeoutId) { + if (cleanupTimeoutId) { + clearTimeout(cleanupTimeoutId); + cleanupTimeoutId = null; + } + // Quick cleanup of old animation classes + document.querySelectorAll('.assembling').forEach((el) => { + el.classList.remove('assembling'); + }); + isActive = false; + removeSkipListeners(); + } + // Hide everything immediately document.body.classList.add('assembly-pending'); @@ -238,6 +252,12 @@ function calculateAnimationTiming(nodes: NodeInfo[], edges: EdgeInfo[], viewport edgeDelays.set(edge.id, bothNodesLanded); }); + // Cancel any previous animation's cleanup timeout + if (cleanupTimeoutId) { + clearTimeout(cleanupTimeoutId); + cleanupTimeoutId = null; + } + isActive = true; addSkipListeners(); From b71e9f13b3279ff61fd2386f9fa0857962c448cb Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 17:45:03 +0100 Subject: [PATCH 182/656] Update welcome modal: darker background, lighter card headers, shadow, more height, reorder actions --- src/lib/components/WelcomeModal.svelte | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/lib/components/WelcomeModal.svelte b/src/lib/components/WelcomeModal.svelte index 3f09cf33..0b3c1e65 100644 --- a/src/lib/components/WelcomeModal.svelte +++ b/src/lib/components/WelcomeModal.svelte @@ -100,16 +100,16 @@
        - - - Home - - + + + Home + + Docs @@ -169,7 +169,7 @@ display: flex; flex-direction: column; gap: 16px; - background: var(--surface-raised); + background: var(--surface); overflow: hidden; } @@ -245,7 +245,7 @@ align-items: start; gap: 10px; overflow-y: auto; - max-height: 320px; + max-height: 420px; padding: 16px; } @@ -263,6 +263,7 @@ text-align: left; overflow: hidden; font-family: inherit; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); transition: border-color 0.15s ease, box-shadow 0.15s ease; } @@ -280,7 +281,7 @@ z-index: 1; padding: 6px 8px; text-align: left; - background: var(--surface); + background: var(--surface-raised); border-bottom: 1px solid var(--border); border-radius: var(--radius-md) var(--radius-md) 0 0; transition: padding 0.15s ease; From 9f531943f9f96be73244ed93e355cffc287a1c84 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 17:51:14 +0100 Subject: [PATCH 183/656] Remove examples manifest generation script, manually maintain order --- README.md | 1 - package.json | 5 ++--- scripts/generate-examples-manifest.js | 24 ------------------------ static/examples/manifest.json | 6 +++--- 4 files changed, 5 insertions(+), 31 deletions(-) delete mode 100644 scripts/generate-examples-manifest.js diff --git a/README.md b/README.md index 04709439..e05472a7 100644 --- a/README.md +++ b/README.md @@ -536,7 +536,6 @@ https://view.pathsim.org/?modelgh=pathsim/pathview/static/examples/feedback-syst | `npm run extract:simulation` | Simulation params only | | `npm run extract:deps` | Dependencies only | | `npm run extract:validate` | Validate config files | -| `npm run examples` | Generate examples manifest | --- diff --git a/package.json b/package.json index 347fd580..c16eccb2 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,8 @@ "extract:simulation": "python scripts/extract.py --simulation", "extract:deps": "python scripts/extract.py --deps", "extract:validate": "python scripts/extract.py --validate", - "examples": "node scripts/generate-examples-manifest.js", - "dev": "npm run examples && vite dev", - "build": "npm run examples && vite build", + "dev": "vite dev", + "build": "vite build", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", diff --git a/scripts/generate-examples-manifest.js b/scripts/generate-examples-manifest.js deleted file mode 100644 index b93134f0..00000000 --- a/scripts/generate-examples-manifest.js +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env node -/** - * Generates manifest.json from example .json files - * SVG previews are manually exported using the app's SVG export feature - */ - -import { readdirSync, writeFileSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const examplesDir = join(__dirname, '..', 'static', 'examples'); -const manifestPath = join(examplesDir, 'manifest.json'); - -// Find all .json files (excluding manifest.json) -const files = readdirSync(examplesDir) - .filter(f => f.endsWith('.json') && f !== 'manifest.json') - .sort(); - -// Write manifest -const manifest = { files }; -writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); - -console.log(`Generated manifest with ${files.length} examples`); diff --git a/static/examples/manifest.json b/static/examples/manifest.json index b628becd..46620d31 100644 --- a/static/examples/manifest.json +++ b/static/examples/manifest.json @@ -1,12 +1,12 @@ { "files": [ "feedback-system.json", - "pid-subsystem.json", "squarewave-lpf.json", - "bouncing-ball.json", + "pid-subsystem.json", + "thermostat.json", "cascade-subsystem.json", + "bouncing-ball.json", "fmcw-radar.json", - "thermostat.json", "vanderpol.json" ] } From 5ea9c07a17c81241dc234470edbfa15abc11e93a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Jan 2026 16:54:17 +0000 Subject: [PATCH 184/656] Bump version to 0.4.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a3fef628..b55c4eb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pathview", - "version": "0.4.4", + "version": "0.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pathview", - "version": "0.4.4", + "version": "0.4.5", "dependencies": { "@codemirror/lang-python": "^6.0.0", "@codemirror/theme-one-dark": "^6.0.0", diff --git a/package.json b/package.json index c16eccb2..c2602adf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pathview", - "version": "0.4.4", + "version": "0.4.5", "private": true, "type": "module", "scripts": { From a6d9ca09239dcb7cb8d544b13f74c01120383209 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 22:07:10 +0100 Subject: [PATCH 185/656] Add orthogonal wire routing with A* pathfinding --- package-lock.json | 22 ++ package.json | 2 + src/lib/components/FlowCanvas.svelte | 97 ++++++- src/lib/components/canvas/flowConverters.ts | 3 +- .../components/edges/OrthogonalEdge.svelte | 232 +++++++++++++++ src/lib/routing/constants.ts | 14 + src/lib/routing/gridBuilder.ts | 80 ++++++ src/lib/routing/index.ts | 10 + src/lib/routing/pathOptimizer.ts | 75 +++++ src/lib/routing/pathfinder.ts | 62 ++++ src/lib/routing/routeCalculator.ts | 164 +++++++++++ src/lib/routing/types.ts | 42 +++ src/lib/stores/graph/connections.ts | 11 +- src/lib/stores/graph/index.ts | 1 + src/lib/stores/routing.ts | 265 ++++++++++++++++++ src/lib/types/nodes.ts | 8 + 16 files changed, 1083 insertions(+), 5 deletions(-) create mode 100644 src/lib/components/edges/OrthogonalEdge.svelte create mode 100644 src/lib/routing/constants.ts create mode 100644 src/lib/routing/gridBuilder.ts create mode 100644 src/lib/routing/index.ts create mode 100644 src/lib/routing/pathOptimizer.ts create mode 100644 src/lib/routing/pathfinder.ts create mode 100644 src/lib/routing/routeCalculator.ts create mode 100644 src/lib/routing/types.ts create mode 100644 src/lib/stores/routing.ts diff --git a/package-lock.json b/package-lock.json index a3fef628..85c24211 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@xyflow/svelte": "^1.5.0", "codemirror": "^6.0.0", "katex": "^0.16.0", + "pathfinding": "^0.4.18", "plotly.js-dist-min": "^2.35.0", "pyodide": "^0.26.0" }, @@ -21,6 +22,7 @@ "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@types/node": "^22.0.0", + "@types/pathfinding": "^0.1.0", "@types/plotly.js": "^3.0.8", "eslint": "^9.0.0", "eslint-plugin-svelte": "^2.0.0", @@ -1372,6 +1374,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pathfinding": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@types/pathfinding/-/pathfinding-0.1.0.tgz", + "integrity": "sha512-QkKZqdAPhfkHIoiCWa/4yVolcCDin10qScbdiyqtDsY9cxlcslVgoU1g9yVdFytsHqJQevkYNcNBgD/P7OZbQg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/plotly.js": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/@types/plotly.js/-/plotly.js-3.0.8.tgz", @@ -2219,6 +2228,11 @@ "node": ">=8" } }, + "node_modules/heap": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.5.tgz", + "integrity": "sha512-G7HLD+WKcrOyJP5VQwYZNC3Z6FcQ7YYjEFiFoIj8PfEr73mu421o8B1N5DKUcc8K37EsJ2XXWA8DtrDz/2dReg==" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2583,6 +2597,14 @@ "node": ">=8" } }, + "node_modules/pathfinding": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/pathfinding/-/pathfinding-0.4.18.tgz", + "integrity": "sha512-R0TGEQ9GRcFCDvAWlJAWC+KGJ9SLbW4c0nuZRcioVlXVTlw+F5RvXQ8SQgSqI9KXWC1ew95vgmIiyaWTlCe9Ag==", + "dependencies": { + "heap": "0.2.5" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index c16eccb2..c8e9a4c9 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@types/node": "^22.0.0", + "@types/pathfinding": "^0.1.0", "@types/plotly.js": "^3.0.8", "eslint": "^9.0.0", "eslint-plugin-svelte": "^2.0.0", @@ -39,6 +40,7 @@ "@xyflow/svelte": "^1.5.0", "codemirror": "^6.0.0", "katex": "^0.16.0", + "pathfinding": "^0.4.18", "plotly.js-dist-min": "^2.35.0", "pyodide": "^0.26.0" } diff --git a/src/lib/components/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index 00a6bcc7..7cab8e4d 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -16,12 +16,13 @@ import BaseNode from './nodes/BaseNode.svelte'; import EventNode from './nodes/EventNode.svelte'; import AnnotationNode from './nodes/AnnotationNode.svelte'; - import ArrowEdge from './edges/ArrowEdge.svelte'; + import OrthogonalEdge from './edges/OrthogonalEdge.svelte'; import FlowUpdater from './FlowUpdater.svelte'; import { graphStore } from '$lib/stores/graph'; import { eventStore, setEventSelection } from '$lib/stores/events'; import { selectedNodeIds as graphSelectedNodeIds } from '$lib/stores/graph/state'; import { historyStore } from '$lib/stores/history'; + import { routingStore, buildRoutingContext } from '$lib/stores/routing'; import { themeStore, type Theme } from '$lib/stores/theme'; import { clearSelectionTrigger, nudgeTrigger, selectNodeTrigger, registerHasSelection, triggerFitView } from '$lib/stores/viewActions'; import { dropTargetBridge } from '$lib/stores/dropTargetBridge'; @@ -215,6 +216,90 @@ pendingNodeUpdates = []; } + // Helper to get port position in world coordinates + function getPortPosition(nodeId: string, portIndex: number, isOutput: boolean): { x: number; y: number } | null { + const node = nodes.find(n => n.id === nodeId); + if (!node) return null; + + const nodeData = node.data as NodeInstance; + const ports = isOutput ? nodeData.outputs : nodeData.inputs; + if (portIndex >= ports.length) return null; + + const rotation = (nodeData.params?.['_rotation'] as number) || 0; + const width = node.measured?.width ?? node.width ?? 80; + const height = node.measured?.height ?? node.height ?? 40; + + // Calculate port offset from center based on rotation + const portCount = ports.length; + const portSpacing = 20; // G.x2 + const span = (portCount - 1) * portSpacing; + const offsetFromCenter = -span / 2 + portIndex * portSpacing; + + let x = node.position.x; + let y = node.position.y; + + // Position based on rotation (output = right side for rotation 0) + if (isOutput) { + switch (rotation) { + case 1: // outputs at bottom + x += offsetFromCenter; + y += height / 2; + break; + case 2: // outputs at left + x -= width / 2; + y += offsetFromCenter; + break; + case 3: // outputs at top + x += offsetFromCenter; + y -= height / 2; + break; + default: // rotation 0 - outputs at right + x += width / 2; + y += offsetFromCenter; + break; + } + } else { + // Inputs are opposite to outputs + switch (rotation) { + case 1: // inputs at top + x += offsetFromCenter; + y -= height / 2; + break; + case 2: // inputs at right + x += width / 2; + y += offsetFromCenter; + break; + case 3: // inputs at bottom + x += offsetFromCenter; + y += height / 2; + break; + default: // rotation 0 - inputs at left + x -= width / 2; + y += offsetFromCenter; + break; + } + } + + return { x, y }; + } + + // Update routing context and recalculate all routes + function updateRoutingContext() { + // Only include block nodes (not events or annotations) for routing + const blockNodesForRouting = nodes.filter(n => n.type === 'pathview'); + if (blockNodesForRouting.length === 0) { + routingStore.clearRoutes(); + return; + } + + const { nodeBounds, canvasBounds } = buildRoutingContext(blockNodesForRouting); + routingStore.setContext(nodeBounds, canvasBounds); + + // Recalculate all routes + const connections = get(graphStore.connections); + routingStore.recalculateAllRoutes(connections, getPortPosition); + } + // Custom node types - will add more for different shapes const nodeTypes: NodeTypes = { pathview: BaseNode, @@ -222,9 +307,9 @@ annotation: AnnotationNode }; - // Custom edge types with arrow in middle + // Custom edge types - orthogonal routing with arrow const edgeTypes: EdgeTypes = { - arrow: ArrowEdge + orthogonal: OrthogonalEdge }; // SvelteFlow state - this is the source of truth for visual state @@ -493,6 +578,9 @@ cleanups.push(graphStore.connections.subscribe((connections: Connection[]) => { if (isSyncing) return; edges = connections.map(toFlowEdge); + // Recalculate routes when connections change + // Use setTimeout to ensure nodes are updated first + setTimeout(() => updateRoutingContext(), 0); })); // Handle node drag start - capture state for undo @@ -519,6 +607,9 @@ isSyncing = false; } historyStore.endDrag(); + + // Update routing context and recalculate routes + updateRoutingContext(); } // Handle node and edge delete diff --git a/src/lib/components/canvas/flowConverters.ts b/src/lib/components/canvas/flowConverters.ts index dcfccfe2..6407b209 100644 --- a/src/lib/components/canvas/flowConverters.ts +++ b/src/lib/components/canvas/flowConverters.ts @@ -55,7 +55,8 @@ export function toFlowEdge(conn: Connection): Edge { sourceHandle: HANDLE_ID.output(conn.sourceNodeId, conn.sourcePortIndex), target: conn.targetNodeId, targetHandle: HANDLE_ID.input(conn.targetNodeId, conn.targetPortIndex), - type: 'arrow', + type: 'orthogonal', + data: { waypoints: conn.waypoints }, selectable: true, deletable: true, animated: false diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte new file mode 100644 index 00000000..38c63552 --- /dev/null +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -0,0 +1,232 @@ + + + + + + + {#each userWaypoints() as waypoint (waypoint.id)} + + {/each} + + + + + + + + diff --git a/src/lib/routing/constants.ts b/src/lib/routing/constants.ts new file mode 100644 index 00000000..c9ca7e19 --- /dev/null +++ b/src/lib/routing/constants.ts @@ -0,0 +1,14 @@ +/** + * Routing constants + */ + +import { G } from '$lib/constants/grid'; + +/** Margin around nodes for routing (2 grid units = 20px) */ +export const ROUTING_MARGIN = G.x2; + +/** Minimum distance from port before first turn (1 grid unit = 10px) */ +export const PORT_CLEARANCE = G.unit; + +/** Grid resolution for pathfinding (matches base grid = 10px) */ +export const GRID_SIZE = G.unit; diff --git a/src/lib/routing/gridBuilder.ts b/src/lib/routing/gridBuilder.ts new file mode 100644 index 00000000..d83fedaa --- /dev/null +++ b/src/lib/routing/gridBuilder.ts @@ -0,0 +1,80 @@ +/** + * Build pathfinding grid with obstacles marked + */ + +import PF from 'pathfinding'; +import type { Bounds, RoutingContext } from './types'; +import { GRID_SIZE, ROUTING_MARGIN } from './constants'; + +/** + * Convert world coordinates to grid coordinates + * Since everything is grid-aligned, this is a simple division + */ +export function worldToGrid(x: number): number { + return Math.round(x / GRID_SIZE); +} + +/** + * Convert grid coordinates back to world coordinates + */ +export function gridToWorld(gx: number): number { + return gx * GRID_SIZE; +} + +/** + * Build pathfinding grid with obstacles marked + * @param context - Routing context with node bounds and canvas bounds + * @param excludeNodeIds - Node IDs to exclude from obstacles (source/target nodes) + */ +export function buildGrid( + context: RoutingContext, + excludeNodeIds: Set = new Set() +): PF.Grid { + const { nodeBounds, canvasBounds } = context; + + // Calculate grid dimensions from canvas bounds + const gridWidth = Math.ceil(canvasBounds.width / GRID_SIZE) + 1; + const gridHeight = Math.ceil(canvasBounds.height / GRID_SIZE) + 1; + const offsetX = canvasBounds.x; + const offsetY = canvasBounds.y; + + const grid = new PF.Grid(gridWidth, gridHeight); + + // Mark obstacles (nodes with margin) + for (const [nodeId, bounds] of nodeBounds) { + // Skip excluded nodes (source/target) + if (excludeNodeIds.has(nodeId)) continue; + + // Add margin around node + const marginBounds = { + x: bounds.x - ROUTING_MARGIN, + y: bounds.y - ROUTING_MARGIN, + width: bounds.width + 2 * ROUTING_MARGIN, + height: bounds.height + 2 * ROUTING_MARGIN + }; + + // Convert to grid coordinates + const startGx = worldToGrid(marginBounds.x - offsetX); + const startGy = worldToGrid(marginBounds.y - offsetY); + const endGx = worldToGrid(marginBounds.x + marginBounds.width - offsetX); + const endGy = worldToGrid(marginBounds.y + marginBounds.height - offsetY); + + // Mark cells as unwalkable + for (let gx = startGx; gx <= endGx; gx++) { + for (let gy = startGy; gy <= endGy; gy++) { + if (gx >= 0 && gx < gridWidth && gy >= 0 && gy < gridHeight) { + grid.setWalkableAt(gx, gy, false); + } + } + } + } + + return grid; +} + +/** + * Get grid offset (canvas origin in world coordinates) + */ +export function getGridOffset(context: RoutingContext): { x: number; y: number } { + return { x: context.canvasBounds.x, y: context.canvasBounds.y }; +} diff --git a/src/lib/routing/index.ts b/src/lib/routing/index.ts new file mode 100644 index 00000000..356328cb --- /dev/null +++ b/src/lib/routing/index.ts @@ -0,0 +1,10 @@ +/** + * Routing module public API + */ + +export { calculateRoute, calculateSimpleRoute } from './routeCalculator'; +export { buildGrid, worldToGrid, gridToWorld, getGridOffset } from './gridBuilder'; +export { findPath } from './pathfinder'; +export { simplifyPath, snapToGrid, snapPathToGrid, deduplicatePath } from './pathOptimizer'; +export { ROUTING_MARGIN, PORT_CLEARANCE, GRID_SIZE } from './constants'; +export type { Bounds, RoutingContext, RouteSegment, RouteResult } from './types'; diff --git a/src/lib/routing/pathOptimizer.ts b/src/lib/routing/pathOptimizer.ts new file mode 100644 index 00000000..f2bbdb3c --- /dev/null +++ b/src/lib/routing/pathOptimizer.ts @@ -0,0 +1,75 @@ +/** + * Path optimization utilities + */ + +import type { Position } from '$lib/types/common'; +import { GRID_SIZE } from './constants'; + +/** + * Remove collinear intermediate points (keeps only corners) + * This simplifies the path from A* which returns every grid cell + */ +export function simplifyPath(path: Position[]): Position[] { + if (path.length < 3) return path; + + const result: Position[] = [path[0]]; + + for (let i = 1; i < path.length - 1; i++) { + const prev = result[result.length - 1]; + const curr = path[i]; + const next = path[i + 1]; + + // Calculate direction vectors + const dx1 = Math.sign(curr.x - prev.x); + const dy1 = Math.sign(curr.y - prev.y); + const dx2 = Math.sign(next.x - curr.x); + const dy2 = Math.sign(next.y - curr.y); + + // Keep point if direction changes (it's a corner) + const directionChanged = dx1 !== dx2 || dy1 !== dy2; + if (directionChanged) { + result.push(curr); + } + } + + result.push(path[path.length - 1]); + return result; +} + +/** + * Snap a single point to grid + */ +export function snapToGrid(point: Position): Position { + return { + x: Math.round(point.x / GRID_SIZE) * GRID_SIZE, + y: Math.round(point.y / GRID_SIZE) * GRID_SIZE + }; +} + +/** + * Snap all points in a path to grid + */ +export function snapPathToGrid(path: Position[]): Position[] { + return path.map(snapToGrid); +} + +/** + * Merge nearby points that are on the same grid cell + */ +export function deduplicatePath(path: Position[]): Position[] { + if (path.length < 2) return path; + + const result: Position[] = [path[0]]; + + for (let i = 1; i < path.length; i++) { + const prev = result[result.length - 1]; + const curr = path[i]; + + // Skip if same position + if (prev.x !== curr.x || prev.y !== curr.y) { + result.push(curr); + } + } + + return result; +} diff --git a/src/lib/routing/pathfinder.ts b/src/lib/routing/pathfinder.ts new file mode 100644 index 00000000..7a226cb5 --- /dev/null +++ b/src/lib/routing/pathfinder.ts @@ -0,0 +1,62 @@ +/** + * A* pathfinding wrapper + */ + +import PF from 'pathfinding'; +import type { Position } from '$lib/types/common'; +import { worldToGrid, gridToWorld } from './gridBuilder'; +import { GRID_SIZE } from './constants'; + +/** + * Find orthogonal path between two points using A* + * @param start - Start position in world coordinates + * @param end - End position in world coordinates + * @param grid - Pathfinding grid (will be cloned) + * @param offset - Grid offset (canvas origin) + * @returns Array of positions in world coordinates + */ +export function findPath( + start: Position, + end: Position, + grid: PF.Grid, + offset: Position +): Position[] { + const finder = new PF.AStarFinder({ + allowDiagonal: false, + heuristic: PF.Heuristic.manhattan + } as PF.FinderOptions); + + // Convert to grid coordinates + const startGx = worldToGrid(start.x - offset.x); + const startGy = worldToGrid(start.y - offset.y); + const endGx = worldToGrid(end.x - offset.x); + const endGy = worldToGrid(end.y - offset.y); + + // Clone grid (pathfinding modifies it) + const gridClone = grid.clone(); + + // Ensure start and end are walkable (they're on port positions) + const gridWidth = gridClone.width; + const gridHeight = gridClone.height; + + if (startGx >= 0 && startGx < gridWidth && startGy >= 0 && startGy < gridHeight) { + gridClone.setWalkableAt(startGx, startGy, true); + } + if (endGx >= 0 && endGx < gridWidth && endGy >= 0 && endGy < gridHeight) { + gridClone.setWalkableAt(endGx, endGy, true); + } + + // Find path + const rawPath = finder.findPath(startGx, startGy, endGx, endGy, gridClone); + + // If no path found, return direct line (fallback) + if (rawPath.length === 0) { + return [start, end]; + } + + // Convert back to world coordinates + return rawPath.map(([gx, gy]) => ({ + x: gridToWorld(gx) + offset.x, + y: gridToWorld(gy) + offset.y + })); +} diff --git a/src/lib/routing/routeCalculator.ts b/src/lib/routing/routeCalculator.ts new file mode 100644 index 00000000..fba74d30 --- /dev/null +++ b/src/lib/routing/routeCalculator.ts @@ -0,0 +1,164 @@ +/** + * Main route calculation orchestrator + */ + +import type { Position } from '$lib/types/common'; +import type { Connection, Waypoint } from '$lib/types/nodes'; +import type { RoutingContext, RouteResult, RouteSegment } from './types'; +import { buildGrid, getGridOffset } from './gridBuilder'; +import { findPath } from './pathfinder'; +import { simplifyPath, snapPathToGrid, deduplicatePath } from './pathOptimizer'; + +let waypointIdCounter = 0; + +function generateWaypointId(): string { + return `wp_${Date.now()}_${waypointIdCounter++}`; +} + +/** + * Calculate route for a connection, respecting user waypoints + * @param connection - Connection with optional user waypoints + * @param sourcePos - Source port world position + * @param targetPos - Target port world position + * @param context - Routing context with node bounds + * @returns Route result with path, waypoints, and segments + */ +export function calculateRoute( + connection: Connection, + sourcePos: Position, + targetPos: Position, + context: RoutingContext +): RouteResult { + // Get user waypoints, sorted by proximity to source + const userWaypoints = (connection.waypoints || []) + .filter((w) => w.isUserWaypoint) + .sort((a, b) => { + // Sort by manhattan distance from source + const distA = Math.abs(a.position.x - sourcePos.x) + Math.abs(a.position.y - sourcePos.y); + const distB = Math.abs(b.position.x - sourcePos.x) + Math.abs(b.position.y - sourcePos.y); + return distA - distB; + }); + + // Build pathfinding grid, excluding source and target nodes + const excludeNodes = new Set([connection.sourceNodeId, connection.targetNodeId]); + const grid = buildGrid(context, excludeNodes); + const offset = getGridOffset(context); + + // Build path segments between waypoints + const allPoints: Position[] = []; + const allWaypoints: Waypoint[] = []; + + let currentPos = sourcePos; + + // Route through each user waypoint + for (const userWp of userWaypoints) { + const segmentPath = findPath(currentPos, userWp.position, grid, offset); + const simplified = simplifyPath(segmentPath); + + // Add intermediate points (skip first which is currentPos) + for (let i = 1; i < simplified.length - 1; i++) { + allPoints.push(simplified[i]); + // Create auto waypoint for intermediate points + allWaypoints.push({ + id: generateWaypointId(), + position: simplified[i], + isUserWaypoint: false + }); + } + + // Add user waypoint position + allPoints.push(userWp.position); + allWaypoints.push(userWp); + currentPos = userWp.position; + } + + // Final segment to target + const finalPath = findPath(currentPos, targetPos, grid, offset); + const simplified = simplifyPath(finalPath); + + // Add intermediate points from final segment + for (let i = 1; i < simplified.length - 1; i++) { + allPoints.push(simplified[i]); + allWaypoints.push({ + id: generateWaypointId(), + position: simplified[i], + isUserWaypoint: false + }); + } + + // Build complete path: source -> all intermediate points -> target + const fullPath = [sourcePos, ...allPoints, targetPos]; + + // Snap to grid and deduplicate + const snappedPath = deduplicatePath(snapPathToGrid(fullPath)); + + // Build segment info + const segments = buildSegments(snappedPath, allWaypoints); + + return { + path: snappedPath, + waypoints: allWaypoints, + segments + }; +} + +/** + * Build segment info from path and waypoints + */ +function buildSegments(path: Position[], waypoints: Waypoint[]): RouteSegment[] { + const segments: RouteSegment[] = []; + + // Create set of user waypoint positions for fast lookup + const userWaypointPositions = new Set( + waypoints.filter((w) => w.isUserWaypoint).map((w) => `${w.position.x},${w.position.y}`) + ); + + for (let i = 0; i < path.length - 1; i++) { + const start = path[i]; + const end = path[i + 1]; + const isHorizontal = start.y === end.y; + + // Segment is "user" if either endpoint is a user waypoint + const startKey = `${start.x},${start.y}`; + const endKey = `${end.x},${end.y}`; + const isUserSegment = userWaypointPositions.has(startKey) || userWaypointPositions.has(endKey); + + segments.push({ + index: i, + startPoint: start, + endPoint: end, + isHorizontal, + isUserSegment + }); + } + + return segments; +} + +/** + * Calculate simple L-shaped or Z-shaped route without pathfinding + * Used as fallback when no obstacles or for performance + */ +export function calculateSimpleRoute(sourcePos: Position, targetPos: Position): RouteResult { + const path: Position[] = [sourcePos]; + + // Determine if we need an L-shape or Z-shape + const dx = targetPos.x - sourcePos.x; + const dy = targetPos.y - sourcePos.y; + + if (dx !== 0 && dy !== 0) { + // Need a bend - use L-shape (horizontal first, then vertical) + const midPoint = { x: targetPos.x, y: sourcePos.y }; + path.push(midPoint); + } + + path.push(targetPos); + + const segments = buildSegments(path, []); + + return { + path, + waypoints: [], + segments + }; +} diff --git a/src/lib/routing/types.ts b/src/lib/routing/types.ts new file mode 100644 index 00000000..f38af965 --- /dev/null +++ b/src/lib/routing/types.ts @@ -0,0 +1,42 @@ +/** + * Routing-specific type definitions + */ + +import type { Position } from '$lib/types/common'; +import type { Waypoint } from '$lib/types/nodes'; + +/** Rectangle bounds for obstacle detection */ +export interface Bounds { + x: number; + y: number; + width: number; + height: number; +} + +/** Routing context passed to calculator */ +export interface RoutingContext { + /** Node ID -> bounding box (world coordinates, already includes margin) */ + nodeBounds: Map; + /** Canvas bounds for grid calculation */ + canvasBounds: Bounds; +} + +/** Segment of a route (for segment dragging) */ +export interface RouteSegment { + index: number; + startPoint: Position; + endPoint: Position; + isHorizontal: boolean; + /** true if bounded by user waypoints */ + isUserSegment: boolean; +} + +/** Result from route calculation */ +export interface RouteResult { + /** Grid-aligned points including source/target */ + path: Position[]; + /** Generated waypoints (mix of user and auto) */ + waypoints: Waypoint[]; + /** Segment info for interaction */ + segments: RouteSegment[]; +} diff --git a/src/lib/stores/graph/connections.ts b/src/lib/stores/graph/connections.ts index b0be5577..a3b60c35 100644 --- a/src/lib/stores/graph/connections.ts +++ b/src/lib/stores/graph/connections.ts @@ -3,7 +3,7 @@ */ import { get } from 'svelte/store'; -import type { Connection } from '$lib/nodes/types'; +import type { Connection, Waypoint } from '$lib/types/nodes'; import { rootNodes, rootConnections, @@ -93,3 +93,12 @@ export function getAllConnections(): { connection: Connection; subsystemId?: str return all; } + +/** + * Update waypoints for a connection + */ +export function updateConnectionWaypoints(id: string, waypoints: Waypoint[]): void { + updateCurrentConnections((connections) => + connections.map((c) => (c.id === id ? { ...c, waypoints } : c)) + ); +} diff --git a/src/lib/stores/graph/index.ts b/src/lib/stores/graph/index.ts index 95e7fae5..80ad13cf 100644 --- a/src/lib/stores/graph/index.ts +++ b/src/lib/stores/graph/index.ts @@ -75,6 +75,7 @@ export const graphStore = { addConnection: connections.addConnection, removeConnection: connections.removeConnection, getAllConnections: connections.getAllConnections, + updateConnectionWaypoints: connections.updateConnectionWaypoints, // ==================== PORT OPERATIONS ==================== addInputPort: ports.addInputPort, diff --git a/src/lib/stores/routing.ts b/src/lib/stores/routing.ts new file mode 100644 index 00000000..3d688f5d --- /dev/null +++ b/src/lib/stores/routing.ts @@ -0,0 +1,265 @@ +/** + * Routing store - manages route calculations and caching + */ + +import { writable, derived, get } from 'svelte/store'; +import type { Position } from '$lib/types/common'; +import type { Connection, Waypoint } from '$lib/types/nodes'; +import type { RoutingContext, RouteResult, Bounds } from '$lib/routing'; +import { calculateRoute, calculateSimpleRoute, ROUTING_MARGIN } from '$lib/routing'; +import { generateId } from '$lib/stores/utils'; +import { graphStore } from '$lib/stores/graph'; +import { historyStore } from '$lib/stores/history'; + +interface RoutingState { + /** Cached routes by connection ID */ + routes: Map; + /** Current routing context (node bounds) */ + context: RoutingContext | null; +} + +const state = writable({ + routes: new Map(), + context: null +}); + +/** + * Routing store - manages route calculations and caching + */ +export const routingStore = { + subscribe: state.subscribe, + + /** + * Update routing context from current nodes + * Call this when nodes are added, removed, or moved + */ + setContext(nodeBounds: Map, canvasBounds: Bounds): void { + const context: RoutingContext = { nodeBounds, canvasBounds }; + state.update((s) => ({ ...s, context })); + }, + + /** + * Get route for a specific connection (as a derived store) + */ + getRoute(connectionId: string) { + return derived(state, ($state) => $state.routes.get(connectionId) || null); + }, + + /** + * Get route synchronously (non-reactive) + */ + getRouteSync(connectionId: string): RouteResult | null { + return get(state).routes.get(connectionId) || null; + }, + + /** + * Calculate and cache route for a single connection + */ + calculateRoute( + connection: Connection, + sourcePos: Position, + targetPos: Position + ): RouteResult | null { + const $state = get(state); + + let result: RouteResult; + if ($state.context && $state.context.nodeBounds.size > 0) { + result = calculateRoute(connection, sourcePos, targetPos, $state.context); + } else { + // No context or empty - use simple routing + result = calculateSimpleRoute(sourcePos, targetPos); + } + + state.update((s) => { + const routes = new Map(s.routes); + routes.set(connection.id, result); + return { ...s, routes }; + }); + + return result; + }, + + /** + * Recalculate all routes + * @param connections - All connections to route + * @param getPortPosition - Function to get port world position + */ + recalculateAllRoutes( + connections: Connection[], + getPortPosition: (nodeId: string, portIndex: number, isOutput: boolean) => Position | null + ): void { + const $state = get(state); + + const routes = new Map(); + + for (const conn of connections) { + const sourcePos = getPortPosition(conn.sourceNodeId, conn.sourcePortIndex, true); + const targetPos = getPortPosition(conn.targetNodeId, conn.targetPortIndex, false); + + if (!sourcePos || !targetPos) continue; + + let result: RouteResult; + if ($state.context && $state.context.nodeBounds.size > 0) { + result = calculateRoute(conn, sourcePos, targetPos, $state.context); + } else { + result = calculateSimpleRoute(sourcePos, targetPos); + } + routes.set(conn.id, result); + } + + state.update((s) => ({ ...s, routes })); + }, + + /** + * Invalidate route for a specific connection (will be recalculated on next render) + */ + invalidateRoute(connectionId: string): void { + state.update((s) => { + const routes = new Map(s.routes); + routes.delete(connectionId); + return { ...s, routes }; + }); + }, + + /** + * Invalidate routes for connections involving specific nodes + */ + invalidateRoutesForNodes(nodeIds: Set): void { + const connections = get(graphStore.connections); + const toInvalidate = connections.filter( + (c) => nodeIds.has(c.sourceNodeId) || nodeIds.has(c.targetNodeId) + ); + + state.update((s) => { + const routes = new Map(s.routes); + for (const conn of toInvalidate) { + routes.delete(conn.id); + } + return { ...s, routes }; + }); + }, + + /** + * Add a user waypoint to a connection + */ + addUserWaypoint(connectionId: string, position: Position): void { + historyStore.mutate(() => { + const connections = get(graphStore.connections); + const connection = connections.find((c) => c.id === connectionId); + if (!connection) return; + + const newWaypoint: Waypoint = { + id: generateId(), + position, + isUserWaypoint: true + }; + + // Get existing user waypoints (filter out auto waypoints) + const existingUserWaypoints = (connection.waypoints || []).filter((w) => w.isUserWaypoint); + const updatedWaypoints = [...existingUserWaypoints, newWaypoint]; + + graphStore.updateConnectionWaypoints(connectionId, updatedWaypoints); + + // Invalidate cached route + routingStore.invalidateRoute(connectionId); + }); + }, + + /** + * Remove a user waypoint from a connection + */ + removeUserWaypoint(connectionId: string, waypointId: string): void { + historyStore.mutate(() => { + const connections = get(graphStore.connections); + const connection = connections.find((c) => c.id === connectionId); + if (!connection?.waypoints) return; + + const updatedWaypoints = connection.waypoints.filter( + (w) => w.id !== waypointId || !w.isUserWaypoint + ); + + graphStore.updateConnectionWaypoints(connectionId, updatedWaypoints); + routingStore.invalidateRoute(connectionId); + }); + }, + + /** + * Move a waypoint to a new position + */ + moveWaypoint(connectionId: string, waypointId: string, newPosition: Position): void { + const connections = get(graphStore.connections); + const connection = connections.find((c) => c.id === connectionId); + if (!connection?.waypoints) return; + + const updatedWaypoints = connection.waypoints.map((w) => + w.id === waypointId ? { ...w, position: newPosition } : w + ); + + graphStore.updateConnectionWaypoints(connectionId, updatedWaypoints); + routingStore.invalidateRoute(connectionId); + }, + + /** + * Clear all user waypoints from a connection (reset route) + */ + resetRoute(connectionId: string): void { + historyStore.mutate(() => { + graphStore.updateConnectionWaypoints(connectionId, []); + routingStore.invalidateRoute(connectionId); + }); + }, + + /** + * Clear all cached routes + */ + clearRoutes(): void { + state.update((s) => ({ ...s, routes: new Map() })); + } +}; + +/** + * Build routing context from SvelteFlow nodes + */ +export function buildRoutingContext( + nodes: Array<{ id: string; position: Position; width?: number; height?: number; measured?: { width?: number; height?: number } }>, + padding = 100 +): { nodeBounds: Map; canvasBounds: Bounds } { + const nodeBounds = new Map(); + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const node of nodes) { + const width = node.measured?.width ?? node.width ?? 80; + const height = node.measured?.height ?? node.height ?? 40; + + // Node position is center (nodeOrigin = [0.5, 0.5]) + const left = node.position.x - width / 2; + const top = node.position.y - height / 2; + + nodeBounds.set(node.id, { + x: left, + y: top, + width, + height + }); + + // Track canvas bounds + minX = Math.min(minX, left - ROUTING_MARGIN); + minY = Math.min(minY, top - ROUTING_MARGIN); + maxX = Math.max(maxX, left + width + ROUTING_MARGIN); + maxY = Math.max(maxY, top + height + ROUTING_MARGIN); + } + + // Add padding + const canvasBounds: Bounds = { + x: minX - padding, + y: minY - padding, + width: maxX - minX + 2 * padding, + height: maxY - minY + 2 * padding + }; + + return { nodeBounds, canvasBounds }; +} diff --git a/src/lib/types/nodes.ts b/src/lib/types/nodes.ts index cd2c7059..a2ebbd6c 100644 --- a/src/lib/types/nodes.ts +++ b/src/lib/types/nodes.ts @@ -117,6 +117,13 @@ export interface NodeInstance { [key: string]: unknown; } +/** Waypoint on a connection route */ +export interface Waypoint { + id: string; + position: Position; + isUserWaypoint: boolean; // true = user-placed (persisted), false = auto-calculated +} + /** Connection between ports */ export interface Connection { id: string; @@ -124,6 +131,7 @@ export interface Connection { sourcePortIndex: number; targetNodeId: string; targetPortIndex: number; + waypoints?: Waypoint[]; // Optional - empty/undefined means auto-route entire path } /** Canvas annotation (markdown/LaTeX text) */ From 262ea79d7afee58608c72aea1cf69ff7bbf7359b Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 22:08:12 +0100 Subject: [PATCH 186/656] Add waypoint keyboard shortcut and enable edge selection --- src/lib/components/FlowCanvas.svelte | 29 +++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/lib/components/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index 7cab8e4d..76ade155 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -53,6 +53,13 @@ }); + // Track mouse position for waypoint placement + let mousePosition = { x: 0, y: 0 }; + + function handleMouseMove(event: MouseEvent) { + mousePosition = { x: event.clientX, y: event.clientY }; + } + // Keyboard shortcuts for node manipulation function handleKeydown(event: KeyboardEvent) { // Ignore if typing in an input field or code editor @@ -73,6 +80,25 @@ return; } + // Handle backslash key - add waypoint to selected edge + if (event.key === '\\') { + const selectedEdge = edges.find(e => e.selected); + if (selectedEdge && canvasEl) { + event.preventDefault(); + // Convert mouse position to flow coordinates + const rect = canvasEl.getBoundingClientRect(); + const flowX = mousePosition.x - rect.left; + const flowY = mousePosition.y - rect.top; + // TODO: Convert screen coords to flow coords using useSvelteFlow + // For now, use approximate position (this will be improved) + const gridSize = 10; + const snappedX = Math.round(flowX / gridSize) * gridSize; + const snappedY = Math.round(flowY / gridSize) * gridSize; + routingStore.addUserWaypoint(selectedEdge.id, { x: snappedX, y: snappedY }); + } + return; + } + const hasSelection = nodes.some((n) => n.selected); if (!hasSelection) return; @@ -862,6 +888,7 @@ ondragenter={handleDragEnter} ondragleave={handleDragLeave} ondblclick={handleCanvasDoubleClick} + onmousemove={handleMouseMove} > {#if isFileDragOver}
        @@ -896,7 +923,7 @@ connectOnClick edgesReconnectable edgesFocusable - edgesSelectable={false} + edgesSelectable zoomOnDoubleClick={false} proOptions={{ hideAttribution: true }} > From 30e8fea9159aa6ac2e530ea219de4839e7f11a7b Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 22:11:12 +0100 Subject: [PATCH 187/656] Add edge context menu Reset Route option, fix waypoint coordinate conversion --- src/lib/components/FlowCanvas.svelte | 16 +++++++--------- src/lib/components/contextMenuBuilders.ts | 7 +++++++ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/lib/components/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index 76ade155..8ddcfa9c 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -25,6 +25,7 @@ import { routingStore, buildRoutingContext } from '$lib/stores/routing'; import { themeStore, type Theme } from '$lib/stores/theme'; import { clearSelectionTrigger, nudgeTrigger, selectNodeTrigger, registerHasSelection, triggerFitView } from '$lib/stores/viewActions'; + import { screenToFlow } from '$lib/utils/viewUtils'; import { dropTargetBridge } from '$lib/stores/dropTargetBridge'; import { contextMenuStore } from '$lib/stores/contextMenu'; import { nodeUpdatesStore } from '$lib/stores/nodeUpdates'; @@ -83,17 +84,14 @@ // Handle backslash key - add waypoint to selected edge if (event.key === '\\') { const selectedEdge = edges.find(e => e.selected); - if (selectedEdge && canvasEl) { + if (selectedEdge) { event.preventDefault(); - // Convert mouse position to flow coordinates - const rect = canvasEl.getBoundingClientRect(); - const flowX = mousePosition.x - rect.left; - const flowY = mousePosition.y - rect.top; - // TODO: Convert screen coords to flow coords using useSvelteFlow - // For now, use approximate position (this will be improved) + // Convert screen position to flow coordinates + const flowPos = screenToFlow(mousePosition); + // Snap to grid const gridSize = 10; - const snappedX = Math.round(flowX / gridSize) * gridSize; - const snappedY = Math.round(flowY / gridSize) * gridSize; + const snappedX = Math.round(flowPos.x / gridSize) * gridSize; + const snappedY = Math.round(flowPos.y / gridSize) * gridSize; routingStore.addUserWaypoint(selectedEdge.id, { x: snappedX, y: snappedY }); } return; diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index fbdc500a..107bb37a 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -7,6 +7,7 @@ import { get } from 'svelte/store'; import type { MenuItemType } from './ContextMenu.svelte'; import type { ContextMenuTarget } from '$lib/stores/contextMenu'; import { graphStore, ANNOTATION_FONT_SIZE } from '$lib/stores/graph'; +import { routingStore } from '$lib/stores/routing'; import { eventStore } from '$lib/stores/events'; import { clipboardStore } from '$lib/stores/clipboard'; import { codePreviewStore } from '$lib/stores/codePreview'; @@ -296,6 +297,12 @@ function buildEventMenu(eventId: string): MenuItemType[] { */ function buildEdgeMenu(edgeId: string): MenuItemType[] { return [ + { + label: 'Reset Route', + icon: 'rotate', + action: () => routingStore.resetRoute(edgeId) + }, + DIVIDER, { label: 'Delete', icon: 'trash', From 5883911a8cec7e09eb6d02b0b166bb976f48beca Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 22:19:50 +0100 Subject: [PATCH 188/656] Enforce port entry/exit directions and add turn penalty to A* pathfinding --- src/lib/components/FlowCanvas.svelte | 22 +++- src/lib/routing/index.ts | 5 +- src/lib/routing/pathfinder.ts | 186 ++++++++++++++++++++++++++- src/lib/routing/routeCalculator.ts | 130 ++++++++++++++++--- src/lib/routing/types.ts | 11 ++ src/lib/stores/routing.ts | 42 ++++-- 6 files changed, 354 insertions(+), 42 deletions(-) diff --git a/src/lib/components/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index 8ddcfa9c..debc65b8 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -22,7 +22,8 @@ import { eventStore, setEventSelection } from '$lib/stores/events'; import { selectedNodeIds as graphSelectedNodeIds } from '$lib/stores/graph/state'; import { historyStore } from '$lib/stores/history'; - import { routingStore, buildRoutingContext } from '$lib/stores/routing'; + import { routingStore, buildRoutingContext, type PortInfo } from '$lib/stores/routing'; + import type { Direction } from '$lib/routing'; import { themeStore, type Theme } from '$lib/stores/theme'; import { clearSelectionTrigger, nudgeTrigger, selectNodeTrigger, registerHasSelection, triggerFitView } from '$lib/stores/viewActions'; import { screenToFlow } from '$lib/utils/viewUtils'; @@ -240,8 +241,8 @@ pendingNodeUpdates = []; } - // Helper to get port position in world coordinates - function getPortPosition(nodeId: string, portIndex: number, isOutput: boolean): { x: number; y: number } | null { + // Helper to get port position and direction in world coordinates + function getPortInfo(nodeId: string, portIndex: number, isOutput: boolean): PortInfo | null { const node = nodes.find(n => n.id === nodeId); if (!node) return null; @@ -261,25 +262,30 @@ let x = node.position.x; let y = node.position.y; + let direction: Direction; - // Position based on rotation (output = right side for rotation 0) + // Position and direction based on rotation (output = right side for rotation 0) if (isOutput) { switch (rotation) { case 1: // outputs at bottom x += offsetFromCenter; y += height / 2; + direction = 'down'; break; case 2: // outputs at left x -= width / 2; y += offsetFromCenter; + direction = 'left'; break; case 3: // outputs at top x += offsetFromCenter; y -= height / 2; + direction = 'up'; break; default: // rotation 0 - outputs at right x += width / 2; y += offsetFromCenter; + direction = 'right'; break; } } else { @@ -288,23 +294,27 @@ case 1: // inputs at top x += offsetFromCenter; y -= height / 2; + direction = 'up'; break; case 2: // inputs at right x += width / 2; y += offsetFromCenter; + direction = 'right'; break; case 3: // inputs at bottom x += offsetFromCenter; y += height / 2; + direction = 'down'; break; default: // rotation 0 - inputs at left x -= width / 2; y += offsetFromCenter; + direction = 'left'; break; } } - return { x, y }; + return { position: { x, y }, direction }; } // Update routing context and recalculate all routes @@ -321,7 +331,7 @@ // Recalculate all routes const connections = get(graphStore.connections); - routingStore.recalculateAllRoutes(connections, getPortPosition); + routingStore.recalculateAllRoutes(connections, getPortInfo); } // Custom node types - will add more for different shapes diff --git a/src/lib/routing/index.ts b/src/lib/routing/index.ts index 356328cb..75f26e96 100644 --- a/src/lib/routing/index.ts +++ b/src/lib/routing/index.ts @@ -4,7 +4,8 @@ export { calculateRoute, calculateSimpleRoute } from './routeCalculator'; export { buildGrid, worldToGrid, gridToWorld, getGridOffset } from './gridBuilder'; -export { findPath } from './pathfinder'; +export { findPath, findPathWithTurnPenalty } from './pathfinder'; export { simplifyPath, snapToGrid, snapPathToGrid, deduplicatePath } from './pathOptimizer'; export { ROUTING_MARGIN, PORT_CLEARANCE, GRID_SIZE } from './constants'; -export type { Bounds, RoutingContext, RouteSegment, RouteResult } from './types'; +export type { Bounds, RoutingContext, RouteSegment, RouteResult, Direction } from './types'; +export { DIRECTION_VECTORS } from './types'; diff --git a/src/lib/routing/pathfinder.ts b/src/lib/routing/pathfinder.ts index 7a226cb5..442daea1 100644 --- a/src/lib/routing/pathfinder.ts +++ b/src/lib/routing/pathfinder.ts @@ -1,20 +1,202 @@ /** - * A* pathfinding wrapper + * A* pathfinding with turn penalty */ import PF from 'pathfinding'; import type { Position } from '$lib/types/common'; +import type { Direction } from './types'; import { worldToGrid, gridToWorld } from './gridBuilder'; import { GRID_SIZE } from './constants'; +/** Cost for making a 90-degree turn (in grid units) */ +const TURN_PENALTY = 2; + +/** Priority queue node for A* with direction tracking */ +interface AStarNode { + x: number; + y: number; + g: number; // Cost from start + h: number; // Heuristic to end + f: number; // Total cost (g + h) + parent: AStarNode | null; + direction: 'horizontal' | 'vertical' | null; +} + /** - * Find orthogonal path between two points using A* + * Find orthogonal path between two points using A* with turn penalty * @param start - Start position in world coordinates * @param end - End position in world coordinates * @param grid - Pathfinding grid (will be cloned) * @param offset - Grid offset (canvas origin) + * @param initialDir - Initial direction of travel (affects first turn penalty) * @returns Array of positions in world coordinates */ +export function findPathWithTurnPenalty( + start: Position, + end: Position, + grid: PF.Grid, + offset: Position, + initialDir: Direction +): Position[] { + // Convert to grid coordinates + const startGx = worldToGrid(start.x - offset.x); + const startGy = worldToGrid(start.y - offset.y); + const endGx = worldToGrid(end.x - offset.x); + const endGy = worldToGrid(end.y - offset.y); + + const gridWidth = grid.width; + const gridHeight = grid.height; + + // Bounds check + if (startGx < 0 || startGx >= gridWidth || startGy < 0 || startGy >= gridHeight || + endGx < 0 || endGx >= gridWidth || endGy < 0 || endGy >= gridHeight) { + return [start, end]; + } + + // Clone grid for manipulation + const walkable = new Set(); + for (let y = 0; y < gridHeight; y++) { + for (let x = 0; x < gridWidth; x++) { + if (grid.isWalkableAt(x, y)) { + walkable.add(`${x},${y}`); + } + } + } + // Ensure start and end are walkable + walkable.add(`${startGx},${startGy}`); + walkable.add(`${endGx},${endGy}`); + + // Initialize open and closed sets + const openSet: AStarNode[] = []; + const closedSet = new Map(); + + // Determine initial direction type + const initialDirType: 'horizontal' | 'vertical' = + (initialDir === 'left' || initialDir === 'right') ? 'horizontal' : 'vertical'; + + // Create start node + const startNode: AStarNode = { + x: startGx, + y: startGy, + g: 0, + h: manhattanDistance(startGx, startGy, endGx, endGy), + f: 0, + parent: null, + direction: initialDirType + }; + startNode.f = startNode.g + startNode.h; + openSet.push(startNode); + + // Neighbor offsets (4-directional) + const neighbors = [ + { dx: 1, dy: 0, dir: 'horizontal' as const }, + { dx: -1, dy: 0, dir: 'horizontal' as const }, + { dx: 0, dy: 1, dir: 'vertical' as const }, + { dx: 0, dy: -1, dir: 'vertical' as const } + ]; + + while (openSet.length > 0) { + // Find node with lowest f score + let lowestIdx = 0; + for (let i = 1; i < openSet.length; i++) { + if (openSet[i].f < openSet[lowestIdx].f) { + lowestIdx = i; + } + } + const current = openSet[lowestIdx]; + + // Check if we reached the end + if (current.x === endGx && current.y === endGy) { + // Reconstruct path + return reconstructPath(current, offset); + } + + // Move current from open to closed + openSet.splice(lowestIdx, 1); + closedSet.set(`${current.x},${current.y}`, current); + + // Explore neighbors + for (const { dx, dy, dir } of neighbors) { + const nx = current.x + dx; + const ny = current.y + dy; + const key = `${nx},${ny}`; + + // Skip if out of bounds or not walkable + if (nx < 0 || nx >= gridWidth || ny < 0 || ny >= gridHeight) continue; + if (!walkable.has(key)) continue; + + // Skip if already in closed set + if (closedSet.has(key)) continue; + + // Calculate movement cost + let moveCost = 1; + + // Add turn penalty if direction changes + if (current.direction !== null && current.direction !== dir) { + moveCost += TURN_PENALTY; + } + + const tentativeG = current.g + moveCost; + + // Check if this path to neighbor is better + const existingIdx = openSet.findIndex(n => n.x === nx && n.y === ny); + if (existingIdx !== -1) { + if (tentativeG < openSet[existingIdx].g) { + // Better path found + openSet[existingIdx].g = tentativeG; + openSet[existingIdx].f = tentativeG + openSet[existingIdx].h; + openSet[existingIdx].parent = current; + openSet[existingIdx].direction = dir; + } + } else { + // New node + const h = manhattanDistance(nx, ny, endGx, endGy); + const neighbor: AStarNode = { + x: nx, + y: ny, + g: tentativeG, + h, + f: tentativeG + h, + parent: current, + direction: dir + }; + openSet.push(neighbor); + } + } + } + + // No path found, return direct line + return [start, end]; +} + +/** + * Manhattan distance heuristic + */ +function manhattanDistance(x1: number, y1: number, x2: number, y2: number): number { + return Math.abs(x1 - x2) + Math.abs(y1 - y2); +} + +/** + * Reconstruct path from A* result + */ +function reconstructPath(endNode: AStarNode, offset: Position): Position[] { + const path: Position[] = []; + let current: AStarNode | null = endNode; + + while (current !== null) { + path.unshift({ + x: gridToWorld(current.x) + offset.x, + y: gridToWorld(current.y) + offset.y + }); + current = current.parent; + } + + return path; +} + +/** + * Original findPath without turn penalty (kept for compatibility) + */ export function findPath( start: Position, end: Position, diff --git a/src/lib/routing/routeCalculator.ts b/src/lib/routing/routeCalculator.ts index fba74d30..66eef8c0 100644 --- a/src/lib/routing/routeCalculator.ts +++ b/src/lib/routing/routeCalculator.ts @@ -4,10 +4,12 @@ import type { Position } from '$lib/types/common'; import type { Connection, Waypoint } from '$lib/types/nodes'; -import type { RoutingContext, RouteResult, RouteSegment } from './types'; +import type { RoutingContext, RouteResult, RouteSegment, Direction } from './types'; +import { DIRECTION_VECTORS } from './types'; import { buildGrid, getGridOffset } from './gridBuilder'; -import { findPath } from './pathfinder'; +import { findPathWithTurnPenalty } from './pathfinder'; import { simplifyPath, snapPathToGrid, deduplicatePath } from './pathOptimizer'; +import { PORT_CLEARANCE } from './constants'; let waypointIdCounter = 0; @@ -16,10 +18,23 @@ function generateWaypointId(): string { } /** - * Calculate route for a connection, respecting user waypoints + * Calculate clearance point - a point PORT_CLEARANCE away from port in the port's facing direction + */ +function getClearancePoint(portPos: Position, direction: Direction): Position { + const vec = DIRECTION_VECTORS[direction]; + return { + x: portPos.x + vec.x * PORT_CLEARANCE, + y: portPos.y + vec.y * PORT_CLEARANCE + }; +} + +/** + * Calculate route for a connection, respecting user waypoints and enforcing port directions * @param connection - Connection with optional user waypoints * @param sourcePos - Source port world position * @param targetPos - Target port world position + * @param sourceDir - Direction the source port faces (wire exits in this direction) + * @param targetDir - Direction the target port faces (wire enters from opposite direction) * @param context - Routing context with node bounds * @returns Route result with path, waypoints, and segments */ @@ -27,6 +42,8 @@ export function calculateRoute( connection: Connection, sourcePos: Position, targetPos: Position, + sourceDir: Direction, + targetDir: Direction, context: RoutingContext ): RouteResult { // Get user waypoints, sorted by proximity to source @@ -44,15 +61,25 @@ export function calculateRoute( const grid = buildGrid(context, excludeNodes); const offset = getGridOffset(context); - // Build path segments between waypoints + // Calculate clearance points to enforce entry/exit directions + const sourceClearance = getClearancePoint(sourcePos, sourceDir); + // Target clearance: approach from the OPPOSITE direction (wire enters into port) + const targetApproachDir = getOppositeDirection(targetDir); + const targetClearance = getClearancePoint(targetPos, targetApproachDir); + + // Build path: source -> sourceClearance -> [waypoints] -> targetClearance -> target const allPoints: Position[] = []; const allWaypoints: Waypoint[] = []; - let currentPos = sourcePos; + // Start with source clearance segment (always straight out from port) + allPoints.push(sourceClearance); + + let currentPos = sourceClearance; + let currentDir = sourceDir; // Track current direction for turn penalties // Route through each user waypoint for (const userWp of userWaypoints) { - const segmentPath = findPath(currentPos, userWp.position, grid, offset); + const segmentPath = findPathWithTurnPenalty(currentPos, userWp.position, grid, offset, currentDir); const simplified = simplifyPath(segmentPath); // Add intermediate points (skip first which is currentPos) @@ -70,10 +97,15 @@ export function calculateRoute( allPoints.push(userWp.position); allWaypoints.push(userWp); currentPos = userWp.position; + + // Update current direction based on last segment + if (simplified.length >= 2) { + currentDir = getDirectionFromSegment(simplified[simplified.length - 2], simplified[simplified.length - 1]); + } } - // Final segment to target - const finalPath = findPath(currentPos, targetPos, grid, offset); + // Route to target clearance point + const finalPath = findPathWithTurnPenalty(currentPos, targetClearance, grid, offset, currentDir); const simplified = simplifyPath(finalPath); // Add intermediate points from final segment @@ -86,7 +118,10 @@ export function calculateRoute( }); } - // Build complete path: source -> all intermediate points -> target + // Add target clearance point + allPoints.push(targetClearance); + + // Build complete path: source -> clearance -> intermediate -> clearance -> target const fullPath = [sourcePos, ...allPoints, targetPos]; // Snap to grid and deduplicate @@ -102,6 +137,32 @@ export function calculateRoute( }; } +/** + * Get the opposite direction + */ +function getOppositeDirection(dir: Direction): Direction { + switch (dir) { + case 'up': return 'down'; + case 'down': return 'up'; + case 'left': return 'right'; + case 'right': return 'left'; + } +} + +/** + * Get direction from a path segment + */ +function getDirectionFromSegment(from: Position, to: Position): Direction { + const dx = to.x - from.x; + const dy = to.y - from.y; + + if (Math.abs(dx) > Math.abs(dy)) { + return dx > 0 ? 'right' : 'left'; + } else { + return dy > 0 ? 'down' : 'up'; + } +} + /** * Build segment info from path and waypoints */ @@ -137,27 +198,54 @@ function buildSegments(path: Position[], waypoints: Waypoint[]): RouteSegment[] /** * Calculate simple L-shaped or Z-shaped route without pathfinding - * Used as fallback when no obstacles or for performance + * Enforces exit direction from source */ -export function calculateSimpleRoute(sourcePos: Position, targetPos: Position): RouteResult { +export function calculateSimpleRoute( + sourcePos: Position, + targetPos: Position, + sourceDir: Direction = 'right', + targetDir: Direction = 'left' +): RouteResult { const path: Position[] = [sourcePos]; - // Determine if we need an L-shape or Z-shape - const dx = targetPos.x - sourcePos.x; - const dy = targetPos.y - sourcePos.y; - - if (dx !== 0 && dy !== 0) { - // Need a bend - use L-shape (horizontal first, then vertical) - const midPoint = { x: targetPos.x, y: sourcePos.y }; - path.push(midPoint); + // Calculate clearance points + const sourceClearance = getClearancePoint(sourcePos, sourceDir); + const targetApproachDir = getOppositeDirection(targetDir); + const targetClearance = getClearancePoint(targetPos, targetApproachDir); + + // Add source clearance + path.push(sourceClearance); + + // Determine if we need additional bends between clearance points + const dx = targetClearance.x - sourceClearance.x; + const dy = targetClearance.y - sourceClearance.y; + + // Check if clearance points are aligned (straight connection possible) + const isHorizontalAligned = Math.abs(dy) < 1; + const isVerticalAligned = Math.abs(dx) < 1; + + if (!isHorizontalAligned && !isVerticalAligned) { + // Need intermediate point(s) for orthogonal routing + // Prefer routing that matches the source exit direction + if (sourceDir === 'right' || sourceDir === 'left') { + // Exit horizontally, so go horizontal first, then vertical + path.push({ x: targetClearance.x, y: sourceClearance.y }); + } else { + // Exit vertically, so go vertical first, then horizontal + path.push({ x: sourceClearance.x, y: targetClearance.y }); + } } + // Add target clearance and final target + path.push(targetClearance); path.push(targetPos); - const segments = buildSegments(path, []); + // Deduplicate in case clearance points overlap with source/target + const finalPath = deduplicatePath(path); + const segments = buildSegments(finalPath, []); return { - path, + path: finalPath, waypoints: [], segments }; diff --git a/src/lib/routing/types.ts b/src/lib/routing/types.ts index f38af965..f6effb20 100644 --- a/src/lib/routing/types.ts +++ b/src/lib/routing/types.ts @@ -5,6 +5,17 @@ import type { Position } from '$lib/types/common'; import type { Waypoint } from '$lib/types/nodes'; +/** Direction a port faces (for enforcing entry/exit angles) */ +export type Direction = 'up' | 'down' | 'left' | 'right'; + +/** Direction vectors for each direction */ +export const DIRECTION_VECTORS: Record = { + up: { x: 0, y: -1 }, + down: { x: 0, y: 1 }, + left: { x: -1, y: 0 }, + right: { x: 1, y: 0 } +}; + /** Rectangle bounds for obstacle detection */ export interface Bounds { x: number; diff --git a/src/lib/stores/routing.ts b/src/lib/stores/routing.ts index 3d688f5d..c38b4ecc 100644 --- a/src/lib/stores/routing.ts +++ b/src/lib/stores/routing.ts @@ -5,12 +5,18 @@ import { writable, derived, get } from 'svelte/store'; import type { Position } from '$lib/types/common'; import type { Connection, Waypoint } from '$lib/types/nodes'; -import type { RoutingContext, RouteResult, Bounds } from '$lib/routing'; +import type { RoutingContext, RouteResult, Bounds, Direction } from '$lib/routing'; import { calculateRoute, calculateSimpleRoute, ROUTING_MARGIN } from '$lib/routing'; import { generateId } from '$lib/stores/utils'; import { graphStore } from '$lib/stores/graph'; import { historyStore } from '$lib/stores/history'; +/** Port info returned from getPortInfo callback */ +export interface PortInfo { + position: Position; + direction: Direction; +} + interface RoutingState { /** Cached routes by connection ID */ routes: Map; @@ -58,16 +64,18 @@ export const routingStore = { calculateRoute( connection: Connection, sourcePos: Position, - targetPos: Position + targetPos: Position, + sourceDir: Direction = 'right', + targetDir: Direction = 'left' ): RouteResult | null { const $state = get(state); let result: RouteResult; if ($state.context && $state.context.nodeBounds.size > 0) { - result = calculateRoute(connection, sourcePos, targetPos, $state.context); + result = calculateRoute(connection, sourcePos, targetPos, sourceDir, targetDir, $state.context); } else { // No context or empty - use simple routing - result = calculateSimpleRoute(sourcePos, targetPos); + result = calculateSimpleRoute(sourcePos, targetPos, sourceDir, targetDir); } state.update((s) => { @@ -82,27 +90,39 @@ export const routingStore = { /** * Recalculate all routes * @param connections - All connections to route - * @param getPortPosition - Function to get port world position + * @param getPortInfo - Function to get port world position and direction */ recalculateAllRoutes( connections: Connection[], - getPortPosition: (nodeId: string, portIndex: number, isOutput: boolean) => Position | null + getPortInfo: (nodeId: string, portIndex: number, isOutput: boolean) => PortInfo | null ): void { const $state = get(state); const routes = new Map(); for (const conn of connections) { - const sourcePos = getPortPosition(conn.sourceNodeId, conn.sourcePortIndex, true); - const targetPos = getPortPosition(conn.targetNodeId, conn.targetPortIndex, false); + const sourceInfo = getPortInfo(conn.sourceNodeId, conn.sourcePortIndex, true); + const targetInfo = getPortInfo(conn.targetNodeId, conn.targetPortIndex, false); - if (!sourcePos || !targetPos) continue; + if (!sourceInfo || !targetInfo) continue; let result: RouteResult; if ($state.context && $state.context.nodeBounds.size > 0) { - result = calculateRoute(conn, sourcePos, targetPos, $state.context); + result = calculateRoute( + conn, + sourceInfo.position, + targetInfo.position, + sourceInfo.direction, + targetInfo.direction, + $state.context + ); } else { - result = calculateSimpleRoute(sourcePos, targetPos); + result = calculateSimpleRoute( + sourceInfo.position, + targetInfo.position, + sourceInfo.direction, + targetInfo.direction + ); } routes.set(conn.id, result); } From 2009bd94e993735db036d7ff21bc05e370b87c93 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 22:21:06 +0100 Subject: [PATCH 189/656] Fix target port entry direction - clearance point now outside block --- src/lib/routing/routeCalculator.ts | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/src/lib/routing/routeCalculator.ts b/src/lib/routing/routeCalculator.ts index 66eef8c0..4d665745 100644 --- a/src/lib/routing/routeCalculator.ts +++ b/src/lib/routing/routeCalculator.ts @@ -63,9 +63,9 @@ export function calculateRoute( // Calculate clearance points to enforce entry/exit directions const sourceClearance = getClearancePoint(sourcePos, sourceDir); - // Target clearance: approach from the OPPOSITE direction (wire enters into port) - const targetApproachDir = getOppositeDirection(targetDir); - const targetClearance = getClearancePoint(targetPos, targetApproachDir); + // Target clearance: place point outside the block in the direction the port faces + // Wire will travel TO this point, then straight INTO the port + const targetClearance = getClearancePoint(targetPos, targetDir); // Build path: source -> sourceClearance -> [waypoints] -> targetClearance -> target const allPoints: Position[] = []; @@ -137,18 +137,6 @@ export function calculateRoute( }; } -/** - * Get the opposite direction - */ -function getOppositeDirection(dir: Direction): Direction { - switch (dir) { - case 'up': return 'down'; - case 'down': return 'up'; - case 'left': return 'right'; - case 'right': return 'left'; - } -} - /** * Get direction from a path segment */ @@ -210,8 +198,7 @@ export function calculateSimpleRoute( // Calculate clearance points const sourceClearance = getClearancePoint(sourcePos, sourceDir); - const targetApproachDir = getOppositeDirection(targetDir); - const targetClearance = getClearancePoint(targetPos, targetApproachDir); + const targetClearance = getClearancePoint(targetPos, targetDir); // Add source clearance path.push(sourceClearance); From 8ac8b49e1f543ffee3697375bb0ba67289e16842 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 22:24:19 +0100 Subject: [PATCH 190/656] Increase PORT_CLEARANCE to 2G (20px) for visible stubs at port exits/entries --- src/lib/routing/constants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/routing/constants.ts b/src/lib/routing/constants.ts index c9ca7e19..313d3a1b 100644 --- a/src/lib/routing/constants.ts +++ b/src/lib/routing/constants.ts @@ -7,8 +7,8 @@ import { G } from '$lib/constants/grid'; /** Margin around nodes for routing (2 grid units = 20px) */ export const ROUTING_MARGIN = G.x2; -/** Minimum distance from port before first turn (1 grid unit = 10px) */ -export const PORT_CLEARANCE = G.unit; +/** Minimum distance from port before first turn (2 grid units = 20px) */ +export const PORT_CLEARANCE = G.x2; /** Grid resolution for pathfinding (matches base grid = 10px) */ export const GRID_SIZE = G.unit; From af540009d516745c3db0612cca3ff3e160b9954b Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 22:25:16 +0100 Subject: [PATCH 191/656] Fix OrthogonalEdge to preserve route stubs instead of replacing endpoints --- .../components/edges/OrthogonalEdge.svelte | 64 ++++++++++++++++--- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index 38c63552..2e7f0a4e 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -102,21 +102,67 @@ const tgt = adjustedTarget(); if (routeResult?.path && routeResult.path.length >= 2) { - // Use calculated route but adjust first and last points to handle tips - const points = [...routeResult.path]; - points[0] = src; - points[points.length - 1] = tgt; + // Use calculated route - start from handle tip, then follow the route + // The route already has clearance points, so we draw: + // handle tip -> first route point -> ... -> last route point -> handle tip + const points = routeResult.path; - let d = `M ${points[0].x} ${points[0].y}`; - for (let i = 1; i < points.length; i++) { + // Start at handle tip + let d = `M ${src.x} ${src.y}`; + + // Draw through all route points (which include clearance points for proper stubs) + for (let i = 0; i < points.length; i++) { d += ` L ${points[i].x} ${points[i].y}`; } + + // End at handle tip + d += ` L ${tgt.x} ${tgt.y}`; + return d; } - // Fallback: simple L-shape - const midX = tgt.x; - return `M ${src.x} ${src.y} L ${midX} ${src.y} L ${midX} ${tgt.y} L ${tgt.x} ${tgt.y}`; + // Fallback: simple L-shape with stubs + const stubLength = 20; // 2G stub + let d = `M ${src.x} ${src.y}`; + + // Determine stub directions based on handle positions + if (sourcePosition === 'right') { + d += ` L ${src.x + stubLength} ${src.y}`; + } else if (sourcePosition === 'left') { + d += ` L ${src.x - stubLength} ${src.y}`; + } else if (sourcePosition === 'bottom') { + d += ` L ${src.x} ${src.y + stubLength}`; + } else if (sourcePosition === 'top') { + d += ` L ${src.x} ${src.y - stubLength}`; + } + + // Route to target stub + const srcStub = sourcePosition === 'right' ? { x: src.x + stubLength, y: src.y } : + sourcePosition === 'left' ? { x: src.x - stubLength, y: src.y } : + sourcePosition === 'bottom' ? { x: src.x, y: src.y + stubLength } : + { x: src.x, y: src.y - stubLength }; + + const tgtStub = targetPosition === 'right' ? { x: tgt.x + stubLength, y: tgt.y } : + targetPosition === 'left' ? { x: tgt.x - stubLength, y: tgt.y } : + targetPosition === 'bottom' ? { x: tgt.x, y: tgt.y + stubLength } : + { x: tgt.x, y: tgt.y - stubLength }; + + // L-shape between stubs + if (Math.abs(srcStub.y - tgtStub.y) < 1) { + // Horizontally aligned + d += ` L ${tgtStub.x} ${tgtStub.y}`; + } else if (Math.abs(srcStub.x - tgtStub.x) < 1) { + // Vertically aligned + d += ` L ${tgtStub.x} ${tgtStub.y}`; + } else { + // Need a bend + d += ` L ${tgtStub.x} ${srcStub.y} L ${tgtStub.x} ${tgtStub.y}`; + } + + // Final segment to target + d += ` L ${tgt.x} ${tgt.y}`; + + return d; }); // Get user waypoints from route result or data From dee402379b78b8af9a06cc716d6747e64b971e80 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 22:27:03 +0100 Subject: [PATCH 192/656] Block 180-degree turns in pathfinding - only allow max 90-degree turns --- src/lib/routing/pathfinder.ts | 65 +++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/src/lib/routing/pathfinder.ts b/src/lib/routing/pathfinder.ts index 442daea1..58ce3c95 100644 --- a/src/lib/routing/pathfinder.ts +++ b/src/lib/routing/pathfinder.ts @@ -1,5 +1,5 @@ /** - * A* pathfinding with turn penalty + * A* pathfinding with turn penalty and no 180-degree turns */ import PF from 'pathfinding'; @@ -11,6 +11,22 @@ import { GRID_SIZE } from './constants'; /** Cost for making a 90-degree turn (in grid units) */ const TURN_PENALTY = 2; +/** Map direction to its opposite (for blocking 180-degree turns) */ +const OPPOSITE_DIR: Record = { + up: 'down', + down: 'up', + left: 'right', + right: 'left' +}; + +/** Neighbor offsets with their directions */ +const NEIGHBORS: Array<{ dx: number; dy: number; dir: Direction }> = [ + { dx: 1, dy: 0, dir: 'right' }, + { dx: -1, dy: 0, dir: 'left' }, + { dx: 0, dy: 1, dir: 'down' }, + { dx: 0, dy: -1, dir: 'up' } +]; + /** Priority queue node for A* with direction tracking */ interface AStarNode { x: number; @@ -19,16 +35,17 @@ interface AStarNode { h: number; // Heuristic to end f: number; // Total cost (g + h) parent: AStarNode | null; - direction: 'horizontal' | 'vertical' | null; + direction: Direction; // Actual direction we arrived from } /** * Find orthogonal path between two points using A* with turn penalty + * Only allows 90-degree turns (no reversing/180-degree turns) * @param start - Start position in world coordinates * @param end - End position in world coordinates * @param grid - Pathfinding grid (will be cloned) * @param offset - Grid offset (canvas origin) - * @param initialDir - Initial direction of travel (affects first turn penalty) + * @param initialDir - Initial direction of travel * @returns Array of positions in world coordinates */ export function findPathWithTurnPenalty( @@ -67,13 +84,10 @@ export function findPathWithTurnPenalty( walkable.add(`${endGx},${endGy}`); // Initialize open and closed sets + // Key includes direction to allow revisiting with different directions const openSet: AStarNode[] = []; const closedSet = new Map(); - // Determine initial direction type - const initialDirType: 'horizontal' | 'vertical' = - (initialDir === 'left' || initialDir === 'right') ? 'horizontal' : 'vertical'; - // Create start node const startNode: AStarNode = { x: startGx, @@ -82,19 +96,11 @@ export function findPathWithTurnPenalty( h: manhattanDistance(startGx, startGy, endGx, endGy), f: 0, parent: null, - direction: initialDirType + direction: initialDir }; startNode.f = startNode.g + startNode.h; openSet.push(startNode); - // Neighbor offsets (4-directional) - const neighbors = [ - { dx: 1, dy: 0, dir: 'horizontal' as const }, - { dx: -1, dy: 0, dir: 'horizontal' as const }, - { dx: 0, dy: 1, dir: 'vertical' as const }, - { dx: 0, dy: -1, dir: 'vertical' as const } - ]; - while (openSet.length > 0) { // Find node with lowest f score let lowestIdx = 0; @@ -113,40 +119,47 @@ export function findPathWithTurnPenalty( // Move current from open to closed openSet.splice(lowestIdx, 1); - closedSet.set(`${current.x},${current.y}`, current); + const closedKey = `${current.x},${current.y},${current.direction}`; + closedSet.set(closedKey, current); + + // Get the direction we must NOT go (opposite of current direction = 180-degree turn) + const blockedDir = OPPOSITE_DIR[current.direction]; // Explore neighbors - for (const { dx, dy, dir } of neighbors) { + for (const { dx, dy, dir } of NEIGHBORS) { + // Block 180-degree turns (reversing) + if (dir === blockedDir) continue; + const nx = current.x + dx; const ny = current.y + dy; - const key = `${nx},${ny}`; + const posKey = `${nx},${ny}`; // Skip if out of bounds or not walkable if (nx < 0 || nx >= gridWidth || ny < 0 || ny >= gridHeight) continue; - if (!walkable.has(key)) continue; + if (!walkable.has(posKey)) continue; - // Skip if already in closed set - if (closedSet.has(key)) continue; + // Skip if already in closed set with this direction + const neighborClosedKey = `${nx},${ny},${dir}`; + if (closedSet.has(neighborClosedKey)) continue; // Calculate movement cost let moveCost = 1; - // Add turn penalty if direction changes - if (current.direction !== null && current.direction !== dir) { + // Add turn penalty if direction changes (90-degree turn) + if (current.direction !== dir) { moveCost += TURN_PENALTY; } const tentativeG = current.g + moveCost; // Check if this path to neighbor is better - const existingIdx = openSet.findIndex(n => n.x === nx && n.y === ny); + const existingIdx = openSet.findIndex(n => n.x === nx && n.y === ny && n.direction === dir); if (existingIdx !== -1) { if (tentativeG < openSet[existingIdx].g) { // Better path found openSet[existingIdx].g = tentativeG; openSet[existingIdx].f = tentativeG + openSet[existingIdx].h; openSet[existingIdx].parent = current; - openSet[existingIdx].direction = dir; } } else { // New node From 98c38dceb9202c7027bf86de7da97f63152c4432 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 22:31:11 +0100 Subject: [PATCH 193/656] Account for handle offset (0.5G) and arrow inset (0.25G) in port positions --- src/lib/components/FlowCanvas.svelte | 24 +++++++++++++++--------- src/lib/routing/constants.ts | 6 ++++++ src/lib/routing/index.ts | 2 +- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/lib/components/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index debc65b8..2f872a32 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -23,7 +23,7 @@ import { selectedNodeIds as graphSelectedNodeIds } from '$lib/stores/graph/state'; import { historyStore } from '$lib/stores/history'; import { routingStore, buildRoutingContext, type PortInfo } from '$lib/stores/routing'; - import type { Direction } from '$lib/routing'; + import { HANDLE_OFFSET, ARROW_INSET, type Direction } from '$lib/routing'; import { themeStore, type Theme } from '$lib/stores/theme'; import { clearSelectionTrigger, nudgeTrigger, selectNodeTrigger, registerHasSelection, triggerFitView } from '$lib/stores/viewActions'; import { screenToFlow } from '$lib/utils/viewUtils'; @@ -242,6 +242,8 @@ } // Helper to get port position and direction in world coordinates + // Returns handle tip position (accounting for handle offset from block edge) + // For inputs, also accounts for arrowhead so stub starts within arrow function getPortInfo(nodeId: string, portIndex: number, isOutput: boolean): PortInfo | null { const node = nodes.find(n => n.id === nodeId); if (!node) return null; @@ -264,26 +266,30 @@ let y = node.position.y; let direction: Direction; + // Additional offset: handle tip is HANDLE_OFFSET outside block edge + // For inputs (targets), add ARROW_INSET so stub starts within arrowhead + const extraOffset = isOutput ? HANDLE_OFFSET : (HANDLE_OFFSET + ARROW_INSET); + // Position and direction based on rotation (output = right side for rotation 0) if (isOutput) { switch (rotation) { case 1: // outputs at bottom x += offsetFromCenter; - y += height / 2; + y += height / 2 + extraOffset; direction = 'down'; break; case 2: // outputs at left - x -= width / 2; + x -= width / 2 + extraOffset; y += offsetFromCenter; direction = 'left'; break; case 3: // outputs at top x += offsetFromCenter; - y -= height / 2; + y -= height / 2 + extraOffset; direction = 'up'; break; default: // rotation 0 - outputs at right - x += width / 2; + x += width / 2 + extraOffset; y += offsetFromCenter; direction = 'right'; break; @@ -293,21 +299,21 @@ switch (rotation) { case 1: // inputs at top x += offsetFromCenter; - y -= height / 2; + y -= height / 2 + extraOffset; direction = 'up'; break; case 2: // inputs at right - x += width / 2; + x += width / 2 + extraOffset; y += offsetFromCenter; direction = 'right'; break; case 3: // inputs at bottom x += offsetFromCenter; - y += height / 2; + y += height / 2 + extraOffset; direction = 'down'; break; default: // rotation 0 - inputs at left - x -= width / 2; + x -= width / 2 + extraOffset; y += offsetFromCenter; direction = 'left'; break; diff --git a/src/lib/routing/constants.ts b/src/lib/routing/constants.ts index 313d3a1b..7016b552 100644 --- a/src/lib/routing/constants.ts +++ b/src/lib/routing/constants.ts @@ -12,3 +12,9 @@ export const PORT_CLEARANCE = G.x2; /** Grid resolution for pathfinding (matches base grid = 10px) */ export const GRID_SIZE = G.unit; + +/** Handle tip offset from block edge (0.5 grid units = 5px) */ +export const HANDLE_OFFSET = G.unit / 2; + +/** Arrow head length - stub should start within arrowhead (0.25 grid units = 2.5px) */ +export const ARROW_INSET = G.unit / 4; diff --git a/src/lib/routing/index.ts b/src/lib/routing/index.ts index 75f26e96..07299bdb 100644 --- a/src/lib/routing/index.ts +++ b/src/lib/routing/index.ts @@ -6,6 +6,6 @@ export { calculateRoute, calculateSimpleRoute } from './routeCalculator'; export { buildGrid, worldToGrid, gridToWorld, getGridOffset } from './gridBuilder'; export { findPath, findPathWithTurnPenalty } from './pathfinder'; export { simplifyPath, snapToGrid, snapPathToGrid, deduplicatePath } from './pathOptimizer'; -export { ROUTING_MARGIN, PORT_CLEARANCE, GRID_SIZE } from './constants'; +export { ROUTING_MARGIN, PORT_CLEARANCE, GRID_SIZE, HANDLE_OFFSET, ARROW_INSET } from './constants'; export type { Bounds, RoutingContext, RouteSegment, RouteResult, Direction } from './types'; export { DIRECTION_VECTORS } from './types'; From 26e2471c700a4f273746bb46813e7f9e19d8a039 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 22:32:29 +0100 Subject: [PATCH 194/656] Reduce stub length to 1G (10px) now that handle offsets are accounted for --- src/lib/components/edges/OrthogonalEdge.svelte | 2 +- src/lib/routing/constants.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index 2e7f0a4e..0a93b908 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -122,7 +122,7 @@ } // Fallback: simple L-shape with stubs - const stubLength = 20; // 2G stub + const stubLength = 10; // 1G stub let d = `M ${src.x} ${src.y}`; // Determine stub directions based on handle positions diff --git a/src/lib/routing/constants.ts b/src/lib/routing/constants.ts index 7016b552..1e2d8e2d 100644 --- a/src/lib/routing/constants.ts +++ b/src/lib/routing/constants.ts @@ -7,8 +7,8 @@ import { G } from '$lib/constants/grid'; /** Margin around nodes for routing (2 grid units = 20px) */ export const ROUTING_MARGIN = G.x2; -/** Minimum distance from port before first turn (2 grid units = 20px) */ -export const PORT_CLEARANCE = G.x2; +/** Minimum distance from port before first turn (1 grid unit = 10px) */ +export const PORT_CLEARANCE = G.unit; /** Grid resolution for pathfinding (matches base grid = 10px) */ export const GRID_SIZE = G.unit; From 697e02df97ca2450c5ac2b1f152de5c3fb857a2f Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 22:35:51 +0100 Subject: [PATCH 195/656] Separate source (0) and target (1G) clearances for wire routing --- src/lib/routing/constants.ts | 7 ++-- src/lib/routing/index.ts | 2 +- src/lib/routing/routeCalculator.ts | 51 ++++++++++++++++++------------ 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/lib/routing/constants.ts b/src/lib/routing/constants.ts index 1e2d8e2d..b5cf5799 100644 --- a/src/lib/routing/constants.ts +++ b/src/lib/routing/constants.ts @@ -7,8 +7,11 @@ import { G } from '$lib/constants/grid'; /** Margin around nodes for routing (2 grid units = 20px) */ export const ROUTING_MARGIN = G.x2; -/** Minimum distance from port before first turn (1 grid unit = 10px) */ -export const PORT_CLEARANCE = G.unit; +/** Minimum distance from source port before first turn (0 - handle offset is enough) */ +export const SOURCE_CLEARANCE = 0; + +/** Minimum distance from target port before first turn (1 grid unit = 10px) */ +export const TARGET_CLEARANCE = G.unit; /** Grid resolution for pathfinding (matches base grid = 10px) */ export const GRID_SIZE = G.unit; diff --git a/src/lib/routing/index.ts b/src/lib/routing/index.ts index 07299bdb..3fe18996 100644 --- a/src/lib/routing/index.ts +++ b/src/lib/routing/index.ts @@ -6,6 +6,6 @@ export { calculateRoute, calculateSimpleRoute } from './routeCalculator'; export { buildGrid, worldToGrid, gridToWorld, getGridOffset } from './gridBuilder'; export { findPath, findPathWithTurnPenalty } from './pathfinder'; export { simplifyPath, snapToGrid, snapPathToGrid, deduplicatePath } from './pathOptimizer'; -export { ROUTING_MARGIN, PORT_CLEARANCE, GRID_SIZE, HANDLE_OFFSET, ARROW_INSET } from './constants'; +export { ROUTING_MARGIN, SOURCE_CLEARANCE, TARGET_CLEARANCE, GRID_SIZE, HANDLE_OFFSET, ARROW_INSET } from './constants'; export type { Bounds, RoutingContext, RouteSegment, RouteResult, Direction } from './types'; export { DIRECTION_VECTORS } from './types'; diff --git a/src/lib/routing/routeCalculator.ts b/src/lib/routing/routeCalculator.ts index 4d665745..7654583c 100644 --- a/src/lib/routing/routeCalculator.ts +++ b/src/lib/routing/routeCalculator.ts @@ -9,7 +9,7 @@ import { DIRECTION_VECTORS } from './types'; import { buildGrid, getGridOffset } from './gridBuilder'; import { findPathWithTurnPenalty } from './pathfinder'; import { simplifyPath, snapPathToGrid, deduplicatePath } from './pathOptimizer'; -import { PORT_CLEARANCE } from './constants'; +import { SOURCE_CLEARANCE, TARGET_CLEARANCE } from './constants'; let waypointIdCounter = 0; @@ -18,13 +18,14 @@ function generateWaypointId(): string { } /** - * Calculate clearance point - a point PORT_CLEARANCE away from port in the port's facing direction + * Calculate clearance point - a point at given distance from port in the port's facing direction */ -function getClearancePoint(portPos: Position, direction: Direction): Position { +function getClearancePoint(portPos: Position, direction: Direction, clearance: number): Position { + if (clearance === 0) return portPos; const vec = DIRECTION_VECTORS[direction]; return { - x: portPos.x + vec.x * PORT_CLEARANCE, - y: portPos.y + vec.y * PORT_CLEARANCE + x: portPos.x + vec.x * clearance, + y: portPos.y + vec.y * clearance }; } @@ -62,19 +63,22 @@ export function calculateRoute( const offset = getGridOffset(context); // Calculate clearance points to enforce entry/exit directions - const sourceClearance = getClearancePoint(sourcePos, sourceDir); + const sourceClearance = getClearancePoint(sourcePos, sourceDir, SOURCE_CLEARANCE); // Target clearance: place point outside the block in the direction the port faces // Wire will travel TO this point, then straight INTO the port - const targetClearance = getClearancePoint(targetPos, targetDir); + const targetClearance = getClearancePoint(targetPos, targetDir, TARGET_CLEARANCE); // Build path: source -> sourceClearance -> [waypoints] -> targetClearance -> target const allPoints: Position[] = []; const allWaypoints: Waypoint[] = []; - // Start with source clearance segment (always straight out from port) - allPoints.push(sourceClearance); + // Start with source clearance segment (if clearance > 0) + if (SOURCE_CLEARANCE > 0) { + allPoints.push(sourceClearance); + } - let currentPos = sourceClearance; + // Start pathfinding from source clearance (or source if no clearance) + let currentPos = SOURCE_CLEARANCE > 0 ? sourceClearance : sourcePos; let currentDir = sourceDir; // Track current direction for turn penalties // Route through each user waypoint @@ -197,17 +201,22 @@ export function calculateSimpleRoute( const path: Position[] = [sourcePos]; // Calculate clearance points - const sourceClearance = getClearancePoint(sourcePos, sourceDir); - const targetClearance = getClearancePoint(targetPos, targetDir); + const sourceClearance = getClearancePoint(sourcePos, sourceDir, SOURCE_CLEARANCE); + const targetClearance = getClearancePoint(targetPos, targetDir, TARGET_CLEARANCE); + + // Add source clearance (if any) + if (SOURCE_CLEARANCE > 0) { + path.push(sourceClearance); + } - // Add source clearance - path.push(sourceClearance); + // Use appropriate start point for routing calculations + const routeStart = SOURCE_CLEARANCE > 0 ? sourceClearance : sourcePos; // Determine if we need additional bends between clearance points - const dx = targetClearance.x - sourceClearance.x; - const dy = targetClearance.y - sourceClearance.y; + const dx = targetClearance.x - routeStart.x; + const dy = targetClearance.y - routeStart.y; - // Check if clearance points are aligned (straight connection possible) + // Check if points are aligned (straight connection possible) const isHorizontalAligned = Math.abs(dy) < 1; const isVerticalAligned = Math.abs(dx) < 1; @@ -216,15 +225,17 @@ export function calculateSimpleRoute( // Prefer routing that matches the source exit direction if (sourceDir === 'right' || sourceDir === 'left') { // Exit horizontally, so go horizontal first, then vertical - path.push({ x: targetClearance.x, y: sourceClearance.y }); + path.push({ x: targetClearance.x, y: routeStart.y }); } else { // Exit vertically, so go vertical first, then horizontal - path.push({ x: sourceClearance.x, y: targetClearance.y }); + path.push({ x: routeStart.x, y: targetClearance.y }); } } // Add target clearance and final target - path.push(targetClearance); + if (TARGET_CLEARANCE > 0) { + path.push(targetClearance); + } path.push(targetPos); // Deduplicate in case clearance points overlap with source/target From 21ed0075c41b9032d1dfd10f417263262ff7f166 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 22:42:59 +0100 Subject: [PATCH 196/656] Fix extraction to use installed PathSim instead of outdated local source --- scripts/extract.py | 8 - src/lib/nodes/generated/blocks.ts | 415 ++++++++++++++++-------------- 2 files changed, 223 insertions(+), 200 deletions(-) diff --git a/scripts/extract.py b/scripts/extract.py index 467fee94..9b7f08ff 100644 --- a/scripts/extract.py +++ b/scripts/extract.py @@ -24,14 +24,6 @@ from typing import Any -# ============================================================================= -# PathSim Path Discovery -# ============================================================================= - -PATHSIM_PATH = Path(__file__).parent.parent.parent / "pathsim" / "src" -if PATHSIM_PATH.exists(): - sys.path.insert(0, str(PATHSIM_PATH)) - # Optional docutils for RST to HTML conversion try: from docutils.core import publish_parts diff --git a/src/lib/nodes/generated/blocks.ts b/src/lib/nodes/generated/blocks.ts index dc207261..68e6e69a 100644 --- a/src/lib/nodes/generated/blocks.ts +++ b/src/lib/nodes/generated/blocks.ts @@ -23,8 +23,8 @@ export const extractedBlocks: Record = { "Constant": { "blockClass": "Constant", - "description": "Produces a constant output signal (SISO)", - "docstringHtml": "

        Produces a constant output signal (SISO)

        \n
        \n

        Parameters

        \n
        \n
        value : float
        \n
        constant defining block output
        \n
        \n
        \n", + "description": "Produces a constant output signal (SISO).", + "docstringHtml": "

        Produces a constant output signal (SISO).

        \n
        \n\\begin{equation*}\ny(t) = const.\n\\end{equation*}\n
        \n
        \n

        Parameters

        \n
        \n
        value : float
        \n
        constant defining block output
        \n
        \n
        \n", "params": { "value": { "type": "integer", @@ -39,8 +39,8 @@ export const extractedBlocks: Record = }, "Source": { "blockClass": "Source", - "description": "Source that produces an arbitrary time dependent output,", - "docstringHtml": "

        Source that produces an arbitrary time dependent output,\ndefined by the func (callable).

        \n
        \n\\begin{equation*}\ny(t) = \\mathrm{func}(t)\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its internal function (func) will\nbe called multiple times per timestep, each time when Simulation._update(t)\nis called in the global simulation loop.

        \n
        \n
        \n

        Example

        \n

        For example a ramp:

        \n
        \nfrom pathsim.blocks import Source\n\nsrc = Source(lambda t : t)\n
        \n

        or a simple sinusoid with some frequency:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import Source\n\n#some parameter\nomega = 100\n\n#the function that gets evaluated\ndef f(t):\n    return np.sin(omega * t)\n\nsrc = Source(f)\n
        \n

        Because the Source block only has a single argument, it can be\nused to decorate a function and make it a PathSim block. This might\nbe handy in some cases to keep definitions concise and localized\nin the code:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import Source\n\n#does the same as the definition above\n\n@Source\ndef src(t):\n    omega = 100\n    return np.sin(omega * t)\n\n#'src' is now a PathSim block\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        function defining time dependent block output
        \n
        \n
        \n", + "description": "Source that produces an arbitrary time dependent output defined by `func` (callable).", + "docstringHtml": "

        Source that produces an arbitrary time dependent output defined by func (callable).

        \n
        \n\\begin{equation*}\ny(t) = \\mathrm{func}(t)\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its internal function (func) will\nbe called multiple times per timestep, each time when Simulation._update(t)\nis called in the global simulation loop.

        \n
        \n
        \n

        Example

        \n

        For example a ramp:

        \n
        \nfrom pathsim.blocks import Source\n\nsrc = Source(lambda t : t)\n
        \n

        or a simple sinusoid with some frequency:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import Source\n\n#some parameter\nomega = 100\n\n#the function that gets evaluated\ndef f(t):\n    return np.sin(omega * t)\n\nsrc = Source(f)\n
        \n

        Because the Source block only has a single argument, it can be\nused to decorate a function and make it a PathSim block. This might\nbe handy in some cases to keep definitions concise and localized\nin the code:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import Source\n\n#does the same as the definition above\n\n@Source\ndef src(t):\n    omega = 100\n    return np.sin(omega * t)\n\n#'src' is now a PathSim block\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        function defining time dependent block output
        \n
        \n
        \n", "params": { "func": { "type": "callable", @@ -81,8 +81,8 @@ export const extractedBlocks: Record = }, "StepSource": { "blockClass": "StepSource", - "description": "Discrete time unit step source block.", - "docstringHtml": "

        Discrete time unit step source block.

        \n

        Utilizes a scheduled event to set the block output\nto the specified output levels at the defined event times.

        \n

        The arguments can be vectorial and in that case, the output is set to the\namplitude that corresponds to the defined delay like a zero-order-hold stage.\nThis functionality enables adding external or time series measurement data\ninto the system.

        \n
        \n

        Examples

        \n

        This is how to use the source as a unit step source:

        \n
        \nfrom pathsim.blocks import StepSource\n\n#default, starts at 0, jumps to 1\nstp = StepSource()\n
        \n

        And this is how to configure it with multiple consecutive steps:

        \n
        \nfrom pathsim.blocks import StepSource\n\n#starts at 0, jumps to 1 at 1, jumps to -1 at 2 and jumps back to 0 at 3\nstp = StepSource(amplitude=[1, -1, 0], tau=[1, 2, 3])\n
        \n

        Similarly implementing measured time series data via zoh:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import StepSource\n\n#some random time series arrays\ntimes, data = np.linspace(0, 100, 1000), np.random.rand(1000)\n\n#pass them to the block\nstp = StepSource(amplitude=data, tau=times)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        amplitude : float | list[float]
        \n
        amplitude of the step signal, or amplitudes / output\nlevels of the multiple steps
        \n
        tau : float | list[float]
        \n
        delay of the step, or delays of the different steps
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        Evt : ScheduleList
        \n
        internal scheduled event directly accessible
        \n
        events : list[ScheduleList]
        \n
        list of interna events
        \n
        \n
        \n", + "description": "Discrete time unit step (or multi step) source block.", + "docstringHtml": "

        Discrete time unit step (or multi step) source block.

        \n

        Utilizes a scheduled event to set the block output\nto the specified output levels at the defined event times.

        \n

        The arguments can be vectorial and in that case, the output is set to the\namplitude that corresponds to the defined delay like a zero-order-hold stage.\nThis functionality enables adding external or time series measurement data\ninto the system.

        \n
        \n

        Examples

        \n

        This is how to use the source as a unit step source:

        \n
        \nfrom pathsim.blocks import StepSource\n\n#default, starts at 0, jumps to 1\nstp = StepSource()\n
        \n

        And this is how to configure it with multiple consecutive steps:

        \n
        \nfrom pathsim.blocks import StepSource\n\n#starts at 0, jumps to 1 at 1, jumps to -1 at 2 and jumps back to 0 at 3\nstp = StepSource(amplitude=[1, -1, 0], tau=[1, 2, 3])\n
        \n

        Similarly implementing measured time series data via zoh:

        \n
        \nimport numpy as np\nfrom pathsim.blocks import StepSource\n\n#some random time series arrays\ntimes, data = np.linspace(0, 100, 1000), np.random.rand(1000)\n\n#pass them to the block\nstp = StepSource(amplitude=data, tau=times)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        amplitude : float | list[float]
        \n
        amplitude of the step signal, or amplitudes / output\nlevels of the multiple steps
        \n
        tau : float | list[float]
        \n
        delay of the step, or delays of the different steps
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        Evt : ScheduleList
        \n
        internal scheduled event directly accessible
        \n
        events : list[ScheduleList]
        \n
        list of interna events
        \n
        \n
        \n", "params": { "amplitude": { "type": "integer", @@ -102,8 +102,8 @@ export const extractedBlocks: Record = }, "PulseSource": { "blockClass": "PulseSource", - "description": "Generates a periodic pulse waveform with defined rise and fall times", - "docstringHtml": "

        Generates a periodic pulse waveform with defined rise and fall times\nusing a hybrid approach with scheduled events and continuous updates.

        \n

        Scheduled events trigger phase changes (low, rising, high, falling),\nand the update method calculates the output value based on the\ncurrent phase, performing linear interpolation during rise and fall.

        \n
        \n

        Parameters

        \n
        \n
        amplitude : float, optional
        \n
        Peak amplitude of the pulse. Default is 1.0.
        \n
        T : float, optional
        \n
        Period of the pulse train. Must be positive. Default is 1.0.
        \n
        t_rise : float, optional
        \n
        Duration of the rising edge. Default is 0.0.
        \n
        t_fall : float, optional
        \n
        Duration of the falling edge. Default is 0.0.
        \n
        tau : float, optional
        \n
        Initial delay before the first pulse cycle begins. Default is 0.0.
        \n
        duty : float, optional
        \n
        Duty cycle, ratio of the pulse ON duration (plateau time only)\nto the total period T (must be between 0 and 1). Default is 0.5.\nThe high plateau duration is T * duty.
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        events : list[Schedule]
        \n
        Internal scheduled events triggering phase transitions.
        \n
        _phase : str
        \n
        Current phase of the pulse ('low', 'rising', 'high', 'falling').
        \n
        _phase_start_time : float
        \n
        Simulation time when the current phase began.
        \n
        \n
        \n", + "description": "Generates a periodic pulse waveform with defined rise and fall times.", + "docstringHtml": "

        Generates a periodic pulse waveform with defined rise and fall times.

        \n

        Scheduled events trigger phase changes (low, rising, high, falling),\nand the update method calculates the output value based on the\ncurrent phase, performing linear interpolation during rise and fall.

        \n
        \n

        Parameters

        \n
        \n
        amplitude : float, optional
        \n
        Peak amplitude of the pulse. Default is 1.0.
        \n
        T : float, optional
        \n
        Period of the pulse train. Must be positive. Default is 1.0.
        \n
        t_rise : float, optional
        \n
        Duration of the rising edge. Default is 0.0.
        \n
        t_fall : float, optional
        \n
        Duration of the falling edge. Default is 0.0.
        \n
        tau : float, optional
        \n
        Initial delay before the first pulse cycle begins. Default is 0.0.
        \n
        duty : float, optional
        \n
        Duty cycle, ratio of the pulse ON duration (plateau time only)\nto the total period T (must be between 0 and 1). Default is 0.5.\nThe high plateau duration is T * duty.
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        events : list[Schedule]
        \n
        Internal scheduled events triggering phase transitions.
        \n
        _phase : str
        \n
        Current phase of the pulse ('low', 'rising', 'high', 'falling').
        \n
        _phase_start_time : float
        \n
        Simulation time when the current phase began.
        \n
        \n
        \n", "params": { "amplitude": { "type": "number", @@ -222,7 +222,7 @@ export const extractedBlocks: Record = "ChirpPhaseNoiseSource": { "blockClass": "ChirpPhaseNoiseSource", "description": "Chirp source, sinusoid with frequency ramp up and ramp down, plus phase noise.", - "docstringHtml": "

        Chirp source, sinusoid with frequency ramp up and ramp down, plus phase noise.

        \n

        This works by using a time dependent triangle wave for the frequency\nand integrating it with a numerical integration engine to get a\ncontinuous phase. This phase is then used to evaluate a sinusoid.

        \n

        Additionally the chirp source can have white and cumulative phase noise.\nMathematically it looks like this for the contributions to the phase from\nthe triangular wave:

        \n
        \n\\begin{equation*}\n\\varphi_t(t) = \\int_0^t \\mathrm{tri}_{f_0, B, T}(\\tau) \\, d\\tau\n\\end{equation*}\n
        \n

        And from the white (w) and cumulative (c) noise:

        \n
        \n\\begin{equation*}\n\\varphi_n(t) = \\sigma_w \\, n_w(t) + \\sigma_c \\int_0^t n_c(\\tau) \\, d\\tau\n\\end{equation*}\n
        \n

        The phase contributions are then used to evaluate a sinusoid to get the final chirp signal:

        \n
        \n\\begin{equation*}\ny(t) = A \\sin(\\varphi_t(t) + \\varphi_n(t) + \\varphi_0)\n\\end{equation*}\n
        \n
        \n

        Parameters

        \n
        \n
        amplitude : float
        \n
        amplitude of the chirp signal
        \n
        f0 : float
        \n
        start frequency of the chirp signal
        \n
        BW : float
        \n
        bandwidth of the frequency ramp of the chirp signal
        \n
        T : float
        \n
        period of the frequency ramp of the chirp signal
        \n
        phase : float
        \n
        phase of sinusoid (initial, radians)
        \n
        sig_cum : float
        \n
        weight for cumulative phase noise contribution
        \n
        sig_white : float
        \n
        weight for white phase noise contribution
        \n
        sampling_rate : float, None
        \n
        frequency with which phase noise is sampled (Hz). If None,\nnoise is sampled every timestep (default is 10 Hz)
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        noise_1 : float
        \n
        internal noise value for white phase noise
        \n
        noise_2 : float
        \n
        internal noise value for cumulative phase noise
        \n
        events : list[Schedule]
        \n
        scheduled event for periodic sampling (only if sampling_rate is set)
        \n
        \n
        \n", + "docstringHtml": "

        Chirp source, sinusoid with frequency ramp up and ramp down, plus phase noise.

        \n

        This works by using a time dependent triangle wave for the frequency\nand integrating it with a numerical integration engine to get a\ncontinuous phase. This phase is then used to evaluate a sinusoid.

        \n

        Additionally the chirp source can have white and cumulative phase noise.\nMathematically it looks like this for the contributions to the phase from\nthe triangular wave:

        \n
        \n\\begin{equation*}\n\\varphi_t(t) = \\int_0^t \\mathrm{tri}_{f_0, B, T}(\\tau) \\, d\\tau\n\\end{equation*}\n
        \n

        And from the white (w) and cumulative (c) noise:

        \n
        \n\\begin{equation*}\n\\varphi_n(t) = \\sigma_w \\, n_w(t) + \\sigma_c \\int_0^t n_c(\\tau) \\, d\\tau\n\\end{equation*}\n
        \n

        The phase contributions are then used to evaluate a sinusoid to get the final chirp signal:

        \n
        \n\\begin{equation*}\ny(t) = A \\sin(\\varphi_t(t) + \\varphi_n(t) + \\varphi_0)\n\\end{equation*}\n
        \n
        \n

        Parameters

        \n
        \n
        amplitude : float
        \n
        amplitude of the chirp signal
        \n
        f0 : float
        \n
        start frequency of the chirp signal
        \n
        BW : float
        \n
        bandwidth of the frequency ramp of the chirp signal
        \n
        T : float
        \n
        period of the frequency ramp of the chirp signal
        \n
        phase : float
        \n
        phase of sinusoid (initial, radians)
        \n
        sig_cum : float
        \n
        weight for cumulative phase noise contribution
        \n
        sig_white : float
        \n
        weight for white phase noise contribution
        \n
        sampling_period : float, None
        \n
        time between phase noise samples. If None,\nnoise is sampled every timestep (default is 0.1)
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        noise_1 : float
        \n
        internal noise value for white phase noise
        \n
        noise_2 : float
        \n
        internal noise value for cumulative phase noise
        \n
        events : list[Schedule]
        \n
        scheduled event for periodic sampling (only if sampling_period is set)
        \n
        \n
        \n", "params": { "amplitude": { "type": "integer", @@ -259,10 +259,10 @@ export const extractedBlocks: Record = "default": "0", "description": "weight for white phase noise contribution" }, - "sampling_rate": { - "type": "integer", - "default": "10", - "description": "frequency with which phase noise is sampled (Hz). If None, noise is sampled every timestep (default is 10 Hz) Attributes ----------" + "sampling_period": { + "type": "number", + "default": "0.1", + "description": "time between phase noise samples. If None, noise is sampled every timestep (default is 0.1)" } }, "inputs": [], @@ -293,18 +293,28 @@ export const extractedBlocks: Record = }, "WhiteNoise": { "blockClass": "WhiteNoise", - "description": "White noise source with uniform spectral density.", - "docstringHtml": "

        White noise source with uniform spectral density. Samples from\ndistribution with 'sampling_rate' and holds noise values constant\nfor time bins (zero-order-hold).

        \n

        If no 'sampling_rate' (None) is specified, every simulation timestep\ngets a new noise value. This is the default setting.

        \n
        \n

        Parameters

        \n
        \n
        spectral_density : float
        \n
        noise spectral density
        \n
        noise : float
        \n
        internal noise value
        \n
        sampling_rate : float, None
        \n
        frequency with which the noise is sampled
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        events : list[Schedule]
        \n
        scheduled event for periodic sampling
        \n
        \n
        \n", + "description": "White noise source with Gaussian distribution.", + "docstringHtml": "

        White noise source with Gaussian distribution.

        \n

        Generates uncorrelated random samples with either constant amplitude\n(standard_deviation mode) or timestep-scaled amplitude for stochastic\nintegration (spectral_density mode).

        \n

        In spectral density mode, output is scaled as √(S₀/dt) so that integrating\nthe noise yields correct statistical properties (Wiener process).

        \n
        \n

        Note

        \n

        If spectral_density is provided, it takes precedence over standard_deviation.\nIf sampling_period is set, noise is sampled at fixed intervals (zero-order hold).

        \n
        \n
        \n

        Parameters

        \n
        \n
        standard_deviation : float
        \n
        output standard deviation for constant-amplitude mode (default: 1.0)
        \n
        spectral_density : float, optional
        \n
        power spectral density S₀ in [signal²/Hz]
        \n
        sampling_period : float, optional
        \n
        time between samples, if None samples every timestep
        \n
        seed : int, optional
        \n
        random seed for reproducibility
        \n
        \n
        \n", "params": { + "standard_deviation": { + "type": "number", + "default": "1.0", + "description": "output standard deviation for constant-amplitude mode (default: 1.0)" + }, "spectral_density": { - "type": "integer", - "default": "1", - "description": "noise spectral density" + "type": "any", + "default": null, + "description": "power spectral density S₀ in [signal²/Hz]" + }, + "sampling_period": { + "type": "any", + "default": null, + "description": "time between samples, if None samples every timestep" }, - "sampling_rate": { + "seed": { "type": "any", "default": null, - "description": "frequency with which the noise is sampled" + "description": "random seed for reproducibility" } }, "inputs": [], @@ -314,23 +324,33 @@ export const extractedBlocks: Record = }, "PinkNoise": { "blockClass": "PinkNoise", - "description": "Pink noise (1/f) source using the Voss-McCartney algorithm.", - "docstringHtml": "

        Pink noise (1/f) source using the Voss-McCartney algorithm.

        \n

        Generates noise with power spectral density inversely proportional to\nfrequency. Samples from distribution with 'sampling_rate' and holds\nnoise values constant for time bins (zero-order-hold).

        \n

        The Voss-McCartney algorithm maintains num_octaves independent\nrandom values. At each sample n, octaves are selectively updated based\non the binary representation of n:

        \n
          \n
        • Octave 0: updated every sample (when n & 1 == 1)
        • \n
        • Octave 1: updated every 2nd sample (when n & 2 == 2)
        • \n
        • Octave 2: updated every 4th sample (when n & 4 == 4)
        • \n
        • Octave k: updated every \\(2^k\\) samples
        • \n
        \n

        The pink noise output is the sum of all octaves, scaled to achieve the\ndesired spectral density:

        \n
        \n\\begin{equation*}\ny[n] = \\sqrt{\\frac{S_0}{N \\cdot dt}} \\sum_{k=0}^{N-1} x_k[n]\n\\end{equation*}\n
        \n

        where \\(S_0\\) is the spectral density, \\(N\\) is num_octaves,\n\\(dt\\) is the sampling timestep, and \\(x_k[n]\\) are the octave\nvalues (each drawn from \\(\\mathcal{N}(0, 1)\\) when updated).

        \n
        \n

        Note

        \n

        If no 'sampling_rate' (None) is specified, every simulation timestep\ngets a new noise value. This is the default setting.

        \n
        \n
        \n

        Parameters

        \n
        \n
        spectral_density : float
        \n
        noise spectral density \\(S_0\\)
        \n
        num_octaves : int
        \n
        number of octaves (levels of randomness), default is 16
        \n
        sampling_rate : float, None
        \n
        frequency with which the noise is sampled
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        n_samples : int
        \n
        internal sample counter
        \n
        octave_values : array[float]
        \n
        internal random numbers for octaves in the Voss-McCartney algorithm
        \n
        events : list[Schedule]
        \n
        scheduled event for periodic sampling
        \n
        \n
        \n
        \n

        References

        \n\n\n\n\n\n
        [1]Voss, R. F., & Clarke, J. (1978). "1/f noise" in music: Music from\n1/f noise. The Journal of the Acoustical Society of America, 63(1),\n258-263.
        \n
        \n", + "description": "Pink noise (1/f noise) source using the Voss-McCartney algorithm.", + "docstringHtml": "

        Pink noise (1/f noise) source using the Voss-McCartney algorithm.

        \n

        Generates noise with power spectral density proportional to 1/f, where\nlower frequencies have more power than higher frequencies.

        \n

        The algorithm maintains num_octaves independent random values representing\ndifferent frequency bands. At each sample, one octave is updated based on the\nbinary representation of the sample counter, creating the characteristic 1/f\nspectrum through the superposition of different update rates.

        \n
        \n

        Note

        \n

        If spectral_density is provided, it takes precedence over standard_deviation.\nIf sampling_period is set, noise is sampled at fixed intervals (zero-order hold).

        \n
        \n
        \n

        Parameters

        \n
        \n
        standard_deviation : float
        \n
        approximate output standard deviation (default: 1.0)
        \n
        spectral_density : float, optional
        \n
        power spectral density, output scaled as √(S₀/(N·dt))
        \n
        num_octaves : int
        \n
        number of frequency bands in algorithm (default: 16)
        \n
        sampling_period : float, optional
        \n
        time between samples, if None samples every timestep
        \n
        seed : int, optional
        \n
        random seed for reproducibility
        \n
        \n
        \n", "params": { + "standard_deviation": { + "type": "number", + "default": "1.0", + "description": "approximate output standard deviation (default: 1.0)" + }, "spectral_density": { - "type": "integer", - "default": "1", - "description": "noise spectral density :math:`S_0`" + "type": "any", + "default": null, + "description": "power spectral density, output scaled as √(S₀/(N·dt))" }, "num_octaves": { "type": "integer", "default": "16", - "description": "number of octaves (levels of randomness), default is 16" + "description": "number of frequency bands in algorithm (default: 16)" + }, + "sampling_period": { + "type": "any", + "default": null, + "description": "time between samples, if None samples every timestep" }, - "sampling_rate": { + "seed": { "type": "any", "default": null, - "description": "frequency with which the noise is sampled" + "description": "random seed for reproducibility" } }, "inputs": [], @@ -341,12 +361,12 @@ export const extractedBlocks: Record = "RandomNumberGenerator": { "blockClass": "RandomNumberGenerator", "description": "Generates a random output value using `numpy.random.rand`.", - "docstringHtml": "

        Generates a random output value using numpy.random.rand.

        \n

        If no sampling_rate (None) is specified, every simulation timestep gets\na random value. Otherwise an internal Schedule event is used to periodically\nsample a random value and set the output like a sero-order-hold stage.

        \n
        \n

        Parameters

        \n
        \n
        sampling_rate : float, None
        \n
        number of random samples per time unit
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        _sample : float
        \n
        internal random number state in case that\nno samplingrate is provided
        \n
        Evt : Schedule
        \n
        internal event that periodically samples a random\nvalue in case samplingrate is provided
        \n
        \n
        \n", + "docstringHtml": "

        Generates a random output value using numpy.random.rand.

        \n

        If no sampling_period (None) is specified, every simulation timestep gets\na random value. Otherwise an internal Schedule event is used to periodically\nsample a random value and set the output like a zero-order-hold stage.

        \n
        \n

        Parameters

        \n
        \n
        sampling_period : float, None
        \n
        time between random samples
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        _sample : float
        \n
        internal random number state in case that\nno sampling_period is provided
        \n
        Evt : Schedule
        \n
        internal event that periodically samples a random\nvalue in case sampling_period is provided
        \n
        \n
        \n", "params": { - "sampling_rate": { + "sampling_period": { "type": "any", "default": null, - "description": "number of random samples per time unit" + "description": "time between random samples" } }, "inputs": [], @@ -356,8 +376,8 @@ export const extractedBlocks: Record = }, "Integrator": { "blockClass": "Integrator", - "description": "Integrates the input signal using a numerical integration engine like this:", - "docstringHtml": "

        Integrates the input signal using a numerical integration engine like this:

        \n
        \n\\begin{equation*}\ny(t) = \\int_0^t u(\\tau) \\ d \\tau\n\\end{equation*}\n
        \n

        or in differential form like this:

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x}(t) &= u(t) \\\\\n y(t) &= x(t)\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        The Integrator block is inherently MIMO capable, so u and y can be vectors.

        \n
        \n

        Example

        \n

        This is how to initialize the integrator:

        \n
        \n#initial value 0.0\ni1 = Integrator()\n\n#initial value 2.5\ni2 = Integrator(2.5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        initial_value : float, array
        \n
        initial value of integrator
        \n
        \n
        \n", + "description": "Integrates the input signal.", + "docstringHtml": "

        Integrates the input signal.

        \n

        Uses a numerical integration engine like this:

        \n
        \n\\begin{equation*}\ny(t) = \\int_0^t u(\\tau) \\ d \\tau\n\\end{equation*}\n
        \n

        or in differential form like this:

        \n
        \n\\begin{equation*}\n\\begin{align}\n \\dot{x}(t) &= u(t) \\\\\n y(t) &= x(t)\n\\end{align}\n\\end{equation*}\n
        \n

        The Integrator block is inherently MIMO capable, so u\nand y can be vectors.

        \n
        \n

        Example

        \n

        This is how to initialize the integrator:

        \n
        \n#initial value 0.0\ni1 = Integrator()\n\n#initial value 2.5\ni2 = Integrator(2.5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        initial_value : float, array
        \n
        initial value of integrator
        \n
        \n
        \n", "params": { "initial_value": { "type": "number", @@ -365,13 +385,13 @@ export const extractedBlocks: Record = "description": "initial value of integrator" } }, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "Differentiator": { "blockClass": "Differentiator", - "description": "Differentiates the input signal (SISO) using a first order transfer function", - "docstringHtml": "

        Differentiates the input signal (SISO) using a first order transfer function\nwith a pole at the origin which implements a high pass filter.

        \n
        \n\\begin{equation*}\nH_\\mathrm{diff}(s) = \\frac{s}{1 + s / f_\\mathrm{max}}\n\\end{equation*}\n
        \n

        The approximation holds for signals up to a frequency of approximately f_max.

        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.

        \n
        \n
        \n

        Note

        \n

        Since this is an approximation of real differentiation, the approximation will not hold\nif there are high frequency components present in the signal. For example if you have\ndiscontinuities such as steps or squere waves.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz\nD = Differentiator(f_max=1e3)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", + "description": "Differentiates the input signal.", + "docstringHtml": "

        Differentiates the input signal.

        \n

        Uses a first order transfer function with a pole at the origin which implements\na high pass filter. Supports vector input.

        \n
        \n\\begin{equation*}\nH_\\mathrm{diff}(s) = \\frac{s}{1 + s / f_\\mathrm{max}}\n\\end{equation*}\n
        \n

        The approximation holds for signals up to a frequency of approximately f_max.

        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.

        \n
        \n
        \n

        Note

        \n

        Since this is an approximation of real differentiation, the approximation will not hold\nif there are high frequency components present in the signal. For example if you have\ndiscontinuities such as steps or squere waves.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz\nD = Differentiator(f_max=1e3)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": { "f_max": { "type": "number", @@ -379,17 +399,13 @@ export const extractedBlocks: Record = "description": "highest expected signal frequency" } }, - "inputs": [ - "in" - ], - "outputs": [ - "out" - ] + "inputs": null, + "outputs": null }, "Delay": { "blockClass": "Delay", - "description": "Delays the input signal by a time constant 'tau' in seconds", - "docstringHtml": "

        Delays the input signal by a time constant 'tau' in seconds\nusing an adaptive rolling buffer.

        \n

        Mathematically this block creates a time delay of the input signal like this:

        \n
        \n\\begin{equation*}\ny(t) =\n\\begin{cases}\nx(t - \\tau) & , t \\geq \\tau \\\\\n0 & , t < \\tau\n\\end{cases}\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        The internal adaptive buffer uses interpolation for the evaluation. This is\nrequired to be compatible with variable step solvers. It has a drawback however.\nThe order of the ode solver used will degrade when this block is used, due to\nthe interpolation.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#5 time units delay\nD = Delay(tau=5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        tau : float
        \n
        delay time constant
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        _buffer : AdaptiveBuffer
        \n
        internal interpolatable adaptive rolling buffer
        \n
        \n
        \n", + "description": "Delays the input signal by a time constant 'tau' in seconds.", + "docstringHtml": "

        Delays the input signal by a time constant 'tau' in seconds.

        \n

        Mathematically this block creates a time delay of the input signal like this:

        \n
        \n\\begin{equation*}\ny(t) =\n\\begin{cases}\nx(t - \\tau) & , t \\geq \\tau \\\\\n0 & , t < \\tau\n\\end{cases}\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        The internal adaptive buffer uses interpolation for the evaluation. This is\nrequired to be compatible with variable step solvers. It has a drawback however.\nThe order of the ode solver used will degrade when this block is used, due to\nthe interpolation.

        \n
        \n
        \n

        Note

        \n

        This block supports vector input, meaning we can have multiple parallel\ndelay paths through this block.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#5 time units delay\nD = Delay(tau=5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        tau : float
        \n
        delay time constant
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        _buffer : AdaptiveBuffer
        \n
        internal interpolatable adaptive rolling buffer
        \n
        \n
        \n", "params": { "tau": { "type": "number", @@ -397,17 +413,13 @@ export const extractedBlocks: Record = "description": "delay time constant" } }, - "inputs": [ - "in" - ], - "outputs": [ - "out" - ] + "inputs": null, + "outputs": null }, "ODE": { "blockClass": "ODE", - "description": "This block implements an ordinary differential equation (ODE)", - "docstringHtml": "

        This block implements an ordinary differential equation (ODE)\ndefined by its right hand side

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x}(t) =& \\mathrm{func}(x(t), u(t), t) \\\\\n y(t) =& x(t)\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        with inhomogenity (input) u and state vector x. The function\ncan be nonlinear and the ODE can be of arbitrary order.\nThe block utilizes the integration engine to solve the ODE\nby integrating the func, which is the right hand side function.

        \n
        \n

        Example

        \n

        For example a linear 1st order ODE:

        \n
        \node = ODE(lambda x, u, t: -x)\n
        \n

        Or something more complex like the Van der Pol system, where it makes\nsense to also specify the jacobian, which improves convergence for\nimplicit solvers but is not needed in most cases:

        \n
        \nimport numpy as np\n\n#initial condition\nx0 = np.array([2, 0])\n\n#van der Pol parameter\nmu = 1000\n\ndef func(x, u, t):\n    return np.array([x[1], mu*(1 - x[0]**2)*x[1] - x[0]])\n\n#analytical jacobian (optional)\ndef jac(x, u, t):\n    return np.array(\n        [[0                , 1               ],\n         [-mu*2*x[0]*x[1]-1, mu*(1 - x[0]**2)]]\n         )\n\n#finally the block\nvdp = ODE(func, x0, jac)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        right hand side function of ODE
        \n
        initial_value : array[float]
        \n
        initial state / initial condition
        \n
        jac : callable, None
        \n
        jacobian of 'func' or 'None'
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE right hand side 'func'
        \n
        \n
        \n", + "description": "Ordinary differential equation (ODE) defined by its right hand side function.", + "docstringHtml": "

        Ordinary differential equation (ODE) defined by its right hand side function.

        \n
        \n\\begin{equation*}\n\\begin{align}\n \\dot{x}(t) &= \\mathrm{func}(x(t), u(t), t) \\\\\n y(t) &= x(t)\n\\end{align}\n\\end{equation*}\n
        \n

        with inhomogenity (input) u and state vector x. The function can be nonlinear\nand the ODE can be of arbitrary order. The block utilizes the integration engine\nto solve the ODE by integrating the func, which is the right hand side function.

        \n
        \n

        Example

        \n

        For example a linear 1st order ODE:

        \n
        \node = ODE(lambda x, u, t: -x)\n
        \n

        Or something more complex like the Van der Pol system, where it makes sense to\nalso specify the jacobian, which improves convergence for implicit solvers but is\nnot needed in most cases:

        \n
        \nimport numpy as np\n\n#initial condition\nx0 = np.array([2, 0])\n\n#van der Pol parameter\nmu = 1000\n\ndef func(x, u, t):\n    return np.array([x[1], mu*(1 - x[0]**2)*x[1] - x[0]])\n\n#analytical jacobian (optional)\ndef jac(x, u, t):\n    return np.array(\n        [[0                , 1               ],\n         [-mu*2*x[0]*x[1]-1, mu*(1 - x[0]**2)]]\n         )\n\n#finally the block\nvdp = ODE(func, x0, jac)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        right hand side function of ODE
        \n
        initial_value : array[float]
        \n
        initial state / initial condition
        \n
        jac : callable, None
        \n
        jacobian of 'func' or 'None'
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE right hand side 'func'
        \n
        \n
        \n", "params": { "func": { "type": "callable", @@ -425,13 +437,13 @@ export const extractedBlocks: Record = "description": "jacobian of 'func' or 'None'" } }, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "DynamicalSystem": { "blockClass": "DynamicalSystem", "description": "This block implements a nonlinear dynamical system / nonlinear state space model.", - "docstringHtml": "

        This block implements a nonlinear dynamical system / nonlinear state space model.

        \n

        Its basically the same as the ODE block with the addition of an output equation\nthat takes the state, input and time as arguments:

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x}(t) =& \\mathrm{func}_\\mathrm{dyn}(x(t), u(t), t) \\\\\n y(t) =& \\mathrm{func}_\\mathrm{alg}(x(t), u(t), t)\n\\end{eqnarray}\n\\end{equation*}\n
        \n
        \n

        Parameters

        \n
        \n
        func_dyn : callable
        \n
        right hand side function of ode-part of the system
        \n
        func_alg : callable
        \n
        output function of the system
        \n
        initial_value : array[float]
        \n
        initial state / initial condition
        \n
        jac_dyn : callable | None
        \n
        optional jacobian of func_dyn to improve convergence\nfor implicit ode solvers
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for func_dyn
        \n
        op_alg : DynamicOperator
        \n
        internal dynamic operator for func_alg
        \n
        \n
        \n", + "docstringHtml": "

        This block implements a nonlinear dynamical system / nonlinear state space model.

        \n

        Its basically the same as the ODE block with the addition of an output equation\nthat takes the state, input and time as arguments:

        \n
        \n\\begin{equation*}\n\\begin{align}\n \\dot{x}(t) &= \\mathrm{func}_\\mathrm{dyn}(x(t), u(t), t) \\\\\n y(t) &= \\mathrm{func}_\\mathrm{alg}(x(t), u(t), t)\n\\end{align}\n\\end{equation*}\n
        \n
        \n

        Parameters

        \n
        \n
        func_dyn : callable
        \n
        right hand side function of ode-part of the system
        \n
        func_alg : callable
        \n
        output function of the system
        \n
        initial_value : array[float]
        \n
        initial state / initial condition
        \n
        jac_dyn : callable | None
        \n
        optional jacobian of func_dyn to improve convergence\nfor implicit ode solvers
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for func_dyn
        \n
        op_alg : DynamicOperator
        \n
        internal dynamic operator for func_alg
        \n
        \n
        \n", "params": { "func_dyn": { "type": "callable", @@ -454,13 +466,13 @@ export const extractedBlocks: Record = "description": "optional jacobian of `func_dyn` to improve convergence for implicit ode solvers" } }, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "StateSpace": { "blockClass": "StateSpace", - "description": "This block defines a linear time invariant (LTI) multi input multi output (MIMO)", - "docstringHtml": "

        This block defines a linear time invariant (LTI) multi input multi output (MIMO)\nstate space model with the structure

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x} &= \\mathbf{A} x + \\mathbf{B} u \\\\\n y &= \\mathbf{C} x + \\mathbf{D} u\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        where A, B, C and D are the state space matrices, x is the state,\nu the input and y the output vector.

        \n
        \n

        Example

        \n

        A SISO state space block with two internal states can be initialized\nlike this:

        \n
        \nS = StateSpace(\n    A=-np.eye(2),\n    B=np.ones((2, 1)),\n    C=np.ones((1, 2)),\n    D=1.0\n    )\n
        \n

        and a MIMO (2 in, 2 out) state space block with three internal states\ncan be initialized like this:

        \n
        \nS = StateSpace(\n    A=-np.eye(3),\n    B=np.ones((3, 2)),\n    C=np.ones((2, 3)),\n    D=np.ones((2, 2))\n    )\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        A, B, C, D : array_like
        \n
        real valued state space matrices
        \n
        initial_value : array_like, None
        \n
        initial state / initial condition
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for state equation
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator for mapping to outputs
        \n
        \n
        \n", + "description": "Linear time invariant (LTI) multi input multi output (MIMO) state space model.", + "docstringHtml": "

        Linear time invariant (LTI) multi input multi output (MIMO) state space model.

        \n
        \n\\begin{equation*}\n\\begin{align}\n \\dot{x} &= \\mathbf{A} x + \\mathbf{B} u \\\\\n y &= \\mathbf{C} x + \\mathbf{D} u\n\\end{align}\n\\end{equation*}\n
        \n

        where A, B, C and D are the state space matrices, x is the state,\nu the input and y the output vector.

        \n
        \n

        Example

        \n

        A SISO state space block with two internal states can be initialized\nlike this:

        \n
        \nS = StateSpace(\n    A=-np.eye(2),\n    B=np.ones((2, 1)),\n    C=np.ones((1, 2)),\n    D=1.0\n    )\n
        \n

        and a MIMO (2 in, 2 out) state space block with three internal states\ncan be initialized like this:

        \n
        \nS = StateSpace(\n    A=-np.eye(3),\n    B=np.ones((3, 2)),\n    C=np.ones((2, 3)),\n    D=np.ones((2, 2))\n    )\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        A, B, C, D : array_like
        \n
        real valued state space matrices
        \n
        initial_value : array_like, None
        \n
        initial state / initial condition
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for state equation
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator for mapping to outputs
        \n
        \n
        \n", "params": { "A": { "type": "number", @@ -488,13 +500,13 @@ export const extractedBlocks: Record = "description": "initial state / initial condition" } }, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "PID": { "blockClass": "PID", "description": "Proportional-Integral-Differntiation (PID) controller.", - "docstringHtml": "

        Proportional-Integral-Differntiation (PID) controller.

        \n

        The transfer function is defined as

        \n
        \n\\begin{equation*}\nH_\\mathrm{diff}(s) = K_p + K_i \\frac{1}{s} + K_d \\frac{s}{1 + s / f_\\mathrm{max}}\n\\end{equation*}\n
        \n

        where the differentiation is approximated by a high pass filter that holds\nfor signals up to a frequency of approximately f_max.

        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.\nSince this block uses an approximation of real differentiation, the approximation will\nnot hold if there are high frequency components present in the signal. For example if\nyou have discontinuities such as steps or square waves.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz\npid = PID(Kp=2, Ki=0.5, Kd=0.1, f_max=1e3)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        Kp : float
        \n
        poroportional controller coefficient
        \n
        Ki : float
        \n
        integral controller coefficient
        \n
        Kd : float
        \n
        differentiator controller coefficient
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", + "docstringHtml": "

        Proportional-Integral-Differntiation (PID) controller.

        \n

        The transfer function is defined as

        \n
        \n\\begin{equation*}\nH_\\mathrm{diff}(s) = K_p + K_i \\frac{1}{s} + K_d \\frac{s}{1 + s / f_\\mathrm{max}}\n\\end{equation*}\n
        \n

        where the differentiation is approximated by a high pass filter that holds\nfor signals up to a frequency of approximately f_max.

        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.\nSince this block uses an approximation of real differentiation, the approximation will\nnot hold if there are high frequency components present in the signal. For example if\nyou have discontinuities such as steps or square waves.

        \n
        \n
        \n

        Note

        \n

        This block supports vector input, meaning we can have multiple parallel\nPID paths through this block.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz\npid = PID(Kp=2, Ki=0.5, Kd=0.1, f_max=1e3)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        Kp : float
        \n
        poroportional controller coefficient
        \n
        Ki : float
        \n
        integral controller coefficient
        \n
        Kd : float
        \n
        differentiator controller coefficient
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": { "Kp": { "type": "integer", @@ -517,17 +529,13 @@ export const extractedBlocks: Record = "description": "highest expected signal frequency" } }, - "inputs": [ - "in" - ], - "outputs": [ - "out" - ] + "inputs": null, + "outputs": null }, "AntiWindupPID": { "blockClass": "AntiWindupPID", - "description": "Proportional-Integral-Differntiation (PID) controller with tracking", - "docstringHtml": "

        Proportional-Integral-Differntiation (PID) controller with tracking\nanti-windup mechanism (back-calculation).

        \n

        Anti-windup mechanisms are needed when the magnitude of the control signal\nfrom the PID controller is limited by some real world saturation. In these cases,\nthe integrator will continue to acumulate the control error and "wind itself up".\nOnce the setpoint is reached, this can result in significant overshoots. This\nimplementation adds a conditional feedback term to the internal integrator that\n"unwinds" it when the PID output crosses some limits. This is pretty much a\ndeadzone feedback element for the integrator.

        \n

        Mathematically, this block implements the following set of ODEs

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n\\dot{x}_1 =& f_\\mathrm{max} (u - x_1) \\\\\n\\dot{x}_2 =& u - w \\\\\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        with the anti-windup feedback (depending on the pid output)

        \n
        \n\\begin{equation*}\nw = K_s (y - \\min(\\max(y, y_\\mathrm{min}), y_\\mathrm{max}))\n\\end{equation*}\n
        \n

        and the output itself

        \n
        \n\\begin{equation*}\ny = K_p u - K_d f_\\mathrm{max} x_1 + K_i x_2\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.\nSince this block uses an approximation of real differentiation, the approximation will\nnot hold if there are high frequency components present in the signal. For example if\nyou have discontinuities such as steps or squere waves.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz, windup limits at [-5, 5]\npid = AntiWindupPID(Kp=2, Ki=0.5, Kd=0.1, f_max=1e3, limits=[-5, 5])\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        Kp : float
        \n
        poroportional controller coefficient
        \n
        Ki : float
        \n
        integral controller coefficient
        \n
        Kd : float
        \n
        differentiator controller coefficient
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        Ks : float
        \n
        feedback term for back calculation for anti-windup control of integrator
        \n
        limits : array_like[float]
        \n
        lower and upper limit for PID output that triggers anti-windup of integrator
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", + "description": "Proportional-Integral-Differntiation (PID) controller with anti-windup mechanism (back-calculation).", + "docstringHtml": "

        Proportional-Integral-Differntiation (PID) controller with anti-windup mechanism (back-calculation).

        \n

        Anti-windup mechanisms are needed when the magnitude of the control signal\nfrom the PID controller is limited by some real world saturation. In these cases,\nthe integrator will continue to acumulate the control error and "wind itself up".\nOnce the setpoint is reached, this can result in significant overshoots. This\nimplementation adds a conditional feedback term to the internal integrator that\n"unwinds" it when the PID output crosses some limits. This is pretty much a\ndeadzone feedback element for the integrator.

        \n

        Mathematically, this block implements the following set of ODEs

        \n
        \n\\begin{equation*}\n\\begin{align}\n\\dot{x}_1 &= f_\\mathrm{max} (u - x_1) \\\\\n\\dot{x}_2 &= u - w\n\\end{align}\n\\end{equation*}\n
        \n

        with the anti-windup feedback (depending on the pid output)

        \n
        \n\\begin{equation*}\nw = K_s (y - \\min(\\max(y, y_\\mathrm{min}), y_\\mathrm{max}))\n\\end{equation*}\n
        \n

        and the output itself

        \n
        \n\\begin{equation*}\ny = K_p u - K_d f_\\mathrm{max} x_1 + K_i x_2\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        Depending on f_max, the resulting system might become stiff or ill conditioned!\nAs a practical choice set f_max to 3x the highest expected signal frequency.\nSince this block uses an approximation of real differentiation, the approximation will\nnot hold if there are high frequency components present in the signal. For example if\nyou have discontinuities such as steps or squere waves.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#cutoff at 1kHz, windup limits at [-5, 5]\npid = AntiWindupPID(Kp=2, Ki=0.5, Kd=0.1, f_max=1e3, limits=[-5, 5])\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        Kp : float
        \n
        poroportional controller coefficient
        \n
        Ki : float
        \n
        integral controller coefficient
        \n
        Kd : float
        \n
        differentiator controller coefficient
        \n
        f_max : float
        \n
        highest expected signal frequency
        \n
        Ks : float
        \n
        feedback term for back calculation for anti-windup control of integrator
        \n
        limits : array_like[float]
        \n
        lower and upper limit for PID output that triggers anti-windup of integrator
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_dyn : DynamicOperator
        \n
        internal dynamic operator for ODE component
        \n
        op_alg : DynamicOperator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": { "Kp": { "type": "integer", @@ -560,17 +568,13 @@ export const extractedBlocks: Record = "description": "lower and upper limit for PID output that triggers anti-windup of integrator" } }, - "inputs": [ - "in" - ], - "outputs": [ - "out" - ] + "inputs": null, + "outputs": null }, "TransferFunctionNumDen": { "blockClass": "TransferFunctionNumDen", "description": "This block defines a LTI (SISO) transfer function.", - "docstringHtml": "

        This block defines a LTI (SISO) transfer function.

        \n

        The transfer function is defined in polynomial (numerator-denominator) form

        \n
        \n\\begin{equation*}\n\\mathbf{H}(s) = \\frac{b_n + b_{n-1} s + \\dots + b_{0} s^n}{a_m + a_{m-1} s + \\dots + a_{0} s^m}\n\\end{equation*}\n
        \n

        where Num is the list of numerator polynomial coefficients and Den the\nlist of denominator coefficients.

        \n

        Upon initialization, the state space realization of the transfer function is\ncomputed using scipy.signal.TransferFunction(Num, Den).to_ss().

        \n

        The resulting state space model of the form

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x} &= \\mathbf{A} x + \\mathbf{B} u \\\\\n y &= \\mathbf{C} x + \\mathbf{D} u\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        is handled the same as the 'StateSpace' block, where A, B, C and D\nare the state space matrices, x is the internal state, u the input and\ny the output vector.

        \n
        \n

        Parameters

        \n
        \n
        Num : array_like
        \n
        numerator polynomial coefficients
        \n
        Den : array_like
        \n
        denominator polynomial coefficients
        \n
        \n
        \n", + "docstringHtml": "

        This block defines a LTI (SISO) transfer function.

        \n

        The transfer function is defined in polynomial (numerator-denominator) form

        \n
        \n\\begin{equation*}\n\\mathbf{H}(s) = \\frac{b_n + b_{n-1} s + \\dots + b_{0} s^n}{a_m + a_{m-1} s + \\dots + a_{0} s^m}\n\\end{equation*}\n
        \n

        where Num is the list of numerator polynomial coefficients and Den the\nlist of denominator coefficients.

        \n

        Upon initialization, the state space realization of the transfer function is\ncomputed using scipy.signal.TransferFunction(Num, Den).to_ss().

        \n

        The resulting state space model of the form

        \n
        \n\\begin{equation*}\n\\begin{align}\n \\dot{x} &= \\mathbf{A} x + \\mathbf{B} u \\\\\n y &= \\mathbf{C} x + \\mathbf{D} u\n\\end{align}\n\\end{equation*}\n
        \n

        is handled the same as the 'StateSpace' block, where A, B, C and D\nare the state space matrices, x is the internal state, u the input and\ny the output vector.

        \n
        \n

        Parameters

        \n
        \n
        Num : array_like
        \n
        numerator polynomial coefficients
        \n
        Den : array_like
        \n
        denominator polynomial coefficients
        \n
        \n
        \n", "params": { "Num": { "type": "array", @@ -583,13 +587,17 @@ export const extractedBlocks: Record = "description": "denominator polynomial coefficients" } }, - "inputs": [], - "outputs": [] + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] }, "TransferFunctionZPG": { "blockClass": "TransferFunctionZPG", "description": "This block defines a LTI (SISO) transfer function.", - "docstringHtml": "

        This block defines a LTI (SISO) transfer function.

        \n

        The transfer function is defined in zeros-poles-gain (ZPG) form

        \n
        \n\\begin{equation*}\n\\mathbf{H}(s) = k \\frac{(s - z_1)(s - z_2)\\cdots(s - z_m)}{(s - p_1)(s - p_2)\\cdots(s - p_n)}\n\\end{equation*}\n
        \n

        where Zeros are the scalar (possibly complex conjugate) zeros of the\ntransfer function, and Poles are the poles (denominator zeros) of the\ntransfer function. Gain is the scalar factor k.

        \n

        Upon initialization, the state space realization of the transfer function is\ncomputed using scipy.signal.ZerosPolesGain(Zeros, Poles, Gain).to_ss().

        \n

        The resulting state space model of the form

        \n
        \n\\begin{equation*}\n\\begin{eqnarray}\n \\dot{x} &= \\mathbf{A} x + \\mathbf{B} u \\\\\n y &= \\mathbf{C} x + \\mathbf{D} u\n\\end{eqnarray}\n\\end{equation*}\n
        \n

        is handled the same as the 'StateSpace' block, where A, B, C and D\nare the state space matrices, x is the internal state, u the input and\ny the output vector.

        \n
        \n

        Parameters

        \n
        \n
        Poles : array_like
        \n
        transfer function poles
        \n
        Zeros : array_like
        \n
        transfer function zeros
        \n
        Gain : float
        \n
        gain term of transfer function
        \n
        \n
        \n", + "docstringHtml": "

        This block defines a LTI (SISO) transfer function.

        \n

        The transfer function is defined in zeros-poles-gain (ZPG) form

        \n
        \n\\begin{equation*}\n\\mathbf{H}(s) = k \\frac{(s - z_1)(s - z_2)\\cdots(s - z_m)}{(s - p_1)(s - p_2)\\cdots(s - p_n)}\n\\end{equation*}\n
        \n

        where Zeros are the scalar (possibly complex conjugate) zeros of the\ntransfer function, and Poles are the poles (denominator zeros) of the\ntransfer function. Gain is the scalar factor k.

        \n

        Upon initialization, the state space realization of the transfer function is\ncomputed using scipy.signal.ZerosPolesGain(Zeros, Poles, Gain).to_ss().

        \n

        The resulting state space model of the form

        \n
        \n\\begin{equation*}\n\\begin{align}\n \\dot{x} &= \\mathbf{A} x + \\mathbf{B} u \\\\\n y &= \\mathbf{C} x + \\mathbf{D} u\n\\end{align}\n\\end{equation*}\n
        \n

        is handled the same as the 'StateSpace' block, where A, B, C and D\nare the state space matrices, x is the internal state, u the input and\ny the output vector.

        \n
        \n

        Parameters

        \n
        \n
        Poles : array_like
        \n
        transfer function poles
        \n
        Zeros : array_like
        \n
        transfer function zeros
        \n
        Gain : float
        \n
        gain term of transfer function
        \n
        \n
        \n", "params": { "Zeros": { "type": "array", @@ -607,8 +615,12 @@ export const extractedBlocks: Record = "description": "gain term of transfer function" } }, - "inputs": [], - "outputs": [] + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] }, "ButterworthLowpassFilter": { "blockClass": "ButterworthLowpassFilter", @@ -626,8 +638,12 @@ export const extractedBlocks: Record = "description": "filter order" } }, - "inputs": [], - "outputs": [] + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] }, "ButterworthHighpassFilter": { "blockClass": "ButterworthHighpassFilter", @@ -645,8 +661,12 @@ export const extractedBlocks: Record = "description": "filter order" } }, - "inputs": [], - "outputs": [] + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] }, "ButterworthBandpassFilter": { "blockClass": "ButterworthBandpassFilter", @@ -664,8 +684,12 @@ export const extractedBlocks: Record = "description": "filter order" } }, - "inputs": [], - "outputs": [] + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] }, "ButterworthBandstopFilter": { "blockClass": "ButterworthBandstopFilter", @@ -683,8 +707,12 @@ export const extractedBlocks: Record = "description": "filter order" } }, - "inputs": [], - "outputs": [] + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] }, "Adder": { "blockClass": "Adder", @@ -697,7 +725,7 @@ export const extractedBlocks: Record = "description": "optional string of operations to be applied before summation, i.e. '+-' will compute the difference, 'None' will just perform regular sum" } }, - "inputs": [], + "inputs": null, "outputs": [ "out" ] @@ -707,15 +735,15 @@ export const extractedBlocks: Record = "description": "Multiplies all signals from all input ports (MISO).", "docstringHtml": "

        Multiplies all signals from all input ports (MISO).

        \n
        \n\\begin{equation*}\ny(t) = \\prod_i u_i(t)\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.

        \n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator that wraps 'prod'
        \n
        \n
        \n", "params": {}, - "inputs": [], + "inputs": null, "outputs": [ "out" ] }, "Amplifier": { "blockClass": "Amplifier", - "description": "Amplifies the input signal by", - "docstringHtml": "

        Amplifies the input signal by\nmultiplication with a constant gain term like this:

        \n
        \n\\begin{equation*}\ny(t) = \\mathrm{gain} \\cdot u(t)\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#amplification by factor 5\nA = Amplifier(gain=5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        gain : float
        \n
        amplifier gain
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", + "description": "Amplifies the input signal by multiplication with a constant gain term.", + "docstringHtml": "

        Amplifies the input signal by multiplication with a constant gain term.

        \n

        Like this:

        \n
        \n\\begin{equation*}\ny(t) = \\mathrm{gain} \\cdot u(t)\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.

        \n
        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#amplification by factor 5\nA = Amplifier(gain=5)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        gain : float
        \n
        amplifier gain
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": { "gain": { "type": "number", @@ -723,17 +751,13 @@ export const extractedBlocks: Record = "description": "amplifier gain" } }, - "inputs": [ - "in" - ], - "outputs": [ - "out" - ] + "inputs": null, + "outputs": null }, "Function": { "blockClass": "Function", - "description": "Arbitrary MIMO function block, defined by a callable object,", - "docstringHtml": "

        Arbitrary MIMO function block, defined by a callable object,\ni.e. function or lambda expression.

        \n

        The function can have multiple arguments that are then provided\nby the input channels of the function block.

        \n

        Form multi input, the function has to specify multiple arguments\nand for multi output, the aoutputs have to be provided as a\ntuple or list.

        \n

        In the context of the global system, this block implements algebraic\ncomponents of the global system ODE/DAE.

        \n
        \n\\begin{equation*}\n\\vec{y} = \\mathrm{func}(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.\nTherefore func must be purely algebraic and not introduce states,\ndelay, etc. For interfacing with external stateful APIs, use the\nWrapper block.

        \n
        \n
        \n

        Note

        \n

        If the outputs are provided as a single numpy array, they are\nconsidered a single output. For MIMO, output has to be tuple.

        \n
        \n
        \n

        Example

        \n

        consider the function:

        \n
        \nfrom pathsim.blocks import Function\n\ndef f(a, b, c):\n    return a**2, a*b, b/c\n\nfn = Function(f)\n
        \n

        then, when the block is uldated, the input channels of the block are\nassigned to the function arguments following this scheme:

        \n
        \ninputs[0] -> a\ninputs[1] -> b\ninputs[2] -> c\n
        \n

        and the function outputs are assigned to the\noutput channels of the block in the same way:

        \n
        \na**2 -> outputs[0]\na*b  -> outputs[1]\nb/c  -> outputs[2]\n
        \n

        Because the Function block only has a single argument, it can be\nused to decorate a function and make it a PathSim block. This might\nbe handy in some cases to keep definitions concise and localized\nin the code:

        \n
        \nfrom pathsim.blocks import Function\n\n#does the same as the definition above\n\n@Function\ndef fn(a, b, c):\n    return a**2, a*b, b/c\n\n#'fn' is now a PathSim block\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        MIMO function that defines algebraic block IO behaviour, signature func(*tuple)
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator that wraps func
        \n
        \n
        \n", + "description": "Arbitrary MIMO function block, defined by a function or `lambda` expression.", + "docstringHtml": "

        Arbitrary MIMO function block, defined by a function or lambda expression.

        \n

        The function can have multiple arguments that are then provided\nby the input channels of the function block.

        \n

        Form multi input, the function has to specify multiple arguments\nand for multi output, the aoutputs have to be provided as a\ntuple or list.

        \n

        In the context of the global system, this block implements algebraic\ncomponents of the global system ODE/DAE.

        \n
        \n\\begin{equation*}\n\\vec{y} = \\mathrm{func}(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Note

        \n

        This block is purely algebraic and its operation (op_alg) will be called\nmultiple times per timestep, each time when Simulation._update(t) is\ncalled in the global simulation loop.\nTherefore func must be purely algebraic and not introduce states,\ndelay, etc. For interfacing with external stateful APIs, use the\nWrapper block.

        \n
        \n
        \n

        Note

        \n

        If the outputs are provided as a single numpy array, they are\nconsidered a single output. For MIMO, output has to be tuple.

        \n
        \n
        \n

        Example

        \n

        consider the function:

        \n
        \nfrom pathsim.blocks import Function\n\ndef f(a, b, c):\n    return a**2, a*b, b/c\n\nfn = Function(f)\n
        \n

        then, when the block is updated, the input channels of the block are\nassigned to the function arguments following this scheme:

        \n
        \ninputs[0] -> a\ninputs[1] -> b\ninputs[2] -> c\n
        \n

        and the function outputs are assigned to the\noutput channels of the block in the same way:

        \n
        \na**2 -> outputs[0]\na*b  -> outputs[1]\nb/c  -> outputs[2]\n
        \n

        Because the Function block only has a single argument, it can be\nused to decorate a function and make it a PathSim block. This might\nbe handy in some cases to keep definitions concise and localized\nin the code:

        \n
        \nfrom pathsim.blocks import Function\n\n#does the same as the definition above\n\n@Function\ndef fn(a, b, c):\n    return a**2, a*b, b/c\n\n#'fn' is now a PathSim block\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        func : callable
        \n
        MIMO function that defines algebraic block IO behaviour, signature func(*tuple)
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator that wraps func
        \n
        \n
        \n", "params": { "func": { "type": "callable", @@ -741,80 +765,80 @@ export const extractedBlocks: Record = "description": "MIMO function that defines algebraic block IO behaviour, signature `func(*tuple)`" } }, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "Sin": { "blockClass": "Sin", "description": "Sine operator block.", "docstringHtml": "

        Sine operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = \\sin(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "Cos": { "blockClass": "Cos", "description": "Cosine operator block.", "docstringHtml": "

        Cosine operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = \\cos(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "Tan": { "blockClass": "Tan", "description": "Tangent operator block.", "docstringHtml": "

        Tangent operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = \\tan(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "Tanh": { "blockClass": "Tanh", "description": "Hyperbolic tangent operator block.", "docstringHtml": "

        Hyperbolic tangent operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = \\tanh(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "Abs": { "blockClass": "Abs", "description": "Absolute value operator block.", "docstringHtml": "

        Absolute value operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = \\vert| \\vec{u} \\vert|\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "Sqrt": { "blockClass": "Sqrt", "description": "Square root operator block.", "docstringHtml": "

        Square root operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = \\sqrt{|\\vec{u}|}\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "Exp": { "blockClass": "Exp", "description": "Exponential operator block.", "docstringHtml": "

        Exponential operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = e^{\\vec{u}}\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "Log": { "blockClass": "Log", "description": "Natural logarithm operator block.", "docstringHtml": "

        Natural logarithm operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = \\ln(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "Log10": { "blockClass": "Log10", "description": "Base-10 logarithm operator block.", "docstringHtml": "

        Base-10 logarithm operator block.

        \n

        This block supports vector inputs. This is the operation it does:

        \n
        \n\\begin{equation*}\n\\vec{y} = \\log_{10}(\\vec{u})\n\\end{equation*}\n
        \n
        \n

        Attributes

        \n
        \n
        op_alg : Operator
        \n
        internal algebraic operator
        \n
        \n
        \n", "params": {}, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "Mod": { "blockClass": "Mod", @@ -827,8 +851,8 @@ export const extractedBlocks: Record = "description": "modulus value Attributes ----------" } }, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "Clip": { "blockClass": "Clip", @@ -846,8 +870,8 @@ export const extractedBlocks: Record = "description": "maximum clipping value Attributes ----------" } }, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "Pow": { "blockClass": "Pow", @@ -860,13 +884,13 @@ export const extractedBlocks: Record = "description": "exponent to raise the input to the power of Attributes ----------" } }, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "Switch": { "blockClass": "Switch", - "description": "Switch block that selects between its inputs and copies", - "docstringHtml": "

        Switch block that selects between its inputs and copies\none of them to the output.

        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#default None -> no passthrough\ns1 = Switch()\n\n#selecting port 2 as passthrough\ns2 = Switch(2)\n\n#change the state of the switch to port 3\ns2.select(3)\n
        \n

        Sets block output depending on self.state like this:

        \n
        \nstate == None -> outputs[0] = 0\n\nstate == 0 -> outputs[0] = inputs[0]\n\nstate == 1 -> outputs[0] = inputs[1]\n\nstate == 2 -> outputs[0] = inputs[2]\n\n...\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        state : int, None
        \n
        state of the switch
        \n
        \n
        \n", + "description": "Switch block that selects between its inputs.", + "docstringHtml": "

        Switch block that selects between its inputs.

        \n
        \n

        Example

        \n

        The block is initialized like this:

        \n
        \n#default None -> no passthrough\ns1 = Switch()\n\n#selecting port 2 as passthrough\ns2 = Switch(2)\n\n#change the state of the switch to port 3\ns2.select(3)\n
        \n

        Sets block output depending on self.state like this:

        \n
        \nstate == None -> outputs[0] = 0\n\nstate == 0 -> outputs[0] = inputs[0]\n\nstate == 1 -> outputs[0] = inputs[1]\n\nstate == 2 -> outputs[0] = inputs[2]\n\n...\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        state : int, None
        \n
        state of the switch
        \n
        \n
        \n", "params": { "state": { "type": "any", @@ -874,7 +898,7 @@ export const extractedBlocks: Record = "description": "state of the switch" } }, - "inputs": [], + "inputs": null, "outputs": [ "out" ] @@ -895,8 +919,8 @@ export const extractedBlocks: Record = "description": "N-D array of data values at the corresponding points. If 1-D, represents scalar values at each point. If 2-D, each column represents a different output dimension (m output values per input point)." } }, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "LUT1D": { "blockClass": "LUT1D", @@ -919,13 +943,13 @@ export const extractedBlocks: Record = "description": "The value to use for points outside the interpolation range. If \"extrapolate\", the interpolator will use linear extrapolation. Default is \"extrapolate\". See https://docs.scipy.org/doc/scipy-1.16.1/reference/generated/scipy.interpolate.interp1d.html for more details" } }, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "SampleHold": { "blockClass": "SampleHold", - "description": "Sample and hold stage that samples the inputs", - "docstringHtml": "

        Sample and hold stage that samples the inputs\nperiodically using scheduled events and produces\nthem at the output.

        \n
        \n

        Parameters

        \n
        \n
        T : float
        \n
        sampling period
        \n
        tau : float
        \n
        delay
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        events : list[Schedule]
        \n
        internal scheduled event for periodic sampling
        \n
        \n
        \n", + "description": "Samples the inputs periodically and produces them at the output.", + "docstringHtml": "

        Samples the inputs periodically and produces them at the output.

        \n
        \n

        Parameters

        \n
        \n
        T : float
        \n
        sampling period
        \n
        tau : float
        \n
        delay
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        events : list[Schedule]
        \n
        internal scheduled event for periodic sampling
        \n
        \n
        \n", "params": { "T": { "type": "integer", @@ -938,8 +962,8 @@ export const extractedBlocks: Record = "description": "delay Attributes ----------" } }, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null }, "FIR": { "blockClass": "FIR", @@ -998,12 +1022,7 @@ export const extractedBlocks: Record = "inputs": [ "in" ], - "outputs": [ - "b4", - "b3", - "b2", - "b1" - ] + "outputs": null }, "DAC": { "blockClass": "DAC", @@ -1031,20 +1050,15 @@ export const extractedBlocks: Record = "description": "Initial delay before the first output update. Default is 0." } }, - "inputs": [ - "b4", - "b3", - "b2", - "b1" - ], + "inputs": null, "outputs": [ "out" ] }, "Counter": { "blockClass": "Counter", - "description": "Counter block that counts the number of detected bidirectional", - "docstringHtml": "

        Counter block that counts the number of detected bidirectional\nzero-crossing events and sets the output accordingly.

        \n
        \n

        Parameters

        \n
        \n
        start : int
        \n
        counter start (initial condition)
        \n
        threshold : float
        \n
        threshold for zero crossing
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        E : ZeroCrossing
        \n
        internal event manager
        \n
        events : list[ZeroCrossing]
        \n
        internal zero crossing event
        \n
        \n
        \n", + "description": "Counts the number of detected bidirectional threshold crossings.", + "docstringHtml": "

        Counts the number of detected bidirectional threshold crossings.

        \n

        Uses zero-crossing events for the detection and sets the output\naccordingly.

        \n
        \n

        Parameters

        \n
        \n
        start : int
        \n
        counter start (initial condition)
        \n
        threshold : float
        \n
        threshold for zero crossing
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        E : ZeroCrossing
        \n
        internal event manager
        \n
        events : list[ZeroCrossing]
        \n
        internal zero crossing event
        \n
        \n
        \n", "params": { "start": { "type": "integer", @@ -1066,8 +1080,8 @@ export const extractedBlocks: Record = }, "CounterUp": { "blockClass": "CounterUp", - "description": "Counter block that counts the number of detected unidirectional", - "docstringHtml": "

        Counter block that counts the number of detected unidirectional\nzero-crossing events and sets the output accordingly.

        \n
        \n

        Note

        \n

        This is a modification of 'Counter' which only counts\nunidirectional zero-crossings (low -> high)

        \n
        \n
        \n

        Parameters

        \n
        \n
        start : int
        \n
        counter start (initial condition)
        \n
        threshold : float
        \n
        threshold for zero crossing
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        E : ZeroCrossingUp
        \n
        internal event manager
        \n
        events : list[ZeroCrossing]
        \n
        internal zero crossing event
        \n
        \n
        \n", + "description": "Counts the number of detected unidirectional (lo->hi) threshold crossings.", + "docstringHtml": "

        Counts the number of detected unidirectional (lo->hi) threshold crossings.

        \n
        \n

        Note

        \n

        This is a modification of 'Counter' which only counts\nunidirectional zero-crossings (low -> high)

        \n
        \n
        \n

        Parameters

        \n
        \n
        start : int
        \n
        counter start (initial condition)
        \n
        threshold : float
        \n
        threshold for zero crossing
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        E : ZeroCrossingUp
        \n
        internal event manager
        \n
        events : list[ZeroCrossing]
        \n
        internal zero crossing event
        \n
        \n
        \n", "params": { "start": { "type": "integer", @@ -1089,8 +1103,8 @@ export const extractedBlocks: Record = }, "CounterDown": { "blockClass": "CounterDown", - "description": "Counter block that counts the number of detected unidirectional", - "docstringHtml": "

        Counter block that counts the number of detected unidirectional\nzero-crossing events and sets the output accordingly.

        \n
        \n

        Note

        \n

        This is a modification of 'Counter' which only counts\nunidirectional zero-crossings (high -> low)

        \n
        \n
        \n

        Parameters

        \n
        \n
        start : int
        \n
        counter start (initial condition)
        \n
        threshold : float
        \n
        threshold for zero crossing
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        E : ZeroCrossingDown
        \n
        internal event manager
        \n
        events : list[ZeroCrossing]
        \n
        internal zero crossing event
        \n
        \n
        \n", + "description": "Counts the number of detected unidirectional (hi->lo) threshold crossings.", + "docstringHtml": "

        Counts the number of detected unidirectional (hi->lo) threshold crossings.

        \n
        \n

        Note

        \n

        This is a modification of 'Counter' which only counts\nunidirectional zero-crossings (high -> low)

        \n
        \n
        \n

        Parameters

        \n
        \n
        start : int
        \n
        counter start (initial condition)
        \n
        threshold : float
        \n
        threshold for zero crossing
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        E : ZeroCrossingDown
        \n
        internal event manager
        \n
        events : list[ZeroCrossing]
        \n
        internal zero crossing event
        \n
        \n
        \n", "params": { "start": { "type": "integer", @@ -1110,15 +1124,48 @@ export const extractedBlocks: Record = "out" ] }, + "Relay": { + "blockClass": "Relay", + "description": "Relay block with hysteresis (Schmitt trigger).", + "docstringHtml": "

        Relay block with hysteresis (Schmitt trigger).

        \n

        Switches output between two values based on input crossing upper and lower\nthresholds. The hysteresis prevents rapid switching when input is noisy.

        \n

        When input rises above threshold_up, output switches to value_up.\nWhen input falls below threshold_down, output switches to value_down.

        \n
        \n

        Examples

        \n

        Basic thermostat that turns heater on below 19°C, off above 21°C:

        \n
        \nfrom pathsim.blocks import Relay\n\nthermostat = Relay(\n    threshold_up=21.0,\n    threshold_down=19.0,\n    value_up=0.0,\n    value_down=1.0\n    )\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        threshold_up : float
        \n
        threshold for transitioning to upper relay state value_up (default: 1.0)
        \n
        threshold_down : float
        \n
        threshold for transitioning to lower relay state value_down (default: 0.0)
        \n
        value_up : float
        \n
        value for upper relay state (default: 1.0)
        \n
        value_down : float
        \n
        value for lower relay state (default: 0.0)
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        events : list[ZeroCrossingUp, ZeroCrossingDown]
        \n
        internal zero crossing events for relay state transitions
        \n
        \n
        \n", + "params": { + "threshold_up": { + "type": "number", + "default": "1.0", + "description": "threshold for transitioning to upper relay state `value_up` (default: 1.0)" + }, + "threshold_down": { + "type": "number", + "default": "0.0", + "description": "threshold for transitioning to lower relay state `value_down` (default: 0.0)" + }, + "value_up": { + "type": "number", + "default": "1.0", + "description": "value for upper relay state (default: 1.0)" + }, + "value_down": { + "type": "number", + "default": "0.0", + "description": "value for lower relay state (default: 0.0)" + } + }, + "inputs": [ + "in" + ], + "outputs": [ + "out" + ] + }, "Scope": { "blockClass": "Scope", - "description": "Block for recording time domain data with variable sampling rate.", - "docstringHtml": "

        Block for recording time domain data with variable sampling rate.

        \n

        A time threshold can be set by t_wait to start recording data after the simulation\ntime is larger then the specified waiting time, i.e. t - t_wait > 0.\nThis is useful for recording data only after all the transients have settled.

        \n

        The block uses an interal Schedule event, when sampling_rate is provided,\notherwise it just samples at every simulation timestep.

        \n
        \n

        Parameters

        \n
        \n
        sampling_rate : int, None
        \n
        number of samples per time unit, default is every timestep
        \n
        t_wait : float
        \n
        wait time before starting recording, optional
        \n
        labels : list[str]
        \n
        labels for the scope traces, and for the csv, optional
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        recording : dict
        \n
        recording, where key is time, and value the recorded values
        \n
        events : list[Schedule]
        \n
        internal scheduled event for periodic input sampling when sampling_rate is provided
        \n
        \n
        \n", + "description": "Block for recording time domain data with variable sampling period.", + "docstringHtml": "

        Block for recording time domain data with variable sampling period.

        \n

        A time threshold can be set by t_wait to start recording data after the simulation\ntime is larger then the specified waiting time, i.e. t - t_wait > 0.\nThis is useful for recording data only after all the transients have settled.

        \n

        The block uses an internal Schedule event, when sampling_period is provided,\notherwise it just samples at every simulation timestep.

        \n
        \n

        Parameters

        \n
        \n
        sampling_period : float, None
        \n
        time between samples, default is every timestep
        \n
        t_wait : float
        \n
        wait time before starting recording, optional
        \n
        labels : list[str]
        \n
        labels for the scope traces, and for the csv, optional
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        recording_time : list[float]
        \n
        recorded time points
        \n
        recording_data : list[float]
        \n
        recorded data points
        \n
        _incremental_idx : int
        \n
        index for incremental reading of accumulated data since last\ncall of incremental read
        \n
        _sample_next_timestep : bool
        \n
        flag to indicate this is a timestep to sample, only used for\nevent based sampling when sampling_period is provided as an arg
        \n
        events : list[Schedule]
        \n
        internal scheduled event for periodic input sampling when\nsampling_period is provided
        \n
        \n
        \n", "params": { - "sampling_rate": { + "sampling_period": { "type": "any", "default": null, - "description": "number of samples per time unit, default is every timestep" + "description": "time between samples, default is every timestep" }, "t_wait": { "type": "number", @@ -1131,13 +1178,13 @@ export const extractedBlocks: Record = "description": "labels for the scope traces, and for the csv, optional" } }, - "inputs": [], + "inputs": null, "outputs": [] }, "Spectrum": { "blockClass": "Spectrum", - "description": "Block for fourier spectrum analysis (basically a spectrum analyzer), computes", - "docstringHtml": "

        Block for fourier spectrum analysis (basically a spectrum analyzer), computes\ncontinuous time running fourier transform (RFT) of the incoming signal.

        \n

        A time threshold can be set by 't_wait' to start recording data only after the\nsimulation time is larger then the specified waiting time, i.e. 't - t_wait > dt'.\nThis is useful for recording the steady state after all the transients have settled.

        \n

        An exponential forgetting factor 'alpha' can be specified for realtime spectral\nanalysis. It biases the spectral components exponentially to the most recent signal\nvalues by applying a single sided exponential window like this:

        \n
        \n\\begin{equation*}\n\\int_0^t u(\\tau) \\exp(\\alpha (t-\\tau)) \\exp(-j \\omega \\tau)\\ d \\tau\n\\end{equation*}\n
        \n

        It is also known as the 'exponentially forgetting transform' (EFT) and a form of\nshort time fourier transform (STFT). It is implemented as a 1st order statespace model

        \n
        \n\\begin{equation*}\n\\dot{x} = - \\alpha x + \\exp(-j \\omega t) u\n\\end{equation*}\n
        \n

        where 'u' is the input signal and 'x' is the state variable that represents the\ncomplex fourier coefficient to the frequency 'omega'. The ODE is integrated using the\nnumerical integration engine of the block.

        \n
        \n

        Example

        \n

        This is how to initialize it:

        \n
        \nimport numpy as np\n\n#linear frequencies (0Hz, DC -> 1kHz)\nsp1 = Spectrum(\n    freq=np.linspace(0, 1e3, 100),\n    labels=['x1', 'x2'] #labels for two inputs\n    )\n\n#log frequencies (1Hz -> 1kHz)\nsp2 = Spectrum(\n    freq=np.logspace(0, 3, 100)\n    )\n\n#log frequencies including DC (0Hz, DC + 1Hz -> 1kHz)\nsp3 = Spectrum(\n    freq=np.hstack([0.0, np.logspace(0, 3, 100)])\n    )\n\n#arbitrary frequencies\nsp4 = Spectrum(\n    freq=np.array([0, 0.5, 20, 1e3])\n    )\n
        \n
        \n
        \n

        Note

        \n

        This block is relatively slow! But it is valuable for long running simulations\nwith few evaluation frequencies, where just FFT'ing the time series data\nwouldnt be efficient OR if only the evaluation at weirdly spaced frequencies\nis required. Otherwise its more efficient to just do an FFT on the time\nseries recording after the simulation has finished.

        \n
        \n
        \n

        Parameters

        \n
        \n
        freq : array[float]
        \n
        list of evaluation frequencies for RFT, can be arbitrarily spaced
        \n
        t_wait : float
        \n
        wait time before starting RFT
        \n
        alpha : float
        \n
        exponential forgetting factor for realtime spectrum
        \n
        labels : list[str]
        \n
        labels for the inputs
        \n
        \n
        \n", + "description": "Block for fourier spectrum analysis (spectrum analyzer).", + "docstringHtml": "

        Block for fourier spectrum analysis (spectrum analyzer).

        \n

        Computes continuous time running fourier transform (RFT) of the incoming signal.

        \n

        A time threshold can be set by 't_wait' to start recording data only after the\nsimulation time is larger then the specified waiting time, i.e. 't - t_wait > dt'.\nThis is useful for recording the steady state after all the transients have settled.

        \n

        An exponential forgetting factor 'alpha' can be specified for realtime spectral\nanalysis. It biases the spectral components exponentially to the most recent signal\nvalues by applying a single sided exponential window like this:

        \n
        \n\\begin{equation*}\n\\int_0^t u(\\tau) \\exp(\\alpha (t-\\tau)) \\exp(-j \\omega \\tau)\\ d \\tau\n\\end{equation*}\n
        \n

        It is also known as the 'exponentially forgetting transform' (EFT) and a form of\nshort time fourier transform (STFT). It is implemented as a 1st order statespace model

        \n
        \n\\begin{equation*}\n\\dot{x} = - \\alpha x + \\exp(-j \\omega t) u\n\\end{equation*}\n
        \n

        where 'u' is the input signal and 'x' is the state variable that represents the\ncomplex fourier coefficient to the frequency 'omega'. The ODE is integrated using the\nnumerical integration engine of the block.

        \n
        \n

        Example

        \n

        This is how to initialize it:

        \n
        \nimport numpy as np\n\n#linear frequencies (0Hz, DC -> 1kHz)\nsp1 = Spectrum(\n    freq=np.linspace(0, 1e3, 100),\n    labels=['x1', 'x2'] #labels for two inputs\n    )\n\n#log frequencies (1Hz -> 1kHz)\nsp2 = Spectrum(\n    freq=np.logspace(0, 3, 100)\n    )\n\n#log frequencies including DC (0Hz, DC + 1Hz -> 1kHz)\nsp3 = Spectrum(\n    freq=np.hstack([0.0, np.logspace(0, 3, 100)])\n    )\n\n#arbitrary frequencies\nsp4 = Spectrum(\n    freq=np.array([0, 0.5, 20, 1e3])\n    )\n
        \n
        \n
        \n

        Note

        \n

        This block is relatively slow! But it is valuable for long running simulations\nwith few evaluation frequencies, where just FFT'ing the time series data\nwouldnt be efficient OR if only the evaluation at weirdly spaced frequencies\nis required. Otherwise its more efficient to just do an FFT on the time\nseries recording after the simulation has finished.

        \n
        \n
        \n

        Parameters

        \n
        \n
        freq : array[float]
        \n
        list of evaluation frequencies for RFT, can be arbitrarily spaced
        \n
        t_wait : float
        \n
        wait time before starting RFT
        \n
        alpha : float
        \n
        exponential forgetting factor for realtime spectrum
        \n
        labels : list[str]
        \n
        labels for the inputs
        \n
        \n
        \n", "params": { "freq": { "type": "array", @@ -1160,13 +1207,13 @@ export const extractedBlocks: Record = "description": "labels for the inputs" } }, - "inputs": [], + "inputs": null, "outputs": [] }, "Subsystem": { "blockClass": "Subsystem", "description": "Subsystem class that holds its own blocks and connecions and", - "docstringHtml": "

        Subsystem class that holds its own blocks and connecions and\ncan natively interface with the main simulation loop.

        \n

        IO interface is realized by a special 'Interface' block, that has extra\nmethods for setting and getting inputs and outputs and serves\nas the interface of the internal blocks to the outside.

        \n

        The subsystem doesnt use its 'inputs' and 'outputs' dicts directly.\nIt exclusively handles data transfer via the 'Interface' block.

        \n

        This class can be used just like any other block during the simulation,\nsince it implements the required methods 'update' for the fixed-point\niteration (resolving algebraic loops with instant time blocks),\nthe 'step' method that performs timestepping (especially for dynamic\nblocks with internal states) and the 'solve' method for solving the\nimplicit update equation for implicit solvers.

        \n
        \n

        Example

        \n

        This is how we can wrap up multiple blocks within a subsystem.\nIn this case vanderpol system built from discrete components\ninstead of using an ODE block (in practice you should use\na monolithic ODE whenever possible due to performance).

        \n
        \nfrom pathsim import Subsystem, Interface, Connection\nfrom pathsim.blocks import Integrator, Function\n\n#van der Pol parameter\nmu = 1000\n\n#blocks in the subsystem\nIf = Interface() # this is the interface to the outside\nI1 = Integrator(2)\nI2 = Integrator(0)\nFn = Function(lambda x1, x2: mu*(1 - x1**2)*x2 - x1)\n\nsub_blocks = [If, I1, I2, Fn]\n\n#connections in the subsystem\nsub_connections = [\n    Connection(I2, I1, Fn[1], If[1]),\n    Connection(I1, Fn, If),\n    Connection(Fn, I2)\n    ]\n\n#the subsystem acts just like a normal block\nvdp = Subsystem(sub_blocks, sub_connections)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        blocks : list[Block]
        \n
        internal blocks of the subsystem
        \n
        connections : list[Connection]
        \n
        internal connections of the subsystem
        \n
        tolerance_fpi : float
        \n
        absolute tolerance for convergence of algebraic loops\ndefault see ´SIM_TOLERANCE_FPI´ in ´_constants.py´
        \n
        iterations_max : int
        \n
        maximum allowed number of iterations for algebraic loop\nsolver, default see ´SIM_ITERATIONS_MAX´ in ´_constants.py´
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        interface : Interface
        \n
        internal interface block for data transfer to the outside
        \n
        graph : Graph
        \n
        internal graph representation for fast system funcion\nevluations using DAG with algebraic depths
        \n
        boosters : None | list[ConnectionBooster]
        \n
        list of boosters (fixed point accelerators) that wrap\nalgebraic loop closing connections assembled from the\nsystem graph
        \n
        \n
        \n", + "docstringHtml": "

        Subsystem class that holds its own blocks and connecions and\ncan natively interface with the main simulation loop.

        \n

        IO interface is realized by a special 'Interface' block, that has extra\nmethods for setting and getting inputs and outputs and serves\nas the interface of the internal blocks to the outside.

        \n

        The subsystem doesnt use its 'inputs' and 'outputs' dicts directly.\nIt exclusively handles data transfer via the 'Interface' block.

        \n

        This class can be used just like any other block during the simulation,\nsince it implements the required methods 'update' for the fixed-point\niteration (resolving algebraic loops with instant time blocks),\nthe 'step' method that performs timestepping (especially for dynamic\nblocks with internal states) and the 'solve' method for solving the\nimplicit update equation for implicit solvers.

        \n
        \n

        Example

        \n

        This is how we can wrap up multiple blocks within a subsystem.\nIn this case vanderpol system built from discrete components\ninstead of using an ODE block (in practice you should use\na monolithic ODE whenever possible due to performance).

        \n
        \nfrom pathsim import Subsystem, Interface, Connection\nfrom pathsim.blocks import Integrator, Function\n\n#van der Pol parameter\nmu = 1000\n\n#blocks in the subsystem\nIf = Interface() # this is the interface to the outside\nI1 = Integrator(2)\nI2 = Integrator(0)\nFn = Function(lambda x1, x2: mu*(1 - x1**2)*x2 - x1)\n\nsub_blocks = [If, I1, I2, Fn]\n\n#connections in the subsystem\nsub_connections = [\n    Connection(I2, I1, Fn[1], If[1]),\n    Connection(I1, Fn, If),\n    Connection(Fn, I2)\n    ]\n\n#the subsystem acts just like a normal block\nvdp = Subsystem(sub_blocks, sub_connections)\n
        \n
        \n
        \n

        Parameters

        \n
        \n
        blocks : list[Block] | None
        \n
        internal blocks of the subsystem
        \n
        connections : list[Connection] | None
        \n
        internal connections of the subsystem
        \n
        \n

        events : list[Event] | None\ntolerance_fpi : float

        \n
        \nabsolute tolerance for convergence of algebraic loops\ndefault see ´SIM_TOLERANCE_FPI´ in ´_constants.py´
        \n
        \n
        iterations_max : int
        \n
        maximum allowed number of iterations for algebraic loop\nsolver, default see ´SIM_ITERATIONS_MAX´ in ´_constants.py´
        \n
        \n
        \n
        \n

        Attributes

        \n
        \n
        interface : Interface
        \n
        internal interface block for data transfer to the outside
        \n
        graph : Graph
        \n
        internal graph representation for fast system funcion\nevluations using DAG with algebraic depths
        \n
        boosters : None | list[ConnectionBooster]
        \n
        list of boosters (fixed point accelerators) that wrap\nalgebraic loop closing connections assembled from the\nsystem graph
        \n
        \n
        \n", "params": {}, "inputs": [], "outputs": [] @@ -1200,11 +1247,8 @@ export const extractedBlocks: Record = "description": "constant source term / generation term of the process" } }, - "inputs": [], - "outputs": [ - "x", - "x/tau" - ] + "inputs": null, + "outputs": null }, "Bubbler4": { "blockClass": "Bubbler4", @@ -1227,17 +1271,8 @@ export const extractedBlocks: Record = "description": "Times at which each vial is replaced with a fresh one. If None, no replacement events are created. If a single value is provided, it is used for all vials. If a single list of floats is provided, it will be used for all vials. If a list of lists is provided, each sublist corresponds to the replacement times for each vial." } }, - "inputs": [ - "sample_in_soluble", - "sample_in_insoluble" - ], - "outputs": [ - "vial1", - "vial2", - "vial3", - "vial4", - "sample_out" - ] + "inputs": null, + "outputs": null }, "Splitter": { "blockClass": "Splitter", @@ -1250,12 +1285,8 @@ export const extractedBlocks: Record = "description": "fractions to split the input signal into, must sum up to one" } }, - "inputs": [ - "in" - ], - "outputs": [ - "out 1.0" - ] + "inputs": null, + "outputs": null }, "GLC": { "blockClass": "GLC", @@ -1298,8 +1329,8 @@ export const extractedBlocks: Record = "description": "BCs: Boundary conditions type, \"C-C\" (Closed-Closed) or \"O-C\" (Open-Closed), default is \"C-C\"" } }, - "inputs": [], - "outputs": [] + "inputs": null, + "outputs": null } }; @@ -1307,7 +1338,7 @@ export const blockConfig: Record = { Sources: ["Constant", "Source", "SinusoidalSource", "StepSource", "PulseSource", "TriangleWaveSource", "SquareWaveSource", "GaussianPulseSource", "ChirpPhaseNoiseSource", "ClockSource", "WhiteNoise", "PinkNoise", "RandomNumberGenerator"], Dynamic: ["Integrator", "Differentiator", "Delay", "ODE", "DynamicalSystem", "StateSpace", "PID", "AntiWindupPID", "TransferFunctionNumDen", "TransferFunctionZPG", "ButterworthLowpassFilter", "ButterworthHighpassFilter", "ButterworthBandpassFilter", "ButterworthBandstopFilter"], Algebraic: ["Adder", "Multiplier", "Amplifier", "Function", "Sin", "Cos", "Tan", "Tanh", "Abs", "Sqrt", "Exp", "Log", "Log10", "Mod", "Clip", "Pow", "Switch", "LUT", "LUT1D"], - Mixed: ["SampleHold", "FIR", "ADC", "DAC", "Counter", "CounterUp", "CounterDown"], + Mixed: ["SampleHold", "FIR", "ADC", "DAC", "Counter", "CounterUp", "CounterDown", "Relay"], Recording: ["Scope", "Spectrum"], Chemical: ["Process", "Bubbler4", "Splitter", "GLC"], }; From f3387fa93c3efa3bd2c9a96b9b6252811d6aa847 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 22:45:45 +0100 Subject: [PATCH 197/656] Route wires around all nodes including source and target --- src/lib/routing/gridBuilder.ts | 11 ++--------- src/lib/routing/routeCalculator.ts | 5 ++--- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/lib/routing/gridBuilder.ts b/src/lib/routing/gridBuilder.ts index d83fedaa..b4a1a51f 100644 --- a/src/lib/routing/gridBuilder.ts +++ b/src/lib/routing/gridBuilder.ts @@ -24,12 +24,8 @@ export function gridToWorld(gx: number): number { /** * Build pathfinding grid with obstacles marked * @param context - Routing context with node bounds and canvas bounds - * @param excludeNodeIds - Node IDs to exclude from obstacles (source/target nodes) */ -export function buildGrid( - context: RoutingContext, - excludeNodeIds: Set = new Set() -): PF.Grid { +export function buildGrid(context: RoutingContext): PF.Grid { const { nodeBounds, canvasBounds } = context; // Calculate grid dimensions from canvas bounds @@ -41,10 +37,7 @@ export function buildGrid( const grid = new PF.Grid(gridWidth, gridHeight); // Mark obstacles (nodes with margin) - for (const [nodeId, bounds] of nodeBounds) { - // Skip excluded nodes (source/target) - if (excludeNodeIds.has(nodeId)) continue; - + for (const [, bounds] of nodeBounds) { // Add margin around node const marginBounds = { x: bounds.x - ROUTING_MARGIN, diff --git a/src/lib/routing/routeCalculator.ts b/src/lib/routing/routeCalculator.ts index 7654583c..3967f3ac 100644 --- a/src/lib/routing/routeCalculator.ts +++ b/src/lib/routing/routeCalculator.ts @@ -57,9 +57,8 @@ export function calculateRoute( return distA - distB; }); - // Build pathfinding grid, excluding source and target nodes - const excludeNodes = new Set([connection.sourceNodeId, connection.targetNodeId]); - const grid = buildGrid(context, excludeNodes); + // Build pathfinding grid with all nodes as obstacles + const grid = buildGrid(context); const offset = getGridOffset(context); // Calculate clearance points to enforce entry/exit directions From 9d89e3258cb5e79cf3e252bbf976d3f7ab7b3683 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 22:48:17 +0100 Subject: [PATCH 198/656] Route around all nodes with 0.25G margin --- src/lib/routing/constants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/routing/constants.ts b/src/lib/routing/constants.ts index b5cf5799..b470730e 100644 --- a/src/lib/routing/constants.ts +++ b/src/lib/routing/constants.ts @@ -4,8 +4,8 @@ import { G } from '$lib/constants/grid'; -/** Margin around nodes for routing (2 grid units = 20px) */ -export const ROUTING_MARGIN = G.x2; +/** Margin around nodes for routing (0.25G = 2.5px) */ +export const ROUTING_MARGIN = G.unit / 4; /** Minimum distance from source port before first turn (0 - handle offset is enough) */ export const SOURCE_CLEARANCE = 0; From cbad19a4948a6ff87109595b893f09ab60f43ed6 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 22:50:58 +0100 Subject: [PATCH 199/656] Increase routing margin to 0.5G --- src/lib/routing/constants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/routing/constants.ts b/src/lib/routing/constants.ts index b470730e..1f34cb9e 100644 --- a/src/lib/routing/constants.ts +++ b/src/lib/routing/constants.ts @@ -4,8 +4,8 @@ import { G } from '$lib/constants/grid'; -/** Margin around nodes for routing (0.25G = 2.5px) */ -export const ROUTING_MARGIN = G.unit / 4; +/** Margin around nodes for routing (0.5G = 5px) */ +export const ROUTING_MARGIN = G.unit / 2; /** Minimum distance from source port before first turn (0 - handle offset is enough) */ export const SOURCE_CLEARANCE = 0; From 8b62c07b6d2c01e0addae10a2e471ef6945f4dda Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 22:54:46 +0100 Subject: [PATCH 200/656] Draw orthogonal stubs to connect handles to grid-aligned path --- .../components/edges/OrthogonalEdge.svelte | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index 0a93b908..ad1f27ef 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -102,21 +102,42 @@ const tgt = adjustedTarget(); if (routeResult?.path && routeResult.path.length >= 2) { - // Use calculated route - start from handle tip, then follow the route - // The route already has clearance points, so we draw: - // handle tip -> first route point -> ... -> last route point -> handle tip + // Use calculated route with orthogonal stubs at ends + // Stubs ensure grid alignment: handle tip -> stub end (grid) -> route -> stub end (grid) -> handle tip const points = routeResult.path; + const firstPoint = points[0]; + const lastPoint = points[points.length - 1]; // Start at handle tip let d = `M ${src.x} ${src.y}`; - // Draw through all route points (which include clearance points for proper stubs) - for (let i = 0; i < points.length; i++) { + // Source stub: orthogonal segment from handle to first route point + // Based on port direction, draw horizontal or vertical first + if (sourcePosition === 'right' || sourcePosition === 'left') { + // Horizontal port: draw horizontal stub, then vertical if needed + d += ` L ${firstPoint.x} ${src.y}`; + if (src.y !== firstPoint.y) d += ` L ${firstPoint.x} ${firstPoint.y}`; + } else { + // Vertical port: draw vertical stub, then horizontal if needed + d += ` L ${src.x} ${firstPoint.y}`; + if (src.x !== firstPoint.x) d += ` L ${firstPoint.x} ${firstPoint.y}`; + } + + // Draw through intermediate route points (skip first, we already drew to it) + for (let i = 1; i < points.length; i++) { d += ` L ${points[i].x} ${points[i].y}`; } - // End at handle tip - d += ` L ${tgt.x} ${tgt.y}`; + // Target stub: orthogonal segment from last route point to handle + if (targetPosition === 'right' || targetPosition === 'left') { + // Horizontal port: align vertically first, then horizontal to handle + if (lastPoint.y !== tgt.y) d += ` L ${lastPoint.x} ${tgt.y}`; + d += ` L ${tgt.x} ${tgt.y}`; + } else { + // Vertical port: align horizontally first, then vertical to handle + if (lastPoint.x !== tgt.x) d += ` L ${tgt.x} ${lastPoint.y}`; + d += ` L ${tgt.x} ${tgt.y}`; + } return d; } From d8d42047a11fda61c5ef622ad6bcc5e4e7ac1766 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 22:58:19 +0100 Subject: [PATCH 201/656] Use H/V SVG commands to guarantee orthogonal paths --- .../components/edges/OrthogonalEdge.svelte | 47 ++++++++++--------- src/lib/routing/pathfinder.ts | 15 ++++-- 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index ad1f27ef..33720327 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -102,41 +102,46 @@ const tgt = adjustedTarget(); if (routeResult?.path && routeResult.path.length >= 2) { - // Use calculated route with orthogonal stubs at ends - // Stubs ensure grid alignment: handle tip -> stub end (grid) -> route -> stub end (grid) -> handle tip + // Use calculated route - draw only orthogonal segments const points = routeResult.path; - const firstPoint = points[0]; - const lastPoint = points[points.length - 1]; // Start at handle tip let d = `M ${src.x} ${src.y}`; - // Source stub: orthogonal segment from handle to first route point - // Based on port direction, draw horizontal or vertical first + // Source stub: go in port direction to align with first route point if (sourcePosition === 'right' || sourcePosition === 'left') { - // Horizontal port: draw horizontal stub, then vertical if needed - d += ` L ${firstPoint.x} ${src.y}`; - if (src.y !== firstPoint.y) d += ` L ${firstPoint.x} ${firstPoint.y}`; + // Horizontal port: horizontal to firstPoint.x, then vertical to firstPoint.y + d += ` H ${points[0].x}`; + d += ` V ${points[0].y}`; } else { - // Vertical port: draw vertical stub, then horizontal if needed - d += ` L ${src.x} ${firstPoint.y}`; - if (src.x !== firstPoint.x) d += ` L ${firstPoint.x} ${firstPoint.y}`; + // Vertical port: vertical to firstPoint.y, then horizontal to firstPoint.x + d += ` V ${points[0].y}`; + d += ` H ${points[0].x}`; } - // Draw through intermediate route points (skip first, we already drew to it) + // Draw through route points using only H and V commands (never diagonal) for (let i = 1; i < points.length; i++) { - d += ` L ${points[i].x} ${points[i].y}`; + const prev = points[i - 1]; + const curr = points[i]; + // Determine if horizontal or vertical based on which coord changed more + if (Math.abs(curr.x - prev.x) > Math.abs(curr.y - prev.y)) { + d += ` H ${curr.x}`; + if (Math.abs(curr.y - prev.y) > 0.5) d += ` V ${curr.y}`; + } else { + d += ` V ${curr.y}`; + if (Math.abs(curr.x - prev.x) > 0.5) d += ` H ${curr.x}`; + } } - // Target stub: orthogonal segment from last route point to handle + // Target stub: from last route point to handle if (targetPosition === 'right' || targetPosition === 'left') { - // Horizontal port: align vertically first, then horizontal to handle - if (lastPoint.y !== tgt.y) d += ` L ${lastPoint.x} ${tgt.y}`; - d += ` L ${tgt.x} ${tgt.y}`; + // Horizontal port: vertical first, then horizontal + d += ` V ${tgt.y}`; + d += ` H ${tgt.x}`; } else { - // Vertical port: align horizontally first, then vertical to handle - if (lastPoint.x !== tgt.x) d += ` L ${tgt.x} ${lastPoint.y}`; - d += ` L ${tgt.x} ${tgt.y}`; + // Vertical port: horizontal first, then vertical + d += ` H ${tgt.x}`; + d += ` V ${tgt.y}`; } return d; diff --git a/src/lib/routing/pathfinder.ts b/src/lib/routing/pathfinder.ts index 58ce3c95..88aaa050 100644 --- a/src/lib/routing/pathfinder.ts +++ b/src/lib/routing/pathfinder.ts @@ -178,8 +178,15 @@ export function findPathWithTurnPenalty( } } - // No path found, return direct line - return [start, end]; + // No path found, return L-shaped fallback (never diagonal) + // Go in initial direction first, then turn + if (initialDir === 'right' || initialDir === 'left') { + // Horizontal first, then vertical + return [start, { x: end.x, y: start.y }, end]; + } else { + // Vertical first, then horizontal + return [start, { x: start.x, y: end.y }, end]; + } } /** @@ -244,9 +251,9 @@ export function findPath( // Find path const rawPath = finder.findPath(startGx, startGy, endGx, endGy, gridClone); - // If no path found, return direct line (fallback) + // If no path found, return L-shaped fallback (never diagonal) if (rawPath.length === 0) { - return [start, end]; + return [start, { x: end.x, y: start.y }, end]; } // Convert back to world coordinates From 75b82d059c4769da6d3ade4e764f42d2799bbd31 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 23:00:54 +0100 Subject: [PATCH 202/656] Use H/V for all route points to guarantee orthogonal paths --- .../components/edges/OrthogonalEdge.svelte | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index 33720327..b26b0a25 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -102,44 +102,31 @@ const tgt = adjustedTarget(); if (routeResult?.path && routeResult.path.length >= 2) { - // Use calculated route - draw only orthogonal segments + // Use calculated route - draw only H/V segments (never diagonal) const points = routeResult.path; - // Start at handle tip let d = `M ${src.x} ${src.y}`; - // Source stub: go in port direction to align with first route point + // Source stub: go in port direction first if (sourcePosition === 'right' || sourcePosition === 'left') { - // Horizontal port: horizontal to firstPoint.x, then vertical to firstPoint.y d += ` H ${points[0].x}`; d += ` V ${points[0].y}`; } else { - // Vertical port: vertical to firstPoint.y, then horizontal to firstPoint.x d += ` V ${points[0].y}`; d += ` H ${points[0].x}`; } - // Draw through route points using only H and V commands (never diagonal) + // Draw through route points using H/V only for (let i = 1; i < points.length; i++) { - const prev = points[i - 1]; - const curr = points[i]; - // Determine if horizontal or vertical based on which coord changed more - if (Math.abs(curr.x - prev.x) > Math.abs(curr.y - prev.y)) { - d += ` H ${curr.x}`; - if (Math.abs(curr.y - prev.y) > 0.5) d += ` V ${curr.y}`; - } else { - d += ` V ${curr.y}`; - if (Math.abs(curr.x - prev.x) > 0.5) d += ` H ${curr.x}`; - } + d += ` H ${points[i].x}`; + d += ` V ${points[i].y}`; } - // Target stub: from last route point to handle + // Target stub: approach in port direction if (targetPosition === 'right' || targetPosition === 'left') { - // Horizontal port: vertical first, then horizontal d += ` V ${tgt.y}`; d += ` H ${tgt.x}`; } else { - // Vertical port: horizontal first, then vertical d += ` H ${tgt.x}`; d += ` V ${tgt.y}`; } From cd9be7d8728f23175b4799198e40663e547727ac Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 23:01:41 +0100 Subject: [PATCH 203/656] Reduce routing margin to 0.25G --- src/lib/routing/constants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/routing/constants.ts b/src/lib/routing/constants.ts index 1f34cb9e..b470730e 100644 --- a/src/lib/routing/constants.ts +++ b/src/lib/routing/constants.ts @@ -4,8 +4,8 @@ import { G } from '$lib/constants/grid'; -/** Margin around nodes for routing (0.5G = 5px) */ -export const ROUTING_MARGIN = G.unit / 2; +/** Margin around nodes for routing (0.25G = 2.5px) */ +export const ROUTING_MARGIN = G.unit / 4; /** Minimum distance from source port before first turn (0 - handle offset is enough) */ export const SOURCE_CLEARANCE = 0; From c32f447e7bdbd3e2cfc64559da240c2545bbd384 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 23:02:43 +0100 Subject: [PATCH 204/656] Add 1G source clearance to enforce exit direction --- src/lib/routing/constants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/routing/constants.ts b/src/lib/routing/constants.ts index b470730e..149f3ede 100644 --- a/src/lib/routing/constants.ts +++ b/src/lib/routing/constants.ts @@ -7,8 +7,8 @@ import { G } from '$lib/constants/grid'; /** Margin around nodes for routing (0.25G = 2.5px) */ export const ROUTING_MARGIN = G.unit / 4; -/** Minimum distance from source port before first turn (0 - handle offset is enough) */ -export const SOURCE_CLEARANCE = 0; +/** Minimum distance from source port before first turn (1G = 10px) */ +export const SOURCE_CLEARANCE = G.unit; /** Minimum distance from target port before first turn (1 grid unit = 10px) */ export const TARGET_CLEARANCE = G.unit; From 4ea0789d97e924fd59b1b6f2192cb377e662c228 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 23:05:32 +0100 Subject: [PATCH 205/656] Simplify path rendering - only add segments when coordinates change --- .../components/edges/OrthogonalEdge.svelte | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index b26b0a25..c736a4d5 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -102,32 +102,30 @@ const tgt = adjustedTarget(); if (routeResult?.path && routeResult.path.length >= 2) { - // Use calculated route - draw only H/V segments (never diagonal) + // Use calculated route - draw only H/V segments const points = routeResult.path; let d = `M ${src.x} ${src.y}`; - - // Source stub: go in port direction first - if (sourcePosition === 'right' || sourcePosition === 'left') { - d += ` H ${points[0].x}`; - d += ` V ${points[0].y}`; - } else { - d += ` V ${points[0].y}`; - d += ` H ${points[0].x}`; - } - - // Draw through route points using H/V only - for (let i = 1; i < points.length; i++) { - d += ` H ${points[i].x}`; - d += ` V ${points[i].y}`; + let currentX = src.x; + let currentY = src.y; + + // Draw to each point using H/V, choosing direction based on which changes + for (const pt of points) { + if (Math.abs(pt.x - currentX) > 0.1) { + d += ` H ${pt.x}`; + currentX = pt.x; + } + if (Math.abs(pt.y - currentY) > 0.1) { + d += ` V ${pt.y}`; + currentY = pt.y; + } } - // Target stub: approach in port direction - if (targetPosition === 'right' || targetPosition === 'left') { - d += ` V ${tgt.y}`; - d += ` H ${tgt.x}`; - } else { + // Final segment to target handle + if (Math.abs(tgt.x - currentX) > 0.1) { d += ` H ${tgt.x}`; + } + if (Math.abs(tgt.y - currentY) > 0.1) { d += ` V ${tgt.y}`; } From 7ede02f0e5a9511a3bc4a6bba4f3cbc4afc3bf3b Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 23:06:39 +0100 Subject: [PATCH 206/656] Follow exact A* path direction between consecutive points --- .../components/edges/OrthogonalEdge.svelte | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index c736a4d5..b86a64f0 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -102,30 +102,39 @@ const tgt = adjustedTarget(); if (routeResult?.path && routeResult.path.length >= 2) { - // Use calculated route - draw only H/V segments + // Use calculated route - follow exact path from A* const points = routeResult.path; let d = `M ${src.x} ${src.y}`; - let currentX = src.x; - let currentY = src.y; + let prevX = src.x; + let prevY = src.y; - // Draw to each point using H/V, choosing direction based on which changes + // Draw to each point, determining direction from previous point for (const pt of points) { - if (Math.abs(pt.x - currentX) > 0.1) { + const dx = Math.abs(pt.x - prevX); + const dy = Math.abs(pt.y - prevY); + + if (dx > 0.1 && dy > 0.1) { + // Both changed - need to pick order (shouldn't happen with proper A*) + // Use port direction for first point, then alternate + d += ` H ${pt.x} V ${pt.y}`; + } else if (dx > 0.1) { d += ` H ${pt.x}`; - currentX = pt.x; - } - if (Math.abs(pt.y - currentY) > 0.1) { + } else if (dy > 0.1) { d += ` V ${pt.y}`; - currentY = pt.y; } + prevX = pt.x; + prevY = pt.y; } - // Final segment to target handle - if (Math.abs(tgt.x - currentX) > 0.1) { + // Final segment to target + const dx = Math.abs(tgt.x - prevX); + const dy = Math.abs(tgt.y - prevY); + if (dx > 0.1 && dy > 0.1) { + d += ` H ${tgt.x} V ${tgt.y}`; + } else if (dx > 0.1) { d += ` H ${tgt.x}`; - } - if (Math.abs(tgt.y - currentY) > 0.1) { + } else if (dy > 0.1) { d += ` V ${tgt.y}`; } From e8e4704b4c0dde64c95977db4fb21c7d912e5d79 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 23:09:59 +0100 Subject: [PATCH 207/656] Revert target clearance direction change --- src/lib/routing/routeCalculator.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/routing/routeCalculator.ts b/src/lib/routing/routeCalculator.ts index 3967f3ac..9e9756b8 100644 --- a/src/lib/routing/routeCalculator.ts +++ b/src/lib/routing/routeCalculator.ts @@ -63,8 +63,7 @@ export function calculateRoute( // Calculate clearance points to enforce entry/exit directions const sourceClearance = getClearancePoint(sourcePos, sourceDir, SOURCE_CLEARANCE); - // Target clearance: place point outside the block in the direction the port faces - // Wire will travel TO this point, then straight INTO the port + // Target clearance: in the direction the port faces (wire enters from that direction) const targetClearance = getClearancePoint(targetPos, targetDir, TARGET_CLEARANCE); // Build path: source -> sourceClearance -> [waypoints] -> targetClearance -> target From 2bdbc9c01461c14df82dbdbc21a4ee244eb08287 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 23:11:10 +0100 Subject: [PATCH 208/656] Disable turn penalty to debug backtracking --- src/lib/routing/pathfinder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/routing/pathfinder.ts b/src/lib/routing/pathfinder.ts index 88aaa050..f0c6b7f1 100644 --- a/src/lib/routing/pathfinder.ts +++ b/src/lib/routing/pathfinder.ts @@ -9,7 +9,7 @@ import { worldToGrid, gridToWorld } from './gridBuilder'; import { GRID_SIZE } from './constants'; /** Cost for making a 90-degree turn (in grid units) */ -const TURN_PENALTY = 2; +const TURN_PENALTY = 0; /** Map direction to its opposite (for blocking 180-degree turns) */ const OPPOSITE_DIR: Record = { From 32f189dda7e5da8265d0cab65e16ad2273d1f3ee Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 23:13:12 +0100 Subject: [PATCH 209/656] Use exact route path without handle adjustments --- .../components/edges/OrthogonalEdge.svelte | 37 +++---------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index b86a64f0..7a6247fe 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -102,40 +102,15 @@ const tgt = adjustedTarget(); if (routeResult?.path && routeResult.path.length >= 2) { - // Use calculated route - follow exact path from A* + // Use calculated route exactly as returned const points = routeResult.path; - let d = `M ${src.x} ${src.y}`; - let prevX = src.x; - let prevY = src.y; - - // Draw to each point, determining direction from previous point - for (const pt of points) { - const dx = Math.abs(pt.x - prevX); - const dy = Math.abs(pt.y - prevY); - - if (dx > 0.1 && dy > 0.1) { - // Both changed - need to pick order (shouldn't happen with proper A*) - // Use port direction for first point, then alternate - d += ` H ${pt.x} V ${pt.y}`; - } else if (dx > 0.1) { - d += ` H ${pt.x}`; - } else if (dy > 0.1) { - d += ` V ${pt.y}`; - } - prevX = pt.x; - prevY = pt.y; - } + // Start from first route point (sourcePos from routing) + let d = `M ${points[0].x} ${points[0].y}`; - // Final segment to target - const dx = Math.abs(tgt.x - prevX); - const dy = Math.abs(tgt.y - prevY); - if (dx > 0.1 && dy > 0.1) { - d += ` H ${tgt.x} V ${tgt.y}`; - } else if (dx > 0.1) { - d += ` H ${tgt.x}`; - } else if (dy > 0.1) { - d += ` V ${tgt.y}`; + // Draw through all points + for (let i = 1; i < points.length; i++) { + d += ` L ${points[i].x} ${points[i].y}`; } return d; From b0245100a7fd3f9cc72c7a6155e8e2cad635d428 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 23:14:55 +0100 Subject: [PATCH 210/656] Separate route points from handle positions - route only contains clearance points --- src/lib/components/edges/OrthogonalEdge.svelte | 17 ++++++++++------- src/lib/routing/routeCalculator.ts | 5 +++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index 7a6247fe..f747c083 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -101,18 +101,21 @@ const src = adjustedSource(); const tgt = adjustedTarget(); - if (routeResult?.path && routeResult.path.length >= 2) { - // Use calculated route exactly as returned + if (routeResult?.path && routeResult.path.length >= 1) { + // Route contains clearance points - connect from actual handles const points = routeResult.path; - // Start from first route point (sourcePos from routing) - let d = `M ${points[0].x} ${points[0].y}`; + // Start at source handle + let d = `M ${src.x} ${src.y}`; - // Draw through all points - for (let i = 1; i < points.length; i++) { - d += ` L ${points[i].x} ${points[i].y}`; + // Draw through all route points (clearance + intermediates) + for (const pt of points) { + d += ` L ${pt.x} ${pt.y}`; } + // End at target handle + d += ` L ${tgt.x} ${tgt.y}`; + return d; } diff --git a/src/lib/routing/routeCalculator.ts b/src/lib/routing/routeCalculator.ts index 9e9756b8..65f3815f 100644 --- a/src/lib/routing/routeCalculator.ts +++ b/src/lib/routing/routeCalculator.ts @@ -123,8 +123,9 @@ export function calculateRoute( // Add target clearance point allPoints.push(targetClearance); - // Build complete path: source -> clearance -> intermediate -> clearance -> target - const fullPath = [sourcePos, ...allPoints, targetPos]; + // Build complete path: just clearance points and intermediates (not port positions) + // The rendering will connect from actual handles to these points + const fullPath = allPoints; // Snap to grid and deduplicate const snappedPath = deduplicatePath(snapPathToGrid(fullPath)); From 8e190340f2a0d257ea9e19db000c085ced88491f Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 23:17:52 +0100 Subject: [PATCH 211/656] Rewrite route calculator - simple and clean --- .../components/edges/OrthogonalEdge.svelte | 10 +- src/lib/routing/routeCalculator.ts | 238 ++++-------------- 2 files changed, 55 insertions(+), 193 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index f747c083..0733ef48 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -101,19 +101,15 @@ const src = adjustedSource(); const tgt = adjustedTarget(); - if (routeResult?.path && routeResult.path.length >= 1) { - // Route contains clearance points - connect from actual handles + if (routeResult?.path && routeResult.path.length >= 2) { + // Route = [sourceStubEnd, ...intermediates, targetStubEnd] + // Draw: handle -> route -> handle const points = routeResult.path; - // Start at source handle let d = `M ${src.x} ${src.y}`; - - // Draw through all route points (clearance + intermediates) for (const pt of points) { d += ` L ${pt.x} ${pt.y}`; } - - // End at target handle d += ` L ${tgt.x} ${tgt.y}`; return d; diff --git a/src/lib/routing/routeCalculator.ts b/src/lib/routing/routeCalculator.ts index 65f3815f..7105192a 100644 --- a/src/lib/routing/routeCalculator.ts +++ b/src/lib/routing/routeCalculator.ts @@ -1,5 +1,5 @@ /** - * Main route calculation orchestrator + * Simple orthogonal route calculator */ import type { Position } from '$lib/types/common'; @@ -8,36 +8,23 @@ import type { RoutingContext, RouteResult, RouteSegment, Direction } from './typ import { DIRECTION_VECTORS } from './types'; import { buildGrid, getGridOffset } from './gridBuilder'; import { findPathWithTurnPenalty } from './pathfinder'; -import { simplifyPath, snapPathToGrid, deduplicatePath } from './pathOptimizer'; +import { simplifyPath, snapToGrid } from './pathOptimizer'; import { SOURCE_CLEARANCE, TARGET_CLEARANCE } from './constants'; -let waypointIdCounter = 0; - -function generateWaypointId(): string { - return `wp_${Date.now()}_${waypointIdCounter++}`; -} - /** - * Calculate clearance point - a point at given distance from port in the port's facing direction + * Calculate stub endpoint (grid-aligned point where stub ends) */ -function getClearancePoint(portPos: Position, direction: Direction, clearance: number): Position { - if (clearance === 0) return portPos; +function getStubEnd(portPos: Position, direction: Direction, clearance: number): Position { const vec = DIRECTION_VECTORS[direction]; - return { + const point = { x: portPos.x + vec.x * clearance, y: portPos.y + vec.y * clearance }; + return snapToGrid(point); } /** - * Calculate route for a connection, respecting user waypoints and enforcing port directions - * @param connection - Connection with optional user waypoints - * @param sourcePos - Source port world position - * @param targetPos - Target port world position - * @param sourceDir - Direction the source port faces (wire exits in this direction) - * @param targetDir - Direction the target port faces (wire enters from opposite direction) - * @param context - Routing context with node bounds - * @returns Route result with path, waypoints, and segments + * Calculate route between two ports */ export function calculateRoute( connection: Connection, @@ -47,203 +34,82 @@ export function calculateRoute( targetDir: Direction, context: RoutingContext ): RouteResult { - // Get user waypoints, sorted by proximity to source - const userWaypoints = (connection.waypoints || []) - .filter((w) => w.isUserWaypoint) - .sort((a, b) => { - // Sort by manhattan distance from source - const distA = Math.abs(a.position.x - sourcePos.x) + Math.abs(a.position.y - sourcePos.y); - const distB = Math.abs(b.position.x - sourcePos.x) + Math.abs(b.position.y - sourcePos.y); - return distA - distB; - }); + // Calculate stub endpoints (grid-aligned virtual ports for A*) + const sourceStubEnd = getStubEnd(sourcePos, sourceDir, SOURCE_CLEARANCE); + const targetStubEnd = getStubEnd(targetPos, targetDir, TARGET_CLEARANCE); - // Build pathfinding grid with all nodes as obstacles + // Build pathfinding grid const grid = buildGrid(context); const offset = getGridOffset(context); - // Calculate clearance points to enforce entry/exit directions - const sourceClearance = getClearancePoint(sourcePos, sourceDir, SOURCE_CLEARANCE); - // Target clearance: in the direction the port faces (wire enters from that direction) - const targetClearance = getClearancePoint(targetPos, targetDir, TARGET_CLEARANCE); + // Find path from source stub end to target stub end + const rawPath = findPathWithTurnPenalty(sourceStubEnd, targetStubEnd, grid, offset, sourceDir); + const simplified = simplifyPath(rawPath); - // Build path: source -> sourceClearance -> [waypoints] -> targetClearance -> target - const allPoints: Position[] = []; - const allWaypoints: Waypoint[] = []; + // Path is: [sourceStubEnd, ...intermediates..., targetStubEnd] + // simplifyPath already includes start and end + const path = simplified; - // Start with source clearance segment (if clearance > 0) - if (SOURCE_CLEARANCE > 0) { - allPoints.push(sourceClearance); - } + return { + path, + waypoints: [], + segments: buildSegments(path) + }; +} - // Start pathfinding from source clearance (or source if no clearance) - let currentPos = SOURCE_CLEARANCE > 0 ? sourceClearance : sourcePos; - let currentDir = sourceDir; // Track current direction for turn penalties - - // Route through each user waypoint - for (const userWp of userWaypoints) { - const segmentPath = findPathWithTurnPenalty(currentPos, userWp.position, grid, offset, currentDir); - const simplified = simplifyPath(segmentPath); - - // Add intermediate points (skip first which is currentPos) - for (let i = 1; i < simplified.length - 1; i++) { - allPoints.push(simplified[i]); - // Create auto waypoint for intermediate points - allWaypoints.push({ - id: generateWaypointId(), - position: simplified[i], - isUserWaypoint: false - }); - } +/** + * Simple L-shaped route (no pathfinding) + */ +export function calculateSimpleRoute( + sourcePos: Position, + targetPos: Position, + sourceDir: Direction = 'right', + targetDir: Direction = 'left' +): RouteResult { + const sourceStubEnd = getStubEnd(sourcePos, sourceDir, SOURCE_CLEARANCE); + const targetStubEnd = getStubEnd(targetPos, targetDir, TARGET_CLEARANCE); - // Add user waypoint position - allPoints.push(userWp.position); - allWaypoints.push(userWp); - currentPos = userWp.position; + // Simple L-shape: go in source direction, then turn toward target + const path: Position[] = [sourceStubEnd]; - // Update current direction based on last segment - if (simplified.length >= 2) { - currentDir = getDirectionFromSegment(simplified[simplified.length - 2], simplified[simplified.length - 1]); + // Add corner if not aligned + if (sourceStubEnd.x !== targetStubEnd.x && sourceStubEnd.y !== targetStubEnd.y) { + if (sourceDir === 'right' || sourceDir === 'left') { + // Horizontal first, then vertical + path.push(snapToGrid({ x: targetStubEnd.x, y: sourceStubEnd.y })); + } else { + // Vertical first, then horizontal + path.push(snapToGrid({ x: sourceStubEnd.x, y: targetStubEnd.y })); } } - // Route to target clearance point - const finalPath = findPathWithTurnPenalty(currentPos, targetClearance, grid, offset, currentDir); - const simplified = simplifyPath(finalPath); - - // Add intermediate points from final segment - for (let i = 1; i < simplified.length - 1; i++) { - allPoints.push(simplified[i]); - allWaypoints.push({ - id: generateWaypointId(), - position: simplified[i], - isUserWaypoint: false - }); - } - - // Add target clearance point - allPoints.push(targetClearance); - - // Build complete path: just clearance points and intermediates (not port positions) - // The rendering will connect from actual handles to these points - const fullPath = allPoints; - - // Snap to grid and deduplicate - const snappedPath = deduplicatePath(snapPathToGrid(fullPath)); - - // Build segment info - const segments = buildSegments(snappedPath, allWaypoints); + path.push(targetStubEnd); return { - path: snappedPath, - waypoints: allWaypoints, - segments + path, + waypoints: [], + segments: buildSegments(path) }; } /** - * Get direction from a path segment + * Build segment info from path */ -function getDirectionFromSegment(from: Position, to: Position): Direction { - const dx = to.x - from.x; - const dy = to.y - from.y; - - if (Math.abs(dx) > Math.abs(dy)) { - return dx > 0 ? 'right' : 'left'; - } else { - return dy > 0 ? 'down' : 'up'; - } -} - -/** - * Build segment info from path and waypoints - */ -function buildSegments(path: Position[], waypoints: Waypoint[]): RouteSegment[] { +function buildSegments(path: Position[]): RouteSegment[] { const segments: RouteSegment[] = []; - // Create set of user waypoint positions for fast lookup - const userWaypointPositions = new Set( - waypoints.filter((w) => w.isUserWaypoint).map((w) => `${w.position.x},${w.position.y}`) - ); - for (let i = 0; i < path.length - 1; i++) { const start = path[i]; const end = path[i + 1]; - const isHorizontal = start.y === end.y; - - // Segment is "user" if either endpoint is a user waypoint - const startKey = `${start.x},${start.y}`; - const endKey = `${end.x},${end.y}`; - const isUserSegment = userWaypointPositions.has(startKey) || userWaypointPositions.has(endKey); segments.push({ index: i, startPoint: start, endPoint: end, - isHorizontal, - isUserSegment + isHorizontal: Math.abs(start.y - end.y) < 1, + isUserSegment: false }); } return segments; } - -/** - * Calculate simple L-shaped or Z-shaped route without pathfinding - * Enforces exit direction from source - */ -export function calculateSimpleRoute( - sourcePos: Position, - targetPos: Position, - sourceDir: Direction = 'right', - targetDir: Direction = 'left' -): RouteResult { - const path: Position[] = [sourcePos]; - - // Calculate clearance points - const sourceClearance = getClearancePoint(sourcePos, sourceDir, SOURCE_CLEARANCE); - const targetClearance = getClearancePoint(targetPos, targetDir, TARGET_CLEARANCE); - - // Add source clearance (if any) - if (SOURCE_CLEARANCE > 0) { - path.push(sourceClearance); - } - - // Use appropriate start point for routing calculations - const routeStart = SOURCE_CLEARANCE > 0 ? sourceClearance : sourcePos; - - // Determine if we need additional bends between clearance points - const dx = targetClearance.x - routeStart.x; - const dy = targetClearance.y - routeStart.y; - - // Check if points are aligned (straight connection possible) - const isHorizontalAligned = Math.abs(dy) < 1; - const isVerticalAligned = Math.abs(dx) < 1; - - if (!isHorizontalAligned && !isVerticalAligned) { - // Need intermediate point(s) for orthogonal routing - // Prefer routing that matches the source exit direction - if (sourceDir === 'right' || sourceDir === 'left') { - // Exit horizontally, so go horizontal first, then vertical - path.push({ x: targetClearance.x, y: routeStart.y }); - } else { - // Exit vertically, so go vertical first, then horizontal - path.push({ x: routeStart.x, y: targetClearance.y }); - } - } - - // Add target clearance and final target - if (TARGET_CLEARANCE > 0) { - path.push(targetClearance); - } - path.push(targetPos); - - // Deduplicate in case clearance points overlap with source/target - const finalPath = deduplicatePath(path); - const segments = buildSegments(finalPath, []); - - return { - path: finalPath, - waypoints: [], - segments - }; -} From 11561f6e0c6bc2af13982c463a75e53a979c3c11 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 23:18:05 +0100 Subject: [PATCH 212/656] Re-enable turn penalty --- src/lib/routing/pathfinder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/routing/pathfinder.ts b/src/lib/routing/pathfinder.ts index f0c6b7f1..88aaa050 100644 --- a/src/lib/routing/pathfinder.ts +++ b/src/lib/routing/pathfinder.ts @@ -9,7 +9,7 @@ import { worldToGrid, gridToWorld } from './gridBuilder'; import { GRID_SIZE } from './constants'; /** Cost for making a 90-degree turn (in grid units) */ -const TURN_PENALTY = 0; +const TURN_PENALTY = 2; /** Map direction to its opposite (for blocking 180-degree turns) */ const OPPOSITE_DIR: Record = { From 01aa55a1e112964447e5afe923b03fa4a3304036 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Fri, 23 Jan 2026 23:19:33 +0100 Subject: [PATCH 213/656] Snap grid offset to grid alignment --- src/lib/routing/gridBuilder.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/lib/routing/gridBuilder.ts b/src/lib/routing/gridBuilder.ts index b4a1a51f..2a2b4582 100644 --- a/src/lib/routing/gridBuilder.ts +++ b/src/lib/routing/gridBuilder.ts @@ -29,10 +29,11 @@ export function buildGrid(context: RoutingContext): PF.Grid { const { nodeBounds, canvasBounds } = context; // Calculate grid dimensions from canvas bounds - const gridWidth = Math.ceil(canvasBounds.width / GRID_SIZE) + 1; - const gridHeight = Math.ceil(canvasBounds.height / GRID_SIZE) + 1; - const offsetX = canvasBounds.x; - const offsetY = canvasBounds.y; + const gridWidth = Math.ceil(canvasBounds.width / GRID_SIZE) + 2; + const gridHeight = Math.ceil(canvasBounds.height / GRID_SIZE) + 2; + // Snap offset to grid + const offsetX = Math.floor(canvasBounds.x / GRID_SIZE) * GRID_SIZE; + const offsetY = Math.floor(canvasBounds.y / GRID_SIZE) * GRID_SIZE; const grid = new PF.Grid(gridWidth, gridHeight); @@ -66,8 +67,11 @@ export function buildGrid(context: RoutingContext): PF.Grid { } /** - * Get grid offset (canvas origin in world coordinates) + * Get grid offset (canvas origin snapped to grid) */ export function getGridOffset(context: RoutingContext): { x: number; y: number } { - return { x: context.canvasBounds.x, y: context.canvasBounds.y }; + return { + x: Math.floor(context.canvasBounds.x / GRID_SIZE) * GRID_SIZE, + y: Math.floor(context.canvasBounds.y / GRID_SIZE) * GRID_SIZE + }; } From c08b5be1e0fa0dcd791c7884fc010092bd7f6b01 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 24 Jan 2026 00:25:37 +0100 Subject: [PATCH 214/656] Add path overlap avoidance, rounded corners, and performance optimizations --- .../components/edges/OrthogonalEdge.svelte | 116 +++++--- src/lib/routing/constants.ts | 10 +- src/lib/routing/gridBuilder.ts | 9 +- src/lib/routing/index.ts | 15 +- src/lib/routing/pathOptimizer.ts | 27 -- src/lib/routing/pathfinder.ts | 247 ++++++++++-------- src/lib/routing/routeCalculator.ts | 129 ++++++--- src/lib/routing/types.ts | 14 +- src/lib/stores/routing.ts | 91 +++++-- 9 files changed, 400 insertions(+), 258 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index 0733ef48..2fb9eac7 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -71,6 +71,8 @@ }); // Offset to start/end path at handle tips (not centers) + // Source: small inset from handle edge + // Target: larger offset to leave room for arrowhead const sourceOffset = 0.5; const targetOffset = 4.5; @@ -96,41 +98,75 @@ return { x, y }; }); - // Build SVG path from route points or fallback to straight line - const pathData = $derived(() => { - const src = adjustedSource(); - const tgt = adjustedTarget(); + // Corner radius for bends (0.5G = 5px) + const CORNER_RADIUS = 5; + + /** + * Build SVG path with rounded corners using quadratic bezier curves + */ + function buildRoundedPath(points: Array<{ x: number; y: number }>, radius: number): string { + if (points.length < 2) return ''; + if (points.length === 2) { + return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`; + } - if (routeResult?.path && routeResult.path.length >= 2) { - // Route = [sourceStubEnd, ...intermediates, targetStubEnd] - // Draw: handle -> route -> handle - const points = routeResult.path; + let d = `M ${points[0].x} ${points[0].y}`; - let d = `M ${src.x} ${src.y}`; - for (const pt of points) { - d += ` L ${pt.x} ${pt.y}`; + for (let i = 1; i < points.length - 1; i++) { + const prev = points[i - 1]; + const curr = points[i]; + const next = points[i + 1]; + + // Calculate distances to prev and next + const distPrev = Math.hypot(curr.x - prev.x, curr.y - prev.y); + const distNext = Math.hypot(next.x - curr.x, next.y - curr.y); + + // Clamp radius to half the shorter segment + const r = Math.min(radius, distPrev / 2, distNext / 2); + + if (r < 0.5) { + // Too short for rounding, just go to point + d += ` L ${curr.x} ${curr.y}`; + continue; } - d += ` L ${tgt.x} ${tgt.y}`; - return d; + // Direction vectors (normalized) + const dxPrev = (prev.x - curr.x) / distPrev; + const dyPrev = (prev.y - curr.y) / distPrev; + const dxNext = (next.x - curr.x) / distNext; + const dyNext = (next.y - curr.y) / distNext; + + // Points where curve starts and ends + const startX = curr.x + dxPrev * r; + const startY = curr.y + dyPrev * r; + const endX = curr.x + dxNext * r; + const endY = curr.y + dyNext * r; + + // Line to curve start, then quadratic bezier with corner as control point + d += ` L ${startX} ${startY} Q ${curr.x} ${curr.y} ${endX} ${endY}`; } - // Fallback: simple L-shape with stubs - const stubLength = 10; // 1G stub - let d = `M ${src.x} ${src.y}`; - - // Determine stub directions based on handle positions - if (sourcePosition === 'right') { - d += ` L ${src.x + stubLength} ${src.y}`; - } else if (sourcePosition === 'left') { - d += ` L ${src.x - stubLength} ${src.y}`; - } else if (sourcePosition === 'bottom') { - d += ` L ${src.x} ${src.y + stubLength}`; - } else if (sourcePosition === 'top') { - d += ` L ${src.x} ${src.y - stubLength}`; + // Line to final point + const last = points[points.length - 1]; + d += ` L ${last.x} ${last.y}`; + + return d; + } + + // Build SVG path from route points or fallback to straight line + const pathData = $derived(() => { + const src = adjustedSource(); + const tgt = adjustedTarget(); + + if (routeResult?.path && routeResult.path.length >= 1) { + // Full path: src -> route points -> tgt + const allPoints = [src, ...routeResult.path, tgt]; + + return buildRoundedPath(allPoints, CORNER_RADIUS); } - // Route to target stub + // Fallback: simple L-shape + const stubLength = 10; const srcStub = sourcePosition === 'right' ? { x: src.x + stubLength, y: src.y } : sourcePosition === 'left' ? { x: src.x - stubLength, y: src.y } : sourcePosition === 'bottom' ? { x: src.x, y: src.y + stubLength } : @@ -141,22 +177,15 @@ targetPosition === 'bottom' ? { x: tgt.x, y: tgt.y + stubLength } : { x: tgt.x, y: tgt.y - stubLength }; - // L-shape between stubs - if (Math.abs(srcStub.y - tgtStub.y) < 1) { - // Horizontally aligned - d += ` L ${tgtStub.x} ${tgtStub.y}`; - } else if (Math.abs(srcStub.x - tgtStub.x) < 1) { - // Vertically aligned - d += ` L ${tgtStub.x} ${tgtStub.y}`; - } else { - // Need a bend - d += ` L ${tgtStub.x} ${srcStub.y} L ${tgtStub.x} ${tgtStub.y}`; + // Build points array for L-shape + const points = [src, srcStub]; + if (Math.abs(srcStub.x - tgtStub.x) > 1 && Math.abs(srcStub.y - tgtStub.y) > 1) { + // Need a corner + points.push({ x: tgtStub.x, y: srcStub.y }); } + points.push(tgtStub, tgt); - // Final segment to target - d += ` L ${tgt.x} ${tgt.y}`; - - return d; + return buildRoundedPath(points, CORNER_RADIUS); }); // Get user waypoints from route result or data @@ -173,10 +202,11 @@ const endArrow = $derived(() => { const tgt = adjustedTarget(); - if (routeResult?.path && routeResult.path.length >= 2) { + if (routeResult?.path && routeResult.path.length >= 1) { const path = routeResult.path; const endPoint = tgt; - const prevPoint = path.length > 1 ? path[path.length - 2] : path[0]; + // The last segment is from path's last point (targetStubEnd) to tgt + const prevPoint = path[path.length - 1]; const dx = endPoint.x - prevPoint.x; const dy = endPoint.y - prevPoint.y; diff --git a/src/lib/routing/constants.ts b/src/lib/routing/constants.ts index 149f3ede..b160d0fa 100644 --- a/src/lib/routing/constants.ts +++ b/src/lib/routing/constants.ts @@ -4,13 +4,13 @@ import { G } from '$lib/constants/grid'; -/** Margin around nodes for routing (0.25G = 2.5px) */ -export const ROUTING_MARGIN = G.unit / 4; +/** Margin around nodes for routing (0.5G = 5px) - covers stub areas */ +export const ROUTING_MARGIN = G.unit / 2; -/** Minimum distance from source port before first turn (1G = 10px) */ -export const SOURCE_CLEARANCE = G.unit; +/** Minimum distance from source port before first turn (0 - handle offset provides clearance) */ +export const SOURCE_CLEARANCE = 0; -/** Minimum distance from target port before first turn (1 grid unit = 10px) */ +/** Minimum distance from target port before first turn (1G = 10px) */ export const TARGET_CLEARANCE = G.unit; /** Grid resolution for pathfinding (matches base grid = 10px) */ diff --git a/src/lib/routing/gridBuilder.ts b/src/lib/routing/gridBuilder.ts index 2a2b4582..e1130ff1 100644 --- a/src/lib/routing/gridBuilder.ts +++ b/src/lib/routing/gridBuilder.ts @@ -4,7 +4,7 @@ import PF from 'pathfinding'; import type { Bounds, RoutingContext } from './types'; -import { GRID_SIZE, ROUTING_MARGIN } from './constants'; +import { GRID_SIZE, ROUTING_MARGIN, TARGET_CLEARANCE } from './constants'; /** * Convert world coordinates to grid coordinates @@ -38,12 +38,13 @@ export function buildGrid(context: RoutingContext): PF.Grid { const grid = new PF.Grid(gridWidth, gridHeight); // Mark obstacles (nodes with margin) + // Extend more on left/right where ports are (TARGET_CLEARANCE) + // Use smaller margin on top/bottom (ROUTING_MARGIN) for (const [, bounds] of nodeBounds) { - // Add margin around node const marginBounds = { - x: bounds.x - ROUTING_MARGIN, + x: bounds.x - TARGET_CLEARANCE, y: bounds.y - ROUTING_MARGIN, - width: bounds.width + 2 * ROUTING_MARGIN, + width: bounds.width + 2 * TARGET_CLEARANCE, height: bounds.height + 2 * ROUTING_MARGIN }; diff --git a/src/lib/routing/index.ts b/src/lib/routing/index.ts index 3fe18996..e9636fd4 100644 --- a/src/lib/routing/index.ts +++ b/src/lib/routing/index.ts @@ -2,10 +2,11 @@ * Routing module public API */ -export { calculateRoute, calculateSimpleRoute } from './routeCalculator'; -export { buildGrid, worldToGrid, gridToWorld, getGridOffset } from './gridBuilder'; -export { findPath, findPathWithTurnPenalty } from './pathfinder'; -export { simplifyPath, snapToGrid, snapPathToGrid, deduplicatePath } from './pathOptimizer'; -export { ROUTING_MARGIN, SOURCE_CLEARANCE, TARGET_CLEARANCE, GRID_SIZE, HANDLE_OFFSET, ARROW_INSET } from './constants'; -export type { Bounds, RoutingContext, RouteSegment, RouteResult, Direction } from './types'; -export { DIRECTION_VECTORS } from './types'; +// Route calculation +export { calculateRoute, calculateSimpleRoute, getPathCells, prepareRoutingGrid, clearRoutingGrid } from './routeCalculator'; + +// Constants used by FlowCanvas +export { ROUTING_MARGIN, HANDLE_OFFSET, ARROW_INSET } from './constants'; + +// Types +export type { Bounds, RoutingContext, RouteResult, Direction } from './types'; diff --git a/src/lib/routing/pathOptimizer.ts b/src/lib/routing/pathOptimizer.ts index f2bbdb3c..a1cabdc5 100644 --- a/src/lib/routing/pathOptimizer.ts +++ b/src/lib/routing/pathOptimizer.ts @@ -46,30 +46,3 @@ export function snapToGrid(point: Position): Position { }; } -/** - * Snap all points in a path to grid - */ -export function snapPathToGrid(path: Position[]): Position[] { - return path.map(snapToGrid); -} - -/** - * Merge nearby points that are on the same grid cell - */ -export function deduplicatePath(path: Position[]): Position[] { - if (path.length < 2) return path; - - const result: Position[] = [path[0]]; - - for (let i = 1; i < path.length; i++) { - const prev = result[result.length - 1]; - const curr = path[i]; - - // Skip if same position - if (prev.x !== curr.x || prev.y !== curr.y) { - result.push(curr); - } - } - - return result; -} diff --git a/src/lib/routing/pathfinder.ts b/src/lib/routing/pathfinder.ts index 88aaa050..29f930e9 100644 --- a/src/lib/routing/pathfinder.ts +++ b/src/lib/routing/pathfinder.ts @@ -11,6 +11,15 @@ import { GRID_SIZE } from './constants'; /** Cost for making a 90-degree turn (in grid units) */ const TURN_PENALTY = 2; +/** Cost for running parallel to another path (same direction) */ +const PATH_OVERLAP_PENALTY = 20; + +/** Cost for crossing another path (perpendicular) - low since crossings are acceptable */ +const PATH_CROSSING_PENALTY = 2; + +/** Number of cells to force walkable in initial direction (exit from port) */ +const EXIT_PATH_LENGTH = 3; + /** Map direction to its opposite (for blocking 180-degree turns) */ const OPPOSITE_DIR: Record = { up: 'down', @@ -38,14 +47,86 @@ interface AStarNode { direction: Direction; // Actual direction we arrived from } +/** Simple binary min-heap for A* open set */ +class MinHeap { + private heap: AStarNode[] = []; + + push(node: AStarNode): void { + this.heap.push(node); + this.bubbleUp(this.heap.length - 1); + } + + pop(): AStarNode | undefined { + if (this.heap.length === 0) return undefined; + const min = this.heap[0]; + const last = this.heap.pop()!; + if (this.heap.length > 0) { + this.heap[0] = last; + this.bubbleDown(0); + } + return min; + } + + get length(): number { + return this.heap.length; + } + + // Find and update node if better path found + updateIfBetter(x: number, y: number, dir: Direction, newG: number, parent: AStarNode): boolean { + for (let i = 0; i < this.heap.length; i++) { + const n = this.heap[i]; + if (n.x === x && n.y === y && n.direction === dir) { + if (newG < n.g) { + n.g = newG; + n.f = newG + n.h; + n.parent = parent; + this.bubbleUp(i); + return true; + } + return false; + } + } + return false; // Not found + } + + has(x: number, y: number, dir: Direction): boolean { + return this.heap.some(n => n.x === x && n.y === y && n.direction === dir); + } + + private bubbleUp(i: number): void { + while (i > 0) { + const parent = Math.floor((i - 1) / 2); + if (this.heap[i].f >= this.heap[parent].f) break; + [this.heap[i], this.heap[parent]] = [this.heap[parent], this.heap[i]]; + i = parent; + } + } + + private bubbleDown(i: number): void { + const n = this.heap.length; + while (true) { + const left = 2 * i + 1; + const right = 2 * i + 2; + let smallest = i; + if (left < n && this.heap[left].f < this.heap[smallest].f) smallest = left; + if (right < n && this.heap[right].f < this.heap[smallest].f) smallest = right; + if (smallest === i) break; + [this.heap[i], this.heap[smallest]] = [this.heap[smallest], this.heap[i]]; + i = smallest; + } + } +} + /** * Find orthogonal path between two points using A* with turn penalty * Only allows 90-degree turns (no reversing/180-degree turns) * @param start - Start position in world coordinates * @param end - End position in world coordinates - * @param grid - Pathfinding grid (will be cloned) + * @param grid - Pathfinding grid * @param offset - Grid offset (canvas origin) * @param initialDir - Initial direction of travel + * @param usedCells - Optional map of cells to directions used by other paths + * @param prebuiltWalkable - Optional pre-built walkable set for performance * @returns Array of positions in world coordinates */ export function findPathWithTurnPenalty( @@ -53,7 +134,9 @@ export function findPathWithTurnPenalty( end: Position, grid: PF.Grid, offset: Position, - initialDir: Direction + initialDir: Direction, + usedCells?: Map>, + prebuiltWalkable?: Set ): Position[] { // Convert to grid coordinates const startGx = worldToGrid(start.x - offset.x); @@ -70,23 +153,37 @@ export function findPathWithTurnPenalty( return [start, end]; } - // Clone grid for manipulation - const walkable = new Set(); - for (let y = 0; y < gridHeight; y++) { - for (let x = 0; x < gridWidth; x++) { - if (grid.isWalkableAt(x, y)) { - walkable.add(`${x},${y}`); + // Use pre-built walkable set or build fresh + let walkable: Set; + if (prebuiltWalkable) { + // Clone to avoid modifying shared set + walkable = new Set(prebuiltWalkable); + } else { + walkable = new Set(); + for (let y = 0; y < gridHeight; y++) { + for (let x = 0; x < gridWidth; x++) { + if (grid.isWalkableAt(x, y)) { + walkable.add(`${x},${y}`); + } } } } + // Ensure start and end are walkable walkable.add(`${startGx},${startGy}`); walkable.add(`${endGx},${endGy}`); - // Initialize open and closed sets - // Key includes direction to allow revisiting with different directions - const openSet: AStarNode[] = []; - const closedSet = new Map(); + // Ensure first few cells in initial direction are walkable (exit path from port) + const initVec = NEIGHBORS.find((n) => n.dir === initialDir); + if (initVec) { + for (let i = 1; i <= EXIT_PATH_LENGTH; i++) { + walkable.add(`${startGx + initVec.dx * i},${startGy + initVec.dy * i}`); + } + } + + // Initialize open (min-heap) and closed sets + const openSet = new MinHeap(); + const closedSet = new Set(); // Create start node const startNode: AStarNode = { @@ -102,78 +199,72 @@ export function findPathWithTurnPenalty( openSet.push(startNode); while (openSet.length > 0) { - // Find node with lowest f score - let lowestIdx = 0; - for (let i = 1; i < openSet.length; i++) { - if (openSet[i].f < openSet[lowestIdx].f) { - lowestIdx = i; - } - } - const current = openSet[lowestIdx]; + const current = openSet.pop()!; // Check if we reached the end if (current.x === endGx && current.y === endGy) { - // Reconstruct path return reconstructPath(current, offset); } - // Move current from open to closed - openSet.splice(lowestIdx, 1); + // Skip if already processed with this direction const closedKey = `${current.x},${current.y},${current.direction}`; - closedSet.set(closedKey, current); + if (closedSet.has(closedKey)) continue; + closedSet.add(closedKey); - // Get the direction we must NOT go (opposite of current direction = 180-degree turn) + // Get the direction we must NOT go (opposite = 180-degree turn) const blockedDir = OPPOSITE_DIR[current.direction]; + const isStartNode = current.parent === null; // Explore neighbors for (const { dx, dy, dir } of NEIGHBORS) { - // Block 180-degree turns (reversing) if (dir === blockedDir) continue; + if (isStartNode && dir !== initialDir) continue; const nx = current.x + dx; const ny = current.y + dy; - const posKey = `${nx},${ny}`; // Skip if out of bounds or not walkable if (nx < 0 || nx >= gridWidth || ny < 0 || ny >= gridHeight) continue; - if (!walkable.has(posKey)) continue; + if (!walkable.has(`${nx},${ny}`)) continue; - // Skip if already in closed set with this direction + // Skip if already closed with this direction const neighborClosedKey = `${nx},${ny},${dir}`; if (closedSet.has(neighborClosedKey)) continue; // Calculate movement cost let moveCost = 1; - - // Add turn penalty if direction changes (90-degree turn) - if (current.direction !== dir) { - moveCost += TURN_PENALTY; + if (current.direction !== dir) moveCost += TURN_PENALTY; + + // Add penalty for cells used by other paths + if (usedCells) { + const worldGx = nx + worldToGrid(offset.x); + const worldGy = ny + worldToGrid(offset.y); + const existingDirs = usedCells.get(`${worldGx},${worldGy}`); + if (existingDirs) { + const isHorizontal = dir === 'left' || dir === 'right'; + const hasPerpendicular = isHorizontal + ? existingDirs.has('up') || existingDirs.has('down') + : existingDirs.has('left') || existingDirs.has('right'); + moveCost += hasPerpendicular ? PATH_CROSSING_PENALTY : PATH_OVERLAP_PENALTY; + } } const tentativeG = current.g + moveCost; - // Check if this path to neighbor is better - const existingIdx = openSet.findIndex(n => n.x === nx && n.y === ny && n.direction === dir); - if (existingIdx !== -1) { - if (tentativeG < openSet[existingIdx].g) { - // Better path found - openSet[existingIdx].g = tentativeG; - openSet[existingIdx].f = tentativeG + openSet[existingIdx].h; - openSet[existingIdx].parent = current; + // Try to update existing node or add new one + if (!openSet.updateIfBetter(nx, ny, dir, tentativeG, current)) { + if (!openSet.has(nx, ny, dir)) { + const h = manhattanDistance(nx, ny, endGx, endGy); + openSet.push({ + x: nx, + y: ny, + g: tentativeG, + h, + f: tentativeG + h, + parent: current, + direction: dir + }); } - } else { - // New node - const h = manhattanDistance(nx, ny, endGx, endGy); - const neighbor: AStarNode = { - x: nx, - y: ny, - g: tentativeG, - h, - f: tentativeG + h, - parent: current, - direction: dir - }; - openSet.push(neighbor); } } } @@ -214,51 +305,3 @@ function reconstructPath(endNode: AStarNode, offset: Position): Position[] { return path; } -/** - * Original findPath without turn penalty (kept for compatibility) - */ -export function findPath( - start: Position, - end: Position, - grid: PF.Grid, - offset: Position -): Position[] { - const finder = new PF.AStarFinder({ - allowDiagonal: false, - heuristic: PF.Heuristic.manhattan - } as PF.FinderOptions); - - // Convert to grid coordinates - const startGx = worldToGrid(start.x - offset.x); - const startGy = worldToGrid(start.y - offset.y); - const endGx = worldToGrid(end.x - offset.x); - const endGy = worldToGrid(end.y - offset.y); - - // Clone grid (pathfinding modifies it) - const gridClone = grid.clone(); - - // Ensure start and end are walkable (they're on port positions) - const gridWidth = gridClone.width; - const gridHeight = gridClone.height; - - if (startGx >= 0 && startGx < gridWidth && startGy >= 0 && startGy < gridHeight) { - gridClone.setWalkableAt(startGx, startGy, true); - } - if (endGx >= 0 && endGx < gridWidth && endGy >= 0 && endGy < gridHeight) { - gridClone.setWalkableAt(endGx, endGy, true); - } - - // Find path - const rawPath = finder.findPath(startGx, startGy, endGx, endGy, gridClone); - - // If no path found, return L-shaped fallback (never diagonal) - if (rawPath.length === 0) { - return [start, { x: end.x, y: start.y }, end]; - } - - // Convert back to world coordinates - return rawPath.map(([gx, gy]) => ({ - x: gridToWorld(gx) + offset.x, - y: gridToWorld(gy) + offset.y - })); -} diff --git a/src/lib/routing/routeCalculator.ts b/src/lib/routing/routeCalculator.ts index 7105192a..72c62b49 100644 --- a/src/lib/routing/routeCalculator.ts +++ b/src/lib/routing/routeCalculator.ts @@ -2,14 +2,17 @@ * Simple orthogonal route calculator */ +import PF from 'pathfinding'; import type { Position } from '$lib/types/common'; -import type { Connection, Waypoint } from '$lib/types/nodes'; -import type { RoutingContext, RouteResult, RouteSegment, Direction } from './types'; +import type { RoutingContext, RouteResult, Direction } from './types'; import { DIRECTION_VECTORS } from './types'; -import { buildGrid, getGridOffset } from './gridBuilder'; +import { buildGrid, getGridOffset, worldToGrid } from './gridBuilder'; import { findPathWithTurnPenalty } from './pathfinder'; import { simplifyPath, snapToGrid } from './pathOptimizer'; -import { SOURCE_CLEARANCE, TARGET_CLEARANCE } from './constants'; +import { SOURCE_CLEARANCE, TARGET_CLEARANCE, GRID_SIZE } from './constants'; + +/** Number of cells to skip at path start for shared source port overlap */ +const SHARED_SOURCE_CELLS = 2; /** * Calculate stub endpoint (grid-aligned point where stub ends) @@ -23,38 +26,75 @@ function getStubEnd(portPos: Position, direction: Direction, clearance: number): return snapToGrid(point); } +/** Cached grid and offset for batch routing */ +let cachedGrid: { grid: PF.Grid; offset: Position; walkable: Set } | null = null; + +/** + * Build and cache grid for batch routing (call before calculateRoute batch) + */ +export function prepareRoutingGrid(context: RoutingContext): void { + const grid = buildGrid(context); + const offset = getGridOffset(context); + + // Pre-build walkable set once + const walkable = new Set(); + for (let y = 0; y < grid.height; y++) { + for (let x = 0; x < grid.width; x++) { + if (grid.isWalkableAt(x, y)) { + walkable.add(`${x},${y}`); + } + } + } + + cachedGrid = { grid, offset, walkable }; +} + +/** + * Clear cached grid after batch routing + */ +export function clearRoutingGrid(): void { + cachedGrid = null; +} + /** * Calculate route between two ports + * @param usedCells - Optional map of cells to directions used by other paths */ export function calculateRoute( - connection: Connection, sourcePos: Position, targetPos: Position, sourceDir: Direction, targetDir: Direction, - context: RoutingContext + context: RoutingContext, + usedCells?: Map> ): RouteResult { // Calculate stub endpoints (grid-aligned virtual ports for A*) const sourceStubEnd = getStubEnd(sourcePos, sourceDir, SOURCE_CLEARANCE); const targetStubEnd = getStubEnd(targetPos, targetDir, TARGET_CLEARANCE); - // Build pathfinding grid - const grid = buildGrid(context); - const offset = getGridOffset(context); + // Use cached grid if available, otherwise build fresh + let grid: PF.Grid; + let offset: Position; + let walkable: Set | undefined; + + if (cachedGrid) { + grid = cachedGrid.grid; + offset = cachedGrid.offset; + walkable = cachedGrid.walkable; + } else { + grid = buildGrid(context); + offset = getGridOffset(context); + } // Find path from source stub end to target stub end - const rawPath = findPathWithTurnPenalty(sourceStubEnd, targetStubEnd, grid, offset, sourceDir); + const rawPath = findPathWithTurnPenalty(sourceStubEnd, targetStubEnd, grid, offset, sourceDir, usedCells, walkable); const simplified = simplifyPath(rawPath); // Path is: [sourceStubEnd, ...intermediates..., targetStubEnd] // simplifyPath already includes start and end const path = simplified; - return { - path, - waypoints: [], - segments: buildSegments(path) - }; + return { path, waypoints: [] }; } /** @@ -85,31 +125,58 @@ export function calculateSimpleRoute( path.push(targetStubEnd); - return { - path, - waypoints: [], - segments: buildSegments(path) - }; + return { path, waypoints: [] }; } /** - * Build segment info from path + * Extract grid cells used by a path with direction info (for overlap/crossing avoidance) + * @param path - Path positions in world coordinates + * @param skipStart - Number of cells to skip at start (for shared source ports) + * @returns Map of cell key to set of directions traveled through that cell */ -function buildSegments(path: Position[]): RouteSegment[] { - const segments: RouteSegment[] = []; +export function getPathCells(path: Position[], skipStart = 2): Map> { + const cells = new Map>(); + let cellCount = 0; for (let i = 0; i < path.length - 1; i++) { const start = path[i]; const end = path[i + 1]; - segments.push({ - index: i, - startPoint: start, - endPoint: end, - isHorizontal: Math.abs(start.y - end.y) < 1, - isUserSegment: false - }); + const dx = end.x - start.x; + const dy = end.y - start.y; + const dist = Math.max(Math.abs(dx), Math.abs(dy)); + + // Determine direction of this segment + let dir: Direction; + if (Math.abs(dx) > Math.abs(dy)) { + dir = dx > 0 ? 'right' : 'left'; + } else { + dir = dy > 0 ? 'down' : 'up'; + } + + if (dist < 1) { + cellCount++; + if (cellCount > skipStart) { + const key = `${worldToGrid(start.x)},${worldToGrid(start.y)}`; + if (!cells.has(key)) cells.set(key, new Set()); + cells.get(key)!.add(dir); + } + continue; + } + + const steps = Math.ceil(dist / GRID_SIZE); + for (let s = 0; s <= steps; s++) { + cellCount++; + if (cellCount > skipStart) { + const t = s / steps; + const x = start.x + dx * t; + const y = start.y + dy * t; + const key = `${worldToGrid(x)},${worldToGrid(y)}`; + if (!cells.has(key)) cells.set(key, new Set()); + cells.get(key)!.add(dir); + } + } } - return segments; + return cells; } diff --git a/src/lib/routing/types.ts b/src/lib/routing/types.ts index f6effb20..c0d0d242 100644 --- a/src/lib/routing/types.ts +++ b/src/lib/routing/types.ts @@ -32,22 +32,10 @@ export interface RoutingContext { canvasBounds: Bounds; } -/** Segment of a route (for segment dragging) */ -export interface RouteSegment { - index: number; - startPoint: Position; - endPoint: Position; - isHorizontal: boolean; - /** true if bounded by user waypoints */ - isUserSegment: boolean; -} - /** Result from route calculation */ export interface RouteResult { /** Grid-aligned points including source/target */ path: Position[]; - /** Generated waypoints (mix of user and auto) */ + /** User waypoints (for future use) */ waypoints: Waypoint[]; - /** Segment info for interaction */ - segments: RouteSegment[]; } diff --git a/src/lib/stores/routing.ts b/src/lib/stores/routing.ts index c38b4ecc..a6cb26be 100644 --- a/src/lib/stores/routing.ts +++ b/src/lib/stores/routing.ts @@ -6,7 +6,7 @@ import { writable, derived, get } from 'svelte/store'; import type { Position } from '$lib/types/common'; import type { Connection, Waypoint } from '$lib/types/nodes'; import type { RoutingContext, RouteResult, Bounds, Direction } from '$lib/routing'; -import { calculateRoute, calculateSimpleRoute, ROUTING_MARGIN } from '$lib/routing'; +import { calculateRoute, calculateSimpleRoute, getPathCells, prepareRoutingGrid, clearRoutingGrid, ROUTING_MARGIN } from '$lib/routing'; import { generateId } from '$lib/stores/utils'; import { graphStore } from '$lib/stores/graph'; import { historyStore } from '$lib/stores/history'; @@ -72,7 +72,7 @@ export const routingStore = { let result: RouteResult; if ($state.context && $state.context.nodeBounds.size > 0) { - result = calculateRoute(connection, sourcePos, targetPos, sourceDir, targetDir, $state.context); + result = calculateRoute(sourcePos, targetPos, sourceDir, targetDir, $state.context); } else { // No context or empty - use simple routing result = calculateSimpleRoute(sourcePos, targetPos, sourceDir, targetDir); @@ -88,7 +88,7 @@ export const routingStore = { }, /** - * Recalculate all routes + * Recalculate all routes with path overlap avoidance * @param connections - All connections to route * @param getPortInfo - Function to get port world position and direction */ @@ -99,34 +99,73 @@ export const routingStore = { const $state = get(state); const routes = new Map(); + const usedCells = new Map>(); + // Pre-build grid once for all routes (performance optimization) + if ($state.context && $state.context.nodeBounds.size > 0) { + prepareRoutingGrid($state.context); + } + + // Group connections by source port so paths from same port can share cells + const bySourcePort = new Map(); for (const conn of connections) { - const sourceInfo = getPortInfo(conn.sourceNodeId, conn.sourcePortIndex, true); - const targetInfo = getPortInfo(conn.targetNodeId, conn.targetPortIndex, false); - - if (!sourceInfo || !targetInfo) continue; - - let result: RouteResult; - if ($state.context && $state.context.nodeBounds.size > 0) { - result = calculateRoute( - conn, - sourceInfo.position, - targetInfo.position, - sourceInfo.direction, - targetInfo.direction, - $state.context - ); - } else { - result = calculateSimpleRoute( - sourceInfo.position, - targetInfo.position, - sourceInfo.direction, - targetInfo.direction - ); + const key = `${conn.sourceNodeId}:${conn.sourcePortIndex}`; + const group = bySourcePort.get(key) || []; + group.push(conn); + bySourcePort.set(key, group); + } + + // Process each source port group + for (const [, groupConns] of bySourcePort) { + const groupCells: Map>[] = []; + + // Calculate routes for all connections from this source port + for (const conn of groupConns) { + const sourceInfo = getPortInfo(conn.sourceNodeId, conn.sourcePortIndex, true); + const targetInfo = getPortInfo(conn.targetNodeId, conn.targetPortIndex, false); + + if (!sourceInfo || !targetInfo) continue; + + let result: RouteResult; + if ($state.context && $state.context.nodeBounds.size > 0) { + result = calculateRoute( + sourceInfo.position, + targetInfo.position, + sourceInfo.direction, + targetInfo.direction, + $state.context, + usedCells + ); + } else { + result = calculateSimpleRoute( + sourceInfo.position, + targetInfo.position, + sourceInfo.direction, + targetInfo.direction + ); + } + routes.set(conn.id, result); + + // Collect cells for this path (add to usedCells after processing whole group) + if (result.path.length > 0) { + groupCells.push(getPathCells(result.path, 2)); + } + } + + // Add all cells from this group to usedCells for subsequent groups + for (const cells of groupCells) { + for (const [cellKey, dirs] of cells) { + if (!usedCells.has(cellKey)) usedCells.set(cellKey, new Set()); + for (const dir of dirs) { + usedCells.get(cellKey)!.add(dir); + } + } } - routes.set(conn.id, result); } + // Clear cached grid + clearRoutingGrid(); + state.update((s) => ({ ...s, routes })); }, From 5308d1e6368c1f30399f523fef0a07e25ae1fd6b Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 24 Jan 2026 00:42:21 +0100 Subject: [PATCH 215/656] Fallback to smoothstep edge when A* pathfinding fails --- .../components/edges/OrthogonalEdge.svelte | 51 ++++++++----------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index 2fb9eac7..4a16adc5 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -1,5 +1,5 @@ - + {#each userWaypoints() as waypoint (waypoint.id)} From 80d6b5fe9795334a6231fac1eb98a710e777b09b Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 24 Jan 2026 00:50:52 +0100 Subject: [PATCH 216/656] Sort connections by distance (longest first), increase routing margin to 2G --- src/lib/routing/constants.ts | 4 ++-- src/lib/routing/gridBuilder.ts | 10 ++++------ src/lib/stores/routing.ts | 20 +++++++++++++++++++- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/lib/routing/constants.ts b/src/lib/routing/constants.ts index b160d0fa..d1d96d45 100644 --- a/src/lib/routing/constants.ts +++ b/src/lib/routing/constants.ts @@ -4,8 +4,8 @@ import { G } from '$lib/constants/grid'; -/** Margin around nodes for routing (0.5G = 5px) - covers stub areas */ -export const ROUTING_MARGIN = G.unit / 2; +/** Margin around nodes for routing (2G = 20px) */ +export const ROUTING_MARGIN = G.unit * 2; /** Minimum distance from source port before first turn (0 - handle offset provides clearance) */ export const SOURCE_CLEARANCE = 0; diff --git a/src/lib/routing/gridBuilder.ts b/src/lib/routing/gridBuilder.ts index e1130ff1..c26bec20 100644 --- a/src/lib/routing/gridBuilder.ts +++ b/src/lib/routing/gridBuilder.ts @@ -4,7 +4,7 @@ import PF from 'pathfinding'; import type { Bounds, RoutingContext } from './types'; -import { GRID_SIZE, ROUTING_MARGIN, TARGET_CLEARANCE } from './constants'; +import { GRID_SIZE, ROUTING_MARGIN } from './constants'; /** * Convert world coordinates to grid coordinates @@ -37,14 +37,12 @@ export function buildGrid(context: RoutingContext): PF.Grid { const grid = new PF.Grid(gridWidth, gridHeight); - // Mark obstacles (nodes with margin) - // Extend more on left/right where ports are (TARGET_CLEARANCE) - // Use smaller margin on top/bottom (ROUTING_MARGIN) + // Mark obstacles (nodes with uniform margin) for (const [, bounds] of nodeBounds) { const marginBounds = { - x: bounds.x - TARGET_CLEARANCE, + x: bounds.x - ROUTING_MARGIN, y: bounds.y - ROUTING_MARGIN, - width: bounds.width + 2 * TARGET_CLEARANCE, + width: bounds.width + 2 * ROUTING_MARGIN, height: bounds.height + 2 * ROUTING_MARGIN }; diff --git a/src/lib/stores/routing.ts b/src/lib/stores/routing.ts index a6cb26be..65b1a110 100644 --- a/src/lib/stores/routing.ts +++ b/src/lib/stores/routing.ts @@ -106,9 +106,27 @@ export const routingStore = { prepareRoutingGrid($state.context); } + // Sort connections by Manhattan distance (longest first) + // Longer routes are less likely to block shorter ones + const sortedConnections = [...connections].sort((a, b) => { + const aSource = getPortInfo(a.sourceNodeId, a.sourcePortIndex, true); + const aTarget = getPortInfo(a.targetNodeId, a.targetPortIndex, false); + const bSource = getPortInfo(b.sourceNodeId, b.sourcePortIndex, true); + const bTarget = getPortInfo(b.targetNodeId, b.targetPortIndex, false); + + const aDist = aSource && aTarget + ? Math.abs(aTarget.position.x - aSource.position.x) + Math.abs(aTarget.position.y - aSource.position.y) + : 0; + const bDist = bSource && bTarget + ? Math.abs(bTarget.position.x - bSource.position.x) + Math.abs(bTarget.position.y - bSource.position.y) + : 0; + + return bDist - aDist; // Longest first + }); + // Group connections by source port so paths from same port can share cells const bySourcePort = new Map(); - for (const conn of connections) { + for (const conn of sortedConnections) { const key = `${conn.sourceNodeId}:${conn.sourcePortIndex}`; const group = bySourcePort.get(key) || []; group.push(conn); From 3cf534806ff78f387ffae6ad3caf75b8bdd2a7f6 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 24 Jan 2026 00:56:05 +0100 Subject: [PATCH 217/656] Add port stub obstacles, reduce routing margin to 1G --- src/lib/components/FlowCanvas.svelte | 21 +++++++++++++++++++-- src/lib/routing/constants.ts | 4 ++-- src/lib/routing/gridBuilder.ts | 18 +++++++++++++++++- src/lib/routing/index.ts | 2 +- src/lib/routing/types.ts | 8 ++++++++ src/lib/stores/routing.ts | 6 +++--- 6 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/lib/components/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index 2f872a32..fbde3285 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -23,7 +23,7 @@ import { selectedNodeIds as graphSelectedNodeIds } from '$lib/stores/graph/state'; import { historyStore } from '$lib/stores/history'; import { routingStore, buildRoutingContext, type PortInfo } from '$lib/stores/routing'; - import { HANDLE_OFFSET, ARROW_INSET, type Direction } from '$lib/routing'; + import { HANDLE_OFFSET, ARROW_INSET, type Direction, type PortStub } from '$lib/routing'; import { themeStore, type Theme } from '$lib/stores/theme'; import { clearSelectionTrigger, nudgeTrigger, selectNodeTrigger, registerHasSelection, triggerFitView } from '$lib/stores/viewActions'; import { screenToFlow } from '$lib/utils/viewUtils'; @@ -333,7 +333,24 @@ } const { nodeBounds, canvasBounds } = buildRoutingContext(blockNodesForRouting); - routingStore.setContext(nodeBounds, canvasBounds); + + // Collect all port stubs for obstacle marking + const portStubs: PortStub[] = []; + for (const node of blockNodesForRouting) { + const nodeData = node.data as NodeInstance; + // Collect input port stubs + for (let i = 0; i < nodeData.inputs.length; i++) { + const info = getPortInfo(node.id, i, false); + if (info) portStubs.push({ position: info.position, direction: info.direction }); + } + // Collect output port stubs + for (let i = 0; i < nodeData.outputs.length; i++) { + const info = getPortInfo(node.id, i, true); + if (info) portStubs.push({ position: info.position, direction: info.direction }); + } + } + + routingStore.setContext(nodeBounds, canvasBounds, portStubs); // Recalculate all routes const connections = get(graphStore.connections); diff --git a/src/lib/routing/constants.ts b/src/lib/routing/constants.ts index d1d96d45..5d7a443e 100644 --- a/src/lib/routing/constants.ts +++ b/src/lib/routing/constants.ts @@ -4,8 +4,8 @@ import { G } from '$lib/constants/grid'; -/** Margin around nodes for routing (2G = 20px) */ -export const ROUTING_MARGIN = G.unit * 2; +/** Margin around nodes for routing (1G = 10px) */ +export const ROUTING_MARGIN = G.unit; /** Minimum distance from source port before first turn (0 - handle offset provides clearance) */ export const SOURCE_CLEARANCE = 0; diff --git a/src/lib/routing/gridBuilder.ts b/src/lib/routing/gridBuilder.ts index c26bec20..c63de026 100644 --- a/src/lib/routing/gridBuilder.ts +++ b/src/lib/routing/gridBuilder.ts @@ -3,7 +3,8 @@ */ import PF from 'pathfinding'; -import type { Bounds, RoutingContext } from './types'; +import type { RoutingContext } from './types'; +import { DIRECTION_VECTORS } from './types'; import { GRID_SIZE, ROUTING_MARGIN } from './constants'; /** @@ -62,6 +63,21 @@ export function buildGrid(context: RoutingContext): PF.Grid { } } + // Mark port stub areas as obstacles (1G extension from port) + if (context.portStubs) { + for (const stub of context.portStubs) { + const vec = DIRECTION_VECTORS[stub.direction]; + // Mark the cell at port position extending 1G in port direction + const stubX = stub.position.x + vec.x * GRID_SIZE; + const stubY = stub.position.y + vec.y * GRID_SIZE; + const gx = worldToGrid(stubX - offsetX); + const gy = worldToGrid(stubY - offsetY); + if (gx >= 0 && gx < gridWidth && gy >= 0 && gy < gridHeight) { + grid.setWalkableAt(gx, gy, false); + } + } + } + return grid; } diff --git a/src/lib/routing/index.ts b/src/lib/routing/index.ts index e9636fd4..8f0cc56c 100644 --- a/src/lib/routing/index.ts +++ b/src/lib/routing/index.ts @@ -9,4 +9,4 @@ export { calculateRoute, calculateSimpleRoute, getPathCells, prepareRoutingGrid, export { ROUTING_MARGIN, HANDLE_OFFSET, ARROW_INSET } from './constants'; // Types -export type { Bounds, RoutingContext, RouteResult, Direction } from './types'; +export type { Bounds, RoutingContext, RouteResult, Direction, PortStub } from './types'; diff --git a/src/lib/routing/types.ts b/src/lib/routing/types.ts index c0d0d242..ad80eb10 100644 --- a/src/lib/routing/types.ts +++ b/src/lib/routing/types.ts @@ -24,12 +24,20 @@ export interface Bounds { height: number; } +/** Port stub for obstacle marking */ +export interface PortStub { + position: Position; + direction: Direction; +} + /** Routing context passed to calculator */ export interface RoutingContext { /** Node ID -> bounding box (world coordinates, already includes margin) */ nodeBounds: Map; /** Canvas bounds for grid calculation */ canvasBounds: Bounds; + /** Port stubs to mark as obstacles */ + portStubs?: PortStub[]; } /** Result from route calculation */ diff --git a/src/lib/stores/routing.ts b/src/lib/stores/routing.ts index 65b1a110..ce98b82a 100644 --- a/src/lib/stores/routing.ts +++ b/src/lib/stores/routing.ts @@ -5,7 +5,7 @@ import { writable, derived, get } from 'svelte/store'; import type { Position } from '$lib/types/common'; import type { Connection, Waypoint } from '$lib/types/nodes'; -import type { RoutingContext, RouteResult, Bounds, Direction } from '$lib/routing'; +import type { RoutingContext, RouteResult, Bounds, Direction, PortStub } from '$lib/routing'; import { calculateRoute, calculateSimpleRoute, getPathCells, prepareRoutingGrid, clearRoutingGrid, ROUTING_MARGIN } from '$lib/routing'; import { generateId } from '$lib/stores/utils'; import { graphStore } from '$lib/stores/graph'; @@ -39,8 +39,8 @@ export const routingStore = { * Update routing context from current nodes * Call this when nodes are added, removed, or moved */ - setContext(nodeBounds: Map, canvasBounds: Bounds): void { - const context: RoutingContext = { nodeBounds, canvasBounds }; + setContext(nodeBounds: Map, canvasBounds: Bounds, portStubs?: PortStub[]): void { + const context: RoutingContext = { nodeBounds, canvasBounds, portStubs }; state.update((s) => ({ ...s, context })); }, From fa2803adfa62c2cf29da9311222098b568eec985 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 24 Jan 2026 17:42:00 +0100 Subject: [PATCH 218/656] Add draggable waypoints and hybrid routing through user waypoints - Add calculateRouteWithWaypoints() for routing through user-placed waypoints - Waypoints are sorted by position along source-target vector - Sequential A* routing: Source -> W1 -> W2 -> ... -> Target - Draggable waypoint markers with grid snapping (10px) - Double-click waypoint to delete - Segment midpoint indicators on selected edges for adding new waypoints - Drag segment midpoints to create and position new waypoints - Full undo/redo support via historyStore.beginDrag/endDrag --- .../components/edges/OrthogonalEdge.svelte | 163 +++++++++++++++++- src/lib/routing/index.ts | 2 +- src/lib/routing/routeCalculator.ts | 160 +++++++++++++++++ src/lib/stores/routing.ts | 92 ++++++++-- 4 files changed, 401 insertions(+), 16 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index 4a16adc5..75c5f5bc 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -2,6 +2,9 @@ import { BaseEdge, getSmoothStepPath, type EdgeProps } from '@xyflow/svelte'; import { hoveredHandle, selectedNodeHighlight } from '$lib/stores/hoveredHandle'; import { routingStore } from '$lib/stores/routing'; + import { historyStore } from '$lib/stores/history'; + import { screenToFlow } from '$lib/utils/viewUtils'; + import { GRID_SIZE } from '$lib/routing/constants'; import type { RouteResult } from '$lib/routing'; import type { Waypoint } from '$lib/types/nodes'; @@ -24,6 +27,47 @@ import { onDestroy } from 'svelte'; + // Drag state for waypoint markers + let draggingWaypointId = $state(null); + + // Drag handlers for waypoint markers + function handleWaypointMouseDown(event: MouseEvent, waypoint: Waypoint) { + event.stopPropagation(); + event.preventDefault(); + + draggingWaypointId = waypoint.id; + historyStore.beginDrag(); + + document.addEventListener('mousemove', handleWaypointDrag); + document.addEventListener('mouseup', handleWaypointDragEnd); + } + + function handleWaypointDrag(event: MouseEvent) { + if (!draggingWaypointId) return; + + const flowPos = screenToFlow({ x: event.clientX, y: event.clientY }); + const snappedPos = { + x: Math.round(flowPos.x / GRID_SIZE) * GRID_SIZE, + y: Math.round(flowPos.y / GRID_SIZE) * GRID_SIZE + }; + + routingStore.moveWaypoint(id, draggingWaypointId, snappedPos); + } + + function handleWaypointDragEnd() { + draggingWaypointId = null; + historyStore.endDrag(); + document.removeEventListener('mousemove', handleWaypointDrag); + document.removeEventListener('mouseup', handleWaypointDragEnd); + } + + // Double-click to delete waypoint + function handleWaypointDoubleClick(event: MouseEvent, waypoint: Waypoint) { + event.stopPropagation(); + event.preventDefault(); + routingStore.removeUserWaypoint(id, waypoint.id); + } + // Get cached route from routing store let routeResult = $state(null); let unsubscribeRoute: (() => void) | null = null; @@ -36,9 +80,17 @@ unsubscribeRoute = routingStore.getRoute(id).subscribe((r) => (routeResult = r)); }); - // Cleanup subscription + // Cleanup subscription and drag handlers onDestroy(() => { if (unsubscribeRoute) unsubscribeRoute(); + if (draggingWaypointId) { + document.removeEventListener('mousemove', handleWaypointDrag); + document.removeEventListener('mouseup', handleWaypointDragEnd); + } + if (draggingNewWaypointId) { + document.removeEventListener('mousemove', handleWaypointDrag); + document.removeEventListener('mouseup', handleSegmentDragEnd); + } }); // Check if this edge is connected to the hovered handle @@ -188,6 +240,67 @@ return dataWaypoints?.filter((w) => w.isUserWaypoint) || []; }); + // Calculate segment midpoints for adding new waypoints + const segmentMidpoints = $derived(() => { + if (!routeResult?.path || routeResult.path.length < 1) return []; + + const src = adjustedSource(); + const tgt = adjustedTarget(); + const allPoints = [src, ...routeResult.path, tgt]; + + const midpoints: Array<{ x: number; y: number; segmentIndex: number }> = []; + for (let i = 0; i < allPoints.length - 1; i++) { + const p1 = allPoints[i]; + const p2 = allPoints[i + 1]; + // Only show midpoints on segments longer than 30px + const dist = Math.hypot(p2.x - p1.x, p2.y - p1.y); + if (dist > 30) { + midpoints.push({ + x: (p1.x + p2.x) / 2, + y: (p1.y + p2.y) / 2, + segmentIndex: i + }); + } + } + return midpoints; + }); + + // Segment drag state (for creating new waypoints by dragging segment midpoints) + let draggingNewWaypointId = $state(null); + + function handleSegmentMouseDown(event: MouseEvent, segmentIndex: number) { + event.stopPropagation(); + event.preventDefault(); + + const flowPos = screenToFlow({ x: event.clientX, y: event.clientY }); + const snappedPos = { + x: Math.round(flowPos.x / GRID_SIZE) * GRID_SIZE, + y: Math.round(flowPos.y / GRID_SIZE) * GRID_SIZE + }; + + historyStore.beginDrag(); + + // Create waypoint at drag start position + // The segment index helps determine where to insert in the waypoint array + // For now, we add at the end and let the routing algorithm sort by position + const waypointId = routingStore.addUserWaypoint(id, snappedPos); + if (waypointId) { + draggingNewWaypointId = waypointId; + draggingWaypointId = waypointId; + + document.addEventListener('mousemove', handleWaypointDrag); + document.addEventListener('mouseup', handleSegmentDragEnd); + } + } + + function handleSegmentDragEnd() { + draggingNewWaypointId = null; + draggingWaypointId = null; + historyStore.endDrag(); + document.removeEventListener('mousemove', handleWaypointDrag); + document.removeEventListener('mouseup', handleSegmentDragEnd); + } + // Arrow at end of path const endArrow = $derived(() => { const tgt = adjustedTarget(); @@ -227,9 +340,29 @@ r="4" class="waypoint-marker" class:selected + class:dragging={draggingWaypointId === waypoint.id} + role="button" + tabindex="-1" + onmousedown={(e) => handleWaypointMouseDown(e, waypoint)} + ondblclick={(e) => handleWaypointDoubleClick(e, waypoint)} /> {/each} + + {#if selected} + {#each segmentMidpoints() as midpoint (midpoint.segmentIndex)} + handleSegmentMouseDown(e, midpoint.segmentIndex)} + /> + {/each} + {/if} + diff --git a/src/lib/routing/index.ts b/src/lib/routing/index.ts index 8f0cc56c..3d28a329 100644 --- a/src/lib/routing/index.ts +++ b/src/lib/routing/index.ts @@ -3,7 +3,7 @@ */ // Route calculation -export { calculateRoute, calculateSimpleRoute, getPathCells, prepareRoutingGrid, clearRoutingGrid } from './routeCalculator'; +export { calculateRoute, calculateRouteWithWaypoints, calculateSimpleRoute, getPathCells, prepareRoutingGrid, clearRoutingGrid } from './routeCalculator'; // Constants used by FlowCanvas export { ROUTING_MARGIN, HANDLE_OFFSET, ARROW_INSET } from './constants'; diff --git a/src/lib/routing/routeCalculator.ts b/src/lib/routing/routeCalculator.ts index 72c62b49..a876b861 100644 --- a/src/lib/routing/routeCalculator.ts +++ b/src/lib/routing/routeCalculator.ts @@ -4,6 +4,7 @@ import PF from 'pathfinding'; import type { Position } from '$lib/types/common'; +import type { Waypoint } from '$lib/types/nodes'; import type { RoutingContext, RouteResult, Direction } from './types'; import { DIRECTION_VECTORS } from './types'; import { buildGrid, getGridOffset, worldToGrid } from './gridBuilder'; @@ -180,3 +181,162 @@ export function getPathCells(path: Position[], skipStart = 2): Map Math.abs(dy)) { + return dx > 0 ? 'right' : 'left'; + } + return dy > 0 ? 'down' : 'up'; +} + +/** + * Get the opposite direction + */ +function oppositeDirection(dir: Direction): Direction { + const opposites: Record = { + up: 'down', + down: 'up', + left: 'right', + right: 'left' + }; + return opposites[dir]; +} + +/** + * Infer exit direction from the end of a path segment + */ +function inferExitDirection(path: Position[]): Direction { + if (path.length < 2) return 'right'; + const last = path[path.length - 1]; + const prev = path[path.length - 2]; + return inferDirection(prev, last); +} + +/** + * Sort waypoints by their position along the source->target vector + */ +function sortWaypointsByPathOrder( + source: Position, + target: Position, + waypoints: Waypoint[] +): Waypoint[] { + const dx = target.x - source.x; + const dy = target.y - source.y; + const len = Math.sqrt(dx * dx + dy * dy); + + if (len === 0) return waypoints; + + const ux = dx / len; + const uy = dy / len; + + return [...waypoints].sort((a, b) => { + const projA = (a.position.x - source.x) * ux + (a.position.y - source.y) * uy; + const projB = (b.position.x - source.x) * ux + (b.position.y - source.y) * uy; + return projA - projB; + }); +} + +/** + * Calculate route through user waypoints using sequential A* segments + * Route: Source -> A* -> W1 -> A* -> W2 -> ... -> A* -> Target + */ +export function calculateRouteWithWaypoints( + sourcePos: Position, + targetPos: Position, + sourceDir: Direction, + targetDir: Direction, + context: RoutingContext, + userWaypoints: Waypoint[], + usedCells?: Map> +): RouteResult { + // If no waypoints, use regular routing + if (userWaypoints.length === 0) { + return calculateRoute(sourcePos, targetPos, sourceDir, targetDir, context, usedCells); + } + + // Sort waypoints by their order along the path + const sortedWaypoints = sortWaypointsByPathOrder(sourcePos, targetPos, userWaypoints); + + // Use cached grid if available, otherwise build fresh + let grid: PF.Grid; + let offset: Position; + let walkable: Set | undefined; + + if (cachedGrid) { + grid = cachedGrid.grid; + offset = cachedGrid.offset; + walkable = cachedGrid.walkable; + } else { + grid = buildGrid(context); + offset = getGridOffset(context); + } + + // Build path segments through all waypoints + const fullPath: Position[] = []; + let currentPos = getStubEnd(sourcePos, sourceDir, SOURCE_CLEARANCE); + let currentDir = sourceDir; + + for (let i = 0; i < sortedWaypoints.length; i++) { + const waypoint = sortedWaypoints[i]; + const waypointPos = snapToGrid(waypoint.position); + + // Route from current position to waypoint + const segmentPath = findPathWithTurnPenalty( + currentPos, + waypointPos, + grid, + offset, + currentDir, + usedCells, + walkable + ); + + // Append path (skip first point if not first segment to avoid duplicates) + if (fullPath.length === 0) { + fullPath.push(...segmentPath); + } else if (segmentPath.length > 0) { + fullPath.push(...segmentPath.slice(1)); + } + + // Update current position and direction for next segment + currentPos = waypointPos; + if (segmentPath.length >= 2) { + // Exit direction is opposite of how we approached (continue in same direction) + currentDir = inferExitDirection(segmentPath); + } else { + // Fallback: infer from next waypoint or target + const nextTarget = i < sortedWaypoints.length - 1 + ? sortedWaypoints[i + 1].position + : targetPos; + currentDir = inferDirection(waypointPos, nextTarget); + } + } + + // Final segment: last waypoint -> target + const targetStubEnd = getStubEnd(targetPos, targetDir, TARGET_CLEARANCE); + const finalPath = findPathWithTurnPenalty( + currentPos, + targetStubEnd, + grid, + offset, + currentDir, + usedCells, + walkable + ); + + if (fullPath.length === 0) { + fullPath.push(...finalPath); + } else if (finalPath.length > 0) { + fullPath.push(...finalPath.slice(1)); + } + + // Simplify the combined path + const simplified = simplifyPath(fullPath); + + return { path: simplified, waypoints: sortedWaypoints }; +} diff --git a/src/lib/stores/routing.ts b/src/lib/stores/routing.ts index ce98b82a..90f91987 100644 --- a/src/lib/stores/routing.ts +++ b/src/lib/stores/routing.ts @@ -6,7 +6,7 @@ import { writable, derived, get } from 'svelte/store'; import type { Position } from '$lib/types/common'; import type { Connection, Waypoint } from '$lib/types/nodes'; import type { RoutingContext, RouteResult, Bounds, Direction, PortStub } from '$lib/routing'; -import { calculateRoute, calculateSimpleRoute, getPathCells, prepareRoutingGrid, clearRoutingGrid, ROUTING_MARGIN } from '$lib/routing'; +import { calculateRoute, calculateRouteWithWaypoints, calculateSimpleRoute, getPathCells, prepareRoutingGrid, clearRoutingGrid, ROUTING_MARGIN } from '$lib/routing'; import { generateId } from '$lib/stores/utils'; import { graphStore } from '$lib/stores/graph'; import { historyStore } from '$lib/stores/history'; @@ -61,7 +61,7 @@ export const routingStore = { /** * Calculate and cache route for a single connection */ - calculateRoute( + calcRoute( connection: Connection, sourcePos: Position, targetPos: Position, @@ -70,9 +70,23 @@ export const routingStore = { ): RouteResult | null { const $state = get(state); + // Extract user waypoints from connection + const userWaypoints = (connection.waypoints || []).filter((w) => w.isUserWaypoint); + let result: RouteResult; if ($state.context && $state.context.nodeBounds.size > 0) { - result = calculateRoute(sourcePos, targetPos, sourceDir, targetDir, $state.context); + if (userWaypoints.length > 0) { + result = calculateRouteWithWaypoints( + sourcePos, + targetPos, + sourceDir, + targetDir, + $state.context, + userWaypoints + ); + } else { + result = calculateRoute(sourcePos, targetPos, sourceDir, targetDir, $state.context); + } } else { // No context or empty - use simple routing result = calculateSimpleRoute(sourcePos, targetPos, sourceDir, targetDir); @@ -144,16 +158,31 @@ export const routingStore = { if (!sourceInfo || !targetInfo) continue; + // Extract user waypoints from connection + const userWaypoints = (conn.waypoints || []).filter((w) => w.isUserWaypoint); + let result: RouteResult; if ($state.context && $state.context.nodeBounds.size > 0) { - result = calculateRoute( - sourceInfo.position, - targetInfo.position, - sourceInfo.direction, - targetInfo.direction, - $state.context, - usedCells - ); + if (userWaypoints.length > 0) { + result = calculateRouteWithWaypoints( + sourceInfo.position, + targetInfo.position, + sourceInfo.direction, + targetInfo.direction, + $state.context, + userWaypoints, + usedCells + ); + } else { + result = calculateRoute( + sourceInfo.position, + targetInfo.position, + sourceInfo.direction, + targetInfo.direction, + $state.context, + usedCells + ); + } } else { result = calculateSimpleRoute( sourceInfo.position, @@ -219,14 +248,16 @@ export const routingStore = { /** * Add a user waypoint to a connection */ - addUserWaypoint(connectionId: string, position: Position): void { + addUserWaypoint(connectionId: string, position: Position): string | null { + let waypointId: string | null = null; historyStore.mutate(() => { const connections = get(graphStore.connections); const connection = connections.find((c) => c.id === connectionId); if (!connection) return; + waypointId = generateId(); const newWaypoint: Waypoint = { - id: generateId(), + id: waypointId, position, isUserWaypoint: true }; @@ -240,6 +271,41 @@ export const routingStore = { // Invalidate cached route routingStore.invalidateRoute(connectionId); }); + return waypointId; + }, + + /** + * Add a user waypoint at a specific index (for segment dragging) + * @returns The ID of the new waypoint + */ + addUserWaypointAtIndex(connectionId: string, position: Position, insertIndex: number): string | null { + let waypointId: string | null = null; + historyStore.mutate(() => { + const connections = get(graphStore.connections); + const connection = connections.find((c) => c.id === connectionId); + if (!connection) return; + + waypointId = generateId(); + const newWaypoint: Waypoint = { + id: waypointId, + position, + isUserWaypoint: true + }; + + // Get existing user waypoints + const existingUserWaypoints = (connection.waypoints || []).filter((w) => w.isUserWaypoint); + + // Insert at the specified index + const updatedWaypoints = [ + ...existingUserWaypoints.slice(0, insertIndex), + newWaypoint, + ...existingUserWaypoints.slice(insertIndex) + ]; + + graphStore.updateConnectionWaypoints(connectionId, updatedWaypoints); + routingStore.invalidateRoute(connectionId); + }); + return waypointId; }, /** From de45f25af0b2a8425a5cfd4f720d4db4c83a1079 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 24 Jan 2026 21:40:49 +0100 Subject: [PATCH 219/656] Fix waypoint dragging by using pointer events with capture --- .../components/edges/OrthogonalEdge.svelte | 107 +++++++++++------- 1 file changed, 67 insertions(+), 40 deletions(-) diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index 75c5f5bc..4f746da5 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -29,22 +29,27 @@ // Drag state for waypoint markers let draggingWaypointId = $state(null); + let dragTarget: SVGCircleElement | null = null; - // Drag handlers for waypoint markers - function handleWaypointMouseDown(event: MouseEvent, waypoint: Waypoint) { + // Drag handlers for waypoint markers - using pointer events with capture + function handleWaypointPointerDown(event: PointerEvent, waypoint: Waypoint) { event.stopPropagation(); event.preventDefault(); + const target = event.currentTarget as SVGCircleElement; + target.setPointerCapture(event.pointerId); + dragTarget = target; + draggingWaypointId = waypoint.id; historyStore.beginDrag(); - - document.addEventListener('mousemove', handleWaypointDrag); - document.addEventListener('mouseup', handleWaypointDragEnd); } - function handleWaypointDrag(event: MouseEvent) { + function handleWaypointPointerMove(event: PointerEvent) { if (!draggingWaypointId) return; + event.stopPropagation(); + event.preventDefault(); + const flowPos = screenToFlow({ x: event.clientX, y: event.clientY }); const snappedPos = { x: Math.round(flowPos.x / GRID_SIZE) * GRID_SIZE, @@ -54,11 +59,18 @@ routingStore.moveWaypoint(id, draggingWaypointId, snappedPos); } - function handleWaypointDragEnd() { + function handleWaypointPointerUp(event: PointerEvent) { + if (!draggingWaypointId) return; + + event.stopPropagation(); + + if (dragTarget) { + dragTarget.releasePointerCapture(event.pointerId); + dragTarget = null; + } + draggingWaypointId = null; historyStore.endDrag(); - document.removeEventListener('mousemove', handleWaypointDrag); - document.removeEventListener('mouseup', handleWaypointDragEnd); } // Double-click to delete waypoint @@ -80,17 +92,9 @@ unsubscribeRoute = routingStore.getRoute(id).subscribe((r) => (routeResult = r)); }); - // Cleanup subscription and drag handlers + // Cleanup subscription onDestroy(() => { if (unsubscribeRoute) unsubscribeRoute(); - if (draggingWaypointId) { - document.removeEventListener('mousemove', handleWaypointDrag); - document.removeEventListener('mouseup', handleWaypointDragEnd); - } - if (draggingNewWaypointId) { - document.removeEventListener('mousemove', handleWaypointDrag); - document.removeEventListener('mouseup', handleSegmentDragEnd); - } }); // Check if this edge is connected to the hovered handle @@ -266,12 +270,16 @@ }); // Segment drag state (for creating new waypoints by dragging segment midpoints) - let draggingNewWaypointId = $state(null); + let segmentDragTarget: SVGCircleElement | null = null; - function handleSegmentMouseDown(event: MouseEvent, segmentIndex: number) { + function handleSegmentPointerDown(event: PointerEvent, segmentIndex: number) { event.stopPropagation(); event.preventDefault(); + const target = event.currentTarget as SVGCircleElement; + target.setPointerCapture(event.pointerId); + segmentDragTarget = target; + const flowPos = screenToFlow({ x: event.clientX, y: event.clientY }); const snappedPos = { x: Math.round(flowPos.x / GRID_SIZE) * GRID_SIZE, @@ -285,20 +293,34 @@ // For now, we add at the end and let the routing algorithm sort by position const waypointId = routingStore.addUserWaypoint(id, snappedPos); if (waypointId) { - draggingNewWaypointId = waypointId; draggingWaypointId = waypointId; - - document.addEventListener('mousemove', handleWaypointDrag); - document.addEventListener('mouseup', handleSegmentDragEnd); } } - function handleSegmentDragEnd() { - draggingNewWaypointId = null; + function handleSegmentPointerMove(event: PointerEvent) { + if (!draggingWaypointId || !segmentDragTarget) return; + + event.stopPropagation(); + event.preventDefault(); + + const flowPos = screenToFlow({ x: event.clientX, y: event.clientY }); + const snappedPos = { + x: Math.round(flowPos.x / GRID_SIZE) * GRID_SIZE, + y: Math.round(flowPos.y / GRID_SIZE) * GRID_SIZE + }; + + routingStore.moveWaypoint(id, draggingWaypointId, snappedPos); + } + + function handleSegmentPointerUp(event: PointerEvent) { + if (!segmentDragTarget) return; + + event.stopPropagation(); + + segmentDragTarget.releasePointerCapture(event.pointerId); + segmentDragTarget = null; draggingWaypointId = null; historyStore.endDrag(); - document.removeEventListener('mousemove', handleWaypointDrag); - document.removeEventListener('mouseup', handleSegmentDragEnd); } // Arrow at end of path @@ -337,13 +359,15 @@ handleWaypointMouseDown(e, waypoint)} + onpointerdown={(e) => handleWaypointPointerDown(e, waypoint)} + onpointermove={handleWaypointPointerMove} + onpointerup={handleWaypointPointerUp} ondblclick={(e) => handleWaypointDoubleClick(e, waypoint)} /> {/each} @@ -354,11 +378,13 @@ handleSegmentMouseDown(e, midpoint.segmentIndex)} + onpointerdown={(e) => handleSegmentPointerDown(e, midpoint.segmentIndex)} + onpointermove={handleSegmentPointerMove} + onpointerup={handleSegmentPointerUp} /> {/each} {/if} @@ -407,8 +433,9 @@ .waypoint-marker { fill: var(--surface-raised); stroke: var(--edge); - stroke-width: 1.5; + stroke-width: 2; cursor: grab; + touch-action: none; transition: stroke 0.15s ease, fill 0.15s ease; @@ -416,7 +443,7 @@ .waypoint-marker:hover { stroke: var(--accent); - stroke-width: 2; + stroke-width: 2.5; } .waypoint-marker.selected { @@ -427,7 +454,7 @@ .waypoint-marker.dragging { cursor: grabbing; stroke: var(--accent); - stroke-width: 2.5; + stroke-width: 3; fill: var(--accent); } @@ -435,18 +462,18 @@ .segment-midpoint { fill: var(--surface-raised); stroke: var(--edge); - stroke-width: 1; + stroke-width: 1.5; cursor: grab; - opacity: 0.6; + touch-action: none; + opacity: 0.7; transition: opacity 0.15s ease, - stroke 0.15s ease, - r 0.15s ease; + stroke 0.15s ease; } .segment-midpoint:hover { opacity: 1; stroke: var(--accent); - stroke-width: 1.5; + stroke-width: 2; } From 956c7f831ad4f11096721dc0a8b5a20aac26d1ea Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 24 Jan 2026 21:45:58 +0100 Subject: [PATCH 220/656] Fix route recalculation during drag and pointer capture cleanup --- scripts/__pycache__/extract.cpython-314.pyc | Bin 0 -> 48313 bytes .../components/edges/OrthogonalEdge.svelte | 87 +++++++++++++----- src/lib/stores/routing.ts | 46 ++++++++- 3 files changed, 108 insertions(+), 25 deletions(-) create mode 100644 scripts/__pycache__/extract.cpython-314.pyc diff --git a/scripts/__pycache__/extract.cpython-314.pyc b/scripts/__pycache__/extract.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a2da7a436dc670604f48b212b0c6b83bc4490804 GIT binary patch literal 48313 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 literal 0 HcmV?d00001 diff --git a/src/lib/components/edges/OrthogonalEdge.svelte b/src/lib/components/edges/OrthogonalEdge.svelte index 4f746da5..3fd156f5 100644 --- a/src/lib/components/edges/OrthogonalEdge.svelte +++ b/src/lib/components/edges/OrthogonalEdge.svelte @@ -1,11 +1,11 @@ + + + + + +

        - {data.name} + {#if renderedNameHtml} + {@html renderedNameHtml} + {:else} + {data.name} + {/if} {#if typeDef} {typeDef.name} {/if} @@ -485,6 +535,23 @@ letter-spacing: -0.2px; } + /* KaTeX math rendering in node names */ + .node-name :global(.katex) { + font-size: 1em; + font-weight: 600; + color: inherit; + } + + .node-name :global(.katex-html) { + white-space: nowrap; + } + + .node-name :global(.math-error) { + color: var(--error); + font-family: var(--font-mono); + font-size: 0.9em; + } + .node-type { display: block; font-size: 8px; diff --git a/src/lib/export/svg/renderer.ts b/src/lib/export/svg/renderer.ts index cd5e0726..cd69e75f 100644 --- a/src/lib/export/svg/renderer.ts +++ b/src/lib/export/svg/renderer.ts @@ -14,6 +14,12 @@ import { eventStore } from '$lib/stores/events'; import { getThemeColors } from '$lib/constants/theme'; import { NODE, EVENT } from '$lib/constants/dimensions'; import { getHandlePath } from '$lib/constants/handlePaths'; +import { latexToSvg, getSvgDimensions, preloadMathJax } from '$lib/utils/mathjaxSvg'; + +// Preload MathJax when module loads +if (typeof window !== 'undefined') { + preloadMathJax(); +} import type { ExportOptions, RenderContext, Bounds } from './types'; import { DEFAULT_OPTIONS } from './types'; import type { NodeInstance } from '$lib/types/nodes'; @@ -39,12 +45,72 @@ function escapeXml(str: string): string { .replace(/'/g, '''); } -function getNodeDimensions(nodeId: string): { width: number; height: number } | null { - const wrapper = document.querySelector(`[data-id="${nodeId}"]`) as HTMLElement; - if (!wrapper) return null; - const rect = wrapper.getBoundingClientRect(); - const zoom = getZoom(); - return { width: rect.width / zoom, height: rect.height / zoom }; +/** + * Extract LaTeX from a string with $...$ delimiters + */ +function extractLatex(text: string): { before: string; latex: string; after: string } | null { + const match = text.match(/^(.*?)\$([^$]+)\$(.*)$/); + if (!match) return null; + return { before: match[1], latex: match[2], after: match[3] }; +} + +/** + * Render a plain text label as SVG + */ +function renderPlainTextLabel( + text: string, + centerX: number, + centerY: number, + color: string, + fontSize: number, + fontWeight: string +): string { + return `${escapeXml(text)}`; +} + +/** + * Render a label that may contain math as native SVG using MathJax + * @param originalText - The original text with $...$ LaTeX delimiters (NOT the rendered DOM content) + */ +async function renderMathLabel( + originalText: string, + centerX: number, + centerY: number, + color: string, + fontSize: number, + fontWeight: string, + ctx: RenderContext +): Promise { + const mathParts = extractLatex(originalText); + + if (!mathParts) { + // Plain text - use regular SVG text + return renderPlainTextLabel(originalText, centerX, centerY, color, fontSize, fontWeight); + } + + try { + // Render the LaTeX to SVG using MathJax + let svg = await latexToSvg(mathParts.latex, false); + const dims = getSvgDimensions(svg); + + // Apply color to the SVG + svg = svg.replace(/currentColor/g, color); + + // Scale factor: MathJax uses ex units, we need to scale to match our font size + // Base assumption: 1ex ≈ 8px, scale to match desired font size + const scale = fontSize / 10; + + // Calculate position (center the SVG) + const x = centerX - (dims.width * scale) / 2; + const y = centerY - (dims.height * scale) / 2; + + // Wrap in a group with transform for positioning and scaling + return `${svg}`; + } catch (e) { + console.error('MathJax SVG rendering error:', e); + // Fall back to plain text showing the raw LaTeX + return renderPlainTextLabel(originalText, centerX, centerY, color, fontSize, fontWeight); + } } // ============================================================================ @@ -131,21 +197,24 @@ function renderHandles(nodeId: string, nodeX: number, nodeY: number, ctx: Render // NODE RENDERING - Pure SVG with DOM-read styles // ============================================================================ -function renderNode(node: NodeInstance, ctx: RenderContext): string { +async function renderNode(node: NodeInstance, ctx: RenderContext): Promise { const wrapper = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement; if (!wrapper) return ''; - const dims = getNodeDimensions(node.id); - if (!dims) return ''; - const { width, height } = dims; + const nodeEl = wrapper.querySelector('.node') as HTMLElement; + if (!nodeEl) return ''; + + // Get dimensions from the actual .node element (not SvelteFlow wrapper) + // This ensures we use our dynamic width calculation for math names + const zoom = getZoom(); + const nodeRect = nodeEl.getBoundingClientRect(); + const width = nodeRect.width / zoom; + const height = nodeRect.height / zoom; // Position is center-origin, convert to top-left for SVG const x = node.position.x - width / 2; const y = node.position.y - height / 2; - const nodeEl = wrapper.querySelector('.node') as HTMLElement; - if (!nodeEl) return ''; - // Read styles from DOM const computed = getComputedStyle(nodeEl); const borderRadius = parseFloat(computed.borderRadius) || 8; @@ -173,8 +242,6 @@ function renderNode(node: NodeInstance, ctx: RenderContext): string { // Check for pinned params section in DOM const pinnedParamsEl = nodeEl.querySelector('.pinned-params') as HTMLElement; - const zoom = getZoom(); - const nodeRect = wrapper.getBoundingClientRect(); // Calculate content center (above pinned params if present) let contentCenterY = y + height / 2; @@ -189,19 +256,15 @@ function renderNode(node: NodeInstance, ctx: RenderContext): string { const centerX = x + width / 2; if (ctx.options.showTypeLabels && nodeType) { - // Name above center - parts.push( - `${escapeXml(nodeName)}` - ); + // Name above center (may contain math) - use original node.name for LaTeX source + parts.push(await renderMathLabel(node.name, centerX, contentCenterY - 4, color, 10, '600', ctx)); // Type below center parts.push( `${escapeXml(nodeType)}` ); } else { - // Just name, centered - parts.push( - `${escapeXml(nodeName)}` - ); + // Just name, centered (may contain math) - use original node.name for LaTeX source + parts.push(await renderMathLabel(node.name, centerX, contentCenterY, color, 10, '600', ctx)); } } @@ -341,11 +404,19 @@ function renderEvent(event: EventInstance, ctx: RenderContext): string { function calculateBounds(nodes: NodeInstance[], events: EventInstance[]): Bounds { const bounds: Bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; + const zoom = getZoom(); for (const node of nodes) { - const dims = getNodeDimensions(node.id); - const width = dims?.width ?? NODE.baseWidth; - const height = dims?.height ?? NODE.baseHeight; + // Get dimensions from the actual .node element (not SvelteFlow wrapper) + const wrapper = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement; + const nodeEl = wrapper?.querySelector('.node') as HTMLElement; + let width = NODE.baseWidth; + let height = NODE.baseHeight; + if (nodeEl) { + const rect = nodeEl.getBoundingClientRect(); + width = rect.width / zoom; + height = rect.height / zoom; + } // Position is center-origin, calculate corners const left = node.position.x - width / 2; const top = node.position.y - height / 2; @@ -381,7 +452,7 @@ function calculateBounds(nodes: NodeInstance[], events: EventInstance[]): Bounds return isFinite(bounds.minX) ? bounds : { minX: 0, minY: 0, maxX: 200, maxY: 200 }; } -export function exportToSVG(options: ExportOptions = {}): string { +export async function exportToSVG(options: ExportOptions = {}): Promise { const opts: Required = { ...DEFAULT_OPTIONS, ...options }; const themeColors = getThemeColors(opts.theme); const ctx: RenderContext = { theme: themeColors, options: opts }; @@ -419,11 +490,11 @@ export function exportToSVG(options: ExportOptions = {}): string { parts.push(''); } - // Nodes + // Nodes (render in parallel for performance) if (nodes.length > 0) { parts.push(''); - for (const node of nodes) { - const rendered = renderNode(node, ctx); + const renderedNodes = await Promise.all(nodes.map((node) => renderNode(node, ctx))); + for (const rendered of renderedNodes) { if (rendered) parts.push(rendered); } parts.push(''); diff --git a/src/lib/utils/inlineMathRenderer.ts b/src/lib/utils/inlineMathRenderer.ts new file mode 100644 index 00000000..e7787828 --- /dev/null +++ b/src/lib/utils/inlineMathRenderer.ts @@ -0,0 +1,149 @@ +/** + * Light inline math renderer for node names + * Only handles $...$ inline math, no markdown processing + */ + +import { loadKatex } from './katexLoader'; + +/** Result of rendering inline math */ +export interface MathRenderResult { + /** Rendered HTML string */ + html: string; + /** Whether the input contained any math */ + hasMath: boolean; +} + +/** Cached render results to avoid re-rendering unchanged content */ +const renderCache = new Map(); + +/** + * Check if a string contains inline math delimiters + */ +export function containsMath(text: string): boolean { + return /\$[^$\n]+\$/.test(text); +} + +/** + * Render inline math in a string + * Only processes $...$ delimited math, leaves other text as-is + * + * @param text - Input text possibly containing $...$ math + * @returns Promise resolving to render result with HTML and hasMath flag + */ +export async function renderInlineMath(text: string): Promise { + if (!text?.trim()) { + return { html: '', hasMath: false }; + } + + // Check cache + const cached = renderCache.get(text); + if (cached) return cached; + + // Check if there's any math to render + if (!containsMath(text)) { + const result = { html: escapeHtml(text), hasMath: false }; + renderCache.set(text, result); + return result; + } + + const katex = await loadKatex(); + let hasMath = false; + + // Replace $...$ with rendered KaTeX + const html = text.replace(/\$([^$\n]+)\$/g, (_, latex) => { + hasMath = true; + try { + return katex.default.renderToString(latex.trim(), { + displayMode: false, + throwOnError: false, + strict: false, + output: 'html' // Use HTML output for DOM rendering + }); + } catch { + return `${escapeHtml(latex)}`; + } + }); + + // Escape non-math parts (already done by KaTeX for math parts) + // We need to escape text outside of math - but the replace already handles it + // Actually we need to be more careful - escape text first, then insert math + + const result = { html, hasMath }; + renderCache.set(text, result); + return result; +} + +/** + * Render inline math synchronously if already cached + * Returns null if not cached (caller should use async version) + */ +export function renderInlineMathSync(text: string): MathRenderResult | null { + return renderCache.get(text) ?? null; +} + +/** + * Clear the render cache (useful for testing or memory management) + */ +export function clearMathCache(): void { + renderCache.clear(); +} + +/** + * Measure the rendered dimensions of a math string + * Creates a temporary hidden element to measure actual rendered size + * + * @param html - Rendered HTML from renderInlineMath + * @returns Dimensions { width, height } in pixels + */ +export function measureRenderedMath(html: string): { width: number; height: number } { + // Create a temporary inline measurement element + // Using span with inline display to get natural content width + // Styles must match .node-name in BaseNode.svelte + const container = document.createElement('span'); + container.style.cssText = ` + position: absolute; + visibility: hidden; + white-space: nowrap; + font-size: 10px; + font-weight: 600; + font-family: system-ui, -apple-system, sans-serif; + letter-spacing: -0.2px; + `; + container.innerHTML = html; + document.body.appendChild(container); + + // Use scrollWidth for accurate content measurement + const result = { + width: Math.ceil(container.scrollWidth), + height: Math.ceil(container.scrollHeight) + }; + + document.body.removeChild(container); + return result; +} + +/** + * Render and measure in one call + * Convenience function for getting both HTML and dimensions + */ +export async function renderAndMeasure(text: string): Promise<{ + html: string; + hasMath: boolean; + width: number; + height: number; +}> { + const rendered = await renderInlineMath(text); + const dimensions = measureRenderedMath(rendered.html); + return { + ...rendered, + ...dimensions + }; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/src/lib/utils/mathjaxSvg.ts b/src/lib/utils/mathjaxSvg.ts new file mode 100644 index 00000000..908c4674 --- /dev/null +++ b/src/lib/utils/mathjaxSvg.ts @@ -0,0 +1,153 @@ +/** + * MathJax SVG renderer for export + * Lazy-loads MathJax from CDN and renders LaTeX to standalone SVG + */ + +declare global { + interface Window { + MathJax?: { + tex2svg?: (latex: string, options?: { display?: boolean }) => HTMLElement; + startup?: { + promise?: Promise; + defaultReady?: () => void; + }; + typesetPromise?: () => Promise; + }; + } +} + +let mathjaxPromise: Promise | null = null; +let mathjaxReady = false; + +/** + * Load MathJax from CDN (lazy, only when needed) + */ +function loadMathJax(): Promise { + if (mathjaxReady) return Promise.resolve(); + if (mathjaxPromise) return mathjaxPromise; + + mathjaxPromise = new Promise((resolve, reject) => { + // Check if already fully loaded + if (typeof window.MathJax?.tex2svg === 'function') { + mathjaxReady = true; + resolve(); + return; + } + + // Configure MathJax before loading the script + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).MathJax = { + tex: { + inlineMath: [['$', '$']], + displayMath: [['$$', '$$']] + }, + svg: { + fontCache: 'none' // Embed fonts for standalone SVG + }, + startup: { + typeset: false // Don't auto-typeset the page + } + }; + + // Load MathJax script + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js'; + script.async = true; + + script.onload = () => { + // Wait for MathJax to be fully initialized + const waitForMathJax = () => { + if (window.MathJax?.startup?.promise) { + window.MathJax.startup.promise + .then(() => { + mathjaxReady = true; + resolve(); + }) + .catch(reject); + } else if (typeof window.MathJax?.tex2svg === 'function') { + // Already ready + mathjaxReady = true; + resolve(); + } else { + // Not ready yet, wait a bit + setTimeout(waitForMathJax, 50); + } + }; + waitForMathJax(); + }; + + script.onerror = () => { + mathjaxPromise = null; + reject(new Error('Failed to load MathJax from CDN')); + }; + + document.head.appendChild(script); + + // Timeout after 30 seconds + setTimeout(() => { + if (!mathjaxReady) { + mathjaxPromise = null; + reject(new Error('MathJax loading timed out')); + } + }, 30000); + }); + + return mathjaxPromise; +} + +/** + * Render LaTeX to SVG string + * @param latex - LaTeX string (without delimiters) + * @param displayMode - true for display math, false for inline + * @returns SVG string (standalone, no external dependencies) + */ +export async function latexToSvg(latex: string, displayMode = false): Promise { + await loadMathJax(); + + if (!window.MathJax?.tex2svg) { + throw new Error('MathJax tex2svg not available'); + } + + const wrapper = window.MathJax.tex2svg(latex, { display: displayMode }); + const svg = wrapper.querySelector('svg'); + + if (!svg) { + throw new Error('MathJax did not produce SVG output'); + } + + return svg.outerHTML; +} + +/** + * Extract SVG dimensions from rendered output + */ +export function getSvgDimensions(svgString: string): { width: number; height: number } { + const widthMatch = svgString.match(/width="([^"]+)"/); + const heightMatch = svgString.match(/height="([^"]+)"/); + + const parseToPixels = (value: string): number => { + const exMatch = value.match(/^([\d.]+)ex$/); + if (exMatch) { + return parseFloat(exMatch[1]) * 8; + } + const emMatch = value.match(/^([\d.]+)em$/); + if (emMatch) { + return parseFloat(emMatch[1]) * 10; + } + return parseFloat(value) || 20; + }; + + return { + width: widthMatch ? parseToPixels(widthMatch[1]) : 20, + height: heightMatch ? parseToPixels(heightMatch[1]) : 20 + }; +} + +/** + * Preload MathJax (call early to avoid delay when exporting) + */ +export function preloadMathJax(): void { + loadMathJax().catch((err) => { + console.warn('MathJax preload failed:', err); + }); +} From a0d587053444b64b944ffdabffb7c0939ef0021b Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 25 Jan 2026 19:54:26 +0100 Subject: [PATCH 280/656] Sync SvelteFlow node bounds when math name width is measured --- src/lib/components/nodes/BaseNode.svelte | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index a15872a9..f7d5ac41 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -1,6 +1,6 @@ From d2800722dea4d9fb0196f8b30ad9fdd3dbd9553e Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 27 Jan 2026 14:27:29 +0100 Subject: [PATCH 325/656] Wrap all graph mutations in historyStore.mutate() for full undo/redo coverage --- src/lib/components/FlowCanvas.svelte | 98 ++++++++++--------- src/lib/components/FlowUpdater.svelte | 43 ++++---- src/lib/components/contextMenuBuilders.ts | 25 ++--- .../dialogs/BlockPropertiesDialog.svelte | 13 ++- .../dialogs/EventPropertiesDialog.svelte | 10 +- .../components/nodes/AnnotationNode.svelte | 21 +++- src/lib/components/nodes/BaseNode.svelte | 11 ++- src/lib/components/panels/EventsPanel.svelte | 31 +++--- src/routes/+page.svelte | 15 +-- 9 files changed, 151 insertions(+), 116 deletions(-) diff --git a/src/lib/components/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index 973bdae2..a77b285a 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -166,21 +166,23 @@ return n; }); - // Sync positions to appropriate stores - nodes.forEach(n => { - if (n.selected) { - if (n.type === 'eventNode') { - if (graphStore.isAtRoot()) { - eventStore.updateEventPosition(n.id, n.position); + // Sync positions to appropriate stores (as a single undoable action) + historyStore.mutate(() => { + nodes.forEach(n => { + if (n.selected) { + if (n.type === 'eventNode') { + if (graphStore.isAtRoot()) { + eventStore.updateEventPosition(n.id, n.position); + } else { + graphStore.updateSubsystemEventPosition(n.id, n.position); + } + } else if (n.type === 'annotation') { + graphStore.updateAnnotationPosition(n.id, n.position); } else { - graphStore.updateSubsystemEventPosition(n.id, n.position); + graphStore.updateNodePosition(n.id, n.position); } - } else if (n.type === 'annotation') { - graphStore.updateAnnotationPosition(n.id, n.position); - } else { - graphStore.updateNodePosition(n.id, n.position); } - } + }); }); } }); @@ -802,47 +804,49 @@ const targetMatch = connection.targetHandle.match(/-input-(\d+)$/); if (sourceMatch && targetMatch) { - const sourcePortIndex = parseInt(sourceMatch[1], 10); - let targetPortIndex = parseInt(targetMatch[1], 10); + historyStore.mutate(() => { + const sourcePortIndex = parseInt(sourceMatch[1], 10); + let targetPortIndex = parseInt(targetMatch[1], 10); - // Check if the target port is already connected - const currentConnections = get(graphStore.connections); - const isPortOccupied = currentConnections.some( - (c) => c.targetNodeId === connection.target && c.targetPortIndex === targetPortIndex - ); + // Check if the target port is already connected + const currentConnections = get(graphStore.connections); + const isPortOccupied = currentConnections.some( + (c) => c.targetNodeId === connection.target && c.targetPortIndex === targetPortIndex + ); - if (isPortOccupied) { - // Find the first available port instead - const availablePort = findFirstAvailableInputPort(connection.target); + if (isPortOccupied) { + // Find the first available port instead + const availablePort = findFirstAvailableInputPort(connection.target); - if (availablePort !== null) { - // Use the first available port - targetPortIndex = availablePort; - } else { - // No available port - check if we can create a new one - const graphNodes = get(graphStore.nodesArray); - const targetNode = graphNodes.find((n) => n.id === connection.target); - if (!targetNode) return; - - const typeDef = nodeRegistry.get(targetNode.type); - if (!typeDef || typeDef.ports.maxInputs !== null) { - // Can't create new port, abort - return; - } + if (availablePort !== null) { + // Use the first available port + targetPortIndex = availablePort; + } else { + // No available port - check if we can create a new one + const graphNodes = get(graphStore.nodesArray); + const targetNode = graphNodes.find((n) => n.id === connection.target); + if (!targetNode) return; + + const typeDef = nodeRegistry.get(targetNode.type); + if (!typeDef || typeDef.ports.maxInputs !== null) { + // Can't create new port, abort + return; + } - // Create a new port and use it - graphStore.addInputPort(connection.target); - targetPortIndex = targetNode.inputs.length; // The new port index + // Create a new port and use it + graphStore.addInputPort(connection.target); + targetPortIndex = targetNode.inputs.length; // The new port index + } } - } - // addConnection uses current navigation context automatically - graphStore.addConnection( - connection.source, - sourcePortIndex, - connection.target, - targetPortIndex - ); + // addConnection uses current navigation context automatically + graphStore.addConnection( + connection.source, + sourcePortIndex, + connection.target, + targetPortIndex + ); + }); } } diff --git a/src/lib/components/FlowUpdater.svelte b/src/lib/components/FlowUpdater.svelte index 7f3d4814..6a4b0b73 100644 --- a/src/lib/components/FlowUpdater.svelte +++ b/src/lib/components/FlowUpdater.svelte @@ -3,6 +3,7 @@ import { useSvelteFlow, useUpdateNodeInternals } from '@xyflow/svelte'; import { graphStore } from '$lib/stores/graph'; import { eventStore } from '$lib/stores/events'; + import { historyStore } from '$lib/stores/history'; import { eventRegistry } from '$lib/events/registry'; import type { EventInstance } from '$lib/events/types'; import { fitViewTrigger, fitViewPadding, type FitViewPadding, zoomInTrigger, zoomOutTrigger, panTrigger, focusNodeTrigger, registerScreenToFlowConverter } from '$lib/stores/viewActions'; @@ -131,9 +132,11 @@ const nodeType = event.dataTransfer?.getData('application/pathview-node'); if (nodeType) { // addNode uses current navigation context automatically - graphStore.addNode(nodeType, { - x: position.x - 80, - y: position.y - 30 + historyStore.mutate(() => { + graphStore.addNode(nodeType, { + x: position.x - 80, + y: position.y - 30 + }); }); return; } @@ -146,23 +149,25 @@ y: position.y - 40 }; - if (graphStore.isAtRoot()) { - // Root level: use eventStore - eventStore.addEvent(eventType, eventPos); - } else { - // Subsystem level: use graphStore - const typeDef = eventRegistry.get(eventType); - if (typeDef) { - const newEvent: EventInstance = { - id: crypto.randomUUID(), - type: eventType, - name: typeDef.name, - position: eventPos, - params: {} - }; - graphStore.addSubsystemEvent(newEvent); + historyStore.mutate(() => { + if (graphStore.isAtRoot()) { + // Root level: use eventStore + eventStore.addEvent(eventType, eventPos); + } else { + // Subsystem level: use graphStore + const typeDef = eventRegistry.get(eventType); + if (typeDef) { + const newEvent: EventInstance = { + id: crypto.randomUUID(), + type: eventType, + name: typeDef.name, + position: eventPos, + params: {} + }; + graphStore.addSubsystemEvent(newEvent); + } } - } + }); return; } }); diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index d16b1d11..39da3924 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -7,6 +7,7 @@ import { get } from 'svelte/store'; import type { MenuItemType } from './ContextMenu.svelte'; import type { ContextMenuTarget } from '$lib/stores/contextMenu'; import { graphStore, ANNOTATION_FONT_SIZE } from '$lib/stores/graph'; +import { historyStore } from '$lib/stores/history'; import { routingStore } from '$lib/stores/routing'; import { eventStore } from '$lib/stores/events'; import { clipboardStore } from '$lib/stores/clipboard'; @@ -142,7 +143,7 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { label: 'Delete', icon: 'trash', shortcut: 'Del', - action: () => graphStore.removeNode(nodeId) + action: () => historyStore.mutate(() => graphStore.removeNode(nodeId)) } ]; } @@ -191,7 +192,7 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { shortcut: 'Ctrl+D', action: () => { graphStore.selectNode(nodeId, false); - graphStore.duplicateSelected(); + historyStore.mutate(() => graphStore.duplicateSelected()); } }, { @@ -208,7 +209,7 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { label: 'Delete', icon: 'trash', shortcut: 'Del', - action: () => graphStore.removeNode(nodeId) + action: () => historyStore.mutate(() => graphStore.removeNode(nodeId)) } ); @@ -228,7 +229,7 @@ function buildSelectionMenu( label: `Duplicate ${count} nodes`, icon: 'copy', shortcut: 'Ctrl+D', - action: () => graphStore.duplicateSelected() + action: () => historyStore.mutate(() => graphStore.duplicateSelected()) }, { label: `Copy ${count} nodes`, @@ -270,7 +271,7 @@ function buildEventMenu(eventId: string): MenuItemType[] { shortcut: 'Ctrl+D', action: () => { eventStore.selectEvent(eventId, false); - eventStore.duplicateSelected(); + historyStore.mutate(() => eventStore.duplicateSelected()); } }, { @@ -287,7 +288,7 @@ function buildEventMenu(eventId: string): MenuItemType[] { label: 'Delete', icon: 'trash', shortcut: 'Del', - action: () => eventStore.removeEvent(eventId) + action: () => historyStore.mutate(() => eventStore.removeEvent(eventId)) } ]; } @@ -307,7 +308,7 @@ function buildEdgeMenu(edgeId: string): MenuItemType[] { label: 'Delete', icon: 'trash', shortcut: 'Del', - action: () => graphStore.removeConnection(edgeId) + action: () => historyStore.mutate(() => graphStore.removeConnection(edgeId)) } ]; } @@ -338,7 +339,7 @@ function buildCanvasMenu( icon: 'type', action: () => { const flowPos = screenToFlow(screenPosition); - graphStore.addAnnotation(flowPos); + historyStore.mutate(() => graphStore.addAnnotation(flowPos)); } }, DIVIDER, @@ -413,7 +414,7 @@ function buildAnnotationMenu(annotationId: string): MenuItemType[] { icon: 'font-size-increase', action: () => { if (currentFontSize < ANNOTATION_FONT_SIZE.MAX) { - graphStore.updateAnnotation(annotationId, { fontSize: currentFontSize + ANNOTATION_FONT_SIZE.STEP }); + historyStore.mutate(() => graphStore.updateAnnotation(annotationId, { fontSize: currentFontSize + ANNOTATION_FONT_SIZE.STEP })); } }, disabled: currentFontSize >= ANNOTATION_FONT_SIZE.MAX @@ -423,7 +424,7 @@ function buildAnnotationMenu(annotationId: string): MenuItemType[] { icon: 'font-size-decrease', action: () => { if (currentFontSize > ANNOTATION_FONT_SIZE.MIN) { - graphStore.updateAnnotation(annotationId, { fontSize: currentFontSize - ANNOTATION_FONT_SIZE.STEP }); + historyStore.mutate(() => graphStore.updateAnnotation(annotationId, { fontSize: currentFontSize - ANNOTATION_FONT_SIZE.STEP })); } }, disabled: currentFontSize <= ANNOTATION_FONT_SIZE.MIN @@ -435,7 +436,7 @@ function buildAnnotationMenu(annotationId: string): MenuItemType[] { shortcut: 'Ctrl+D', action: () => { graphStore.selectNode(annotationId, false); - graphStore.duplicateSelected(); + historyStore.mutate(() => graphStore.duplicateSelected()); } }, { @@ -452,7 +453,7 @@ function buildAnnotationMenu(annotationId: string): MenuItemType[] { label: 'Delete', icon: 'trash', shortcut: 'Del', - action: () => graphStore.removeAnnotation(annotationId) + action: () => historyStore.mutate(() => graphStore.removeAnnotation(annotationId)) } ]; } diff --git a/src/lib/components/dialogs/BlockPropertiesDialog.svelte b/src/lib/components/dialogs/BlockPropertiesDialog.svelte index 116d17bb..a22a1ba8 100644 --- a/src/lib/components/dialogs/BlockPropertiesDialog.svelte +++ b/src/lib/components/dialogs/BlockPropertiesDialog.svelte @@ -3,6 +3,7 @@ import { fade, scale } from 'svelte/transition'; import { cubicOut } from 'svelte/easing'; import { graphStore } from '$lib/stores/graph'; + import { historyStore } from '$lib/stores/history'; import { nodeDialogStore, closeNodeDialog } from '$lib/stores/nodeDialog'; import { nodeRegistry, type NodeInstance } from '$lib/nodes'; import { generateBlockCode } from '$lib/pyodide/pathsimRunner'; @@ -58,7 +59,8 @@ // Handle color selection function handleColorSelect(color: string | undefined) { if (!node) return; - graphStore.updateNodeColor(node.id, color); + const nodeId = node.id; + historyStore.mutate(() => graphStore.updateNodeColor(nodeId, color)); } // Code preview state @@ -112,7 +114,8 @@ // No parsing or type coercion - user writes valid Python syntax function handleParamChange(paramName: string, value: string) { if (!node) return; - graphStore.updateNodeParams(node.id, { [paramName]: value }); + const nodeId = node.id; + historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { [paramName]: value })); } // Check if a parameter is pinned to the node @@ -123,17 +126,19 @@ // Toggle pin state for a parameter function togglePinParam(paramName: string) { if (!node) return; + const nodeId = node.id; const currentPinned = node.pinnedParams ?? []; const newPinned = currentPinned.includes(paramName) ? currentPinned.filter(p => p !== paramName) : [...currentPinned, paramName]; - graphStore.updateNode(node.id, { pinnedParams: newPinned }); + historyStore.mutate(() => graphStore.updateNode(nodeId, { pinnedParams: newPinned })); } // Handle name change function handleNameChange(name: string) { if (!node) return; - graphStore.updateNodeName(node.id, name); + const nodeId = node.id; + historyStore.mutate(() => graphStore.updateNodeName(nodeId, name)); } // Format value for display diff --git a/src/lib/components/dialogs/EventPropertiesDialog.svelte b/src/lib/components/dialogs/EventPropertiesDialog.svelte index 58a9d1f8..8d064b41 100644 --- a/src/lib/components/dialogs/EventPropertiesDialog.svelte +++ b/src/lib/components/dialogs/EventPropertiesDialog.svelte @@ -4,6 +4,7 @@ import { cubicOut } from 'svelte/easing'; import { get } from 'svelte/store'; import { eventStore } from '$lib/stores/events'; + import { historyStore } from '$lib/stores/history'; import { eventDialogStore, closeEventDialog } from '$lib/stores/eventDialog'; import { eventRegistry } from '$lib/events/registry'; import { graphStore } from '$lib/stores/graph'; @@ -53,7 +54,8 @@ // Handle color selection function handleColorSelect(color: string | undefined) { if (!event) return; - eventStore.updateEventColor(event.id, color); + const eventId = event.id; + historyStore.mutate(() => eventStore.updateEventColor(eventId, color)); } // Code preview state @@ -91,13 +93,15 @@ // Handle parameter change function handleParamChange(paramName: string, value: string) { if (!event) return; - eventStore.updateEventParams(event.id, { [paramName]: value }); + const eventId = event.id; + historyStore.mutate(() => eventStore.updateEventParams(eventId, { [paramName]: value })); } // Handle name change function handleNameChange(name: string) { if (!event) return; - eventStore.updateEventName(event.id, name); + const eventId = event.id; + historyStore.mutate(() => eventStore.updateEventName(eventId, name)); } // Format value for display diff --git a/src/lib/components/nodes/AnnotationNode.svelte b/src/lib/components/nodes/AnnotationNode.svelte index fb626dc9..1f701512 100644 --- a/src/lib/components/nodes/AnnotationNode.svelte +++ b/src/lib/components/nodes/AnnotationNode.svelte @@ -2,6 +2,7 @@ import { onDestroy } from 'svelte'; import { NodeResizer } from '@xyflow/svelte'; import { graphStore, ANNOTATION_FONT_SIZE } from '$lib/stores/graph'; + import { historyStore } from '$lib/stores/history'; import { editAnnotationTrigger } from '$lib/stores/viewActions'; import { renderMarkdown } from '$lib/utils/markdownRenderer'; import { getKatexCssUrl } from '$lib/utils/katexLoader'; @@ -84,6 +85,14 @@ } }); + function handleTextareaFocus() { + historyStore.beginDrag(); + } + + function handleTextareaBlur() { + historyStore.endDrag(); + } + function handleInput(e: Event) { const value = (e.target as HTMLTextAreaElement).value; graphStore.updateAnnotation(id, { content: value }); @@ -109,17 +118,17 @@ } function handleColorSelect(newColor: string | undefined) { - graphStore.updateAnnotation(id, { color: newColor }); + historyStore.mutate(() => graphStore.updateAnnotation(id, { color: newColor })); } function increaseFontSize() { const newSize = Math.min(fontSize + ANNOTATION_FONT_SIZE.STEP, ANNOTATION_FONT_SIZE.MAX); - graphStore.updateAnnotation(id, { fontSize: newSize }); + historyStore.mutate(() => graphStore.updateAnnotation(id, { fontSize: newSize })); } function decreaseFontSize() { const newSize = Math.max(fontSize - ANNOTATION_FONT_SIZE.STEP, ANNOTATION_FONT_SIZE.MIN); - graphStore.updateAnnotation(id, { fontSize: newSize }); + historyStore.mutate(() => graphStore.updateAnnotation(id, { fontSize: newSize })); } function handleWheel(e: WheelEvent) { @@ -134,10 +143,10 @@ function handleResizeEnd(_event: unknown, params: { width: number; height: number }) { const snappedWidth = Math.round(params.width / GRID_SIZE) * GRID_SIZE; const snappedHeight = Math.round(params.height / GRID_SIZE) * GRID_SIZE; - graphStore.updateAnnotation(id, { + historyStore.mutate(() => graphStore.updateAnnotation(id, { width: Math.max(100, snappedWidth), height: Math.max(50, snappedHeight) - }); + })); } @@ -198,6 +207,8 @@ value={content} oninput={handleInput} onkeydown={handleTextareaKeydown} + onfocus={handleTextareaFocus} + onblur={handleTextareaBlur} onwheel={handleWheel} placeholder="Markdown with $math$..." spellcheck="false" diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index bf3fc664..fb851536 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -6,6 +6,7 @@ import { NODE_TYPES } from '$lib/constants/nodeTypes'; import { openNodeDialog } from '$lib/stores/nodeDialog'; import { graphStore } from '$lib/stores/graph'; + import { historyStore } from '$lib/stores/history'; import { pinnedPreviewsStore } from '$lib/stores/pinnedPreviews'; import { hoveredHandle, selectedNodeHighlight } from '$lib/stores/hoveredHandle'; import { showTooltip, hideTooltip } from '$lib/components/Tooltip.svelte'; @@ -257,7 +258,7 @@ // Add input port function handleAddInput(event: MouseEvent) { event.stopPropagation(); - graphStore.addInputPort(id); + historyStore.mutate(() => graphStore.addInputPort(id)); } // Get min ports from type definition @@ -268,21 +269,21 @@ function handleRemoveInput(event: MouseEvent) { event.stopPropagation(); if (data.inputs.length > minInputs) { - graphStore.removeInputPort(id); + historyStore.mutate(() => graphStore.removeInputPort(id)); } } // Add output port function handleAddOutput(event: MouseEvent) { event.stopPropagation(); - graphStore.addOutputPort(id); + historyStore.mutate(() => graphStore.addOutputPort(id)); } // Remove output port (respects minOutputs) function handleRemoveOutput(event: MouseEvent) { event.stopPropagation(); if (data.outputs.length > minOutputs) { - graphStore.removeOutputPort(id); + historyStore.mutate(() => graphStore.removeOutputPort(id)); } } @@ -294,7 +295,7 @@ // Handle pinned param change function handlePinnedParamChange(paramName: string, value: string) { - graphStore.updateNodeParams(id, { [paramName]: value }); + historyStore.mutate(() => graphStore.updateNodeParams(id, { [paramName]: value })); } // Format value for display diff --git a/src/lib/components/panels/EventsPanel.svelte b/src/lib/components/panels/EventsPanel.svelte index 9b433523..752a490b 100644 --- a/src/lib/components/panels/EventsPanel.svelte +++ b/src/lib/components/panels/EventsPanel.svelte @@ -3,6 +3,7 @@ import type { EventTypeDefinition, EventInstance } from '$lib/events/types'; import { eventStore } from '$lib/stores/events'; import { graphStore } from '$lib/stores/graph'; + import { historyStore } from '$lib/stores/history'; import { screenToFlow } from '$lib/stores/viewActions'; import { tooltip } from '$lib/components/Tooltip.svelte'; import EventPreview from '$lib/components/nodes/EventPreview.svelte'; @@ -32,20 +33,22 @@ const typeDef = eventRegistry.get(type); if (!typeDef) return; - if (isAtRoot) { - // Root level: use eventStore - eventStore.addEvent(type, position); - } else { - // Subsystem level: use graphStore - const event: EventInstance = { - id: crypto.randomUUID(), - type, - name: typeDef.name, - position, - params: {} - }; - graphStore.addSubsystemEvent(event); - } + historyStore.mutate(() => { + if (isAtRoot) { + // Root level: use eventStore + eventStore.addEvent(type, position); + } else { + // Subsystem level: use graphStore + const event: EventInstance = { + id: crypto.randomUUID(), + type, + name: typeDef.name, + position, + params: {} + }; + graphStore.addSubsystemEvent(event); + } + }); } // Handle drag start diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 856abe87..022c3a6b 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -313,14 +313,15 @@ return buildContextMenuItems(contextMenuTarget, contextMenuPosition, contextMenuCallbacks); } - // Helper to rotate a node (single node, creates its own snapshot) + // Helper to rotate a node (single node) function rotateNode(nodeId: string) { const node = graphStore.getNode(nodeId); if (node) { - const currentRotation = (node.params?.['_rotation'] as number) || 0; - const newRotation = (currentRotation + 1) % 4; - // Note: updateNodeParams creates a snapshot internally - graphStore.updateNodeParams(nodeId, { '_rotation': newRotation }); + historyStore.mutate(() => { + const currentRotation = (node.params?.['_rotation'] as number) || 0; + const newRotation = (currentRotation + 1) % 4; + graphStore.updateNodeParams(nodeId, { '_rotation': newRotation }); + }); // Queue update to re-render handles nodeUpdatesStore.queueUpdate([nodeId]); } @@ -553,7 +554,7 @@ return; case 'd': event.preventDefault(); - graphStore.duplicateSelected(); + historyStore.mutate(() => graphStore.duplicateSelected()); return; case 'c': if (!inputFocused) { @@ -932,7 +933,7 @@ // addNode uses current navigation context automatically // Subsystem creation auto-creates Interface block inside - graphStore.addNode(type, position); + historyStore.mutate(() => graphStore.addNode(type, position)); } From 506d46154546215051df18abdeb93315d9988c5d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 28 Jan 2026 08:06:21 +0000 Subject: [PATCH 326/656] Bump version to 0.4.9 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 790e8c25..0cbc124b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pathview", - "version": "0.4.8", + "version": "0.4.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pathview", - "version": "0.4.8", + "version": "0.4.9", "dependencies": { "@codemirror/lang-python": "^6.0.0", "@codemirror/theme-one-dark": "^6.0.0", diff --git a/package.json b/package.json index f588ca67..26c342b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pathview", - "version": "0.4.8", + "version": "0.4.9", "private": true, "type": "module", "scripts": { From 85369d53fe88579e50c844706435cff139cb0271 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Wed, 28 Jan 2026 16:33:54 +0100 Subject: [PATCH 327/656] new brand colors for logo --- static/favicon.png | Bin 15399 -> 18548 bytes static/pathview_logo.png | Bin 21407 -> 21578 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/static/favicon.png b/static/favicon.png index 815eeee072bf892255411da785f82a04145a0497..c786aa0805466071025133c8ddd3ca633146f27c 100644 GIT binary patch literal 18548 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 PvWL>rzg2S6?yvs=VeBeP diff --git a/static/pathview_logo.png b/static/pathview_logo.png index ee2678ade083941088976c778ac021566504d832..78007b3ed60b25c94133dce03d326114d5bc4301 100644 GIT binary patch literal 21578 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 MVS3YqV&wMpf0xC@6951J literal 21407 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�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 Date: Wed, 28 Jan 2026 18:00:50 +0100 Subject: [PATCH 328/656] Use consistent edge color (#808090) for node borders, handles, and edges --- src/app.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.css b/src/app.css index a595691c..6ee73ecc 100644 --- a/src/app.css +++ b/src/app.css @@ -28,7 +28,7 @@ --error-bg: rgba(239, 68, 68, 0.1); /* ===== SPECIAL ===== */ - --edge: #7F7F7F; + --edge: #808090; --grid-dot: #1a1a24; /* ===== FONTS ===== */ From d84e88845228faa547a1e90a84818177fd4fb5a0 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Wed, 28 Jan 2026 20:59:01 +0100 Subject: [PATCH 329/656] Make --text-muted color fixed (#808090) across themes --- src/app.css | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app.css b/src/app.css index 6ee73ecc..254a073e 100644 --- a/src/app.css +++ b/src/app.css @@ -99,7 +99,6 @@ /* Text */ --text: #1a1a1f; - --text-muted: #606068; --text-disabled: #909098; /* Borders */ From bc141ad0e231aa3a2ad7677c58abf5cb5d8370da Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 28 Jan 2026 20:35:03 +0000 Subject: [PATCH 330/656] Bump version to 0.4.10 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0cbc124b..8e444964 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pathview", - "version": "0.4.9", + "version": "0.4.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pathview", - "version": "0.4.9", + "version": "0.4.10", "dependencies": { "@codemirror/lang-python": "^6.0.0", "@codemirror/theme-one-dark": "^6.0.0", diff --git a/package.json b/package.json index 26c342b6..9d626406 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pathview", - "version": "0.4.9", + "version": "0.4.10", "private": true, "type": "module", "scripts": { From 19e1f638f0999a254cb35db83e6e108ed34e1490 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 13:26:53 +0100 Subject: [PATCH 331/656] Add fly-in animation trigger for node placement --- src/lib/stores/viewActions.ts | 4 +++- src/lib/stores/viewTriggers.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/lib/stores/viewActions.ts b/src/lib/stores/viewActions.ts index f706e20f..d970e474 100644 --- a/src/lib/stores/viewActions.ts +++ b/src/lib/stores/viewActions.ts @@ -27,7 +27,9 @@ export { selectNodeTrigger, triggerSelectNodes, editAnnotationTrigger, - triggerEditAnnotation + triggerEditAnnotation, + flyInAnimationTrigger, + triggerFlyInAnimation } from './viewTriggers'; // Re-export all utilities diff --git a/src/lib/stores/viewTriggers.ts b/src/lib/stores/viewTriggers.ts index ee439019..6c837e9c 100644 --- a/src/lib/stores/viewTriggers.ts +++ b/src/lib/stores/viewTriggers.ts @@ -92,3 +92,14 @@ export const editAnnotationTrigger = writable<{ annotationId: string; id: number export function triggerEditAnnotation(annotationId: string): void { editAnnotationTrigger.update((current) => ({ annotationId, id: current.id + 1 })); } + +// Fly-in animation trigger - triggers fly-in animation for a newly placed node +export const flyInAnimationTrigger = writable<{ nodeId: string; position: { x: number; y: number }; id: number }>({ + nodeId: '', + position: { x: 0, y: 0 }, + id: 0 +}); + +export function triggerFlyInAnimation(nodeId: string, position: { x: number; y: number }): void { + flyInAnimationTrigger.update((current) => ({ nodeId, position, id: current.id + 1 })); +} From 505a3ff4fd66e2780d0d861ed76b15d108d30f67 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 13:27:32 +0100 Subject: [PATCH 332/656] Add single-node fly-in animation utility --- src/lib/animation/flyInAnimation.ts | 85 +++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/lib/animation/flyInAnimation.ts diff --git a/src/lib/animation/flyInAnimation.ts b/src/lib/animation/flyInAnimation.ts new file mode 100644 index 00000000..7c2012b2 --- /dev/null +++ b/src/lib/animation/flyInAnimation.ts @@ -0,0 +1,85 @@ +/** + * Single-Node Fly-In Animation + * + * Animates a newly placed node flying in from the left edge of the viewport. + * Reuses the CSS animation infrastructure from assemblyAnimation. + * + * Usage: + * import { runFlyInAnimation } from '$lib/animation/flyInAnimation'; + * + * // Inside SvelteFlow context (FlowUpdater): + * runFlyInAnimation(nodeId, position, getViewport); + */ + +// ============================================================================ +// Configuration +// ============================================================================ + +const CONFIG = { + duration: 500, // Animation duration (ms) - matches assembly animation + flyDistanceMargin: 100, // Extra margin beyond viewport edge (px in flow coords) + domReadyDelay: 50 // Wait for DOM to render after node creation (ms) +}; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ViewportInfo { + zoom: number; + x: number; // Viewport pan x + y: number; // Viewport pan y + width: number; // Canvas width in pixels + height: number; // Canvas height in pixels +} + +// ============================================================================ +// Animation Function +// ============================================================================ + +/** + * Animate a single node flying in from the left edge of the viewport + * + * @param nodeId - The ID of the node to animate + * @param targetPosition - The node's final position in flow coordinates + * @param getViewport - Function to get current viewport info + */ +export function runFlyInAnimation( + nodeId: string, + targetPosition: { x: number; y: number }, + getViewport: () => ViewportInfo +): void { + // Wait for DOM element to exist + setTimeout(() => { + const nodeEl = document.querySelector(`[data-id="${nodeId}"]`) as HTMLElement; + if (!nodeEl) return; + + const viewport = getViewport(); + + // Calculate left edge of viewport in flow coordinates + const leftEdgeX = -viewport.x / viewport.zoom; + + // Fly from left edge (with margin) to target position + // The fly-from value is relative to the node's final position + const flyFromX = leftEdgeX - CONFIG.flyDistanceMargin - targetPosition.x; + const flyFromY = 0; // Keep vertical position (fly horizontally) + + // Set CSS variables for the animation + nodeEl.style.setProperty('--fly-from-x', `${flyFromX}px`); + nodeEl.style.setProperty('--fly-from-y', `${flyFromY}px`); + nodeEl.style.setProperty('--assembly-duration', `${CONFIG.duration}ms`); + nodeEl.style.setProperty('--assembly-delay', '0ms'); + + // Add the assembling class to trigger the CSS animation + nodeEl.classList.add('assembling'); + + // Cleanup after animation completes + setTimeout(() => { + nodeEl.classList.remove('assembling'); + nodeEl.style.removeProperty('--fly-from-x'); + nodeEl.style.removeProperty('--fly-from-y'); + nodeEl.style.removeProperty('--assembly-duration'); + nodeEl.style.removeProperty('--assembly-delay'); + }, CONFIG.duration + 50); + }, CONFIG.domReadyDelay); +} From 17417929dc02f668e3afcf3ffb1cbae1b4ba3c41 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 13:29:05 +0100 Subject: [PATCH 333/656] Wire up click fly-in animation for node placement --- src/lib/components/FlowUpdater.svelte | 26 +++++++++++++++++++++++++- src/routes/+page.svelte | 9 +++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/lib/components/FlowUpdater.svelte b/src/lib/components/FlowUpdater.svelte index 6a4b0b73..6d0eeb0a 100644 --- a/src/lib/components/FlowUpdater.svelte +++ b/src/lib/components/FlowUpdater.svelte @@ -6,10 +6,11 @@ import { historyStore } from '$lib/stores/history'; import { eventRegistry } from '$lib/events/registry'; import type { EventInstance } from '$lib/events/types'; - import { fitViewTrigger, fitViewPadding, type FitViewPadding, zoomInTrigger, zoomOutTrigger, panTrigger, focusNodeTrigger, registerScreenToFlowConverter } from '$lib/stores/viewActions'; + import { fitViewTrigger, fitViewPadding, type FitViewPadding, zoomInTrigger, zoomOutTrigger, panTrigger, focusNodeTrigger, registerScreenToFlowConverter, flyInAnimationTrigger } from '$lib/stores/viewActions'; import { get } from 'svelte/store'; import { dropTargetBridge } from '$lib/stores/dropTargetBridge'; import { assemblyAnimationTrigger, runAssemblyAnimation } from '$lib/animation/assemblyAnimation'; + import { runFlyInAnimation } from '$lib/animation/flyInAnimation'; import { importFile } from '$lib/schema/fileOps'; import { ALL_COMPONENT_EXTENSIONS } from '$lib/types/component'; @@ -293,4 +294,27 @@ ); } }); + + // Listen for fly-in animation trigger (when node is added via click) + let lastFlyInTrigger = 0; + flyInAnimationTrigger.subscribe((value) => { + if (value.id > lastFlyInTrigger && value.nodeId) { + lastFlyInTrigger = value.id; + runFlyInAnimation( + value.nodeId, + value.position, + () => { + const vp = getViewport(); + const canvas = document.querySelector('.svelte-flow') as HTMLElement; + return { + zoom: vp.zoom, + x: vp.x, + y: vp.y, + width: canvas?.clientWidth ?? window.innerWidth, + height: canvas?.clientHeight ?? window.innerHeight + }; + } + ); + } + }); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 022c3a6b..0886a219 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -45,7 +45,7 @@ import { newGraph, saveFile, saveAsFile, setupAutoSave, clearAutoSave, debouncedAutoSave, openImportDialog, importFromUrl, currentFileName } from '$lib/schema/fileOps'; import { confirmationStore } from '$lib/stores/confirmation'; import ConfirmationModal from '$lib/components/ConfirmationModal.svelte'; - import { triggerFitView, triggerZoomIn, triggerZoomOut, triggerPan, getViewportCenter, screenToFlow, triggerClearSelection, triggerNudge, hasAnySelection, setFitViewPadding } from '$lib/stores/viewActions'; + import { triggerFitView, triggerZoomIn, triggerZoomOut, triggerPan, getViewportCenter, screenToFlow, triggerClearSelection, triggerNudge, hasAnySelection, setFitViewPadding, triggerFlyInAnimation } from '$lib/stores/viewActions'; import { nodeUpdatesStore } from '$lib/stores/nodeUpdates'; import { pinnedPreviewsStore } from '$lib/stores/pinnedPreviews'; import { clipboardStore } from '$lib/stores/clipboard'; @@ -933,7 +933,12 @@ // addNode uses current navigation context automatically // Subsystem creation auto-creates Interface block inside - historyStore.mutate(() => graphStore.addNode(type, position)); + const newNode = historyStore.mutate(() => graphStore.addNode(type, position)); + + // Trigger fly-in animation for the new node + if (newNode) { + triggerFlyInAnimation(newNode.id, position); + } } From ca63f3f508e8674f4b60aed4aa63eddccf7ab2a3 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 13:29:31 +0100 Subject: [PATCH 334/656] Fix drop placement to center node at cursor with grid snap --- src/lib/components/FlowUpdater.svelte | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/lib/components/FlowUpdater.svelte b/src/lib/components/FlowUpdater.svelte index 6d0eeb0a..bf5f2513 100644 --- a/src/lib/components/FlowUpdater.svelte +++ b/src/lib/components/FlowUpdater.svelte @@ -13,6 +13,7 @@ import { runFlyInAnimation } from '$lib/animation/flyInAnimation'; import { importFile } from '$lib/schema/fileOps'; import { ALL_COMPONENT_EXTENSIONS } from '$lib/types/component'; + import { GRID_SIZE } from '$lib/constants/grid'; interface Props { pendingUpdates: string[]; @@ -132,12 +133,14 @@ // Check for node drop const nodeType = event.dataTransfer?.getData('application/pathview-node'); if (nodeType) { + // Snap cursor position to grid - node center will be at cursor + // (nodes use center origin [0.5, 0.5]) + const snappedX = Math.round(position.x / GRID_SIZE) * GRID_SIZE; + const snappedY = Math.round(position.y / GRID_SIZE) * GRID_SIZE; + // addNode uses current navigation context automatically historyStore.mutate(() => { - graphStore.addNode(nodeType, { - x: position.x - 80, - y: position.y - 30 - }); + graphStore.addNode(nodeType, { x: snappedX, y: snappedY }); }); return; } From a7a4549ece1372698e58728b693208cb05d86a36 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 13:30:16 +0100 Subject: [PATCH 335/656] Add custom drag preview showing node shape --- src/lib/components/panels/NodeLibrary.svelte | 42 ++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/lib/components/panels/NodeLibrary.svelte b/src/lib/components/panels/NodeLibrary.svelte index ff5b49ec..a08c7aef 100644 --- a/src/lib/components/panels/NodeLibrary.svelte +++ b/src/lib/components/panels/NodeLibrary.svelte @@ -22,6 +22,10 @@ // Track drag state to prevent click after drag let isDragging = $state(false); + // Drag preview - rendered off-screen, used as drag image + let dragPreviewNode = $state(null); + let dragPreviewElement: HTMLDivElement; + // Collapsed categories let collapsedCategories = $state>(new Set()); @@ -85,12 +89,27 @@ return result; }); + // Handle mouse enter to prepare drag preview + function handleMouseEnter(node: NodeTypeDefinition) { + dragPreviewNode = node; + } + // Handle drag start function handleDragStart(event: DragEvent, nodeType: NodeTypeDefinition) { isDragging = true; if (event.dataTransfer) { event.dataTransfer.setData('application/pathview-node', nodeType.type); event.dataTransfer.effectAllowed = 'copy'; + + // Use the pre-rendered preview as drag image, centered on cursor + if (dragPreviewElement) { + const rect = dragPreviewElement.getBoundingClientRect(); + event.dataTransfer.setDragImage( + dragPreviewElement, + rect.width / 2, + rect.height / 2 + ); + } } } @@ -99,6 +118,7 @@ // Reset after a short delay to prevent click from firing setTimeout(() => { isDragging = false; + dragPreviewNode = null; }, 100); } @@ -196,6 +216,7 @@ class="node-tile" class:selected={isSelected(node)} draggable="true" + onmouseenter={() => handleMouseEnter(node)} ondragstart={(e) => handleDragStart(e, node)} ondragend={handleDragEnd} onclick={() => handleNodeClick(node)} @@ -219,6 +240,15 @@ Click or drag to add ↑↓ Enter

        + + +
        From 4682075fa557dccb2d9ba4ad28bc4ac648d8d681 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 13:42:03 +0100 Subject: [PATCH 336/656] Improve fly-in animation: start from cursor position, faster timing, fix flicker --- src/lib/animation/flyInAnimation.ts | 87 +++++++++++++++++++-------- src/lib/components/FlowUpdater.svelte | 4 +- src/lib/stores/viewTriggers.ts | 21 ++++++- src/routes/+page.svelte | 4 +- 4 files changed, 85 insertions(+), 31 deletions(-) diff --git a/src/lib/animation/flyInAnimation.ts b/src/lib/animation/flyInAnimation.ts index 7c2012b2..37a22579 100644 --- a/src/lib/animation/flyInAnimation.ts +++ b/src/lib/animation/flyInAnimation.ts @@ -16,7 +16,7 @@ // ============================================================================ const CONFIG = { - duration: 500, // Animation duration (ms) - matches assembly animation + duration: 300, // Animation duration (ms) flyDistanceMargin: 100, // Extra margin beyond viewport edge (px in flow coords) domReadyDelay: 50 // Wait for DOM to render after node creation (ms) }; @@ -38,31 +38,58 @@ export interface ViewportInfo { // ============================================================================ /** - * Animate a single node flying in from the left edge of the viewport + * Animate a single node flying in from the cursor position * * @param nodeId - The ID of the node to animate * @param targetPosition - The node's final position in flow coordinates * @param getViewport - Function to get current viewport info + * @param cursorScreen - Cursor position in screen coordinates (optional) + * @param screenToFlow - Function to convert screen to flow coordinates (optional) */ export function runFlyInAnimation( nodeId: string, targetPosition: { x: number; y: number }, - getViewport: () => ViewportInfo + getViewport: () => ViewportInfo, + cursorScreen?: { x: number; y: number } | null, + screenToFlow?: (pos: { x: number; y: number }) => { x: number; y: number } ): void { - // Wait for DOM element to exist - setTimeout(() => { - const nodeEl = document.querySelector(`[data-id="${nodeId}"]`) as HTMLElement; - if (!nodeEl) return; + let flyFromX: number; + let flyFromY: number; + if (cursorScreen && screenToFlow) { + // Convert cursor screen position to flow coordinates + const cursorFlow = screenToFlow(cursorScreen); + // Calculate offset from target position (fly-from is relative to final position) + flyFromX = cursorFlow.x - targetPosition.x; + flyFromY = cursorFlow.y - targetPosition.y; + } else { + // Fallback: fly from left edge of viewport const viewport = getViewport(); - - // Calculate left edge of viewport in flow coordinates const leftEdgeX = -viewport.x / viewport.zoom; + flyFromX = leftEdgeX - CONFIG.flyDistanceMargin - targetPosition.x; + flyFromY = 0; + } + + // Poll for DOM element (typically appears within 1-2 frames) + let attempts = 0; + const maxAttempts = 20; + + function tryAnimate() { + const nodeEl = document.querySelector(`[data-id="${nodeId}"]`) as HTMLElement; - // Fly from left edge (with margin) to target position - // The fly-from value is relative to the node's final position - const flyFromX = leftEdgeX - CONFIG.flyDistanceMargin - targetPosition.x; - const flyFromY = 0; // Keep vertical position (fly horizontally) + if (!nodeEl) { + attempts++; + if (attempts < maxAttempts) { + requestAnimationFrame(tryAnimate); + } + return; + } + + // Immediately set initial position to prevent flicker + // This positions the node at the start of the animation before CSS takes over + nodeEl.style.translate = `${flyFromX}px ${flyFromY}px`; + nodeEl.style.scale = '0.8'; + nodeEl.style.opacity = '0'; // Set CSS variables for the animation nodeEl.style.setProperty('--fly-from-x', `${flyFromX}px`); @@ -70,16 +97,26 @@ export function runFlyInAnimation( nodeEl.style.setProperty('--assembly-duration', `${CONFIG.duration}ms`); nodeEl.style.setProperty('--assembly-delay', '0ms'); - // Add the assembling class to trigger the CSS animation - nodeEl.classList.add('assembling'); - - // Cleanup after animation completes - setTimeout(() => { - nodeEl.classList.remove('assembling'); - nodeEl.style.removeProperty('--fly-from-x'); - nodeEl.style.removeProperty('--fly-from-y'); - nodeEl.style.removeProperty('--assembly-duration'); - nodeEl.style.removeProperty('--assembly-delay'); - }, CONFIG.duration + 50); - }, CONFIG.domReadyDelay); + // Start animation in next frame (after initial styles are applied) + requestAnimationFrame(() => { + // Remove inline styles and let animation take over + nodeEl.style.removeProperty('translate'); + nodeEl.style.removeProperty('scale'); + nodeEl.style.removeProperty('opacity'); + + // Add the assembling class to trigger the CSS animation + nodeEl.classList.add('assembling'); + + // Cleanup after animation completes + setTimeout(() => { + nodeEl.classList.remove('assembling'); + nodeEl.style.removeProperty('--fly-from-x'); + nodeEl.style.removeProperty('--fly-from-y'); + nodeEl.style.removeProperty('--assembly-duration'); + nodeEl.style.removeProperty('--assembly-delay'); + }, CONFIG.duration + 50); + }); + } + + requestAnimationFrame(tryAnimate); } diff --git a/src/lib/components/FlowUpdater.svelte b/src/lib/components/FlowUpdater.svelte index bf5f2513..a2325cf5 100644 --- a/src/lib/components/FlowUpdater.svelte +++ b/src/lib/components/FlowUpdater.svelte @@ -316,7 +316,9 @@ width: canvas?.clientWidth ?? window.innerWidth, height: canvas?.clientHeight ?? window.innerHeight }; - } + }, + value.cursorScreen, + screenToFlowPosition ); } }); diff --git a/src/lib/stores/viewTriggers.ts b/src/lib/stores/viewTriggers.ts index 6c837e9c..b05a1cd1 100644 --- a/src/lib/stores/viewTriggers.ts +++ b/src/lib/stores/viewTriggers.ts @@ -94,12 +94,27 @@ export function triggerEditAnnotation(annotationId: string): void { } // Fly-in animation trigger - triggers fly-in animation for a newly placed node -export const flyInAnimationTrigger = writable<{ nodeId: string; position: { x: number; y: number }; id: number }>({ +export const flyInAnimationTrigger = writable<{ + nodeId: string; + position: { x: number; y: number }; + cursorScreen: { x: number; y: number } | null; + id: number +}>({ nodeId: '', position: { x: 0, y: 0 }, + cursorScreen: null, id: 0 }); -export function triggerFlyInAnimation(nodeId: string, position: { x: number; y: number }): void { - flyInAnimationTrigger.update((current) => ({ nodeId, position, id: current.id + 1 })); +export function triggerFlyInAnimation( + nodeId: string, + position: { x: number; y: number }, + cursorScreen?: { x: number; y: number } +): void { + flyInAnimationTrigger.update((current) => ({ + nodeId, + position, + cursorScreen: cursorScreen ?? null, + id: current.id + 1 + })); } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 0886a219..7f755dcc 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -935,9 +935,9 @@ // Subsystem creation auto-creates Interface block inside const newNode = historyStore.mutate(() => graphStore.addNode(type, position)); - // Trigger fly-in animation for the new node + // Trigger fly-in animation for the new node (from cursor position) if (newNode) { - triggerFlyInAnimation(newNode.id, position); + triggerFlyInAnimation(newNode.id, position, mousePosition); } } From 32ea9783d06ecec8c33e5b2c34739399bbf837b8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 4 Feb 2026 12:56:17 +0000 Subject: [PATCH 337/656] Bump version to 0.4.11 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8e444964..1186be83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pathview", - "version": "0.4.10", + "version": "0.4.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pathview", - "version": "0.4.10", + "version": "0.4.11", "dependencies": { "@codemirror/lang-python": "^6.0.0", "@codemirror/theme-one-dark": "^6.0.0", diff --git a/package.json b/package.json index 9d626406..85e6d96b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pathview", - "version": "0.4.10", + "version": "0.4.11", "private": true, "type": "module", "scripts": { From 85892d31009f1bcfcfa592e26a414e62ea9979f8 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 15:05:51 +0100 Subject: [PATCH 338/656] Add port labels store and keyboard shortcut (L) --- .../dialogs/KeyboardShortcutsDialog.svelte | 1 + src/lib/stores/index.ts | 1 + src/lib/stores/portLabels.ts | 38 +++++++++++++++++++ src/routes/+page.svelte | 5 +++ 4 files changed, 45 insertions(+) create mode 100644 src/lib/stores/portLabels.ts diff --git a/src/lib/components/dialogs/KeyboardShortcutsDialog.svelte b/src/lib/components/dialogs/KeyboardShortcutsDialog.svelte index be3eb955..af9bae29 100644 --- a/src/lib/components/dialogs/KeyboardShortcutsDialog.svelte +++ b/src/lib/components/dialogs/KeyboardShortcutsDialog.svelte @@ -58,6 +58,7 @@ { keys: ['H'], description: 'Go to root' }, { keys: ['+'], description: 'Zoom in' }, { keys: ['-'], description: 'Zoom out' }, + { keys: ['L'], description: 'Port labels' }, { keys: ['T'], description: 'Theme' } ] }, diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index 54eb9348..93368c75 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -19,6 +19,7 @@ export { contextMenuStore } from './contextMenu'; export { nodeUpdatesStore } from './nodeUpdates'; export { pinnedPreviewsStore } from './pinnedPreviews'; export { hoveredHandle, selectedNodeHighlight } from './hoveredHandle'; +export { portLabelsStore } from './portLabels'; // View actions (re-exports triggers and utils) export * from './viewActions'; diff --git a/src/lib/stores/portLabels.ts b/src/lib/stores/portLabels.ts new file mode 100644 index 00000000..4e8722cd --- /dev/null +++ b/src/lib/stores/portLabels.ts @@ -0,0 +1,38 @@ +/** + * Port Labels Store + * + * Controls global visibility of port labels inside nodes. + * Toggle with 'L' key. Persists to localStorage. + */ + +import { writable, get } from 'svelte/store'; +import { browser } from '$app/environment'; + +const STORAGE_KEY = 'pathview-portLabels'; + +function getInitialValue(): boolean { + if (!browser) return false; + return localStorage.getItem(STORAGE_KEY) === 'true'; +} + +const store = writable(getInitialValue()); + +// Persist to localStorage on change +store.subscribe((value) => { + if (browser) { + localStorage.setItem(STORAGE_KEY, String(value)); + } +}); + +export const portLabelsStore = { + subscribe: store.subscribe, + toggle(): void { + store.update((current) => !current); + }, + set(value: boolean): void { + store.set(value); + }, + get(): boolean { + return get(store); + } +}; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7f755dcc..e9de62d2 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -48,6 +48,7 @@ import { triggerFitView, triggerZoomIn, triggerZoomOut, triggerPan, getViewportCenter, screenToFlow, triggerClearSelection, triggerNudge, hasAnySelection, setFitViewPadding, triggerFlyInAnimation } from '$lib/stores/viewActions'; import { nodeUpdatesStore } from '$lib/stores/nodeUpdates'; import { pinnedPreviewsStore } from '$lib/stores/pinnedPreviews'; + import { portLabelsStore } from '$lib/stores/portLabels'; import { clipboardStore } from '$lib/stores/clipboard'; import Tooltip, { tooltip } from '$lib/components/Tooltip.svelte'; import { isInputFocused } from '$lib/utils/focus'; @@ -653,6 +654,10 @@ event.preventDefault(); pinnedPreviewsStore.toggle(); return; + case 'l': + event.preventDefault(); + portLabelsStore.toggle(); + return; case 'b': event.preventDefault(); toggleNodeLibrary(); From 601b15f7cae626df845f33d188f926e4dd0fda40 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 15:08:08 +0100 Subject: [PATCH 339/656] Add showPortLabels parameter to node dimension calculation --- src/lib/constants/dimensions.ts | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/lib/constants/dimensions.ts b/src/lib/constants/dimensions.ts index f95c5b24..dd6d4a2d 100644 --- a/src/lib/constants/dimensions.ts +++ b/src/lib/constants/dimensions.ts @@ -42,6 +42,14 @@ export const EVENT = { /** Export padding: 4 grid units = 40px */ export const EXPORT_PADDING = G.x4; +/** Port label dimensions (when labels are shown) */ +export const PORT_LABEL = { + /** Width of label column for horizontal ports: 4 grid units = 40px */ + columnWidth: G.x4, + /** Height of label row for vertical ports: 2 grid units = 20px */ + rowHeight: G.x2 +} as const; + /** * Round up to next 2G (20px) boundary. * This ensures nodes expand by 1G in each direction (symmetric from center). @@ -75,6 +83,8 @@ export function getPortPositionCalc(index: number, total: number): string { /** * Calculate node dimensions from node data. * Used by both SvelteFlow (for bounds) and BaseNode (for CSS). + * + * @param showPortLabels - If true, adds space for port label columns/rows */ export function calculateNodeDimensions( name: string, @@ -82,7 +92,8 @@ export function calculateNodeDimensions( outputCount: number, pinnedParamCount: number, rotation: number, - typeName?: string + typeName?: string, + showPortLabels?: boolean ): { width: number; height: number } { const isVertical = rotation === 1 || rotation === 3; const maxPortsOnSide = Math.max(inputCount, outputCount); @@ -97,7 +108,7 @@ export function calculateNodeDimensions( const nameWidth = name.length * 6 + 20; const typeWidth = typeName ? typeName.length * 5 + 20 : 0; const pinnedParamsWidth = pinnedParamCount > 0 ? 160 : 0; - const width = snapTo2G(Math.max( + let width = snapTo2G(Math.max( NODE.baseWidth, nameWidth, typeWidth, @@ -107,9 +118,22 @@ export function calculateNodeDimensions( // Height: content height vs port dimension (they share vertical space) const contentHeight = NODE.baseHeight + pinnedParamsHeight; - const height = isVertical + let height = isVertical ? snapTo2G(contentHeight) : snapTo2G(Math.max(contentHeight, minPortDimension)); + // Add space for port labels if enabled + if (showPortLabels) { + if (isVertical) { + // Vertical ports: add rows for labels above/below content + if (inputCount > 0) height += PORT_LABEL.rowHeight; + if (outputCount > 0) height += PORT_LABEL.rowHeight; + } else { + // Horizontal ports: add columns for labels on left/right + if (inputCount > 0) width += PORT_LABEL.columnWidth; + if (outputCount > 0) width += PORT_LABEL.columnWidth; + } + } + return { width, height }; } From 035856e0216e03f4f42e2fec545cd44b01ddff61 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 15:21:38 +0100 Subject: [PATCH 340/656] Implement port labels rendering in BaseNode --- src/lib/components/nodes/BaseNode.svelte | 169 ++++++++++++++++++++++- 1 file changed, 165 insertions(+), 4 deletions(-) diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index fb851536..153260e7 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -8,11 +8,12 @@ import { graphStore } from '$lib/stores/graph'; import { historyStore } from '$lib/stores/history'; import { pinnedPreviewsStore } from '$lib/stores/pinnedPreviews'; + import { portLabelsStore } from '$lib/stores/portLabels'; import { hoveredHandle, selectedNodeHighlight } from '$lib/stores/hoveredHandle'; import { showTooltip, hideTooltip } from '$lib/components/Tooltip.svelte'; import { paramInput } from '$lib/actions/paramInput'; import { plotDataStore } from '$lib/plotting/processing/plotDataStore'; - import { NODE, getPortPositionCalc, calculateNodeDimensions, snapTo2G } from '$lib/constants/dimensions'; + import { NODE, PORT_LABEL, getPortPositionCalc, calculateNodeDimensions, snapTo2G } from '$lib/constants/dimensions'; import { containsMath, renderInlineMath, renderInlineMathSync, measureRenderedMath, getBaselineTextHeight } from '$lib/utils/inlineMathRenderer'; import { getKatexCssUrl } from '$lib/utils/katexLoader'; import PlotPreview from './PlotPreview.svelte'; @@ -58,9 +59,24 @@ hasPlotData = state.plots.has(id); }); + // Port labels visibility + let showPortLabels = $state(false); + const unsubscribePortLabels = portLabelsStore.subscribe((value) => { + showPortLabels = value; + }); + + // Re-measure node when port labels toggle changes + $effect(() => { + // Dependency on showPortLabels + if (showPortLabels !== undefined) { + updateNodeInternals(id); + } + }); + onDestroy(() => { unsubscribePinned(); unsubscribePlotData(); + unsubscribePortLabels(); if (hoverTimeout) clearTimeout(hoverTimeout); }); @@ -186,7 +202,8 @@ data.outputs.length, pinnedCount, rotation, - typeDef?.name + typeDef?.name, + showPortLabels )); // Use measured width if math is rendered and measured, otherwise use calculated const nodeWidth = $derived(() => { @@ -200,13 +217,19 @@ const pinnedParamsWidth = pinnedCount > 0 ? 160 : 0; // Minimum width for layout (without name string-length estimate) - const minLayoutWidth = snapTo2G(Math.max( + let minLayoutWidth = snapTo2G(Math.max( NODE.baseWidth, typeWidth, pinnedParamsWidth, isVertical ? minPortDimension : 0 )); + // Add port label columns if enabled (horizontal ports only) + if (showPortLabels && !isVertical) { + if (data.inputs.length > 0) minLayoutWidth += PORT_LABEL.columnWidth; + if (data.outputs.length > 0) minLayoutWidth += PORT_LABEL.columnWidth; + } + // Add horizontal padding from .node-content (12px each side = 24px) const measuredMathWidth = snapTo2G(measuredNameWidth + 24); return Math.max(minLayoutWidth, measuredMathWidth); @@ -229,7 +252,13 @@ const pinnedParamsHeight = pinnedCount > 0 ? 7 + 24 * pinnedCount : 0; // Content height: math height + type label (12px) + padding (12px) - const contentHeight = measuredNameHeight + 24 + pinnedParamsHeight; + let contentHeight = measuredNameHeight + 24 + pinnedParamsHeight; + + // Add port label rows if enabled (vertical ports only) + if (showPortLabels && isVertical) { + if (data.inputs.length > 0) contentHeight += PORT_LABEL.rowHeight; + if (data.outputs.length > 0) contentHeight += PORT_LABEL.rowHeight; + } return isVertical ? snapTo2G(contentHeight) @@ -305,6 +334,11 @@ return String(value); } + // Truncate port label for display + function truncateLabel(name: string, maxChars: number = 5): string { + return name.length > maxChars ? name.slice(0, maxChars) : name; + } + // Format default value for placeholder (Python style) function formatDefault(value: unknown): string { if (value === null || value === undefined) return 'None'; @@ -379,6 +413,9 @@ class:vertical={isVertical} class:preview-hovered={showPreview} class:subsystem-type={isSubsystemType} + class:show-labels={showPortLabels} + class:has-inputs={data.inputs.length > 0} + class:has-outputs={data.outputs.length > 0} data-rotation={rotation} style="width: {nodeWidth()}px; height: {nodeHeight()}px; --node-color: {nodeColor};" ondblclick={handleDoubleClick} @@ -400,6 +437,26 @@
        {/if} + + {#if showPortLabels && data.inputs.length > 0 && !isVertical} +
        + {#each data.inputs as port, i} + + {truncateLabel(port.name)} + + {/each} +
        + {/if} + + + {#if showPortLabels && data.inputs.length > 0 && isVertical} +
        + {#each data.inputs as port} + {truncateLabel(port.name)} + {/each} +
        + {/if} +
        @@ -440,6 +497,26 @@ {/if}
        + + {#if showPortLabels && data.outputs.length > 0 && !isVertical} +
        + {#each data.outputs as port, i} + + {truncateLabel(port.name)} + + {/each} +
        + {/if} + + + {#if showPortLabels && data.outputs.length > 0 && isVertical} +
        + {#each data.outputs as port} + {truncateLabel(port.name)} + {/each} +
        + {/if} + {#if allowsDynamicInputs && selected}
        @@ -870,4 +947,88 @@ opacity: 1; } } + + /* Port labels - 3-column grid layout when labels are shown (horizontal) */ + .node.show-labels:not(.vertical) { + display: grid; + grid-template-columns: var(--input-col, 0px) 1fr var(--output-col, 0px); + } + .node.show-labels.has-inputs:not(.vertical) { + --input-col: 40px; + } + .node.show-labels.has-outputs:not(.vertical) { + --output-col: 40px; + } + + /* Port labels - 3-row grid layout when labels are shown (vertical) */ + .node.show-labels.vertical { + display: grid; + grid-template-rows: var(--input-row, 0px) 1fr var(--output-row, 0px); + } + .node.show-labels.vertical.has-inputs { + --input-row: 20px; + } + .node.show-labels.vertical.has-outputs { + --output-row: 20px; + } + + /* Label containers */ + .port-labels { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + } + + /* Horizontal layout: left/right columns */ + .port-labels-input:not(.port-labels-right) { + text-align: right; + padding-right: 4px; + } + .port-labels-output:not(.port-labels-left) { + text-align: left; + padding-left: 4px; + } + + /* Flipped for rotation 2 */ + .port-labels-input.port-labels-right { + text-align: left; + padding-left: 4px; + } + .port-labels-output.port-labels-left { + text-align: right; + padding-right: 4px; + } + + /* Individual port labels (absolute positioning for horizontal) */ + .port-label { + position: absolute; + font-size: 8px; + color: var(--text-muted); + white-space: nowrap; + transform: translateY(-50%); + overflow: hidden; + text-overflow: ellipsis; + max-width: 36px; + left: 0; + right: 0; + } + + /* Vertical rotation - horizontal row of labels */ + .port-labels-row { + display: flex; + align-items: center; + justify-content: center; + gap: 20px; + height: 100%; + } + + .port-labels-row .port-label { + position: relative; + transform: none; + top: auto; + left: auto; + right: auto; + text-align: center; + } From 3f173d0fcb430cdd8651445c5fd14fc7ba41d013 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 16:47:12 +0100 Subject: [PATCH 341/656] Add per-node port label toggles for input/output visibility --- src/lib/components/contextMenuBuilders.ts | 59 +++ src/lib/components/nodes/BaseNode.svelte | 415 ++++++++++++++-------- src/lib/constants/dimensions.ts | 30 +- 3 files changed, 347 insertions(+), 157 deletions(-) diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index 39da3924..0faaab0d 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -25,6 +25,7 @@ import { hasExportableData, exportRecordingData } from '$lib/utils/csvExport'; import { exportToSVG } from '$lib/export/svg'; import { downloadSvg } from '$lib/utils/download'; import { plotSettingsStore, DEFAULT_BLOCK_SETTINGS } from '$lib/stores/plotSettings'; +import { portLabelsStore } from '$lib/stores/portLabels'; /** Divider menu item */ const DIVIDER: MenuItemType = { label: '', action: () => {}, divider: true }; @@ -73,6 +74,10 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { // Interface blocks have limited options if (isInterface) { + const globalLabels = get(portLabelsStore); + const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; + const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; + return [ { label: 'Properties', @@ -86,6 +91,21 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { action: () => graphStore.drillUp() }, DIVIDER, + { + label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) + ) + }, + { + label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) + ) + }, + DIVIDER, { label: 'View Code', icon: 'braces', @@ -96,6 +116,10 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { // Subsystem blocks get "Enter" option if (isSubsystem) { + const globalLabels = get(portLabelsStore); + const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; + const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; + return [ { label: 'Properties', @@ -109,6 +133,21 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { action: () => graphStore.drillDown(nodeId) }, DIVIDER, + { + label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) + ) + }, + { + label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) + ) + }, + DIVIDER, { label: 'View Code', icon: 'braces', @@ -152,6 +191,11 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { const isRecordingNode = node.type === 'Scope' || node.type === 'Spectrum'; const dataSource = node.type === 'Scope' ? 'scope' : 'spectrum'; + // Per-node port label visibility (undefined = follow global) + const globalLabels = get(portLabelsStore); + const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; + const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; + // Regular blocks const items: MenuItemType[] = [ { @@ -161,6 +205,21 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { action: () => openNodeDialog(nodeId) }, DIVIDER, + { + label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) + ) + }, + { + label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) + ) + }, + DIVIDER, { label: 'View Code', icon: 'braces', diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index 153260e7..87e99586 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -59,16 +59,27 @@ hasPlotData = state.plots.has(id); }); - // Port labels visibility - let showPortLabels = $state(false); + // Global port labels visibility + let globalShowPortLabels = $state(false); const unsubscribePortLabels = portLabelsStore.subscribe((value) => { - showPortLabels = value; + globalShowPortLabels = value; }); + // Per-node overrides (undefined = follow global) + const nodeShowInputLabels = $derived(data.params?.['_showInputLabels'] as boolean | undefined); + const nodeShowOutputLabels = $derived(data.params?.['_showOutputLabels'] as boolean | undefined); + + // Effective visibility (per-node overrides global) + const showInputLabels = $derived(nodeShowInputLabels ?? globalShowPortLabels); + const showOutputLabels = $derived(nodeShowOutputLabels ?? globalShowPortLabels); + + // For CSS class (show-labels when either is visible) + const showPortLabels = $derived(showInputLabels || showOutputLabels); + // Re-measure node when port labels toggle changes $effect(() => { - // Dependency on showPortLabels - if (showPortLabels !== undefined) { + // Dependency on showInputLabels and showOutputLabels + if (showInputLabels !== undefined || showOutputLabels !== undefined) { updateNodeInternals(id); } }); @@ -203,7 +214,8 @@ pinnedCount, rotation, typeDef?.name, - showPortLabels + showInputLabels, + showOutputLabels )); // Use measured width if math is rendered and measured, otherwise use calculated const nodeWidth = $derived(() => { @@ -225,9 +237,9 @@ )); // Add port label columns if enabled (horizontal ports only) - if (showPortLabels && !isVertical) { - if (data.inputs.length > 0) minLayoutWidth += PORT_LABEL.columnWidth; - if (data.outputs.length > 0) minLayoutWidth += PORT_LABEL.columnWidth; + if (!isVertical) { + if (showInputLabels && data.inputs.length > 0) minLayoutWidth += PORT_LABEL.columnWidth; + if (showOutputLabels && data.outputs.length > 0) minLayoutWidth += PORT_LABEL.columnWidth; } // Add horizontal padding from .node-content (12px each side = 24px) @@ -255,9 +267,9 @@ let contentHeight = measuredNameHeight + 24 + pinnedParamsHeight; // Add port label rows if enabled (vertical ports only) - if (showPortLabels && isVertical) { - if (data.inputs.length > 0) contentHeight += PORT_LABEL.rowHeight; - if (data.outputs.length > 0) contentHeight += PORT_LABEL.rowHeight; + if (isVertical) { + if (showInputLabels && data.inputs.length > 0) contentHeight += PORT_LABEL.rowHeight; + if (showOutputLabels && data.outputs.length > 0) contentHeight += PORT_LABEL.rowHeight; } return isVertical @@ -414,8 +426,8 @@ class:preview-hovered={showPreview} class:subsystem-type={isSubsystemType} class:show-labels={showPortLabels} - class:has-inputs={data.inputs.length > 0} - class:has-outputs={data.outputs.length > 0} + class:has-inputs={showInputLabels && data.inputs.length > 0} + class:has-outputs={showOutputLabels && data.outputs.length > 0} data-rotation={rotation} style="width: {nodeWidth()}px; height: {nodeHeight()}px; --node-color: {nodeColor};" ondblclick={handleDoubleClick} @@ -437,85 +449,90 @@
        {/if} - - {#if showPortLabels && data.inputs.length > 0 && !isVertical} -
        - {#each data.inputs as port, i} - - {truncateLabel(port.name)} - - {/each} -
        - {/if} - - - {#if showPortLabels && data.inputs.length > 0 && isVertical} -
        - {#each data.inputs as port} - {truncateLabel(port.name)} - {/each} -
        - {/if} - - -
        - -
        - {#if renderedNameHtml} - {@html renderedNameHtml} + +
        + + {#if showInputLabels && data.inputs.length > 0} + {#if isVertical} +
        + {#each data.inputs as port, i} + + {truncateLabel(port.name)} + + {/each} +
        {:else} - {data.name} +
        + {#each data.inputs as port, i} + + {truncateLabel(port.name)} + + {/each} +
        {/if} - {#if typeDef} - {typeDef.name} - {/if} -
        + {/if} - - {#if validPinnedParams().length > 0 && typeDef} - -
        e.stopPropagation()} ondblclick={(e) => e.stopPropagation()}> - {#each validPinnedParams() as paramName} - {@const paramDef = typeDef.params.find(p => p.name === paramName)} - {#if paramDef} -
        - - handlePinnedParamChange(paramName, e.currentTarget.value)} - onmousedown={(e) => e.stopPropagation()} - onfocus={(e) => e.stopPropagation()} - use:paramInput - /> -
        - {/if} - {/each} + +
        + +
        + {#if renderedNameHtml} + {@html renderedNameHtml} + {:else} + {data.name} + {/if} + {#if typeDef} + {typeDef.name} + {/if}
        - {/if} -
        - - {#if showPortLabels && data.outputs.length > 0 && !isVertical} -
        - {#each data.outputs as port, i} - - {truncateLabel(port.name)} - - {/each} + + {#if validPinnedParams().length > 0 && typeDef} + +
        e.stopPropagation()} ondblclick={(e) => e.stopPropagation()}> + {#each validPinnedParams() as paramName} + {@const paramDef = typeDef.params.find(p => p.name === paramName)} + {#if paramDef} +
        + + handlePinnedParamChange(paramName, e.currentTarget.value)} + onmousedown={(e) => e.stopPropagation()} + onfocus={(e) => e.stopPropagation()} + use:paramInput + /> +
        + {/if} + {/each} +
        + {/if}
        - {/if} - - {#if showPortLabels && data.outputs.length > 0 && isVertical} -
        - {#each data.outputs as port} - {truncateLabel(port.name)} - {/each} -
        - {/if} + + {#if showOutputLabels && data.outputs.length > 0} + {#if isVertical} +
        + {#each data.outputs as port, i} + + {truncateLabel(port.name)} + + {/each} +
        + {:else} +
        + {#each data.outputs as port, i} + + {truncateLabel(port.name)} + + {/each} +
        + {/if} + {/if} +
        {#if allowsDynamicInputs && selected} @@ -569,29 +586,32 @@ position: relative; /* Dimensions set via inline style using grid constants */ /* Note: center-origin handled by SvelteFlow's nodeOrigin={[0.5, 0.5]} */ - display: flex; - flex-direction: column; background: var(--surface-raised); border: 1px solid var(--edge); font-size: 10px; - overflow: visible; + overflow: visible; /* Allow handles to extend outside */ + --node-radius: 8px; } - /* Shape variants */ + /* Shape variants - set both border-radius and custom property for inner clipping */ .shape-pill { - border-radius: 20px; + --node-radius: 20px; + border-radius: var(--node-radius); } .shape-rect { - border-radius: 4px; + --node-radius: 4px; + border-radius: var(--node-radius); } .shape-circle { - border-radius: 16px; + --node-radius: 16px; + border-radius: var(--node-radius); } .shape-diamond { - border-radius: 4px; + --node-radius: 4px; + border-radius: var(--node-radius); transform: rotate(45deg); } @@ -600,11 +620,13 @@ } .shape-mixed { + --node-radius: 12px; border-radius: 12px 4px 12px 4px; } .shape-default { - border-radius: 8px; + --node-radius: 8px; + border-radius: var(--node-radius); } /* Subsystem/Interface dashed border */ @@ -627,13 +649,21 @@ z-index: 1000 !important; } - /* Inner wrapper for content - fills node, clips to rounded corners */ + /* Clip wrapper - fills node, clips content to rounded corners */ + .node-clip { + position: absolute; + inset: 0; + overflow: hidden; + border-radius: max(0px, calc(var(--node-radius, 8px) - 1px)); + display: flex; + flex-direction: column; + } + + /* Inner wrapper for content */ .node-inner { flex: 1; display: flex; flex-direction: column; - border-radius: inherit; - overflow: hidden; min-height: 0; } @@ -690,21 +720,24 @@ margin-top: 2px; } - /* Pinned parameters */ + /* Pinned parameters - rectangular, clipped by node-clip's overflow:hidden */ .pinned-params { display: flex; flex-direction: column; gap: 4px; - padding: 4px 10px 6px; + padding: 4px 8px 6px; border-top: 1px solid var(--border); background: var(--surface); + border-radius: 0; + overflow: hidden; } .pinned-param { display: flex; align-items: center; - gap: 6px; + gap: 4px; min-width: 0; + max-width: 100%; } .pinned-param label { @@ -721,7 +754,7 @@ flex: 1; min-width: 0; height: 20px; - padding: 2px 8px; + padding: 2px 6px; font-size: 8px; font-family: var(--font-mono); background: var(--surface-raised); @@ -949,27 +982,45 @@ } /* Port labels - 3-column grid layout when labels are shown (horizontal) */ - .node.show-labels:not(.vertical) { + .node.show-labels:not(.vertical) .node-clip { display: grid; - grid-template-columns: var(--input-col, 0px) 1fr var(--output-col, 0px); + grid-template-columns: var(--left-col, 0px) 1fr var(--right-col, 0px); + grid-template-rows: 1fr; } - .node.show-labels.has-inputs:not(.vertical) { - --input-col: 40px; + /* Rotation 0: inputs left, outputs right */ + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs { + --left-col: 40px; } - .node.show-labels.has-outputs:not(.vertical) { - --output-col: 40px; + .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs { + --right-col: 40px; + } + /* Rotation 2: inputs right, outputs left */ + .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs { + --left-col: 40px; + } + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs { + --right-col: 40px; } /* Port labels - 3-row grid layout when labels are shown (vertical) */ - .node.show-labels.vertical { + .node.show-labels.vertical .node-clip { display: grid; - grid-template-rows: var(--input-row, 0px) 1fr var(--output-row, 0px); + grid-template-columns: 1fr; + grid-template-rows: var(--top-row, 0px) 1fr var(--bottom-row, 0px); + } + /* Rotation 1: inputs top, outputs bottom */ + .node.show-labels.vertical[data-rotation="1"].has-inputs { + --top-row: 40px; } - .node.show-labels.vertical.has-inputs { - --input-row: 20px; + .node.show-labels.vertical[data-rotation="1"].has-outputs { + --bottom-row: 40px; } - .node.show-labels.vertical.has-outputs { - --output-row: 20px; + /* Rotation 3: inputs bottom, outputs top */ + .node.show-labels.vertical[data-rotation="3"].has-outputs { + --top-row: 40px; + } + .node.show-labels.vertical[data-rotation="3"].has-inputs { + --bottom-row: 40px; } /* Label containers */ @@ -977,27 +1028,63 @@ position: relative; min-width: 0; min-height: 0; - overflow: hidden; + overflow: visible; } - /* Horizontal layout: left/right columns */ - .port-labels-input:not(.port-labels-right) { - text-align: right; - padding-right: 4px; + /* Horizontal layout: explicit grid column placement */ + .node.show-labels:not(.vertical) .node-clip > .port-labels-input { + grid-column: 1; + grid-row: 1; + border-right: 1px solid var(--border); } - .port-labels-output:not(.port-labels-left) { - text-align: left; - padding-left: 4px; + .node.show-labels:not(.vertical) .node-clip > .node-inner { + grid-column: 2; + grid-row: 1; + } + .node.show-labels:not(.vertical) .node-clip > .port-labels-output { + grid-column: 3; + grid-row: 1; + border-left: 1px solid var(--border); } - /* Flipped for rotation 2 */ - .port-labels-input.port-labels-right { - text-align: left; - padding-left: 4px; + /* Rotation 2: swap input/output column positions */ + .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-input { + grid-column: 3; + border-right: none; + border-left: 1px solid var(--border); } - .port-labels-output.port-labels-left { - text-align: right; - padding-right: 4px; + .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-output { + grid-column: 1; + border-left: none; + border-right: 1px solid var(--border); + } + + /* Vertical layout: explicit grid row placement */ + .node.show-labels.vertical .node-clip > .port-labels-input { + grid-column: 1; + grid-row: 1; + border-bottom: 1px solid var(--border); + } + .node.show-labels.vertical .node-clip > .node-inner { + grid-column: 1; + grid-row: 2; + } + .node.show-labels.vertical .node-clip > .port-labels-output { + grid-column: 1; + grid-row: 3; + border-top: 1px solid var(--border); + } + + /* Rotation 3: swap input/output row positions */ + .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-input { + grid-row: 3; + border-bottom: none; + border-top: 1px solid var(--border); + } + .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-output { + grid-row: 1; + border-top: none; + border-bottom: 1px solid var(--border); } /* Individual port labels (absolute positioning for horizontal) */ @@ -1010,25 +1097,69 @@ overflow: hidden; text-overflow: ellipsis; max-width: 36px; - left: 0; - right: 0; + line-height: 1; + } + + /* Input labels: align right (near separator), away from handle edge */ + .port-labels-input .port-label { + right: 6px; + text-align: right; + } + + /* Output labels: align left (near separator), away from handle edge */ + .port-labels-output .port-label { + left: 6px; + text-align: left; + } + + /* Rotation 2: swap alignment */ + .node[data-rotation="2"] .port-labels-input .port-label { + right: auto; + left: 6px; + text-align: left; + } + .node[data-rotation="2"] .port-labels-output .port-label { + left: auto; + right: 6px; + text-align: right; } - /* Vertical rotation - horizontal row of labels */ + /* Vertical rotation - row of labels with 90deg rotation */ .port-labels-row { - display: flex; - align-items: center; - justify-content: center; - gap: 20px; - height: 100%; + position: relative; } + /* Reset horizontal-specific styles for vertical labels */ .port-labels-row .port-label { - position: relative; - transform: none; - top: auto; - left: auto; + position: absolute; + width: auto; + max-width: none; right: auto; - text-align: center; + /* Use center origin for simpler positioning */ + transform-origin: center center; + /* text-align: left = text starts at original left edge = visual bottom after -90deg rotation */ + text-align: left; + } + + /* Input labels at top row: center vertically, shift toward bottom separator */ + .node.show-labels.vertical .port-labels-input .port-label { + top: 50%; + bottom: auto; + transform: translateX(-50%) translateY(calc(-50% + 6px)) rotate(-90deg); + } + + /* Output labels at bottom row: center vertically, shift toward top separator */ + .node.show-labels.vertical .port-labels-output .port-label { + top: 50%; + bottom: auto; + transform: translateX(-50%) translateY(calc(-50% - 6px)) rotate(-90deg); + } + + /* Rotation 3: swap the vertical shifts */ + .node.show-labels.vertical[data-rotation="3"] .port-labels-input .port-label { + transform: translateX(-50%) translateY(calc(-50% - 6px)) rotate(-90deg); + } + .node.show-labels.vertical[data-rotation="3"] .port-labels-output .port-label { + transform: translateX(-50%) translateY(calc(-50% + 6px)) rotate(-90deg); } diff --git a/src/lib/constants/dimensions.ts b/src/lib/constants/dimensions.ts index dd6d4a2d..3a5bf0e8 100644 --- a/src/lib/constants/dimensions.ts +++ b/src/lib/constants/dimensions.ts @@ -46,8 +46,8 @@ export const EXPORT_PADDING = G.x4; export const PORT_LABEL = { /** Width of label column for horizontal ports: 4 grid units = 40px */ columnWidth: G.x4, - /** Height of label row for vertical ports: 2 grid units = 20px */ - rowHeight: G.x2 + /** Height of label row for vertical ports: 4 grid units = 40px (same as column width) */ + rowHeight: G.x4 } as const; /** @@ -84,7 +84,8 @@ export function getPortPositionCalc(index: number, total: number): string { * Calculate node dimensions from node data. * Used by both SvelteFlow (for bounds) and BaseNode (for CSS). * - * @param showPortLabels - If true, adds space for port label columns/rows + * @param showInputLabels - If true, adds space for input port label column/row + * @param showOutputLabels - If true, adds space for output port label column/row */ export function calculateNodeDimensions( name: string, @@ -93,7 +94,8 @@ export function calculateNodeDimensions( pinnedParamCount: number, rotation: number, typeName?: string, - showPortLabels?: boolean + showInputLabels?: boolean, + showOutputLabels?: boolean ): { width: number; height: number } { const isVertical = rotation === 1 || rotation === 3; const maxPortsOnSide = Math.max(inputCount, outputCount); @@ -122,17 +124,15 @@ export function calculateNodeDimensions( ? snapTo2G(contentHeight) : snapTo2G(Math.max(contentHeight, minPortDimension)); - // Add space for port labels if enabled - if (showPortLabels) { - if (isVertical) { - // Vertical ports: add rows for labels above/below content - if (inputCount > 0) height += PORT_LABEL.rowHeight; - if (outputCount > 0) height += PORT_LABEL.rowHeight; - } else { - // Horizontal ports: add columns for labels on left/right - if (inputCount > 0) width += PORT_LABEL.columnWidth; - if (outputCount > 0) width += PORT_LABEL.columnWidth; - } + // Add space for port labels if enabled (separately for inputs and outputs) + if (isVertical) { + // Vertical ports: add rows for labels above/below content + if (showInputLabels && inputCount > 0) height += PORT_LABEL.rowHeight; + if (showOutputLabels && outputCount > 0) height += PORT_LABEL.rowHeight; + } else { + // Horizontal ports: add columns for labels on left/right + if (showInputLabels && inputCount > 0) width += PORT_LABEL.columnWidth; + if (showOutputLabels && outputCount > 0) width += PORT_LABEL.columnWidth; } return { width, height }; From 5f831d974fcf75d228104244d74b17ea04d0556b Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 16:50:33 +0100 Subject: [PATCH 342/656] Fix port label menu icon to use existing type icon --- src/lib/components/contextMenuBuilders.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index 0faaab0d..b2be54d5 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -93,14 +93,14 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { DIVIDER, { label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', - icon: 'tag', + icon: 'type', action: () => historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) ) }, { label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', - icon: 'tag', + icon: 'type', action: () => historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) ) @@ -135,14 +135,14 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { DIVIDER, { label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', - icon: 'tag', + icon: 'type', action: () => historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) ) }, { label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', - icon: 'tag', + icon: 'type', action: () => historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) ) @@ -207,14 +207,14 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { DIVIDER, { label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', - icon: 'tag', + icon: 'type', action: () => historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) ) }, { label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', - icon: 'tag', + icon: 'type', action: () => historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) ) From 3e7cbefd0c3aecaaa12a12e3baaf700268fad470 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 16:51:19 +0100 Subject: [PATCH 343/656] Add tag icon for port label menu items --- src/lib/components/contextMenuBuilders.ts | 4 ++-- src/lib/components/icons/Icon.svelte | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index b2be54d5..6c665bf2 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -207,14 +207,14 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { DIVIDER, { label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', - icon: 'type', + icon: 'tag', action: () => historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) ) }, { label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', - icon: 'type', + icon: 'tag', action: () => historyStore.mutate(() => graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) ) diff --git a/src/lib/components/icons/Icon.svelte b/src/lib/components/icons/Icon.svelte index b8cbc042..336c3626 100644 --- a/src/lib/components/icons/Icon.svelte +++ b/src/lib/components/icons/Icon.svelte @@ -415,6 +415,11 @@ +{:else if name === 'tag'} + + + + {:else if name === 'font-size-increase'} A From 27a12192cf0264717f741e466cf835c1ad2ed0d8 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 16:53:48 +0100 Subject: [PATCH 344/656] Only show port label menu items when node has inputs/outputs --- src/lib/components/contextMenuBuilders.ts | 146 ++++++++++++++-------- 1 file changed, 93 insertions(+), 53 deletions(-) diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index 6c665bf2..dc1ed488 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -77,8 +77,10 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { const globalLabels = get(portLabelsStore); const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; + const hasInputs = node.inputs && node.inputs.length > 0; + const hasOutputs = node.outputs && node.outputs.length > 0; - return [ + const items: MenuItemType[] = [ { label: 'Properties', icon: 'settings', @@ -89,29 +91,41 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { label: 'Exit Subsystem', icon: 'exit', action: () => graphStore.drillUp() - }, - DIVIDER, - { - label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', - icon: 'type', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) - ) - }, - { - label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', - icon: 'type', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) - ) - }, + } + ]; + + if (hasInputs || hasOutputs) { + items.push(DIVIDER); + if (hasInputs) { + items.push({ + label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) + ) + }); + } + if (hasOutputs) { + items.push({ + label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) + ) + }); + } + } + + items.push( DIVIDER, { label: 'View Code', icon: 'braces', action: () => showBlockCode(nodeId) } - ]; + ); + + return items; } // Subsystem blocks get "Enter" option @@ -119,8 +133,10 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { const globalLabels = get(portLabelsStore); const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; + const hasInputs = node.inputs && node.inputs.length > 0; + const hasOutputs = node.outputs && node.outputs.length > 0; - return [ + const items: MenuItemType[] = [ { label: 'Properties', icon: 'settings', @@ -131,22 +147,32 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { icon: 'enter', shortcut: 'Dbl-click', action: () => graphStore.drillDown(nodeId) - }, - DIVIDER, - { - label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', - icon: 'type', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) - ) - }, - { - label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', - icon: 'type', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) - ) - }, + } + ]; + + if (hasInputs || hasOutputs) { + items.push(DIVIDER); + if (hasInputs) { + items.push({ + label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) + ) + }); + } + if (hasOutputs) { + items.push({ + label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) + ) + }); + } + } + + items.push( DIVIDER, { label: 'View Code', @@ -184,7 +210,9 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { shortcut: 'Del', action: () => historyStore.mutate(() => graphStore.removeNode(nodeId)) } - ]; + ); + + return items; } // Check if this is a recording node (Scope or Spectrum) @@ -195,6 +223,8 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { const globalLabels = get(portLabelsStore); const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; + const hasInputs = node.inputs && node.inputs.length > 0; + const hasOutputs = node.outputs && node.outputs.length > 0; // Regular blocks const items: MenuItemType[] = [ @@ -203,29 +233,39 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { icon: 'settings', shortcut: 'Dbl-click', action: () => openNodeDialog(nodeId) - }, - DIVIDER, - { - label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', - icon: 'tag', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) - ) - }, - { - label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', - icon: 'tag', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) - ) - }, + } + ]; + + if (hasInputs || hasOutputs) { + items.push(DIVIDER); + if (hasInputs) { + items.push({ + label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) + ) + }); + } + if (hasOutputs) { + items.push({ + label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) + ) + }); + } + } + + items.push( DIVIDER, { label: 'View Code', icon: 'braces', action: () => showBlockCode(nodeId) } - ]; + ); // Add CSV export for recording nodes if (isRecordingNode) { From b149f11839c6e9a320389c70199da4eb1fa58128 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 17:04:54 +0100 Subject: [PATCH 345/656] Fix port label grid layout for source/sink nodes --- src/lib/components/nodes/BaseNode.svelte | 207 ++++++++++++----------- 1 file changed, 111 insertions(+), 96 deletions(-) diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index 87e99586..d0ef0ac4 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -228,23 +228,27 @@ const typeWidth = typeDef ? typeDef.name.length * 5 + 20 : 0; const pinnedParamsWidth = pinnedCount > 0 ? 160 : 0; - // Minimum width for layout (without name string-length estimate) - let minLayoutWidth = snapTo2G(Math.max( + // Minimum content width for layout (without name string-length estimate) + const minContentWidth = snapTo2G(Math.max( NODE.baseWidth, typeWidth, pinnedParamsWidth, isVertical ? minPortDimension : 0 )); - // Add port label columns if enabled (horizontal ports only) + // Add horizontal padding from .node-content (12px each side = 24px) + const measuredMathWidth = snapTo2G(measuredNameWidth + 24); + + // Content width is max of minimum and measured + let totalWidth = Math.max(minContentWidth, measuredMathWidth); + + // Add port label columns on top of content width (horizontal ports only) if (!isVertical) { - if (showInputLabels && data.inputs.length > 0) minLayoutWidth += PORT_LABEL.columnWidth; - if (showOutputLabels && data.outputs.length > 0) minLayoutWidth += PORT_LABEL.columnWidth; + if (showInputLabels && data.inputs.length > 0) totalWidth += PORT_LABEL.columnWidth; + if (showOutputLabels && data.outputs.length > 0) totalWidth += PORT_LABEL.columnWidth; } - // Add horizontal padding from .node-content (12px each side = 24px) - const measuredMathWidth = snapTo2G(measuredNameWidth + 24); - return Math.max(minLayoutWidth, measuredMathWidth); + return totalWidth; } return nodeDimensions.width; }); @@ -264,17 +268,19 @@ const pinnedParamsHeight = pinnedCount > 0 ? 7 + 24 * pinnedCount : 0; // Content height: math height + type label (12px) + padding (12px) - let contentHeight = measuredNameHeight + 24 + pinnedParamsHeight; + const contentHeight = measuredNameHeight + 24 + pinnedParamsHeight; - // Add port label rows if enabled (vertical ports only) + let totalHeight = isVertical + ? snapTo2G(contentHeight) + : snapTo2G(Math.max(contentHeight, minPortDimension)); + + // Add port label rows on top of content height (vertical ports only) if (isVertical) { - if (showInputLabels && data.inputs.length > 0) contentHeight += PORT_LABEL.rowHeight; - if (showOutputLabels && data.outputs.length > 0) contentHeight += PORT_LABEL.rowHeight; + if (showInputLabels && data.inputs.length > 0) totalHeight += PORT_LABEL.rowHeight; + if (showOutputLabels && data.outputs.length > 0) totalHeight += PORT_LABEL.rowHeight; } - return isVertical - ? snapTo2G(contentHeight) - : snapTo2G(Math.max(contentHeight, minPortDimension)); + return totalHeight; } } return nodeDimensions.height; @@ -981,46 +987,47 @@ } } - /* Port labels - 3-column grid layout when labels are shown (horizontal) */ + /* Port labels - grid layout when labels are shown (horizontal) */ + /* Base: enable grid on .node-clip */ .node.show-labels:not(.vertical) .node-clip { display: grid; - grid-template-columns: var(--left-col, 0px) 1fr var(--right-col, 0px); grid-template-rows: 1fr; } - /* Rotation 0: inputs left, outputs right */ - .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs { - --left-col: 40px; - } - .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs { - --right-col: 40px; + + /* Rotation 0/2: Both inputs and outputs - 3 columns */ + .node.show-labels:not(.vertical).has-inputs.has-outputs .node-clip { + grid-template-columns: 40px 1fr 40px; } - /* Rotation 2: inputs right, outputs left */ - .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs { - --left-col: 40px; + /* Rotation 0: inputs only - 2 columns (labels left, content right) */ + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs:not(.has-outputs) .node-clip, + .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs:not(.has-inputs) .node-clip { + grid-template-columns: 40px 1fr; } - .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs { - --right-col: 40px; + /* Rotation 0: outputs only - 2 columns (content left, labels right) */ + .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs:not(.has-inputs) .node-clip, + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs:not(.has-outputs) .node-clip { + grid-template-columns: 1fr 40px; } - /* Port labels - 3-row grid layout when labels are shown (vertical) */ + /* Port labels - grid layout when labels are shown (vertical) */ .node.show-labels.vertical .node-clip { display: grid; grid-template-columns: 1fr; - grid-template-rows: var(--top-row, 0px) 1fr var(--bottom-row, 0px); - } - /* Rotation 1: inputs top, outputs bottom */ - .node.show-labels.vertical[data-rotation="1"].has-inputs { - --top-row: 40px; } - .node.show-labels.vertical[data-rotation="1"].has-outputs { - --bottom-row: 40px; + + /* Rotation 1/3: Both inputs and outputs - 3 rows */ + .node.show-labels.vertical.has-inputs.has-outputs .node-clip { + grid-template-rows: 40px 1fr 40px; } - /* Rotation 3: inputs bottom, outputs top */ - .node.show-labels.vertical[data-rotation="3"].has-outputs { - --top-row: 40px; + /* Rotation 1: inputs only - 2 rows (labels top, content bottom) */ + .node.show-labels.vertical[data-rotation="1"].has-inputs:not(.has-outputs) .node-clip, + .node.show-labels.vertical[data-rotation="3"].has-outputs:not(.has-inputs) .node-clip { + grid-template-rows: 40px 1fr; } - .node.show-labels.vertical[data-rotation="3"].has-inputs { - --bottom-row: 40px; + /* Rotation 1: outputs only - 2 rows (content top, labels bottom) */ + .node.show-labels.vertical[data-rotation="1"].has-outputs:not(.has-inputs) .node-clip, + .node.show-labels.vertical[data-rotation="3"].has-inputs:not(.has-outputs) .node-clip { + grid-template-rows: 1fr 40px; } /* Label containers */ @@ -1031,61 +1038,69 @@ overflow: visible; } - /* Horizontal layout: explicit grid column placement */ - .node.show-labels:not(.vertical) .node-clip > .port-labels-input { - grid-column: 1; - grid-row: 1; - border-right: 1px solid var(--border); - } - .node.show-labels:not(.vertical) .node-clip > .node-inner { - grid-column: 2; - grid-row: 1; - } - .node.show-labels:not(.vertical) .node-clip > .port-labels-output { - grid-column: 3; - grid-row: 1; - border-left: 1px solid var(--border); - } - - /* Rotation 2: swap input/output column positions */ - .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-input { - grid-column: 3; - border-right: none; - border-left: 1px solid var(--border); - } - .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-output { - grid-column: 1; - border-left: none; - border-right: 1px solid var(--border); - } - - /* Vertical layout: explicit grid row placement */ - .node.show-labels.vertical .node-clip > .port-labels-input { - grid-column: 1; - grid-row: 1; - border-bottom: 1px solid var(--border); - } - .node.show-labels.vertical .node-clip > .node-inner { - grid-column: 1; - grid-row: 2; - } - .node.show-labels.vertical .node-clip > .port-labels-output { - grid-column: 1; - grid-row: 3; - border-top: 1px solid var(--border); - } - - /* Rotation 3: swap input/output row positions */ - .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-input { - grid-row: 3; - border-bottom: none; - border-top: 1px solid var(--border); - } - .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-output { - grid-row: 1; - border-top: none; - border-bottom: 1px solid var(--border); - } + /* Horizontal layout: grid column placement - rotation 0 (inputs left, outputs right) */ + /* Both labels: input=1, content=2, output=3 */ + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs.has-outputs .node-clip > .node-inner { grid-column: 2; } + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-column: 3; } + /* Input labels only: input=1, content=2 */ + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-column: 2; } + /* Output labels only: content=1, output=2 */ + .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-column: 2; } + + /* Horizontal layout: grid column placement - rotation 2 (inputs right, outputs left) */ + /* Both labels: output=1, content=2, input=3 */ + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs.has-outputs .node-clip > .node-inner { grid-column: 2; } + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-column: 3; } + /* Output labels only (left side): output=1, content=2 */ + .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-column: 2; } + /* Input labels only (right side): content=1, input=2 */ + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-column: 1; } + .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-column: 2; } + + /* Horizontal borders */ + .node.show-labels:not(.vertical) .node-clip > .port-labels-input { grid-row: 1; border-right: 1px solid var(--border); } + .node.show-labels:not(.vertical) .node-clip > .node-inner { grid-row: 1; } + .node.show-labels:not(.vertical) .node-clip > .port-labels-output { grid-row: 1; border-left: 1px solid var(--border); } + /* Rotation 2: swap borders */ + .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-input { border-right: none; border-left: 1px solid var(--border); } + .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-output { border-left: none; border-right: 1px solid var(--border); } + + /* Vertical layout: grid row placement - rotation 1 (inputs top, outputs bottom) */ + /* Both labels: input=1, content=2, output=3 */ + .node.show-labels.vertical[data-rotation="1"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-row: 1; } + .node.show-labels.vertical[data-rotation="1"].has-inputs.has-outputs .node-clip > .node-inner { grid-row: 2; } + .node.show-labels.vertical[data-rotation="1"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-row: 3; } + /* Input labels only: input=1, content=2 */ + .node.show-labels.vertical[data-rotation="1"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-row: 1; } + .node.show-labels.vertical[data-rotation="1"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-row: 2; } + /* Output labels only: content=1, output=2 */ + .node.show-labels.vertical[data-rotation="1"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-row: 1; } + .node.show-labels.vertical[data-rotation="1"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-row: 2; } + + /* Vertical layout: grid row placement - rotation 3 (inputs bottom, outputs top) */ + /* Both labels: output=1, content=2, input=3 */ + .node.show-labels.vertical[data-rotation="3"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-row: 1; } + .node.show-labels.vertical[data-rotation="3"].has-inputs.has-outputs .node-clip > .node-inner { grid-row: 2; } + .node.show-labels.vertical[data-rotation="3"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-row: 3; } + /* Output labels only (top): output=1, content=2 */ + .node.show-labels.vertical[data-rotation="3"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-row: 1; } + .node.show-labels.vertical[data-rotation="3"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-row: 2; } + /* Input labels only (bottom): content=1, input=2 */ + .node.show-labels.vertical[data-rotation="3"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-row: 1; } + .node.show-labels.vertical[data-rotation="3"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-row: 2; } + + /* Vertical borders */ + .node.show-labels.vertical .node-clip > .port-labels-input { grid-column: 1; border-bottom: 1px solid var(--border); } + .node.show-labels.vertical .node-clip > .node-inner { grid-column: 1; } + .node.show-labels.vertical .node-clip > .port-labels-output { grid-column: 1; border-top: 1px solid var(--border); } + /* Rotation 3: swap borders */ + .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-input { border-bottom: none; border-top: 1px solid var(--border); } + .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-output { border-top: none; border-bottom: 1px solid var(--border); } /* Individual port labels (absolute positioning for horizontal) */ .port-label { From 58403272610f7c2612a93b10ed41ff4c33b49724 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 17:14:18 +0100 Subject: [PATCH 346/656] Fix show-labels class to only apply when labels actually displayed --- src/lib/components/nodes/BaseNode.svelte | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index d0ef0ac4..e25cc016 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -73,8 +73,11 @@ const showInputLabels = $derived(nodeShowInputLabels ?? globalShowPortLabels); const showOutputLabels = $derived(nodeShowOutputLabels ?? globalShowPortLabels); - // For CSS class (show-labels when either is visible) - const showPortLabels = $derived(showInputLabels || showOutputLabels); + // For CSS class (show-labels only when labels are actually displayed) + const showPortLabels = $derived( + (showInputLabels && data.inputs.length > 0) || + (showOutputLabels && data.outputs.length > 0) + ); // Re-measure node when port labels toggle changes $effect(() => { From 76a6fdf75c493465e4fdf96d872f78e2a91ef209 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 17:20:22 +0100 Subject: [PATCH 347/656] Add min-width:0 to node-inner and grid fallback for robustness --- src/lib/components/nodes/BaseNode.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index e25cc016..86ad1a98 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -673,6 +673,7 @@ flex: 1; display: flex; flex-direction: column; + min-width: 0; min-height: 0; } @@ -991,9 +992,10 @@ } /* Port labels - grid layout when labels are shown (horizontal) */ - /* Base: enable grid on .node-clip */ + /* Base: enable grid on .node-clip with fallback column template */ .node.show-labels:not(.vertical) .node-clip { display: grid; + grid-template-columns: 1fr; grid-template-rows: 1fr; } From a992e9ee161960cb5a748e1838a6dd78c352655a Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 18:51:47 +0100 Subject: [PATCH 348/656] Consolidate dimension calculation and port label utilities - Move nodeWidth/nodeHeight logic from BaseNode to dimensions.ts - Add getPortOffset() for numeric port position calculation - Create shared portLabels.ts with visibility and truncation helpers - Simplify BaseNode with gridTemplate() computed property --- src/lib/components/nodes/BaseNode.svelte | 197 ++++++++--------------- src/lib/constants/dimensions.ts | 86 ++++++---- src/lib/utils/portLabels.ts | 42 +++++ 3 files changed, 167 insertions(+), 158 deletions(-) create mode 100644 src/lib/utils/portLabels.ts diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index 86ad1a98..e14dac84 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -13,8 +13,9 @@ import { showTooltip, hideTooltip } from '$lib/components/Tooltip.svelte'; import { paramInput } from '$lib/actions/paramInput'; import { plotDataStore } from '$lib/plotting/processing/plotDataStore'; - import { NODE, PORT_LABEL, getPortPositionCalc, calculateNodeDimensions, snapTo2G } from '$lib/constants/dimensions'; - import { containsMath, renderInlineMath, renderInlineMathSync, measureRenderedMath, getBaselineTextHeight } from '$lib/utils/inlineMathRenderer'; + import { PORT_LABEL, getPortPositionCalc, calculateNodeDimensions } from '$lib/constants/dimensions'; + import { truncatePortLabel } from '$lib/utils/portLabels'; + import { containsMath, renderInlineMath, renderInlineMathSync, measureRenderedMath } from '$lib/utils/inlineMathRenderer'; import { getKatexCssUrl } from '$lib/utils/katexLoader'; import PlotPreview from './PlotPreview.svelte'; @@ -69,15 +70,16 @@ const nodeShowInputLabels = $derived(data.params?.['_showInputLabels'] as boolean | undefined); const nodeShowOutputLabels = $derived(data.params?.['_showOutputLabels'] as boolean | undefined); - // Effective visibility (per-node overrides global) + // Effective visibility settings (per-node overrides global) const showInputLabels = $derived(nodeShowInputLabels ?? globalShowPortLabels); const showOutputLabels = $derived(nodeShowOutputLabels ?? globalShowPortLabels); - // For CSS class (show-labels only when labels are actually displayed) - const showPortLabels = $derived( - (showInputLabels && data.inputs.length > 0) || - (showOutputLabels && data.outputs.length > 0) - ); + // Actual visibility: setting is ON and ports exist (single source of truth) + const hasVisibleInputLabels = $derived(showInputLabels && data.inputs.length > 0); + const hasVisibleOutputLabels = $derived(showOutputLabels && data.outputs.length > 0); + + // For CSS class (show-labels when any labels are actually displayed) + const showPortLabels = $derived(hasVisibleInputLabels || hasVisibleOutputLabels); // Re-measure node when port labels toggle changes $effect(() => { @@ -209,6 +211,13 @@ const maxPortsOnSide = $derived(Math.max(data.inputs.length, data.outputs.length)); const pinnedCount = $derived(validPinnedParams().length); + // Measured name dimensions for math rendering (null if not measured or no math) + const measuredName = $derived( + nameHasMath && measuredNameWidth !== null && measuredNameHeight !== null + ? { width: measuredNameWidth, height: measuredNameHeight } + : null + ); + // Node dimensions - calculated from shared utility (same as SvelteFlow bounds) const nodeDimensions = $derived(calculateNodeDimensions( data.name, @@ -217,76 +226,43 @@ pinnedCount, rotation, typeDef?.name, - showInputLabels, - showOutputLabels + hasVisibleInputLabels, + hasVisibleOutputLabels, + measuredName )); - // Use measured width if math is rendered and measured, otherwise use calculated - const nodeWidth = $derived(() => { - if (measuredNameWidth !== null && nameHasMath) { - // For math names, use measured width instead of string-length estimate - // But still respect minimum width needed for ports, pinned params, type label - const isVertical = rotation === 1 || rotation === 3; - const maxPortsOnSide = Math.max(data.inputs.length, data.outputs.length); - const minPortDimension = Math.max(1, maxPortsOnSide) * NODE.portSpacing; - const typeWidth = typeDef ? typeDef.name.length * 5 + 20 : 0; - const pinnedParamsWidth = pinnedCount > 0 ? 160 : 0; - - // Minimum content width for layout (without name string-length estimate) - const minContentWidth = snapTo2G(Math.max( - NODE.baseWidth, - typeWidth, - pinnedParamsWidth, - isVertical ? minPortDimension : 0 - )); - - // Add horizontal padding from .node-content (12px each side = 24px) - const measuredMathWidth = snapTo2G(measuredNameWidth + 24); - - // Content width is max of minimum and measured - let totalWidth = Math.max(minContentWidth, measuredMathWidth); - - // Add port label columns on top of content width (horizontal ports only) - if (!isVertical) { - if (showInputLabels && data.inputs.length > 0) totalWidth += PORT_LABEL.columnWidth; - if (showOutputLabels && data.outputs.length > 0) totalWidth += PORT_LABEL.columnWidth; - } - - return totalWidth; - } - return nodeDimensions.width; - }); - // Height calculation - only override for tall math (like \displaystyle) - // Compare measured math height to baseline text height for robustness - const nodeHeight = $derived(() => { - if (measuredNameHeight !== null && nameHasMath) { - // Get baseline height of standard text - only grow if math is significantly taller - const baselineHeight = getBaselineTextHeight(); - if (measuredNameHeight > baselineHeight * 1.2) { - const isVertical = rotation === 1 || rotation === 3; - const maxPortsOnSide = Math.max(data.inputs.length, data.outputs.length); - const minPortDimension = Math.max(1, maxPortsOnSide) * NODE.portSpacing; - - // Pinned params height: border(1) + padding(10) + rows(24 each) - const pinnedParamsHeight = pinnedCount > 0 ? 7 + 24 * pinnedCount : 0; - - // Content height: math height + type label (12px) + padding (12px) - const contentHeight = measuredNameHeight + 24 + pinnedParamsHeight; - - let totalHeight = isVertical - ? snapTo2G(contentHeight) - : snapTo2G(Math.max(contentHeight, minPortDimension)); - - // Add port label rows on top of content height (vertical ports only) - if (isVertical) { - if (showInputLabels && data.inputs.length > 0) totalHeight += PORT_LABEL.rowHeight; - if (showOutputLabels && data.outputs.length > 0) totalHeight += PORT_LABEL.rowHeight; - } - - return totalHeight; + // Grid template for port labels layout (computed in JS instead of CSS selectors) + const gridTemplate = $derived(() => { + if (!showPortLabels) return { columns: undefined, rows: undefined }; + + const labelSize = `${PORT_LABEL.columnWidth}px`; + + if (isVertical) { + // Vertical layout: rows for input/output labels + if (hasVisibleInputLabels && hasVisibleOutputLabels) { + // Both: [input-labels] [content] [output-labels] + return { columns: undefined, rows: `${labelSize} 1fr ${labelSize}` }; + } else if (hasVisibleInputLabels) { + // Input only: rotation 1 = top, rotation 3 = bottom + return { columns: undefined, rows: rotation === 1 ? `${labelSize} 1fr` : `1fr ${labelSize}` }; + } else if (hasVisibleOutputLabels) { + // Output only: rotation 1 = bottom, rotation 3 = top + return { columns: undefined, rows: rotation === 1 ? `1fr ${labelSize}` : `${labelSize} 1fr` }; + } + } else { + // Horizontal layout: columns for input/output labels + if (hasVisibleInputLabels && hasVisibleOutputLabels) { + // Both: [input-labels] [content] [output-labels] + return { columns: `${labelSize} 1fr ${labelSize}`, rows: undefined }; + } else if (hasVisibleInputLabels) { + // Input only: rotation 0 = left, rotation 2 = right + return { columns: rotation === 0 ? `${labelSize} 1fr` : `1fr ${labelSize}`, rows: undefined }; + } else if (hasVisibleOutputLabels) { + // Output only: rotation 0 = right, rotation 2 = left + return { columns: rotation === 0 ? `1fr ${labelSize}` : `${labelSize} 1fr`, rows: undefined }; } } - return nodeDimensions.height; + return { columns: undefined, rows: undefined }; }); // Check if this is a Subsystem or Interface node (using shapes utility) @@ -355,11 +331,6 @@ return String(value); } - // Truncate port label for display - function truncateLabel(name: string, maxChars: number = 5): string { - return name.length > maxChars ? name.slice(0, maxChars) : name; - } - // Format default value for placeholder (Python style) function formatDefault(value: unknown): string { if (value === null || value === undefined) return 'None'; @@ -435,10 +406,10 @@ class:preview-hovered={showPreview} class:subsystem-type={isSubsystemType} class:show-labels={showPortLabels} - class:has-inputs={showInputLabels && data.inputs.length > 0} - class:has-outputs={showOutputLabels && data.outputs.length > 0} + class:has-inputs={hasVisibleInputLabels} + class:has-outputs={hasVisibleOutputLabels} data-rotation={rotation} - style="width: {nodeWidth()}px; height: {nodeHeight()}px; --node-color: {nodeColor};" + style="width: {nodeDimensions.width}px; height: {nodeDimensions.height}px; --node-color: {nodeColor};" ondblclick={handleDoubleClick} onmouseenter={handleMouseEnter} onmouseleave={handleMouseLeave} @@ -459,14 +430,18 @@ {/if} -
        +
        - {#if showInputLabels && data.inputs.length > 0} + {#if hasVisibleInputLabels} {#if isVertical}
        {#each data.inputs as port, i} - {truncateLabel(port.name)} + {truncatePortLabel(port.name)} {/each}
        @@ -474,7 +449,7 @@
        {#each data.inputs as port, i} - {truncateLabel(port.name)} + {truncatePortLabel(port.name)} {/each}
        @@ -522,12 +497,12 @@
        - {#if showOutputLabels && data.outputs.length > 0} + {#if hasVisibleOutputLabels} {#if isVertical}
        {#each data.outputs as port, i} - {truncateLabel(port.name)} + {truncatePortLabel(port.name)} {/each}
        @@ -535,7 +510,7 @@
        {#each data.outputs as port, i} - {truncateLabel(port.name)} + {truncatePortLabel(port.name)} {/each}
        @@ -991,48 +966,10 @@ } } - /* Port labels - grid layout when labels are shown (horizontal) */ - /* Base: enable grid on .node-clip with fallback column template */ - .node.show-labels:not(.vertical) .node-clip { + /* Port labels - grid layout when labels are shown */ + /* Grid template columns/rows are set via inline style from JS */ + .node.show-labels .node-clip { display: grid; - grid-template-columns: 1fr; - grid-template-rows: 1fr; - } - - /* Rotation 0/2: Both inputs and outputs - 3 columns */ - .node.show-labels:not(.vertical).has-inputs.has-outputs .node-clip { - grid-template-columns: 40px 1fr 40px; - } - /* Rotation 0: inputs only - 2 columns (labels left, content right) */ - .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs:not(.has-outputs) .node-clip, - .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs:not(.has-inputs) .node-clip { - grid-template-columns: 40px 1fr; - } - /* Rotation 0: outputs only - 2 columns (content left, labels right) */ - .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs:not(.has-inputs) .node-clip, - .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs:not(.has-outputs) .node-clip { - grid-template-columns: 1fr 40px; - } - - /* Port labels - grid layout when labels are shown (vertical) */ - .node.show-labels.vertical .node-clip { - display: grid; - grid-template-columns: 1fr; - } - - /* Rotation 1/3: Both inputs and outputs - 3 rows */ - .node.show-labels.vertical.has-inputs.has-outputs .node-clip { - grid-template-rows: 40px 1fr 40px; - } - /* Rotation 1: inputs only - 2 rows (labels top, content bottom) */ - .node.show-labels.vertical[data-rotation="1"].has-inputs:not(.has-outputs) .node-clip, - .node.show-labels.vertical[data-rotation="3"].has-outputs:not(.has-inputs) .node-clip { - grid-template-rows: 40px 1fr; - } - /* Rotation 1: outputs only - 2 rows (content top, labels bottom) */ - .node.show-labels.vertical[data-rotation="1"].has-outputs:not(.has-inputs) .node-clip, - .node.show-labels.vertical[data-rotation="3"].has-inputs:not(.has-outputs) .node-clip { - grid-template-rows: 1fr 40px; } /* Label containers */ diff --git a/src/lib/constants/dimensions.ts b/src/lib/constants/dimensions.ts index 3a5bf0e8..83500862 100644 --- a/src/lib/constants/dimensions.ts +++ b/src/lib/constants/dimensions.ts @@ -58,6 +58,23 @@ export function snapTo2G(value: number): number { return Math.ceil(value / G.x2) * G.x2; } +/** + * Calculate port offset from center in pixels. + * Used for SVG rendering and numeric calculations. + * + * @param index - Port index (0-based) + * @param total - Total number of ports on this edge + * @returns Offset in pixels from center (negative = before center, positive = after) + */ +export function getPortOffset(index: number, total: number): number { + if (total <= 0 || total === 1) { + return 0; // Single port at center + } + // For N ports with spacing S: span = (N-1)*S, offset from center = -span/2 + i*S + const span = (total - 1) * NODE.portSpacing; + return -span / 2 + index * NODE.portSpacing; +} + /** * Calculate port position as CSS calc() expression. * Uses offset from center to ensure grid alignment regardless of node size, @@ -68,24 +85,23 @@ export function snapTo2G(value: number): number { * @returns CSS position value (e.g., "50%" or "calc(50% + 10px)") */ export function getPortPositionCalc(index: number, total: number): string { - if (total <= 0 || total === 1) { - return '50%'; // Single port at center - } - // For N ports with spacing S: span = (N-1)*S, offset from center = -span/2 + i*S - const span = (total - 1) * NODE.portSpacing; - const offsetFromCenter = -span / 2 + index * NODE.portSpacing; - if (offsetFromCenter === 0) { + const offset = getPortOffset(index, total); + if (offset === 0) { return '50%'; } - return `calc(50% + ${offsetFromCenter}px)`; + return `calc(50% + ${offset}px)`; } +/** Baseline text height for comparing math rendering (approximate) */ +const BASELINE_TEXT_HEIGHT = 14; + /** * Calculate node dimensions from node data. * Used by both SvelteFlow (for bounds) and BaseNode (for CSS). * - * @param showInputLabels - If true, adds space for input port label column/row - * @param showOutputLabels - If true, adds space for output port label column/row + * @param hasVisibleInputLabels - True if input labels are visible (setting ON and inputs exist) + * @param hasVisibleOutputLabels - True if output labels are visible (setting ON and outputs exist) + * @param measuredName - Optional measured dimensions for math-rendered names */ export function calculateNodeDimensions( name: string, @@ -94,23 +110,29 @@ export function calculateNodeDimensions( pinnedParamCount: number, rotation: number, typeName?: string, - showInputLabels?: boolean, - showOutputLabels?: boolean + hasVisibleInputLabels?: boolean, + hasVisibleOutputLabels?: boolean, + measuredName?: { width: number; height: number } | null ): { width: number; height: number } { const isVertical = rotation === 1 || rotation === 3; const maxPortsOnSide = Math.max(inputCount, outputCount); const minPortDimension = Math.max(1, maxPortsOnSide) * NODE.portSpacing; - // Pinned params height: border(1) + padding(10) + rows(20 each) + gaps(4 between) + // Pinned params dimensions const pinnedParamsHeight = pinnedParamCount > 0 ? 7 + 24 * pinnedParamCount : 0; + const pinnedParamsWidth = pinnedParamCount > 0 ? 160 : 0; - // Width: base, name estimate, type name estimate, pinned params minimum, port dimension (if vertical) - // Name uses 10px font (~6px per char), type uses 8px font (~5px per char), plus padding for node margins - // Use slightly larger estimates to ensure text fits (ceil behavior) - const nameWidth = name.length * 6 + 20; + // Type label width estimate (8px font, ~5px per char) const typeWidth = typeName ? typeName.length * 5 + 20 : 0; - const pinnedParamsWidth = pinnedParamCount > 0 ? 160 : 0; - let width = snapTo2G(Math.max( + + // Name width: use measured if available, otherwise estimate (10px font, ~6px per char) + // Add 24px for horizontal padding in .node-content (12px each side) + const nameWidth = measuredName + ? snapTo2G(measuredName.width + 24) + : name.length * 6 + 20; + + // Content width (without port labels) + let contentWidth = snapTo2G(Math.max( NODE.baseWidth, nameWidth, typeWidth, @@ -118,21 +140,29 @@ export function calculateNodeDimensions( isVertical ? minPortDimension : 0 )); - // Height: content height vs port dimension (they share vertical space) - const contentHeight = NODE.baseHeight + pinnedParamsHeight; + // Content height: check if math is significantly taller than baseline text + let contentHeight: number; + if (measuredName && measuredName.height > BASELINE_TEXT_HEIGHT * 1.2) { + // Math is tall (e.g., \displaystyle fractions) - use measured height + type label + padding + contentHeight = measuredName.height + 24 + pinnedParamsHeight; + } else { + // Normal text height + contentHeight = NODE.baseHeight + pinnedParamsHeight; + } + + // Final dimensions accounting for port space + let width = contentWidth; let height = isVertical ? snapTo2G(contentHeight) : snapTo2G(Math.max(contentHeight, minPortDimension)); - // Add space for port labels if enabled (separately for inputs and outputs) + // Add space for port labels if visible if (isVertical) { - // Vertical ports: add rows for labels above/below content - if (showInputLabels && inputCount > 0) height += PORT_LABEL.rowHeight; - if (showOutputLabels && outputCount > 0) height += PORT_LABEL.rowHeight; + if (hasVisibleInputLabels) height += PORT_LABEL.rowHeight; + if (hasVisibleOutputLabels) height += PORT_LABEL.rowHeight; } else { - // Horizontal ports: add columns for labels on left/right - if (showInputLabels && inputCount > 0) width += PORT_LABEL.columnWidth; - if (showOutputLabels && outputCount > 0) width += PORT_LABEL.columnWidth; + if (hasVisibleInputLabels) width += PORT_LABEL.columnWidth; + if (hasVisibleOutputLabels) width += PORT_LABEL.columnWidth; } return { width, height }; diff --git a/src/lib/utils/portLabels.ts b/src/lib/utils/portLabels.ts new file mode 100644 index 00000000..67acca7c --- /dev/null +++ b/src/lib/utils/portLabels.ts @@ -0,0 +1,42 @@ +/** + * Port Labels Utility + * + * Shared logic for determining port label visibility. + * Used by both BaseNode.svelte (canvas) and SVG renderer (export). + */ + +import type { NodeInstance } from '$lib/types/nodes'; + +/** + * Get effective port label visibility for a node. + * Per-node settings override global setting. + * + * @param node - The node instance + * @param globalShowLabels - Global port labels setting (from portLabelsStore) + * @returns Object with hasVisibleInputLabels and hasVisibleOutputLabels + */ +export function getEffectivePortLabelVisibility( + node: NodeInstance, + globalShowLabels: boolean +): { inputs: boolean; outputs: boolean } { + // Per-node overrides (undefined = follow global) + const inputSetting = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalShowLabels; + const outputSetting = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalShowLabels; + + // Actual visibility: setting is ON and ports exist + return { + inputs: inputSetting && node.inputs.length > 0, + outputs: outputSetting && node.outputs.length > 0 + }; +} + +/** + * Truncate port label for display. + * + * @param name - Port name + * @param maxChars - Maximum characters (default: 5) + * @returns Truncated name + */ +export function truncatePortLabel(name: string, maxChars: number = 5): string { + return name.length > maxChars ? name.slice(0, maxChars) : name; +} From 28fa9736e114644071eeb67f7fd4f2a9d50215af Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 18:51:51 +0100 Subject: [PATCH 349/656] Fix theme colors to match app.css and add textDisabled --- src/lib/constants/theme.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/lib/constants/theme.ts b/src/lib/constants/theme.ts index fd58fa02..4165cda0 100644 --- a/src/lib/constants/theme.ts +++ b/src/lib/constants/theme.ts @@ -17,6 +17,8 @@ export interface ThemeColors { text: string; /** Muted text color */ textMuted: string; + /** Disabled text color (lighter than muted) */ + textDisabled: string; /** Accent color (default node color) */ accent: string; } @@ -27,18 +29,20 @@ export const THEMES: Record<'light' | 'dark', ThemeColors> = { surface: '#08080c', surfaceRaised: '#1c1c26', border: 'rgba(255, 255, 255, 0.08)', - edge: '#7F7F7F', + edge: '#808090', text: '#f0f0f5', textMuted: '#808090', + textDisabled: '#505060', accent: '#0070C0' }, light: { surface: '#f0f0f4', surfaceRaised: '#ffffff', border: 'rgba(0, 0, 0, 0.10)', - edge: '#7F7F7F', + edge: '#808090', // inherits from :root text: '#1a1a1f', - textMuted: '#606068', + textMuted: '#808090', // inherits from :root + textDisabled: '#909098', accent: '#0070C0' } } as const; From c224db76c1418dcf24e9f45eeccdb2a3cd7df346 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 18:51:54 +0100 Subject: [PATCH 350/656] Refactor SVG export to use dom-to-svg library --- package-lock.json | 42 ++- package.json | 1 + src/lib/export/svg/renderer.ts | 602 +++++++-------------------------- src/lib/export/svg/types.ts | 5 +- 4 files changed, 159 insertions(+), 491 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1186be83..a3f8eafd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@codemirror/theme-one-dark": "^6.0.0", "@xyflow/svelte": "^1.5.0", "codemirror": "^6.0.0", + "dom-to-svg": "^0.12.2", "katex": "^0.16.0", "pathfinding": "^0.4.18", "plotly.js-dist-min": "^2.35.0", @@ -1220,7 +1221,6 @@ "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1260,7 +1260,6 @@ "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -1369,7 +1368,6 @@ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1423,7 +1421,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1721,7 +1718,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -1811,6 +1807,17 @@ "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==", "license": "MIT" }, + "node_modules/dom-to-svg": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/dom-to-svg/-/dom-to-svg-0.12.2.tgz", + "integrity": "sha512-zVlswIYMj3669dUfErBszLcYOy+NzPEFhMezdzLVaqFcaj7VQecS0o8r9bqzBSczDtex2X/4HMZktFoj4EDqOA==", + "license": "MIT", + "dependencies": { + "gradient-parser": "^1.0.2", + "postcss": "^8.2.9", + "postcss-value-parser": "^4.1.0" + } + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -1872,7 +1879,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2218,6 +2224,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gradient-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/gradient-parser/-/gradient-parser-1.1.1.tgz", + "integrity": "sha512-Hu0YfNU+38EsTmnUfLXUKFMXq9yz7htGYpF4x+dlbBhUCvIvzLt0yVLT/gJRmvLKFJdqNFrz4eKkIUjIXSr7Tw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2492,7 +2506,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -2609,7 +2622,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -2618,7 +2630,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2636,7 +2647,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -2652,7 +2662,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2760,6 +2769,12 @@ "node": ">=4" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2776,7 +2791,6 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -2961,7 +2975,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3004,7 +3017,6 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.0.tgz", "integrity": "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3172,7 +3184,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3211,7 +3222,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index 85e6d96b..942daeed 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@codemirror/theme-one-dark": "^6.0.0", "@xyflow/svelte": "^1.5.0", "codemirror": "^6.0.0", + "dom-to-svg": "^0.12.2", "katex": "^0.16.0", "pathfinding": "^0.4.18", "plotly.js-dist-min": "^2.35.0", diff --git a/src/lib/export/svg/renderer.ts b/src/lib/export/svg/renderer.ts index a7e2d851..8b8d4628 100644 --- a/src/lib/export/svg/renderer.ts +++ b/src/lib/export/svg/renderer.ts @@ -1,512 +1,166 @@ /** * SVG Renderer * - * Renders the current graph view as SVG using a hybrid approach: - * - Edges: cloned directly from SvelteFlow's SVG (already vector graphics) - * - Nodes/Events: pure SVG with dimensions and styles read from DOM - * - * This approach ensures pixel-perfect accuracy while producing clean SVG output. + * Renders the current graph view as SVG using dom-to-svg library. + * This captures the exact visual appearance of the canvas. */ -import { get } from 'svelte/store'; -import { graphStore } from '$lib/stores/graph'; -import { eventStore } from '$lib/stores/events'; +import { elementToSVG, inlineResources } from 'dom-to-svg'; import { getThemeColors } from '$lib/constants/theme'; -import { NODE, EVENT } from '$lib/constants/dimensions'; -import { getHandlePath } from '$lib/constants/handlePaths'; -import { latexToSvg, getSvgDimensions, preloadMathJax } from '$lib/utils/mathjaxSvg'; - -// Preload MathJax when module loads -if (typeof window !== 'undefined') { - preloadMathJax(); -} -import type { ExportOptions, RenderContext, Bounds } from './types'; +import { EXPORT_PADDING } from '$lib/constants/dimensions'; +import type { ExportOptions } from './types'; import { DEFAULT_OPTIONS } from './types'; -import type { NodeInstance } from '$lib/types/nodes'; -import type { EventInstance } from '$lib/types/events'; - -// ============================================================================ -// DOM UTILITIES -// ============================================================================ - -function getZoom(): number { - const viewport = document.querySelector('.svelte-flow__viewport') as HTMLElement; - if (!viewport) return 1; - const match = viewport.style.transform.match(/scale\(([^)]+)\)/); - return match ? parseFloat(match[1]) : 1; -} - -function escapeXml(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} /** - * Extract LaTeX from a string with $...$ delimiters + * Get the current viewport transform values */ -function extractLatex(text: string): { before: string; latex: string; after: string } | null { - const match = text.match(/^(.*?)\$([^$]+)\$(.*)$/); - if (!match) return null; - return { before: match[1], latex: match[2], after: match[3] }; -} +function getViewportTransform(): { x: number; y: number; scale: number } { + const viewport = document.querySelector('.svelte-flow__viewport') as HTMLElement; + if (!viewport) return { x: 0, y: 0, scale: 1 }; -/** - * Render a plain text label as SVG - */ -function renderPlainTextLabel( - text: string, - centerX: number, - centerY: number, - color: string, - fontSize: number, - fontWeight: string -): string { - return `${escapeXml(text)}`; + const transform = viewport.style.transform; + const translateMatch = transform.match(/translate\(([^,]+),\s*([^)]+)\)/); + const scaleMatch = transform.match(/scale\(([^)]+)\)/); + + return { + x: translateMatch ? parseFloat(translateMatch[1]) : 0, + y: translateMatch ? parseFloat(translateMatch[2]) : 0, + scale: scaleMatch ? parseFloat(scaleMatch[1]) : 1 + }; } /** - * Render a label that may contain math as native SVG using MathJax - * @param originalText - The original text with $...$ LaTeX delimiters (NOT the rendered DOM content) + * Calculate the bounding box of all content in graph coordinates */ -async function renderMathLabel( - originalText: string, - centerX: number, - centerY: number, - color: string, - fontSize: number, - fontWeight: string, - ctx: RenderContext -): Promise { - const mathParts = extractLatex(originalText); - - if (!mathParts) { - // Plain text - use regular SVG text - return renderPlainTextLabel(originalText, centerX, centerY, color, fontSize, fontWeight); - } - - try { - // Render the LaTeX to SVG using MathJax - // Wrap in \boldsymbol to match the bold font-weight (600) used on canvas - const boldLatex = `\\boldsymbol{${mathParts.latex}}`; - let svg = await latexToSvg(boldLatex, false); - const dims = getSvgDimensions(svg); - - // Apply color to the SVG - svg = svg.replace(/currentColor/g, color); - - // Add stroke to math paths to match system font weight (600) - // MathJax math fonts are lighter than system-ui bold - svg = svg.replace(/${svg}`; - } catch (e) { - console.error('MathJax SVG rendering error:', e); - // Fall back to plain text showing the raw LaTeX - return renderPlainTextLabel(originalText, centerX, centerY, color, fontSize, fontWeight); - } -} - -// ============================================================================ -// EDGE RENDERING - Clone from DOM -// ============================================================================ - -function renderEdges(ctx: RenderContext): string { - const container = document.querySelector('.svelte-flow__edges'); - if (!container) return ''; - - const parts: string[] = []; - - container.querySelectorAll('.svelte-flow__edge').forEach((edge) => { - const edgeParts: string[] = []; - - // Get all paths and groups within this edge - edge.querySelectorAll('path').forEach((pathEl) => { - const d = pathEl.getAttribute('d'); - if (!d) return; - - // Check if it's the main edge path or arrow - if (pathEl.classList.contains('svelte-flow__edge-path')) { - edgeParts.push( - `` - ); - } - }); - - // Find arrow groups (have transform with rotate) - edge.querySelectorAll('g').forEach((g) => { - const transform = g.getAttribute('transform'); - if (transform && transform.includes('rotate')) { - const arrowPath = g.querySelector('path'); - if (arrowPath) { - const d = arrowPath.getAttribute('d'); - if (d) { - edgeParts.push(``); - } - } - } - }); - - if (edgeParts.length > 0) { - parts.push(`${edgeParts.join('')}`); - } - }); - - return parts.length > 0 ? `\n${parts.join('\n')}\n` : ''; -} - -// ============================================================================ -// HANDLE RENDERING -// ============================================================================ - -function renderHandles(nodeId: string, nodeX: number, nodeY: number, ctx: RenderContext): string { - const wrapper = document.querySelector(`[data-id="${nodeId}"]`); - if (!wrapper) return ''; +function calculateContentBounds(): { minX: number; minY: number; maxX: number; maxY: number } { + const bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; + const viewport = document.querySelector('.svelte-flow__viewport') as HTMLElement; + if (!viewport) return { minX: 0, minY: 0, maxX: 200, maxY: 200 }; - const nodeEl = wrapper.querySelector('[data-rotation]') || wrapper; - const rotation = parseInt(nodeEl.getAttribute('data-rotation') || '0'); - const paths = getHandlePath(rotation); - const zoom = getZoom(); - const nodeRect = wrapper.getBoundingClientRect(); + const { scale } = getViewportTransform(); - const handles: string[] = []; + // Get all nodes and events + const elements = viewport.querySelectorAll('.svelte-flow__node, .svelte-flow__edge'); + elements.forEach((el) => { + const rect = (el as HTMLElement).getBoundingClientRect(); + const viewportRect = viewport.getBoundingClientRect(); - nodeEl.querySelectorAll('.svelte-flow__handle').forEach((handle) => { - const rect = handle.getBoundingClientRect(); - const cx = (rect.left + rect.width / 2 - nodeRect.left) / zoom; - const cy = (rect.top + rect.height / 2 - nodeRect.top) / zoom; - const x = nodeX + cx - paths.width / 2; - const y = nodeY + cy - paths.height / 2; + // Convert to viewport-relative coordinates, accounting for scale + const left = (rect.left - viewportRect.left) / scale; + const top = (rect.top - viewportRect.top) / scale; + const right = left + rect.width / scale; + const bottom = top + rect.height / scale; - handles.push(` - - -`); + bounds.minX = Math.min(bounds.minX, left); + bounds.minY = Math.min(bounds.minY, top); + bounds.maxX = Math.max(bounds.maxX, right); + bounds.maxY = Math.max(bounds.maxY, bottom); }); - return handles.join('\n'); -} - -// ============================================================================ -// NODE RENDERING - Pure SVG with DOM-read styles -// ============================================================================ - -async function renderNode(node: NodeInstance, ctx: RenderContext): Promise { - const wrapper = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement; - if (!wrapper) return ''; - - const nodeEl = wrapper.querySelector('.node') as HTMLElement; - if (!nodeEl) return ''; - - // Get dimensions from the actual .node element (not SvelteFlow wrapper) - // This ensures we use our dynamic width calculation for math names - const zoom = getZoom(); - const nodeRect = nodeEl.getBoundingClientRect(); - const width = nodeRect.width / zoom; - const height = nodeRect.height / zoom; - - // Position is center-origin, convert to top-left for SVG - const x = node.position.x - width / 2; - const y = node.position.y - height / 2; - - // Read styles from DOM - const computed = getComputedStyle(nodeEl); - const borderRadius = parseFloat(computed.borderRadius) || 8; - const isSubsystem = node.type === 'Subsystem' || node.type === 'Interface'; - const color = node.color || ctx.theme.accent; - - // Get text content - const nameEl = nodeEl.querySelector('.node-name'); - const typeEl = nodeEl.querySelector('.node-type'); - const nodeName = nameEl?.textContent || node.name; - const nodeType = typeEl?.textContent || ''; - - const parts: string[] = []; - - // Background fill - parts.push( - `` - ); - - // Border - const strokeDasharray = isSubsystem ? ' stroke-dasharray="4 2"' : ''; - parts.push( - `` - ); - - // Check for pinned params section in DOM - const pinnedParamsEl = nodeEl.querySelector('.pinned-params') as HTMLElement; - - // Calculate content center (above pinned params if present) - let contentCenterY = y + height / 2; - if (pinnedParamsEl) { - const pinnedRect = pinnedParamsEl.getBoundingClientRect(); - const pinnedTop = (pinnedRect.top - nodeRect.top) / zoom; - contentCenterY = y + pinnedTop / 2; - } - - // Labels - if (ctx.options.showLabels) { - const centerX = x + width / 2; - - if (ctx.options.showTypeLabels && nodeType) { - // Name above center (may contain math) - use original node.name for LaTeX source - // Spacing: name at -6 and type at +10 gives 16px gap for better separation - parts.push(await renderMathLabel(node.name, centerX, contentCenterY - 6, color, 10, '600', ctx)); - // Type below center - parts.push( - `${escapeXml(nodeType)}` - ); - } else { - // Just name, centered (may contain math) - use original node.name for LaTeX source - parts.push(await renderMathLabel(node.name, centerX, contentCenterY, color, 10, '600', ctx)); - } - } - - // Pinned parameters - read positions from DOM - if (pinnedParamsEl) { - const pinnedRect = pinnedParamsEl.getBoundingClientRect(); - const pinnedTop = y + (pinnedRect.top - nodeRect.top) / zoom; - const pinnedHeight = pinnedRect.height / zoom; - - // Separator line - parts.push( - `` - ); - - // Background for pinned params area (square top, rounded bottom to match node) - const px = x + 1; - const py = pinnedTop + 1; - const pw = width - 2; - const ph = pinnedHeight - 1; - const br = Math.max(0, borderRadius - 1); - // Path: start top-left, go right, down, rounded bottom-right, left, rounded bottom-left, up - parts.push( - `` - ); - - // Each pinned param row - read from DOM - pinnedParamsEl.querySelectorAll('.pinned-param').forEach((paramEl) => { - const labelEl = paramEl.querySelector('label') as HTMLElement; - const inputEl = paramEl.querySelector('input') as HTMLInputElement; - if (!labelEl || !inputEl) return; - - const labelRect = labelEl.getBoundingClientRect(); - const inputRect = inputEl.getBoundingClientRect(); - - // Label position - const labelX = x + (labelRect.left - nodeRect.left) / zoom; - const labelY = y + (labelRect.top + labelRect.height / 2 - nodeRect.top) / zoom; - const labelText = labelEl.textContent || ''; - - parts.push( - `${escapeXml(labelText)}` - ); - - // Input box position - const inputX = x + (inputRect.left - nodeRect.left) / zoom; - const inputY = y + (inputRect.top - nodeRect.top) / zoom; - const inputW = inputRect.width / zoom; - const inputH = inputRect.height / zoom; - const inputValue = inputEl.value || inputEl.placeholder || ''; - const inputBorderRadius = parseFloat(getComputedStyle(inputEl).borderRadius) || inputH / 2; - - // Input background (pill shape) - parts.push( - `` - ); - - // Input value - parts.push( - `${escapeXml(inputValue)}` - ); - }); - } - - // Handles - if (ctx.options.showHandles) { - const handles = renderHandles(node.id, x, y, ctx); - if (handles) parts.push(handles); - } - - return `\n${parts.join('\n')}\n`; + return isFinite(bounds.minX) ? bounds : { minX: 0, minY: 0, maxX: 200, maxY: 200 }; } -// ============================================================================ -// EVENT RENDERING - Pure SVG -// ============================================================================ - -function renderEvent(event: EventInstance, ctx: RenderContext): string { - const wrapper = document.querySelector(`[data-id="${event.id}"]`) as HTMLElement; - if (!wrapper) return ''; - - const zoom = getZoom(); - - // Get text from DOM - const nameEl = wrapper.querySelector('.event-name'); - const typeEl = wrapper.querySelector('.event-type'); - const eventName = nameEl?.textContent || event.name; - const eventType = typeEl?.textContent || ''; - - // Position is center-origin, so position IS the center - const cx = event.position.x; - const cy = event.position.y; - const color = event.color || ctx.theme.accent; - - const parts: string[] = []; - - // Get diamond element and its dimensions from DOM - const diamondEl = wrapper.querySelector('.diamond') as HTMLElement; - if (diamondEl) { - const diamondRect = diamondEl.getBoundingClientRect(); - const diamondSize = diamondRect.width / zoom; // Diamond is square - const diamondOffset = diamondSize / 2; - const borderRadius = parseFloat(getComputedStyle(diamondEl).borderRadius) || 4; - - // Diamond background - parts.push( - `` - ); - - // Diamond border - parts.push( - `` - ); - } +/** + * Export the current graph view as SVG + */ +export async function exportToSVG(options: ExportOptions = {}): Promise { + const opts: Required = { ...DEFAULT_OPTIONS, ...options }; + const theme = getThemeColors(opts.theme); + const padding = opts.padding ?? EXPORT_PADDING; - // Labels - if (ctx.options.showLabels) { - if (ctx.options.showTypeLabels && eventType) { - parts.push( - `${escapeXml(eventName)}` - ); - parts.push( - `${escapeXml(eventType)}` - ); - } else { - parts.push( - `${escapeXml(eventName)}` - ); - } + // Find the SvelteFlow container + const flowContainer = document.querySelector('.svelte-flow') as HTMLElement; + if (!flowContainer) { + throw new Error('SvelteFlow container not found'); } - return `\n${parts.join('\n')}\n`; -} - -// ============================================================================ -// BOUNDS & MAIN EXPORT -// ============================================================================ + // Store original styles to restore later + const viewport = flowContainer.querySelector('.svelte-flow__viewport') as HTMLElement; + const originalTransform = viewport?.style.transform || ''; + const originalContainerStyle = flowContainer.style.cssText; -function calculateBounds(nodes: NodeInstance[], events: EventInstance[]): Bounds { - const bounds: Bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; - const zoom = getZoom(); - - for (const node of nodes) { - // Get dimensions from the actual .node element (not SvelteFlow wrapper) - const wrapper = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement; - const nodeEl = wrapper?.querySelector('.node') as HTMLElement; - let width = NODE.baseWidth; - let height = NODE.baseHeight; - if (nodeEl) { - const rect = nodeEl.getBoundingClientRect(); - width = rect.width / zoom; - height = rect.height / zoom; + try { + // Calculate content bounds before resetting transform + const bounds = calculateContentBounds(); + const contentWidth = bounds.maxX - bounds.minX; + const contentHeight = bounds.maxY - bounds.minY; + + // Reset viewport transform to identity for clean capture + // Position viewport so content starts at (padding, padding) + if (viewport) { + const offsetX = -bounds.minX + padding; + const offsetY = -bounds.minY + padding; + viewport.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(1)`; } - // Position is center-origin, calculate corners - const left = node.position.x - width / 2; - const top = node.position.y - height / 2; - bounds.minX = Math.min(bounds.minX, left); - bounds.minY = Math.min(bounds.minY, top); - bounds.maxX = Math.max(bounds.maxX, left + width); - bounds.maxY = Math.max(bounds.maxY, top + height); - } - - for (const event of events) { - // Events use center-origin, get actual bounding box from DOM - const wrapper = document.querySelector(`[data-id="${event.id}"]`) as HTMLElement; - let boundingSize = EVENT.size; // Fallback - if (wrapper) { - const diamondEl = wrapper.querySelector('.diamond') as HTMLElement; - if (diamondEl) { - const zoom = getZoom(); - const diamondSize = diamondEl.getBoundingClientRect().width / zoom; - // Rotated 45° square has bounding box of size * sqrt(2) - boundingSize = diamondSize * Math.SQRT2; - } + // Set container size to match content + padding + const svgWidth = contentWidth + padding * 2; + const svgHeight = contentHeight + padding * 2; + flowContainer.style.width = `${svgWidth}px`; + flowContainer.style.height = `${svgHeight}px`; + flowContainer.style.overflow = 'visible'; + + // Force reflow + flowContainer.offsetHeight; + + // Convert DOM to SVG using dom-to-svg + const svgDocument = elementToSVG(flowContainer); + + // Inline external resources (fonts, images) + await inlineResources(svgDocument.documentElement); + + // Get the SVG element + const svgElement = svgDocument.documentElement; + + // Set proper dimensions and viewBox + svgElement.setAttribute('width', String(svgWidth)); + svgElement.setAttribute('height', String(svgHeight)); + svgElement.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`); + + // Add background if requested + if (opts.background === 'solid') { + const bgRect = svgDocument.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bgRect.setAttribute('x', '0'); + bgRect.setAttribute('y', '0'); + bgRect.setAttribute('width', String(svgWidth)); + bgRect.setAttribute('height', String(svgHeight)); + bgRect.setAttribute('fill', theme.surface); + svgElement.insertBefore(bgRect, svgElement.firstChild); } - const left = event.position.x - boundingSize / 2; - const top = event.position.y - boundingSize / 2; - bounds.minX = Math.min(bounds.minX, left); - bounds.minY = Math.min(bounds.minY, top); - bounds.maxX = Math.max(bounds.maxX, left + boundingSize); - bounds.maxY = Math.max(bounds.maxY, top + boundingSize); - } - - return isFinite(bounds.minX) ? bounds : { minX: 0, minY: 0, maxX: 200, maxY: 200 }; -} - -export async function exportToSVG(options: ExportOptions = {}): Promise { - const opts: Required = { ...DEFAULT_OPTIONS, ...options }; - const themeColors = getThemeColors(opts.theme); - const ctx: RenderContext = { theme: themeColors, options: opts }; - - const nodes = get(graphStore.nodesArray); - const events = get(eventStore.eventsArray); - const bounds = calculateBounds(nodes, events); - - const width = bounds.maxX - bounds.minX + opts.padding * 2; - const height = bounds.maxY - bounds.minY + opts.padding * 2; - const viewBox = `${bounds.minX - opts.padding} ${bounds.minY - opts.padding} ${width} ${height}`; - - const parts: string[] = [ - ``, - `` - ]; + // Remove elements we don't want in export + // Remove selection box, minimap, controls, etc. + const selectorsToRemove = [ + '.svelte-flow__minimap', + '.svelte-flow__controls', + '.svelte-flow__attribution', + '.svelte-flow__selection', + '.svelte-flow__nodesselection', + '.svelte-flow__background', // Remove default background (we add our own) + '.port-controls', // Remove +/- port buttons + '.selection-glow' // Remove selection glow effects + ]; + + selectorsToRemove.forEach((selector) => { + svgElement.querySelectorAll(selector).forEach((el) => el.remove()); + }); - // Background - if (opts.background === 'solid') { - parts.push( - `` - ); - } + // Serialize to string + const serializer = new XMLSerializer(); + let svgString = serializer.serializeToString(svgDocument); - // Edges - const edges = renderEdges(ctx); - if (edges) parts.push(edges); + // Add XML declaration + svgString = '\n' + svgString; - // Events - if (events.length > 0) { - parts.push(''); - for (const event of events) { - parts.push(renderEvent(event, ctx)); + return svgString; + } finally { + // Restore original styles + if (viewport) { + viewport.style.transform = originalTransform; } - parts.push(''); - } + flowContainer.style.cssText = originalContainerStyle; - // Nodes (render in parallel for performance) - if (nodes.length > 0) { - parts.push(''); - const renderedNodes = await Promise.all(nodes.map((node) => renderNode(node, ctx))); - for (const rendered of renderedNodes) { - if (rendered) parts.push(rendered); - } - parts.push(''); + // Force reflow to apply restored styles + flowContainer.offsetHeight; } - - parts.push(''); - return parts.join('\n'); } diff --git a/src/lib/export/svg/types.ts b/src/lib/export/svg/types.ts index 9b54a7cb..904cd231 100644 --- a/src/lib/export/svg/types.ts +++ b/src/lib/export/svg/types.ts @@ -19,6 +19,8 @@ export interface ExportOptions { showTypeLabels?: boolean; /** Whether to render handle shapes (default: true) */ showHandles?: boolean; + /** Whether to render port labels: true, false, or 'auto' to match canvas state (default: 'auto') */ + showPortLabels?: boolean | 'auto'; } /** Render context passed to all renderers */ @@ -44,5 +46,6 @@ export const DEFAULT_OPTIONS: Required = { padding: EXPORT_PADDING, showLabels: true, showTypeLabels: true, - showHandles: true + showHandles: true, + showPortLabels: 'auto' }; From 65cceec4a26fd697f19543e0bb7c0334d1030c5b Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 18:51:58 +0100 Subject: [PATCH 351/656] Update README with port labels docs and dom-to-svg dependency --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 86dfeb67..a2716df7 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A web-based visual node editor for building and simulating dynamic systems with - [Pyodide](https://pyodide.org/) for in-browser Python/NumPy/SciPy - [Plotly.js](https://plotly.com/javascript/) for interactive plots - [CodeMirror 6](https://codemirror.net/) for code editing +- [dom-to-svg](https://github.com/nicolo-ribaudo/dom-to-svg) for SVG graph export ## Getting Started @@ -504,6 +505,7 @@ Press `?` to see all shortcuts in the app. Key shortcuts: | | `X` / `Y` | Flip H/V | | | `Arrows` | Nudge selection | | **Wires** | `\` | Add waypoint to selected edge | +| **Labels** | `L` | Toggle port labels | | **View** | `F` | Fit view | | | `H` | Go to root | | | `T` | Toggle theme | @@ -621,6 +623,15 @@ Shapes are defined in `src/lib/nodes/shapes/registry.ts` and applied via CSS cla Colors are CSS-driven - see `src/app.css` for variables and `src/lib/utils/colors.ts` for palettes. +### Port Labels + +Port labels show the name of each input/output port alongside the node. Toggle globally with `L` key, or per-node via right-click menu. + +- **Global toggle**: Press `L` to show/hide port labels for all nodes +- **Per-node override**: Right-click node → "Show Input Labels" / "Show Output Labels" +- **Truncation**: Labels are truncated to 5 characters for compact display +- **SVG export**: Port labels are included when exporting the graph as SVG + ### Adding Custom Shapes 1. Register the shape in `src/lib/nodes/shapes/registry.ts`: From b9d476e58a03cf00faa3c3bfed57fb95b13d7555 Mon Sep 17 00:00:00 2001 From: Milan Date: Wed, 4 Feb 2026 18:55:02 +0100 Subject: [PATCH 352/656] Revert to manual SVG renderer (dom-to-svg incompatible with SvelteFlow) --- README.md | 1 - package-lock.json | 30 +- package.json | 1 - src/lib/export/svg/renderer.ts | 602 ++++++++++++++++++++++++++------- 4 files changed, 478 insertions(+), 156 deletions(-) diff --git a/README.md b/README.md index a2716df7..aa8dc0d1 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ A web-based visual node editor for building and simulating dynamic systems with - [Pyodide](https://pyodide.org/) for in-browser Python/NumPy/SciPy - [Plotly.js](https://plotly.com/javascript/) for interactive plots - [CodeMirror 6](https://codemirror.net/) for code editing -- [dom-to-svg](https://github.com/nicolo-ribaudo/dom-to-svg) for SVG graph export ## Getting Started diff --git a/package-lock.json b/package-lock.json index a3f8eafd..2781aeed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@codemirror/theme-one-dark": "^6.0.0", "@xyflow/svelte": "^1.5.0", "codemirror": "^6.0.0", - "dom-to-svg": "^0.12.2", "katex": "^0.16.0", "pathfinding": "^0.4.18", "plotly.js-dist-min": "^2.35.0", @@ -1807,17 +1806,6 @@ "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==", "license": "MIT" }, - "node_modules/dom-to-svg": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/dom-to-svg/-/dom-to-svg-0.12.2.tgz", - "integrity": "sha512-zVlswIYMj3669dUfErBszLcYOy+NzPEFhMezdzLVaqFcaj7VQecS0o8r9bqzBSczDtex2X/4HMZktFoj4EDqOA==", - "license": "MIT", - "dependencies": { - "gradient-parser": "^1.0.2", - "postcss": "^8.2.9", - "postcss-value-parser": "^4.1.0" - } - }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -2224,14 +2212,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gradient-parser": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/gradient-parser/-/gradient-parser-1.1.1.tgz", - "integrity": "sha512-Hu0YfNU+38EsTmnUfLXUKFMXq9yz7htGYpF4x+dlbBhUCvIvzLt0yVLT/gJRmvLKFJdqNFrz4eKkIUjIXSr7Tw==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2506,6 +2486,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, "funding": [ { "type": "github", @@ -2622,6 +2603,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -2647,6 +2629,7 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -2769,12 +2752,6 @@ "node": ">=4" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "license": "MIT" - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2975,6 +2952,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index 942daeed..85e6d96b 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "@codemirror/theme-one-dark": "^6.0.0", "@xyflow/svelte": "^1.5.0", "codemirror": "^6.0.0", - "dom-to-svg": "^0.12.2", "katex": "^0.16.0", "pathfinding": "^0.4.18", "plotly.js-dist-min": "^2.35.0", diff --git a/src/lib/export/svg/renderer.ts b/src/lib/export/svg/renderer.ts index 8b8d4628..a7e2d851 100644 --- a/src/lib/export/svg/renderer.ts +++ b/src/lib/export/svg/renderer.ts @@ -1,166 +1,512 @@ /** * SVG Renderer * - * Renders the current graph view as SVG using dom-to-svg library. - * This captures the exact visual appearance of the canvas. + * Renders the current graph view as SVG using a hybrid approach: + * - Edges: cloned directly from SvelteFlow's SVG (already vector graphics) + * - Nodes/Events: pure SVG with dimensions and styles read from DOM + * + * This approach ensures pixel-perfect accuracy while producing clean SVG output. */ -import { elementToSVG, inlineResources } from 'dom-to-svg'; +import { get } from 'svelte/store'; +import { graphStore } from '$lib/stores/graph'; +import { eventStore } from '$lib/stores/events'; import { getThemeColors } from '$lib/constants/theme'; -import { EXPORT_PADDING } from '$lib/constants/dimensions'; -import type { ExportOptions } from './types'; +import { NODE, EVENT } from '$lib/constants/dimensions'; +import { getHandlePath } from '$lib/constants/handlePaths'; +import { latexToSvg, getSvgDimensions, preloadMathJax } from '$lib/utils/mathjaxSvg'; + +// Preload MathJax when module loads +if (typeof window !== 'undefined') { + preloadMathJax(); +} +import type { ExportOptions, RenderContext, Bounds } from './types'; import { DEFAULT_OPTIONS } from './types'; +import type { NodeInstance } from '$lib/types/nodes'; +import type { EventInstance } from '$lib/types/events'; -/** - * Get the current viewport transform values - */ -function getViewportTransform(): { x: number; y: number; scale: number } { +// ============================================================================ +// DOM UTILITIES +// ============================================================================ + +function getZoom(): number { const viewport = document.querySelector('.svelte-flow__viewport') as HTMLElement; - if (!viewport) return { x: 0, y: 0, scale: 1 }; + if (!viewport) return 1; + const match = viewport.style.transform.match(/scale\(([^)]+)\)/); + return match ? parseFloat(match[1]) : 1; +} - const transform = viewport.style.transform; - const translateMatch = transform.match(/translate\(([^,]+),\s*([^)]+)\)/); - const scaleMatch = transform.match(/scale\(([^)]+)\)/); +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} - return { - x: translateMatch ? parseFloat(translateMatch[1]) : 0, - y: translateMatch ? parseFloat(translateMatch[2]) : 0, - scale: scaleMatch ? parseFloat(scaleMatch[1]) : 1 - }; +/** + * Extract LaTeX from a string with $...$ delimiters + */ +function extractLatex(text: string): { before: string; latex: string; after: string } | null { + const match = text.match(/^(.*?)\$([^$]+)\$(.*)$/); + if (!match) return null; + return { before: match[1], latex: match[2], after: match[3] }; } /** - * Calculate the bounding box of all content in graph coordinates + * Render a plain text label as SVG */ -function calculateContentBounds(): { minX: number; minY: number; maxX: number; maxY: number } { - const bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; - const viewport = document.querySelector('.svelte-flow__viewport') as HTMLElement; - if (!viewport) return { minX: 0, minY: 0, maxX: 200, maxY: 200 }; +function renderPlainTextLabel( + text: string, + centerX: number, + centerY: number, + color: string, + fontSize: number, + fontWeight: string +): string { + return `${escapeXml(text)}`; +} + +/** + * Render a label that may contain math as native SVG using MathJax + * @param originalText - The original text with $...$ LaTeX delimiters (NOT the rendered DOM content) + */ +async function renderMathLabel( + originalText: string, + centerX: number, + centerY: number, + color: string, + fontSize: number, + fontWeight: string, + ctx: RenderContext +): Promise { + const mathParts = extractLatex(originalText); - const { scale } = getViewportTransform(); + if (!mathParts) { + // Plain text - use regular SVG text + return renderPlainTextLabel(originalText, centerX, centerY, color, fontSize, fontWeight); + } - // Get all nodes and events - const elements = viewport.querySelectorAll('.svelte-flow__node, .svelte-flow__edge'); - elements.forEach((el) => { - const rect = (el as HTMLElement).getBoundingClientRect(); - const viewportRect = viewport.getBoundingClientRect(); + try { + // Render the LaTeX to SVG using MathJax + // Wrap in \boldsymbol to match the bold font-weight (600) used on canvas + const boldLatex = `\\boldsymbol{${mathParts.latex}}`; + let svg = await latexToSvg(boldLatex, false); + const dims = getSvgDimensions(svg); - // Convert to viewport-relative coordinates, accounting for scale - const left = (rect.left - viewportRect.left) / scale; - const top = (rect.top - viewportRect.top) / scale; - const right = left + rect.width / scale; - const bottom = top + rect.height / scale; + // Apply color to the SVG + svg = svg.replace(/currentColor/g, color); - bounds.minX = Math.min(bounds.minX, left); - bounds.minY = Math.min(bounds.minY, top); - bounds.maxX = Math.max(bounds.maxX, right); - bounds.maxY = Math.max(bounds.maxY, bottom); + // Add stroke to math paths to match system font weight (600) + // MathJax math fonts are lighter than system-ui bold + svg = svg.replace(/${svg}`; + } catch (e) { + console.error('MathJax SVG rendering error:', e); + // Fall back to plain text showing the raw LaTeX + return renderPlainTextLabel(originalText, centerX, centerY, color, fontSize, fontWeight); + } +} + +// ============================================================================ +// EDGE RENDERING - Clone from DOM +// ============================================================================ + +function renderEdges(ctx: RenderContext): string { + const container = document.querySelector('.svelte-flow__edges'); + if (!container) return ''; + + const parts: string[] = []; + + container.querySelectorAll('.svelte-flow__edge').forEach((edge) => { + const edgeParts: string[] = []; + + // Get all paths and groups within this edge + edge.querySelectorAll('path').forEach((pathEl) => { + const d = pathEl.getAttribute('d'); + if (!d) return; + + // Check if it's the main edge path or arrow + if (pathEl.classList.contains('svelte-flow__edge-path')) { + edgeParts.push( + `` + ); + } + }); + + // Find arrow groups (have transform with rotate) + edge.querySelectorAll('g').forEach((g) => { + const transform = g.getAttribute('transform'); + if (transform && transform.includes('rotate')) { + const arrowPath = g.querySelector('path'); + if (arrowPath) { + const d = arrowPath.getAttribute('d'); + if (d) { + edgeParts.push(``); + } + } + } + }); + + if (edgeParts.length > 0) { + parts.push(`${edgeParts.join('')}`); + } }); - return isFinite(bounds.minX) ? bounds : { minX: 0, minY: 0, maxX: 200, maxY: 200 }; + return parts.length > 0 ? `\n${parts.join('\n')}\n` : ''; } -/** - * Export the current graph view as SVG - */ -export async function exportToSVG(options: ExportOptions = {}): Promise { - const opts: Required = { ...DEFAULT_OPTIONS, ...options }; - const theme = getThemeColors(opts.theme); - const padding = opts.padding ?? EXPORT_PADDING; +// ============================================================================ +// HANDLE RENDERING +// ============================================================================ + +function renderHandles(nodeId: string, nodeX: number, nodeY: number, ctx: RenderContext): string { + const wrapper = document.querySelector(`[data-id="${nodeId}"]`); + if (!wrapper) return ''; + + const nodeEl = wrapper.querySelector('[data-rotation]') || wrapper; + const rotation = parseInt(nodeEl.getAttribute('data-rotation') || '0'); + const paths = getHandlePath(rotation); + const zoom = getZoom(); + const nodeRect = wrapper.getBoundingClientRect(); + + const handles: string[] = []; + + nodeEl.querySelectorAll('.svelte-flow__handle').forEach((handle) => { + const rect = handle.getBoundingClientRect(); + const cx = (rect.left + rect.width / 2 - nodeRect.left) / zoom; + const cy = (rect.top + rect.height / 2 - nodeRect.top) / zoom; + const x = nodeX + cx - paths.width / 2; + const y = nodeY + cy - paths.height / 2; + + handles.push(` + + +`); + }); + + return handles.join('\n'); +} + +// ============================================================================ +// NODE RENDERING - Pure SVG with DOM-read styles +// ============================================================================ + +async function renderNode(node: NodeInstance, ctx: RenderContext): Promise { + const wrapper = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement; + if (!wrapper) return ''; + + const nodeEl = wrapper.querySelector('.node') as HTMLElement; + if (!nodeEl) return ''; + + // Get dimensions from the actual .node element (not SvelteFlow wrapper) + // This ensures we use our dynamic width calculation for math names + const zoom = getZoom(); + const nodeRect = nodeEl.getBoundingClientRect(); + const width = nodeRect.width / zoom; + const height = nodeRect.height / zoom; + + // Position is center-origin, convert to top-left for SVG + const x = node.position.x - width / 2; + const y = node.position.y - height / 2; + + // Read styles from DOM + const computed = getComputedStyle(nodeEl); + const borderRadius = parseFloat(computed.borderRadius) || 8; + const isSubsystem = node.type === 'Subsystem' || node.type === 'Interface'; + const color = node.color || ctx.theme.accent; + + // Get text content + const nameEl = nodeEl.querySelector('.node-name'); + const typeEl = nodeEl.querySelector('.node-type'); + const nodeName = nameEl?.textContent || node.name; + const nodeType = typeEl?.textContent || ''; + + const parts: string[] = []; + + // Background fill + parts.push( + `` + ); + + // Border + const strokeDasharray = isSubsystem ? ' stroke-dasharray="4 2"' : ''; + parts.push( + `` + ); + + // Check for pinned params section in DOM + const pinnedParamsEl = nodeEl.querySelector('.pinned-params') as HTMLElement; - // Find the SvelteFlow container - const flowContainer = document.querySelector('.svelte-flow') as HTMLElement; - if (!flowContainer) { - throw new Error('SvelteFlow container not found'); + // Calculate content center (above pinned params if present) + let contentCenterY = y + height / 2; + if (pinnedParamsEl) { + const pinnedRect = pinnedParamsEl.getBoundingClientRect(); + const pinnedTop = (pinnedRect.top - nodeRect.top) / zoom; + contentCenterY = y + pinnedTop / 2; } - // Store original styles to restore later - const viewport = flowContainer.querySelector('.svelte-flow__viewport') as HTMLElement; - const originalTransform = viewport?.style.transform || ''; - const originalContainerStyle = flowContainer.style.cssText; + // Labels + if (ctx.options.showLabels) { + const centerX = x + width / 2; - try { - // Calculate content bounds before resetting transform - const bounds = calculateContentBounds(); - const contentWidth = bounds.maxX - bounds.minX; - const contentHeight = bounds.maxY - bounds.minY; - - // Reset viewport transform to identity for clean capture - // Position viewport so content starts at (padding, padding) - if (viewport) { - const offsetX = -bounds.minX + padding; - const offsetY = -bounds.minY + padding; - viewport.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(1)`; + if (ctx.options.showTypeLabels && nodeType) { + // Name above center (may contain math) - use original node.name for LaTeX source + // Spacing: name at -6 and type at +10 gives 16px gap for better separation + parts.push(await renderMathLabel(node.name, centerX, contentCenterY - 6, color, 10, '600', ctx)); + // Type below center + parts.push( + `${escapeXml(nodeType)}` + ); + } else { + // Just name, centered (may contain math) - use original node.name for LaTeX source + parts.push(await renderMathLabel(node.name, centerX, contentCenterY, color, 10, '600', ctx)); } + } - // Set container size to match content + padding - const svgWidth = contentWidth + padding * 2; - const svgHeight = contentHeight + padding * 2; - flowContainer.style.width = `${svgWidth}px`; - flowContainer.style.height = `${svgHeight}px`; - flowContainer.style.overflow = 'visible'; - - // Force reflow - flowContainer.offsetHeight; - - // Convert DOM to SVG using dom-to-svg - const svgDocument = elementToSVG(flowContainer); - - // Inline external resources (fonts, images) - await inlineResources(svgDocument.documentElement); - - // Get the SVG element - const svgElement = svgDocument.documentElement; - - // Set proper dimensions and viewBox - svgElement.setAttribute('width', String(svgWidth)); - svgElement.setAttribute('height', String(svgHeight)); - svgElement.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`); - - // Add background if requested - if (opts.background === 'solid') { - const bgRect = svgDocument.createElementNS('http://www.w3.org/2000/svg', 'rect'); - bgRect.setAttribute('x', '0'); - bgRect.setAttribute('y', '0'); - bgRect.setAttribute('width', String(svgWidth)); - bgRect.setAttribute('height', String(svgHeight)); - bgRect.setAttribute('fill', theme.surface); - svgElement.insertBefore(bgRect, svgElement.firstChild); - } + // Pinned parameters - read positions from DOM + if (pinnedParamsEl) { + const pinnedRect = pinnedParamsEl.getBoundingClientRect(); + const pinnedTop = y + (pinnedRect.top - nodeRect.top) / zoom; + const pinnedHeight = pinnedRect.height / zoom; + + // Separator line + parts.push( + `` + ); + + // Background for pinned params area (square top, rounded bottom to match node) + const px = x + 1; + const py = pinnedTop + 1; + const pw = width - 2; + const ph = pinnedHeight - 1; + const br = Math.max(0, borderRadius - 1); + // Path: start top-left, go right, down, rounded bottom-right, left, rounded bottom-left, up + parts.push( + `` + ); + + // Each pinned param row - read from DOM + pinnedParamsEl.querySelectorAll('.pinned-param').forEach((paramEl) => { + const labelEl = paramEl.querySelector('label') as HTMLElement; + const inputEl = paramEl.querySelector('input') as HTMLInputElement; + if (!labelEl || !inputEl) return; + + const labelRect = labelEl.getBoundingClientRect(); + const inputRect = inputEl.getBoundingClientRect(); + + // Label position + const labelX = x + (labelRect.left - nodeRect.left) / zoom; + const labelY = y + (labelRect.top + labelRect.height / 2 - nodeRect.top) / zoom; + const labelText = labelEl.textContent || ''; - // Remove elements we don't want in export - // Remove selection box, minimap, controls, etc. - const selectorsToRemove = [ - '.svelte-flow__minimap', - '.svelte-flow__controls', - '.svelte-flow__attribution', - '.svelte-flow__selection', - '.svelte-flow__nodesselection', - '.svelte-flow__background', // Remove default background (we add our own) - '.port-controls', // Remove +/- port buttons - '.selection-glow' // Remove selection glow effects - ]; - - selectorsToRemove.forEach((selector) => { - svgElement.querySelectorAll(selector).forEach((el) => el.remove()); + parts.push( + `${escapeXml(labelText)}` + ); + + // Input box position + const inputX = x + (inputRect.left - nodeRect.left) / zoom; + const inputY = y + (inputRect.top - nodeRect.top) / zoom; + const inputW = inputRect.width / zoom; + const inputH = inputRect.height / zoom; + const inputValue = inputEl.value || inputEl.placeholder || ''; + const inputBorderRadius = parseFloat(getComputedStyle(inputEl).borderRadius) || inputH / 2; + + // Input background (pill shape) + parts.push( + `` + ); + + // Input value + parts.push( + `${escapeXml(inputValue)}` + ); }); + } + + // Handles + if (ctx.options.showHandles) { + const handles = renderHandles(node.id, x, y, ctx); + if (handles) parts.push(handles); + } + + return `\n${parts.join('\n')}\n`; +} + +// ============================================================================ +// EVENT RENDERING - Pure SVG +// ============================================================================ + +function renderEvent(event: EventInstance, ctx: RenderContext): string { + const wrapper = document.querySelector(`[data-id="${event.id}"]`) as HTMLElement; + if (!wrapper) return ''; + + const zoom = getZoom(); + + // Get text from DOM + const nameEl = wrapper.querySelector('.event-name'); + const typeEl = wrapper.querySelector('.event-type'); + const eventName = nameEl?.textContent || event.name; + const eventType = typeEl?.textContent || ''; + + // Position is center-origin, so position IS the center + const cx = event.position.x; + const cy = event.position.y; + const color = event.color || ctx.theme.accent; + + const parts: string[] = []; + + // Get diamond element and its dimensions from DOM + const diamondEl = wrapper.querySelector('.diamond') as HTMLElement; + if (diamondEl) { + const diamondRect = diamondEl.getBoundingClientRect(); + const diamondSize = diamondRect.width / zoom; // Diamond is square + const diamondOffset = diamondSize / 2; + const borderRadius = parseFloat(getComputedStyle(diamondEl).borderRadius) || 4; + + // Diamond background + parts.push( + `` + ); + + // Diamond border + parts.push( + `` + ); + } + + // Labels + if (ctx.options.showLabels) { + if (ctx.options.showTypeLabels && eventType) { + parts.push( + `${escapeXml(eventName)}` + ); + parts.push( + `${escapeXml(eventType)}` + ); + } else { + parts.push( + `${escapeXml(eventName)}` + ); + } + } - // Serialize to string - const serializer = new XMLSerializer(); - let svgString = serializer.serializeToString(svgDocument); + return `\n${parts.join('\n')}\n`; +} + +// ============================================================================ +// BOUNDS & MAIN EXPORT +// ============================================================================ + +function calculateBounds(nodes: NodeInstance[], events: EventInstance[]): Bounds { + const bounds: Bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; + const zoom = getZoom(); + + for (const node of nodes) { + // Get dimensions from the actual .node element (not SvelteFlow wrapper) + const wrapper = document.querySelector(`[data-id="${node.id}"]`) as HTMLElement; + const nodeEl = wrapper?.querySelector('.node') as HTMLElement; + let width = NODE.baseWidth; + let height = NODE.baseHeight; + if (nodeEl) { + const rect = nodeEl.getBoundingClientRect(); + width = rect.width / zoom; + height = rect.height / zoom; + } + // Position is center-origin, calculate corners + const left = node.position.x - width / 2; + const top = node.position.y - height / 2; + bounds.minX = Math.min(bounds.minX, left); + bounds.minY = Math.min(bounds.minY, top); + bounds.maxX = Math.max(bounds.maxX, left + width); + bounds.maxY = Math.max(bounds.maxY, top + height); + } - // Add XML declaration - svgString = '\n' + svgString; + for (const event of events) { + // Events use center-origin, get actual bounding box from DOM + const wrapper = document.querySelector(`[data-id="${event.id}"]`) as HTMLElement; + let boundingSize = EVENT.size; // Fallback - return svgString; - } finally { - // Restore original styles - if (viewport) { - viewport.style.transform = originalTransform; + if (wrapper) { + const diamondEl = wrapper.querySelector('.diamond') as HTMLElement; + if (diamondEl) { + const zoom = getZoom(); + const diamondSize = diamondEl.getBoundingClientRect().width / zoom; + // Rotated 45° square has bounding box of size * sqrt(2) + boundingSize = diamondSize * Math.SQRT2; + } } - flowContainer.style.cssText = originalContainerStyle; - // Force reflow to apply restored styles - flowContainer.offsetHeight; + const left = event.position.x - boundingSize / 2; + const top = event.position.y - boundingSize / 2; + bounds.minX = Math.min(bounds.minX, left); + bounds.minY = Math.min(bounds.minY, top); + bounds.maxX = Math.max(bounds.maxX, left + boundingSize); + bounds.maxY = Math.max(bounds.maxY, top + boundingSize); } + + return isFinite(bounds.minX) ? bounds : { minX: 0, minY: 0, maxX: 200, maxY: 200 }; +} + +export async function exportToSVG(options: ExportOptions = {}): Promise { + const opts: Required = { ...DEFAULT_OPTIONS, ...options }; + const themeColors = getThemeColors(opts.theme); + const ctx: RenderContext = { theme: themeColors, options: opts }; + + const nodes = get(graphStore.nodesArray); + const events = get(eventStore.eventsArray); + const bounds = calculateBounds(nodes, events); + + const width = bounds.maxX - bounds.minX + opts.padding * 2; + const height = bounds.maxY - bounds.minY + opts.padding * 2; + const viewBox = `${bounds.minX - opts.padding} ${bounds.minY - opts.padding} ${width} ${height}`; + + const parts: string[] = [ + ``, + `` + ]; + + // Background + if (opts.background === 'solid') { + parts.push( + `` + ); + } + + // Edges + const edges = renderEdges(ctx); + if (edges) parts.push(edges); + + // Events + if (events.length > 0) { + parts.push(''); + for (const event of events) { + parts.push(renderEvent(event, ctx)); + } + parts.push(''); + } + + // Nodes (render in parallel for performance) + if (nodes.length > 0) { + parts.push(''); + const renderedNodes = await Promise.all(nodes.map((node) => renderNode(node, ctx))); + for (const rendered of renderedNodes) { + if (rendered) parts.push(rendered); + } + parts.push(''); + } + + parts.push(''); + return parts.join('\n'); } From 82fbaedecf23b88991526df7dbe5cc1e441ac17d Mon Sep 17 00:00:00 2001 From: Milan Date: Sat, 7 Feb 2026 16:22:50 +0100 Subject: [PATCH 353/656] Add .pvm file format specification for external tooling --- README.md | 2 + docs/pvm-spec.md | 646 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 648 insertions(+) create mode 100644 docs/pvm-spec.md diff --git a/README.md b/README.md index 86dfeb67..1d7573ca 100644 --- a/README.md +++ b/README.md @@ -527,6 +527,8 @@ PathView uses JSON-based file formats for saving and sharing: | `.blk` | Block | Single block with parameters (for sharing/reuse) | | `.sub` | Subsystem | Subsystem with internal graph (for sharing/reuse) | +The `.pvm` format is fully documented in [**docs/pvm-spec.md**](docs/pvm-spec.md). Use this spec if you are building tools that read or write PathView models (e.g., code generators, importers). A reference Python code generator is available at `scripts/pvm2py.py`. + ### Export Options - **File > Save** - Save complete model as `.pvm` diff --git a/docs/pvm-spec.md b/docs/pvm-spec.md new file mode 100644 index 00000000..e74711e1 --- /dev/null +++ b/docs/pvm-spec.md @@ -0,0 +1,646 @@ +# PathView Model (.pvm) File Specification + +**Version:** 1.0.0 +**Format:** JSON (UTF-8) + +A `.pvm` file is a complete, self-contained description of a PathSim simulation model. It contains the block diagram (nodes, connections, subsystems), event definitions, user-defined Python code, simulation solver settings, and optional canvas annotations. + +This document is the authoritative reference for anyone building tools that read or write `.pvm` files (e.g., code generators, importers, analyzers). + +--- + +## Table of Contents + +- [1. Root Structure](#1-root-structure) +- [2. Metadata](#2-metadata) +- [3. Graph](#3-graph) + - [3.1 Nodes](#31-nodes) + - [3.2 Ports](#32-ports) + - [3.3 Parameters](#33-parameters) + - [3.4 Connections](#34-connections) + - [3.5 Annotations](#35-annotations) +- [4. Subsystems](#4-subsystems) + - [4.1 Subsystem Node](#41-subsystem-node) + - [4.2 Interface Node](#42-interface-node) + - [4.3 Nesting](#43-nesting) +- [5. Events](#5-events) +- [6. Code Context](#6-code-context) +- [7. Simulation Settings](#7-simulation-settings) +- [8. Block Registry](#8-block-registry) +- [9. UI-Only Fields](#9-ui-only-fields) +- [10. Component Files (.blk, .sub)](#10-component-files-blk-sub) +- [11. Worked Example](#11-worked-example) + +--- + +## 1. Root Structure + +```json +{ + "version": "1.0.0", + "metadata": { ... }, + "graph": { + "nodes": [ ... ], + "connections": [ ... ], + "annotations": [ ... ] + }, + "events": [ ... ], + "codeContext": { + "code": "..." + }, + "simulationSettings": { ... } +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `version` | string | yes | File format version. Currently `"1.0.0"`. | +| `metadata` | object | yes | File metadata (name, timestamps). | +| `graph` | object | yes | The block diagram. | +| `graph.nodes` | array | yes | Block instances. | +| `graph.connections` | array | yes | Wires between ports. | +| `graph.annotations` | array | no | Canvas text labels. UI-only, no simulation semantics. | +| `events` | array | no | Root-level event instances. | +| `codeContext` | object | no | User-defined Python code executed before simulation. | +| `simulationSettings` | object | no | Solver and timestepping configuration. | + +--- + +## 2. Metadata + +```json +{ + "created": "2026-01-25T00:44:51.546Z", + "modified": "2026-01-25T00:44:51.546Z", + "name": "my-model", + "description": "Optional description" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `created` | string | yes | ISO 8601 timestamp. | +| `modified` | string | yes | ISO 8601 timestamp. | +| `name` | string | yes | Model display name. | +| `description` | string | no | Free-text description. | + +--- + +## 3. Graph + +### 3.1 Nodes + +Each node is an instance of a PathSim block class. + +```json +{ + "id": "c03d0b76-28b8-4406-8f0a-ef81b732854d", + "type": "Adder", + "name": "Difference", + "position": { "x": 840, "y": 400 }, + "inputs": [ ... ], + "outputs": [ ... ], + "params": { "operations": "\"+-\"" }, + "pinnedParams": ["operations"], + "color": "#0070C0" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | string | yes | Unique node ID (typically UUID v4). | +| `type` | string | yes | PathSim block class name (e.g., `"Integrator"`, `"Adder"`, `"Scope"`). See [Block Registry](#8-block-registry). Special values: `"Subsystem"`, `"Interface"`. | +| `name` | string | yes | User-editable display name. Used as variable name in code generation (after sanitization). | +| `position` | object | yes | Canvas position `{ x: number, y: number }`. UI-only. | +| `inputs` | array | yes | Input port instances (can be empty `[]`). | +| `outputs` | array | yes | Output port instances (can be empty `[]`). | +| `params` | object | yes | Parameter values. Keys are parameter names, values are Python expressions stored as strings or numbers. See [Parameters](#33-parameters). | +| `pinnedParams` | array | no | Parameter names to show inline on the node. UI-only. | +| `color` | string | no | Custom node color (hex). UI-only. | +| `graph` | object | no | Only present on `Subsystem` nodes. See [Subsystems](#4-subsystems). | + +### 3.2 Ports + +Ports define the input/output connection points on a node. + +```json +{ + "id": "c03d0b76-28b8-4406-8f0a-ef81b732854d-input-0", + "nodeId": "c03d0b76-28b8-4406-8f0a-ef81b732854d", + "name": "in 0", + "direction": "input", + "index": 0, + "color": "#969696" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | string | yes | Unique port ID. Convention: `${nodeId}-${direction}-${index}`. | +| `nodeId` | string | yes | ID of the owning node. | +| `name` | string | yes | Port display name (e.g., `"in 0"`, `"out 0"`, or a custom label). | +| `direction` | string | yes | `"input"` or `"output"`. | +| `index` | number | yes | Zero-based port index within the direction. This is the index used by connections. | +| `color` | string | no | Port color (hex). Default: `"#969696"`. UI-only. | + +**Port semantics for code generation:** The `index` field maps directly to PathSim's port indexing. In generated Python code, `node[0]` refers to the first output port, and connections use the syntax `Connection(source[sourcePortIndex], target[targetPortIndex])`. + +### 3.3 Parameters + +Parameter values are **Python expressions stored as JSON values** (usually strings, sometimes numbers or booleans). They are passed verbatim to PathSim constructors at runtime. PathSim handles all type checking and validation. + +```json +{ + "gain": "2.5", + "initial_value": "x0", + "operations": "\"+-\"", + "func": "lambda x: x**2", + "amplitude": 1, + "_rotation": 2 +} +``` + +Key rules: +- **String values** are the most common: `"2.5"`, `"x0"`, `"np.pi"`. These are Python expressions. +- **Numeric values** may appear (e.g., `1`, `0.5`). Treat as equivalent to their string form. +- **Values can reference variables** defined in the Code Context (e.g., `"x0"`, `"g"`). +- **Values can be Python expressions**: `"np.random.rand()"`, `"lambda t: np.sin(t)"`. +- **String-typed params** are double-quoted inside the JSON string: `"\"+-\""` represents the Python string `"+-"`. +- **Empty string `""`** means "use PathSim default" (parameter not specified by the user). +- **Keys starting with `_`** are UI-internal (e.g., `_rotation`, `_color`). Code generators should **skip** these. + +### 3.4 Connections + +A connection is a directed wire from a source output port to a target input port. + +```json +{ + "id": "c47a35bc-3030-4699-ab4a-d7aabca093d8", + "sourceNodeId": "c03d0b76-28b8-4406-8f0a-ef81b732854d", + "sourcePortIndex": 0, + "targetNodeId": "353e895d-8dfa-4c17-a752-043d1fc38749", + "targetPortIndex": 0, + "waypoints": [ ... ] +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | string | yes | Unique connection ID. | +| `sourceNodeId` | string | yes | ID of the source node. | +| `sourcePortIndex` | number | yes | Index into the source node's `outputs` array. | +| `targetNodeId` | string | yes | ID of the target node. | +| `targetPortIndex` | number | yes | Index into the target node's `inputs` array. | +| `waypoints` | array | no | Route control points. UI-only, no simulation semantics. | + +**Fan-out:** A single output port can connect to multiple input ports (multiple connections share the same `sourceNodeId`/`sourcePortIndex`). + +**No fan-in:** Each input port receives at most one connection. + +**Waypoints** (UI-only, can be ignored by code generators): + +```json +{ + "id": "wp_001", + "position": { "x": 150, "y": 100 }, + "isUserWaypoint": true +} +``` + +### 3.5 Annotations + +Canvas text labels with Markdown/LaTeX support. **Purely visual** — no simulation semantics. + +```json +{ + "id": "a0d9ddea-c4f8-4363-9c0a-5e19e6c52491", + "position": { "x": 660, "y": 260 }, + "content": "# Linear Feedback System\n\nA description with $\\LaTeX$.", + "width": 500, + "height": 80, + "color": "#0070C0", + "fontSize": 12 +} +``` + +Code generators should **ignore** annotations entirely. + +--- + +## 4. Subsystems + +Subsystems are nodes that contain a nested block diagram. They enable hierarchical model composition. + +### 4.1 Subsystem Node + +A node with `"type": "Subsystem"` has a `graph` field containing its internal block diagram: + +```json +{ + "id": "sub1", + "type": "Subsystem", + "name": "PID Controller", + "position": { "x": 300, "y": 150 }, + "inputs": [ + { "id": "sub1-input-0", "nodeId": "sub1", "name": "error", "direction": "input", "index": 0 } + ], + "outputs": [ + { "id": "sub1-output-0", "nodeId": "sub1", "name": "control", "direction": "output", "index": 0 } + ], + "params": {}, + "graph": { + "nodes": [ ... ], + "connections": [ ... ], + "annotations": [], + "events": [] + } +} +``` + +The subsystem's external `inputs` and `outputs` define its interface to the parent graph. Internal connections are fully contained in `graph.connections`. + +### 4.2 Interface Node + +Every subsystem graph contains exactly one `Interface` node. This node acts as the bridge between the subsystem's external ports and its internal graph. + +**Port direction is inverted** relative to the parent subsystem: +- Parent subsystem's **inputs** become Interface's **outputs** (data flows into the subsystem, out of the Interface to internal blocks) +- Parent subsystem's **outputs** become Interface's **inputs** (data flows from internal blocks into the Interface, out of the subsystem) + +``` +Parent graph: Inside subsystem: + ┌─────────────────────────┐ + ──[input 0]──> Subsystem │ Interface ──[output 0]──> internal blocks + │ Interface <──[input 0]── internal blocks + <──[output 0]── Subsystem │ │ + └─────────────────────────┘ +``` + +Interface node example: + +```json +{ + "id": "iface1", + "type": "Interface", + "name": "Interface", + "position": { "x": 50, "y": 50 }, + "inputs": [ + { "id": "iface1-input-0", "nodeId": "iface1", "name": "control", "direction": "input", "index": 0 } + ], + "outputs": [ + { "id": "iface1-output-0", "nodeId": "iface1", "name": "error", "direction": "output", "index": 0 } + ], + "params": {} +} +``` + +### 4.3 Nesting + +Subsystems can be nested arbitrarily deep. A subsystem's `graph.nodes` can contain other `Subsystem` nodes, each with their own `graph` and `Interface`. + +In PathSim Python code, subsystems map to `Subsystem(blocks=[...], connections=[...])` constructors. The Interface maps to `Interface()`. See `scripts/pvm2py.py` for a reference implementation. + +--- + +## 5. Events + +Events define discrete-time behavior (zero-crossing detection, scheduled actions, etc.). + +Events can appear in two places: +- **Root level:** `file.events[]` — global events for the top-level simulation. +- **Inside subsystems:** `node.graph.events[]` — events scoped to that subsystem. + +```json +{ + "id": "1455e789-9e7d-4139-8a4e-896feecff3d3", + "type": "pathsim.events.ZeroCrossing", + "name": "Bounce", + "position": { "x": 1320, "y": 200 }, + "params": { + "func_evt": "bounce_detect", + "func_act": "bounce_resolve" + }, + "color": "#FF6B6B" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | string | yes | Unique event ID. | +| `type` | string | yes | Fully qualified event type (e.g., `"pathsim.events.ZeroCrossing"`). The class name is the part after the last `.`. | +| `name` | string | yes | User-defined name. Used as variable name in code generation. | +| `position` | object | yes | Canvas position. UI-only. | +| `params` | object | yes | Event parameters (Python expressions). | +| `color` | string | no | Custom color. UI-only. | + +**Common event types:** + +| Type | Description | Key Parameters | +|------|-------------|----------------| +| `pathsim.events.ZeroCrossing` | Triggers when a function crosses zero (bidirectional) | `func_evt`, `func_act`, `tolerance` | +| `pathsim.events.ZeroCrossingUp` | Triggers on positive-going zero crossing | `func_evt`, `func_act`, `tolerance` | +| `pathsim.events.ZeroCrossingDown` | Triggers on negative-going zero crossing | `func_evt`, `func_act`, `tolerance` | +| `pathsim.events.Condition` | Triggers when a boolean condition becomes true | `func_evt`, `func_act` | +| `pathsim.events.Schedule` | Time-based periodic event | `func_act`, `t_start`, `t_end`, `t_period` | + +Event parameters reference Python callables, typically defined in the Code Context. For example, `"func_evt": "bounce_detect"` references a function `bounce_detect` defined in `codeContext.code`. + +--- + +## 6. Code Context + +User-defined Python code that runs before the simulation. Used to define variables, helper functions, and event callbacks. + +```json +{ + "code": "# gravity\ng = 9.81\n\n# event callback\ndef bounce_detect(t):\n return pos.engine.state\n" +} +``` + +This code is executed in the simulation namespace and can: +- Define variables referenced by node parameters (e.g., a node param `"gain": "g"` references `g = 9.81`) +- Import additional libraries +- Define functions used by events +- Access block instances by their sanitized variable names + +The code runs after `import numpy as np` and `import matplotlib.pyplot as plt` are already available. + +--- + +## 7. Simulation Settings + +```json +{ + "duration": "10", + "dt": "0.01", + "solver": "RKBS32", + "adaptive": true, + "atol": "1e-6", + "rtol": "1e-4", + "ftol": "1e-9", + "dt_min": "", + "dt_max": "0.1", + "ghostTraces": 6, + "plotResults": true +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `duration` | string | yes | Simulation duration. Python expression. | +| `dt` | string | yes | Initial/fixed time step. Python expression. | +| `solver` | string | yes | Solver type (see below). | +| `adaptive` | boolean | yes | Enable adaptive timestepping. | +| `atol` | string | yes | Absolute local truncation error tolerance. Python expression. | +| `rtol` | string | yes | Relative local truncation error tolerance. Python expression. | +| `ftol` | string | yes | Fixed-point iteration convergence tolerance. Python expression. | +| `dt_min` | string | yes | Minimum timestep (adaptive only). Python expression. | +| `dt_max` | string | yes | Maximum timestep (adaptive only). Python expression. | +| `ghostTraces` | number | no | Number of previous results to overlay. UI-only. | +| `plotResults` | boolean | no | Auto-open plot panel after simulation. UI-only. | + +**Empty string `""`** means "use PathSim default." Default values used by pvm2py: + +| Setting | Default | +|---------|---------| +| `duration` | `10.0` | +| `dt` | `0.01` | +| `solver` | `SSPRK22` | +| `atol` | `1e-6` | +| `rtol` | `1e-3` | +| `ftol` | `1e-12` | +| `dt_min` | `1e-12` | +| `dt_max` | (none) | + +**Available solvers:** + +| Solver | Type | Description | +|--------|------|-------------| +| `SSPRK22` | Explicit | Strong stability preserving Runge-Kutta (2,2). Default. | +| `RK4` | Explicit | Classic 4th-order Runge-Kutta. Fixed step only. | +| `RKBS32` | Explicit | Bogacki-Shampine (3,2). Adaptive capable. | +| `RKCK54` | Explicit | Cash-Karp (5,4). Adaptive capable. | +| `BDF2` | Implicit | Backward differentiation formula, 2nd order. For stiff systems. | +| `GEAR52A` | Implicit | Gear's method (5,2). For stiff systems. | +| `ESDIRK43` | Implicit | Explicit singly diagonally implicit Runge-Kutta (4,3). | + +--- + +## 8. Block Registry + +The file `scripts/generated/registry.json` maps block type names to their PathSim import paths and valid parameter names. It is generated from PathSim source by `npm run extract` (or `python scripts/extract.py`). + +```json +{ + "blocks": { + "Constant": { + "blockClass": "Constant", + "importPath": "pathsim.blocks", + "params": ["value"] + }, + "Integrator": { + "blockClass": "Integrator", + "importPath": "pathsim.blocks", + "params": ["initial_value"] + }, + "Adder": { + "blockClass": "Adder", + "importPath": "pathsim.blocks", + "params": ["operations"] + } + }, + "events": { + "ZeroCrossing": { + "eventClass": "ZeroCrossing", + "importPath": "pathsim.events", + "params": ["func_evt", "func_act", "tolerance"] + } + } +} +``` + +**For code generators:** Use this registry to: +1. Resolve `node.type` to the correct PathSim class name and import path +2. Filter `node.params` to only include keys listed in `registry.blocks[type].params` (skip `_`-prefixed keys and any keys not in the list) + +The full registry is checked into the repo at `scripts/generated/registry.json`. + +--- + +## 9. UI-Only Fields + +These fields are used by the PathView editor for layout and display. Code generators and analysis tools should **ignore** them: + +| Field | Where | Description | +|-------|-------|-------------| +| `node.position` | nodes | Canvas x/y position | +| `node.color` | nodes | Custom node color | +| `node.pinnedParams` | nodes | Inline parameter display | +| `port.color` | ports | Port color | +| `connection.waypoints` | connections | Wire routing control points | +| `annotations` | graph | Canvas text labels | +| `event.position` | events | Canvas position | +| `event.color` | events | Custom color | +| `params._rotation` | params | Node rotation (0-3, quarter turns) | +| `params._color` | params | Node color (legacy) | +| `simulationSettings.ghostTraces` | settings | Ghost trace display count | +| `simulationSettings.plotResults` | settings | Auto-open plot panel | + +--- + +## 10. Component Files (.blk, .sub) + +PathView also supports single-component files for sharing individual blocks or subsystems. + +### Block file (.blk) + +```json +{ + "version": "1.0", + "type": "block", + "metadata": { "name": "MyBlock", "created": "...", "modified": "..." }, + "content": { + "node": { ... } + } +} +``` + +### Subsystem file (.sub) + +```json +{ + "version": "1.0", + "type": "subsystem", + "metadata": { "name": "MySubsystem", "created": "...", "modified": "..." }, + "content": { + "node": { ... } + } +} +``` + +The `content.node` field contains a single `NodeInstance` in the same format as nodes in the `.pvm` graph. For subsystems, the node includes the `graph` field. + +--- + +## 11. Worked Example + +A minimal model with a step input feeding an integrator, recorded by a scope: + +```json +{ + "version": "1.0.0", + "metadata": { + "created": "2026-01-01T00:00:00.000Z", + "modified": "2026-01-01T00:00:00.000Z", + "name": "step-response" + }, + "graph": { + "nodes": [ + { + "id": "src1", + "type": "StepSource", + "name": "Step", + "position": { "x": 100, "y": 200 }, + "inputs": [], + "outputs": [ + { "id": "src1-output-0", "nodeId": "src1", "name": "out 0", "direction": "output", "index": 0 } + ], + "params": { "amplitude": "1.0", "tau": "1.0" } + }, + { + "id": "int1", + "type": "Integrator", + "name": "Integrator", + "position": { "x": 300, "y": 200 }, + "inputs": [ + { "id": "int1-input-0", "nodeId": "int1", "name": "in 0", "direction": "input", "index": 0 } + ], + "outputs": [ + { "id": "int1-output-0", "nodeId": "int1", "name": "out 0", "direction": "output", "index": 0 } + ], + "params": { "initial_value": "0.0" } + }, + { + "id": "scope1", + "type": "Scope", + "name": "Output", + "position": { "x": 500, "y": 200 }, + "inputs": [ + { "id": "scope1-input-0", "nodeId": "scope1", "name": "in 0", "direction": "input", "index": 0 } + ], + "outputs": [], + "params": {} + } + ], + "connections": [ + { + "id": "conn1", + "sourceNodeId": "src1", + "sourcePortIndex": 0, + "targetNodeId": "int1", + "targetPortIndex": 0 + }, + { + "id": "conn2", + "sourceNodeId": "int1", + "sourcePortIndex": 0, + "targetNodeId": "scope1", + "targetPortIndex": 0 + } + ] + }, + "events": [], + "codeContext": { "code": "" }, + "simulationSettings": { + "duration": "10", + "dt": "0.01", + "solver": "SSPRK22", + "adaptive": false, + "atol": "1e-6", + "rtol": "1e-3", + "ftol": "1e-12", + "dt_min": "", + "dt_max": "", + "ghostTraces": 0, + "plotResults": true + } +} +``` + +The equivalent Python code generated by `pvm2py`: + +```python +from pathsim import Simulation, Connection +from pathsim.blocks import StepSource, Integrator, Scope +from pathsim.solvers import SSPRK22 + +step = StepSource(amplitude=1.0, tau=1.0) +integrator = Integrator(initial_value=0.0) +output = Scope() + +sim = Simulation( + [step, integrator, output], + [ + Connection(step[0], integrator[0]), + Connection(integrator[0], output[0]), + ], + Solver=SSPRK22, + dt=0.01, +) + +sim.run(duration=10) +``` + +--- + +## Notes for Code Generator Authors + +1. **Parameters are Python expressions.** The values in `params` are not guaranteed to be numeric literals. They can be variable references (`"x0"`), expressions (`"np.pi / 4"`), or callables (`"lambda t: np.sin(t)"`). A C code generator will need to either evaluate these at export time or map a supported subset. + +2. **The registry is your friend.** Use `scripts/generated/registry.json` to know which parameters are valid for each block type, and to resolve import paths. Parameters not in the registry are either dead or UI-internal. + +3. **Scope and Spectrum blocks are recording sinks.** They have inputs but no outputs. A C code generator might map these to data logging or output arrays. + +4. **Source blocks have no inputs.** `Constant`, `StepSource`, `SinusoidalSource`, etc., are pure signal sources with only output ports. + +5. **Reference implementation.** The `scripts/pvm2py.py` script is a complete, working `.pvm`-to-Python code generator. It demonstrates how to traverse the graph, handle subsystems, resolve imports, and generate simulation code. From 5e2aac03863fba347a13a17c4ae97b6a96502074 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Mon, 9 Feb 2026 11:38:12 +0100 Subject: [PATCH 354/656] Add URL param theme selection (?theme=dark/light) --- src/lib/stores/theme.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lib/stores/theme.ts b/src/lib/stores/theme.ts index 095a2ca8..3159e1be 100644 --- a/src/lib/stores/theme.ts +++ b/src/lib/stores/theme.ts @@ -12,6 +12,14 @@ export type Theme = 'light' | 'dark'; function getInitialTheme(): Theme { if (!browser) return 'dark'; + // URL parameter has highest priority + const urlTheme = new URL(window.location.href).searchParams.get('theme'); + if (urlTheme === 'dark' || urlTheme === 'light') { + localStorage.setItem('pathview-theme', urlTheme); + document.documentElement.setAttribute('data-theme', urlTheme); + return urlTheme; + } + const stored = localStorage.getItem('pathview-theme'); if (stored === 'light' || stored === 'dark') { return stored; From 8c986f215b247b0410ec1c26d615b2a9e52d0e35 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 9 Feb 2026 10:54:54 +0000 Subject: [PATCH 355/656] Bump version to 0.4.12 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1186be83..ed91634b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pathview", - "version": "0.4.11", + "version": "0.4.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pathview", - "version": "0.4.11", + "version": "0.4.12", "dependencies": { "@codemirror/lang-python": "^6.0.0", "@codemirror/theme-one-dark": "^6.0.0", diff --git a/package.json b/package.json index 85e6d96b..2ee3452e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pathview", - "version": "0.4.11", + "version": "0.4.12", "private": true, "type": "module", "scripts": { From 01caaa18697a7930c5f0990c7047bb3fe6af35f8 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Mon, 9 Feb 2026 23:05:38 +0100 Subject: [PATCH 356/656] Refactor: extract buildPortLabelItems() helper to deduplicate context menu code --- src/lib/components/contextMenuBuilders.ts | 118 +++++++--------------- 1 file changed, 36 insertions(+), 82 deletions(-) diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index dc1ed488..2e644281 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -26,10 +26,43 @@ import { exportToSVG } from '$lib/export/svg'; import { downloadSvg } from '$lib/utils/download'; import { plotSettingsStore, DEFAULT_BLOCK_SETTINGS } from '$lib/stores/plotSettings'; import { portLabelsStore } from '$lib/stores/portLabels'; +import { getEffectivePortLabelVisibility } from '$lib/utils/portLabels'; +import type { NodeInstance } from '$lib/types/nodes'; /** Divider menu item */ const DIVIDER: MenuItemType = { label: '', action: () => {}, divider: true }; +/** Build port label toggle menu items for a node */ +function buildPortLabelItems(nodeId: string, node: NodeInstance): MenuItemType[] { + const globalLabels = get(portLabelsStore); + const { inputs: showInputLabels, outputs: showOutputLabels } = getEffectivePortLabelVisibility(node, globalLabels); + const hasInputs = node.inputs && node.inputs.length > 0; + const hasOutputs = node.outputs && node.outputs.length > 0; + + if (!hasInputs && !hasOutputs) return []; + + const items: MenuItemType[] = [DIVIDER]; + if (hasInputs) { + items.push({ + label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) + ) + }); + } + if (hasOutputs) { + items.push({ + label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', + icon: 'tag', + action: () => historyStore.mutate(() => + graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) + ) + }); + } + return items; +} + /** Show block code in preview dialog */ function showBlockCode(nodeId: string): void { const node = graphStore.getNode(nodeId); @@ -74,12 +107,6 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { // Interface blocks have limited options if (isInterface) { - const globalLabels = get(portLabelsStore); - const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; - const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; - const hasInputs = node.inputs && node.inputs.length > 0; - const hasOutputs = node.outputs && node.outputs.length > 0; - const items: MenuItemType[] = [ { label: 'Properties', @@ -94,27 +121,7 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { } ]; - if (hasInputs || hasOutputs) { - items.push(DIVIDER); - if (hasInputs) { - items.push({ - label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', - icon: 'tag', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) - ) - }); - } - if (hasOutputs) { - items.push({ - label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', - icon: 'tag', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) - ) - }); - } - } + items.push(...buildPortLabelItems(nodeId, node)); items.push( DIVIDER, @@ -130,12 +137,6 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { // Subsystem blocks get "Enter" option if (isSubsystem) { - const globalLabels = get(portLabelsStore); - const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; - const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; - const hasInputs = node.inputs && node.inputs.length > 0; - const hasOutputs = node.outputs && node.outputs.length > 0; - const items: MenuItemType[] = [ { label: 'Properties', @@ -150,27 +151,7 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { } ]; - if (hasInputs || hasOutputs) { - items.push(DIVIDER); - if (hasInputs) { - items.push({ - label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', - icon: 'tag', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) - ) - }); - } - if (hasOutputs) { - items.push({ - label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', - icon: 'tag', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) - ) - }); - } - } + items.push(...buildPortLabelItems(nodeId, node)); items.push( DIVIDER, @@ -219,13 +200,6 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { const isRecordingNode = node.type === 'Scope' || node.type === 'Spectrum'; const dataSource = node.type === 'Scope' ? 'scope' : 'spectrum'; - // Per-node port label visibility (undefined = follow global) - const globalLabels = get(portLabelsStore); - const showInputLabels = (node.params?.['_showInputLabels'] as boolean | undefined) ?? globalLabels; - const showOutputLabels = (node.params?.['_showOutputLabels'] as boolean | undefined) ?? globalLabels; - const hasInputs = node.inputs && node.inputs.length > 0; - const hasOutputs = node.outputs && node.outputs.length > 0; - // Regular blocks const items: MenuItemType[] = [ { @@ -236,27 +210,7 @@ function buildNodeMenu(nodeId: string): MenuItemType[] { } ]; - if (hasInputs || hasOutputs) { - items.push(DIVIDER); - if (hasInputs) { - items.push({ - label: showInputLabels ? 'Hide Input Labels' : 'Show Input Labels', - icon: 'tag', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showInputLabels: !showInputLabels }) - ) - }); - } - if (hasOutputs) { - items.push({ - label: showOutputLabels ? 'Hide Output Labels' : 'Show Output Labels', - icon: 'tag', - action: () => historyStore.mutate(() => - graphStore.updateNodeParams(nodeId, { _showOutputLabels: !showOutputLabels }) - ) - }); - } - } + items.push(...buildPortLabelItems(nodeId, node)); items.push( DIVIDER, From 275d784d3d7fd0df2b26edebace5efec9b1de16c Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Mon, 9 Feb 2026 23:07:31 +0100 Subject: [PATCH 357/656] Refactor: replace 42 CSS grid selectors with inline styles from gridLayout() --- src/lib/components/nodes/BaseNode.svelte | 171 +++++++++++------------ 1 file changed, 80 insertions(+), 91 deletions(-) diff --git a/src/lib/components/nodes/BaseNode.svelte b/src/lib/components/nodes/BaseNode.svelte index e14dac84..0fded88a 100644 --- a/src/lib/components/nodes/BaseNode.svelte +++ b/src/lib/components/nodes/BaseNode.svelte @@ -231,38 +231,93 @@ measuredName )); - // Grid template for port labels layout (computed in JS instead of CSS selectors) - const gridTemplate = $derived(() => { - if (!showPortLabels) return { columns: undefined, rows: undefined }; + // Grid layout for port labels (computed in JS, replaces CSS grid-placement selectors) + const gridLayout = $derived(() => { + if (!showPortLabels) { + return { + columns: undefined, rows: undefined, + inputStyle: '', innerStyle: '', outputStyle: '' + }; + } const labelSize = `${PORT_LABEL.columnWidth}px`; + let columns: string | undefined; + let rows: string | undefined; + let inputStyle = ''; + let innerStyle = ''; + let outputStyle = ''; if (isVertical) { - // Vertical layout: rows for input/output labels + // Vertical: rows for labels, single column + const inputBorder = rotation === 1 ? 'border-bottom' : 'border-top'; + const outputBorder = rotation === 1 ? 'border-top' : 'border-bottom'; + const colStyle = 'grid-column: 1;'; + if (hasVisibleInputLabels && hasVisibleOutputLabels) { - // Both: [input-labels] [content] [output-labels] - return { columns: undefined, rows: `${labelSize} 1fr ${labelSize}` }; + // rotation 1: input(row1) content(row2) output(row3) + // rotation 3: output(row1) content(row2) input(row3) + rows = `${labelSize} 1fr ${labelSize}`; + if (rotation === 1) { + inputStyle = `${colStyle} grid-row: 1; ${inputBorder}: 1px solid var(--border);`; + innerStyle = `${colStyle} grid-row: 2;`; + outputStyle = `${colStyle} grid-row: 3; ${outputBorder}: 1px solid var(--border);`; + } else { + outputStyle = `${colStyle} grid-row: 1; ${outputBorder}: 1px solid var(--border);`; + innerStyle = `${colStyle} grid-row: 2;`; + inputStyle = `${colStyle} grid-row: 3; ${inputBorder}: 1px solid var(--border);`; + } } else if (hasVisibleInputLabels) { - // Input only: rotation 1 = top, rotation 3 = bottom - return { columns: undefined, rows: rotation === 1 ? `${labelSize} 1fr` : `1fr ${labelSize}` }; + rows = rotation === 1 ? `${labelSize} 1fr` : `1fr ${labelSize}`; + const inputRow = rotation === 1 ? 1 : 2; + const innerRow = rotation === 1 ? 2 : 1; + inputStyle = `${colStyle} grid-row: ${inputRow}; ${inputBorder}: 1px solid var(--border);`; + innerStyle = `${colStyle} grid-row: ${innerRow};`; } else if (hasVisibleOutputLabels) { - // Output only: rotation 1 = bottom, rotation 3 = top - return { columns: undefined, rows: rotation === 1 ? `1fr ${labelSize}` : `${labelSize} 1fr` }; + rows = rotation === 1 ? `1fr ${labelSize}` : `${labelSize} 1fr`; + const outputRow = rotation === 1 ? 2 : 1; + const innerRow = rotation === 1 ? 1 : 2; + outputStyle = `${colStyle} grid-row: ${outputRow}; ${outputBorder}: 1px solid var(--border);`; + innerStyle = `${colStyle} grid-row: ${innerRow};`; } } else { - // Horizontal layout: columns for input/output labels + // Horizontal: columns for labels, single row + const rowStyle = 'grid-row: 1;'; + // rotation 0: inputs left (border-right), outputs right (border-left) + // rotation 2: inputs right (border-left), outputs left (border-right) + const inputBorder = rotation === 0 ? 'border-right' : 'border-left'; + const outputBorder = rotation === 0 ? 'border-left' : 'border-right'; + if (hasVisibleInputLabels && hasVisibleOutputLabels) { - // Both: [input-labels] [content] [output-labels] - return { columns: `${labelSize} 1fr ${labelSize}`, rows: undefined }; + columns = `${labelSize} 1fr ${labelSize}`; + if (rotation === 0) { + // input(col1) content(col2) output(col3) + inputStyle = `${rowStyle} grid-column: 1; ${inputBorder}: 1px solid var(--border);`; + innerStyle = `${rowStyle} grid-column: 2;`; + outputStyle = `${rowStyle} grid-column: 3; ${outputBorder}: 1px solid var(--border);`; + } else { + // output(col1) content(col2) input(col3) + outputStyle = `${rowStyle} grid-column: 1; ${outputBorder}: 1px solid var(--border);`; + innerStyle = `${rowStyle} grid-column: 2;`; + inputStyle = `${rowStyle} grid-column: 3; ${inputBorder}: 1px solid var(--border);`; + } } else if (hasVisibleInputLabels) { - // Input only: rotation 0 = left, rotation 2 = right - return { columns: rotation === 0 ? `${labelSize} 1fr` : `1fr ${labelSize}`, rows: undefined }; + // rotation 0: input(col1) content(col2) | rotation 2: content(col1) input(col2) + columns = rotation === 0 ? `${labelSize} 1fr` : `1fr ${labelSize}`; + const inputCol = rotation === 0 ? 1 : 2; + const innerCol = rotation === 0 ? 2 : 1; + inputStyle = `${rowStyle} grid-column: ${inputCol}; ${inputBorder}: 1px solid var(--border);`; + innerStyle = `${rowStyle} grid-column: ${innerCol};`; } else if (hasVisibleOutputLabels) { - // Output only: rotation 0 = right, rotation 2 = left - return { columns: rotation === 0 ? `1fr ${labelSize}` : `${labelSize} 1fr`, rows: undefined }; + // rotation 0: content(col1) output(col2) | rotation 2: output(col1) content(col2) + columns = rotation === 0 ? `1fr ${labelSize}` : `${labelSize} 1fr`; + const outputCol = rotation === 0 ? 2 : 1; + const innerCol = rotation === 0 ? 1 : 2; + outputStyle = `${rowStyle} grid-column: ${outputCol}; ${outputBorder}: 1px solid var(--border);`; + innerStyle = `${rowStyle} grid-column: ${innerCol};`; } } - return { columns: undefined, rows: undefined }; + + return { columns, rows, inputStyle, innerStyle, outputStyle }; }); // Check if this is a Subsystem or Interface node (using shapes utility) @@ -406,8 +461,6 @@ class:preview-hovered={showPreview} class:subsystem-type={isSubsystemType} class:show-labels={showPortLabels} - class:has-inputs={hasVisibleInputLabels} - class:has-outputs={hasVisibleOutputLabels} data-rotation={rotation} style="width: {nodeDimensions.width}px; height: {nodeDimensions.height}px; --node-color: {nodeColor};" ondblclick={handleDoubleClick} @@ -432,13 +485,13 @@
        {#if hasVisibleInputLabels} {#if isVertical} -
        +
        {#each data.inputs as port, i} {truncatePortLabel(port.name)} @@ -446,7 +499,7 @@ {/each}
        {:else} -
        +
        {#each data.inputs as port, i} {truncatePortLabel(port.name)} @@ -457,7 +510,7 @@ {/if} -
        +
        {#if renderedNameHtml} @@ -499,7 +552,7 @@ {#if hasVisibleOutputLabels} {#if isVertical} -
        +
        {#each data.outputs as port, i} {truncatePortLabel(port.name)} @@ -507,7 +560,7 @@ {/each}
        {:else} -
        +
        {#each data.outputs as port, i} {truncatePortLabel(port.name)} @@ -980,70 +1033,6 @@ overflow: visible; } - /* Horizontal layout: grid column placement - rotation 0 (inputs left, outputs right) */ - /* Both labels: input=1, content=2, output=3 */ - .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-column: 1; } - .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs.has-outputs .node-clip > .node-inner { grid-column: 2; } - .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-column: 3; } - /* Input labels only: input=1, content=2 */ - .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-column: 1; } - .node.show-labels:not(.vertical)[data-rotation="0"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-column: 2; } - /* Output labels only: content=1, output=2 */ - .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-column: 1; } - .node.show-labels:not(.vertical)[data-rotation="0"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-column: 2; } - - /* Horizontal layout: grid column placement - rotation 2 (inputs right, outputs left) */ - /* Both labels: output=1, content=2, input=3 */ - .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-column: 1; } - .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs.has-outputs .node-clip > .node-inner { grid-column: 2; } - .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-column: 3; } - /* Output labels only (left side): output=1, content=2 */ - .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-column: 1; } - .node.show-labels:not(.vertical)[data-rotation="2"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-column: 2; } - /* Input labels only (right side): content=1, input=2 */ - .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-column: 1; } - .node.show-labels:not(.vertical)[data-rotation="2"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-column: 2; } - - /* Horizontal borders */ - .node.show-labels:not(.vertical) .node-clip > .port-labels-input { grid-row: 1; border-right: 1px solid var(--border); } - .node.show-labels:not(.vertical) .node-clip > .node-inner { grid-row: 1; } - .node.show-labels:not(.vertical) .node-clip > .port-labels-output { grid-row: 1; border-left: 1px solid var(--border); } - /* Rotation 2: swap borders */ - .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-input { border-right: none; border-left: 1px solid var(--border); } - .node.show-labels:not(.vertical)[data-rotation="2"] .node-clip > .port-labels-output { border-left: none; border-right: 1px solid var(--border); } - - /* Vertical layout: grid row placement - rotation 1 (inputs top, outputs bottom) */ - /* Both labels: input=1, content=2, output=3 */ - .node.show-labels.vertical[data-rotation="1"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-row: 1; } - .node.show-labels.vertical[data-rotation="1"].has-inputs.has-outputs .node-clip > .node-inner { grid-row: 2; } - .node.show-labels.vertical[data-rotation="1"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-row: 3; } - /* Input labels only: input=1, content=2 */ - .node.show-labels.vertical[data-rotation="1"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-row: 1; } - .node.show-labels.vertical[data-rotation="1"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-row: 2; } - /* Output labels only: content=1, output=2 */ - .node.show-labels.vertical[data-rotation="1"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-row: 1; } - .node.show-labels.vertical[data-rotation="1"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-row: 2; } - - /* Vertical layout: grid row placement - rotation 3 (inputs bottom, outputs top) */ - /* Both labels: output=1, content=2, input=3 */ - .node.show-labels.vertical[data-rotation="3"].has-inputs.has-outputs .node-clip > .port-labels-output { grid-row: 1; } - .node.show-labels.vertical[data-rotation="3"].has-inputs.has-outputs .node-clip > .node-inner { grid-row: 2; } - .node.show-labels.vertical[data-rotation="3"].has-inputs.has-outputs .node-clip > .port-labels-input { grid-row: 3; } - /* Output labels only (top): output=1, content=2 */ - .node.show-labels.vertical[data-rotation="3"].has-outputs:not(.has-inputs) .node-clip > .port-labels-output { grid-row: 1; } - .node.show-labels.vertical[data-rotation="3"].has-outputs:not(.has-inputs) .node-clip > .node-inner { grid-row: 2; } - /* Input labels only (bottom): content=1, input=2 */ - .node.show-labels.vertical[data-rotation="3"].has-inputs:not(.has-outputs) .node-clip > .node-inner { grid-row: 1; } - .node.show-labels.vertical[data-rotation="3"].has-inputs:not(.has-outputs) .node-clip > .port-labels-input { grid-row: 2; } - - /* Vertical borders */ - .node.show-labels.vertical .node-clip > .port-labels-input { grid-column: 1; border-bottom: 1px solid var(--border); } - .node.show-labels.vertical .node-clip > .node-inner { grid-column: 1; } - .node.show-labels.vertical .node-clip > .port-labels-output { grid-column: 1; border-top: 1px solid var(--border); } - /* Rotation 3: swap borders */ - .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-input { border-bottom: none; border-top: 1px solid var(--border); } - .node.show-labels.vertical[data-rotation="3"] .node-clip > .port-labels-output { border-top: none; border-bottom: 1px solid var(--border); } - /* Individual port labels (absolute positioning for horizontal) */ .port-label { position: absolute; From 1307a387acc089b0734a771182163d5db00c0da9 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Mon, 9 Feb 2026 23:09:16 +0100 Subject: [PATCH 358/656] Add port label rendering to SVG export with auto-resolve from store --- src/lib/export/svg/renderer.ts | 185 ++++++++++++++++++++++++++++++++- 1 file changed, 180 insertions(+), 5 deletions(-) diff --git a/src/lib/export/svg/renderer.ts b/src/lib/export/svg/renderer.ts index a7e2d851..fc402f4d 100644 --- a/src/lib/export/svg/renderer.ts +++ b/src/lib/export/svg/renderer.ts @@ -12,8 +12,10 @@ import { get } from 'svelte/store'; import { graphStore } from '$lib/stores/graph'; import { eventStore } from '$lib/stores/events'; import { getThemeColors } from '$lib/constants/theme'; -import { NODE, EVENT } from '$lib/constants/dimensions'; +import { NODE, EVENT, PORT_LABEL, getPortOffset } from '$lib/constants/dimensions'; import { getHandlePath } from '$lib/constants/handlePaths'; +import { portLabelsStore } from '$lib/stores/portLabels'; +import { getEffectivePortLabelVisibility, truncatePortLabel } from '$lib/utils/portLabels'; import { latexToSvg, getSvgDimensions, preloadMathJax } from '$lib/utils/mathjaxSvg'; // Preload MathJax when module loads @@ -199,6 +201,143 @@ function renderHandles(nodeId: string, nodeX: number, nodeY: number, ctx: Render return handles.join('\n'); } +// ============================================================================ +// PORT LABEL RENDERING +// ============================================================================ + +/** + * Resolve showPortLabels option: 'auto' reads from store, otherwise use boolean value + */ +function resolveShowPortLabels(option: boolean | 'auto'): boolean { + if (option === 'auto') { + return portLabelsStore.get(); + } + return option; +} + +/** + * Render port labels for a node as SVG text + separator lines. + * Returns SVG string and the pixel offsets consumed by label columns/rows + * (used to shift content center). + */ +function renderPortLabels( + node: NodeInstance, + x: number, + y: number, + width: number, + height: number, + globalShowLabels: boolean, + ctx: RenderContext +): { svg: string; inputOffset: number; outputOffset: number } { + const { inputs: hasInputLabels, outputs: hasOutputLabels } = getEffectivePortLabelVisibility(node, globalShowLabels); + if (!hasInputLabels && !hasOutputLabels) { + return { svg: '', inputOffset: 0, outputOffset: 0 }; + } + + const parts: string[] = []; + const rotation = (node.params?.['_rotation'] as number) || 0; + const isVertical = rotation === 1 || rotation === 3; + const labelColumnWidth = PORT_LABEL.columnWidth; + + if (isVertical) { + // Vertical: label rows at top/bottom + // rotation 1: inputs top, outputs bottom + // rotation 3: inputs bottom, outputs top + const inputRowY = rotation === 1 + ? y // top + : y + height - labelColumnWidth; // bottom + const outputRowY = rotation === 1 + ? y + height - labelColumnWidth // bottom + : y; // top + + if (hasInputLabels) { + // Separator line + const sepY = rotation === 1 ? inputRowY + labelColumnWidth : inputRowY; + parts.push(``); + + // Port label text (rotated -90deg) + const centerY = inputRowY + labelColumnWidth / 2; + for (let i = 0; i < node.inputs.length; i++) { + const offset = getPortOffset(i, node.inputs.length); + const labelX = x + width / 2 + offset; + const label = truncatePortLabel(node.inputs[i].name); + parts.push(`${escapeXml(label)}`); + } + } + + if (hasOutputLabels) { + // Separator line + const sepY = rotation === 1 ? outputRowY : outputRowY + labelColumnWidth; + parts.push(``); + + // Port label text (rotated -90deg) + const centerY = outputRowY + labelColumnWidth / 2; + for (let i = 0; i < node.outputs.length; i++) { + const offset = getPortOffset(i, node.outputs.length); + const labelX = x + width / 2 + offset; + const label = truncatePortLabel(node.outputs[i].name); + parts.push(`${escapeXml(label)}`); + } + } + + return { + svg: parts.join('\n'), + inputOffset: hasInputLabels ? labelColumnWidth : 0, + outputOffset: hasOutputLabels ? labelColumnWidth : 0 + }; + } else { + // Horizontal: label columns at left/right + // rotation 0: inputs left, outputs right + // rotation 2: inputs right, outputs left + const inputColX = rotation === 0 + ? x // left + : x + width - labelColumnWidth; // right + const outputColX = rotation === 0 + ? x + width - labelColumnWidth // right + : x; // left + + if (hasInputLabels) { + // Separator line + const sepX = rotation === 0 ? inputColX + labelColumnWidth : inputColX; + parts.push(``); + + // Port label text + // rotation 0: align right (near separator), rotation 2: align left + const textAnchor = rotation === 0 ? 'end' : 'start'; + const textX = rotation === 0 ? inputColX + labelColumnWidth - 6 : inputColX + 6; + for (let i = 0; i < node.inputs.length; i++) { + const offset = getPortOffset(i, node.inputs.length); + const labelY = y + height / 2 + offset; + const label = truncatePortLabel(node.inputs[i].name); + parts.push(`${escapeXml(label)}`); + } + } + + if (hasOutputLabels) { + // Separator line + const sepX = rotation === 0 ? outputColX : outputColX + labelColumnWidth; + parts.push(``); + + // Port label text + // rotation 0: align left (near separator), rotation 2: align right + const textAnchor = rotation === 0 ? 'start' : 'end'; + const textX = rotation === 0 ? outputColX + 6 : outputColX + labelColumnWidth - 6; + for (let i = 0; i < node.outputs.length; i++) { + const offset = getPortOffset(i, node.outputs.length); + const labelY = y + height / 2 + offset; + const label = truncatePortLabel(node.outputs[i].name); + parts.push(`${escapeXml(label)}`); + } + } + + return { + svg: parts.join('\n'), + inputOffset: hasInputLabels ? labelColumnWidth : 0, + outputOffset: hasOutputLabels ? labelColumnWidth : 0 + }; + } +} + // ============================================================================ // NODE RENDERING - Pure SVG with DOM-read styles // ============================================================================ @@ -246,20 +385,56 @@ async function renderNode(node: NodeInstance, ctx: RenderContext): Promise` ); + // Port labels + const showPortLabels = resolveShowPortLabels(ctx.options.showPortLabels); + const portLabelResult = showPortLabels + ? renderPortLabels(node, x, y, width, height, showPortLabels, ctx) + : { svg: '', inputOffset: 0, outputOffset: 0 }; + if (portLabelResult.svg) { + parts.push(portLabelResult.svg); + } + // Check for pinned params section in DOM const pinnedParamsEl = nodeEl.querySelector('.pinned-params') as HTMLElement; - // Calculate content center (above pinned params if present) + // Calculate content center, accounting for port label columns/rows and pinned params + const rotation = (node.params?.['_rotation'] as number) || 0; + const isVerticalNode = rotation === 1 || rotation === 3; + + let contentCenterX = x + width / 2; let contentCenterY = y + height / 2; + + // Shift content center for port label columns/rows + if (isVerticalNode) { + // Vertical: label rows shift Y center + const topOffset = (rotation === 1 ? portLabelResult.inputOffset : portLabelResult.outputOffset); + const bottomOffset = (rotation === 1 ? portLabelResult.outputOffset : portLabelResult.inputOffset); + const contentTop = y + topOffset; + const contentBottom = y + height - bottomOffset; + contentCenterY = (contentTop + contentBottom) / 2; + } else { + // Horizontal: label columns shift X center + const leftOffset = (rotation === 0 ? portLabelResult.inputOffset : portLabelResult.outputOffset); + const rightOffset = (rotation === 0 ? portLabelResult.outputOffset : portLabelResult.inputOffset); + const contentLeft = x + leftOffset; + const contentRight = x + width - rightOffset; + contentCenterX = (contentLeft + contentRight) / 2; + } + if (pinnedParamsEl) { + // pinnedTop is from DOM, already accounts for grid layout including port label rows const pinnedRect = pinnedParamsEl.getBoundingClientRect(); - const pinnedTop = (pinnedRect.top - nodeRect.top) / zoom; - contentCenterY = y + pinnedTop / 2; + const pinnedTopFromNode = (pinnedRect.top - nodeRect.top) / zoom; + // Content area is from content start to pinned params top + const contentAreaTop = isVerticalNode + ? y + (rotation === 1 ? portLabelResult.inputOffset : portLabelResult.outputOffset) + : y; + contentCenterY = (contentAreaTop + y + pinnedTopFromNode) / 2; } // Labels if (ctx.options.showLabels) { - const centerX = x + width / 2; + const centerX = contentCenterX; if (ctx.options.showTypeLabels && nodeType) { // Name above center (may contain math) - use original node.name for LaTeX source From 1608e6aaf870aa32296eb76ca97e4dcc07db9717 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Mon, 9 Feb 2026 23:23:06 +0100 Subject: [PATCH 359/656] Add Adder operations as port labels via custom parser in portLabelParams --- src/lib/nodes/uiConfig.ts | 19 ++++++++++++++++++- src/lib/stores/graph/nodes.ts | 2 +- src/lib/stores/graph/ports.ts | 5 +++-- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/lib/nodes/uiConfig.ts b/src/lib/nodes/uiConfig.ts index f7e367e5..dc140b25 100644 --- a/src/lib/nodes/uiConfig.ts +++ b/src/lib/nodes/uiConfig.ts @@ -9,6 +9,22 @@ export interface PortLabelConfig { param: string; direction: 'input' | 'output'; + /** Optional custom parser to convert param value to label strings. + * Default uses parsePythonList (for ["a", "b"] format). */ + parser?: (value: unknown) => string[] | null; +} + +/** + * Parse an operations string into individual character labels. + * E.g. '+-' → ['+', '-'], None/null → null + */ +function parseOperationsString(value: unknown): string[] | null { + if (value === null || value === undefined || value === 'None' || value === '') { + return null; + } + const str = String(value).trim(); + if (str.length === 0) return null; + return [...str]; } /** @@ -18,7 +34,8 @@ export interface PortLabelConfig { */ export const portLabelParams: Record = { Scope: { param: 'labels', direction: 'input' }, - Spectrum: { param: 'labels', direction: 'input' } + Spectrum: { param: 'labels', direction: 'input' }, + Adder: { param: 'operations', direction: 'input', parser: parseOperationsString } }; /** diff --git a/src/lib/stores/graph/nodes.ts b/src/lib/stores/graph/nodes.ts index 7637fc53..36071911 100644 --- a/src/lib/stores/graph/nodes.ts +++ b/src/lib/stores/graph/nodes.ts @@ -211,7 +211,7 @@ export function updateNodeParams(id: string, params: Record): v if (node) { for (const config of getPortLabelConfigs(node.type)) { if (config.param in params) { - syncPortNamesFromLabels(id, params[config.param], config.direction); + syncPortNamesFromLabels(id, params[config.param], config.direction, config.parser); } } } diff --git a/src/lib/stores/graph/ports.ts b/src/lib/stores/graph/ports.ts index 503b9c88..5ddd916f 100644 --- a/src/lib/stores/graph/ports.ts +++ b/src/lib/stores/graph/ports.ts @@ -278,13 +278,14 @@ function parsePythonList(value: unknown): string[] | null { export function syncPortNamesFromLabels( nodeId: string, labelsValue: unknown, - direction: 'input' | 'output' + direction: 'input' | 'output', + parser?: (value: unknown) => string[] | null ): void { const currentGraph = getCurrentGraph(); const node = currentGraph.nodes.get(nodeId); if (!node) return; - const labels = parsePythonList(labelsValue); + const labels = parser ? parser(labelsValue) : parsePythonList(labelsValue); const config = getPortConfig(direction); const currentPorts = node[config.portsKey] as PortInstance[]; From 8c2cff3f48f600f6cc57e84a2c77b403f09a3643 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Mon, 9 Feb 2026 23:24:26 +0100 Subject: [PATCH 360/656] Fix Adder operations parser to strip surrounding Python quotes --- src/lib/nodes/uiConfig.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib/nodes/uiConfig.ts b/src/lib/nodes/uiConfig.ts index dc140b25..a697c86d 100644 --- a/src/lib/nodes/uiConfig.ts +++ b/src/lib/nodes/uiConfig.ts @@ -16,13 +16,19 @@ export interface PortLabelConfig { /** * Parse an operations string into individual character labels. - * E.g. '+-' → ['+', '-'], None/null → null + * Handles Python-style quoted strings: '+-' or "+-" → ['+', '-'] + * Also handles unquoted: +- → ['+', '-'] */ function parseOperationsString(value: unknown): string[] | null { if (value === null || value === undefined || value === 'None' || value === '') { return null; } - const str = String(value).trim(); + let str = String(value).trim(); + if (str.length === 0) return null; + // Strip surrounding Python quotes (single or double) + if ((str.startsWith("'") && str.endsWith("'")) || (str.startsWith('"') && str.endsWith('"'))) { + str = str.slice(1, -1); + } if (str.length === 0) return null; return [...str]; } From 0ca154b38225c8fc7459c81e7e36530b762308cc Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 00:13:20 +0100 Subject: [PATCH 361/656] Overhaul WelcomeModal: tiles as links, inline examples, PNG screenshots --- src/lib/components/WelcomeModal.svelte | 116 ++++++++----------------- 1 file changed, 36 insertions(+), 80 deletions(-) diff --git a/src/lib/components/WelcomeModal.svelte b/src/lib/components/WelcomeModal.svelte index 55b2d2ef..363e4d94 100644 --- a/src/lib/components/WelcomeModal.svelte +++ b/src/lib/components/WelcomeModal.svelte @@ -8,25 +8,33 @@ interface Example { name: string; - description?: string; - file: string; - previewBase: string; + description: string; + filename: string; + basename: string; } interface Props { onNew: () => void; - onLoadExample: (file: string) => void; onClose: () => void; } - let { onNew, onLoadExample, onClose }: Props = $props(); + let { onNew, onClose }: Props = $props(); + + const examples: Example[] = [ + { filename: 'feedback-system.json', basename: 'feedback-system', name: 'Feedback System', description: 'Linear feedback system with delayed step excitation' }, + { filename: 'harmonic-oscillator.json', basename: 'harmonic-oscillator', name: 'Harmonic Oscillator', description: 'Linear spring-mass-damper system' }, + { filename: 'squarewave-lpf.json', basename: 'squarewave-lpf', name: 'Squarewave LPF', description: 'Low pass filtering of a square wave' }, + { filename: 'pid-subsystem.json', basename: 'pid-subsystem', name: 'PID Loop', description: 'Classic PID control loop as subsystem' }, + { filename: 'thermostat.json', basename: 'thermostat', name: 'Thermostat', description: 'Relay based thermostat heating system' }, + { filename: 'cascade-subsystem.json', basename: 'cascade-subsystem', name: 'Cascade PI', description: 'Cascade PI controller with subsystems' }, + { filename: 'bouncing-ball.json', basename: 'bouncing-ball', name: 'Bouncing Ball', description: 'Bouncing ball with event-based collision' }, + { filename: 'fmcw-radar.json', basename: 'fmcw-radar', name: 'FMCW Radar', description: 'Frequency-modulated continuous wave radar' }, + { filename: 'vanderpol.json', basename: 'vanderpol', name: 'Van der Pol', description: 'Van der Pol oscillator system' } + ]; - let examples = $state([]); - let loading = $state(true); let isDark = $state(true); onMount(() => { - // Detect theme and watch for changes const updateTheme = () => { isDark = document.documentElement.getAttribute('data-theme') !== 'light'; }; @@ -35,41 +43,6 @@ const observer = new MutationObserver(updateTheme); observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); - // Load examples - (async () => { - try { - const manifestRes = await fetch(`${base}/examples/manifest.json`); - if (!manifestRes.ok) throw new Error('No manifest'); - const manifest = await manifestRes.json(); - const files: string[] = manifest.files || []; - - const loadedExamples = await Promise.all( - files.map(async (filename): Promise => { - try { - const fileRes = await fetch(`${base}/examples/${filename}`); - if (fileRes.ok) { - const data = await fileRes.json(); - const baseName = filename.replace('.json', ''); - return { - name: data.metadata?.name || baseName, - description: data.metadata?.description, - file: `${base}/examples/${filename}`, - previewBase: `${base}/examples/${baseName}` - }; - } - } catch (e) { - console.warn(`Could not load example: ${filename}`); - } - return null; - }) - ); - examples = loadedExamples.filter((e): e is Example => e !== null); - } catch (e) { - console.warn('Could not load examples'); - } - loading = false; - })(); - return () => observer.disconnect(); }); @@ -78,11 +51,6 @@ onClose(); } - function handleExample(file: string) { - onLoadExample(file); - onClose(); - } - function handleKeydown(e: KeyboardEvent) { if (e.key === 'Escape') { onClose(); @@ -137,29 +105,25 @@
        - {#if loading} -
        -
        Loading examples...
        +
        + - {:else if examples.length > 0} -
        -
        - {#each examples as example} - - {/each} -
        -
        - {/if} +
        @@ -244,13 +208,6 @@ margin: -16px -24px -24px -24px; } - .loading-text { - color: var(--text-disabled); - font-size: 11px; - text-align: center; - padding: 16px; - } - .examples-grid { display: grid; grid-template-columns: repeat(3, 1fr); @@ -276,6 +233,8 @@ text-align: left; overflow: hidden; font-family: inherit; + text-decoration: none; + color: inherit; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); transition: border-color 0.15s ease, box-shadow 0.15s ease; } @@ -285,7 +244,6 @@ box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); } - .example-info { position: absolute; top: 0; @@ -300,7 +258,6 @@ transition: padding 0.15s ease; } - .example-name { font-size: 11px; font-weight: 500; @@ -351,5 +308,4 @@ grid-template-columns: 1fr; } } - From 387ce9af68be031db1413e1782decf010470a8f8 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 00:13:50 +0100 Subject: [PATCH 362/656] Remove onLoadExample prop from WelcomeModal usage --- src/routes/+page.svelte | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index e9de62d2..d97071fd 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -856,15 +856,6 @@ } } - // Load example file - async function handleLoadExample(url: string) { - const result = await importFromUrl(url); - if (result.success) { - // Trigger fit view after a brief delay to let nodes render - setTimeout(() => triggerFitView(), 100); - } - } - /** * Expand GitHub shorthand to raw.githubusercontent.com URL * Format: owner/repo/path/to/file.pvm @@ -1362,7 +1353,6 @@ {#if showWelcomeModal} showWelcomeModal = false} /> {/if} From a11e0cb0806ed75d71b4c7c9ba3ac1215e57d8ad Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 00:14:41 +0100 Subject: [PATCH 363/656] Add screenshot capture script, puppeteer-core dep, and gitignore entry --- .gitignore | 3 + package-lock.json | 876 ++++++++++++++++++++++++++++++++- package.json | 4 +- scripts/capture-screenshots.js | 88 ++++ 4 files changed, 967 insertions(+), 4 deletions(-) create mode 100644 scripts/capture-screenshots.js diff --git a/.gitignore b/.gitignore index 91d8dfa3..b4947e25 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ tmpclaude-* __pycache__/ + +# Generated screenshots +static/examples/screenshots/ diff --git a/package-lock.json b/package-lock.json index ebc8bac7..81f745f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "eslint-plugin-svelte": "^2.0.0", "prettier": "^3.0.0", "prettier-plugin-svelte": "^3.0.0", + "puppeteer-core": "^24.37.2", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "typescript": "^5.0.0", @@ -871,6 +872,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@puppeteer/browsers": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.12.0.tgz", + "integrity": "sha512-Xuq42yxcQJ54ti8ZHNzF5snFvtpgXzNToJ1bXUGQRaiO8t+B6UM8sTUJfvV+AJnqtkJU/7hdy6nbKyA12aHtRw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.3", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.53.5", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz", @@ -1220,6 +1243,7 @@ "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1259,6 +1283,7 @@ "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -1292,6 +1317,13 @@ "vite": "^6.3.0 || ^7.0.0" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -1367,6 +1399,7 @@ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1385,6 +1418,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@xyflow/svelte": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-1.5.0.tgz", @@ -1420,6 +1464,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1437,6 +1482,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1454,6 +1509,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1486,6 +1551,19 @@ "node": ">= 0.4" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1495,6 +1573,21 @@ "node": ">= 0.4" } }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1502,6 +1595,113 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.3.tgz", + "integrity": "sha512-9+kwVx8QYvt3hPWnmb19tPnh38c6Nihz8Lx3t0g9+4GoIf3/fTgYwM4Z6NxgI+B9elLQA7mLE9PpqcWtOMRDiQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/basic-ftp": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", + "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -1513,6 +1713,16 @@ "concat-map": "0.0.1" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1556,6 +1766,35 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chromium-bidi": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-13.1.1.tgz", + "integrity": "sha512-zB9MpoPd7VJwjowQqiW3FKOvQwffFMjQ8Iejp5ZW+sJaKLRhZX1sTxzl3Zt22TDB4zP0OOqs8lRoY7eAW5geyQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1717,6 +1956,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -1765,6 +2005,16 @@ "node": ">=12" } }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1800,12 +2050,52 @@ "node": ">=0.10.0" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/devalue": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.1.tgz", "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==", "license": "MIT" }, + "node_modules/devtools-protocol": { + "version": "0.0.1566079", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", + "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -1848,6 +2138,16 @@ "@esbuild/win32-x64": "0.27.2" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1861,12 +2161,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { "version": "9.39.2", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2026,6 +2349,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -2081,6 +2418,37 @@ "node": ">=0.10.0" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2088,6 +2456,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2102,6 +2477,16 @@ "dev": true, "license": "MIT" }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2186,6 +2571,47 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2227,6 +2653,34 @@ "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.5.tgz", "integrity": "sha512-G7HLD+WKcrOyJP5VQwYZNC3Z6FcQ7YYjEFiFoIj8PfEr73mu421o8B1N5DKUcc8K37EsJ2XXWA8DtrDz/2dReg==" }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2264,6 +2718,16 @@ "node": ">=0.8.19" } }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2274,6 +2738,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -2433,6 +2907,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2455,6 +2939,13 @@ "node": "*" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -2508,6 +2999,26 @@ "dev": true, "license": "MIT" }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2558,6 +3069,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2599,6 +3144,13 @@ "heap": "0.2.5" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2612,6 +3164,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2645,6 +3198,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2768,6 +3322,7 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -2789,6 +3344,54 @@ "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2799,6 +3402,25 @@ "node": ">=6" } }, + "node_modules/puppeteer-core": { + "version": "24.37.2", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.2.tgz", + "integrity": "sha512-nN8qwE3TGF2vA/+xemPxbesntTuqD9vCGOiZL2uh8HES3pPzLX20MyQjB42dH2rhQ3W3TljZ4ZaKZ0yX/abQuw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.12.0", + "chromium-bidi": "13.1.1", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1566079", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.4.0", + "ws": "^8.19.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/pyodide": { "version": "0.26.4", "resolved": "https://registry.npmjs.org/pyodide/-/pyodide-0.26.4.tgz", @@ -2825,6 +3447,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2948,6 +3580,58 @@ "node": ">=18" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2958,6 +3642,46 @@ "node": ">=0.10.0" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2995,6 +3719,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.0.tgz", "integrity": "sha512-ZhLtvroYxUxr+HQJfMZEDRsGsmU46x12RvAv/zi9584f5KOX7bUrEbhPJ7cKFmUvZTJXi/CFZUYwDC6M1FigPw==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3116,6 +3841,43 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3143,6 +3905,13 @@ "node": ">=6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3156,12 +3925,20 @@ "node": ">= 0.8.0" } }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3200,6 +3977,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3295,6 +4073,13 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.0.tgz", + "integrity": "sha512-U9VIlNRrq94d1xxR9JrCEAx5Gv/2W7ERSv8oWRoNe/QYbfccS0V3h/H6qeNeCRJxXGMhhnkqvwNrvPAYeuP9VA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3321,10 +4106,35 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3342,6 +4152,56 @@ } } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3360,6 +4220,16 @@ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 2ee3452e..91b087e5 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "extract:validate": "python scripts/extract.py --validate", "pvm2py": "python scripts/pvm2py.py", "dev": "vite dev", - "build": "vite build", + "screenshots": "node scripts/capture-screenshots.js", + "build": "node scripts/capture-screenshots.js && vite build", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", @@ -30,6 +31,7 @@ "eslint-plugin-svelte": "^2.0.0", "prettier": "^3.0.0", "prettier-plugin-svelte": "^3.0.0", + "puppeteer-core": "^24.37.2", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "typescript": "^5.0.0", diff --git a/scripts/capture-screenshots.js b/scripts/capture-screenshots.js new file mode 100644 index 00000000..4cd31df4 --- /dev/null +++ b/scripts/capture-screenshots.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node + +/** + * Captures screenshots of example models for welcome modal tiles. + * Captures both dark and light themes using ?theme= URL parameter. + * Run with: npm run screenshots + */ + +import puppeteer from 'puppeteer-core'; +import { readFileSync, existsSync, mkdirSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const STATIC_DIR = join(__dirname, '..', 'static', 'examples'); +const SCREENSHOTS_DIR = join(STATIC_DIR, 'screenshots'); +const MANIFEST_PATH = join(STATIC_DIR, 'manifest.json'); + +const BASE_URL = 'https://view.pathsim.org'; +const VIEWPORT = { width: 800, height: 500 }; +const DEVICE_SCALE_FACTOR = 2; +const SETTLE_DELAY = 1500; +const THEMES = ['dark', 'light']; + +async function captureScreenshot(browser, filename, theme) { + const basename = filename.replace('.json', ''); + const url = `${BASE_URL}?model=examples/${filename}&theme=${theme}`; + console.log(` ${basename} ${theme}...`); + + const page = await browser.newPage(); + await page.setViewport({ width: VIEWPORT.width, height: VIEWPORT.height, deviceScaleFactor: DEVICE_SCALE_FACTOR }); + + try { + await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 }); + await new Promise((resolve) => setTimeout(resolve, SETTLE_DELAY)); + + const outputPath = join(SCREENSHOTS_DIR, `${basename}-${theme}.png`); + await page.screenshot({ path: outputPath, type: 'png' }); + console.log(` Saved: ${basename}-${theme}.png`); + } catch (error) { + console.error(` Error: ${error.message}`); + } finally { + await page.close(); + } +} + +async function main() { + if (!existsSync(MANIFEST_PATH)) { + console.error('manifest.json not found at', MANIFEST_PATH); + process.exit(1); + } + + const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf-8')); + const files = manifest.files || []; + + if (files.length === 0) { + console.log('No example files in manifest.'); + return; + } + + if (!existsSync(SCREENSHOTS_DIR)) { + mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + } + + console.log('Launching browser...'); + const browser = await puppeteer.launch({ + headless: true, + channel: 'chrome', + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + + try { + for (const filename of files) { + for (const theme of THEMES) { + await captureScreenshot(browser, filename, theme); + } + } + } finally { + await browser.close(); + } + + console.log('\nDone!'); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); From e0f176d807660fc70d71e4679ad93249b8eb85f4 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 00:26:29 +0100 Subject: [PATCH 364/656] Clear URL params when creating a new model --- src/routes/+page.svelte | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index d97071fd..7aaad8bc 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -845,6 +845,10 @@ if (!confirmed) return; } newGraph(); + // Clear ?model= URL param so the URL reflects a blank canvas + if (window.location.search) { + window.history.replaceState({}, '', window.location.pathname); + } } async function handleOpen() { From 59e2cef196cd18a0aace240d53ce0fde28f73adb Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 00:27:57 +0100 Subject: [PATCH 365/656] Adjust screenshot viewport to 1000x600 at 1x scale --- scripts/capture-screenshots.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/capture-screenshots.js b/scripts/capture-screenshots.js index 4cd31df4..ce35bb5d 100644 --- a/scripts/capture-screenshots.js +++ b/scripts/capture-screenshots.js @@ -17,8 +17,8 @@ const SCREENSHOTS_DIR = join(STATIC_DIR, 'screenshots'); const MANIFEST_PATH = join(STATIC_DIR, 'manifest.json'); const BASE_URL = 'https://view.pathsim.org'; -const VIEWPORT = { width: 800, height: 500 }; -const DEVICE_SCALE_FACTOR = 2; +const VIEWPORT = { width: 1000, height: 600 }; +const DEVICE_SCALE_FACTOR = 1; const SETTLE_DELAY = 1500; const THEMES = ['dark', 'light']; From 2608a3574557c3717ed2600ab9f91b55ea913682 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 9 Feb 2026 23:39:42 +0000 Subject: [PATCH 366/656] Bump version to 0.5.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 81f745f7..024f57ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pathview", - "version": "0.4.12", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pathview", - "version": "0.4.12", + "version": "0.5.0", "dependencies": { "@codemirror/lang-python": "^6.0.0", "@codemirror/theme-one-dark": "^6.0.0", diff --git a/package.json b/package.json index 91b087e5..d8024cc1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pathview", - "version": "0.4.12", + "version": "0.5.0", "private": true, "type": "module", "scripts": { From 0205c41f32432c8134db6d6094fe33e9bdcc571e Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 11:17:57 +0100 Subject: [PATCH 367/656] Add REPL worker subprocess for Flask backend Co-Authored-By: KDW1 --- server/worker.py | 275 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 server/worker.py diff --git a/server/worker.py b/server/worker.py new file mode 100644 index 00000000..76c2bdcc --- /dev/null +++ b/server/worker.py @@ -0,0 +1,275 @@ +""" +REPL Worker Subprocess for PathView Flask Backend. + +Direct port of worker.ts to Python. Reads JSON messages from stdin, +executes Python code, and writes JSON responses to stdout. + +Same message protocol (REPLRequest/REPLResponse as JSON lines over stdin/stdout). + +Threading model: +- Reader thread: reads JSON from stdin, dispatches to main thread or queues for streaming +- Main thread: processes init/exec/eval synchronously; for streaming, runs the loop +- Stdout lock: thread-safe writing to stdout (protocol messages only) +""" + +import sys +import io +import json +import threading +import traceback +import queue + +# Lock for thread-safe stdout writing (protocol messages only) +_stdout_lock = threading.Lock() + +# Worker state +_namespace = {} +_clean_globals = set() +_initialized = False + +# Streaming state +_streaming_active = False +_streaming_code_queue = queue.Queue() + +# Queue for messages from reader thread -> main thread +_message_queue = queue.Queue() + + +def send(response: dict) -> None: + """Send a JSON response to the parent process via stdout.""" + with _stdout_lock: + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + + +def _capture_output(func): + """Run func with stdout/stderr captured, sending output as messages.""" + old_stdout = sys.stdout + old_stderr = sys.stderr + captured_out = io.StringIO() + captured_err = io.StringIO() + sys.stdout = captured_out + sys.stderr = captured_err + try: + result = func() + finally: + sys.stdout = old_stdout + sys.stderr = old_stderr + out = captured_out.getvalue() + err = captured_err.getvalue() + if out: + send({"type": "stdout", "value": out}) + if err: + send({"type": "stderr", "value": err}) + return result + + +def initialize() -> None: + """Initialize the worker: import standard packages, capture clean globals.""" + global _initialized, _namespace, _clean_globals + + if _initialized: + send({"type": "ready"}) + return + + send({"type": "progress", "value": "Initializing Python worker..."}) + + # Set up the namespace with common imports + _namespace = {"__builtins__": __builtins__} + exec("import numpy as np", _namespace) + exec("import gc", _namespace) + exec("import json", _namespace) + + # Capture clean state for later cleanup + _clean_globals = set(_namespace.keys()) + + _initialized = True + send({"type": "ready"}) + + +def exec_code(msg_id: str, code: str) -> None: + """Execute Python code (no return value).""" + if not _initialized: + send({"type": "error", "id": msg_id, "error": "Worker not initialized"}) + return + + try: + def run(): + exec(code, _namespace) + _capture_output(run) + send({"type": "ok", "id": msg_id}) + except Exception as e: + tb = traceback.format_exc() + send({"type": "error", "id": msg_id, "error": str(e), "traceback": tb}) + + +def eval_expr(msg_id: str, expr: str) -> None: + """Evaluate Python expression and return JSON result.""" + if not _initialized: + send({"type": "error", "id": msg_id, "error": "Worker not initialized"}) + return + + try: + def run(): + # Mirror worker.ts: store result, then JSON-serialize + exec_code_str = f"_eval_result = {expr}" + exec(exec_code_str, _namespace) + to_json = _namespace.get("_to_json", str) + return json.dumps(_namespace["_eval_result"], default=to_json) + result = _capture_output(run) + send({"type": "value", "id": msg_id, "value": result}) + except Exception as e: + tb = traceback.format_exc() + send({"type": "error", "id": msg_id, "error": str(e), "traceback": tb}) + + +def run_streaming_loop(msg_id: str, expr: str) -> None: + """Run streaming loop - steps generator continuously and posts results.""" + global _streaming_active + + if not _initialized: + send({"type": "error", "id": msg_id, "error": "Worker not initialized"}) + return + + _streaming_active = True + # Clear any stale code from previous runs + while not _streaming_code_queue.empty(): + try: + _streaming_code_queue.get_nowait() + except queue.Empty: + break + + try: + while _streaming_active: + # Execute any queued code first (for runtime parameter changes) + # Errors in queued code are reported but don't stop the simulation + while True: + try: + code = _streaming_code_queue.get_nowait() + except queue.Empty: + break + try: + def run_queued(c=code): + exec(c, _namespace) + _capture_output(run_queued) + except Exception as e: + send({"type": "stderr", "value": f"Stream exec error: {e}"}) + + # Step the generator + def run_step(): + exec_code_str = f"_eval_result = {expr}" + exec(exec_code_str, _namespace) + to_json = _namespace.get("_to_json", str) + return json.dumps(_namespace["_eval_result"], default=to_json) + result = _capture_output(run_step) + + # Parse result + parsed = json.loads(result) + + # Check if stopped during Python execution - still send final data + if not _streaming_active: + if not parsed.get("done") and parsed.get("result"): + send({"type": "stream-data", "id": msg_id, "value": result}) + break + + # Check if simulation completed + if parsed.get("done"): + break + + # Send result and continue + send({"type": "stream-data", "id": msg_id, "value": result}) + + except Exception as e: + tb = traceback.format_exc() + send({"type": "error", "id": msg_id, "error": str(e), "traceback": tb}) + finally: + _streaming_active = False + # Always send done when loop ends + send({"type": "stream-done", "id": msg_id}) + + +def stop_streaming() -> None: + """Stop the streaming loop.""" + global _streaming_active + _streaming_active = False + + +def reader_thread() -> None: + """Read JSON messages from stdin and dispatch to the main thread.""" + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + send({"type": "error", "error": f"Invalid JSON: {line}"}) + continue + + msg_type = msg.get("type") + + # stream-stop and stream-exec are handled directly (thread-safe) + if msg_type == "stream-stop": + stop_streaming() + elif msg_type == "stream-exec": + code = msg.get("code") + if code and _streaming_active: + _streaming_code_queue.put(code) + else: + # All other messages go to main thread + _message_queue.put(msg) + + # stdin closed — signal main thread to exit + _message_queue.put(None) + + +def main() -> None: + """Main loop: process messages from the reader thread.""" + # Start the reader thread + t = threading.Thread(target=reader_thread, daemon=True) + t.start() + + while True: + msg = _message_queue.get() + if msg is None: + # stdin closed, exit + break + + msg_type = msg.get("type") + msg_id = msg.get("id") + code = msg.get("code") + expr = msg.get("expr") + + try: + if msg_type == "init": + initialize() + + elif msg_type == "exec": + if not msg_id or not isinstance(code, str): + raise ValueError("Invalid exec request: missing id or code") + exec_code(msg_id, code) + + elif msg_type == "eval": + if not msg_id or not isinstance(expr, str): + raise ValueError("Invalid eval request: missing id or expr") + eval_expr(msg_id, expr) + + elif msg_type == "stream-start": + if not msg_id or not isinstance(expr, str): + raise ValueError("Invalid stream-start request: missing id or expr") + # Run in the main thread (blocking until done/stopped) + run_streaming_loop(msg_id, expr) + + else: + raise ValueError(f"Unknown message type: {msg_type}") + + except Exception as e: + send({ + "type": "error", + "id": msg_id, + "error": str(e), + }) + + +if __name__ == "__main__": + main() From fd735ffd27318544a269e735e6674190e87019c5 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 11:42:19 +0100 Subject: [PATCH 368/656] Add Flask server and fix worker threading for Windows subprocess pipes Co-Authored-By: KDW1 --- server/app.py | 358 ++++++++++++++++++++++++++++++++++++++++ server/requirements.txt | 2 + server/worker.py | 100 ++++++----- 3 files changed, 419 insertions(+), 41 deletions(-) create mode 100644 server/app.py create mode 100644 server/requirements.txt diff --git a/server/app.py b/server/app.py new file mode 100644 index 00000000..76cb254e --- /dev/null +++ b/server/app.py @@ -0,0 +1,358 @@ +""" +Flask server for PathView backend. + +Manages worker subprocesses per session. Routes translate HTTP requests +into subprocess messages and relay responses back. + +Each session gets its own worker subprocess with an isolated Python namespace. +""" + +import os +import sys +import json +import subprocess +import threading +import time +import uuid +import atexit +from pathlib import Path + +from flask import Flask, Response, request, jsonify +from flask_cors import CORS + +app = Flask(__name__) +CORS(app) + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +SESSION_TTL = 3600 # 1 hour of inactivity before cleanup +CLEANUP_INTERVAL = 60 # Check for stale sessions every 60 seconds +REQUEST_TIMEOUT = 300 # 5 minutes default timeout for exec/eval +WORKER_SCRIPT = str(Path(__file__).parent / "worker.py") + +# --------------------------------------------------------------------------- +# Session management +# --------------------------------------------------------------------------- + +class Session: + """A worker subprocess bound to a session.""" + + def __init__(self, session_id: str): + self.session_id = session_id + self.last_active = time.time() + self.lock = threading.Lock() + self.process = subprocess.Popen( + [sys.executable, "-u", WORKER_SCRIPT], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, # line buffered + ) + self._initialized = False + + def send_message(self, msg: dict) -> None: + """Write a JSON message to the subprocess stdin.""" + self.last_active = time.time() + line = json.dumps(msg) + "\n" + self.process.stdin.write(line) + self.process.stdin.flush() + + def read_line(self) -> dict | None: + """Read one JSON line from the subprocess stdout.""" + line = self.process.stdout.readline() + if not line: + return None + return json.loads(line.strip()) + + def ensure_initialized(self) -> list[dict]: + """Initialize the worker if not already done. Returns any messages received.""" + if self._initialized: + return [] + messages = [] + self.send_message({"type": "init"}) + while True: + resp = self.read_line() + if resp is None: + raise RuntimeError("Worker process died during initialization") + messages.append(resp) + if resp.get("type") == "ready": + self._initialized = True + break + if resp.get("type") == "error": + raise RuntimeError(resp.get("error", "Unknown init error")) + return messages + + def is_alive(self) -> bool: + return self.process.poll() is None + + def kill(self) -> None: + """Kill the subprocess.""" + try: + self.process.stdin.close() + except Exception: + pass + try: + self.process.kill() + self.process.wait(timeout=5) + except Exception: + pass + + +# Global session store +_sessions: dict[str, Session] = {} +_sessions_lock = threading.Lock() + + +def get_or_create_session(session_id: str) -> Session: + """Get an existing session or create a new one.""" + with _sessions_lock: + session = _sessions.get(session_id) + if session and not session.is_alive(): + # Dead process, remove stale entry + _sessions.pop(session_id, None) + session = None + if session is None: + session = Session(session_id) + _sessions[session_id] = session + return session + + +def remove_session(session_id: str) -> None: + """Kill and remove a session.""" + with _sessions_lock: + session = _sessions.pop(session_id, None) + if session: + session.kill() + + +def cleanup_stale_sessions() -> None: + """Remove sessions that have been inactive beyond TTL.""" + while True: + time.sleep(CLEANUP_INTERVAL) + now = time.time() + stale = [] + with _sessions_lock: + for sid, session in _sessions.items(): + if now - session.last_active > SESSION_TTL: + stale.append(sid) + for sid in stale: + remove_session(sid) + + +# Start cleanup thread +_cleanup_thread = threading.Thread(target=cleanup_stale_sessions, daemon=True) +_cleanup_thread.start() + + +def _get_session_id() -> str: + """Extract session ID from request headers or generate one.""" + return request.headers.get("X-Session-ID") or str(uuid.uuid4()) + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +@app.route("/api/health", methods=["GET"]) +def health(): + return jsonify({"status": "ok"}) + + +@app.route("/api/exec", methods=["POST"]) +def api_exec(): + """Execute Python code in the session's worker.""" + session_id = _get_session_id() + data = request.get_json(force=True) + code = data.get("code", "") + msg_id = data.get("id", str(uuid.uuid4())) + + session = get_or_create_session(session_id) + with session.lock: + try: + session.ensure_initialized() + session.send_message({"type": "exec", "id": msg_id, "code": code}) + + # Collect responses until we get ok/error for this id + stdout_lines = [] + stderr_lines = [] + while True: + resp = session.read_line() + if resp is None: + return jsonify({"type": "error", "id": msg_id, "error": "Worker process died"}), 500 + resp_type = resp.get("type") + if resp_type == "stdout": + stdout_lines.append(resp.get("value", "")) + elif resp_type == "stderr": + stderr_lines.append(resp.get("value", "")) + elif resp_type == "ok" and resp.get("id") == msg_id: + result = {"type": "ok", "id": msg_id} + if stdout_lines: + result["stdout"] = "".join(stdout_lines) + if stderr_lines: + result["stderr"] = "".join(stderr_lines) + return jsonify(result) + elif resp_type == "error" and resp.get("id") == msg_id: + result = resp + if stdout_lines: + result["stdout"] = "".join(stdout_lines) + if stderr_lines: + result["stderr"] = "".join(stderr_lines) + return jsonify(result), 400 + + except Exception as e: + return jsonify({"type": "error", "id": msg_id, "error": str(e)}), 500 + + +@app.route("/api/eval", methods=["POST"]) +def api_eval(): + """Evaluate a Python expression in the session's worker.""" + session_id = _get_session_id() + data = request.get_json(force=True) + expr = data.get("expr", "") + msg_id = data.get("id", str(uuid.uuid4())) + + session = get_or_create_session(session_id) + with session.lock: + try: + session.ensure_initialized() + session.send_message({"type": "eval", "id": msg_id, "expr": expr}) + + stdout_lines = [] + stderr_lines = [] + while True: + resp = session.read_line() + if resp is None: + return jsonify({"type": "error", "id": msg_id, "error": "Worker process died"}), 500 + resp_type = resp.get("type") + if resp_type == "stdout": + stdout_lines.append(resp.get("value", "")) + elif resp_type == "stderr": + stderr_lines.append(resp.get("value", "")) + elif resp_type == "value" and resp.get("id") == msg_id: + result = resp + if stdout_lines: + result["stdout"] = "".join(stdout_lines) + if stderr_lines: + result["stderr"] = "".join(stderr_lines) + return jsonify(result) + elif resp_type == "error" and resp.get("id") == msg_id: + result = resp + if stdout_lines: + result["stdout"] = "".join(stdout_lines) + if stderr_lines: + result["stderr"] = "".join(stderr_lines) + return jsonify(result), 400 + + except Exception as e: + return jsonify({"type": "error", "id": msg_id, "error": str(e)}), 500 + + +@app.route("/api/stream", methods=["POST"]) +def api_stream(): + """Start a streaming simulation, returning results as SSE.""" + session_id = _get_session_id() + data = request.get_json(force=True) + expr = data.get("expr", "") + msg_id = data.get("id", str(uuid.uuid4())) + + session = get_or_create_session(session_id) + + def generate(): + with session.lock: + try: + session.ensure_initialized() + session.send_message({"type": "stream-start", "id": msg_id, "expr": expr}) + + while True: + resp = session.read_line() + if resp is None: + yield f"event: error\ndata: {json.dumps({'error': 'Worker process died'})}\n\n" + break + resp_type = resp.get("type") + + if resp_type == "stream-data": + yield f"event: data\ndata: {json.dumps({'done': False, 'result': json.loads(resp.get('value', '{}'))})}\n\n" + elif resp_type == "stream-done": + yield f"event: done\ndata: {{}}\n\n" + break + elif resp_type == "stdout": + yield f"event: stdout\ndata: {json.dumps(resp.get('value', ''))}\n\n" + elif resp_type == "stderr": + yield f"event: stderr\ndata: {json.dumps(resp.get('value', ''))}\n\n" + elif resp_type == "error": + yield f"event: error\ndata: {json.dumps({'error': resp.get('error', ''), 'traceback': resp.get('traceback', '')})}\n\n" + break + + except Exception as e: + yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n" + + return Response( + generate(), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + +@app.route("/api/stream/exec", methods=["POST"]) +def api_stream_exec(): + """Queue code to execute during an active stream.""" + session_id = _get_session_id() + data = request.get_json(force=True) + code = data.get("code", "") + + session = get_or_create_session(session_id) + try: + session.send_message({"type": "stream-exec", "code": code}) + return jsonify({"status": "queued"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/stream/stop", methods=["POST"]) +def api_stream_stop(): + """Stop an active streaming session.""" + session_id = _get_session_id() + + session = get_or_create_session(session_id) + try: + session.send_message({"type": "stream-stop"}) + return jsonify({"status": "stopped"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/session", methods=["DELETE"]) +def api_session_delete(): + """Kill a session's worker subprocess.""" + session_id = _get_session_id() + remove_session(session_id) + return jsonify({"status": "terminated"}) + + +# --------------------------------------------------------------------------- +# Cleanup on exit +# --------------------------------------------------------------------------- + +@atexit.register +def _cleanup_all_sessions(): + with _sessions_lock: + for session in _sessions.values(): + session.kill() + _sessions.clear() + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 5000)) + debug = os.environ.get("FLASK_DEBUG", "1") == "1" + print(f"PathView Flask backend starting on port {port}") + app.run(host="0.0.0.0", port=port, debug=debug, threaded=True) diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 00000000..3535b452 --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,2 @@ +flask>=3.0 +flask-cors>=4.0 diff --git a/server/worker.py b/server/worker.py index 76c2bdcc..6040cc03 100644 --- a/server/worker.py +++ b/server/worker.py @@ -7,8 +7,9 @@ Same message protocol (REPLRequest/REPLResponse as JSON lines over stdin/stdout). Threading model: -- Reader thread: reads JSON from stdin, dispatches to main thread or queues for streaming -- Main thread: processes init/exec/eval synchronously; for streaming, runs the loop +- Main thread: reads stdin, processes init/exec/eval synchronously +- During streaming: a reader thread handles stream-stop and stream-exec + while the main thread runs the streaming loop - Stdout lock: thread-safe writing to stdout (protocol messages only) """ @@ -31,9 +32,6 @@ _streaming_active = False _streaming_code_queue = queue.Queue() -# Queue for messages from reader thread -> main thread -_message_queue = queue.Queue() - def send(response: dict) -> None: """Send a JSON response to the parent process via stdout.""" @@ -64,6 +62,17 @@ def _capture_output(func): return result +def read_message(): + """Read one JSON message from stdin. Returns None on EOF.""" + line = sys.stdin.readline() + if not line: + return None + line = line.strip() + if not line: + return read_message() # skip blank lines + return json.loads(line) + + def initialize() -> None: """Initialize the worker: import standard packages, capture clean globals.""" global _initialized, _namespace, _clean_globals @@ -123,6 +132,34 @@ def run(): send({"type": "error", "id": msg_id, "error": str(e), "traceback": tb}) +def _streaming_reader_thread(stop_event: threading.Event) -> None: + """Read stdin during streaming, handling stream-stop and stream-exec.""" + global _streaming_active + while not stop_event.is_set(): + line = sys.stdin.readline() + if not line: + # EOF — stop streaming and signal main loop to exit + _streaming_active = False + break + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + send({"type": "error", "error": f"Invalid JSON: {line}"}) + continue + + msg_type = msg.get("type") + if msg_type == "stream-stop": + _streaming_active = False + elif msg_type == "stream-exec": + code = msg.get("code") + if code and _streaming_active: + _streaming_code_queue.put(code) + # Other messages during streaming are ignored (shouldn't happen) + + def run_streaming_loop(msg_id: str, expr: str) -> None: """Run streaming loop - steps generator continuously and posts results.""" global _streaming_active @@ -139,6 +176,11 @@ def run_streaming_loop(msg_id: str, expr: str) -> None: except queue.Empty: break + # Start reader thread to handle stream-stop and stream-exec + stop_event = threading.Event() + reader = threading.Thread(target=_streaming_reader_thread, args=(stop_event,), daemon=True) + reader.start() + try: while _streaming_active: # Execute any queued code first (for runtime parameter changes) @@ -184,6 +226,7 @@ def run_step(): send({"type": "error", "id": msg_id, "error": str(e), "traceback": tb}) finally: _streaming_active = False + stop_event.set() # Always send done when loop ends send({"type": "stream-done", "id": msg_id}) @@ -194,43 +237,10 @@ def stop_streaming() -> None: _streaming_active = False -def reader_thread() -> None: - """Read JSON messages from stdin and dispatch to the main thread.""" - for line in sys.stdin: - line = line.strip() - if not line: - continue - try: - msg = json.loads(line) - except json.JSONDecodeError: - send({"type": "error", "error": f"Invalid JSON: {line}"}) - continue - - msg_type = msg.get("type") - - # stream-stop and stream-exec are handled directly (thread-safe) - if msg_type == "stream-stop": - stop_streaming() - elif msg_type == "stream-exec": - code = msg.get("code") - if code and _streaming_active: - _streaming_code_queue.put(code) - else: - # All other messages go to main thread - _message_queue.put(msg) - - # stdin closed — signal main thread to exit - _message_queue.put(None) - - def main() -> None: - """Main loop: process messages from the reader thread.""" - # Start the reader thread - t = threading.Thread(target=reader_thread, daemon=True) - t.start() - + """Main loop: read messages from stdin and process them.""" while True: - msg = _message_queue.get() + msg = read_message() if msg is None: # stdin closed, exit break @@ -257,9 +267,17 @@ def main() -> None: elif msg_type == "stream-start": if not msg_id or not isinstance(expr, str): raise ValueError("Invalid stream-start request: missing id or expr") - # Run in the main thread (blocking until done/stopped) + # Blocking: runs streaming loop, reader thread handles + # stream-stop and stream-exec during this time run_streaming_loop(msg_id, expr) + elif msg_type == "stream-stop": + stop_streaming() + + elif msg_type == "stream-exec": + if isinstance(code, str) and _streaming_active: + _streaming_code_queue.put(code) + else: raise ValueError(f"Unknown message type: {msg_type}") From a32539c00ae1cc6a2d05d4b7d1c88aa63e9f903b Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 11:43:44 +0100 Subject: [PATCH 369/656] Add FlaskBackend class implementing Backend interface via HTTP/SSE Co-Authored-By: KDW1 --- src/lib/pyodide/backend/flask/backend.ts | 457 +++++++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 src/lib/pyodide/backend/flask/backend.ts diff --git a/src/lib/pyodide/backend/flask/backend.ts b/src/lib/pyodide/backend/flask/backend.ts new file mode 100644 index 00000000..842d1598 --- /dev/null +++ b/src/lib/pyodide/backend/flask/backend.ts @@ -0,0 +1,457 @@ +/** + * Flask Backend + * Implements the Backend interface using a Flask server with subprocess workers + */ + +import type { Backend, BackendState } from '../types'; +import { backendState } from '../state'; +import { TIMEOUTS } from '$lib/constants/python'; +import { STATUS_MESSAGES } from '$lib/constants/messages'; + +/** + * Flask Backend Implementation + * + * Communicates with a Flask server that manages Python subprocess workers. + * Each browser session gets its own isolated Python process on the server. + * Supports streaming via Server-Sent Events (SSE). + */ +export class FlaskBackend implements Backend { + private host: string; + private sessionId: string; + private messageId = 0; + private _isStreaming = false; + private streamAbortController: AbortController | null = null; + + // Stream state + private streamState: { + onData: ((data: unknown) => void) | null; + onDone: (() => void) | null; + onError: ((error: Error) => void) | null; + } = { onData: null, onDone: null, onError: null }; + + // Output callbacks + private stdoutCallback: ((value: string) => void) | null = null; + private stderrCallback: ((value: string) => void) | null = null; + + constructor(host: string) { + this.host = host.replace(/\/$/, ''); // strip trailing slash + // Get or create session ID from sessionStorage + const stored = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('flask-session-id') : null; + if (stored) { + this.sessionId = stored; + } else { + this.sessionId = crypto.randomUUID(); + if (typeof sessionStorage !== 'undefined') { + sessionStorage.setItem('flask-session-id', this.sessionId); + } + } + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + async init(): Promise { + const state = this.getState(); + if (state.initialized || state.loading) return; + + backendState.update((s) => ({ + ...s, + loading: true, + error: null, + progress: 'Connecting to Flask server...' + })); + + try { + // Health check + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), TIMEOUTS.INIT); + + const resp = await fetch(`${this.host}/api/health`, { + signal: controller.signal + }); + clearTimeout(timeout); + + if (!resp.ok) { + throw new Error(`Server health check failed: ${resp.status}`); + } + + // Trigger worker initialization with a no-op exec + backendState.update((s) => ({ ...s, progress: 'Initializing Python worker...' })); + await this.exec('pass', TIMEOUTS.INIT); + + backendState.update((s) => ({ + ...s, + initialized: true, + loading: false, + progress: STATUS_MESSAGES.READY + })); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + backendState.update((s) => ({ + ...s, + loading: false, + error: `Flask backend error: ${msg}` + })); + throw error; + } + } + + terminate(): void { + // Abort any active stream + if (this.streamAbortController) { + this.streamAbortController.abort(); + this.streamAbortController = null; + } + + // Clear stream state + this._isStreaming = false; + this.streamState = { onData: null, onDone: null, onError: null }; + + // Kill server-side session (fire and forget) + fetch(`${this.host}/api/session`, { + method: 'DELETE', + headers: { 'X-Session-ID': this.sessionId } + }).catch(() => {}); + + // Reset state + backendState.reset(); + } + + // ------------------------------------------------------------------------- + // State + // ------------------------------------------------------------------------- + + getState(): BackendState { + return backendState.get(); + } + + subscribe(callback: (state: BackendState) => void): () => void { + return backendState.subscribe(callback); + } + + isReady(): boolean { + return this.getState().initialized; + } + + isLoading(): boolean { + return this.getState().loading; + } + + getError(): string | null { + return this.getState().error; + } + + // ------------------------------------------------------------------------- + // Execution + // ------------------------------------------------------------------------- + + async exec(code: string, timeout: number = TIMEOUTS.SIMULATION): Promise { + const id = this.generateId(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const resp = await fetch(`${this.host}/api/exec`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Session-ID': this.sessionId + }, + body: JSON.stringify({ id, code }), + signal: controller.signal + }); + + const data = await resp.json(); + + // Forward stdout/stderr from response + if (data.stdout && this.stdoutCallback) this.stdoutCallback(data.stdout); + if (data.stderr && this.stderrCallback) this.stderrCallback(data.stderr); + + if (data.type === 'error') { + const errorMsg = data.traceback ? `${data.error}\n${data.traceback}` : data.error; + throw new Error(errorMsg); + } + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new Error('Execution timeout'); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + } + + async evaluate(expr: string, timeout: number = TIMEOUTS.SIMULATION): Promise { + const id = this.generateId(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const resp = await fetch(`${this.host}/api/eval`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Session-ID': this.sessionId + }, + body: JSON.stringify({ id, expr }), + signal: controller.signal + }); + + const data = await resp.json(); + + // Forward stdout/stderr from response + if (data.stdout && this.stdoutCallback) this.stdoutCallback(data.stdout); + if (data.stderr && this.stderrCallback) this.stderrCallback(data.stderr); + + if (data.type === 'error') { + const errorMsg = data.traceback ? `${data.error}\n${data.traceback}` : data.error; + throw new Error(errorMsg); + } + + if (data.value === undefined) { + throw new Error('No value returned from eval'); + } + + return JSON.parse(data.value) as T; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new Error('Evaluation timeout'); + } + throw error; + } finally { + clearTimeout(timeoutId); + } + } + + // ------------------------------------------------------------------------- + // Streaming + // ------------------------------------------------------------------------- + + startStreaming( + expr: string, + onData: (data: T) => void, + onDone: () => void, + onError: (error: Error) => void + ): void { + if (!this.isReady()) { + onError(new Error('Backend not initialized')); + return; + } + + // Stop any existing stream + if (this._isStreaming) { + this.stopStreaming(); + } + + const id = this.generateId(); + this._isStreaming = true; + this.streamState = { + onData: onData as (data: unknown) => void, + onDone, + onError + }; + + this.streamAbortController = new AbortController(); + + // Start SSE stream + this.consumeSSEStream(id, expr, this.streamAbortController.signal); + } + + stopStreaming(): void { + if (!this._isStreaming) return; + + // Send stop to server + fetch(`${this.host}/api/stream/stop`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Session-ID': this.sessionId + } + }).catch(() => {}); + + // Abort the SSE connection + if (this.streamAbortController) { + this.streamAbortController.abort(); + this.streamAbortController = null; + } + } + + isStreaming(): boolean { + return this._isStreaming; + } + + execDuringStreaming(code: string): void { + if (!this._isStreaming) { + console.warn('Cannot exec during streaming: no active stream'); + return; + } + + // Fire and forget + fetch(`${this.host}/api/stream/exec`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Session-ID': this.sessionId + }, + body: JSON.stringify({ code }) + }).catch(() => {}); + } + + // ------------------------------------------------------------------------- + // Output Callbacks + // ------------------------------------------------------------------------- + + onStdout(callback: (value: string) => void): void { + this.stdoutCallback = callback; + } + + onStderr(callback: (value: string) => void): void { + this.stderrCallback = callback; + } + + // ------------------------------------------------------------------------- + // Private Methods + // ------------------------------------------------------------------------- + + private generateId(): string { + return `repl_${++this.messageId}`; + } + + /** + * Consume an SSE stream from /api/stream, dispatching events to callbacks. + */ + private async consumeSSEStream(id: string, expr: string, signal: AbortSignal): Promise { + try { + const resp = await fetch(`${this.host}/api/stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Session-ID': this.sessionId + }, + body: JSON.stringify({ id, expr }), + signal + }); + + if (!resp.ok || !resp.body) { + throw new Error(`Stream request failed: ${resp.status}`); + } + + const reader = resp.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let currentEvent = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + + // Process complete SSE messages (separated by double newlines) + const parts = buffer.split('\n\n'); + buffer = parts.pop() || ''; // Keep incomplete last part + + for (const part of parts) { + if (!part.trim()) continue; + + // Parse SSE fields + let eventType = ''; + let eventData = ''; + + for (const line of part.split('\n')) { + if (line.startsWith('event: ')) { + eventType = line.slice(7); + } else if (line.startsWith('data: ')) { + eventData = line.slice(6); + } + } + + if (!eventType) continue; + + this.handleSSEEvent(eventType, eventData); + + if (eventType === 'done' || eventType === 'error') { + return; + } + } + } + } catch (error) { + if (signal.aborted) { + // Aborted by stopStreaming — call onDone + this._isStreaming = false; + if (this.streamState.onDone) { + this.streamState.onDone(); + } + this.streamState = { onData: null, onDone: null, onError: null }; + return; + } + this._isStreaming = false; + if (this.streamState.onError) { + this.streamState.onError(error instanceof Error ? error : new Error(String(error))); + } + this.streamState = { onData: null, onDone: null, onError: null }; + } + } + + private handleSSEEvent(eventType: string, data: string): void { + switch (eventType) { + case 'data': { + if (this.streamState.onData) { + try { + const parsed = JSON.parse(data); + this.streamState.onData(parsed); + } catch { + // Ignore parse errors + } + } + break; + } + case 'stdout': { + if (this.stdoutCallback) { + try { + this.stdoutCallback(JSON.parse(data)); + } catch { + this.stdoutCallback(data); + } + } + break; + } + case 'stderr': { + if (this.stderrCallback) { + try { + this.stderrCallback(JSON.parse(data)); + } catch { + this.stderrCallback(data); + } + } + break; + } + case 'done': { + this._isStreaming = false; + if (this.streamState.onDone) { + this.streamState.onDone(); + } + this.streamState = { onData: null, onDone: null, onError: null }; + break; + } + case 'error': { + this._isStreaming = false; + if (this.streamState.onError) { + try { + const parsed = JSON.parse(data); + const msg = parsed.traceback + ? `${parsed.error}\n${parsed.traceback}` + : parsed.error || 'Unknown error'; + this.streamState.onError(new Error(msg)); + } catch { + this.streamState.onError(new Error(data || 'Stream error')); + } + } + this.streamState = { onData: null, onDone: null, onError: null }; + backendState.update((s) => ({ ...s, error: 'Stream error' })); + break; + } + } + } +} From 36d8496ae6b9a7e0258380e04f879c85f585165f Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 11:46:18 +0100 Subject: [PATCH 370/656] Integrate FlaskBackend into registry with URL param switching Co-Authored-By: KDW1 --- package.json | 3 ++- src/lib/pyodide/backend/index.ts | 25 +++++++++++++++++++++++-- src/lib/pyodide/backend/registry.ts | 14 ++++++++++++-- src/routes/+page.svelte | 4 ++++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index d8024cc1..a2a30f9b 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "eslint .", - "format": "prettier --write ." + "format": "prettier --write .", + "server": "python server/app.py" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.0", diff --git a/src/lib/pyodide/backend/index.ts b/src/lib/pyodide/backend/index.ts index aea26ee5..5a227a12 100644 --- a/src/lib/pyodide/backend/index.ts +++ b/src/lib/pyodide/backend/index.ts @@ -20,21 +20,42 @@ export { getBackendType, hasBackend, terminateBackend, + setFlaskHost, type BackendType } from './registry'; -// Re-export PyodideBackend for direct use if needed +// Re-export backend implementations export { PyodideBackend } from './pyodide/backend'; +export { FlaskBackend } from './flask/backend'; // ============================================================================ // Backward-Compatible Convenience Functions // These delegate to the current backend and maintain API compatibility // ============================================================================ -import { getBackend } from './registry'; +import { getBackend, switchBackend, setFlaskHost } from './registry'; import { backendState } from './state'; import { consoleStore } from '$lib/stores/console'; +/** + * Initialize backend from URL parameters. + * Reads `?backend=flask` and `?host=...` from the current URL. + * Call this early in page mount, before any backend usage. + */ +export function initBackendFromUrl(): void { + if (typeof window === 'undefined') return; + const params = new URLSearchParams(window.location.search); + const backendParam = params.get('backend'); + const hostParam = params.get('host'); + + if (backendParam === 'flask') { + if (hostParam) { + setFlaskHost(hostParam); + } + switchBackend('flask'); + } +} + // Alias for backward compatibility export const replState = { subscribe: backendState.subscribe diff --git a/src/lib/pyodide/backend/registry.ts b/src/lib/pyodide/backend/registry.ts index e1eaaddc..263ae5b0 100644 --- a/src/lib/pyodide/backend/registry.ts +++ b/src/lib/pyodide/backend/registry.ts @@ -5,11 +5,20 @@ import type { Backend } from './types'; import { PyodideBackend } from './pyodide/backend'; +import { FlaskBackend } from './flask/backend'; -export type BackendType = 'pyodide' | 'local' | 'remote'; +export type BackendType = 'pyodide' | 'flask' | 'remote'; let currentBackend: Backend | null = null; let currentBackendType: BackendType | null = null; +let flaskHost = 'http://localhost:5000'; + +/** + * Set the Flask backend host URL + */ +export function setFlaskHost(host: string): void { + flaskHost = host; +} /** * Get the current backend, creating a Pyodide backend if none exists @@ -29,7 +38,8 @@ export function createBackend(type: BackendType): Backend { switch (type) { case 'pyodide': return new PyodideBackend(); - case 'local': + case 'flask': + return new FlaskBackend(flaskHost); case 'remote': throw new Error(`Backend type '${type}' not yet implemented`); default: diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7aaad8bc..268e53f1 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -40,6 +40,7 @@ import { openEventDialog } from '$lib/stores/eventDialog'; import type { MenuItemType } from '$lib/components/ContextMenu.svelte'; import { pyodideState, simulationState, initPyodide, stopSimulation, continueStreamingSimulation } from '$lib/pyodide/bridge'; + import { initBackendFromUrl } from '$lib/pyodide/backend'; import { runGraphStreamingSimulation, validateGraphSimulation } from '$lib/pyodide/pathsimRunner'; import { consoleStore } from '$lib/stores/console'; import { newGraph, saveFile, saveAsFile, setupAutoSave, clearAutoSave, debouncedAutoSave, openImportDialog, importFromUrl, currentFileName } from '$lib/schema/fileOps'; @@ -381,6 +382,9 @@ const continueTooltip = { text: "Continue", shortcut: "Shift+Enter" }; onMount(() => { + // Check URL params for backend selection (e.g. ?backend=flask&host=http://localhost:5000) + initBackendFromUrl(); + // Subscribe to stores (with cleanup) const unsubPinnedPreviews = pinnedPreviewsStore.subscribe((pinned) => { showPinnedPreviews = pinned; From 4b4df3d0750465f7938066051289e0b69efaae1b Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 11:58:25 +0100 Subject: [PATCH 371/656] Add dynamic package installation from PYTHON_PACKAGES config Co-Authored-By: KDW1 --- server/app.py | 24 ++++++++++- server/worker.py | 55 ++++++++++++++++++++++-- src/lib/pyodide/backend/flask/backend.ts | 35 ++++++++++++++- 3 files changed, 107 insertions(+), 7 deletions(-) diff --git a/server/app.py b/server/app.py index 76cb254e..6f8eff40 100644 --- a/server/app.py +++ b/server/app.py @@ -67,12 +67,15 @@ def read_line(self) -> dict | None: return None return json.loads(line.strip()) - def ensure_initialized(self) -> list[dict]: + def ensure_initialized(self, packages: list[dict] | None = None) -> list[dict]: """Initialize the worker if not already done. Returns any messages received.""" if self._initialized: return [] messages = [] - self.send_message({"type": "init"}) + init_msg = {"type": "init"} + if packages: + init_msg["packages"] = packages + self.send_message(init_msg) while True: resp = self.read_line() if resp is None: @@ -161,6 +164,23 @@ def health(): return jsonify({"status": "ok"}) +@app.route("/api/init", methods=["POST"]) +def api_init(): + """Initialize a session's worker with packages from the frontend config.""" + session_id = _get_session_id() + data = request.get_json(force=True) + packages = data.get("packages", []) + + session = get_or_create_session(session_id) + with session.lock: + try: + messages = session.ensure_initialized(packages=packages) + # Return all progress/stdout/stderr messages collected during init + return jsonify({"type": "ready", "messages": messages}) + except Exception as e: + return jsonify({"type": "error", "error": str(e)}), 500 + + @app.route("/api/exec", methods=["POST"]) def api_exec(): """Execute Python code in the session's worker.""" diff --git a/server/worker.py b/server/worker.py index 6040cc03..e73ba130 100644 --- a/server/worker.py +++ b/server/worker.py @@ -16,6 +16,7 @@ import sys import io import json +import subprocess import threading import traceback import queue @@ -73,8 +74,43 @@ def read_message(): return json.loads(line) -def initialize() -> None: - """Initialize the worker: import standard packages, capture clean globals.""" +def _install_package(pip_spec: str, pre: bool = False) -> None: + """Install a package via pip if not already available.""" + cmd = [sys.executable, "-m", "pip", "install", pip_spec, "--quiet"] + if pre: + cmd.append("--pre") + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"pip install failed for {pip_spec}: {result.stderr.strip()}") + + +def _ensure_package(pkg: dict) -> None: + """Ensure a package is installed and importable. Mirrors the Pyodide worker loop.""" + import_name = pkg.get("import", "") + pip_spec = pkg.get("pip", import_name) + required = pkg.get("required", False) + pre = pkg.get("pre", False) + + send({"type": "progress", "value": f"Installing {import_name}..."}) + + try: + # Try importing first — skip pip if already installed + exec(f"import {import_name}", _namespace) + except ImportError: + # Not installed — pip install then import + _install_package(pip_spec, pre) + exec(f"import {import_name}", _namespace) + + # Log version if available + try: + version = eval(f"{import_name}.__version__", _namespace) + send({"type": "stdout", "value": f"{import_name} {version} loaded successfully\n"}) + except Exception: + send({"type": "stdout", "value": f"{import_name} loaded successfully\n"}) + + +def initialize(packages: list[dict] | None = None) -> None: + """Initialize the worker: install packages, import standard libs, capture clean globals.""" global _initialized, _namespace, _clean_globals if _initialized: @@ -89,6 +125,19 @@ def initialize() -> None: exec("import gc", _namespace) exec("import json", _namespace) + # Install and import packages from the frontend config (single source of truth) + if packages: + send({"type": "progress", "value": "Installing dependencies..."}) + for pkg in packages: + try: + _ensure_package(pkg) + except Exception as e: + if pkg.get("required", False): + raise RuntimeError( + f"Failed to install required package {pkg.get('pip', pkg.get('import', '?'))}: {e}" + ) + send({"type": "stderr", "value": f"Optional package {pkg.get('import', '?')} failed: {e}\n"}) + # Capture clean state for later cleanup _clean_globals = set(_namespace.keys()) @@ -252,7 +301,7 @@ def main() -> None: try: if msg_type == "init": - initialize() + initialize(packages=msg.get("packages")) elif msg_type == "exec": if not msg_id or not isinstance(code, str): diff --git a/src/lib/pyodide/backend/flask/backend.ts b/src/lib/pyodide/backend/flask/backend.ts index 842d1598..7f91c358 100644 --- a/src/lib/pyodide/backend/flask/backend.ts +++ b/src/lib/pyodide/backend/flask/backend.ts @@ -7,6 +7,7 @@ import type { Backend, BackendState } from '../types'; import { backendState } from '../state'; import { TIMEOUTS } from '$lib/constants/python'; import { STATUS_MESSAGES } from '$lib/constants/messages'; +import { PYTHON_PACKAGES } from '$lib/constants/dependencies'; /** * Flask Backend Implementation @@ -76,9 +77,39 @@ export class FlaskBackend implements Backend { throw new Error(`Server health check failed: ${resp.status}`); } - // Trigger worker initialization with a no-op exec + // Initialize worker with packages from the shared config (single source of truth) backendState.update((s) => ({ ...s, progress: 'Initializing Python worker...' })); - await this.exec('pass', TIMEOUTS.INIT); + + const initController = new AbortController(); + const initTimeout = setTimeout(() => initController.abort(), TIMEOUTS.INIT); + + const initResp = await fetch(`${this.host}/api/init`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Session-ID': this.sessionId + }, + body: JSON.stringify({ packages: PYTHON_PACKAGES }), + signal: initController.signal + }); + clearTimeout(initTimeout); + + const initData = await initResp.json(); + + if (initData.type === 'error') { + throw new Error(initData.error); + } + + // Forward any stdout/stderr messages from init + if (initData.messages) { + for (const msg of initData.messages) { + if (msg.type === 'stdout' && this.stdoutCallback) this.stdoutCallback(msg.value); + if (msg.type === 'stderr' && this.stderrCallback) this.stderrCallback(msg.value); + if (msg.type === 'progress') { + backendState.update((s) => ({ ...s, progress: msg.value })); + } + } + } backendState.update((s) => ({ ...s, From b183c18c68fc14658be38b7767bccdbb323a44fe Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 12:03:19 +0100 Subject: [PATCH 372/656] Update README with Flask backend documentation Co-Authored-By: KDW1 --- README.md | 106 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 88 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b7f7f82c..c9ccad0f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ # PathView - System Modeling in the Browser -A web-based visual node editor for building and simulating dynamic systems with [PathSim](https://github.com/pathsim/pathsim) as the backend. Runs entirely in the browser via Pyodide - no server required. The UI is hosted at [view.pathsim.org](https://view.pathsim.org), free to use for everyone. +A web-based visual node editor for building and simulating dynamic systems with [PathSim](https://github.com/pathsim/pathsim) as the backend. Runs entirely in the browser via Pyodide by default — no server required. Optionally, a Flask backend enables server-side Python execution with any packages (including those with native dependencies that Pyodide can't run). The UI is hosted at [view.pathsim.org](https://view.pathsim.org), free to use for everyone. ## Tech Stack @@ -24,6 +24,15 @@ npm install npm run dev ``` +To use the Flask backend (server-side Python): + +```bash +pip install -r server/requirements.txt +npm run server # Start Flask backend on port 5000 +npm run dev # Start Vite dev server (separate terminal) +# Open http://localhost:5173/?backend=flask +``` + For production: ```bash @@ -61,7 +70,8 @@ src/ │ ├── routing/ # Orthogonal wire routing (A* pathfinding) │ ├── pyodide/ # Python runtime (backend, bridge) │ │ └── backend/ # Modular backend system (registry, state, types) -│ │ └── pyodide/ # Pyodide Web Worker implementation +│ │ ├── pyodide/ # Pyodide Web Worker implementation +│ │ └── flask/ # Flask HTTP/SSE backend implementation │ ├── schema/ # File I/O (save/load, component export) │ ├── simulation/ # Simulation metadata │ │ └── generated/ # Auto-generated defaults @@ -72,6 +82,11 @@ src/ ├── routes/ # SvelteKit pages └── app.css # Global styles with CSS variables +server/ +├── app.py # Flask server (subprocess management, HTTP routes) +├── worker.py # REPL worker subprocess (Python execution) +└── requirements.txt # Server Python dependencies + scripts/ ├── config/ # Configuration files for extraction │ ├── schemas/ # JSON schemas for validation @@ -100,8 +115,8 @@ scripts/ │ v ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ Plot/Console │<────│ bridge.ts │<────│ REPL Worker │ -│ (results) │ │ (queue + rAF) │ │ (Pyodide) │ +│ Plot/Console │<────│ bridge.ts │<────│ Backend │ +│ (results) │ │ (queue + rAF) │ │ (Pyodide/Flask) │ └─────────────────┘ └─────────────────┘ └─────────────────┘ ``` @@ -153,6 +168,7 @@ Key files: `src/lib/routing/` (pathfinder, grid builder, route calculator) | **Backend** | Modular Python execution interface | `pyodide/backend/` | | **Backend Registry** | Factory for swappable backends | `pyodide/backend/registry.ts` | | **PyodideBackend** | Web Worker Pyodide implementation | `pyodide/backend/pyodide/` | +| **FlaskBackend** | HTTP/SSE Flask server implementation | `pyodide/backend/flask/` | | **Simulation Bridge** | High-level simulation API | `pyodide/bridge.ts` | | **Schema** | File/component save/load operations | `schema/fileOps.ts`, `schema/componentOps.ts` | | **Export Utils** | SVG/CSV/Python file downloads | `utils/download.ts`, `export/svg/`, `utils/csvExport.ts` | @@ -326,29 +342,36 @@ The Python runtime uses a modular backend architecture, allowing different execu ┌──────────────┼──────────────┐ ▼ ▼ ▼ ┌───────────┐ ┌───────────┐ ┌───────────┐ - │ Pyodide │ │ Local │ │ Remote │ + │ Pyodide │ │ Flask │ │ Remote │ │ Backend │ │ Backend │ │ Backend │ - │ (Worker) │ │ (Flask) │ │ (Server) │ + │ (default) │ │ (HTTP) │ │ (future) │ └───────────┘ └───────────┘ └───────────┘ - │ (future) (future) - ▼ - ┌───────────┐ - │ Web Worker│ - │ (Pyodide) │ - └───────────┘ + │ │ + ▼ ▼ + ┌───────────┐ ┌───────────┐ + │ Web Worker│ │ Flask │──> Python subprocess + │ (Pyodide) │ │ Server │ (one per session) + └───────────┘ └───────────┘ ``` ### Backend Registry ```typescript -import { getBackend, switchBackend } from '$lib/pyodide/backend'; +import { getBackend, switchBackend, setFlaskHost } from '$lib/pyodide/backend'; // Get current backend (defaults to Pyodide) const backend = getBackend(); -// Switch to a different backend type (future) -// switchBackend('local'); // Use local Python via Flask -// switchBackend('remote'); // Use remote server +// Switch to Flask backend +setFlaskHost('http://localhost:5000'); +switchBackend('flask'); +``` + +Backend selection can also be controlled via URL parameters: + +``` +http://localhost:5173/?backend=flask # Flask on default port +http://localhost:5173/?backend=flask&host=http://myserver:5000 # Custom host ``` ### REPL Protocol @@ -426,6 +449,52 @@ await stopSimulation(); execDuringStreaming('source.amplitude = 2.0'); ``` +### Flask Backend + +The Flask backend enables server-side Python execution for packages that Pyodide can't run (e.g., FESTIM or other packages with native C/Fortran dependencies). It mirrors the Web Worker architecture: one subprocess per session with the same REPL protocol. + +``` +Browser Tab Flask Server Worker Subprocess +┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ FlaskBackend │ HTTP/SSE │ app.py │ stdin │ worker.py │ +│ exec() │──POST────────→│ route → session │──JSON───→│ exec(code, ns) │ +│ eval() │──POST────────→│ subprocess mgr │──JSON───→│ eval(expr, ns) │ +│ stream() │──POST (SSE)──→│ pipe SSE relay │←─JSON────│ streaming loop │ +│ inject() │──POST────────→│ → code queue │──JSON───→│ queue drain │ +│ stop() │──POST────────→│ → stop flag │──JSON───→│ stop check │ +└──────────────┘ └──────────────────┘ └──────────────────┘ +``` + +**Setup:** + +```bash +pip install -r server/requirements.txt +npm run server # Starts Flask on port 5000 +npm run dev # Starts Vite dev server (separate terminal) +``` + +Then open `http://localhost:5173/?backend=flask`. + +**Key properties:** +- **Process isolation** — each session gets its own Python subprocess +- **Namespace persistence** — variables persist across exec/eval calls within a session +- **Dynamic packages** — packages from `PYTHON_PACKAGES` (the same config used by Pyodide) are pip-installed on first init +- **Session TTL** — stale sessions cleaned up after 1 hour of inactivity +- **Streaming** — simulations stream via SSE, with the same code injection support as Pyodide + +**API routes:** + +| Route | Method | Action | +|-------|--------|--------| +| `/api/health` | GET | Health check | +| `/api/init` | POST | Initialize worker with packages | +| `/api/exec` | POST | Execute Python code | +| `/api/eval` | POST | Evaluate expression, return JSON | +| `/api/stream` | POST | Start streaming simulation (SSE) | +| `/api/stream/exec` | POST | Inject code during streaming | +| `/api/stream/stop` | POST | Stop streaming | +| `/api/session` | DELETE | Kill session subprocess | + --- ## State Management @@ -583,7 +652,8 @@ https://view.pathsim.org/?modelgh=pathsim/pathview/static/examples/feedback-syst | Script | Purpose | |--------|---------| -| `npm run dev` | Start development server | +| `npm run dev` | Start Vite development server | +| `npm run server` | Start Flask backend server (port 5000) | | `npm run build` | Production build | | `npm run preview` | Preview production build | | `npm run check` | TypeScript/Svelte type checking | @@ -665,7 +735,7 @@ Port labels show the name of each input/output port alongside the node. Toggle g 2. **Subsystems are nested graphs** - The Interface node inside a subsystem mirrors its parent's ports (inverted direction). -3. **No server required** - Everything runs client-side via Pyodide WebAssembly. +3. **No server required by default** - Everything runs client-side via Pyodide. The optional Flask backend enables server-side execution for packages with native dependencies. 4. **Registry pattern** - Nodes and events are registered centrally for extensibility. From 7491986d30d2ae08600a35790ed85658be7d4863 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 12:15:02 +0100 Subject: [PATCH 373/656] Add backend protocol and toolbox integration spec docs Co-Authored-By: KDW1 --- docs/backend-protocol-spec.md | 1151 +++++++++++++++++++++++++++++++++ docs/toolbox-spec.md | 699 ++++++++++++++++++++ 2 files changed, 1850 insertions(+) create mode 100644 docs/backend-protocol-spec.md create mode 100644 docs/toolbox-spec.md diff --git a/docs/backend-protocol-spec.md b/docs/backend-protocol-spec.md new file mode 100644 index 00000000..6a4df23e --- /dev/null +++ b/docs/backend-protocol-spec.md @@ -0,0 +1,1151 @@ +# PathView REPL Backend Protocol Specification + +**Version:** 1.0.0 +**Status:** Normative + +The PathView REPL backend protocol defines the contract that any Python execution backend must implement. It covers the TypeScript interface, the wire-level message protocol between main thread and worker, the HTTP API used by the Flask reference implementation, and the streaming semantics that enable live simulation. + +This document is the authoritative reference for anyone building a new backend (remote server, cloud worker, WebSocket bridge, etc.). + +--- + +## Table of Contents + +- [1. Overview](#1-overview) +- [2. Backend Interface](#2-backend-interface) + - [2.1 Lifecycle Methods](#21-lifecycle-methods) + - [2.2 State Methods](#22-state-methods) + - [2.3 Execution Methods](#23-execution-methods) + - [2.4 Streaming Methods](#24-streaming-methods) + - [2.5 Output Callbacks](#25-output-callbacks) +- [3. REPL Message Protocol](#3-repl-message-protocol) + - [3.1 Request Messages (main -> worker)](#31-request-messages-main---worker) + - [3.2 Response Messages (worker -> main)](#32-response-messages-worker---main) +- [4. Message Flows](#4-message-flows) + - [4.1 Init Flow](#41-init-flow) + - [4.2 Exec Flow](#42-exec-flow) + - [4.3 Eval Flow](#43-eval-flow) + - [4.4 Streaming Flow](#44-streaming-flow) + - [4.5 Code Injection During Streaming](#45-code-injection-during-streaming) + - [4.6 Stop Streaming](#46-stop-streaming) +- [5. Streaming Semantics](#5-streaming-semantics) +- [6. HTTP API (Flask Reference Implementation)](#6-http-api-flask-reference-implementation) + - [6.1 Route Summary](#61-route-summary) + - [6.2 Route Details](#62-route-details) +- [7. SSE Event Format](#7-sse-event-format) +- [8. State Management](#8-state-management) +- [9. Backend Registration](#9-backend-registration) +- [10. Worked Example](#10-worked-example) + - [10.1 Abstract Message Flow](#101-abstract-message-flow) + - [10.2 HTTP Equivalents](#102-http-equivalents) + +--- + +## 1. Overview + +PathView uses a modular backend system for Python execution. The `Backend` interface defines a transport-agnostic contract that decouples the UI from the execution environment. Two implementations currently exist: + +| Backend | Transport | Environment | +|---------|-----------|-------------| +| `PyodideBackend` | Web Worker `postMessage` | Browser (Pyodide WASM) | +| `FlaskBackend` | HTTP + SSE | Local/remote Flask server | + +The `Backend` interface is defined in `src/lib/pyodide/backend/types.ts`. Implementations are registered in `src/lib/pyodide/backend/registry.ts` and re-exported from `src/lib/pyodide/backend/index.ts`. + +This spec defines the protocol at two levels: + +1. **Abstract level** -- the TypeScript `Backend` interface and the `REPLRequest`/`REPLResponse` message types. +2. **Wire level** -- the HTTP routes and SSE event format used by HTTP-based backends. + +Any new backend must implement the abstract interface. HTTP-based backends should additionally follow the wire-level conventions documented in sections 6 and 7. + +--- + +## 2. Backend Interface + +```typescript +interface Backend { + // Lifecycle + init(): Promise; + terminate(): void; + + // State + getState(): BackendState; + subscribe(callback: (state: BackendState) => void): () => void; + isReady(): boolean; + isLoading(): boolean; + getError(): string | null; + + // Execution + exec(code: string, timeout?: number): Promise; + evaluate(expr: string, timeout?: number): Promise; + + // Streaming + startStreaming( + expr: string, + onData: (data: T) => void, + onDone: () => void, + onError: (error: Error) => void + ): void; + stopStreaming(): void; + isStreaming(): boolean; + execDuringStreaming(code: string): void; + + // Output + onStdout(callback: (value: string) => void): void; + onStderr(callback: (value: string) => void): void; +} + +interface BackendState { + initialized: boolean; + loading: boolean; + error: string | null; + progress: string; +} +``` + +### 2.1 Lifecycle Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `init` | `init(): Promise` | Initialize the backend. Load the runtime, connect to the server, install packages, etc. The promise resolves when the backend is ready to execute code. Must set `BackendState.loading = true` at the start and `initialized = true` on success. Called once at application startup. Idempotent -- calling it when already initialized or loading is a no-op. | +| `terminate` | `terminate(): void` | Tear down the backend and release all resources. Reject pending requests, abort active streams, destroy workers or connections. Must call `backendState.reset()` to return state to initial values. | + +### 2.2 State Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `getState` | `getState(): BackendState` | Return a snapshot of the current backend state. | +| `subscribe` | `subscribe(callback: (state: BackendState) => void): () => void` | Subscribe to state changes. Returns an unsubscribe function. Delegates to the shared `backendState` Svelte store. | +| `isReady` | `isReady(): boolean` | Return `true` if `initialized` is `true`. Shorthand for `getState().initialized`. | +| `isLoading` | `isLoading(): boolean` | Return `true` if the backend is currently initializing. Shorthand for `getState().loading`. | +| `getError` | `getError(): string \| null` | Return the current error message, or `null` if no error. Shorthand for `getState().error`. | + +### 2.3 Execution Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `exec` | `exec(code: string, timeout?: number): Promise` | Execute Python code with no return value. The promise resolves on success and rejects on error. The `timeout` parameter (milliseconds) is optional; implementations should default to a reasonable value. Called for code setup, imports, variable definitions, and simulation construction. | +| `evaluate` | `evaluate(expr: string, timeout?: number): Promise` | Evaluate a Python expression and return the result as a parsed JSON value. The backend must serialize the result to JSON on the Python side and deserialize it on the TypeScript side. Rejects if the expression errors or the result is not JSON-serializable. | + +### 2.4 Streaming Methods + +| Method | Signature | Description | +|--------|-----------|-------------| +| `startStreaming` | `startStreaming(expr: string, onData: (data: T) => void, onDone: () => void, onError: (error: Error) => void): void` | Begin an autonomous streaming loop. The backend repeatedly evaluates `expr`, calling `onData` with each parsed JSON result. The loop continues until the expression returns `{done: true}`, `stopStreaming()` is called, or an error occurs. `onDone` is always called when the loop exits (regardless of reason). `onError` is called if the main expression throws. If a stream is already active, it is stopped before the new one begins. | +| `stopStreaming` | `stopStreaming(): void` | Request the streaming loop to stop. The backend finishes the current step and then sends `stream-done`. This is a request, not an immediate abort -- `onDone` will still fire. | +| `isStreaming` | `isStreaming(): boolean` | Return `true` if a streaming loop is currently active. | +| `execDuringStreaming` | `execDuringStreaming(code: string): void` | Queue Python code to be executed between streaming steps. The code is drained and executed before the next evaluation of the stream expression. Used for runtime parameter changes and event injection. Errors in queued code are reported via stderr but do not stop the stream. No-op if no stream is active. | + +### 2.5 Output Callbacks + +| Method | Signature | Description | +|--------|-----------|-------------| +| `onStdout` | `onStdout(callback: (value: string) => void): void` | Register a callback for captured stdout output. Only one callback is active at a time (last registration wins). Called during `exec`, `evaluate`, and streaming whenever Python code writes to stdout. | +| `onStderr` | `onStderr(callback: (value: string) => void): void` | Register a callback for captured stderr output. Same semantics as `onStdout`. | + +--- + +## 3. REPL Message Protocol + +The REPL message protocol defines the typed messages exchanged between the main thread and the execution worker. For the `PyodideBackend`, these are `postMessage` payloads. For the `FlaskBackend`, they are mapped onto HTTP request/response bodies and SSE events. The types are defined in `src/lib/pyodide/backend/types.ts`. + +### 3.1 Request Messages (main -> worker) + +#### `init` + +Initialize the Python runtime and install packages. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"init"` | yes | Message type discriminant. | + +The Pyodide worker reads its package list from the compiled-in `PYTHON_PACKAGES` constant. HTTP-based backends receive the package list in the request body (see [Section 6](#6-http-api-flask-reference-implementation)). + +#### `exec` + +Execute Python code (no return value expected). + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"exec"` | yes | Message type discriminant. | +| `id` | `string` | yes | Unique request ID for correlating the response. Convention: `"repl_N"`. | +| `code` | `string` | yes | Python code to execute. | + +#### `eval` + +Evaluate a Python expression and return the JSON-serialized result. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"eval"` | yes | Message type discriminant. | +| `id` | `string` | yes | Unique request ID. | +| `expr` | `string` | yes | Python expression to evaluate. The result must be JSON-serializable. | + +#### `stream-start` + +Begin an autonomous streaming loop. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"stream-start"` | yes | Message type discriminant. | +| `id` | `string` | yes | Unique stream ID. All `stream-data` and `stream-done` responses reference this ID. | +| `expr` | `string` | yes | Python expression to evaluate each iteration. Should return `{done: bool, result: any}`. | + +#### `stream-stop` + +Request the streaming loop to stop. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"stream-stop"` | yes | Message type discriminant. | + +No additional fields. The worker finishes the current iteration and then sends `stream-done`. + +#### `stream-exec` + +Queue code for execution between streaming steps. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"stream-exec"` | yes | Message type discriminant. | +| `code` | `string` | yes | Python code to queue. Executed before the next evaluation of the stream expression. | + +### 3.2 Response Messages (worker -> main) + +#### `ready` + +Sent after successful initialization. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"ready"` | yes | Message type discriminant. | + +#### `ok` + +Sent after successful `exec` completion. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"ok"` | yes | Message type discriminant. | +| `id` | `string` | yes | The request ID from the originating `exec` request. | + +#### `value` + +Sent after successful `eval` completion. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"value"` | yes | Message type discriminant. | +| `id` | `string` | yes | The request ID from the originating `eval` request. | +| `value` | `string` | yes | JSON-serialized result of the evaluated expression. The main thread parses this with `JSON.parse()`. | + +#### `error` + +Sent when any operation fails. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"error"` | yes | Message type discriminant. | +| `id` | `string` | no | The request ID of the failed operation. May be absent for global errors (e.g., init failures). | +| `error` | `string` | yes | Human-readable error message. | +| `traceback` | `string` | no | Python traceback string, if available. | + +#### `stdout` + +Captured standard output from Python execution. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"stdout"` | yes | Message type discriminant. | +| `value` | `string` | yes | The captured output text. | + +#### `stderr` + +Captured standard error from Python execution. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"stderr"` | yes | Message type discriminant. | +| `value` | `string` | yes | The captured error text. | + +#### `progress` + +Loading progress updates during initialization. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"progress"` | yes | Message type discriminant. | +| `value` | `string` | yes | Human-readable progress message (e.g., `"Installing pathsim..."`). | + +#### `stream-data` + +A single result from the streaming loop. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"stream-data"` | yes | Message type discriminant. | +| `id` | `string` | yes | The stream ID from the originating `stream-start` request. | +| `value` | `string` | yes | JSON-serialized step result. Convention: `{"done": false, "result": {...}}`. | + +#### `stream-done` + +The streaming loop has exited. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"stream-done"` | yes | Message type discriminant. | +| `id` | `string` | yes | The stream ID from the originating `stream-start` request. | + +This message is **always** sent when the loop exits, whether it completed naturally (`done: true`), was stopped via `stream-stop`, or errored. + +--- + +## 4. Message Flows + +### 4.1 Init Flow + +``` +main worker + | | + |──── init ──────────────────────>| + | | (load Pyodide / connect) + |<──── progress("Loading...") ────| + |<──── progress("Installing...") ─| + |<──── stdout("pathsim loaded") ──| + |<──── progress("Installing...") ─| + |<──── ready ─────────────────────| + | | +``` + +The worker may send zero or more `progress`, `stdout`, and `stderr` messages during initialization. The sequence ends with exactly one `ready` or `error`. + +### 4.2 Exec Flow + +``` +main worker + | | + |──── exec {id, code} ──────────>| + | | (execute Python code) + |<──── stdout("...") ────────────| (zero or more) + |<──── stderr("...") ────────────| (zero or more) + |<──── ok {id} ──────────────────| (success) + | | + | ── OR on failure ── | + | | + |<──── error {id, error, tb?} ───| + | | +``` + +### 4.3 Eval Flow + +``` +main worker + | | + |──── eval {id, expr} ──────────>| + | | (evaluate expression) + |<──── stdout("...") ────────────| (zero or more) + |<──── value {id, value} ────────| (success, value is JSON string) + | | + | ── OR on failure ── | + | | + |<──── error {id, error, tb?} ───| + | | +``` + +### 4.4 Streaming Flow + +``` +main worker + | | + |── stream-start {id, expr} ────>| + | | (enter streaming loop) + |<── stream-data {id, value} ────| \ + |<── stream-data {id, value} ────| } repeated until done + |<── stream-data {id, value} ────| / + | | (expr returns {done: true}) + |<── stream-done {id} ───────────| + | | +``` + +### 4.5 Code Injection During Streaming + +``` +main worker + | | + |── stream-start {id, expr} ────>| + |<── stream-data {id, value} ────| + |<── stream-data {id, value} ────| + | | + |── stream-exec {code} ─────────>| (queued) + | | + | | [drain queue: exec code] + | | [eval expr] + |<── stream-data {id, value} ────| + |<── stream-data {id, value} ────| + |<── stream-done {id} ───────────| + | | +``` + +If the queued code errors, the worker sends a `stderr` message but continues the streaming loop: + +``` + |── stream-exec {code} ─────────>| + | | [exec code → error] + |<── stderr("Stream exec error") | + | | [eval expr → continues] + |<── stream-data {id, value} ────| +``` + +### 4.6 Stop Streaming + +``` +main worker + | | + |── stream-start {id, expr} ────>| + |<── stream-data {id, value} ────| + |<── stream-data {id, value} ────| + | | + |── stream-stop ─────────────────>| + | | (finish current step) + |<── stream-data {id, value} ────| (optional: final partial result) + |<── stream-done {id} ───────────| (always sent) + | | +``` + +The worker does not abort the currently executing Python step. It sets a flag and exits the loop after the current step completes. + +--- + +## 5. Streaming Semantics + +The streaming loop is the core mechanism for live simulation. It runs autonomously on the worker side after receiving `stream-start`. + +### Loop Algorithm + +``` +function runStreamingLoop(id, expr): + active = true + codeQueue = [] + + try: + while active: + // 1. Drain and execute queued code + while codeQueue is not empty: + code = codeQueue.dequeue() + try: + exec(code) + except error: + send stderr("Stream exec error: " + error) + + // 2. Evaluate the stream expression + result = eval(expr) // JSON: {done: bool, result: any} + + // 3. Check for stop (set by stream-stop handler) + if not active: + if result is not done and has data: + send stream-data(id, result) + break + + // 4. Check for natural completion + if result.done: + break + + // 5. Send result and continue + send stream-data(id, result) + + except error: + send error(id, error, traceback?) + finally: + active = false + send stream-done(id) // ALWAYS sent +``` + +### Key Rules + +1. **Code queue drain.** On each iteration, ALL queued code snippets are drained and executed before the expression is evaluated. This ensures parameter changes take effect on the next step. + +2. **Queue isolation.** Errors in queued code are reported via `stderr` but do **not** stop the streaming loop. The loop continues with the next evaluation. + +3. **Expression errors are fatal.** If the main stream expression throws, the loop exits and sends `error` followed by `stream-done`. + +4. **`stream-done` is always sent.** Regardless of whether the loop completed naturally (`done: true`), was stopped (`stream-stop`), or errored, the `stream-done` message is always the last message for that stream ID. + +5. **Result convention.** The stream expression should return a JSON-serializable object with the shape `{done: boolean, result: any}`. When `done` is `true`, the loop exits without sending the final result as `stream-data`. + +6. **Stop semantics.** `stream-stop` sets a flag. The worker does not preempt a running Python evaluation. The flag is checked after the current step completes. + +7. **Single stream.** Only one stream can be active at a time. Starting a new stream while one is active will stop the existing stream first. + +--- + +## 6. HTTP API (Flask Reference Implementation) + +The Flask backend (`src/lib/pyodide/backend/flask/backend.ts`) maps the abstract protocol onto HTTP requests. All routes except `/api/health` require the `X-Session-ID` header to identify the browser session. Each session gets an isolated Python process on the server. + +### 6.1 Route Summary + +| Route | Method | Description | +|-------|--------|-------------| +| `/api/health` | GET | Server health check | +| `/api/init` | POST | Initialize Python worker with packages | +| `/api/exec` | POST | Execute Python code | +| `/api/eval` | POST | Evaluate Python expression | +| `/api/stream` | POST | Start streaming (returns SSE stream) | +| `/api/stream/exec` | POST | Queue code during streaming | +| `/api/stream/stop` | POST | Stop active stream | +| `/api/session` | DELETE | Terminate session and destroy worker | + +### 6.2 Route Details + +#### GET /api/health + +Server health check. No authentication required. + +**Request:** +``` +GET /api/health +``` + +**Response:** +```json +{ + "status": "ok" +} +``` + +--- + +#### POST /api/init + +Initialize the Python runtime and install packages. + +**Request:** +``` +POST /api/init +Content-Type: application/json +X-Session-ID: +``` + +```json +{ + "packages": [ + { + "pip": "pathsim==0.16.5", + "import": "pathsim", + "required": true, + "pre": true + }, + { + "pip": "pathsim-chem>=0.2rc2", + "import": "pathsim_chem", + "required": false, + "pre": true + } + ] +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `packages` | `PackageConfig[]` | no | Packages to install. If omitted, only the base runtime is initialized. | +| `packages[].pip` | `string` | yes | pip install specifier (e.g., `"pathsim==0.16.5"`). | +| `packages[].import` | `string` | yes | Python import name for verification (e.g., `"pathsim"`). | +| `packages[].required` | `boolean` | yes | If `true`, failure to install this package is a fatal error. | +| `packages[].pre` | `boolean` | yes | If `true`, pass `pre=True` to pip (allow pre-release versions). | + +**Success Response:** +```json +{ + "type": "ready", + "messages": [ + { "type": "progress", "value": "Installing pathsim..." }, + { "type": "stdout", "value": "pathsim 0.16.5 loaded successfully" }, + { "type": "progress", "value": "Installing pathsim_chem..." }, + { "type": "stdout", "value": "pathsim_chem 0.2rc3.dev1 loaded successfully" } + ] +} +``` + +The `messages` array contains all `progress`, `stdout`, and `stderr` messages that were generated during initialization, in order. + +**Error Response:** +```json +{ + "type": "error", + "error": "Failed to install required package pathsim==0.16.5: ..." +} +``` + +--- + +#### POST /api/exec + +Execute Python code with no return value. + +**Request:** +``` +POST /api/exec +Content-Type: application/json +X-Session-ID: +``` + +```json +{ + "id": "repl_1", + "code": "x = 42\nprint('hello')" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | `string` | yes | Request ID for correlation. | +| `code` | `string` | yes | Python code to execute. | + +**Success Response:** +```json +{ + "type": "ok", + "id": "repl_1", + "stdout": "hello", + "stderr": "" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"ok"` | yes | Success discriminant. | +| `id` | `string` | yes | Echoed request ID. | +| `stdout` | `string` | no | Captured stdout output during execution. | +| `stderr` | `string` | no | Captured stderr output during execution. | + +**Error Response:** +```json +{ + "type": "error", + "id": "repl_1", + "error": "NameError: name 'y' is not defined", + "traceback": "Traceback (most recent call last):\n ...", + "stdout": "", + "stderr": "" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"error"` | yes | Error discriminant. | +| `id` | `string` | yes | Echoed request ID. | +| `error` | `string` | yes | Error message. | +| `traceback` | `string` | no | Python traceback. | +| `stdout` | `string` | no | Any stdout captured before the error. | +| `stderr` | `string` | no | Any stderr captured before the error. | + +--- + +#### POST /api/eval + +Evaluate a Python expression and return the JSON-serialized result. + +**Request:** +``` +POST /api/eval +Content-Type: application/json +X-Session-ID: +``` + +```json +{ + "id": "repl_2", + "expr": "json.dumps({'x': x, 'y': [1,2,3]})" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | `string` | yes | Request ID. | +| `expr` | `string` | yes | Python expression to evaluate. Result must be JSON-serializable. | + +**Success Response:** +```json +{ + "type": "value", + "id": "repl_2", + "value": "{\"x\": 42, \"y\": [1, 2, 3]}", + "stdout": "", + "stderr": "" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | `"value"` | yes | Success discriminant. | +| `id` | `string` | yes | Echoed request ID. | +| `value` | `string` | yes | JSON-serialized result. The client parses this with `JSON.parse()`. | +| `stdout` | `string` | no | Captured stdout. | +| `stderr` | `string` | no | Captured stderr. | + +**Error Response:** Same shape as exec error response. + +--- + +#### POST /api/stream + +Start a streaming loop. Returns a Server-Sent Events stream. + +**Request:** +``` +POST /api/stream +Content-Type: application/json +X-Session-ID: +``` + +```json +{ + "id": "repl_3", + "expr": "step_simulation()" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `id` | `string` | yes | Stream ID. | +| `expr` | `string` | yes | Python expression to evaluate each iteration. Should return `{done: bool, result: any}`. | + +**Response:** `Content-Type: text/event-stream` (SSE). See [Section 7](#7-sse-event-format). + +--- + +#### POST /api/stream/exec + +Queue code for execution between streaming steps. + +**Request:** +``` +POST /api/stream/exec +Content-Type: application/json +X-Session-ID: +``` + +```json +{ + "code": "controller.set_gain(2.0)" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `code` | `string` | yes | Python code to queue. | + +**Response:** +```json +{ + "status": "queued" +} +``` + +--- + +#### POST /api/stream/stop + +Stop the active streaming loop. + +**Request:** +``` +POST /api/stream/stop +Content-Type: application/json +X-Session-ID: +``` + +```json +{} +``` + +**Response:** +```json +{ + "status": "stopped" +} +``` + +--- + +#### DELETE /api/session + +Terminate the session and destroy the server-side Python worker process. + +**Request:** +``` +DELETE /api/session +X-Session-ID: +``` + +**Response:** +```json +{ + "status": "terminated" +} +``` + +--- + +## 7. SSE Event Format + +The `/api/stream` endpoint returns a standard Server-Sent Events stream. Each event has an `event` field (the event type) and a `data` field (JSON payload). Events are separated by double newlines. + +### Event Types + +#### `data` -- Streaming step result + +``` +event: data +data: {"done":false,"result":{"t":0.5,"values":[1.2,3.4]}} + +``` + +The `data` field is a JSON string matching the return value of the stream expression. + +#### `stdout` -- Captured standard output + +``` +event: stdout +data: "Step 50 complete" + +``` + +The `data` field is a JSON-encoded string. + +#### `stderr` -- Captured standard error + +``` +event: stderr +data: "Stream exec error: NameError: name 'foo' is not defined" + +``` + +The `data` field is a JSON-encoded string. + +#### `done` -- Stream completed + +``` +event: done +data: {} + +``` + +Sent when the streaming loop exits (any reason). This is always the last event in the stream. The SSE connection closes after this event. + +#### `error` -- Stream error + +``` +event: error +data: {"error":"ZeroDivisionError: division by zero","traceback":"Traceback ..."} + +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `error` | `string` | yes | Error message. | +| `traceback` | `string` | no | Python traceback. | + +Sent when the main stream expression throws. The `done` event is NOT sent after `error` in the SSE stream (the error event terminates the stream). The client-side `FlaskBackend` maps this to an `onError` callback. + +### Complete SSE Example + +``` +event: data +data: {"done":false,"result":{"t":0.0,"values":[0.0]}} + +event: stdout +data: "Simulation step 1" + +event: data +data: {"done":false,"result":{"t":0.5,"values":[0.25]}} + +event: data +data: {"done":false,"result":{"t":1.0,"values":[1.0]}} + +event: done +data: {} + +``` + +--- + +## 8. State Management + +All backends share a single `backendState` Svelte writable store defined in `src/lib/pyodide/backend/state.ts`. This store drives the UI loading indicators, error displays, and ready checks. + +```typescript +const initialState: BackendState = { + initialized: false, + loading: false, + error: null, + progress: '' +}; +``` + +### State Transitions + +Backends must follow this state machine: + +``` + init() called + [idle] ──────────────────────────> [loading] + initialized: false initialized: false + loading: false loading: true + error: null error: null + progress: '' progress: "Loading..." + | + ┌────────────────┼────────────────┐ + | | | + (progress) (success) (failure) + | | | + [loading] [ready] [error] + progress: initialized: true loading: false + "Installing..." loading: false error: "msg" + progress: "Ready" + | + terminate() + | + [idle] (backendState.reset()) +``` + +### Rules for Implementations + +1. **Set `loading = true` and `error = null`** at the start of `init()`. +2. **Update `progress`** during loading to give the user feedback (e.g., `"Loading Pyodide..."`, `"Installing pathsim..."`). +3. **Set `initialized = true` and `loading = false`** on successful initialization. +4. **Set `loading = false` and `error = `** on initialization failure. +5. **Call `backendState.reset()`** in `terminate()` to return to the idle state. +6. **Set `error`** when execution errors occur that affect the overall backend health (not for per-request errors, which are returned via the promise rejection or error callback). + +--- + +## 9. Backend Registration + +To add a new backend type to PathView: + +### Step 1: Implement the Backend Interface + +Create a new file (e.g., `src/lib/pyodide/backend/remote/backend.ts`): + +```typescript +import type { Backend, BackendState } from '../types'; +import { backendState } from '../state'; + +export class RemoteBackend implements Backend { + // ... implement all methods from the Backend interface +} +``` + +### Step 2: Add Type to BackendType Union + +In `src/lib/pyodide/backend/registry.ts`: + +```typescript +export type BackendType = 'pyodide' | 'flask' | 'remote'; +``` + +### Step 3: Add Case to createBackend() + +In `src/lib/pyodide/backend/registry.ts`: + +```typescript +export function createBackend(type: BackendType): Backend { + switch (type) { + case 'pyodide': + return new PyodideBackend(); + case 'flask': + return new FlaskBackend(flaskHost); + case 'remote': + return new RemoteBackend(remoteConfig); + default: + throw new Error(`Unknown backend type: ${type}`); + } +} +``` + +### Step 4: Re-export from index.ts + +In `src/lib/pyodide/backend/index.ts`: + +```typescript +export { RemoteBackend } from './remote/backend'; +``` + +The backend is now available via `switchBackend('remote')` or the `?backend=remote` URL parameter (if `initBackendFromUrl()` is updated to handle it). + +--- + +## 10. Worked Example + +This section traces a complete session: initialization with packages, code execution, expression evaluation, then a streaming simulation with code injection and stop. Both the abstract message protocol and the HTTP equivalents are shown. + +### 10.1 Abstract Message Flow + +``` +=== INITIALIZATION === + +main → worker: { type: "init" } +worker → main: { type: "progress", value: "Loading Pyodide..." } +worker → main: { type: "progress", value: "Installing dependencies..." } +worker → main: { type: "progress", value: "Installing pathsim..." } +worker → main: { type: "stdout", value: "pathsim 0.16.5 loaded successfully" } +worker → main: { type: "progress", value: "Installing pathsim_chem..." } +worker → main: { type: "stdout", value: "pathsim_chem 0.2rc3.dev1 loaded successfully" } +worker → main: { type: "ready" } + +=== EXEC: Set up simulation === + +main → worker: { type: "exec", id: "repl_1", code: "import json\nfrom pathsim import Simulation, Connection\nfrom pathsim.blocks import Integrator, Constant, Scope\nfrom pathsim.solvers import SSPRK22\n\nconstant = Constant(value=1.0)\nintegrator = Integrator(initial_value=0.0)\nscope = Scope()\nsim = Simulation(\n [constant, integrator, scope],\n [Connection(constant[0], integrator[0]),\n Connection(integrator[0], scope[0])],\n Solver=SSPRK22, dt=0.01\n)" } +worker → main: { type: "ok", id: "repl_1" } + +=== EXEC: Set up streaming generator === + +main → worker: { type: "exec", id: "repl_2", code: "sim_iter = sim.run_generator(duration=10, steps_per_yield=100)\ndef step_simulation():\n try:\n result = next(sim_iter)\n return {'done': False, 'result': result}\n except StopIteration:\n return {'done': True, 'result': None}" } +worker → main: { type: "ok", id: "repl_2" } + +=== EVAL: Check initial state === + +main → worker: { type: "eval", id: "repl_3", expr: "json.dumps({'t': 0, 'ready': True})" } +worker → main: { type: "value", id: "repl_3", value: "{\"t\": 0, \"ready\": true}" } + +=== STREAMING: Run simulation with live updates === + +main → worker: { type: "stream-start", id: "repl_4", expr: "json.dumps(step_simulation(), default=str)" } +worker → main: { type: "stream-data", id: "repl_4", value: "{\"done\":false,\"result\":{\"t\":1.0}}" } +worker → main: { type: "stream-data", id: "repl_4", value: "{\"done\":false,\"result\":{\"t\":2.0}}" } + +--- User changes a parameter at t=2.0 --- + +main → worker: { type: "stream-exec", code: "constant.set(value=2.0)" } + +--- Worker drains queue, applies change, then continues --- + +worker → main: { type: "stream-data", id: "repl_4", value: "{\"done\":false,\"result\":{\"t\":3.0}}" } +worker → main: { type: "stream-data", id: "repl_4", value: "{\"done\":false,\"result\":{\"t\":4.0}}" } + +--- User stops the simulation at t=4.0 --- + +main → worker: { type: "stream-stop" } + +--- Worker finishes current step --- + +worker → main: { type: "stream-data", id: "repl_4", value: "{\"done\":false,\"result\":{\"t\":5.0}}" } +worker → main: { type: "stream-done", id: "repl_4" } +``` + +### 10.2 HTTP Equivalents + +The same session expressed as HTTP requests (Flask backend): + +``` +=== INITIALIZATION === + +GET /api/health +→ 200 {"status": "ok"} + +POST /api/init +Headers: X-Session-ID: a1b2c3d4-... +Body: { + "packages": [ + {"pip": "pathsim==0.16.5", "import": "pathsim", "required": true, "pre": true}, + {"pip": "pathsim-chem>=0.2rc2", "import": "pathsim_chem", "required": false, "pre": true} + ] +} +→ 200 { + "type": "ready", + "messages": [ + {"type": "progress", "value": "Installing pathsim..."}, + {"type": "stdout", "value": "pathsim 0.16.5 loaded successfully"}, + {"type": "progress", "value": "Installing pathsim_chem..."}, + {"type": "stdout", "value": "pathsim_chem 0.2rc3.dev1 loaded successfully"} + ] +} + +=== EXEC === + +POST /api/exec +Headers: Content-Type: application/json, X-Session-ID: a1b2c3d4-... +Body: {"id": "repl_1", "code": "import json\nfrom pathsim import ..."} +→ 200 {"type": "ok", "id": "repl_1"} + +POST /api/exec +Headers: Content-Type: application/json, X-Session-ID: a1b2c3d4-... +Body: {"id": "repl_2", "code": "sim_iter = sim.run_generator(...)..."} +→ 200 {"type": "ok", "id": "repl_2"} + +=== EVAL === + +POST /api/eval +Headers: Content-Type: application/json, X-Session-ID: a1b2c3d4-... +Body: {"id": "repl_3", "expr": "json.dumps({'t': 0, 'ready': True})"} +→ 200 {"type": "value", "id": "repl_3", "value": "{\"t\": 0, \"ready\": true}"} + +=== STREAMING === + +POST /api/stream +Headers: Content-Type: application/json, X-Session-ID: a1b2c3d4-... +Body: {"id": "repl_4", "expr": "json.dumps(step_simulation(), default=str)"} +→ 200 (SSE stream) + + event: data + data: {"done":false,"result":{"t":1.0}} + + event: data + data: {"done":false,"result":{"t":2.0}} + +--- concurrent request --- + +POST /api/stream/exec +Headers: Content-Type: application/json, X-Session-ID: a1b2c3d4-... +Body: {"code": "constant.set(value=2.0)"} +→ 200 {"status": "queued"} + +--- SSE continues --- + + event: data + data: {"done":false,"result":{"t":3.0}} + + event: data + data: {"done":false,"result":{"t":4.0}} + +--- concurrent request --- + +POST /api/stream/stop +Headers: Content-Type: application/json, X-Session-ID: a1b2c3d4-... +Body: {} +→ 200 {"status": "stopped"} + +--- SSE concludes --- + + event: data + data: {"done":false,"result":{"t":5.0}} + + event: done + data: {} + +=== CLEANUP === + +DELETE /api/session +Headers: X-Session-ID: a1b2c3d4-... +→ 200 {"status": "terminated"} +``` + +--- + +## Notes for Backend Authors + +1. **JSON serialization.** All values crossing the boundary (eval results, stream data) must be valid JSON strings. The Python side should use `json.dumps()` with a `default` handler for non-serializable types. + +2. **ID correlation.** The `id` field in requests is echoed in responses. Clients use it to match responses to pending promises. Generate unique IDs (the convention is `"repl_N"` with an incrementing counter). + +3. **Timeout handling.** Clients set their own timeouts. Backends should not enforce timeouts unless they need to protect server resources. If a backend does enforce timeouts, it should send an `error` response with a clear timeout message. + +4. **Stdout/stderr capture.** Backends must capture Python's stdout and stderr and forward them as `stdout`/`stderr` messages. For Web Worker backends, this is done via Pyodide's `setStdout`/`setStderr`. For HTTP backends, captured output is included in the response body or sent as SSE events during streaming. + +5. **Session isolation.** HTTP-based backends must isolate sessions. Each `X-Session-ID` maps to a separate Python process or namespace. Leaking state between sessions is a correctness and security issue. + +6. **Idempotent init.** Calling `init()` when already initialized or loading should be a no-op, not an error. + +7. **Graceful terminate.** `terminate()` must clean up all resources: reject pending promises, abort streams, kill workers/processes, and reset state. It should not throw. diff --git a/docs/toolbox-spec.md b/docs/toolbox-spec.md new file mode 100644 index 00000000..891da7e4 --- /dev/null +++ b/docs/toolbox-spec.md @@ -0,0 +1,699 @@ +# PathView Toolbox Integration Specification + +**Version:** 1.0.0 + +A toolbox is a Python package that provides computational blocks (and optionally events) for the PathView visual simulation environment. Toolboxes extend PathView with new block types that appear in the Block Library panel. + +This document is the authoritative reference for third-party developers creating toolbox packages and integrating them into PathView. + +Existing toolboxes: `pathsim` (core simulation blocks), `pathsim-chem` (chemical engineering blocks). + +--- + +## Table of Contents + +- [1. Overview](#1-overview) +- [2. Python Package Requirements](#2-python-package-requirements) + - [2.1 Block Classes](#21-block-classes) + - [2.2 Event Classes](#22-event-classes) +- [3. Toolbox Config Directory](#3-toolbox-config-directory) +- [4. blocks.json Schema](#4-blocksjson-schema) +- [5. events.json Schema](#5-eventsjson-schema) +- [6. requirements-pyodide.txt](#6-requirements-pyodidetxt) +- [7. Extraction Pipeline](#7-extraction-pipeline) + - [7.1 Discovery](#71-discovery) + - [7.2 Block Extraction Flow](#72-block-extraction-flow) + - [7.3 Event Extraction Flow](#73-event-extraction-flow) + - [7.4 Dependency Extraction Flow](#74-dependency-extraction-flow) +- [8. Generated Output](#8-generated-output) + - [8.1 blocks.ts](#81-blocksts) + - [8.2 events.ts](#82-eventsts) + - [8.3 dependencies.ts](#83-dependenciests) +- [9. Block Metadata Contract (Block.info())](#9-block-metadata-contract-blockinfo) + - [9.1 Port Label Semantics](#91-port-label-semantics) + - [9.2 Parameter Type Inference](#92-parameter-type-inference) +- [10. Runtime Package Installation](#10-runtime-package-installation) +- [11. Step-by-Step Walkthrough](#11-step-by-step-walkthrough) +- [12. UI Configuration (Optional)](#12-ui-configuration-optional) + +--- + +## 1. Overview + +Each toolbox provides a set of block types (and optionally event types) that PathView discovers, extracts metadata from, and presents in the Block Library panel. The integration requires three things: + +1. **A Python package** with blocks that implement the `Block.info()` classmethod (and optionally an `events` submodule). +2. **A config directory** at `scripts/config//` containing `blocks.json` (and optionally `events.json`). +3. **An entry** in `scripts/config/requirements-pyodide.txt` so both the Pyodide and Flask backends install the package at runtime. + +The extraction pipeline (`npm run extract`) reads the config files, imports the Python packages, calls `Block.info()` on each class, and generates TypeScript source files that PathView consumes at build time. No manual registration beyond these three steps is needed. + +--- + +## 2. Python Package Requirements + +### 2.1 Block Classes + +The toolbox Python package must have an importable module containing block classes. For example, a package named `pathsim-controls` (installed as `pathsim_controls`) would expose blocks via `pathsim_controls.blocks`. + +Each block class must implement the `info()` classmethod returning a dict with the following keys: + +```python +@classmethod +def info(cls): + return { + "input_port_labels": {"x": 0, "y": 1}, # or None or {} + "output_port_labels": {"out": 0}, # or None or {} + "parameters": { + "gain": {"default": 1.0}, + "mode": {"default": "linear"} + }, + "description": "A proportional controller block." + } +``` + +| Key | Type | Description | +|-----|------|-------------| +| `input_port_labels` | `dict`, `None`, or `{}` | Defines input port names and indices. See [Port Label Semantics](#91-port-label-semantics). | +| `output_port_labels` | `dict`, `None`, or `{}` | Defines output port names and indices. See [Port Label Semantics](#91-port-label-semantics). | +| `parameters` | `dict` | Map of parameter names to dicts containing at minimum a `"default"` key. | +| `description` | `str` | RST-formatted docstring. The first line/sentence is used as the short description. | + +If a block does not implement `info()`, the extractor falls back to `__init__` signature introspection, but this is less reliable. All new toolbox blocks should implement `info()`. + +### 2.2 Event Classes + +Event classes live in a separate submodule (e.g., `pathsim_controls.events`). Events do not use an `info()` classmethod. Instead, the extractor inspects the `__init__` signature to discover parameters: + +```python +class ThresholdEvent: + """Triggers when a signal crosses a threshold value.""" + + def __init__(self, func_evt=None, func_act=None, threshold=0.0, tolerance=1e-4): + ... +``` + +Parameter names, default values, and the class docstring are extracted automatically. + +--- + +## 3. Toolbox Config Directory + +Each toolbox has a config directory at: + +``` +scripts/config// +``` + +The directory name should match the pip package name (e.g., `pathsim-chem`, `pathsim-controls`). + +Required contents: + +| File | Required | Description | +|------|----------|-------------| +| `blocks.json` | Yes | Block categories and class names to extract | +| `events.json` | No | Event class names to extract | + +Example directory structure: + +``` +scripts/config/ + schemas/ # JSON schemas (shared, do not modify) + blocks.schema.json + events.schema.json + pathsim/ # Core toolbox + blocks.json + events.json + simulation.json + pathsim-chem/ # Chemical engineering toolbox + blocks.json + pathsim-controls/ # Your new toolbox + blocks.json + events.json +``` + +--- + +## 4. blocks.json Schema + +A `blocks.json` file declares which block classes to extract from a toolbox and how to organize them into categories. + +```json +{ + "$schema": "../schemas/blocks.schema.json", + "toolbox": "pathsim-controls", + "importPath": "pathsim_controls.blocks", + "categories": { + "Controls": ["PIDController", "StateEstimator"] + }, + "extraDocstrings": [] +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `$schema` | string | No | JSON Schema reference for editor validation. Should be `"../schemas/blocks.schema.json"`. | +| `toolbox` | string | Yes | Toolbox identifier. Must match the config directory name. | +| `importPath` | string | Yes | Python import path to the blocks module (e.g., `"pathsim_controls.blocks"`). | +| `categories` | object | Yes | Map of category names to arrays of block entries. Category names appear as section headers in the Block Library panel. | +| `extraDocstrings` | string[] | No | Additional classes to extract docstrings from (not blocks themselves). Used by the core toolbox for `Subsystem` and `Interface`. | + +**Block entries** within each category array can be either: + +- **A string** -- the class name, imported from `importPath`: + ```json + "PIDController" + ``` +- **An object** -- for classes that live in a different module than `importPath`: + ```json + {"class": "PIDController", "import": "pathsim_controls.advanced"} + ``` + +**Real-world example** (`pathsim-chem/blocks.json`): + +```json +{ + "$schema": "../schemas/blocks.schema.json", + "toolbox": "pathsim-chem", + "importPath": "pathsim_chem.tritium", + "categories": { + "Chemical": ["Process", "Bubbler4", "Splitter", "GLC"] + } +} +``` + +--- + +## 5. events.json Schema + +An `events.json` file declares which event classes to extract from a toolbox. + +```json +{ + "$schema": "../schemas/events.schema.json", + "toolbox": "pathsim-controls", + "importPath": "pathsim_controls.events", + "events": ["ThresholdEvent", "TimerEvent"] +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `$schema` | string | No | JSON Schema reference for editor validation. Should be `"../schemas/events.schema.json"`. | +| `toolbox` | string | Yes | Toolbox identifier. Must match the config directory name. | +| `importPath` | string | Yes | Python import path to the events module (e.g., `"pathsim_controls.events"`). | +| `events` | string[] | Yes | List of event class names to extract from the module. | + +**Real-world example** (`pathsim/events.json`): + +```json +{ + "$schema": "../schemas/events.schema.json", + "toolbox": "pathsim", + "importPath": "pathsim.events", + "events": [ + "ZeroCrossing", + "ZeroCrossingUp", + "ZeroCrossingDown", + "Schedule", + "ScheduleList", + "Condition" + ] +} +``` + +--- + +## 6. requirements-pyodide.txt + +The file `scripts/config/requirements-pyodide.txt` is the single source of truth for runtime Python dependencies. Both the Pyodide web worker and the Flask backend install packages from this file. + +**Syntax:** + +``` +--pre +pathsim +pathsim-chem>=0.2rc2 # optional +pathsim-controls # optional +``` + +| Syntax Element | Description | +|----------------|-------------| +| `--pre` | Global flag. Applies to all packages below it. Allows pre-release versions (e.g., `rc`, `beta`). | +| `pathsim` | A required package. If installation fails, app startup is blocked. | +| `pathsim-chem>=0.2rc2 # optional` | An optional package with a version specifier. The `# optional` comment sets the package's `required` field to `false` -- installation failure will not block app startup. | +| `>=`, `==`, `~=`, `<`, `<=`, `>` | Standard pip version specifiers. All are supported. | + +**How the file is parsed** (from `ConfigLoader.load_requirements()`): + +1. Lines starting with `#` are ignored (pure comments). +2. The `--pre` flag sets `pre: true` for all subsequent packages. +3. For each package line, the pip spec is the text before any `#` comment. +4. If the comment contains `# optional` (case-insensitive), `required` is set to `false`. +5. The Python import name is derived by stripping version specifiers and replacing `-` with `_` (e.g., `pathsim-chem` becomes `pathsim_chem`). + +--- + +## 7. Extraction Pipeline + +The extraction is run via: + +```bash +npm run extract # Extract all (blocks, events, simulation, dependencies) +python scripts/extract.py # Same, invoked directly +``` + +Selective extraction is also supported: + +```bash +python scripts/extract.py --blocks # Blocks only +python scripts/extract.py --events # Events only +python scripts/extract.py --deps # Dependencies only +python scripts/extract.py --registry # JSON registry only (for pvm2py) +python scripts/extract.py --validate # Validate config files only +``` + +### 7.1 Discovery + +The `discover_toolboxes()` method scans `scripts/config/` for subdirectories that contain a `blocks.json` file (excluding the `schemas/` directory). Toolboxes are auto-discovered -- no manual registration step is needed beyond creating the config directory. + +### 7.2 Block Extraction Flow + +For each discovered toolbox: + +1. Load and validate `blocks.json`. +2. Import the Python module specified in `importPath`. +3. For each class name in each category: + a. Get the class object from the module via `getattr()`. + b. Call `cls.info()` to get the metadata dict. + c. Process `input_port_labels` and `output_port_labels` via `_process_port_labels()` -- converting the dict to a sorted list of names, `None` for variable ports, or `[]` for no ports. + d. For each parameter, infer the type from the default value (see [Parameter Type Inference](#92-parameter-type-inference)). + e. Extract parameter descriptions from the RST docstring. + f. Convert the full docstring to HTML using `docutils` (if available). +4. If `info()` is not available, fall back to `__init__` signature introspection. +5. Process `extraDocstrings` classes (docstring-only extraction, no parameter/port extraction). +6. Generate `src/lib/nodes/generated/blocks.ts`. + +### 7.3 Event Extraction Flow + +For each discovered toolbox that has an `events.json`: + +1. Load and validate `events.json`. +2. Import the Python module specified in `importPath`. +3. For each event class name: + a. Get the class object from the module. + b. Inspect the `__init__` signature for parameters (skipping `self`). + c. Infer parameter types from names and default values. + d. Extract parameter descriptions from the class docstring. + e. Convert the full docstring to HTML. +4. Generate `src/lib/events/generated/events.ts`. + +### 7.4 Dependency Extraction Flow + +1. Parse `scripts/config/requirements-pyodide.txt` into a list of package configs. +2. For each package, import the module and read `__version__` to get the installed version. +3. Pin exact versions for release builds (dev versions keep the original spec). +4. Load `scripts/config/pyodide.json` for Pyodide runtime configuration. +5. Generate `src/lib/constants/dependencies.ts` containing the `PYTHON_PACKAGES` array. + +--- + +## 8. Generated Output + +The extraction pipeline generates three TypeScript files. These are auto-generated and should not be edited by hand. + +### 8.1 blocks.ts + +**Path:** `src/lib/nodes/generated/blocks.ts` + +```typescript +export interface ExtractedParam { + type: string; // "integer", "number", "string", "boolean", "callable", "array", "any" + default: string | null; + description: string; + min?: number; + max?: number; + options?: string[]; +} + +export interface ExtractedBlock { + blockClass: string; + description: string; + docstringHtml: string; + params: Record; + inputs: string[] | null; // null = variable, [] = none, [...] = fixed named + outputs: string[] | null; // null = variable, [] = none, [...] = fixed named +} + +export const extractedBlocks: Record = { ... }; + +export const blockConfig: Record = { + Sources: ["Constant", "Source", ...], + Controls: ["PIDController", "StateEstimator"], + ... +}; + +export const blockImportPaths: Record = { + "Constant": "pathsim.blocks", + "PIDController": "pathsim_controls.blocks", + ... +}; +``` + +The `blockConfig` object maps category names (used as Block Library section headers) to arrays of block class names. The `blockImportPaths` object maps each block class name to its Python import path. + +### 8.2 events.ts + +**Path:** `src/lib/events/generated/events.ts` + +```typescript +import type { EventTypeDefinition } from '../types'; + +export const extractedEvents: EventTypeDefinition[] = [ + { + type: "pathsim.events.ZeroCrossing", // fully qualified Python path + name: "ZeroCrossing", // display name + eventClass: "ZeroCrossing", // class name + description: "...", + docstringHtml: "...", + params: [ + { name: "func_evt", type: "callable", default: "None", description: "..." }, + { name: "func_act", type: "callable", default: "None", description: "..." }, + { name: "tolerance", type: "number", default: "0.0001", description: "..." } + ] + } +]; +``` + +The `type` field uses the fully qualified Python import path (e.g., `"pathsim.events.ZeroCrossing"`). This is used in `.pvm` files to reference event types and for code generation. + +### 8.3 dependencies.ts + +**Path:** `src/lib/constants/dependencies.ts` + +```typescript +export interface PackageConfig { + pip: string; // pip install spec (e.g., "pathsim==0.16.5") + pre: boolean; // whether to use --pre flag + required: boolean; // whether failure blocks startup + import: string; // Python import name (e.g., "pathsim_chem") +} + +export const PYTHON_PACKAGES: PackageConfig[] = [ + { + "pip": "pathsim==0.16.5", + "required": true, + "pre": true, + "import": "pathsim" + }, + { + "pip": "pathsim-chem>=0.2rc2", + "required": false, + "pre": true, + "import": "pathsim_chem" + } +]; +``` + +This file also exports `PYODIDE_VERSION`, `PYODIDE_CDN_URL`, `PYODIDE_PRELOAD`, `PATHVIEW_VERSION`, and `EXTRACTED_VERSIONS`. + +--- + +## 9. Block Metadata Contract (Block.info()) + +The `info()` classmethod is the primary interface between a toolbox's Python code and PathView's extraction pipeline. + +```python +@classmethod +def info(cls): + return { + "input_port_labels": {"x": 0, "y": 1}, + "output_port_labels": {"out": 0}, + "parameters": { + "gain": {"default": 1.0}, + "mode": {"default": "linear"} + }, + "description": "A proportional controller block." + } +``` + +### 9.1 Port Label Semantics + +The values of `input_port_labels` and `output_port_labels` have precise semantics that control both extraction and UI behavior: + +| Value | Meaning | Extracted As | UI Behavior | +|-------|---------|--------------|-------------| +| `None` | Variable/unlimited ports | `null` in TypeScript | Add/remove port buttons shown | +| `{}` | No ports of this direction | `[]` (empty array) | No ports rendered | +| `{"name": index, ...}` | Fixed labeled ports | `["name", ...]` sorted by index | Ports locked, names displayed | + +The extraction function `_process_port_labels()` converts dicts to sorted name lists: + +```python +{"x": 0, "y": 1} # → ["x", "y"] (sorted by index value) +{"out": 0} # → ["out"] +None # → None (variable ports) +{} # → [] (no ports) +``` + +### 9.2 Parameter Type Inference + +The extractor infers TypeScript-side parameter types from Python default values using these rules (evaluated in order): + +| Condition | Inferred Type | +|-----------|---------------| +| Name starts with `func_` or `func`, or default is callable | `"callable"` | +| Default is `True` or `False` | `"boolean"` | +| Default is `int` (and not `bool`) | `"integer"` | +| Default is `float` | `"number"` | +| Default is `str` | `"string"` | +| Default is `list`, `tuple`, or ndarray | `"array"` | +| Default is `None` or anything else | `"any"` | + +Note: `bool` is checked before `int` because in Python `isinstance(True, int)` is `True`. + +--- + +## 10. Runtime Package Installation + +The `PYTHON_PACKAGES` constant (generated from `requirements-pyodide.txt`) drives package installation in both backends: + +**Pyodide backend** (`src/lib/pyodide/backend/pyodide/worker.ts`): +```typescript +for (const pkg of PYTHON_PACKAGES) { + await pyodide.runPythonAsync(` + import micropip + await micropip.install('${pkg.pip}'${pkg.pre ? ', pre=True' : ''}) + `); +} +``` + +**Flask backend** (`src/lib/pyodide/backend/flask/backend.ts`): +```typescript +await fetch(`${baseUrl}/api/init`, { + method: 'POST', + body: JSON.stringify({ packages: PYTHON_PACKAGES }), +}); +``` + +Both backends respect the `required` flag. If a package has `required: false` (from the `# optional` comment in requirements), installation failure is caught and logged but does not block app startup. If `required: true`, a failed installation aborts initialization. + +--- + +## 11. Step-by-Step Walkthrough + +This section walks through adding a hypothetical `pathsim-controls` toolbox with two blocks (`PIDController`, `StateEstimator`) and one event (`ThresholdEvent`). + +### Step 1: Add to requirements + +Edit `scripts/config/requirements-pyodide.txt`: + +``` +--pre +pathsim +pathsim-chem>=0.2rc2 # optional +pathsim-controls # optional +``` + +The `# optional` comment means PathView will continue loading if this package is unavailable. + +### Step 2: Create config directory + +Create the directory `scripts/config/pathsim-controls/`. + +**`scripts/config/pathsim-controls/blocks.json`:** + +```json +{ + "$schema": "../schemas/blocks.schema.json", + "toolbox": "pathsim-controls", + "importPath": "pathsim_controls.blocks", + "categories": { + "Controls": ["PIDController", "StateEstimator"] + } +} +``` + +**`scripts/config/pathsim-controls/events.json`:** + +```json +{ + "$schema": "../schemas/events.schema.json", + "toolbox": "pathsim-controls", + "importPath": "pathsim_controls.events", + "events": ["ThresholdEvent"] +} +``` + +### Step 3: Ensure the Python package is installed + +The extraction script needs to import the package. Install it in your development environment: + +```bash +pip install pathsim-controls +``` + +Verify the blocks module is importable and `info()` works: + +```python +from pathsim_controls.blocks import PIDController +print(PIDController.info()) +``` + +### Step 4: Run the extraction + +```bash +npm run extract +``` + +Expected output: + +``` +PathSim Metadata Extractor +======================================== + +Extracting dependencies... + Pinned pathsim to version 0.16.5 + pathsim-controls ... + +Extracting blocks... + Processing toolbox: pathsim + Extracted Constant + ... + Processing toolbox: pathsim-controls + Extracted PIDController + Extracted StateEstimator +Generated: src/lib/nodes/generated/blocks.ts + +Extracting events... + Processing events from: pathsim + Extracted ZeroCrossing + ... + Processing events from: pathsim-controls + Extracted ThresholdEvent +Generated: src/lib/events/generated/events.ts + +Done! +``` + +### Step 5: Verify in dev server + +```bash +npm run dev +``` + +Open the app in a browser. The Block Library panel should now show a "Controls" section containing "PIDController" and "StateEstimator". The Events panel should list "ThresholdEvent". + +### Step 6: Build for production + +```bash +npm run build +``` + +The generated TypeScript files are compiled into the production bundle. The `PYTHON_PACKAGES` constant ensures runtime installation of `pathsim-controls` in both Pyodide and Flask backends. + +--- + +## 12. UI Configuration (Optional) + +After a toolbox's blocks are extracted and appear in the Block Library, you may want to customize their UI behavior. These configurations are in PathView's TypeScript source and are optional. + +### Port synchronization + +For blocks where each input has a corresponding output (parallel processing blocks), add the block class name to the `syncPortBlocks` set. This hides the output port controls and keeps output count in sync with input count. + +**File:** `src/lib/nodes/uiConfig.ts` + +```typescript +export const syncPortBlocks = new Set([ + 'Integrator', + 'Differentiator', + 'Delay', + // ... existing entries ... + 'PIDController', // add your block here +]); +``` + +### Port labels from parameters + +For blocks that derive port names from a parameter value (e.g., a list of channel labels), add an entry to `portLabelParams`. When the user changes the parameter, port names update automatically. + +**File:** `src/lib/nodes/uiConfig.ts` + +```typescript +export const portLabelParams: Record = { + Scope: { param: 'labels', direction: 'input' }, + Spectrum: { param: 'labels', direction: 'input' }, + Adder: { param: 'operations', direction: 'input', parser: parseOperationsString }, + // Add your block: + MyMuxBlock: { param: 'labels', direction: 'input' }, +}; +``` + +The `PortLabelConfig` interface: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `param` | string | Yes | Parameter name whose value determines port labels. | +| `direction` | `"input"` or `"output"` | Yes | Which port direction the labels apply to. | +| `parser` | function | No | Custom parser to convert the param value to a `string[]`. Default uses `parsePythonList`. | + +### Custom shapes + +Map your toolbox's categories to node shapes using the shape registry. Available built-in shapes: `pill`, `rect`, `circle`, `diamond`, `mixed`, `default`. + +**File:** `src/lib/nodes/shapes/registry.ts` + +```typescript +// Add to the categoryShapeMap or call setCategoryShape: +setCategoryShape('Controls', 'rect'); +``` + +The existing category-to-shape mapping: + +| Category | Shape | +|----------|-------| +| Sources | `pill` | +| Dynamic | `rect` | +| Algebraic | `rect` | +| Mixed | `mixed` | +| Recording | `pill` | +| Subsystem | `rect` | + +Categories not in the map use the `default` shape. + +--- + +## Notes for Toolbox Authors + +1. **`info()` is the contract.** Implement it on every block class. The extractor falls back to `__init__` introspection, but `info()` gives you explicit control over port definitions and parameter metadata. + +2. **Port labels must be consistent.** The index values in port label dicts must be zero-based and contiguous. The extractor sorts by index, so `{"y": 1, "x": 0}` correctly produces `["x", "y"]`. + +3. **Parameter defaults drive type inference.** If your parameter should be a `number` in the UI, make sure the default is a Python `float`, not `None`. Use `None` only when the type truly cannot be determined. + +4. **Docstrings are rendered as HTML.** If `docutils` is installed, RST-formatted docstrings are converted to HTML and displayed in the block documentation panel. Write proper RST with `:param:` directives for best results. + +5. **The `# optional` flag is important.** Mark your toolbox as optional in `requirements-pyodide.txt` unless PathView cannot function without it. This ensures the app starts even if your package has installation issues in a user's browser environment. + +6. **Test the extraction locally.** Run `python scripts/extract.py --validate` to check config file validity, then `python scripts/extract.py --blocks` to verify block extraction before committing. From 29a1ee09984f0aeb3016e4b8a282efc6b95e4498 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 12:16:31 +0100 Subject: [PATCH 374/656] Add spec doc references to README Co-Authored-By: KDW1 --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index c9ccad0f..34152ff4 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,8 @@ npm run build No code changes needed - the extraction script automatically discovers toolbox directories. +For the full toolbox integration reference (Python package contract, config schemas, extraction pipeline, generated output), see [**docs/toolbox-spec.md**](docs/toolbox-spec.md). + --- ## Python Backend System @@ -482,6 +484,8 @@ Then open `http://localhost:5173/?backend=flask`. - **Session TTL** — stale sessions cleaned up after 1 hour of inactivity - **Streaming** — simulations stream via SSE, with the same code injection support as Pyodide +For the full protocol reference (message types, HTTP routes, SSE format, streaming semantics, how to implement a new backend), see [**docs/backend-protocol-spec.md**](docs/backend-protocol-spec.md). + **API routes:** | Route | Method | Action | @@ -599,6 +603,14 @@ PathView uses JSON-based file formats for saving and sharing: The `.pvm` format is fully documented in [**docs/pvm-spec.md**](docs/pvm-spec.md). Use this spec if you are building tools that read or write PathView models (e.g., code generators, importers). A reference Python code generator is available at `scripts/pvm2py.py`. +### Specification Documents + +| Document | Audience | +|----------|----------| +| [**docs/pvm-spec.md**](docs/pvm-spec.md) | Building tools that read/write `.pvm` model files | +| [**docs/backend-protocol-spec.md**](docs/backend-protocol-spec.md) | Implementing a new execution backend (remote server, cloud worker, etc.) | +| [**docs/toolbox-spec.md**](docs/toolbox-spec.md) | Creating a third-party toolbox package for PathView | + ### Export Options - **File > Save** - Save complete model as `.pvm` From 281510e0b45169fd38cc600e60291a0360022576 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 12:29:37 +0100 Subject: [PATCH 375/656] Remove dead code from worker.py and FlaskBackend Co-Authored-By: KDW1 --- server/worker.py | 6 +----- src/lib/pyodide/backend/flask/backend.ts | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/server/worker.py b/server/worker.py index e73ba130..9fc3db05 100644 --- a/server/worker.py +++ b/server/worker.py @@ -26,7 +26,6 @@ # Worker state _namespace = {} -_clean_globals = set() _initialized = False # Streaming state @@ -111,7 +110,7 @@ def _ensure_package(pkg: dict) -> None: def initialize(packages: list[dict] | None = None) -> None: """Initialize the worker: install packages, import standard libs, capture clean globals.""" - global _initialized, _namespace, _clean_globals + global _initialized, _namespace if _initialized: send({"type": "ready"}) @@ -138,9 +137,6 @@ def initialize(packages: list[dict] | None = None) -> None: ) send({"type": "stderr", "value": f"Optional package {pkg.get('import', '?')} failed: {e}\n"}) - # Capture clean state for later cleanup - _clean_globals = set(_namespace.keys()) - _initialized = True send({"type": "ready"}) diff --git a/src/lib/pyodide/backend/flask/backend.ts b/src/lib/pyodide/backend/flask/backend.ts index 7f91c358..2b381d49 100644 --- a/src/lib/pyodide/backend/flask/backend.ts +++ b/src/lib/pyodide/backend/flask/backend.ts @@ -371,7 +371,6 @@ export class FlaskBackend implements Backend { const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; - let currentEvent = ''; while (true) { const { value, done } = await reader.read(); From ceba7110319e6daff83a2d1a577e320a79d48172 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 17:51:00 +0100 Subject: [PATCH 376/656] Add pip-installable package with CLI and auto-detection Co-Authored-By: KDW1 --- .github/workflows/publish-pypi.yml | 77 +++++ .gitignore | 3 + README.md | 65 +++-- package.json | 3 +- pathview_server/__init__.py | 3 + pathview_server/__main__.py | 6 + pathview_server/app.py | 406 ++++++++++++++++++++++++++ pathview_server/cli.py | 55 ++++ {server => pathview_server}/worker.py | 0 pyproject.toml | 34 +++ scripts/build_package.py | 63 ++++ server/app.py | 378 ------------------------ server/requirements.txt | 2 - src/lib/pyodide/backend/index.ts | 29 ++ src/routes/+page.svelte | 6 +- 15 files changed, 727 insertions(+), 403 deletions(-) create mode 100644 .github/workflows/publish-pypi.yml create mode 100644 pathview_server/__init__.py create mode 100644 pathview_server/__main__.py create mode 100644 pathview_server/app.py create mode 100644 pathview_server/cli.py rename {server => pathview_server}/worker.py (100%) create mode 100644 pyproject.toml create mode 100644 scripts/build_package.py delete mode 100644 server/app.py delete mode 100644 server/requirements.txt diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 00000000..0fe59be7 --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,77 @@ +name: Publish to PyPI + +on: + release: + types: [published] + workflow_dispatch: + inputs: + publish_to: + description: 'Publish target' + required: true + default: 'testpypi' + type: choice + options: + - testpypi + - pypi + +permissions: + contents: read + +jobs: + build-and-publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Python build tools + run: pip install build twine + + - name: Install Python dependencies + run: | + pip install -r scripts/config/requirements-pyodide.txt + pip install -r scripts/config/requirements-build.txt + + - name: Install Node dependencies + run: npm ci + + - name: Extract blocks from PathSim + run: npm run extract + + - name: Build package (frontend + wheel) + run: python scripts/build_package.py + + - name: Check distribution + run: twine check dist/* + + - name: Publish to Test PyPI + if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi' + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }} + run: twine upload --repository testpypi dist/* + + - name: Publish to PyPI + if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi') + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: twine upload dist/* + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ diff --git a/.gitignore b/.gitignore index b4947e25..7ae8466c 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ tmpclaude-* __pycache__/ +*.egg-info/ +dist/ +pathview_server/static/ # Generated screenshots static/examples/screenshots/ diff --git a/README.md b/README.md index 34152ff4..39087945 100644 --- a/README.md +++ b/README.md @@ -17,29 +17,39 @@ A web-based visual node editor for building and simulating dynamic systems with - [Plotly.js](https://plotly.com/javascript/) for interactive plots - [CodeMirror 6](https://codemirror.net/) for code editing -## Getting Started +## Installation + +### pip install (recommended for users) + +```bash +pip install pathview +pathview serve +``` + +This starts the PathView server with a local Python backend and opens your browser. No Node.js required. + +**Options:** +- `--port PORT` — server port (default: 5000) +- `--host HOST` — bind address (default: 127.0.0.1) +- `--no-browser` — don't auto-open the browser +- `--debug` — debug mode with auto-reload + +### Development setup ```bash npm install npm run dev ``` -To use the Flask backend (server-side Python): +To use the Flask backend during development: ```bash -pip install -r server/requirements.txt +pip install flask flask-cors npm run server # Start Flask backend on port 5000 npm run dev # Start Vite dev server (separate terminal) # Open http://localhost:5173/?backend=flask ``` -For production: - -```bash -npm run build -npm run preview -``` - ## Project Structure ``` @@ -82,10 +92,11 @@ src/ ├── routes/ # SvelteKit pages └── app.css # Global styles with CSS variables -server/ +pathview_server/ # Python package (pip install pathview) ├── app.py # Flask server (subprocess management, HTTP routes) ├── worker.py # REPL worker subprocess (Python execution) -└── requirements.txt # Server Python dependencies +├── cli.py # CLI entry point (pathview serve) +└── static/ # Bundled frontend (generated at build time) scripts/ ├── config/ # Configuration files for extraction @@ -467,15 +478,21 @@ Browser Tab Flask Server Worker Subprocess └──────────────┘ └──────────────────┘ └──────────────────┘ ``` -**Setup:** +**Standalone (pip package):** ```bash -pip install -r server/requirements.txt -npm run server # Starts Flask on port 5000 -npm run dev # Starts Vite dev server (separate terminal) +pip install pathview +pathview serve ``` -Then open `http://localhost:5173/?backend=flask`. +**Development (separate servers):** + +```bash +pip install flask flask-cors +npm run server # Starts Flask API on port 5000 +npm run dev # Starts Vite dev server (separate terminal) +# Open http://localhost:5173/?backend=flask +``` **Key properties:** - **Process isolation** — each session gets its own Python subprocess @@ -666,7 +683,8 @@ https://view.pathsim.org/?modelgh=pathsim/pathview/static/examples/feedback-syst |--------|---------| | `npm run dev` | Start Vite development server | | `npm run server` | Start Flask backend server (port 5000) | -| `npm run build` | Production build | +| `npm run build` | Production build (GitHub Pages) | +| `npm run build:package` | Build pip package (frontend + wheel) | | `npm run preview` | Preview production build | | `npm run check` | TypeScript/Svelte type checking | | `npm run lint` | Run ESLint | @@ -783,7 +801,9 @@ Port labels show the name of each input/output port alongside the node. Toggle g ## Deployment -PathView uses a dual deployment strategy with automatic versioning: +PathView has two deployment targets: + +### GitHub Pages (web) | Trigger | What happens | Deployed to | |---------|--------------|-------------| @@ -791,6 +811,13 @@ PathView uses a dual deployment strategy with automatic versioning: | Release published | Bump `package.json`, build, deploy | [view.pathsim.org/](https://view.pathsim.org/) | | Manual dispatch | Choose `dev` or `release` | Respective path | +### PyPI (pip package) + +| Trigger | What happens | Published to | +|---------|--------------|--------------| +| Release published | Build frontend + wheel, publish | [pypi.org/project/pathview](https://pypi.org/project/pathview/) | +| Manual dispatch | Choose `testpypi` or `pypi` | Respective index | + ### How it works 1. Both versions deploy to the `deployment` branch using GitHub Actions diff --git a/package.json b/package.json index a2a30f9b..7ea72aa5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "eslint .", "format": "prettier --write .", - "server": "python server/app.py" + "server": "python -m pathview_server.app", + "build:package": "python scripts/build_package.py" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.0", diff --git a/pathview_server/__init__.py b/pathview_server/__init__.py new file mode 100644 index 00000000..8808f05d --- /dev/null +++ b/pathview_server/__init__.py @@ -0,0 +1,3 @@ +"""PathView Server — local Flask backend for PathView.""" + +__version__ = "0.5.0" diff --git a/pathview_server/__main__.py b/pathview_server/__main__.py new file mode 100644 index 00000000..4082f5cc --- /dev/null +++ b/pathview_server/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for: python -m pathview_server""" + +from pathview_server.cli import main + +if __name__ == "__main__": + main() diff --git a/pathview_server/app.py b/pathview_server/app.py new file mode 100644 index 00000000..70169251 --- /dev/null +++ b/pathview_server/app.py @@ -0,0 +1,406 @@ +""" +Flask server for PathView backend. + +Manages worker subprocesses per session. Routes translate HTTP requests +into subprocess messages and relay responses back. + +Each session gets its own worker subprocess with an isolated Python namespace. +""" + +import os +import sys +import json +import subprocess +import threading +import time +import uuid +import atexit +from pathlib import Path + +from flask import Flask, Response, request, jsonify, send_from_directory +from flask_cors import CORS + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +SESSION_TTL = 3600 # 1 hour of inactivity before cleanup +CLEANUP_INTERVAL = 60 # Check for stale sessions every 60 seconds +REQUEST_TIMEOUT = 300 # 5 minutes default timeout for exec/eval +WORKER_SCRIPT = str(Path(__file__).parent / "worker.py") + +# --------------------------------------------------------------------------- +# Session management +# --------------------------------------------------------------------------- + +class Session: + """A worker subprocess bound to a session.""" + + def __init__(self, session_id: str): + self.session_id = session_id + self.last_active = time.time() + self.lock = threading.Lock() + self.process = subprocess.Popen( + [sys.executable, "-u", WORKER_SCRIPT], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, # line buffered + ) + self._initialized = False + + def send_message(self, msg: dict) -> None: + """Write a JSON message to the subprocess stdin.""" + self.last_active = time.time() + line = json.dumps(msg) + "\n" + self.process.stdin.write(line) + self.process.stdin.flush() + + def read_line(self) -> dict | None: + """Read one JSON line from the subprocess stdout.""" + line = self.process.stdout.readline() + if not line: + return None + return json.loads(line.strip()) + + def ensure_initialized(self, packages: list[dict] | None = None) -> list[dict]: + """Initialize the worker if not already done. Returns any messages received.""" + if self._initialized: + return [] + messages = [] + init_msg = {"type": "init"} + if packages: + init_msg["packages"] = packages + self.send_message(init_msg) + while True: + resp = self.read_line() + if resp is None: + raise RuntimeError("Worker process died during initialization") + messages.append(resp) + if resp.get("type") == "ready": + self._initialized = True + break + if resp.get("type") == "error": + raise RuntimeError(resp.get("error", "Unknown init error")) + return messages + + def is_alive(self) -> bool: + return self.process.poll() is None + + def kill(self) -> None: + """Kill the subprocess.""" + try: + self.process.stdin.close() + except Exception: + pass + try: + self.process.kill() + self.process.wait(timeout=5) + except Exception: + pass + + +# Global session store +_sessions: dict[str, Session] = {} +_sessions_lock = threading.Lock() + + +def get_or_create_session(session_id: str) -> Session: + """Get an existing session or create a new one.""" + with _sessions_lock: + session = _sessions.get(session_id) + if session and not session.is_alive(): + # Dead process, remove stale entry + _sessions.pop(session_id, None) + session = None + if session is None: + session = Session(session_id) + _sessions[session_id] = session + return session + + +def remove_session(session_id: str) -> None: + """Kill and remove a session.""" + with _sessions_lock: + session = _sessions.pop(session_id, None) + if session: + session.kill() + + +def cleanup_stale_sessions() -> None: + """Remove sessions that have been inactive beyond TTL.""" + while True: + time.sleep(CLEANUP_INTERVAL) + now = time.time() + stale = [] + with _sessions_lock: + for sid, session in _sessions.items(): + if now - session.last_active > SESSION_TTL: + stale.append(sid) + for sid in stale: + remove_session(sid) + + +# Start cleanup thread +_cleanup_thread = threading.Thread(target=cleanup_stale_sessions, daemon=True) +_cleanup_thread.start() + + +def _get_session_id() -> str: + """Extract session ID from request headers or generate one.""" + return request.headers.get("X-Session-ID") or str(uuid.uuid4()) + + +# --------------------------------------------------------------------------- +# App factory +# --------------------------------------------------------------------------- + +def create_app(serve_static: bool = False) -> Flask: + """Create the Flask application. + + Args: + serve_static: If True, serve the bundled frontend and skip CORS. + If False, API-only mode with CORS (for dev with Vite). + """ + app = Flask(__name__) + + if not serve_static: + CORS(app) + + # ----------------------------------------------------------------------- + # API routes + # ----------------------------------------------------------------------- + + @app.route("/api/health", methods=["GET"]) + def health(): + return jsonify({"status": "ok"}) + + @app.route("/api/init", methods=["POST"]) + def api_init(): + """Initialize a session's worker with packages from the frontend config.""" + session_id = _get_session_id() + data = request.get_json(force=True) + packages = data.get("packages", []) + + session = get_or_create_session(session_id) + with session.lock: + try: + messages = session.ensure_initialized(packages=packages) + return jsonify({"type": "ready", "messages": messages}) + except Exception as e: + return jsonify({"type": "error", "error": str(e)}), 500 + + @app.route("/api/exec", methods=["POST"]) + def api_exec(): + """Execute Python code in the session's worker.""" + session_id = _get_session_id() + data = request.get_json(force=True) + code = data.get("code", "") + msg_id = data.get("id", str(uuid.uuid4())) + + session = get_or_create_session(session_id) + with session.lock: + try: + session.ensure_initialized() + session.send_message({"type": "exec", "id": msg_id, "code": code}) + + stdout_lines = [] + stderr_lines = [] + while True: + resp = session.read_line() + if resp is None: + return jsonify({"type": "error", "id": msg_id, "error": "Worker process died"}), 500 + resp_type = resp.get("type") + if resp_type == "stdout": + stdout_lines.append(resp.get("value", "")) + elif resp_type == "stderr": + stderr_lines.append(resp.get("value", "")) + elif resp_type == "ok" and resp.get("id") == msg_id: + result = {"type": "ok", "id": msg_id} + if stdout_lines: + result["stdout"] = "".join(stdout_lines) + if stderr_lines: + result["stderr"] = "".join(stderr_lines) + return jsonify(result) + elif resp_type == "error" and resp.get("id") == msg_id: + result = resp + if stdout_lines: + result["stdout"] = "".join(stdout_lines) + if stderr_lines: + result["stderr"] = "".join(stderr_lines) + return jsonify(result), 400 + + except Exception as e: + return jsonify({"type": "error", "id": msg_id, "error": str(e)}), 500 + + @app.route("/api/eval", methods=["POST"]) + def api_eval(): + """Evaluate a Python expression in the session's worker.""" + session_id = _get_session_id() + data = request.get_json(force=True) + expr = data.get("expr", "") + msg_id = data.get("id", str(uuid.uuid4())) + + session = get_or_create_session(session_id) + with session.lock: + try: + session.ensure_initialized() + session.send_message({"type": "eval", "id": msg_id, "expr": expr}) + + stdout_lines = [] + stderr_lines = [] + while True: + resp = session.read_line() + if resp is None: + return jsonify({"type": "error", "id": msg_id, "error": "Worker process died"}), 500 + resp_type = resp.get("type") + if resp_type == "stdout": + stdout_lines.append(resp.get("value", "")) + elif resp_type == "stderr": + stderr_lines.append(resp.get("value", "")) + elif resp_type == "value" and resp.get("id") == msg_id: + result = resp + if stdout_lines: + result["stdout"] = "".join(stdout_lines) + if stderr_lines: + result["stderr"] = "".join(stderr_lines) + return jsonify(result) + elif resp_type == "error" and resp.get("id") == msg_id: + result = resp + if stdout_lines: + result["stdout"] = "".join(stdout_lines) + if stderr_lines: + result["stderr"] = "".join(stderr_lines) + return jsonify(result), 400 + + except Exception as e: + return jsonify({"type": "error", "id": msg_id, "error": str(e)}), 500 + + @app.route("/api/stream", methods=["POST"]) + def api_stream(): + """Start a streaming simulation, returning results as SSE.""" + session_id = _get_session_id() + data = request.get_json(force=True) + expr = data.get("expr", "") + msg_id = data.get("id", str(uuid.uuid4())) + + session = get_or_create_session(session_id) + + def generate(): + with session.lock: + try: + session.ensure_initialized() + session.send_message({"type": "stream-start", "id": msg_id, "expr": expr}) + + while True: + resp = session.read_line() + if resp is None: + yield f"event: error\ndata: {json.dumps({'error': 'Worker process died'})}\n\n" + break + resp_type = resp.get("type") + + if resp_type == "stream-data": + yield f"event: data\ndata: {json.dumps({'done': False, 'result': json.loads(resp.get('value', '{}'))})}\n\n" + elif resp_type == "stream-done": + yield f"event: done\ndata: {{}}\n\n" + break + elif resp_type == "stdout": + yield f"event: stdout\ndata: {json.dumps(resp.get('value', ''))}\n\n" + elif resp_type == "stderr": + yield f"event: stderr\ndata: {json.dumps(resp.get('value', ''))}\n\n" + elif resp_type == "error": + yield f"event: error\ndata: {json.dumps({'error': resp.get('error', ''), 'traceback': resp.get('traceback', '')})}\n\n" + break + + except Exception as e: + yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n" + + return Response( + generate(), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + @app.route("/api/stream/exec", methods=["POST"]) + def api_stream_exec(): + """Queue code to execute during an active stream.""" + session_id = _get_session_id() + data = request.get_json(force=True) + code = data.get("code", "") + + session = get_or_create_session(session_id) + try: + session.send_message({"type": "stream-exec", "code": code}) + return jsonify({"status": "queued"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + @app.route("/api/stream/stop", methods=["POST"]) + def api_stream_stop(): + """Stop an active streaming session.""" + session_id = _get_session_id() + + session = get_or_create_session(session_id) + try: + session.send_message({"type": "stream-stop"}) + return jsonify({"status": "stopped"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + @app.route("/api/session", methods=["DELETE"]) + def api_session_delete(): + """Kill a session's worker subprocess.""" + session_id = _get_session_id() + remove_session(session_id) + return jsonify({"status": "terminated"}) + + # ----------------------------------------------------------------------- + # Static file serving (pip package mode) + # ----------------------------------------------------------------------- + + if serve_static: + static_dir = Path(__file__).parent / "static" + + @app.route("/", defaults={"path": ""}) + @app.route("/") + def serve_frontend(path): + """Serve the bundled SvelteKit frontend with SPA fallback.""" + if path.startswith("api/"): + return jsonify({"error": "Not found"}), 404 + + file_path = static_dir / path + if path and file_path.is_file(): + return send_from_directory(static_dir, path) + + # SPA fallback + return send_from_directory(static_dir, "index.html") + + return app + + +# --------------------------------------------------------------------------- +# Cleanup on exit +# --------------------------------------------------------------------------- + +@atexit.register +def _cleanup_all_sessions(): + with _sessions_lock: + for session in _sessions.values(): + session.kill() + _sessions.clear() + + +# --------------------------------------------------------------------------- +# Entry point (dev mode: API-only with CORS) +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 5000)) + debug = os.environ.get("FLASK_DEBUG", "1") == "1" + print(f"PathView Flask backend (API-only) starting on port {port}") + app = create_app(serve_static=False) + app.run(host="0.0.0.0", port=port, debug=debug, threaded=True) diff --git a/pathview_server/cli.py b/pathview_server/cli.py new file mode 100644 index 00000000..d45f35c8 --- /dev/null +++ b/pathview_server/cli.py @@ -0,0 +1,55 @@ +"""CLI entry point for the pathview command.""" + +import argparse +import sys +import threading +import time +import webbrowser + +from pathview_server import __version__ + + +def main(): + parser = argparse.ArgumentParser( + prog="pathview", + description="PathView — visual node editor for dynamic systems", + ) + parser.add_argument("command", nargs="?", default="serve", choices=["serve"], + help="Command to run (default: serve)") + parser.add_argument("--port", type=int, default=5000, + help="Port to run the server on (default: 5000)") + parser.add_argument("--host", type=str, default="127.0.0.1", + help="Host to bind to (default: 127.0.0.1)") + parser.add_argument("--no-browser", action="store_true", + help="Don't automatically open the browser") + parser.add_argument("--debug", action="store_true", + help="Run in debug mode") + parser.add_argument("--version", action="version", + version=f"pathview {__version__}") + + args = parser.parse_args() + + from pathview_server.app import create_app + + app = create_app(serve_static=True) + + if not args.no_browser: + def open_browser(): + time.sleep(1.5) + webbrowser.open(f"http://{args.host}:{args.port}") + + threading.Thread(target=open_browser, daemon=True).start() + + print(f"PathView v{__version__}") + print(f"Running at http://{args.host}:{args.port}") + print("Press Ctrl+C to stop\n") + + try: + app.run(host=args.host, port=args.port, debug=args.debug, threaded=True) + except KeyboardInterrupt: + print("\nStopping PathView server...") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/server/worker.py b/pathview_server/worker.py similarity index 100% rename from server/worker.py rename to pathview_server/worker.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..167b30ef --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pathview" +version = "0.5.0" +description = "Visual node editor for building and simulating dynamic systems with PathSim" +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.9" +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering", +] +dependencies = [ + "flask>=3.0", + "flask-cors>=4.0", +] + +[project.urls] +Homepage = "https://view.pathsim.org" +Repository = "https://github.com/pathsim/pathview" + +[project.scripts] +pathview = "pathview_server.cli:main" + +[tool.setuptools] +packages = ["pathview_server"] + +[tool.setuptools.package-data] +pathview_server = ["static/**/*"] diff --git a/scripts/build_package.py b/scripts/build_package.py new file mode 100644 index 00000000..087c4932 --- /dev/null +++ b/scripts/build_package.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +Build script for the PathView PyPI package. + +1. Builds the SvelteKit frontend (vite build) +2. Copies build/ output to pathview_server/static/ +3. Builds the Python wheel +""" + +import os +import sys +import shutil +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent +BUILD_DIR = REPO_ROOT / "build" +STATIC_DIR = REPO_ROOT / "pathview_server" / "static" + + +def run(cmd, **kwargs): + print(f" > {' '.join(cmd)}") + result = subprocess.run(cmd, cwd=kwargs.pop("cwd", REPO_ROOT), **kwargs) + if result.returncode != 0: + print(f"ERROR: command failed (exit {result.returncode})") + sys.exit(result.returncode) + + +def main(): + print("[1/4] Cleaning previous builds...") + for d in [BUILD_DIR, STATIC_DIR, REPO_ROOT / "dist"]: + if d.exists(): + shutil.rmtree(d) + + # Remove egg-info + for p in REPO_ROOT.glob("*.egg-info"): + shutil.rmtree(p) + + print("[2/4] Building SvelteKit frontend...") + env = os.environ.copy() + env["BASE_PATH"] = "" + run(["npx", "vite", "build"], env=env) + + if not (BUILD_DIR / "index.html").exists(): + print("ERROR: build/index.html not found") + sys.exit(1) + + print("[3/4] Copying frontend to pathview_server/static/...") + shutil.copytree(BUILD_DIR, STATIC_DIR) + print(f" Copied {sum(1 for _ in STATIC_DIR.rglob('*') if _.is_file())} files") + + print("[4/4] Building Python wheel...") + run([sys.executable, "-m", "build"]) + + print("\nDone! Output:") + dist = REPO_ROOT / "dist" + if dist.exists(): + for f in sorted(dist.iterdir()): + print(f" {f.name}") + + +if __name__ == "__main__": + main() diff --git a/server/app.py b/server/app.py deleted file mode 100644 index 6f8eff40..00000000 --- a/server/app.py +++ /dev/null @@ -1,378 +0,0 @@ -""" -Flask server for PathView backend. - -Manages worker subprocesses per session. Routes translate HTTP requests -into subprocess messages and relay responses back. - -Each session gets its own worker subprocess with an isolated Python namespace. -""" - -import os -import sys -import json -import subprocess -import threading -import time -import uuid -import atexit -from pathlib import Path - -from flask import Flask, Response, request, jsonify -from flask_cors import CORS - -app = Flask(__name__) -CORS(app) - -# --------------------------------------------------------------------------- -# Configuration -# --------------------------------------------------------------------------- - -SESSION_TTL = 3600 # 1 hour of inactivity before cleanup -CLEANUP_INTERVAL = 60 # Check for stale sessions every 60 seconds -REQUEST_TIMEOUT = 300 # 5 minutes default timeout for exec/eval -WORKER_SCRIPT = str(Path(__file__).parent / "worker.py") - -# --------------------------------------------------------------------------- -# Session management -# --------------------------------------------------------------------------- - -class Session: - """A worker subprocess bound to a session.""" - - def __init__(self, session_id: str): - self.session_id = session_id - self.last_active = time.time() - self.lock = threading.Lock() - self.process = subprocess.Popen( - [sys.executable, "-u", WORKER_SCRIPT], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=1, # line buffered - ) - self._initialized = False - - def send_message(self, msg: dict) -> None: - """Write a JSON message to the subprocess stdin.""" - self.last_active = time.time() - line = json.dumps(msg) + "\n" - self.process.stdin.write(line) - self.process.stdin.flush() - - def read_line(self) -> dict | None: - """Read one JSON line from the subprocess stdout.""" - line = self.process.stdout.readline() - if not line: - return None - return json.loads(line.strip()) - - def ensure_initialized(self, packages: list[dict] | None = None) -> list[dict]: - """Initialize the worker if not already done. Returns any messages received.""" - if self._initialized: - return [] - messages = [] - init_msg = {"type": "init"} - if packages: - init_msg["packages"] = packages - self.send_message(init_msg) - while True: - resp = self.read_line() - if resp is None: - raise RuntimeError("Worker process died during initialization") - messages.append(resp) - if resp.get("type") == "ready": - self._initialized = True - break - if resp.get("type") == "error": - raise RuntimeError(resp.get("error", "Unknown init error")) - return messages - - def is_alive(self) -> bool: - return self.process.poll() is None - - def kill(self) -> None: - """Kill the subprocess.""" - try: - self.process.stdin.close() - except Exception: - pass - try: - self.process.kill() - self.process.wait(timeout=5) - except Exception: - pass - - -# Global session store -_sessions: dict[str, Session] = {} -_sessions_lock = threading.Lock() - - -def get_or_create_session(session_id: str) -> Session: - """Get an existing session or create a new one.""" - with _sessions_lock: - session = _sessions.get(session_id) - if session and not session.is_alive(): - # Dead process, remove stale entry - _sessions.pop(session_id, None) - session = None - if session is None: - session = Session(session_id) - _sessions[session_id] = session - return session - - -def remove_session(session_id: str) -> None: - """Kill and remove a session.""" - with _sessions_lock: - session = _sessions.pop(session_id, None) - if session: - session.kill() - - -def cleanup_stale_sessions() -> None: - """Remove sessions that have been inactive beyond TTL.""" - while True: - time.sleep(CLEANUP_INTERVAL) - now = time.time() - stale = [] - with _sessions_lock: - for sid, session in _sessions.items(): - if now - session.last_active > SESSION_TTL: - stale.append(sid) - for sid in stale: - remove_session(sid) - - -# Start cleanup thread -_cleanup_thread = threading.Thread(target=cleanup_stale_sessions, daemon=True) -_cleanup_thread.start() - - -def _get_session_id() -> str: - """Extract session ID from request headers or generate one.""" - return request.headers.get("X-Session-ID") or str(uuid.uuid4()) - - -# --------------------------------------------------------------------------- -# Routes -# --------------------------------------------------------------------------- - -@app.route("/api/health", methods=["GET"]) -def health(): - return jsonify({"status": "ok"}) - - -@app.route("/api/init", methods=["POST"]) -def api_init(): - """Initialize a session's worker with packages from the frontend config.""" - session_id = _get_session_id() - data = request.get_json(force=True) - packages = data.get("packages", []) - - session = get_or_create_session(session_id) - with session.lock: - try: - messages = session.ensure_initialized(packages=packages) - # Return all progress/stdout/stderr messages collected during init - return jsonify({"type": "ready", "messages": messages}) - except Exception as e: - return jsonify({"type": "error", "error": str(e)}), 500 - - -@app.route("/api/exec", methods=["POST"]) -def api_exec(): - """Execute Python code in the session's worker.""" - session_id = _get_session_id() - data = request.get_json(force=True) - code = data.get("code", "") - msg_id = data.get("id", str(uuid.uuid4())) - - session = get_or_create_session(session_id) - with session.lock: - try: - session.ensure_initialized() - session.send_message({"type": "exec", "id": msg_id, "code": code}) - - # Collect responses until we get ok/error for this id - stdout_lines = [] - stderr_lines = [] - while True: - resp = session.read_line() - if resp is None: - return jsonify({"type": "error", "id": msg_id, "error": "Worker process died"}), 500 - resp_type = resp.get("type") - if resp_type == "stdout": - stdout_lines.append(resp.get("value", "")) - elif resp_type == "stderr": - stderr_lines.append(resp.get("value", "")) - elif resp_type == "ok" and resp.get("id") == msg_id: - result = {"type": "ok", "id": msg_id} - if stdout_lines: - result["stdout"] = "".join(stdout_lines) - if stderr_lines: - result["stderr"] = "".join(stderr_lines) - return jsonify(result) - elif resp_type == "error" and resp.get("id") == msg_id: - result = resp - if stdout_lines: - result["stdout"] = "".join(stdout_lines) - if stderr_lines: - result["stderr"] = "".join(stderr_lines) - return jsonify(result), 400 - - except Exception as e: - return jsonify({"type": "error", "id": msg_id, "error": str(e)}), 500 - - -@app.route("/api/eval", methods=["POST"]) -def api_eval(): - """Evaluate a Python expression in the session's worker.""" - session_id = _get_session_id() - data = request.get_json(force=True) - expr = data.get("expr", "") - msg_id = data.get("id", str(uuid.uuid4())) - - session = get_or_create_session(session_id) - with session.lock: - try: - session.ensure_initialized() - session.send_message({"type": "eval", "id": msg_id, "expr": expr}) - - stdout_lines = [] - stderr_lines = [] - while True: - resp = session.read_line() - if resp is None: - return jsonify({"type": "error", "id": msg_id, "error": "Worker process died"}), 500 - resp_type = resp.get("type") - if resp_type == "stdout": - stdout_lines.append(resp.get("value", "")) - elif resp_type == "stderr": - stderr_lines.append(resp.get("value", "")) - elif resp_type == "value" and resp.get("id") == msg_id: - result = resp - if stdout_lines: - result["stdout"] = "".join(stdout_lines) - if stderr_lines: - result["stderr"] = "".join(stderr_lines) - return jsonify(result) - elif resp_type == "error" and resp.get("id") == msg_id: - result = resp - if stdout_lines: - result["stdout"] = "".join(stdout_lines) - if stderr_lines: - result["stderr"] = "".join(stderr_lines) - return jsonify(result), 400 - - except Exception as e: - return jsonify({"type": "error", "id": msg_id, "error": str(e)}), 500 - - -@app.route("/api/stream", methods=["POST"]) -def api_stream(): - """Start a streaming simulation, returning results as SSE.""" - session_id = _get_session_id() - data = request.get_json(force=True) - expr = data.get("expr", "") - msg_id = data.get("id", str(uuid.uuid4())) - - session = get_or_create_session(session_id) - - def generate(): - with session.lock: - try: - session.ensure_initialized() - session.send_message({"type": "stream-start", "id": msg_id, "expr": expr}) - - while True: - resp = session.read_line() - if resp is None: - yield f"event: error\ndata: {json.dumps({'error': 'Worker process died'})}\n\n" - break - resp_type = resp.get("type") - - if resp_type == "stream-data": - yield f"event: data\ndata: {json.dumps({'done': False, 'result': json.loads(resp.get('value', '{}'))})}\n\n" - elif resp_type == "stream-done": - yield f"event: done\ndata: {{}}\n\n" - break - elif resp_type == "stdout": - yield f"event: stdout\ndata: {json.dumps(resp.get('value', ''))}\n\n" - elif resp_type == "stderr": - yield f"event: stderr\ndata: {json.dumps(resp.get('value', ''))}\n\n" - elif resp_type == "error": - yield f"event: error\ndata: {json.dumps({'error': resp.get('error', ''), 'traceback': resp.get('traceback', '')})}\n\n" - break - - except Exception as e: - yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n" - - return Response( - generate(), - mimetype="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "X-Accel-Buffering": "no", - }, - ) - - -@app.route("/api/stream/exec", methods=["POST"]) -def api_stream_exec(): - """Queue code to execute during an active stream.""" - session_id = _get_session_id() - data = request.get_json(force=True) - code = data.get("code", "") - - session = get_or_create_session(session_id) - try: - session.send_message({"type": "stream-exec", "code": code}) - return jsonify({"status": "queued"}) - except Exception as e: - return jsonify({"error": str(e)}), 500 - - -@app.route("/api/stream/stop", methods=["POST"]) -def api_stream_stop(): - """Stop an active streaming session.""" - session_id = _get_session_id() - - session = get_or_create_session(session_id) - try: - session.send_message({"type": "stream-stop"}) - return jsonify({"status": "stopped"}) - except Exception as e: - return jsonify({"error": str(e)}), 500 - - -@app.route("/api/session", methods=["DELETE"]) -def api_session_delete(): - """Kill a session's worker subprocess.""" - session_id = _get_session_id() - remove_session(session_id) - return jsonify({"status": "terminated"}) - - -# --------------------------------------------------------------------------- -# Cleanup on exit -# --------------------------------------------------------------------------- - -@atexit.register -def _cleanup_all_sessions(): - with _sessions_lock: - for session in _sessions.values(): - session.kill() - _sessions.clear() - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - -if __name__ == "__main__": - port = int(os.environ.get("PORT", 5000)) - debug = os.environ.get("FLASK_DEBUG", "1") == "1" - print(f"PathView Flask backend starting on port {port}") - app.run(host="0.0.0.0", port=port, debug=debug, threaded=True) diff --git a/server/requirements.txt b/server/requirements.txt deleted file mode 100644 index 3535b452..00000000 --- a/server/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -flask>=3.0 -flask-cors>=4.0 diff --git a/src/lib/pyodide/backend/index.ts b/src/lib/pyodide/backend/index.ts index 5a227a12..86c6aae9 100644 --- a/src/lib/pyodide/backend/index.ts +++ b/src/lib/pyodide/backend/index.ts @@ -56,6 +56,35 @@ export function initBackendFromUrl(): void { } } +/** + * Auto-detect if a Flask backend is available at the same origin. + * Used when the frontend is served by the Flask server (pip package mode). + * URL parameters take precedence — if `?backend=` is set, auto-detection is skipped. + */ +export async function autoDetectBackend(): Promise { + if (typeof window === 'undefined') return; + + // URL params override auto-detection + const params = new URLSearchParams(window.location.search); + if (params.has('backend')) return; + + try { + const response = await fetch('/api/health', { + method: 'GET', + signal: AbortSignal.timeout(2000) + }); + if (response.ok) { + const data = await response.json(); + if (data.status === 'ok') { + setFlaskHost(window.location.origin); + switchBackend('flask'); + } + } + } catch { + // No Flask backend at same origin — will use Pyodide + } +} + // Alias for backward compatibility export const replState = { subscribe: backendState.subscribe diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 268e53f1..d6d5c1f7 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -40,7 +40,7 @@ import { openEventDialog } from '$lib/stores/eventDialog'; import type { MenuItemType } from '$lib/components/ContextMenu.svelte'; import { pyodideState, simulationState, initPyodide, stopSimulation, continueStreamingSimulation } from '$lib/pyodide/bridge'; - import { initBackendFromUrl } from '$lib/pyodide/backend'; + import { initBackendFromUrl, autoDetectBackend } from '$lib/pyodide/backend'; import { runGraphStreamingSimulation, validateGraphSimulation } from '$lib/pyodide/pathsimRunner'; import { consoleStore } from '$lib/stores/console'; import { newGraph, saveFile, saveAsFile, setupAutoSave, clearAutoSave, debouncedAutoSave, openImportDialog, importFromUrl, currentFileName } from '$lib/schema/fileOps'; @@ -382,8 +382,8 @@ const continueTooltip = { text: "Continue", shortcut: "Shift+Enter" }; onMount(() => { - // Check URL params for backend selection (e.g. ?backend=flask&host=http://localhost:5000) - initBackendFromUrl(); + // Auto-detect same-origin Flask backend (pip package mode), then check URL params + autoDetectBackend().then(() => initBackendFromUrl()); // Subscribe to stores (with cleanup) const unsubPinnedPreviews = pinnedPreviewsStore.subscribe((pinned) => { From 8c042c0b1922158ddc23136f0a29355cb29ee9d8 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 21:05:06 +0100 Subject: [PATCH 377/656] Fix streaming, logging, and review issues for Flask backend - Migrate streaming from SSE to polling (start+poll pattern) - Fix stdin race condition with noop flush and leftover queue - Fix logging capture with permanent _ProtocolWriter on sys.stdout/stderr - Add recursion guard to _ProtocolWriter to prevent infinite loops - Fix ensureServerInit to retry on failure instead of caching rejection - Fix initBackendFromUrl to call init() for proper callback setup - Add resp.ok checks and poll response validation in FlaskBackend - Align stopStreaming to wait for server confirmation like Pyodide - Validate X-Session-ID header, return 400 when missing - Prevent orphan sessions in poll/exec/stop by using lookup instead of create - Move cleanup thread start into create_app factory - Fix requires-python to >=3.10 and add numpy dependency - Remove dead code: _capture_output, REQUEST_TIMEOUT, unused imports Co-Authored-By: KDW1 --- pathview_server/app.py | 178 +++++++--- pathview_server/worker.py | 182 ++++++----- pyproject.toml | 3 +- scripts/build_package.py | 4 +- src/lib/pyodide/backend/flask/backend.ts | 392 +++++++++++------------ src/lib/pyodide/backend/index.ts | 7 +- 6 files changed, 426 insertions(+), 340 deletions(-) diff --git a/pathview_server/app.py b/pathview_server/app.py index 70169251..a909e4e7 100644 --- a/pathview_server/app.py +++ b/pathview_server/app.py @@ -10,6 +10,7 @@ import os import sys import json +import queue import subprocess import threading import time @@ -17,7 +18,7 @@ import atexit from pathlib import Path -from flask import Flask, Response, request, jsonify, send_from_directory +from flask import Flask, request, jsonify, send_from_directory from flask_cors import CORS # --------------------------------------------------------------------------- @@ -26,7 +27,6 @@ SESSION_TTL = 3600 # 1 hour of inactivity before cleanup CLEANUP_INTERVAL = 60 # Check for stale sessions every 60 seconds -REQUEST_TIMEOUT = 300 # 5 minutes default timeout for exec/eval WORKER_SCRIPT = str(Path(__file__).parent / "worker.py") # --------------------------------------------------------------------------- @@ -49,6 +49,10 @@ def __init__(self, session_id: str): bufsize=1, # line buffered ) self._initialized = False + # Streaming state: background thread reads worker stdout into a queue + self._stream_queue: queue.Queue[dict] = queue.Queue() + self._stream_reader: threading.Thread | None = None + self._streaming = False def send_message(self, msg: dict) -> None: """Write a JSON message to the subprocess stdin.""" @@ -85,11 +89,63 @@ def ensure_initialized(self, packages: list[dict] | None = None) -> list[dict]: raise RuntimeError(resp.get("error", "Unknown init error")) return messages + def start_stream_reader(self) -> None: + """Start a background thread that reads worker stdout into the stream queue.""" + self._streaming = True + # Clear any stale messages + while not self._stream_queue.empty(): + try: + self._stream_queue.get_nowait() + except queue.Empty: + break + + def reader(): + while self._streaming: + resp = self.read_line() + if resp is None: + self._stream_queue.put({"type": "error", "error": "Worker process died"}) + self._streaming = False + break + self._stream_queue.put(resp) + if resp.get("type") in ("stream-done", "error"): + self._streaming = False + break + + self._stream_reader = threading.Thread(target=reader, daemon=True) + self._stream_reader.start() + + def stop_stream_reader(self) -> None: + """Signal the stream reader to stop.""" + self._streaming = False + + def flush_worker_reader(self) -> None: + """Send a noop message to unblock the worker's stdin reader thread. + + After streaming ends, the worker's reader thread is blocked on stdin.readline(). + This sends a harmless message so the reader thread wakes up, checks stop_event, + and exits — allowing the main thread to resume reading stdin safely. + """ + try: + self.send_message({"type": "noop"}) + except Exception: + pass + + def drain_stream_queue(self) -> list[dict]: + """Drain all messages currently in the stream queue.""" + messages = [] + while True: + try: + messages.append(self._stream_queue.get_nowait()) + except queue.Empty: + break + return messages + def is_alive(self) -> bool: return self.process.poll() is None def kill(self) -> None: """Kill the subprocess.""" + self._streaming = False try: self.process.stdin.close() except Exception: @@ -142,14 +198,22 @@ def cleanup_stale_sessions() -> None: remove_session(sid) -# Start cleanup thread -_cleanup_thread = threading.Thread(target=cleanup_stale_sessions, daemon=True) -_cleanup_thread.start() +_cleanup_started = False + + +def _start_cleanup_thread() -> None: + """Start the cleanup thread once (idempotent).""" + global _cleanup_started + if _cleanup_started: + return + _cleanup_started = True + t = threading.Thread(target=cleanup_stale_sessions, daemon=True) + t.start() -def _get_session_id() -> str: - """Extract session ID from request headers or generate one.""" - return request.headers.get("X-Session-ID") or str(uuid.uuid4()) +def _get_session_id() -> str | None: + """Extract session ID from request headers. Returns None if missing.""" + return request.headers.get("X-Session-ID") # --------------------------------------------------------------------------- @@ -164,6 +228,7 @@ def create_app(serve_static: bool = False) -> Flask: If False, API-only mode with CORS (for dev with Vite). """ app = Flask(__name__) + _start_cleanup_thread() if not serve_static: CORS(app) @@ -180,6 +245,8 @@ def health(): def api_init(): """Initialize a session's worker with packages from the frontend config.""" session_id = _get_session_id() + if not session_id: + return jsonify({"type": "error", "error": "Missing X-Session-ID header"}), 400 data = request.get_json(force=True) packages = data.get("packages", []) @@ -195,6 +262,8 @@ def api_init(): def api_exec(): """Execute Python code in the session's worker.""" session_id = _get_session_id() + if not session_id: + return jsonify({"type": "error", "error": "Missing X-Session-ID header"}), 400 data = request.get_json(force=True) code = data.get("code", "") msg_id = data.get("id", str(uuid.uuid4())) @@ -238,6 +307,8 @@ def api_exec(): def api_eval(): """Evaluate a Python expression in the session's worker.""" session_id = _get_session_id() + if not session_id: + return jsonify({"type": "error", "error": "Missing X-Session-ID header"}), 400 data = request.get_json(force=True) expr = data.get("expr", "") msg_id = data.get("id", str(uuid.uuid4())) @@ -277,62 +348,62 @@ def api_eval(): except Exception as e: return jsonify({"type": "error", "id": msg_id, "error": str(e)}), 500 - @app.route("/api/stream", methods=["POST"]) - def api_stream(): - """Start a streaming simulation, returning results as SSE.""" + @app.route("/api/stream/start", methods=["POST"]) + def api_stream_start(): + """Start streaming — sends stream-start to worker and returns immediately. + + A background thread reads worker stdout into a queue. The frontend + polls /api/stream/poll to drain that queue, mirroring how the Pyodide + worker sends stream-data / stream-done messages via postMessage. + """ session_id = _get_session_id() + if not session_id: + return jsonify({"type": "error", "error": "Missing X-Session-ID header"}), 400 data = request.get_json(force=True) expr = data.get("expr", "") msg_id = data.get("id", str(uuid.uuid4())) session = get_or_create_session(session_id) + with session.lock: + try: + session.ensure_initialized() + session.send_message({"type": "stream-start", "id": msg_id, "expr": expr}) + session.start_stream_reader() + return jsonify({"status": "started", "id": msg_id}) + except Exception as e: + return jsonify({"type": "error", "error": str(e)}), 500 - def generate(): - with session.lock: - try: - session.ensure_initialized() - session.send_message({"type": "stream-start", "id": msg_id, "expr": expr}) - - while True: - resp = session.read_line() - if resp is None: - yield f"event: error\ndata: {json.dumps({'error': 'Worker process died'})}\n\n" - break - resp_type = resp.get("type") - - if resp_type == "stream-data": - yield f"event: data\ndata: {json.dumps({'done': False, 'result': json.loads(resp.get('value', '{}'))})}\n\n" - elif resp_type == "stream-done": - yield f"event: done\ndata: {{}}\n\n" - break - elif resp_type == "stdout": - yield f"event: stdout\ndata: {json.dumps(resp.get('value', ''))}\n\n" - elif resp_type == "stderr": - yield f"event: stderr\ndata: {json.dumps(resp.get('value', ''))}\n\n" - elif resp_type == "error": - yield f"event: error\ndata: {json.dumps({'error': resp.get('error', ''), 'traceback': resp.get('traceback', '')})}\n\n" - break - - except Exception as e: - yield f"event: error\ndata: {json.dumps({'error': str(e)})}\n\n" - - return Response( - generate(), - mimetype="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "X-Accel-Buffering": "no", - }, - ) + @app.route("/api/stream/poll", methods=["POST"]) + def api_stream_poll(): + """Poll for stream messages — returns all queued messages since last poll.""" + session_id = _get_session_id() + if not session_id: + return jsonify({"type": "error", "error": "Missing X-Session-ID header"}), 400 + with _sessions_lock: + session = _sessions.get(session_id) + if not session: + return jsonify({"messages": [], "done": True}) + messages = session.drain_stream_queue() + done = any(m.get("type") in ("stream-done", "error") for m in messages) + if done: + # Send noop to unblock the worker's stdin reader thread + # so the main thread can resume processing exec/eval + session.flush_worker_reader() + return jsonify({"messages": messages, "done": done}) @app.route("/api/stream/exec", methods=["POST"]) def api_stream_exec(): """Queue code to execute during an active stream.""" session_id = _get_session_id() + if not session_id: + return jsonify({"error": "Missing X-Session-ID header"}), 400 data = request.get_json(force=True) code = data.get("code", "") - session = get_or_create_session(session_id) + with _sessions_lock: + session = _sessions.get(session_id) + if not session: + return jsonify({"error": "No active session"}), 404 try: session.send_message({"type": "stream-exec", "code": code}) return jsonify({"status": "queued"}) @@ -343,8 +414,13 @@ def api_stream_exec(): def api_stream_stop(): """Stop an active streaming session.""" session_id = _get_session_id() + if not session_id: + return jsonify({"error": "Missing X-Session-ID header"}), 400 - session = get_or_create_session(session_id) + with _sessions_lock: + session = _sessions.get(session_id) + if not session: + return jsonify({"status": "stopped"}) try: session.send_message({"type": "stream-stop"}) return jsonify({"status": "stopped"}) @@ -355,6 +431,8 @@ def api_stream_stop(): def api_session_delete(): """Kill a session's worker subprocess.""" session_id = _get_session_id() + if not session_id: + return jsonify({"error": "Missing X-Session-ID header"}), 400 remove_session(session_id) return jsonify({"status": "terminated"}) diff --git a/pathview_server/worker.py b/pathview_server/worker.py index 9fc3db05..558a2701 100644 --- a/pathview_server/worker.py +++ b/pathview_server/worker.py @@ -8,13 +8,13 @@ Threading model: - Main thread: reads stdin, processes init/exec/eval synchronously -- During streaming: a reader thread handles stream-stop and stream-exec - while the main thread runs the streaming loop +- During streaming: a reader thread handles stream-stop and stream-exec, + puts non-streaming messages into a leftover queue +- After streaming: main thread drains leftovers before resuming stdin reads - Stdout lock: thread-safe writing to stdout (protocol messages only) """ import sys -import io import json import subprocess import threading @@ -24,6 +24,9 @@ # Lock for thread-safe stdout writing (protocol messages only) _stdout_lock = threading.Lock() +# Keep a reference to the real stdout pipe — protocol messages go here. +_real_stdout = sys.stdout + # Worker state _namespace = {} _initialized = False @@ -31,46 +34,56 @@ # Streaming state _streaming_active = False _streaming_code_queue = queue.Queue() +_leftover_queue: queue.Queue[dict | None] = queue.Queue() def send(response: dict) -> None: """Send a JSON response to the parent process via stdout.""" with _stdout_lock: - sys.stdout.write(json.dumps(response) + "\n") - sys.stdout.flush() - - -def _capture_output(func): - """Run func with stdout/stderr captured, sending output as messages.""" - old_stdout = sys.stdout - old_stderr = sys.stderr - captured_out = io.StringIO() - captured_err = io.StringIO() - sys.stdout = captured_out - sys.stderr = captured_err - try: - result = func() - finally: - sys.stdout = old_stdout - sys.stderr = old_stderr - out = captured_out.getvalue() - err = captured_err.getvalue() - if out: - send({"type": "stdout", "value": out}) - if err: - send({"type": "stderr", "value": err}) - return result + _real_stdout.write(json.dumps(response) + "\n") + _real_stdout.flush() + + +class _ProtocolWriter: + """File-like object that routes writes through the worker protocol. + + Installed as sys.stdout/sys.stderr permanently so that ALL output + (print, logging handlers, third-party libraries) is captured and + forwarded to the frontend console. This avoids the stale-reference + bug where StreamHandler(sys.stdout) captures a temporary StringIO. + """ + + def __init__(self, msg_type: str): + self.msg_type = msg_type + self._in_write = False + + def write(self, text: str) -> int: + if text and not self._in_write: + self._in_write = True + try: + send({"type": self.msg_type, "value": text}) + except Exception: + pass + finally: + self._in_write = False + return len(text) if text else 0 + + def flush(self) -> None: + pass + + def isatty(self) -> bool: + return False def read_message(): """Read one JSON message from stdin. Returns None on EOF.""" - line = sys.stdin.readline() - if not line: - return None - line = line.strip() - if not line: - return read_message() # skip blank lines - return json.loads(line) + while True: + line = sys.stdin.readline() + if not line: + return None + line = line.strip() + if line: + return json.loads(line) def _install_package(pip_spec: str, pre: bool = False) -> None: @@ -118,6 +131,12 @@ def initialize(packages: list[dict] | None = None) -> None: send({"type": "progress", "value": "Initializing Python worker..."}) + # Replace sys.stdout/stderr with protocol writers BEFORE importing packages. + # Any StreamHandler created later (e.g. by pathsim's LoggerManager singleton) + # will capture these persistent objects, so logging always routes through send(). + sys.stdout = _ProtocolWriter("stdout") + sys.stderr = _ProtocolWriter("stderr") + # Set up the namespace with common imports _namespace = {"__builtins__": __builtins__} exec("import numpy as np", _namespace) @@ -148,9 +167,7 @@ def exec_code(msg_id: str, code: str) -> None: return try: - def run(): - exec(code, _namespace) - _capture_output(run) + exec(code, _namespace) send({"type": "ok", "id": msg_id}) except Exception as e: tb = traceback.format_exc() @@ -164,13 +181,10 @@ def eval_expr(msg_id: str, expr: str) -> None: return try: - def run(): - # Mirror worker.ts: store result, then JSON-serialize - exec_code_str = f"_eval_result = {expr}" - exec(exec_code_str, _namespace) - to_json = _namespace.get("_to_json", str) - return json.dumps(_namespace["_eval_result"], default=to_json) - result = _capture_output(run) + exec_code_str = f"_eval_result = {expr}" + exec(exec_code_str, _namespace) + to_json = _namespace.get("_to_json", str) + result = json.dumps(_namespace["_eval_result"], default=to_json) send({"type": "value", "id": msg_id, "value": result}) except Exception as e: tb = traceback.format_exc() @@ -178,13 +192,19 @@ def run(): def _streaming_reader_thread(stop_event: threading.Event) -> None: - """Read stdin during streaming, handling stream-stop and stream-exec.""" + """Read stdin during streaming, handling stream-stop and stream-exec. + + Non-streaming messages are saved to _leftover_queue for the main loop. + After stop_event is set, the thread will exit once it reads one more line + (the flush message sent by the server). + """ global _streaming_active - while not stop_event.is_set(): + while True: line = sys.stdin.readline() if not line: - # EOF — stop streaming and signal main loop to exit + # EOF — stop streaming _streaming_active = False + _leftover_queue.put(None) break line = line.strip() if not line: @@ -195,6 +215,11 @@ def _streaming_reader_thread(stop_event: threading.Event) -> None: send({"type": "error", "error": f"Invalid JSON: {line}"}) continue + # If stop_event is set, we're just flushing — put message in leftovers + if stop_event.is_set(): + _leftover_queue.put(msg) + break + msg_type = msg.get("type") if msg_type == "stream-stop": _streaming_active = False @@ -202,7 +227,9 @@ def _streaming_reader_thread(stop_event: threading.Event) -> None: code = msg.get("code") if code and _streaming_active: _streaming_code_queue.put(code) - # Other messages during streaming are ignored (shouldn't happen) + else: + # Non-streaming message arrived — save for main loop + _leftover_queue.put(msg) def run_streaming_loop(msg_id: str, expr: str) -> None: @@ -236,35 +263,30 @@ def run_streaming_loop(msg_id: str, expr: str) -> None: except queue.Empty: break try: - def run_queued(c=code): - exec(c, _namespace) - _capture_output(run_queued) + exec(code, _namespace) except Exception as e: send({"type": "stderr", "value": f"Stream exec error: {e}"}) # Step the generator - def run_step(): - exec_code_str = f"_eval_result = {expr}" - exec(exec_code_str, _namespace) - to_json = _namespace.get("_to_json", str) - return json.dumps(_namespace["_eval_result"], default=to_json) - result = _capture_output(run_step) - - # Parse result - parsed = json.loads(result) + exec_code_str = f"_eval_result = {expr}" + exec(exec_code_str, _namespace) + raw_result = _namespace["_eval_result"] + done = raw_result.get("done", False) if isinstance(raw_result, dict) else False # Check if stopped during Python execution - still send final data if not _streaming_active: - if not parsed.get("done") and parsed.get("result"): - send({"type": "stream-data", "id": msg_id, "value": result}) + if not done and (isinstance(raw_result, dict) and raw_result.get("result")): + to_json = _namespace.get("_to_json", str) + send({"type": "stream-data", "id": msg_id, "value": json.dumps(raw_result, default=to_json)}) break # Check if simulation completed - if parsed.get("done"): + if done: break # Send result and continue - send({"type": "stream-data", "id": msg_id, "value": result}) + to_json = _namespace.get("_to_json", str) + send({"type": "stream-data", "id": msg_id, "value": json.dumps(raw_result, default=to_json)}) except Exception as e: tb = traceback.format_exc() @@ -274,18 +296,25 @@ def run_step(): stop_event.set() # Always send done when loop ends send({"type": "stream-done", "id": msg_id}) - - -def stop_streaming() -> None: - """Stop the streaming loop.""" - global _streaming_active - _streaming_active = False + # Reader thread will exit on the next stdin read (flush message from server) def main() -> None: """Main loop: read messages from stdin and process them.""" while True: - msg = read_message() + # First drain any leftover messages from the streaming reader thread + msg = None + while not _leftover_queue.empty(): + try: + msg = _leftover_queue.get_nowait() + if msg is not None: + break + except queue.Empty: + break + + # If no leftover, read from stdin directly + if msg is None: + msg = read_message() if msg is None: # stdin closed, exit break @@ -317,11 +346,16 @@ def main() -> None: run_streaming_loop(msg_id, expr) elif msg_type == "stream-stop": - stop_streaming() + # Only during streaming (handled by reader thread) + pass elif msg_type == "stream-exec": - if isinstance(code, str) and _streaming_active: - _streaming_code_queue.put(code) + # Only during streaming (handled by reader thread) + pass + + elif msg_type == "noop": + # Flush message from server — ignore + pass else: raise ValueError(f"Unknown message type: {msg_type}") diff --git a/pyproject.toml b/pyproject.toml index 167b30ef..1e0518bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "0.5.0" description = "Visual node editor for building and simulating dynamic systems with PathSim" readme = "README.md" license = {text = "MIT"} -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Science/Research", @@ -18,6 +18,7 @@ classifiers = [ dependencies = [ "flask>=3.0", "flask-cors>=4.0", + "numpy", ] [project.urls] diff --git a/scripts/build_package.py b/scripts/build_package.py index 087c4932..72841df9 100644 --- a/scripts/build_package.py +++ b/scripts/build_package.py @@ -20,7 +20,9 @@ def run(cmd, **kwargs): print(f" > {' '.join(cmd)}") - result = subprocess.run(cmd, cwd=kwargs.pop("cwd", REPO_ROOT), **kwargs) + # shell=True needed on Windows for npx/npm resolution + result = subprocess.run(cmd, cwd=kwargs.pop("cwd", REPO_ROOT), + shell=(sys.platform == "win32"), **kwargs) if result.returncode != 0: print(f"ERROR: command failed (exit {result.returncode})") sys.exit(result.returncode) diff --git a/src/lib/pyodide/backend/flask/backend.ts b/src/lib/pyodide/backend/flask/backend.ts index 2b381d49..8d7f695c 100644 --- a/src/lib/pyodide/backend/flask/backend.ts +++ b/src/lib/pyodide/backend/flask/backend.ts @@ -1,6 +1,10 @@ /** * Flask Backend - * Implements the Backend interface using a Flask server with subprocess workers + * Implements the Backend interface using a Flask server with subprocess workers. + * + * Mirrors the Pyodide worker's message-passing pattern: + * - exec/eval use simple request/response + * - streaming uses start + poll (like postMessage with stream-data/stream-done) */ import type { Backend, BackendState } from '../types'; @@ -9,21 +13,18 @@ import { TIMEOUTS } from '$lib/constants/python'; import { STATUS_MESSAGES } from '$lib/constants/messages'; import { PYTHON_PACKAGES } from '$lib/constants/dependencies'; -/** - * Flask Backend Implementation - * - * Communicates with a Flask server that manages Python subprocess workers. - * Each browser session gets its own isolated Python process on the server. - * Supports streaming via Server-Sent Events (SSE). - */ +/** Polling interval for stream results (ms) */ +const STREAM_POLL_INTERVAL = 30; + export class FlaskBackend implements Backend { private host: string; private sessionId: string; private messageId = 0; private _isStreaming = false; - private streamAbortController: AbortController | null = null; + private streamPollTimer: ReturnType | null = null; + private serverInitPromise: Promise | null = null; - // Stream state + // Stream callbacks — same shape as PyodideBackend's streamState private streamState: { onData: ((data: unknown) => void) | null; onDone: (() => void) | null; @@ -35,8 +36,7 @@ export class FlaskBackend implements Backend { private stderrCallback: ((value: string) => void) | null = null; constructor(host: string) { - this.host = host.replace(/\/$/, ''); // strip trailing slash - // Get or create session ID from sessionStorage + this.host = host.replace(/\/$/, ''); const stored = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('flask-session-id') : null; if (stored) { this.sessionId = stored; @@ -64,25 +64,13 @@ export class FlaskBackend implements Backend { })); try { - // Health check - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), TIMEOUTS.INIT); - const resp = await fetch(`${this.host}/api/health`, { - signal: controller.signal + signal: AbortSignal.timeout(TIMEOUTS.INIT) }); - clearTimeout(timeout); - - if (!resp.ok) { - throw new Error(`Server health check failed: ${resp.status}`); - } + if (!resp.ok) throw new Error(`Server health check failed: ${resp.status}`); - // Initialize worker with packages from the shared config (single source of truth) backendState.update((s) => ({ ...s, progress: 'Initializing Python worker...' })); - const initController = new AbortController(); - const initTimeout = setTimeout(() => initController.abort(), TIMEOUTS.INIT); - const initResp = await fetch(`${this.host}/api/init`, { method: 'POST', headers: { @@ -90,17 +78,12 @@ export class FlaskBackend implements Backend { 'X-Session-ID': this.sessionId }, body: JSON.stringify({ packages: PYTHON_PACKAGES }), - signal: initController.signal + signal: AbortSignal.timeout(TIMEOUTS.INIT) }); - clearTimeout(initTimeout); - const initData = await initResp.json(); - if (initData.type === 'error') { - throw new Error(initData.error); - } + if (initData.type === 'error') throw new Error(initData.error); - // Forward any stdout/stderr messages from init if (initData.messages) { for (const msg of initData.messages) { if (msg.type === 'stdout' && this.stdoutCallback) this.stdoutCallback(msg.value); @@ -111,6 +94,8 @@ export class FlaskBackend implements Backend { } } + this.serverInitPromise = Promise.resolve(); + backendState.update((s) => ({ ...s, initialized: true, @@ -119,33 +104,22 @@ export class FlaskBackend implements Backend { })); } catch (error) { const msg = error instanceof Error ? error.message : String(error); - backendState.update((s) => ({ - ...s, - loading: false, - error: `Flask backend error: ${msg}` - })); + backendState.update((s) => ({ ...s, loading: false, error: `Flask backend error: ${msg}` })); throw error; } } terminate(): void { - // Abort any active stream - if (this.streamAbortController) { - this.streamAbortController.abort(); - this.streamAbortController = null; - } - - // Clear stream state + this.stopStreaming(); this._isStreaming = false; this.streamState = { onData: null, onDone: null, onError: null }; - // Kill server-side session (fire and forget) fetch(`${this.host}/api/session`, { method: 'DELETE', headers: { 'X-Session-ID': this.sessionId } }).catch(() => {}); - // Reset state + this.serverInitPromise = null; backendState.reset(); } @@ -174,89 +148,105 @@ export class FlaskBackend implements Backend { } // ------------------------------------------------------------------------- - // Execution + // Lazy server init // ------------------------------------------------------------------------- - async exec(code: string, timeout: number = TIMEOUTS.SIMULATION): Promise { - const id = this.generateId(); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); + private ensureServerInit(): Promise { + if (this.serverInitPromise) return this.serverInitPromise; - try { - const resp = await fetch(`${this.host}/api/exec`, { + this.serverInitPromise = (async () => { + const resp = await fetch(`${this.host}/api/init`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Session-ID': this.sessionId }, - body: JSON.stringify({ id, code }), - signal: controller.signal + body: JSON.stringify({ packages: PYTHON_PACKAGES }), + signal: AbortSignal.timeout(TIMEOUTS.INIT) }); - const data = await resp.json(); + if (data.type === 'error') throw new Error(data.error); + if (data.messages) { + for (const msg of data.messages) { + if (msg.type === 'stdout' && this.stdoutCallback) this.stdoutCallback(msg.value); + if (msg.type === 'stderr' && this.stderrCallback) this.stderrCallback(msg.value); + } + } + })(); - // Forward stdout/stderr from response - if (data.stdout && this.stdoutCallback) this.stdoutCallback(data.stdout); - if (data.stderr && this.stderrCallback) this.stderrCallback(data.stderr); + // Clear on failure so subsequent calls retry instead of returning the rejected promise + this.serverInitPromise.catch(() => { + this.serverInitPromise = null; + }); - if (data.type === 'error') { - const errorMsg = data.traceback ? `${data.error}\n${data.traceback}` : data.error; - throw new Error(errorMsg); - } - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - throw new Error('Execution timeout'); - } - throw error; - } finally { - clearTimeout(timeoutId); - } + return this.serverInitPromise; } - async evaluate(expr: string, timeout: number = TIMEOUTS.SIMULATION): Promise { + // ------------------------------------------------------------------------- + // Execution + // ------------------------------------------------------------------------- + + async exec(code: string, timeout: number = TIMEOUTS.SIMULATION): Promise { + await this.ensureServerInit(); const id = this.generateId(); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - try { - const resp = await fetch(`${this.host}/api/eval`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Session-ID': this.sessionId - }, - body: JSON.stringify({ id, expr }), - signal: controller.signal - }); + const resp = await fetch(`${this.host}/api/exec`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Session-ID': this.sessionId + }, + body: JSON.stringify({ id, code }), + signal: AbortSignal.timeout(timeout) + }); - const data = await resp.json(); + if (!resp.ok && resp.status >= 500) { + throw new Error(`Server error: ${resp.status}`); + } - // Forward stdout/stderr from response - if (data.stdout && this.stdoutCallback) this.stdoutCallback(data.stdout); - if (data.stderr && this.stderrCallback) this.stderrCallback(data.stderr); + const data = await resp.json(); + if (data.stdout && this.stdoutCallback) this.stdoutCallback(data.stdout); + if (data.stderr && this.stderrCallback) this.stderrCallback(data.stderr); - if (data.type === 'error') { - const errorMsg = data.traceback ? `${data.error}\n${data.traceback}` : data.error; - throw new Error(errorMsg); - } + if (data.type === 'error') { + const errorMsg = data.traceback ? `${data.error}\n${data.traceback}` : data.error; + throw new Error(errorMsg); + } + } - if (data.value === undefined) { - throw new Error('No value returned from eval'); - } + async evaluate(expr: string, timeout: number = TIMEOUTS.SIMULATION): Promise { + await this.ensureServerInit(); + const id = this.generateId(); - return JSON.parse(data.value) as T; - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - throw new Error('Evaluation timeout'); - } - throw error; - } finally { - clearTimeout(timeoutId); + const resp = await fetch(`${this.host}/api/eval`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Session-ID': this.sessionId + }, + body: JSON.stringify({ id, expr }), + signal: AbortSignal.timeout(timeout) + }); + + if (!resp.ok && resp.status >= 500) { + throw new Error(`Server error: ${resp.status}`); } + + const data = await resp.json(); + if (data.stdout && this.stdoutCallback) this.stdoutCallback(data.stdout); + if (data.stderr && this.stderrCallback) this.stderrCallback(data.stderr); + + if (data.type === 'error') { + const errorMsg = data.traceback ? `${data.error}\n${data.traceback}` : data.error; + throw new Error(errorMsg); + } + + if (data.value === undefined) throw new Error('No value returned from eval'); + return JSON.parse(data.value) as T; } // ------------------------------------------------------------------------- - // Streaming + // Streaming — mirrors Pyodide worker's postMessage pattern via polling // ------------------------------------------------------------------------- startStreaming( @@ -265,12 +255,6 @@ export class FlaskBackend implements Backend { onDone: () => void, onError: (error: Error) => void ): void { - if (!this.isReady()) { - onError(new Error('Backend not initialized')); - return; - } - - // Stop any existing stream if (this._isStreaming) { this.stopStreaming(); } @@ -283,29 +267,59 @@ export class FlaskBackend implements Backend { onError }; - this.streamAbortController = new AbortController(); - - // Start SSE stream - this.consumeSSEStream(id, expr, this.streamAbortController.signal); + // Start stream on server, then poll for results + this.ensureServerInit() + .then(() => + fetch(`${this.host}/api/stream/start`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Session-ID': this.sessionId + }, + body: JSON.stringify({ id, expr }) + }) + ) + .then((resp) => resp.json()) + .then((data) => { + if (data.type === 'error') { + throw new Error(data.error); + } + // Start polling loop — same as Pyodide worker's onmessage dispatching + this.pollStreamResults(); + }) + .catch((error) => { + this._isStreaming = false; + onError(error instanceof Error ? error : new Error(String(error))); + this.streamState = { onData: null, onDone: null, onError: null }; + }); } stopStreaming(): void { if (!this._isStreaming) return; - // Send stop to server + // Stop polling timer — the server will send stream-done which triggers onDone + if (this.streamPollTimer) { + clearTimeout(this.streamPollTimer); + this.streamPollTimer = null; + } + + // Tell server to stop, then do one final poll to get the stream-done message fetch(`${this.host}/api/stream/stop`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Session-ID': this.sessionId } - }).catch(() => {}); - - // Abort the SSE connection - if (this.streamAbortController) { - this.streamAbortController.abort(); - this.streamAbortController = null; - } + }) + .then(() => this.pollStreamResults()) + .catch(() => { + // If final poll fails, clean up locally + this._isStreaming = false; + if (this.streamState.onDone) { + this.streamState.onDone(); + } + this.streamState = { onData: null, onDone: null, onError: null }; + }); } isStreaming(): boolean { @@ -318,7 +332,6 @@ export class FlaskBackend implements Backend { return; } - // Fire and forget fetch(`${this.host}/api/stream/exec`, { method: 'POST', headers: { @@ -350,72 +363,37 @@ export class FlaskBackend implements Backend { } /** - * Consume an SSE stream from /api/stream, dispatching events to callbacks. + * Poll the server for stream messages and dispatch them to callbacks. + * This mirrors the Pyodide backend's handleResponse for stream-data/stream-done. */ - private async consumeSSEStream(id: string, expr: string, signal: AbortSignal): Promise { + private async pollStreamResults(): Promise { + if (!this._isStreaming) return; + try { - const resp = await fetch(`${this.host}/api/stream`, { + const resp = await fetch(`${this.host}/api/stream/poll`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Session-ID': this.sessionId - }, - body: JSON.stringify({ id, expr }), - signal + } }); - if (!resp.ok || !resp.body) { - throw new Error(`Stream request failed: ${resp.status}`); - } - - const reader = resp.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { value, done } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - // Process complete SSE messages (separated by double newlines) - const parts = buffer.split('\n\n'); - buffer = parts.pop() || ''; // Keep incomplete last part - - for (const part of parts) { - if (!part.trim()) continue; - - // Parse SSE fields - let eventType = ''; - let eventData = ''; - - for (const line of part.split('\n')) { - if (line.startsWith('event: ')) { - eventType = line.slice(7); - } else if (line.startsWith('data: ')) { - eventData = line.slice(6); - } - } + const data = await resp.json(); - if (!eventType) continue; + if (!Array.isArray(data?.messages)) { + throw new Error(data?.error || 'Invalid poll response'); + } - this.handleSSEEvent(eventType, eventData); + for (const msg of data.messages) { + this.handleStreamMessage(msg); + if (!this._isStreaming) return; // done or error stopped streaming + } - if (eventType === 'done' || eventType === 'error') { - return; - } - } + // Schedule next poll if still streaming + if (this._isStreaming) { + this.streamPollTimer = setTimeout(() => this.pollStreamResults(), STREAM_POLL_INTERVAL); } } catch (error) { - if (signal.aborted) { - // Aborted by stopStreaming — call onDone - this._isStreaming = false; - if (this.streamState.onDone) { - this.streamState.onDone(); - } - this.streamState = { onData: null, onDone: null, onError: null }; - return; - } this._isStreaming = false; if (this.streamState.onError) { this.streamState.onError(error instanceof Error ? error : new Error(String(error))); @@ -424,62 +402,52 @@ export class FlaskBackend implements Backend { } } - private handleSSEEvent(eventType: string, data: string): void { - switch (eventType) { - case 'data': { - if (this.streamState.onData) { + /** + * Handle a single message from the worker — same dispatch as PyodideBackend.handleResponse + */ + private handleStreamMessage(msg: Record): void { + const type = msg.type as string; + + switch (type) { + case 'stream-data': { + if (this.streamState.onData && msg.value) { try { - const parsed = JSON.parse(data); - this.streamState.onData(parsed); + this.streamState.onData(JSON.parse(msg.value as string)); } catch { // Ignore parse errors } } break; } - case 'stdout': { - if (this.stdoutCallback) { - try { - this.stdoutCallback(JSON.parse(data)); - } catch { - this.stdoutCallback(data); - } + case 'stream-done': { + this._isStreaming = false; + if (this.streamState.onDone) { + this.streamState.onDone(); } + this.streamState = { onData: null, onDone: null, onError: null }; break; } - case 'stderr': { - if (this.stderrCallback) { - try { - this.stderrCallback(JSON.parse(data)); - } catch { - this.stderrCallback(data); - } + case 'stdout': { + if (this.stdoutCallback && msg.value) { + this.stdoutCallback(msg.value as string); } break; } - case 'done': { - this._isStreaming = false; - if (this.streamState.onDone) { - this.streamState.onDone(); + case 'stderr': { + if (this.stderrCallback && msg.value) { + this.stderrCallback(msg.value as string); } - this.streamState = { onData: null, onDone: null, onError: null }; break; } case 'error': { this._isStreaming = false; if (this.streamState.onError) { - try { - const parsed = JSON.parse(data); - const msg = parsed.traceback - ? `${parsed.error}\n${parsed.traceback}` - : parsed.error || 'Unknown error'; - this.streamState.onError(new Error(msg)); - } catch { - this.streamState.onError(new Error(data || 'Stream error')); - } + const errorMsg = msg.traceback + ? `${msg.error}\n${msg.traceback}` + : (msg.error as string) || 'Unknown error'; + this.streamState.onError(new Error(errorMsg)); } this.streamState = { onData: null, onDone: null, onError: null }; - backendState.update((s) => ({ ...s, error: 'Stream error' })); break; } } diff --git a/src/lib/pyodide/backend/index.ts b/src/lib/pyodide/backend/index.ts index 86c6aae9..ef04b5ce 100644 --- a/src/lib/pyodide/backend/index.ts +++ b/src/lib/pyodide/backend/index.ts @@ -33,7 +33,7 @@ export { FlaskBackend } from './flask/backend'; // These delegate to the current backend and maintain API compatibility // ============================================================================ -import { getBackend, switchBackend, setFlaskHost } from './registry'; +import { getBackend, switchBackend, setFlaskHost, getBackendType } from './registry'; import { backendState } from './state'; import { consoleStore } from '$lib/stores/console'; @@ -42,7 +42,7 @@ import { consoleStore } from '$lib/stores/console'; * Reads `?backend=flask` and `?host=...` from the current URL. * Call this early in page mount, before any backend usage. */ -export function initBackendFromUrl(): void { +export async function initBackendFromUrl(): Promise { if (typeof window === 'undefined') return; const params = new URLSearchParams(window.location.search); const backendParam = params.get('backend'); @@ -53,6 +53,7 @@ export function initBackendFromUrl(): void { setFlaskHost(hostParam); } switchBackend('flask'); + await init(); } } @@ -78,6 +79,8 @@ export async function autoDetectBackend(): Promise { if (data.status === 'ok') { setFlaskHost(window.location.origin); switchBackend('flask'); + // Run full init — sets up callbacks, logs progress, initializes worker + await init(); } } } catch { From 0cb9ecbefec3bba0a2ae3567921b0789b49a6f94 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 21:25:20 +0100 Subject: [PATCH 378/656] Add exec/eval timeout watchdog (30s worker, 35s server) --- pathview_server/app.py | 43 ++++++++++++++++++++--- pathview_server/worker.py | 72 ++++++++++++++++++++++++++++++++++----- 2 files changed, 102 insertions(+), 13 deletions(-) diff --git a/pathview_server/app.py b/pathview_server/app.py index a909e4e7..d4397d82 100644 --- a/pathview_server/app.py +++ b/pathview_server/app.py @@ -27,6 +27,7 @@ SESSION_TTL = 3600 # 1 hour of inactivity before cleanup CLEANUP_INTERVAL = 60 # Check for stale sessions every 60 seconds +EXEC_TIMEOUT = 35 # Server-side timeout for exec/eval (slightly > worker's 30s) WORKER_SCRIPT = str(Path(__file__).parent / "worker.py") # --------------------------------------------------------------------------- @@ -68,6 +69,32 @@ def read_line(self) -> dict | None: return None return json.loads(line.strip()) + def read_line_timeout(self, timeout: float = EXEC_TIMEOUT) -> dict | None: + """Read one JSON line with a timeout. Returns None on EOF or timeout. + + Raises TimeoutError if no response within the timeout period. + """ + result = [None] + error = [None] + + def reader(): + try: + result[0] = self.read_line() + except Exception as e: + error[0] = e + + t = threading.Thread(target=reader, daemon=True) + t.start() + t.join(timeout) + + if t.is_alive(): + raise TimeoutError(f"Worker unresponsive after {timeout}s") + + if error[0]: + raise error[0] + + return result[0] + def ensure_initialized(self, packages: list[dict] | None = None) -> list[dict]: """Initialize the worker if not already done. Returns any messages received.""" if self._initialized: @@ -277,9 +304,10 @@ def api_exec(): stdout_lines = [] stderr_lines = [] while True: - resp = session.read_line() + resp = session.read_line_timeout() if resp is None: - return jsonify({"type": "error", "id": msg_id, "error": "Worker process died"}), 500 + remove_session(session_id) + return jsonify({"type": "error", "errorType": "worker-crashed", "id": msg_id, "error": "Worker process died"}), 500 resp_type = resp.get("type") if resp_type == "stdout": stdout_lines.append(resp.get("value", "")) @@ -300,6 +328,9 @@ def api_exec(): result["stderr"] = "".join(stderr_lines) return jsonify(result), 400 + except TimeoutError: + remove_session(session_id) + return jsonify({"type": "error", "errorType": "timeout", "id": msg_id, "error": "Execution timed out"}), 504 except Exception as e: return jsonify({"type": "error", "id": msg_id, "error": str(e)}), 500 @@ -322,9 +353,10 @@ def api_eval(): stdout_lines = [] stderr_lines = [] while True: - resp = session.read_line() + resp = session.read_line_timeout() if resp is None: - return jsonify({"type": "error", "id": msg_id, "error": "Worker process died"}), 500 + remove_session(session_id) + return jsonify({"type": "error", "errorType": "worker-crashed", "id": msg_id, "error": "Worker process died"}), 500 resp_type = resp.get("type") if resp_type == "stdout": stdout_lines.append(resp.get("value", "")) @@ -345,6 +377,9 @@ def api_eval(): result["stderr"] = "".join(stderr_lines) return jsonify(result), 400 + except TimeoutError: + remove_session(session_id) + return jsonify({"type": "error", "errorType": "timeout", "id": msg_id, "error": "Execution timed out"}), 504 except Exception as e: return jsonify({"type": "error", "id": msg_id, "error": str(e)}), 500 diff --git a/pathview_server/worker.py b/pathview_server/worker.py index 558a2701..c31322c2 100644 --- a/pathview_server/worker.py +++ b/pathview_server/worker.py @@ -20,6 +20,7 @@ import threading import traceback import queue +import ctypes # Lock for thread-safe stdout writing (protocol messages only) _stdout_lock = threading.Lock() @@ -31,6 +32,9 @@ _namespace = {} _initialized = False +# Default timeout for exec/eval (seconds) +EXEC_TIMEOUT = 30 + # Streaming state _streaming_active = False _streaming_code_queue = queue.Queue() @@ -160,6 +164,51 @@ def initialize(packages: list[dict] | None = None) -> None: send({"type": "ready"}) +def _raise_in_thread(thread_id: int, exc_type: type) -> None: + """Raise an exception in the given thread (best-effort interrupt).""" + ctypes.pythonapi.PyThreadState_SetAsyncExc( + ctypes.c_ulong(thread_id), ctypes.py_object(exc_type) + ) + + +def _run_with_timeout(func, timeout: float = EXEC_TIMEOUT): + """Run func() in a daemon thread with a timeout. + + Returns (result, error_string, traceback_string). + If timeout fires, raises TimeoutError. + """ + result_holder = [None, None, None] # result, error, traceback + + def target(): + try: + result_holder[0] = func() + except Exception as e: + result_holder[1] = str(e) + result_holder[2] = traceback.format_exc() + + t = threading.Thread(target=target, daemon=True) + t.start() + t.join(timeout) + + if t.is_alive(): + # Try to interrupt the stuck thread + _raise_in_thread(t.ident, KeyboardInterrupt) + t.join(2) # Give it 2s to handle the interrupt + raise TimeoutError(f"Execution timed out after {timeout}s") + + if result_holder[1] is not None: + raise _ExecError(result_holder[1], result_holder[2]) + + return result_holder[0] + + +class _ExecError(Exception): + """Wraps an error from user code execution with its traceback.""" + def __init__(self, message: str, tb: str | None = None): + super().__init__(message) + self.tb = tb + + def exec_code(msg_id: str, code: str) -> None: """Execute Python code (no return value).""" if not _initialized: @@ -167,11 +216,12 @@ def exec_code(msg_id: str, code: str) -> None: return try: - exec(code, _namespace) + _run_with_timeout(lambda: exec(code, _namespace)) send({"type": "ok", "id": msg_id}) - except Exception as e: - tb = traceback.format_exc() - send({"type": "error", "id": msg_id, "error": str(e), "traceback": tb}) + except TimeoutError as e: + send({"type": "error", "id": msg_id, "error": str(e)}) + except _ExecError as e: + send({"type": "error", "id": msg_id, "error": str(e), "traceback": e.tb}) def eval_expr(msg_id: str, expr: str) -> None: @@ -181,14 +231,18 @@ def eval_expr(msg_id: str, expr: str) -> None: return try: - exec_code_str = f"_eval_result = {expr}" - exec(exec_code_str, _namespace) + def do_eval(): + exec_code_str = f"_eval_result = {expr}" + exec(exec_code_str, _namespace) + + _run_with_timeout(do_eval) to_json = _namespace.get("_to_json", str) result = json.dumps(_namespace["_eval_result"], default=to_json) send({"type": "value", "id": msg_id, "value": result}) - except Exception as e: - tb = traceback.format_exc() - send({"type": "error", "id": msg_id, "error": str(e), "traceback": tb}) + except TimeoutError as e: + send({"type": "error", "id": msg_id, "error": str(e)}) + except _ExecError as e: + send({"type": "error", "id": msg_id, "error": str(e), "traceback": e.tb}) def _streaming_reader_thread(stop_event: threading.Event) -> None: From 4938d118f91b660748d70c0292210c39406d1e15 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 21:25:38 +0100 Subject: [PATCH 379/656] Use waitress as production WSGI server, keep Flask dev server for --debug --- pathview_server/cli.py | 6 +++++- pyproject.toml | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pathview_server/cli.py b/pathview_server/cli.py index d45f35c8..39e61717 100644 --- a/pathview_server/cli.py +++ b/pathview_server/cli.py @@ -45,7 +45,11 @@ def open_browser(): print("Press Ctrl+C to stop\n") try: - app.run(host=args.host, port=args.port, debug=args.debug, threaded=True) + if args.debug: + app.run(host=args.host, port=args.port, debug=True, threaded=True) + else: + from waitress import serve + serve(app, host=args.host, port=args.port, threads=4) except KeyboardInterrupt: print("\nStopping PathView server...") sys.exit(0) diff --git a/pyproject.toml b/pyproject.toml index 1e0518bb..315966e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "flask>=3.0", "flask-cors>=4.0", "numpy", + "waitress>=3.0", ] [project.urls] From 4ee85e20200c0e67f311ac1282bb2799934f10cb Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 21:25:55 +0100 Subject: [PATCH 380/656] Replace sleep-based browser open with health check polling --- pathview_server/cli.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/pathview_server/cli.py b/pathview_server/cli.py index 39e61717..63340ce0 100644 --- a/pathview_server/cli.py +++ b/pathview_server/cli.py @@ -34,11 +34,19 @@ def main(): app = create_app(serve_static=True) if not args.no_browser: - def open_browser(): - time.sleep(1.5) - webbrowser.open(f"http://{args.host}:{args.port}") + def open_browser_when_ready(): + import urllib.request + health_url = f"http://{args.host}:{args.port}/api/health" + deadline = time.time() + 10 + while time.time() < deadline: + try: + urllib.request.urlopen(health_url, timeout=1) + webbrowser.open(f"http://{args.host}:{args.port}") + return + except Exception: + time.sleep(0.2) - threading.Thread(target=open_browser, daemon=True).start() + threading.Thread(target=open_browser_when_ready, daemon=True).start() print(f"PathView v{__version__}") print(f"Running at http://{args.host}:{args.port}") From 4d18231a992200977141acf8c79774e41d7727b1 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 21:26:33 +0100 Subject: [PATCH 381/656] Add worker crash recovery with auto-reinit on next request --- src/lib/pyodide/backend/flask/backend.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/lib/pyodide/backend/flask/backend.ts b/src/lib/pyodide/backend/flask/backend.ts index 8d7f695c..dcdb37ed 100644 --- a/src/lib/pyodide/backend/flask/backend.ts +++ b/src/lib/pyodide/backend/flask/backend.ts @@ -200,15 +200,12 @@ export class FlaskBackend implements Backend { signal: AbortSignal.timeout(timeout) }); - if (!resp.ok && resp.status >= 500) { - throw new Error(`Server error: ${resp.status}`); - } - const data = await resp.json(); if (data.stdout && this.stdoutCallback) this.stdoutCallback(data.stdout); if (data.stderr && this.stderrCallback) this.stderrCallback(data.stderr); if (data.type === 'error') { + this.handleWorkerError(data); const errorMsg = data.traceback ? `${data.error}\n${data.traceback}` : data.error; throw new Error(errorMsg); } @@ -228,15 +225,12 @@ export class FlaskBackend implements Backend { signal: AbortSignal.timeout(timeout) }); - if (!resp.ok && resp.status >= 500) { - throw new Error(`Server error: ${resp.status}`); - } - const data = await resp.json(); if (data.stdout && this.stdoutCallback) this.stdoutCallback(data.stdout); if (data.stderr && this.stderrCallback) this.stderrCallback(data.stderr); if (data.type === 'error') { + this.handleWorkerError(data); const errorMsg = data.traceback ? `${data.error}\n${data.traceback}` : data.error; throw new Error(errorMsg); } @@ -362,6 +356,20 @@ export class FlaskBackend implements Backend { return `repl_${++this.messageId}`; } + /** + * Check if a response indicates the worker crashed or timed out. + * If so, clear serverInitPromise so the next request triggers re-init. + */ + private handleWorkerError(data: Record): void { + const errorType = data.errorType as string | undefined; + if (errorType === 'worker-crashed' || errorType === 'timeout') { + this.serverInitPromise = null; + if (this.stderrCallback) { + this.stderrCallback('Python worker crashed, restarting on next request...\n'); + } + } + } + /** * Poll the server for stream messages and dispatch them to callbacks. * This mirrors the Pyodide backend's handleResponse for stream-data/stream-done. From 4bdaa0665adcf1d7ca4228c25486b1f7cd82571b Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 21:28:14 +0100 Subject: [PATCH 382/656] Add pytest test suite and CI workflow (22 tests) --- .github/workflows/test.yml | 31 +++++++ pyproject.toml | 5 ++ tests/__init__.py | 0 tests/conftest.py | 39 +++++++++ tests/test_app.py | 157 ++++++++++++++++++++++++++++++++++++ tests/test_worker.py | 160 +++++++++++++++++++++++++++++++++++++ 6 files changed, 392 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_app.py create mode 100644 tests/test_worker.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..517f3136 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: Tests + +on: + push: + branches: [main, feature/flask-backend] + pull_request: + branches: [main, feature/flask-backend] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[test]" + + - name: Run tests + run: pytest tests/ -v diff --git a/pyproject.toml b/pyproject.toml index 315966e6..23493c96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,11 @@ dependencies = [ "waitress>=3.0", ] +[project.optional-dependencies] +test = [ + "pytest>=8.0", +] + [project.urls] Homepage = "https://view.pathsim.org" Repository = "https://github.com/pathsim/pathview" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..e5a0b75e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,39 @@ +"""Shared pytest fixtures for PathView server tests.""" + +import pytest + +from pathview_server.app import create_app, _sessions, _sessions_lock + + +@pytest.fixture() +def app(): + """Create a Flask test app (API-only, no static serving).""" + application = create_app(serve_static=False) + application.config["TESTING"] = True + yield application + # Clean up all sessions after each test + with _sessions_lock: + for session in _sessions.values(): + session.kill() + _sessions.clear() + + +@pytest.fixture() +def client(app): + """Flask test client.""" + return app.test_client() + + +@pytest.fixture() +def session_id(): + """A stable session ID for tests.""" + return "test-session-001" + + +@pytest.fixture() +def session_headers(session_id): + """Headers with session ID and content type.""" + return { + "X-Session-ID": session_id, + "Content-Type": "application/json", + } diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 00000000..354a6d5e --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,157 @@ +"""Integration tests for Flask routes.""" + +import json + + +def test_health(client): + resp = client.get("/api/health") + assert resp.status_code == 200 + assert resp.get_json()["status"] == "ok" + + +def test_init_missing_session_id(client): + resp = client.post("/api/init", json={}) + assert resp.status_code == 400 + assert "Missing X-Session-ID" in resp.get_json()["error"] + + +def test_init_creates_session(client, session_headers): + resp = client.post("/api/init", json={"packages": []}, headers=session_headers) + assert resp.status_code == 200 + data = resp.get_json() + assert data["type"] == "ready" + + +def test_exec_simple(client, session_headers): + # Init first + client.post("/api/init", json={"packages": []}, headers=session_headers) + + # Execute code + resp = client.post("/api/exec", json={"code": "x = 42"}, headers=session_headers) + assert resp.status_code == 200 + assert resp.get_json()["type"] == "ok" + + +def test_exec_with_print(client, session_headers): + client.post("/api/init", json={"packages": []}, headers=session_headers) + + resp = client.post( + "/api/exec", json={"code": "print('hello world')"}, headers=session_headers + ) + assert resp.status_code == 200 + data = resp.get_json() + assert data["type"] == "ok" + assert "hello world" in data.get("stdout", "") + + +def test_exec_error(client, session_headers): + client.post("/api/init", json={"packages": []}, headers=session_headers) + + resp = client.post( + "/api/exec", json={"code": "raise ValueError('test error')"}, headers=session_headers + ) + assert resp.status_code == 400 + data = resp.get_json() + assert data["type"] == "error" + assert "test error" in data["error"] + + +def test_eval_simple(client, session_headers): + client.post("/api/init", json={"packages": []}, headers=session_headers) + + # Set a variable, then eval it + client.post("/api/exec", json={"code": "y = 123"}, headers=session_headers) + + resp = client.post("/api/eval", json={"expr": "y"}, headers=session_headers) + assert resp.status_code == 200 + data = resp.get_json() + assert data["type"] == "value" + assert json.loads(data["value"]) == 123 + + +def test_eval_expression(client, session_headers): + client.post("/api/init", json={"packages": []}, headers=session_headers) + + resp = client.post("/api/eval", json={"expr": "2 + 3"}, headers=session_headers) + assert resp.status_code == 200 + data = resp.get_json() + assert json.loads(data["value"]) == 5 + + +def test_eval_error(client, session_headers): + client.post("/api/init", json={"packages": []}, headers=session_headers) + + resp = client.post( + "/api/eval", json={"expr": "undefined_var"}, headers=session_headers + ) + assert resp.status_code == 400 + data = resp.get_json() + assert data["type"] == "error" + + +def test_session_delete(client, session_headers): + client.post("/api/init", json={"packages": []}, headers=session_headers) + + resp = client.delete("/api/session", headers=session_headers) + assert resp.status_code == 200 + assert resp.get_json()["status"] == "terminated" + + +def test_session_persistence(client, session_headers): + """Variables set in one exec should be available in the next.""" + client.post("/api/init", json={"packages": []}, headers=session_headers) + + client.post("/api/exec", json={"code": "my_var = 'persistent'"}, headers=session_headers) + + resp = client.post("/api/eval", json={"expr": "my_var"}, headers=session_headers) + data = resp.get_json() + assert json.loads(data["value"]) == "persistent" + + +def test_streaming_lifecycle(client, session_headers): + """Test stream start → poll → done cycle.""" + client.post("/api/init", json={"packages": []}, headers=session_headers) + + # Set up a generator that yields one value then is done + client.post( + "/api/exec", + json={"code": "_step = 0\ndef _gen():\n global _step\n _step += 1\n return {'result': _step, 'done': _step >= 2}"}, + headers=session_headers, + ) + + # Start streaming + resp = client.post( + "/api/stream/start", + json={"expr": "_gen()"}, + headers=session_headers, + ) + assert resp.status_code == 200 + assert resp.get_json()["status"] == "started" + + # Poll until done + import time + done = False + messages = [] + deadline = time.time() + 10 + while not done and time.time() < deadline: + resp = client.post("/api/stream/poll", headers=session_headers) + data = resp.get_json() + messages.extend(data.get("messages", [])) + done = data.get("done", False) + if not done: + time.sleep(0.1) + + assert done + types = [m["type"] for m in messages] + assert "stream-data" in types + assert "stream-done" in types + + +def test_exec_missing_session_id(client): + resp = client.post("/api/exec", json={"code": "pass"}) + assert resp.status_code == 400 + + +def test_eval_missing_session_id(client): + resp = client.post("/api/eval", json={"expr": "1"}) + assert resp.status_code == 400 diff --git a/tests/test_worker.py b/tests/test_worker.py new file mode 100644 index 00000000..8397720a --- /dev/null +++ b/tests/test_worker.py @@ -0,0 +1,160 @@ +"""Unit tests for the worker subprocess message protocol.""" + +import json +import subprocess +import sys +from pathlib import Path + +WORKER_SCRIPT = str(Path(__file__).parent.parent / "pathview_server" / "worker.py") + + +def _start_worker(): + """Start a worker subprocess and return it.""" + return subprocess.Popen( + [sys.executable, "-u", WORKER_SCRIPT], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + + +def _send(proc, msg): + """Send a JSON message to the worker.""" + proc.stdin.write(json.dumps(msg) + "\n") + proc.stdin.flush() + + +def _recv(proc): + """Read one JSON response from the worker.""" + line = proc.stdout.readline() + if not line: + return None + return json.loads(line.strip()) + + +def _recv_until(proc, target_type, target_id=None): + """Read messages until we get the target type. Returns (target_msg, other_msgs).""" + others = [] + while True: + msg = _recv(proc) + if msg is None: + raise RuntimeError("Worker died unexpectedly") + if msg.get("type") == target_type and (target_id is None or msg.get("id") == target_id): + return msg, others + others.append(msg) + + +def test_init_ready(): + proc = _start_worker() + try: + _send(proc, {"type": "init"}) + msg, _ = _recv_until(proc, "ready") + assert msg["type"] == "ready" + finally: + proc.kill() + proc.wait() + + +def test_exec_ok(): + proc = _start_worker() + try: + _send(proc, {"type": "init"}) + _recv_until(proc, "ready") + + _send(proc, {"type": "exec", "id": "e1", "code": "x = 10"}) + msg, _ = _recv_until(proc, "ok", "e1") + assert msg["type"] == "ok" + assert msg["id"] == "e1" + finally: + proc.kill() + proc.wait() + + +def test_exec_error(): + proc = _start_worker() + try: + _send(proc, {"type": "init"}) + _recv_until(proc, "ready") + + _send(proc, {"type": "exec", "id": "e2", "code": "1/0"}) + msg, _ = _recv_until(proc, "error", "e2") + assert msg["type"] == "error" + assert "ZeroDivisionError" in msg.get("traceback", "") + finally: + proc.kill() + proc.wait() + + +def test_eval_value(): + proc = _start_worker() + try: + _send(proc, {"type": "init"}) + _recv_until(proc, "ready") + + _send(proc, {"type": "exec", "id": "e3", "code": "z = 42"}) + _recv_until(proc, "ok", "e3") + + _send(proc, {"type": "eval", "id": "v1", "expr": "z"}) + msg, _ = _recv_until(proc, "value", "v1") + assert json.loads(msg["value"]) == 42 + finally: + proc.kill() + proc.wait() + + +def test_eval_error(): + proc = _start_worker() + try: + _send(proc, {"type": "init"}) + _recv_until(proc, "ready") + + _send(proc, {"type": "eval", "id": "v2", "expr": "undefined_variable"}) + msg, _ = _recv_until(proc, "error", "v2") + assert msg["type"] == "error" + assert "NameError" in msg.get("traceback", "") or "undefined_variable" in msg.get("error", "") + finally: + proc.kill() + proc.wait() + + +def test_exec_print_captured(): + proc = _start_worker() + try: + _send(proc, {"type": "init"}) + _recv_until(proc, "ready") + + _send(proc, {"type": "exec", "id": "e4", "code": "print('captured')"}) + msg, others = _recv_until(proc, "ok", "e4") + stdout_msgs = [m for m in others if m.get("type") == "stdout"] + assert any("captured" in m.get("value", "") for m in stdout_msgs) + finally: + proc.kill() + proc.wait() + + +def test_exec_before_init(): + proc = _start_worker() + try: + _send(proc, {"type": "exec", "id": "e5", "code": "x = 1"}) + msg, _ = _recv_until(proc, "error", "e5") + assert "not initialized" in msg["error"].lower() + finally: + proc.kill() + proc.wait() + + +def test_double_init(): + proc = _start_worker() + try: + _send(proc, {"type": "init"}) + _recv_until(proc, "ready") + + # Second init should just return ready immediately + _send(proc, {"type": "init"}) + msg, _ = _recv_until(proc, "ready") + assert msg["type"] == "ready" + finally: + proc.kill() + proc.wait() From 67ec646815df2561be1feeb45571daa0c4b1f91c Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 21:28:40 +0100 Subject: [PATCH 383/656] Use pyproject.toml as single source of truth for version --- pathview_server/__init__.py | 6 +++++- scripts/build_package.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/pathview_server/__init__.py b/pathview_server/__init__.py index 8808f05d..1431ca56 100644 --- a/pathview_server/__init__.py +++ b/pathview_server/__init__.py @@ -1,3 +1,7 @@ """PathView Server — local Flask backend for PathView.""" -__version__ = "0.5.0" +try: + from importlib.metadata import version + __version__ = version("pathview") +except Exception: + __version__ = "0.5.0" # fallback for editable installs / dev diff --git a/scripts/build_package.py b/scripts/build_package.py index 72841df9..537ad312 100644 --- a/scripts/build_package.py +++ b/scripts/build_package.py @@ -7,7 +7,9 @@ 3. Builds the Python wheel """ +import json import os +import re import sys import shutil import subprocess @@ -28,7 +30,30 @@ def run(cmd, **kwargs): sys.exit(result.returncode) +def _sync_version(): + """Read version from pyproject.toml and sync to package.json.""" + pyproject = REPO_ROOT / "pyproject.toml" + text = pyproject.read_text() + match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE) + if not match: + print("ERROR: could not find version in pyproject.toml") + sys.exit(1) + version = match.group(1) + + pkg_json_path = REPO_ROOT / "package.json" + pkg = json.loads(pkg_json_path.read_text()) + if pkg.get("version") != version: + print(f" Syncing version {pkg.get('version')} → {version} in package.json") + pkg["version"] = version + pkg_json_path.write_text(json.dumps(pkg, indent=2) + "\n") + return version + + def main(): + print("[0/4] Syncing version...") + version = _sync_version() + print(f" Version: {version}") + print("[1/4] Cleaning previous builds...") for d in [BUILD_DIR, STATIC_DIR, REPO_ROOT / "dist"]: if d.exists(): From 8c4aa8f94c76e64b04c6a0c903594a5c509c462c Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 21:28:55 +0100 Subject: [PATCH 384/656] Add network security warning when binding to 0.0.0.0 --- pathview_server/cli.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pathview_server/cli.py b/pathview_server/cli.py index 63340ce0..df298757 100644 --- a/pathview_server/cli.py +++ b/pathview_server/cli.py @@ -50,7 +50,13 @@ def open_browser_when_ready(): print(f"PathView v{__version__}") print(f"Running at http://{args.host}:{args.port}") - print("Press Ctrl+C to stop\n") + + if args.host == "0.0.0.0": + print("\nWARNING: Binding to 0.0.0.0 makes the server accessible on your network.") + print(" There is no authentication — anyone on your network can execute Python code.") + print(" Only use this on trusted networks.") + + print("\nPress Ctrl+C to stop\n") try: if args.debug: From 71e37fdd8a7bf53661eaa6d07f3f860fadbe7f40 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 21:29:27 +0100 Subject: [PATCH 385/656] Share session across tabs via localStorage + BroadcastChannel --- src/lib/pyodide/backend/flask/backend.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/lib/pyodide/backend/flask/backend.ts b/src/lib/pyodide/backend/flask/backend.ts index dcdb37ed..63038eb3 100644 --- a/src/lib/pyodide/backend/flask/backend.ts +++ b/src/lib/pyodide/backend/flask/backend.ts @@ -16,6 +16,9 @@ import { PYTHON_PACKAGES } from '$lib/constants/dependencies'; /** Polling interval for stream results (ms) */ const STREAM_POLL_INTERVAL = 30; +/** BroadcastChannel name for cross-tab session coordination */ +const SESSION_CHANNEL = 'flask-session'; + export class FlaskBackend implements Backend { private host: string; private sessionId: string; @@ -23,6 +26,7 @@ export class FlaskBackend implements Backend { private _isStreaming = false; private streamPollTimer: ReturnType | null = null; private serverInitPromise: Promise | null = null; + private broadcastChannel: BroadcastChannel | null = null; // Stream callbacks — same shape as PyodideBackend's streamState private streamState: { @@ -37,15 +41,26 @@ export class FlaskBackend implements Backend { constructor(host: string) { this.host = host.replace(/\/$/, ''); - const stored = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('flask-session-id') : null; + const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('flask-session-id') : null; if (stored) { this.sessionId = stored; } else { this.sessionId = crypto.randomUUID(); - if (typeof sessionStorage !== 'undefined') { - sessionStorage.setItem('flask-session-id', this.sessionId); + if (typeof localStorage !== 'undefined') { + localStorage.setItem('flask-session-id', this.sessionId); } } + + // Listen for session termination from other tabs + if (typeof BroadcastChannel !== 'undefined') { + this.broadcastChannel = new BroadcastChannel(SESSION_CHANNEL); + this.broadcastChannel.onmessage = (event) => { + if (event.data?.type === 'session-terminated') { + this.serverInitPromise = null; + backendState.reset(); + } + }; + } } // ------------------------------------------------------------------------- @@ -119,6 +134,9 @@ export class FlaskBackend implements Backend { headers: { 'X-Session-ID': this.sessionId } }).catch(() => {}); + // Notify other tabs that the session was terminated + this.broadcastChannel?.postMessage({ type: 'session-terminated' }); + this.serverInitPromise = null; backendState.reset(); } From 24bf65f14acfb2677062cc398e58e2c998c88f62 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 21:29:46 +0100 Subject: [PATCH 386/656] Remove shell=True from build script, use explicit npx binary resolution --- scripts/build_package.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/scripts/build_package.py b/scripts/build_package.py index 537ad312..68eb4adf 100644 --- a/scripts/build_package.py +++ b/scripts/build_package.py @@ -20,11 +20,19 @@ STATIC_DIR = REPO_ROOT / "pathview_server" / "static" +def _find_npx(): + """Find the npx binary. On Windows, use npx.cmd.""" + name = "npx.cmd" if sys.platform == "win32" else "npx" + path = shutil.which(name) + if not path: + print(f"ERROR: {name} not found on PATH") + sys.exit(1) + return path + + def run(cmd, **kwargs): print(f" > {' '.join(cmd)}") - # shell=True needed on Windows for npx/npm resolution - result = subprocess.run(cmd, cwd=kwargs.pop("cwd", REPO_ROOT), - shell=(sys.platform == "win32"), **kwargs) + result = subprocess.run(cmd, cwd=kwargs.pop("cwd", REPO_ROOT), **kwargs) if result.returncode != 0: print(f"ERROR: command failed (exit {result.returncode})") sys.exit(result.returncode) @@ -66,7 +74,8 @@ def main(): print("[2/4] Building SvelteKit frontend...") env = os.environ.copy() env["BASE_PATH"] = "" - run(["npx", "vite", "build"], env=env) + npx = _find_npx() + run([npx, "vite", "build"], env=env) if not (BUILD_DIR / "index.html").exists(): print("ERROR: build/index.html not found") From c2c81627efdf63fe2becbadb8aca8d385445286b Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 21:42:51 +0100 Subject: [PATCH 387/656] =?UTF-8?q?Fix=20stream=20stop=E2=86=92exec=20cras?= =?UTF-8?q?h:=20wait=20for=20reader=20thread=20before=20stdout=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pathview_server/app.py | 18 +++++++++++++ tests/test_app.py | 60 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/pathview_server/app.py b/pathview_server/app.py index d4397d82..3bfb43ea 100644 --- a/pathview_server/app.py +++ b/pathview_server/app.py @@ -145,6 +145,18 @@ def stop_stream_reader(self) -> None: """Signal the stream reader to stop.""" self._streaming = False + def wait_for_stream_reader(self, timeout: float = 5) -> None: + """Wait for the stream reader thread to fully exit. + + Must be called before any direct stdout reads (exec/eval) to prevent + concurrent reads on the same pipe which cause JSONDecodeError. + """ + reader = self._stream_reader + if reader is not None and reader.is_alive(): + self._streaming = False + reader.join(timeout) + self._stream_reader = None + def flush_worker_reader(self) -> None: """Send a noop message to unblock the worker's stdin reader thread. @@ -298,6 +310,9 @@ def api_exec(): session = get_or_create_session(session_id) with session.lock: try: + # Wait for any lingering stream reader thread to exit before + # reading stdout — prevents concurrent pipe reads / JSONDecodeError + session.wait_for_stream_reader() session.ensure_initialized() session.send_message({"type": "exec", "id": msg_id, "code": code}) @@ -347,6 +362,9 @@ def api_eval(): session = get_or_create_session(session_id) with session.lock: try: + # Wait for any lingering stream reader thread to exit before + # reading stdout — prevents concurrent pipe reads / JSONDecodeError + session.wait_for_stream_reader() session.ensure_initialized() session.send_message({"type": "eval", "id": msg_id, "expr": expr}) diff --git a/tests/test_app.py b/tests/test_app.py index 354a6d5e..6215521c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -147,6 +147,66 @@ def test_streaming_lifecycle(client, session_headers): assert "stream-done" in types +def test_stream_stop_then_exec(client, session_headers): + """Regression: exec after stream/stop must not crash with JSONDecodeError. + + The stream reader thread must fully exit before exec reads stdout. + """ + client.post("/api/init", json={"packages": []}, headers=session_headers) + + # Set up a generator that never finishes on its own (needs manual stop) + client.post( + "/api/exec", + json={"code": "_counter = 0\ndef _infinite():\n global _counter\n _counter += 1\n return {'result': _counter, 'done': False}"}, + headers=session_headers, + ) + + # Start streaming + resp = client.post( + "/api/stream/start", + json={"expr": "_infinite()"}, + headers=session_headers, + ) + assert resp.status_code == 200 + + # Let a few polls go through + import time + for _ in range(5): + client.post("/api/stream/poll", headers=session_headers) + time.sleep(0.05) + + # Stop streaming + client.post("/api/stream/stop", headers=session_headers) + + # Poll until done + done = False + deadline = time.time() + 10 + while not done and time.time() < deadline: + resp = client.post("/api/stream/poll", headers=session_headers) + data = resp.get_json() + done = data.get("done", False) + if not done: + time.sleep(0.1) + + # Now exec should work without crashing + resp = client.post( + "/api/exec", + json={"code": "result_after_stop = 'ok'"}, + headers=session_headers, + ) + assert resp.status_code == 200 + assert resp.get_json()["type"] == "ok" + + # Verify the namespace is intact + resp = client.post( + "/api/eval", + json={"expr": "result_after_stop"}, + headers=session_headers, + ) + assert resp.status_code == 200 + assert json.loads(resp.get_json()["value"]) == "ok" + + def test_exec_missing_session_id(client): resp = client.post("/api/exec", json={"code": "pass"}) assert resp.status_code == 400 From 66061f0cbad823aa8d2ef76266b1a705b727545f Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 22:03:07 +0100 Subject: [PATCH 388/656] =?UTF-8?q?Fix=20stop=E2=86=92restart=20hang:=20ac?= =?UTF-8?q?tively=20stop=20streaming=20in=20wait=5Ffor=5Fstream=5Freader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pathview_server/app.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/pathview_server/app.py b/pathview_server/app.py index 3bfb43ea..1b0c91b6 100644 --- a/pathview_server/app.py +++ b/pathview_server/app.py @@ -150,13 +150,38 @@ def wait_for_stream_reader(self, timeout: float = 5) -> None: Must be called before any direct stdout reads (exec/eval) to prevent concurrent reads on the same pipe which cause JSONDecodeError. + + Actively stops streaming if needed: sends stream-stop to the worker + so it sends stream-done, which unblocks the reader thread. """ reader = self._stream_reader - if reader is not None and reader.is_alive(): + if reader is None: + return + + if reader.is_alive(): self._streaming = False + # Ensure the worker stops streaming — stream-stop might not have + # been sent yet if api_stream_stop races with api_exec. + try: + self.send_message({"type": "stream-stop"}) + except Exception: + pass reader.join(timeout) + + # Send noop to unblock the worker's stdin reader thread so the + # worker main loop can resume processing exec/eval messages. + self.flush_worker_reader() + self._stream_reader = None + # Drain stale stream messages (stream-data, stream-done) from the + # queue so they don't leak into subsequent poll responses. + while not self._stream_queue.empty(): + try: + self._stream_queue.get_nowait() + except queue.Empty: + break + def flush_worker_reader(self) -> None: """Send a noop message to unblock the worker's stdin reader thread. From 06227776084727f739d824b1f0e7a9234a432238 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Tue, 10 Feb 2026 22:10:41 +0100 Subject: [PATCH 389/656] Keep final stream data in queue so frontend can poll it before restart --- pathview_server/app.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/pathview_server/app.py b/pathview_server/app.py index 1b0c91b6..d36358ce 100644 --- a/pathview_server/app.py +++ b/pathview_server/app.py @@ -173,14 +173,9 @@ def wait_for_stream_reader(self, timeout: float = 5) -> None: self.flush_worker_reader() self._stream_reader = None - - # Drain stale stream messages (stream-data, stream-done) from the - # queue so they don't leak into subsequent poll responses. - while not self._stream_queue.empty(): - try: - self._stream_queue.get_nowait() - except queue.Empty: - break + # Don't drain the stream queue here — the frontend's poll chain + # still needs the final stream-data/stream-done messages. + # start_stream_reader() clears stale messages when a new stream begins. def flush_worker_reader(self) -> None: """Send a noop message to unblock the worker's stdin reader thread. From 412d7deb96d9e293c3db71a28c48a25b33ea52c3 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Wed, 11 Feb 2026 10:05:22 +0100 Subject: [PATCH 390/656] Add virtualenv isolation for Flask backend workers --- pathview_server/app.py | 52 ++++++++++++++++++++++++++++----------- pathview_server/cli.py | 6 ++++- pathview_server/venv.py | 34 +++++++++++++++++++++++++ pathview_server/worker.py | 6 +++-- 4 files changed, 81 insertions(+), 17 deletions(-) create mode 100644 pathview_server/venv.py diff --git a/pathview_server/app.py b/pathview_server/app.py index d36358ce..bf717bc2 100644 --- a/pathview_server/app.py +++ b/pathview_server/app.py @@ -21,6 +21,8 @@ from flask import Flask, request, jsonify, send_from_directory from flask_cors import CORS +from pathview_server.venv import get_venv_python + # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- @@ -42,7 +44,7 @@ def __init__(self, session_id: str): self.last_active = time.time() self.lock = threading.Lock() self.process = subprocess.Popen( - [sys.executable, "-u", WORKER_SCRIPT], + [get_venv_python(), "-u", WORKER_SCRIPT], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -152,21 +154,29 @@ def wait_for_stream_reader(self, timeout: float = 5) -> None: concurrent reads on the same pipe which cause JSONDecodeError. Actively stops streaming if needed: sends stream-stop to the worker - so it sends stream-done, which unblocks the reader thread. + so it sends stream-done, which the reader thread reads naturally and + exits via its own loop termination. This ensures all final stream-data + messages are read into the queue before the reader exits. """ reader = self._stream_reader if reader is None: return if reader.is_alive(): - self._streaming = False - # Ensure the worker stops streaming — stream-stop might not have - # been sent yet if api_stream_stop races with api_exec. + # Do NOT set self._streaming = False here — that would cause the + # reader thread to exit early on its next loop check, missing the + # final stream-data and stream-done messages. Instead, send + # stream-stop so the worker sends stream-done, which the reader + # reads and exits naturally (line 137-139 in start_stream_reader). try: self.send_message({"type": "stream-stop"}) except Exception: pass reader.join(timeout) + # If reader is still alive after timeout, force-stop it + if reader.is_alive(): + self._streaming = False + reader.join(1) # Send noop to unblock the worker's stdin reader thread so the # worker main loop can resume processing exec/eval messages. @@ -189,9 +199,19 @@ def flush_worker_reader(self) -> None: except Exception: pass - def drain_stream_queue(self) -> list[dict]: - """Drain all messages currently in the stream queue.""" - messages = [] + def drain_stream_queue(self, timeout: float = 0) -> list[dict]: + """Drain all messages from the stream queue. + + If timeout > 0, blocks until at least one message arrives or + the timeout expires. This turns polling into long-polling, + eliminating empty responses and reducing HTTP overhead. + """ + messages: list[dict] = [] + if timeout > 0 and self._stream_queue.empty(): + try: + messages.append(self._stream_queue.get(timeout=timeout)) + except queue.Empty: + return messages while True: try: messages.append(self._stream_queue.get_nowait()) @@ -290,7 +310,7 @@ def create_app(serve_static: bool = False) -> Flask: _start_cleanup_thread() if not serve_static: - CORS(app) + CORS(app, max_age=3600) # ----------------------------------------------------------------------- # API routes @@ -425,9 +445,8 @@ def api_eval(): def api_stream_start(): """Start streaming — sends stream-start to worker and returns immediately. - A background thread reads worker stdout into a queue. The frontend - polls /api/stream/poll to drain that queue, mirroring how the Pyodide - worker sends stream-data / stream-done messages via postMessage. + A background thread reads worker stdout into a queue. The + frontend long-polls /api/stream/poll to drain that queue. """ session_id = _get_session_id() if not session_id: @@ -448,7 +467,12 @@ def api_stream_start(): @app.route("/api/stream/poll", methods=["POST"]) def api_stream_poll(): - """Poll for stream messages — returns all queued messages since last poll.""" + """Long-poll for stream messages. + + Blocks up to 100 ms waiting for data before returning. + This eliminates empty responses and reduces HTTP overhead + compared to blind 30 ms polling. + """ session_id = _get_session_id() if not session_id: return jsonify({"type": "error", "error": "Missing X-Session-ID header"}), 400 @@ -456,7 +480,7 @@ def api_stream_poll(): session = _sessions.get(session_id) if not session: return jsonify({"messages": [], "done": True}) - messages = session.drain_stream_queue() + messages = session.drain_stream_queue(timeout=0.1) done = any(m.get("type") in ("stream-done", "error") for m in messages) if done: # Send noop to unblock the worker's stdin reader thread diff --git a/pathview_server/cli.py b/pathview_server/cli.py index df298757..e82b7d11 100644 --- a/pathview_server/cli.py +++ b/pathview_server/cli.py @@ -7,6 +7,7 @@ import webbrowser from pathview_server import __version__ +from pathview_server.venv import VENV_DIR, ensure_venv def main(): @@ -29,9 +30,11 @@ def main(): args = parser.parse_args() + ensure_venv() + from pathview_server.app import create_app - app = create_app(serve_static=True) + app = create_app(serve_static=not args.debug) if not args.no_browser: def open_browser_when_ready(): @@ -49,6 +52,7 @@ def open_browser_when_ready(): threading.Thread(target=open_browser_when_ready, daemon=True).start() print(f"PathView v{__version__}") + print(f" Python venv: {VENV_DIR}") print(f"Running at http://{args.host}:{args.port}") if args.host == "0.0.0.0": diff --git a/pathview_server/venv.py b/pathview_server/venv.py new file mode 100644 index 00000000..df8e28a3 --- /dev/null +++ b/pathview_server/venv.py @@ -0,0 +1,34 @@ +"""Virtual environment management for PathView worker subprocesses. + +Creates and manages a dedicated venv at ~/.pathview/venv so that simulation +dependencies (pathsim, pathsim-chem, numpy, etc.) are installed in isolation +rather than polluting the user's global/active environment. +""" + +import subprocess +import sys +from pathlib import Path + +VENV_DIR = Path.home() / ".pathview" / "venv" + + +def get_venv_python() -> str: + """Return path to the venv's Python executable.""" + if sys.platform == "win32": + return str(VENV_DIR / "Scripts" / "python.exe") + return str(VENV_DIR / "bin" / "python") + + +def ensure_venv() -> str: + """Create the venv if it doesn't exist. Returns venv Python path.""" + python = get_venv_python() + if Path(python).exists(): + return python + + print("Creating PathView virtual environment...") + VENV_DIR.parent.mkdir(parents=True, exist_ok=True) + subprocess.run([sys.executable, "-m", "venv", str(VENV_DIR)], check=True) + # Upgrade pip in the venv + subprocess.run([python, "-m", "pip", "install", "--upgrade", "pip", "--quiet"], check=True) + print(f" Virtual environment created at {VENV_DIR}") + return python diff --git a/pathview_server/worker.py b/pathview_server/worker.py index c31322c2..04b0b7e0 100644 --- a/pathview_server/worker.py +++ b/pathview_server/worker.py @@ -143,11 +143,10 @@ def initialize(packages: list[dict] | None = None) -> None: # Set up the namespace with common imports _namespace = {"__builtins__": __builtins__} - exec("import numpy as np", _namespace) exec("import gc", _namespace) exec("import json", _namespace) - # Install and import packages from the frontend config (single source of truth) + # Install packages FIRST (pathsim brings numpy as a dependency) if packages: send({"type": "progress", "value": "Installing dependencies..."}) for pkg in packages: @@ -160,6 +159,9 @@ def initialize(packages: list[dict] | None = None) -> None: ) send({"type": "stderr", "value": f"Optional package {pkg.get('import', '?')} failed: {e}\n"}) + # Import numpy AFTER packages are installed (numpy comes with pathsim) + exec("import numpy as np", _namespace) + _initialized = True send({"type": "ready"}) From 5a315c03ffd39434c5e4580680386096425894f5 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Wed, 11 Feb 2026 10:16:10 +0100 Subject: [PATCH 391/656] Fix CI: ensure venv exists before app tests spawn workers --- tests/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index e5a0b75e..d3ceb8f5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,11 +3,13 @@ import pytest from pathview_server.app import create_app, _sessions, _sessions_lock +from pathview_server.venv import ensure_venv @pytest.fixture() def app(): """Create a Flask test app (API-only, no static serving).""" + ensure_venv() application = create_app(serve_static=False) application.config["TESTING"] = True yield application From f4cfa69171e54bd82e2c079c99a8b1057a726333 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Wed, 11 Feb 2026 10:17:34 +0100 Subject: [PATCH 392/656] Fix CI: handle missing numpy in fresh venv without simulation packages --- pathview_server/worker.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pathview_server/worker.py b/pathview_server/worker.py index 04b0b7e0..2546e745 100644 --- a/pathview_server/worker.py +++ b/pathview_server/worker.py @@ -159,8 +159,12 @@ def initialize(packages: list[dict] | None = None) -> None: ) send({"type": "stderr", "value": f"Optional package {pkg.get('import', '?')} failed: {e}\n"}) - # Import numpy AFTER packages are installed (numpy comes with pathsim) - exec("import numpy as np", _namespace) + # Import numpy AFTER packages are installed (numpy comes with pathsim). + # In a fresh venv without simulation packages, numpy won't be available. + try: + exec("import numpy as np", _namespace) + except Exception: + pass _initialized = True send({"type": "ready"}) From 796a2468eab8b534fe0ab53d99bec2bddfcfcf3f Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Wed, 11 Feb 2026 10:45:48 +0100 Subject: [PATCH 393/656] Consolidate duplicated exec/eval routes and init logic --- pathview_server/app.py | 79 +++++----------- src/lib/pyodide/backend/flask/backend.ts | 112 +++++++++++------------ 2 files changed, 75 insertions(+), 116 deletions(-) diff --git a/pathview_server/app.py b/pathview_server/app.py index bf717bc2..5bf56ba8 100644 --- a/pathview_server/app.py +++ b/pathview_server/app.py @@ -337,15 +337,16 @@ def api_init(): except Exception as e: return jsonify({"type": "error", "error": str(e)}), 500 - @app.route("/api/exec", methods=["POST"]) - def api_exec(): - """Execute Python code in the session's worker.""" + def _handle_worker_request(msg: dict, success_type: str) -> tuple: + """Send a message to the worker and collect the response. + + Shared by api_exec (success_type="ok") and api_eval (success_type="value"). + Returns a Flask response tuple. + """ session_id = _get_session_id() if not session_id: return jsonify({"type": "error", "error": "Missing X-Session-ID header"}), 400 - data = request.get_json(force=True) - code = data.get("code", "") - msg_id = data.get("id", str(uuid.uuid4())) + msg_id = msg.get("id", str(uuid.uuid4())) session = get_or_create_session(session_id) with session.lock: @@ -354,7 +355,7 @@ def api_exec(): # reading stdout — prevents concurrent pipe reads / JSONDecodeError session.wait_for_stream_reader() session.ensure_initialized() - session.send_message({"type": "exec", "id": msg_id, "code": code}) + session.send_message(msg) stdout_lines = [] stderr_lines = [] @@ -368,8 +369,8 @@ def api_exec(): stdout_lines.append(resp.get("value", "")) elif resp_type == "stderr": stderr_lines.append(resp.get("value", "")) - elif resp_type == "ok" and resp.get("id") == msg_id: - result = {"type": "ok", "id": msg_id} + elif resp_type == success_type and resp.get("id") == msg_id: + result = resp if stdout_lines: result["stdout"] = "".join(stdout_lines) if stderr_lines: @@ -389,57 +390,25 @@ def api_exec(): except Exception as e: return jsonify({"type": "error", "id": msg_id, "error": str(e)}), 500 + @app.route("/api/exec", methods=["POST"]) + def api_exec(): + """Execute Python code in the session's worker.""" + data = request.get_json(force=True) + msg_id = data.get("id", str(uuid.uuid4())) + return _handle_worker_request( + {"type": "exec", "id": msg_id, "code": data.get("code", "")}, + success_type="ok", + ) + @app.route("/api/eval", methods=["POST"]) def api_eval(): """Evaluate a Python expression in the session's worker.""" - session_id = _get_session_id() - if not session_id: - return jsonify({"type": "error", "error": "Missing X-Session-ID header"}), 400 data = request.get_json(force=True) - expr = data.get("expr", "") msg_id = data.get("id", str(uuid.uuid4())) - - session = get_or_create_session(session_id) - with session.lock: - try: - # Wait for any lingering stream reader thread to exit before - # reading stdout — prevents concurrent pipe reads / JSONDecodeError - session.wait_for_stream_reader() - session.ensure_initialized() - session.send_message({"type": "eval", "id": msg_id, "expr": expr}) - - stdout_lines = [] - stderr_lines = [] - while True: - resp = session.read_line_timeout() - if resp is None: - remove_session(session_id) - return jsonify({"type": "error", "errorType": "worker-crashed", "id": msg_id, "error": "Worker process died"}), 500 - resp_type = resp.get("type") - if resp_type == "stdout": - stdout_lines.append(resp.get("value", "")) - elif resp_type == "stderr": - stderr_lines.append(resp.get("value", "")) - elif resp_type == "value" and resp.get("id") == msg_id: - result = resp - if stdout_lines: - result["stdout"] = "".join(stdout_lines) - if stderr_lines: - result["stderr"] = "".join(stderr_lines) - return jsonify(result) - elif resp_type == "error" and resp.get("id") == msg_id: - result = resp - if stdout_lines: - result["stdout"] = "".join(stdout_lines) - if stderr_lines: - result["stderr"] = "".join(stderr_lines) - return jsonify(result), 400 - - except TimeoutError: - remove_session(session_id) - return jsonify({"type": "error", "errorType": "timeout", "id": msg_id, "error": "Execution timed out"}), 504 - except Exception as e: - return jsonify({"type": "error", "id": msg_id, "error": str(e)}), 500 + return _handle_worker_request( + {"type": "eval", "id": msg_id, "expr": data.get("expr", "")}, + success_type="value", + ) @app.route("/api/stream/start", methods=["POST"]) def api_stream_start(): diff --git a/src/lib/pyodide/backend/flask/backend.ts b/src/lib/pyodide/backend/flask/backend.ts index 63038eb3..cdb19ca6 100644 --- a/src/lib/pyodide/backend/flask/backend.ts +++ b/src/lib/pyodide/backend/flask/backend.ts @@ -13,8 +13,10 @@ import { TIMEOUTS } from '$lib/constants/python'; import { STATUS_MESSAGES } from '$lib/constants/messages'; import { PYTHON_PACKAGES } from '$lib/constants/dependencies'; -/** Polling interval for stream results (ms) */ -const STREAM_POLL_INTERVAL = 30; +/** Delay between polls (ms). The server uses long-polling (blocks up + * to 100 ms until data arrives), so data delivery is near-instant. + * This interval is just a safety gap between consecutive requests. */ +const STREAM_POLL_INTERVAL = 5; /** BroadcastChannel name for cross-tab session coordination */ const SESSION_CHANNEL = 'flask-session'; @@ -86,28 +88,7 @@ export class FlaskBackend implements Backend { backendState.update((s) => ({ ...s, progress: 'Initializing Python worker...' })); - const initResp = await fetch(`${this.host}/api/init`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Session-ID': this.sessionId - }, - body: JSON.stringify({ packages: PYTHON_PACKAGES }), - signal: AbortSignal.timeout(TIMEOUTS.INIT) - }); - const initData = await initResp.json(); - - if (initData.type === 'error') throw new Error(initData.error); - - if (initData.messages) { - for (const msg of initData.messages) { - if (msg.type === 'stdout' && this.stdoutCallback) this.stdoutCallback(msg.value); - if (msg.type === 'stderr' && this.stderrCallback) this.stderrCallback(msg.value); - if (msg.type === 'progress') { - backendState.update((s) => ({ ...s, progress: msg.value })); - } - } - } + await this.postInit({ updateProgress: true }); this.serverInitPromise = Promise.resolve(); @@ -126,6 +107,10 @@ export class FlaskBackend implements Backend { terminate(): void { this.stopStreaming(); + if (this.streamPollTimer) { + clearTimeout(this.streamPollTimer); + this.streamPollTimer = null; + } this._isStreaming = false; this.streamState = { onData: null, onDone: null, onError: null }; @@ -172,25 +157,7 @@ export class FlaskBackend implements Backend { private ensureServerInit(): Promise { if (this.serverInitPromise) return this.serverInitPromise; - this.serverInitPromise = (async () => { - const resp = await fetch(`${this.host}/api/init`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Session-ID': this.sessionId - }, - body: JSON.stringify({ packages: PYTHON_PACKAGES }), - signal: AbortSignal.timeout(TIMEOUTS.INIT) - }); - const data = await resp.json(); - if (data.type === 'error') throw new Error(data.error); - if (data.messages) { - for (const msg of data.messages) { - if (msg.type === 'stdout' && this.stdoutCallback) this.stdoutCallback(msg.value); - if (msg.type === 'stderr' && this.stderrCallback) this.stderrCallback(msg.value); - } - } - })(); + this.serverInitPromise = this.postInit({ updateProgress: false }); // Clear on failure so subsequent calls retry instead of returning the rejected promise this.serverInitPromise.catch(() => { @@ -271,6 +238,13 @@ export class FlaskBackend implements Backend { this.stopStreaming(); } + // Clear any lingering poll timer from a previous stream so it + // doesn't pick up a stale stream-done and fire the NEW onDone. + if (this.streamPollTimer) { + clearTimeout(this.streamPollTimer); + this.streamPollTimer = null; + } + const id = this.generateId(); this._isStreaming = true; this.streamState = { @@ -296,7 +270,6 @@ export class FlaskBackend implements Backend { if (data.type === 'error') { throw new Error(data.error); } - // Start polling loop — same as Pyodide worker's onmessage dispatching this.pollStreamResults(); }) .catch((error) => { @@ -309,29 +282,19 @@ export class FlaskBackend implements Backend { stopStreaming(): void { if (!this._isStreaming) return; - // Stop polling timer — the server will send stream-done which triggers onDone - if (this.streamPollTimer) { - clearTimeout(this.streamPollTimer); - this.streamPollTimer = null; - } - - // Tell server to stop, then do one final poll to get the stream-done message + // Just send the stop signal — don't disrupt the polling loop. + // The poll loop will naturally pick up stream-done and clean up, + // matching how Pyodide's stopStreaming just sends stream-stop and + // lets worker.onmessage handle the rest. fetch(`${this.host}/api/stream/stop`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Session-ID': this.sessionId } - }) - .then(() => this.pollStreamResults()) - .catch(() => { - // If final poll fails, clean up locally - this._isStreaming = false; - if (this.streamState.onDone) { - this.streamState.onDone(); - } - this.streamState = { onData: null, onDone: null, onError: null }; - }); + }).catch(() => { + // Network failure — poll loop will also fail and clean up + }); } isStreaming(): boolean { @@ -374,6 +337,33 @@ export class FlaskBackend implements Backend { return `repl_${++this.messageId}`; } + /** + * POST /api/init with packages and forward worker messages to callbacks. + * Shared by init() (first load with progress UI) and ensureServerInit() (lazy re-init). + */ + private async postInit(opts: { updateProgress: boolean }): Promise { + const resp = await fetch(`${this.host}/api/init`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Session-ID': this.sessionId + }, + body: JSON.stringify({ packages: PYTHON_PACKAGES }), + signal: AbortSignal.timeout(TIMEOUTS.INIT) + }); + const data = await resp.json(); + if (data.type === 'error') throw new Error(data.error); + if (data.messages) { + for (const msg of data.messages) { + if (msg.type === 'stdout' && this.stdoutCallback) this.stdoutCallback(msg.value); + if (msg.type === 'stderr' && this.stderrCallback) this.stderrCallback(msg.value); + if (msg.type === 'progress' && opts.updateProgress) { + backendState.update((s) => ({ ...s, progress: msg.value })); + } + } + } + } + /** * Check if a response indicates the worker crashed or timed out. * If so, clear serverInitPromise so the next request triggers re-init. From 393b0c9cbd3642f8e4845b146b20e08e3b3524bc Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sat, 14 Feb 2026 21:57:36 +0100 Subject: [PATCH 394/656] Replace sponsor with consulting link in welcome modal --- src/lib/components/WelcomeModal.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/components/WelcomeModal.svelte b/src/lib/components/WelcomeModal.svelte index 363e4d94..6f0731c7 100644 --- a/src/lib/components/WelcomeModal.svelte +++ b/src/lib/components/WelcomeModal.svelte @@ -97,9 +97,9 @@ Issue
        - - - Sponsor + + + Consulting
        From c84d10b9188378c7ab3b1faf7b10a5c564c545dd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Feb 2026 21:15:23 +0000 Subject: [PATCH 395/656] Bump version to 0.6.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 024f57ca..c763a755 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pathview", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pathview", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@codemirror/lang-python": "^6.0.0", "@codemirror/theme-one-dark": "^6.0.0", diff --git a/package.json b/package.json index 7ea72aa5..0a9444bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pathview", - "version": "0.5.0", + "version": "0.6.0", "private": true, "type": "module", "scripts": { From ab9c3731abcea7d68d6b9fbf51c3d602c4febe8c Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Sun, 15 Feb 2026 18:00:37 +0100 Subject: [PATCH 396/656] Switch PyPI publishing to Trusted Publishing with auto version bump --- .github/workflows/publish-pypi.yml | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 0fe59be7..bcb97933 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -15,7 +15,8 @@ on: - pypi permissions: - contents: read + contents: write + id-token: write jobs: build-and-publish: @@ -25,6 +26,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Update version from release tag + if: github.event_name == 'release' + run: | + VERSION="${{ github.ref_name }}" + VERSION="${VERSION#v}" + echo "Updating pyproject.toml version to $VERSION" + sed -i "s/^version = \".*\"/version = \"$VERSION\"/" pyproject.toml + - name: Setup Node uses: actions/setup-node@v4 with: @@ -58,17 +67,13 @@ jobs: - name: Publish to Test PyPI if: github.event_name == 'workflow_dispatch' && inputs.publish_to == 'testpypi' - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }} - run: twine upload --repository testpypi dist/* + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ - name: Publish to PyPI if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.publish_to == 'pypi') - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} - run: twine upload dist/* + uses: pypa/gh-action-pypi-publish@release/v1 - name: Upload build artifacts uses: actions/upload-artifact@v4 From b48fcae736b03a4cea5e5a9ea651c8df397f1dc5 Mon Sep 17 00:00:00 2001 From: Milan Rother Date: Mon, 16 Feb 2026 13:10:46 +0100 Subject: [PATCH 397/656] Replace SVG export with dom2svg --- package-lock.json | 18 +- package.json | 2 +- src/lib/export/dom2svg/index.d.ts | 75 + src/lib/export/dom2svg/index.js | 2730 +++++++++++++++++++++++++++ src/lib/export/dom2svg/index.js.map | 1 + src/lib/export/svg/index.ts | 7 +- src/lib/export/svg/renderer.ts | 820 ++------ src/lib/export/svg/types.ts | 34 +- 8 files changed, 2992 insertions(+), 695 deletions(-) create mode 100644 src/lib/export/dom2svg/index.d.ts create mode 100644 src/lib/export/dom2svg/index.js create mode 100644 src/lib/export/dom2svg/index.js.map diff --git a/package-lock.json b/package-lock.json index c763a755..11afa6a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,23 @@ "vite": "^7.3.0" } }, + "../dom2svg": { + "version": "0.1.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "opentype.js": "^1.3.4" + }, + "bin": { + "dom2svg": "bin/dom2svg.mjs" + }, + "devDependencies": { + "puppeteer-core": "^24.37.3", + "tsup": "^8.0.0", + "typescript": "^5.4.0", + "vitest": "^2.0.0" + } + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", @@ -1399,7 +1416,6 @@ "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } diff --git a/package.json b/package.json index 0a9444bc..39786f98 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@codemirror/theme-one-dark": "^6.0.0", "@xyflow/svelte": "^1.5.0", "codemirror": "^6.0.0", - "katex": "^0.16.0", +"katex": "^0.16.0", "pathfinding": "^0.4.18", "plotly.js-dist-min": "^2.35.0", "pyodide": "^0.26.0" diff --git a/src/lib/export/dom2svg/index.d.ts b/src/lib/export/dom2svg/index.d.ts new file mode 100644 index 00000000..3f332834 --- /dev/null +++ b/src/lib/export/dom2svg/index.d.ts @@ -0,0 +1,75 @@ +/** Configuration for a single font face */ +interface FontConfig { + url: string; + weight?: string | number; + style?: string; +} +/** Font mapping: family name → URL string, single config, or array of configs for multiple weights/styles */ +type FontMapping = Record; +/** Options for domToSvg() */ +interface DomToSvgOptions { + /** Map of font-family → URL or FontConfig for text-to-path conversion */ + fonts?: FontMapping; + /** CSS selector or predicate to exclude elements */ + exclude?: string | ((element: Element) => boolean); + /** Custom handler for specific elements — return SVGElement to use it, or null to fall through to default rendering */ + handler?: (element: Element, context: RenderContext) => SVGElement | null; + /** Background color for the root SVG (default: transparent) */ + background?: string; + /** Extra padding around the captured area in px */ + padding?: number; + /** Whether to convert text to paths using opentype.js (default: false) */ + textToPath?: boolean; + /** Skip applying CSS transforms as SVG attributes (default: false). + * When true, element positions come solely from getBoundingClientRect + * which already includes CSS transforms. Use this when capturing containers + * with nested CSS transforms (e.g. SvelteFlow, React Flow) where + * the default behaviour would double-apply transforms. */ + flattenTransforms?: boolean; +} +/** Internal render context passed through the tree */ +interface RenderContext { + /** The output SVG document */ + svgDocument: Document; + /** The element for shared definitions */ + defs: SVGDefsElement; + /** ID generator for unique IDs */ + idGenerator: IdGenerator; + /** Options from the caller */ + options: DomToSvgOptions; + /** Font cache (available when textToPath is enabled) */ + fontCache?: FontCache; + /** Current inherited opacity */ + opacity: number; +} +/** Interface for unique ID generation */ +interface IdGenerator { + next(prefix?: string): string; +} +/** Interface for the font cache */ +interface FontCache { + getFont(family: string, weight?: string | number, style?: string): Promise; + has(family: string): boolean; +} +/** Result of domToSvg() */ +interface DomToSvgResult { + /** The generated SVG element */ + svg: SVGSVGElement; + /** Serialize to SVG string */ + toString(): string; + /** Serialize to a Blob */ + toBlob(): Blob; + /** Trigger a download in the browser */ + download(filename?: string): void; +} + +/** + * Convert a DOM element (including hybrid HTML/SVG) to a self-contained SVG. + * + * @param element - The root DOM element to convert + * @param options - Configuration options + * @returns A result object with the SVG and serialization helpers + */ +declare function domToSvg(element: Element, options?: DomToSvgOptions): Promise; + +export { type DomToSvgOptions, type DomToSvgResult, type FontConfig, type FontMapping, domToSvg }; diff --git a/src/lib/export/dom2svg/index.js b/src/lib/export/dom2svg/index.js new file mode 100644 index 00000000..50a5cb10 --- /dev/null +++ b/src/lib/export/dom2svg/index.js @@ -0,0 +1,2730 @@ +// src/utils/dom.ts +var SVG_NS = "http://www.w3.org/2000/svg"; +var XLINK_NS = "http://www.w3.org/1999/xlink"; +var XMLNS_NS = "http://www.w3.org/2000/xmlns/"; +function isElement(node) { + return node.nodeType === Node.ELEMENT_NODE; +} +function isTextNode(node) { + return node.nodeType === Node.TEXT_NODE; +} +function isSvgElement(element) { + return element.namespaceURI === SVG_NS; +} +function isImageElement(element) { + return element instanceof HTMLImageElement; +} +function isCanvasElement(element) { + return element instanceof HTMLCanvasElement; +} +function isFormElement(element) { + return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement; +} +function createSvgElement(doc, tagName) { + return doc.createElementNS(SVG_NS, tagName); +} +function setAttributes(element, attrs) { + for (const [key, value] of Object.entries(attrs)) { + element.setAttribute(key, String(value)); + if (key === "href") { + element.setAttributeNS(XLINK_NS, "xlink:href", String(value)); + } + } +} +function getPseudoStyles(element, pseudo) { + return window.getComputedStyle(element, pseudo); +} + +// src/utils/id-generator.ts +var globalCounter = 0; +function createIdGenerator() { + return { + next(prefix = "d2s") { + return `${prefix}-${globalCounter++}`; + } + }; +} + +// src/core/styles.ts +function isInvisible(styles) { + return styles.display === "none"; +} +function isVisibilityHidden(styles) { + return styles.visibility === "hidden"; +} +function parseBorderSide(width, style, color) { + return { + width: parseFloat(width) || 0, + style, + color + }; +} +function parseBorders(styles) { + return { + top: parseBorderSide( + styles.borderTopWidth, + styles.borderTopStyle, + styles.borderTopColor + ), + right: parseBorderSide( + styles.borderRightWidth, + styles.borderRightStyle, + styles.borderRightColor + ), + bottom: parseBorderSide( + styles.borderBottomWidth, + styles.borderBottomStyle, + styles.borderBottomColor + ), + left: parseBorderSide( + styles.borderLeftWidth, + styles.borderLeftStyle, + styles.borderLeftColor + ) + }; +} +function parseBorderRadii(styles) { + return { + topLeft: parseRadiusPair(styles.borderTopLeftRadius), + topRight: parseRadiusPair(styles.borderTopRightRadius), + bottomRight: parseRadiusPair(styles.borderBottomRightRadius), + bottomLeft: parseRadiusPair(styles.borderBottomLeftRadius) + }; +} +function parseRadiusPair(value) { + const parts = value.split(/\s+/).map((v) => parseFloat(v) || 0); + return [parts[0] ?? 0, parts[1] ?? parts[0] ?? 0]; +} +function hasBorder(borders) { + return borders.top.width > 0 && borders.top.style !== "none" || borders.right.width > 0 && borders.right.style !== "none" || borders.bottom.width > 0 && borders.bottom.style !== "none" || borders.left.width > 0 && borders.left.style !== "none"; +} +function hasRadius(radii) { + return radii.topLeft[0] > 0 || radii.topLeft[1] > 0 || radii.topRight[0] > 0 || radii.topRight[1] > 0 || radii.bottomRight[0] > 0 || radii.bottomRight[1] > 0 || radii.bottomLeft[0] > 0 || radii.bottomLeft[1] > 0; +} +function isUniformRadius(radii) { + const [rx, ry] = radii.topLeft; + return radii.topRight[0] === rx && radii.topRight[1] === ry && radii.bottomRight[0] === rx && radii.bottomRight[1] === ry && radii.bottomLeft[0] === rx && radii.bottomLeft[1] === ry; +} +function hasOverflowClip(styles) { + const clipped = /* @__PURE__ */ new Set(["hidden", "clip", "scroll", "auto"]); + return clipped.has(styles.overflow) || clipped.has(styles.overflowX) || clipped.has(styles.overflowY); +} +function parseBackgroundColor(styles) { + const bg = styles.backgroundColor; + if (!bg || bg === "transparent" || bg === "rgba(0, 0, 0, 0)") return null; + return bg; +} +function hasBackgroundImage(styles) { + return !!styles.backgroundImage && styles.backgroundImage !== "none"; +} +function createsStackingContext(styles) { + if (styles.position !== "static" && styles.position !== "" && styles.zIndex !== "auto") { + return true; + } + if (parseFloat(styles.opacity) < 1) return true; + if (styles.transform && styles.transform !== "none") return true; + if (styles.filter && styles.filter !== "none") return true; + if (styles.isolation === "isolate") return true; + if (styles.mixBlendMode && styles.mixBlendMode !== "normal") return true; + return false; +} +function getZIndex(styles) { + if (styles.zIndex === "auto" || !styles.zIndex) return 0; + return parseInt(styles.zIndex, 10) || 0; +} +function isPositioned(styles) { + return styles.position !== "static" && styles.position !== ""; +} +function isFloat(styles) { + return styles.cssFloat !== "none" && styles.cssFloat !== ""; +} +function clampRadii(radii, width, height) { + const topH = radii.topLeft[0] + radii.topRight[0]; + const bottomH = radii.bottomLeft[0] + radii.bottomRight[0]; + const leftV = radii.topLeft[1] + radii.bottomLeft[1]; + const rightV = radii.topRight[1] + radii.bottomRight[1]; + let f = 1; + if (topH > 0) f = Math.min(f, width / topH); + if (bottomH > 0) f = Math.min(f, width / bottomH); + if (leftV > 0) f = Math.min(f, height / leftV); + if (rightV > 0) f = Math.min(f, height / rightV); + if (f >= 1) return radii; + return { + topLeft: [radii.topLeft[0] * f, radii.topLeft[1] * f], + topRight: [radii.topRight[0] * f, radii.topRight[1] * f], + bottomRight: [radii.bottomRight[0] * f, radii.bottomRight[1] * f], + bottomLeft: [radii.bottomLeft[0] * f, radii.bottomLeft[1] * f] + }; +} +function isInlineLevel(styles) { + const d = styles.display; + return d === "inline" || d === "inline-block" || d === "inline-flex" || d === "inline-grid" || d === "inline-table"; +} + +// src/utils/geometry.ts +function getRelativeBox(element, root) { + const elRect = element.getBoundingClientRect(); + const rootRect = root.getBoundingClientRect(); + return { + x: elRect.left - rootRect.left, + y: elRect.top - rootRect.top, + width: elRect.width, + height: elRect.height + }; +} +function buildRoundedRectPath(x, y, width, height, radii) { + const [tlx, tly] = radii.topLeft; + const [trx, try_] = radii.topRight; + const [brx, bry] = radii.bottomRight; + const [blx, bly] = radii.bottomLeft; + return [ + `M ${x + tlx} ${y}`, + `L ${x + width - trx} ${y}`, + trx || try_ ? `A ${trx} ${try_} 0 0 1 ${x + width} ${y + try_}` : "", + `L ${x + width} ${y + height - bry}`, + brx || bry ? `A ${brx} ${bry} 0 0 1 ${x + width - brx} ${y + height}` : "", + `L ${x + blx} ${y + height}`, + blx || bly ? `A ${blx} ${bly} 0 0 1 ${x} ${y + height - bly}` : "", + `L ${x} ${y + tly}`, + tlx || tly ? `A ${tlx} ${tly} 0 0 1 ${x + tlx} ${y}` : "", + "Z" + ].filter(Boolean).join(" "); +} + +// src/assets/gradients.ts +function parseLinearGradient(value) { + const match = value.match(/linear-gradient\((.+)\)/); + if (!match) return null; + const body = match[1]; + const parts = splitGradientArgs(body); + if (parts.length < 2) return null; + let angle = 180; + let stopsStart = 0; + const first = parts[0].trim(); + if (first.startsWith("to ")) { + angle = directionToAngle(first); + stopsStart = 1; + } else if (first.match(/^-?[\d.]+(?:deg|rad|turn|grad)/)) { + angle = parseAngle(first); + stopsStart = 1; + } + const stops = []; + const rawStops = parts.slice(stopsStart); + for (let i = 0; i < rawStops.length; i++) { + const { color, position } = parseColorStop(rawStops[i].trim(), i, rawStops.length); + stops.push({ color, position }); + } + return { angle, stops }; +} +function createSvgLinearGradient(gradient, box, ctx) { + const id = ctx.idGenerator.next("grad"); + const el = createSvgElement( + ctx.svgDocument, + "linearGradient" + ); + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + const angleRad = gradient.angle * Math.PI / 180; + const dx = Math.sin(angleRad); + const dy = -Math.cos(angleRad); + const halfLen = Math.abs(box.width / 2 * dx) + Math.abs(box.height / 2 * dy); + const x1 = cx - dx * halfLen; + const y1 = cy - dy * halfLen; + const x2 = cx + dx * halfLen; + const y2 = cy + dy * halfLen; + setAttributes(el, { + id, + gradientUnits: "userSpaceOnUse", + x1: x1.toFixed(2), + y1: y1.toFixed(2), + x2: x2.toFixed(2), + y2: y2.toFixed(2) + }); + for (const stop of gradient.stops) { + const stopEl = createSvgElement(ctx.svgDocument, "stop"); + setAttributes(stopEl, { + offset: `${(stop.position * 100).toFixed(1)}%`, + "stop-color": stop.color + }); + el.appendChild(stopEl); + } + ctx.defs.appendChild(el); + return el; +} +function rasterizeGradient(value, width, height) { + if (value.includes("conic-gradient")) { + return rasterizeConicGradient(value, width, height); + } + if (value.includes("radial-gradient")) { + return rasterizeRadialGradient(value, width, height); + } + return null; +} +function rasterizeConicGradient(value, width, height) { + const match = value.match(/conic-gradient\((.+)\)/); + if (!match) return null; + const scale2 = 2; + const canvas = document.createElement("canvas"); + canvas.width = Math.ceil(width * scale2); + canvas.height = Math.ceil(height * scale2); + const ctx = canvas.getContext("2d"); + if (!ctx || !("createConicGradient" in ctx)) return null; + ctx.scale(scale2, scale2); + const body = match[1]; + const parts = splitGradientArgs(body); + let startDeg = 0; + let stopsStart = 0; + const first = parts[0].trim(); + const fromMatch = first.match(/^from\s+(-?[\d.]+)(deg|rad|turn|grad)/); + if (fromMatch) { + startDeg = parseAngle(fromMatch[1] + fromMatch[2]); + stopsStart = 1; + } + const cx = width / 2; + const cy = height / 2; + const startRad = (startDeg - 90) * Math.PI / 180; + const gradient = ctx.createConicGradient(startRad, cx, cy); + const rawStops = parts.slice(stopsStart); + for (let i = 0; i < rawStops.length; i++) { + const stop = rawStops[i].trim(); + const { color, position } = parseColorStop(stop, i, rawStops.length); + try { + gradient.addColorStop(position, color); + } catch { + } + } + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + return canvas.toDataURL("image/png"); +} +function rasterizeRadialGradient(value, width, height) { + const match = value.match(/radial-gradient\((.+)\)/); + if (!match) return null; + const scale2 = 2; + const canvas = document.createElement("canvas"); + canvas.width = Math.ceil(width * scale2); + canvas.height = Math.ceil(height * scale2); + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + ctx.scale(scale2, scale2); + const body = match[1]; + const parts = splitGradientArgs(body); + let isCircle = false; + let stopsStart = 0; + let customCx = null; + let customCy = null; + const first = parts[0].trim(); + if (first === "circle" || first.startsWith("circle ")) { + isCircle = true; + stopsStart = 1; + } else if (first === "ellipse" || first.startsWith("ellipse ")) { + stopsStart = 1; + } else if (first.includes("at ") && !first.includes("#") && !first.match(/^(rgb|hsl)/)) { + stopsStart = 1; + } + if (stopsStart === 1) { + const atMatch = first.match(/at\s+(.+)/); + if (atMatch) { + const posParts = atMatch[1].trim().split(/\s+/); + customCx = parseLengthOrPercent(posParts[0], width); + customCy = parseLengthOrPercent(posParts[1] ?? posParts[0], height); + } + } + const cx = customCx ?? width / 2; + const cy = customCy ?? height / 2; + const rx = width / 2; + const ry = height / 2; + const radius = isCircle ? Math.sqrt(rx * rx + ry * ry) : Math.max(rx, ry); + ctx.save(); + if (!isCircle && rx !== ry) { + ctx.translate(cx, cy); + ctx.scale(rx / radius, ry / radius); + ctx.translate(-cx, -cy); + } + const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius); + const rawStops = parts.slice(stopsStart); + for (let i = 0; i < rawStops.length; i++) { + const stop = rawStops[i].trim(); + const { color, position } = parseColorStop(stop, i, rawStops.length); + try { + gradient.addColorStop(position, color); + } catch { + } + } + ctx.fillStyle = gradient; + if (!isCircle && rx !== ry) { + const sx = radius / rx; + const sy = radius / ry; + ctx.fillRect(cx * (1 - sx), cy * (1 - sy), width * sx, height * sy); + } else { + ctx.fillRect(0, 0, width, height); + } + ctx.restore(); + return canvas.toDataURL("image/png"); +} +function parseColorStop(stop, index, total) { + const lastParen = stop.lastIndexOf(")"); + const tail = lastParen >= 0 ? stop.slice(lastParen + 1) : stop; + const posMatch = tail.match(/\s+([\d.]+%)\s*$/); + if (posMatch) { + const posStr = posMatch[1]; + const colorEnd = stop.length - posMatch[0].length; + return { + color: stop.slice(0, colorEnd).trim(), + position: parseFloat(posStr) / 100 + }; + } + if (lastParen < 0) { + const spaceIdx = stop.lastIndexOf(" "); + if (spaceIdx > 0 && stop.slice(spaceIdx).match(/[\d.]+%/)) { + return { + color: stop.slice(0, spaceIdx).trim(), + position: parseFloat(stop.slice(spaceIdx)) / 100 + }; + } + } + return { + color: stop, + position: total > 1 ? index / (total - 1) : 0 + }; +} +function splitGradientArgs(str) { + const parts = []; + let depth = 0; + let current = ""; + for (const char of str) { + if (char === "(") depth++; + else if (char === ")") depth--; + if (char === "," && depth === 0) { + parts.push(current); + current = ""; + } else { + current += char; + } + } + if (current) parts.push(current); + return parts; +} +function directionToAngle(dir) { + const map = { + "to top": 0, + "to right": 90, + "to bottom": 180, + "to left": 270, + "to top right": 45, + "to top left": 315, + "to bottom right": 135, + "to bottom left": 225 + }; + return map[dir] ?? 180; +} +function parseAngle(value) { + if (value.endsWith("deg")) return parseFloat(value); + if (value.endsWith("rad")) return parseFloat(value) * 180 / Math.PI; + if (value.endsWith("turn")) return parseFloat(value) * 360; + if (value.endsWith("grad")) return parseFloat(value) * 0.9; + return parseFloat(value); +} +function parseLengthOrPercent(value, containerSize) { + if (value === "center") return containerSize / 2; + if (value === "left" || value === "top") return 0; + if (value === "right" || value === "bottom") return containerSize; + if (value.endsWith("%")) return parseFloat(value) / 100 * containerSize; + const num = parseFloat(value); + return isNaN(num) ? null : num; +} + +// src/assets/images.ts +var IMAGE_TIMEOUT_MS = 1e4; +var MAX_CANVAS_DIM = 4096; +async function imageToDataUrl(url) { + if (url.startsWith("data:")) return url; + return new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + const timer = setTimeout(() => { + console.warn(`dom2svg: Image load timed out after ${IMAGE_TIMEOUT_MS}ms, using original URL: ${url}`); + img.onload = null; + img.onerror = null; + resolve(url); + }, IMAGE_TIMEOUT_MS); + img.onload = () => { + clearTimeout(timer); + try { + const canvas = document.createElement("canvas"); + let w = img.naturalWidth; + let h = img.naturalHeight; + if (w > MAX_CANVAS_DIM || h > MAX_CANVAS_DIM) { + const scale2 = MAX_CANVAS_DIM / Math.max(w, h); + w = Math.round(w * scale2); + h = Math.round(h * scale2); + } + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.drawImage(img, 0, 0, w, h); + resolve(canvas.toDataURL("image/png")); + } else { + resolve(url); + } + } catch { + console.warn(`dom2svg: CORS prevented inlining image, external URL will remain in SVG: ${url}`); + resolve(url); + } + }; + img.onerror = () => { + clearTimeout(timer); + console.warn(`dom2svg: Failed to load image, external URL will remain in SVG: ${url}`); + resolve(url); + }; + img.src = url; + }); +} +function extractUrlFromCss(value) { + const match = value.match(/url\(["']?([^"')]+)["']?\)/); + return match?.[1] ?? null; +} +function canvasToDataUrl(canvas) { + try { + return canvas.toDataURL("image/png"); + } catch { + return ""; + } +} + +// src/transforms/parse.ts +function parseTransform(value) { + if (!value || value === "none") return []; + const functions = []; + const regex = /(\w+)\(([^)]+)\)/g; + let match; + while ((match = regex.exec(value)) !== null) { + const name = match[1]; + const args = match[2].split(",").map((s) => s.trim()); + switch (name) { + case "matrix": { + const vals = args.map(parseFloat); + if (vals.length === 6) { + functions.push({ + type: "matrix", + values: vals + }); + } + break; + } + case "translate": { + const x = parseLengthValue(args[0]); + const y = args[1] ? parseLengthValue(args[1]) : 0; + functions.push({ type: "translate", x, y }); + break; + } + case "translateX": { + functions.push({ type: "translate", x: parseLengthValue(args[0]), y: 0 }); + break; + } + case "translateY": { + functions.push({ type: "translate", x: 0, y: parseLengthValue(args[0]) }); + break; + } + case "scale": { + const sx = parseFloat(args[0]); + const sy = args[1] ? parseFloat(args[1]) : sx; + functions.push({ type: "scale", x: sx, y: sy }); + break; + } + case "scaleX": { + functions.push({ type: "scale", x: parseFloat(args[0]), y: 1 }); + break; + } + case "scaleY": { + functions.push({ type: "scale", x: 1, y: parseFloat(args[0]) }); + break; + } + case "rotate": { + functions.push({ type: "rotate", angle: parseAngleValue(args[0]) }); + break; + } + case "skewX": { + functions.push({ type: "skewX", angle: parseAngleValue(args[0]) }); + break; + } + case "skewY": { + functions.push({ type: "skewY", angle: parseAngleValue(args[0]) }); + break; + } + } + } + return functions; +} +function parseLengthValue(value) { + return parseFloat(value) || 0; +} +function parseAngleValue(value) { + value = value.trim(); + if (value.endsWith("rad")) return parseFloat(value) * 180 / Math.PI; + if (value.endsWith("turn")) return parseFloat(value) * 360; + if (value.endsWith("grad")) return parseFloat(value) * 0.9; + return parseFloat(value) || 0; +} + +// src/transforms/matrix.ts +function identity() { + return [1, 0, 0, 1, 0, 0]; +} +function multiply(a, b) { + return [ + a[0] * b[0] + a[2] * b[1], + a[1] * b[0] + a[3] * b[1], + a[0] * b[2] + a[2] * b[3], + a[1] * b[2] + a[3] * b[3], + a[0] * b[4] + a[2] * b[5] + a[4], + a[1] * b[4] + a[3] * b[5] + a[5] + ]; +} +function translate(tx, ty) { + return [1, 0, 0, 1, tx, ty]; +} +function scale(sx, sy) { + return [sx, 0, 0, sy, 0, 0]; +} +function rotate(angleDeg) { + const rad = angleDeg * Math.PI / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + return [cos, sin, -sin, cos, 0, 0]; +} +function skewX(angleDeg) { + const rad = angleDeg * Math.PI / 180; + return [1, 0, Math.tan(rad), 1, 0, 0]; +} +function skewY(angleDeg) { + const rad = angleDeg * Math.PI / 180; + return [1, Math.tan(rad), 0, 1, 0, 0]; +} +function isIdentity(m) { + return Math.abs(m[0] - 1) < 1e-10 && Math.abs(m[1]) < 1e-10 && Math.abs(m[2]) < 1e-10 && Math.abs(m[3] - 1) < 1e-10 && Math.abs(m[4]) < 1e-10 && Math.abs(m[5]) < 1e-10; +} +function toSvgTransform(m) { + return `matrix(${m.map((v) => v.toFixed(6)).join(",")})`; +} + +// src/transforms/svg.ts +function cssTransformToSvg(cssTransform, transformOrigin, box) { + const functions = parseTransform(cssTransform); + if (functions.length === 0) return null; + const [ox, oy] = parseTransformOrigin(transformOrigin, box); + let result = identity(); + result = multiply(result, translate(ox, oy)); + for (const fn of functions) { + result = multiply(result, transformFunctionToMatrix(fn)); + } + result = multiply(result, translate(-ox, -oy)); + if (isIdentity(result)) return null; + return toSvgTransform(result); +} +function transformFunctionToMatrix(fn) { + switch (fn.type) { + case "matrix": + return fn.values; + case "translate": + return translate(fn.x, fn.y); + case "scale": + return scale(fn.x, fn.y); + case "rotate": + return rotate(fn.angle); + case "skewX": + return skewX(fn.angle); + case "skewY": + return skewY(fn.angle); + } +} +function parseTransformOrigin(origin, box) { + const parts = origin.split(/\s+/); + const x = parseOriginValue(parts[0] ?? "50%", box.width, box.x); + const y = parseOriginValue(parts[1] ?? "50%", box.height, box.y); + return [x, y]; +} +function parseOriginValue(value, size, offset) { + if (value === "left" || value === "top") return offset; + if (value === "right" || value === "bottom") return offset + size; + if (value === "center") return offset + size / 2; + if (value.endsWith("%")) { + return offset + parseFloat(value) / 100 * size; + } + return offset + parseFloat(value); +} + +// src/assets/filters.ts +function createSvgFilter(filterValue, ctx) { + const functions = parseCssFilterFunctions(filterValue); + if (functions.length === 0) return null; + const id = ctx.idGenerator.next("filter"); + const filter = createSvgElement(ctx.svgDocument, "filter"); + setAttributes(filter, { + id, + x: "-50%", + y: "-50%", + width: "200%", + height: "200%" + }); + let hasAny = false; + for (const fn of functions) { + const primitives = createFilterPrimitives(fn, ctx); + for (const prim of primitives) { + filter.appendChild(prim); + hasAny = true; + } + } + if (!hasAny) return null; + ctx.defs.appendChild(filter); + return id; +} +function parseFilterAmount(raw) { + const trimmed = raw.trim(); + if (trimmed.endsWith("%")) { + return (parseFloat(trimmed) || 0) / 100; + } + return parseFloat(trimmed) || 0; +} +function parseAngle2(raw) { + const trimmed = raw.trim(); + if (trimmed.endsWith("rad")) return (parseFloat(trimmed) || 0) * (180 / Math.PI); + if (trimmed.endsWith("grad")) return (parseFloat(trimmed) || 0) * 0.9; + if (trimmed.endsWith("turn")) return (parseFloat(trimmed) || 0) * 360; + return parseFloat(trimmed) || 0; +} +function createFilterPrimitives(fn, ctx) { + switch (fn.name) { + case "blur": { + const radius = parseFloat(fn.args) || 0; + const blur = createSvgElement(ctx.svgDocument, "feGaussianBlur"); + setAttributes(blur, { stdDeviation: radius }); + return [blur]; + } + case "brightness": { + const amount = parseFilterAmount(fn.args); + return [createComponentTransfer(ctx, { slope: amount })]; + } + case "contrast": { + const amount = parseFilterAmount(fn.args); + const intercept = 0.5 - 0.5 * amount; + return [createComponentTransfer(ctx, { slope: amount, intercept })]; + } + case "drop-shadow": { + const parsed = parseDropShadow(`drop-shadow(${fn.args})`); + if (!parsed) return []; + const shadow = createSvgElement(ctx.svgDocument, "feDropShadow"); + setAttributes(shadow, { + dx: parsed.offsetX, + dy: parsed.offsetY, + stdDeviation: parsed.blur / 2, + "flood-color": parsed.color, + "flood-opacity": 1 + }); + return [shadow]; + } + case "grayscale": { + const amount = parseFilterAmount(fn.args); + const s = Math.max(0, Math.min(1, 1 - amount)); + const matrix = createSvgElement(ctx.svgDocument, "feColorMatrix"); + setAttributes(matrix, { type: "saturate", values: s }); + return [matrix]; + } + case "hue-rotate": { + const degrees = parseAngle2(fn.args); + const matrix = createSvgElement(ctx.svgDocument, "feColorMatrix"); + setAttributes(matrix, { type: "hueRotate", values: degrees }); + return [matrix]; + } + case "invert": { + const amount = parseFilterAmount(fn.args); + const lo = amount; + const hi = 1 - amount; + return [createComponentTransfer(ctx, { + type: "table", + tableValues: `${lo} ${hi}` + })]; + } + case "opacity": { + const amount = parseFilterAmount(fn.args); + const transfer = createSvgElement(ctx.svgDocument, "feComponentTransfer"); + const funcA = createSvgElement(ctx.svgDocument, "feFuncA"); + setAttributes(funcA, { type: "linear", slope: amount, intercept: 0 }); + transfer.appendChild(funcA); + return [transfer]; + } + case "saturate": { + const amount = parseFilterAmount(fn.args); + const matrix = createSvgElement(ctx.svgDocument, "feColorMatrix"); + setAttributes(matrix, { type: "saturate", values: amount }); + return [matrix]; + } + case "sepia": { + const amount = Math.max(0, Math.min(1, parseFilterAmount(fn.args))); + const a = amount; + const b = 1 - amount; + const values = [ + b + a * 0.393, + a * 0.769, + a * 0.189, + 0, + 0, + a * 0.349, + b + a * 0.686, + a * 0.168, + 0, + 0, + a * 0.272, + a * 0.534, + b + a * 0.131, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ].map((v) => v.toFixed(4)).join(" "); + const matrix = createSvgElement(ctx.svgDocument, "feColorMatrix"); + setAttributes(matrix, { type: "matrix", values }); + return [matrix]; + } + default: + return []; + } +} +function createComponentTransfer(ctx, opts) { + const transfer = createSvgElement(ctx.svgDocument, "feComponentTransfer"); + for (const channel of ["feFuncR", "feFuncG", "feFuncB"]) { + const func = createSvgElement(ctx.svgDocument, channel); + if (opts.type === "table" && opts.tableValues) { + setAttributes(func, { type: "table", tableValues: opts.tableValues }); + } else { + const attrs = { + type: "linear", + slope: opts.slope ?? 1 + }; + if (opts.intercept !== void 0) attrs.intercept = opts.intercept; + setAttributes(func, attrs); + } + transfer.appendChild(func); + } + return transfer; +} +function parseCssFilterFunctions(value) { + const results = []; + const regex = /([a-z-]+)\(/gi; + let match; + while ((match = regex.exec(value)) !== null) { + const name = match[1]; + const argsStart = match.index + match[0].length; + let depth = 1; + let i = argsStart; + for (; i < value.length && depth > 0; i++) { + if (value[i] === "(") depth++; + else if (value[i] === ")") depth--; + } + const args = value.slice(argsStart, i - 1).trim(); + results.push({ name: name.toLowerCase(), args }); + regex.lastIndex = i; + } + return results; +} +function parseDropShadow(value) { + const startIdx = value.indexOf("drop-shadow("); + if (startIdx === -1) return null; + const argsStart = startIdx + "drop-shadow(".length; + let depth = 1; + let argsEnd = argsStart; + for (let i = argsStart; i < value.length && depth > 0; i++) { + if (value[i] === "(") depth++; + else if (value[i] === ")") depth--; + if (depth > 0) argsEnd = i + 1; + } + const args = value.slice(argsStart, argsEnd).trim(); + if (!args) return null; + const parts = []; + let current = ""; + let parenDepth = 0; + for (const char of args) { + if (char === "(") parenDepth++; + else if (char === ")") parenDepth--; + if (char === " " && parenDepth === 0 && current) { + parts.push(current); + current = ""; + } else { + current += char; + } + } + if (current) parts.push(current); + if (parts.length < 2) return null; + const numericParts = []; + let color = "rgba(0,0,0,0.3)"; + for (const part of parts) { + const num = parseFloat(part); + if (!isNaN(num) && (part.endsWith("px") || part.match(/^-?[\d.]+$/))) { + numericParts.push(num); + } else { + color = part; + } + } + return { + offsetX: numericParts[0] ?? 0, + offsetY: numericParts[1] ?? 0, + blur: numericParts[2] ?? 0, + color + }; +} + +// src/assets/box-shadow.ts +function parseBoxShadows(value) { + if (!value || value === "none") return []; + const shadows = []; + const parts = splitTopLevelCommas(value); + for (const part of parts) { + const shadow = parseSingleShadow(part.trim()); + if (shadow) shadows.push(shadow); + } + return shadows; +} +function splitTopLevelCommas(str) { + const parts = []; + let depth = 0; + let current = ""; + for (const char of str) { + if (char === "(") depth++; + else if (char === ")") depth--; + if (char === "," && depth === 0) { + parts.push(current); + current = ""; + } else { + current += char; + } + } + if (current) parts.push(current); + return parts; +} +function parseSingleShadow(value) { + let inset = false; + let working = value; + if (working.startsWith("inset ")) { + inset = true; + working = working.slice(6).trim(); + } else if (working.endsWith(" inset")) { + inset = true; + working = working.slice(0, -6).trim(); + } + const tokens = []; + let current = ""; + let depth = 0; + for (const char of working) { + if (char === "(") depth++; + else if (char === ")") depth--; + if (char === " " && depth === 0 && current) { + tokens.push(current); + current = ""; + } else { + current += char; + } + } + if (current) tokens.push(current); + const numericValues = []; + const colorParts = []; + for (const token of tokens) { + const num = parseFloat(token); + if (!isNaN(num) && (token.endsWith("px") || token.match(/^-?[\d.]+$/))) { + numericValues.push(num); + } else { + colorParts.push(token); + } + } + if (numericValues.length < 2) return null; + return { + inset, + offsetX: numericValues[0], + offsetY: numericValues[1], + blur: numericValues[2] ?? 0, + spread: numericValues[3] ?? 0, + color: colorParts.join(" ") || "rgba(0, 0, 0, 0.3)" + }; +} +function renderBoxShadows(shadows, box, radii, ctx, group) { + for (let i = shadows.length - 1; i >= 0; i--) { + const shadow = shadows[i]; + if (shadow.inset) { + renderInsetShadow(shadow, box, radii, ctx, group); + } else { + renderOuterShadow(shadow, box, radii, ctx, group); + } + } +} +function renderOuterShadow(shadow, box, radii, ctx, group) { + const spreadBox = { + x: box.x + shadow.offsetX - shadow.spread, + y: box.y + shadow.offsetY - shadow.spread, + width: box.width + shadow.spread * 2, + height: box.height + shadow.spread * 2 + }; + const spreadRadii = expandRadii(radii, shadow.spread); + const shape = createShadowShape(spreadBox, spreadRadii, ctx); + shape.setAttribute("fill", shadow.color); + if (shadow.blur > 0) { + const filterId = ctx.idGenerator.next("shadow"); + const filter = createSvgElement(ctx.svgDocument, "filter"); + const margin = shadow.blur * 2 + Math.abs(shadow.offsetX) + Math.abs(shadow.offsetY) + shadow.spread; + const safeW = Math.max(spreadBox.width, 1); + const safeH = Math.max(spreadBox.height, 1); + setAttributes(filter, { + id: filterId, + x: `-${(margin / safeW * 100 + 10).toFixed(0)}%`, + y: `-${(margin / safeH * 100 + 10).toFixed(0)}%`, + width: `${(200 + margin / safeW * 200 + 20).toFixed(0)}%`, + height: `${(200 + margin / safeH * 200 + 20).toFixed(0)}%` + }); + const feGaussianBlur = createSvgElement(ctx.svgDocument, "feGaussianBlur"); + setAttributes(feGaussianBlur, { + in: "SourceGraphic", + stdDeviation: shadow.blur / 2 + }); + filter.appendChild(feGaussianBlur); + ctx.defs.appendChild(filter); + shape.setAttribute("filter", `url(#${filterId})`); + } + group.insertBefore(shape, group.firstChild); +} +function renderInsetShadow(shadow, box, radii, ctx, group) { + const clipId = ctx.idGenerator.next("inset-clip"); + const clipPath = createSvgElement(ctx.svgDocument, "clipPath"); + clipPath.setAttribute("id", clipId); + const clipShape = createShadowShape(box, radii, ctx); + clipPath.appendChild(clipShape); + ctx.defs.appendChild(clipPath); + const innerBox = { + x: box.x + shadow.offsetX + shadow.spread, + y: box.y + shadow.offsetY + shadow.spread, + width: Math.max(0, box.width - shadow.spread * 2), + height: Math.max(0, box.height - shadow.spread * 2) + }; + const innerRadii = expandRadii(radii, -shadow.spread); + const g = createSvgElement(ctx.svgDocument, "g"); + g.setAttribute("clip-path", `url(#${clipId})`); + const outerRect = createSvgElement(ctx.svgDocument, "rect"); + const pad = shadow.blur * 3 + Math.abs(shadow.offsetX) + Math.abs(shadow.offsetY) + 100; + setAttributes(outerRect, { + x: box.x - pad, + y: box.y - pad, + width: box.width + pad * 2, + height: box.height + pad * 2, + fill: shadow.color + }); + const innerShape = createShadowShape(innerBox, innerRadii, ctx); + innerShape.setAttribute("fill", shadow.color); + const maskId = ctx.idGenerator.next("inset-mask"); + const mask = createSvgElement(ctx.svgDocument, "mask"); + mask.setAttribute("id", maskId); + const maskWhite = createSvgElement(ctx.svgDocument, "rect"); + setAttributes(maskWhite, { x: box.x - pad, y: box.y - pad, width: box.width + pad * 2, height: box.height + pad * 2, fill: "white" }); + const maskBlack = createShadowShape(innerBox, innerRadii, ctx); + maskBlack.setAttribute("fill", "black"); + mask.appendChild(maskWhite); + mask.appendChild(maskBlack); + ctx.defs.appendChild(mask); + outerRect.setAttribute("mask", `url(#${maskId})`); + if (shadow.blur > 0) { + const filterId = ctx.idGenerator.next("inset-blur"); + const filter = createSvgElement(ctx.svgDocument, "filter"); + setAttributes(filter, { id: filterId, x: "-50%", y: "-50%", width: "200%", height: "200%" }); + const feBlur = createSvgElement(ctx.svgDocument, "feGaussianBlur"); + setAttributes(feBlur, { in: "SourceGraphic", stdDeviation: shadow.blur / 2 }); + filter.appendChild(feBlur); + ctx.defs.appendChild(filter); + outerRect.setAttribute("filter", `url(#${filterId})`); + } + g.appendChild(outerRect); + group.insertBefore(g, group.firstChild); +} +function createShadowShape(box, radii, ctx) { + if (hasRadius(radii) && !isUniformRadius(radii)) { + const path = createSvgElement(ctx.svgDocument, "path"); + path.setAttribute("d", buildRoundedRectPath(box.x, box.y, box.width, box.height, radii)); + return path; + } + const rect = createSvgElement(ctx.svgDocument, "rect"); + setAttributes(rect, { x: box.x, y: box.y, width: box.width, height: box.height }); + if (hasRadius(radii) && isUniformRadius(radii)) { + setAttributes(rect, { rx: radii.topLeft[0], ry: radii.topLeft[1] }); + } + return rect; +} +function expandRadii(radii, amount) { + return { + topLeft: [Math.max(0, radii.topLeft[0] + amount), Math.max(0, radii.topLeft[1] + amount)], + topRight: [Math.max(0, radii.topRight[0] + amount), Math.max(0, radii.topRight[1] + amount)], + bottomRight: [Math.max(0, radii.bottomRight[0] + amount), Math.max(0, radii.bottomRight[1] + amount)], + bottomLeft: [Math.max(0, radii.bottomLeft[0] + amount), Math.max(0, radii.bottomLeft[1] + amount)] + }; +} + +// src/assets/clip-path.ts +function parseLengthValue2(raw) { + const trimmed = raw.trim(); + if (trimmed.endsWith("%")) { + return { value: parseFloat(trimmed) || 0, isPct: true }; + } + return { value: parseFloat(trimmed) || 0, isPct: false }; +} +function parseClipPath(value) { + if (!value || value === "none") return null; + const insetMatch = value.match(/^inset\((.+)\)$/); + if (insetMatch) return parseInset(insetMatch[1]); + const circleMatch = value.match(/^circle\((.+)\)$/); + if (circleMatch) return parseCircle(circleMatch[1]); + const ellipseMatch = value.match(/^ellipse\((.+)\)$/); + if (ellipseMatch) return parseEllipse(ellipseMatch[1]); + const polygonMatch = value.match(/^polygon\((.+)\)$/); + if (polygonMatch) return parsePolygon(polygonMatch[1]); + const pathMatch = value.match(/^path\(["']?(.+?)["']?\)$/); + if (pathMatch) return { type: "path", d: pathMatch[1] }; + return null; +} +function parseInset(args) { + const roundIdx = args.indexOf(" round "); + let insetPart = args; + let round; + if (roundIdx >= 0) { + insetPart = args.slice(0, roundIdx); + round = args.slice(roundIdx + 7).trim(); + } + const values = insetPart.trim().split(/\s+/).map((v) => parseFloat(v) || 0); + const top = values[0] ?? 0; + const right = values[1] ?? top; + const bottom = values[2] ?? top; + const left = values[3] ?? right; + return { type: "inset", top, right, bottom, left, round }; +} +function parseCircle(args) { + const atIdx = args.indexOf(" at "); + let radius = 0; + let cx = 0; + let cy = 0; + let cxPct = false; + let cyPct = false; + if (atIdx >= 0) { + radius = parseFloat(args.slice(0, atIdx)) || 0; + const center = args.slice(atIdx + 4).trim().split(/\s+/); + const cxVal = parseLengthValue2(center[0]); + const cyVal = parseLengthValue2(center[1]); + cx = cxVal.value; + cxPct = cxVal.isPct; + cy = cyVal.value; + cyPct = cyVal.isPct; + } else { + radius = parseFloat(args) || 0; + cx = 50; + cy = 50; + cxPct = true; + cyPct = true; + } + return { type: "circle", radius, cx, cy, cxPct, cyPct }; +} +function parseEllipse(args) { + const atIdx = args.indexOf(" at "); + let rx = 0; + let ry = 0; + let cx = 0; + let cy = 0; + let cxPct = false; + let cyPct = false; + if (atIdx >= 0) { + const radii = args.slice(0, atIdx).trim().split(/\s+/); + rx = parseFloat(radii[0]) || 0; + ry = parseFloat(radii[1]) || 0; + const center = args.slice(atIdx + 4).trim().split(/\s+/); + const cxVal = parseLengthValue2(center[0]); + const cyVal = parseLengthValue2(center[1]); + cx = cxVal.value; + cxPct = cxVal.isPct; + cy = cyVal.value; + cyPct = cyVal.isPct; + } else { + const parts = args.trim().split(/\s+/); + rx = parseFloat(parts[0]) || 0; + ry = parseFloat(parts[1]) || 0; + cx = 50; + cy = 50; + cxPct = true; + cyPct = true; + } + return { type: "ellipse", rx, ry, cx, cy, cxPct, cyPct }; +} +function parsePolygon(args) { + let cleaned = args.trim(); + if (cleaned.startsWith("nonzero,") || cleaned.startsWith("evenodd,")) { + cleaned = cleaned.slice(cleaned.indexOf(",") + 1).trim(); + } + const points = []; + const pairs = cleaned.split(","); + for (const pair of pairs) { + const parts = pair.trim().split(/\s+/); + if (parts.length >= 2) { + points.push([parseFloat(parts[0]) || 0, parseFloat(parts[1]) || 0]); + } + } + if (points.length < 3) return null; + return { type: "polygon", points }; +} +function createSvgClipPath(shape, box, ctx) { + const clipId = ctx.idGenerator.next("clip"); + const clipPath = createSvgElement(ctx.svgDocument, "clipPath"); + clipPath.setAttribute("id", clipId); + const svgShape = shapeToSvg(shape, box, ctx); + if (!svgShape) return null; + clipPath.appendChild(svgShape); + ctx.defs.appendChild(clipPath); + return clipId; +} +function shapeToSvg(shape, box, ctx) { + switch (shape.type) { + case "inset": { + const x = box.x + shape.left; + const y = box.y + shape.top; + const w = Math.max(0, box.width - shape.left - shape.right); + const h = Math.max(0, box.height - shape.top - shape.bottom); + if (shape.round) { + const radiiValues = shape.round.split("/").map( + (part) => part.trim().split(/\s+/).map((v) => parseFloat(v) || 0) + ); + const h_values = radiiValues[0] ?? [0]; + const v_values = radiiValues[1] ?? h_values; + const radii = { + topLeft: [h_values[0] ?? 0, v_values[0] ?? 0], + topRight: [h_values[1] ?? h_values[0] ?? 0, v_values[1] ?? v_values[0] ?? 0], + bottomRight: [h_values[2] ?? h_values[0] ?? 0, v_values[2] ?? v_values[0] ?? 0], + bottomLeft: [h_values[3] ?? h_values[1] ?? h_values[0] ?? 0, v_values[3] ?? v_values[1] ?? v_values[0] ?? 0] + }; + const path = createSvgElement(ctx.svgDocument, "path"); + path.setAttribute("d", buildRoundedRectPath(x, y, w, h, radii)); + return path; + } + const rect = createSvgElement(ctx.svgDocument, "rect"); + setAttributes(rect, { x, y, width: w, height: h }); + return rect; + } + case "circle": { + const resolvedCx = shape.cxPct ? shape.cx / 100 * box.width : shape.cx; + const resolvedCy = shape.cyPct ? shape.cy / 100 * box.height : shape.cy; + const circle = createSvgElement(ctx.svgDocument, "circle"); + setAttributes(circle, { + cx: box.x + resolvedCx, + cy: box.y + resolvedCy, + r: shape.radius + }); + return circle; + } + case "ellipse": { + const resolvedCx = shape.cxPct ? shape.cx / 100 * box.width : shape.cx; + const resolvedCy = shape.cyPct ? shape.cy / 100 * box.height : shape.cy; + const ellipse = createSvgElement(ctx.svgDocument, "ellipse"); + setAttributes(ellipse, { + cx: box.x + resolvedCx, + cy: box.y + resolvedCy, + rx: shape.rx, + ry: shape.ry + }); + return ellipse; + } + case "polygon": { + const polygon = createSvgElement(ctx.svgDocument, "polygon"); + const pointsStr = shape.points.map(([x, y]) => `${box.x + x},${box.y + y}`).join(" "); + polygon.setAttribute("points", pointsStr); + return polygon; + } + case "path": { + const path = createSvgElement(ctx.svgDocument, "path"); + path.setAttribute("d", shape.d); + path.setAttribute("transform", `translate(${box.x}, ${box.y})`); + return path; + } + default: + return null; + } +} + +// src/renderers/html-element.ts +async function renderHtmlElement(element, rootElement, ctx) { + const group = createSvgElement(ctx.svgDocument, "g"); + const styles = window.getComputedStyle(element); + const box = getRelativeBox(element, rootElement); + const radii = clampRadii(parseBorderRadii(styles), box.width, box.height); + if (!ctx.options.flattenTransforms && styles.transform && styles.transform !== "none") { + const svgTransform = cssTransformToSvg( + styles.transform, + styles.transformOrigin, + box + ); + if (svgTransform) { + group.setAttribute("transform", svgTransform); + } + } + const clipPathValue = styles.clipPath; + if (clipPathValue && clipPathValue !== "none") { + const shape = parseClipPath(clipPathValue); + if (shape) { + const clipId = createSvgClipPath(shape, box, ctx); + if (clipId) group.setAttribute("clip-path", `url(#${clipId})`); + } + } + const hidden = isVisibilityHidden(styles); + if (!hidden) { + if (styles.filter && styles.filter !== "none") { + const filterId = createSvgFilter(styles.filter, ctx); + if (filterId) { + group.setAttribute("filter", `url(#${filterId})`); + } + } + const boxShadowValue = styles.boxShadow; + if (boxShadowValue && boxShadowValue !== "none") { + const shadows = parseBoxShadows(boxShadowValue); + if (shadows.length > 0) { + renderBoxShadows(shadows, box, radii, ctx, group); + } + } + const bgColor = parseBackgroundColor(styles); + if (bgColor) { + const rect = createBoxShape(box, radii, ctx); + rect.setAttribute("fill", bgColor); + group.appendChild(rect); + } + if (hasBackgroundImage(styles)) { + await renderBackgroundImages(styles, box, radii, ctx, group); + } + const borders = parseBorders(styles); + if (hasBorder(borders)) { + renderBorders(group, box, borders, radii, ctx); + } + renderOutline(styles, box, radii, ctx, group); + if (isImageElement(element) && element.src) { + const dataUrl = await imageToDataUrl(element.src); + const imgEl = createSvgElement(ctx.svgDocument, "image"); + setAttributes(imgEl, { + x: box.x, + y: box.y, + width: box.width, + height: box.height, + href: dataUrl + }); + const objectFit = styles.objectFit || element.style.objectFit; + if (objectFit === "fill" || objectFit === "") { + imgEl.setAttribute("preserveAspectRatio", "none"); + } else if (objectFit === "contain" || objectFit === "scale-down") { + imgEl.setAttribute("preserveAspectRatio", "xMidYMid meet"); + } else if (objectFit === "cover") { + imgEl.setAttribute("preserveAspectRatio", "xMidYMid slice"); + } + if (hasRadius(radii)) { + const clipId = ctx.idGenerator.next("clip"); + const clipPath = createSvgElement(ctx.svgDocument, "clipPath"); + clipPath.setAttribute("id", clipId); + const clipShape = createSvgElement(ctx.svgDocument, "path"); + clipShape.setAttribute("d", buildRoundedRectPath(box.x, box.y, box.width, box.height, radii)); + clipPath.appendChild(clipShape); + ctx.defs.appendChild(clipPath); + imgEl.setAttribute("clip-path", `url(#${clipId})`); + } + group.appendChild(imgEl); + } + if (isCanvasElement(element)) { + const dataUrl = canvasToDataUrl(element); + if (dataUrl) { + const imgEl = createSvgElement(ctx.svgDocument, "image"); + setAttributes(imgEl, { + x: box.x, + y: box.y, + width: box.width, + height: box.height, + href: dataUrl + }); + group.appendChild(imgEl); + } + } + if (isFormElement(element)) { + renderFormContent(element, styles, box, ctx, group); + } + if (styles.display === "list-item") { + renderListMarker(element, styles, box, ctx, group); + } + const maskImage = styles.webkitMaskImage || styles.maskImage || styles.webkitMask || styles.mask; + if (maskImage && maskImage !== "none") { + await applyMaskImage(maskImage, styles, box, ctx, group); + } + await renderPseudoElement(element, "::before", rootElement, ctx, group); + } + if (hasOverflowClip(styles) && element !== rootElement) { + const maskGroup = createOverflowMask(box, radii, ctx); + group.appendChild(maskGroup); + group.__childTarget = maskGroup; + } + return group; +} +async function renderPseudoAfter(element, rootElement, ctx, group) { + await renderPseudoElement(element, "::after", rootElement, ctx, group); +} +function getChildTarget(group) { + return group.__childTarget ?? group; +} +function createBoxShape(box, radii, ctx) { + if (hasRadius(radii) && !isUniformRadius(radii)) { + return createRoundedRectPath(box, radii, ctx); + } + const rect = createSvgElement(ctx.svgDocument, "rect"); + setAttributes(rect, { + x: box.x, + y: box.y, + width: box.width, + height: box.height + }); + if (hasRadius(radii) && isUniformRadius(radii)) { + setAttributes(rect, { + rx: radii.topLeft[0], + ry: radii.topLeft[1] + }); + } + return rect; +} +function createRoundedRectPath(box, radii, ctx) { + const path = createSvgElement(ctx.svgDocument, "path"); + path.setAttribute("d", buildRoundedRectPath(box.x, box.y, box.width, box.height, radii)); + return path; +} +function borderDashArray(style, width) { + if (style === "dashed") return `${width * 3} ${width * 2}`; + if (style === "dotted") return `${width} ${width}`; + return null; +} +function renderBorders(group, box, borders, radii, ctx) { + if (borders.top.width === borders.right.width && borders.right.width === borders.bottom.width && borders.bottom.width === borders.left.width && borders.top.color === borders.right.color && borders.right.color === borders.bottom.color && borders.bottom.color === borders.left.color && borders.top.style === borders.right.style && borders.right.style === borders.bottom.style && borders.bottom.style === borders.left.style && borders.top.width > 0 && borders.top.style !== "none") { + const halfW = borders.top.width / 2; + const insetBox = { + x: box.x + halfW, + y: box.y + halfW, + width: Math.max(0, box.width - borders.top.width), + height: Math.max(0, box.height - borders.top.width) + }; + const insetRadii = { + topLeft: [Math.max(0, radii.topLeft[0] - halfW), Math.max(0, radii.topLeft[1] - halfW)], + topRight: [Math.max(0, radii.topRight[0] - halfW), Math.max(0, radii.topRight[1] - halfW)], + bottomRight: [Math.max(0, radii.bottomRight[0] - halfW), Math.max(0, radii.bottomRight[1] - halfW)], + bottomLeft: [Math.max(0, radii.bottomLeft[0] - halfW), Math.max(0, radii.bottomLeft[1] - halfW)] + }; + const shape = createBoxShape(insetBox, insetRadii, ctx); + setAttributes(shape, { + fill: "none", + stroke: borders.top.color, + "stroke-width": borders.top.width + }); + const dash = borderDashArray(borders.top.style, borders.top.width); + if (dash) shape.setAttribute("stroke-dasharray", dash); + group.appendChild(shape); + return; + } + const { x, y, width, height } = box; + const bT = borders.top.width; + const bR = borders.right.width; + const bB = borders.bottom.width; + const bL = borders.left.width; + const ox0 = x, oy0 = y; + const ox1 = x + width, oy1 = y + height; + const ix0 = x + bL, iy0 = y + bT; + const ix1 = x + width - bR, iy1 = y + height - bB; + const sides = [ + { w: bT, side: borders.top, trapD: `M ${ox0} ${oy0} L ${ox1} ${oy0} L ${ix1} ${iy0} L ${ix0} ${iy0} Z`, lineD: `M ${ix0} ${oy0 + bT / 2} L ${ix1} ${oy0 + bT / 2}` }, + { w: bR, side: borders.right, trapD: `M ${ox1} ${oy0} L ${ox1} ${oy1} L ${ix1} ${iy1} L ${ix1} ${iy0} Z`, lineD: `M ${ox1 - bR / 2} ${iy0} L ${ox1 - bR / 2} ${iy1}` }, + { w: bB, side: borders.bottom, trapD: `M ${ox1} ${oy1} L ${ox0} ${oy1} L ${ix0} ${iy1} L ${ix1} ${iy1} Z`, lineD: `M ${ix1} ${oy1 - bB / 2} L ${ix0} ${oy1 - bB / 2}` }, + { w: bL, side: borders.left, trapD: `M ${ox0} ${oy1} L ${ox0} ${oy0} L ${ix0} ${iy0} L ${ix0} ${iy1} Z`, lineD: `M ${ox0 + bL / 2} ${iy1} L ${ox0 + bL / 2} ${iy0}` } + ]; + for (const { w, side, trapD, lineD } of sides) { + if (w <= 0 || side.style === "none") continue; + const dash = borderDashArray(side.style, w); + if (dash) { + const line = createSvgElement(ctx.svgDocument, "path"); + setAttributes(line, { d: lineD, fill: "none", stroke: side.color, "stroke-width": w }); + line.setAttribute("stroke-dasharray", dash); + group.appendChild(line); + } else { + const path = createSvgElement(ctx.svgDocument, "path"); + path.setAttribute("d", trapD); + path.setAttribute("fill", side.color); + group.appendChild(path); + } + } +} +function createOverflowMask(box, radii, ctx) { + const maskId = ctx.idGenerator.next("mask"); + const mask = createSvgElement(ctx.svgDocument, "mask"); + mask.setAttribute("id", maskId); + const maskRect = createBoxShape(box, radii, ctx); + maskRect.setAttribute("fill", "white"); + mask.appendChild(maskRect); + ctx.defs.appendChild(mask); + const masked = createSvgElement(ctx.svgDocument, "g"); + masked.setAttribute("mask", `url(#${maskId})`); + return masked; +} +function applyClipMask(target, box, radii, ctx, group) { + const maskId = ctx.idGenerator.next("mask"); + const mask = createSvgElement(ctx.svgDocument, "mask"); + mask.setAttribute("id", maskId); + const maskRect = createBoxShape(box, radii, ctx); + maskRect.setAttribute("fill", "white"); + mask.appendChild(maskRect); + ctx.defs.appendChild(mask); + const wrapper = createSvgElement(ctx.svgDocument, "g"); + wrapper.setAttribute("mask", `url(#${maskId})`); + wrapper.appendChild(target); + group.appendChild(wrapper); +} +async function applyMaskImage(maskImage, styles, box, ctx, group) { + const url = extractUrlFromCss(maskImage); + if (!url) return; + let imageUrl = url; + if (!url.startsWith("data:")) { + try { + imageUrl = await imageToDataUrl(url); + } catch { + return; + } + } + const maskSize = styles.webkitMaskSize || styles.maskSize || "auto"; + let imgWidth = box.width; + let imgHeight = box.height; + if (maskSize !== "auto" && maskSize !== "contain" && maskSize !== "cover") { + const parts = maskSize.split(/\s+/); + const w = parseFloat(parts[0]); + const h = parseFloat(parts[1] || parts[0]); + if (!isNaN(w)) imgWidth = w; + if (!isNaN(h)) imgHeight = h; + } + const maskId = ctx.idGenerator.next("mask"); + const mask = createSvgElement(ctx.svgDocument, "mask"); + mask.setAttribute("id", maskId); + mask.setAttribute("style", "mask-type: alpha"); + const imgEl = createSvgElement(ctx.svgDocument, "image"); + setAttributes(imgEl, { + x: box.x, + y: box.y, + width: imgWidth, + height: imgHeight, + href: imageUrl + }); + mask.appendChild(imgEl); + ctx.defs.appendChild(mask); + group.setAttribute("mask", `url(#${maskId})`); +} +function renderListMarker(element, styles, box, ctx, group) { + let markerText = ""; + try { + const markerStyles = window.getComputedStyle(element, "::marker"); + const content = markerStyles.content; + if (content && content !== "none" && content !== "normal") { + markerText = content.replace(/^["']|["']$/g, ""); + } + } catch { + } + if (!markerText) { + const listStyleType = styles.listStyleType; + if (listStyleType === "none") return; + if (listStyleType === "disc") { + markerText = "\u2022"; + } else if (listStyleType === "circle") { + markerText = "\u25CB"; + } else if (listStyleType === "square") { + markerText = "\u25A0"; + } else if (listStyleType === "decimal" || listStyleType === "" || !listStyleType) { + let count = 1; + let sibling = element.previousElementSibling; + while (sibling) { + const sibStyles = window.getComputedStyle(sibling); + if (sibStyles.display === "list-item") count++; + sibling = sibling.previousElementSibling; + } + markerText = `${count}.`; + } else { + markerText = "\u2022"; + } + } + if (!markerText) return; + const fontSize = parseFloat(styles.fontSize) || 16; + const paddingLeft = parseFloat(styles.paddingLeft) || 0; + const paddingTop = parseFloat(styles.paddingTop) || 0; + const lineHeight = parseFloat(styles.lineHeight) || fontSize * 1.2; + const markerX = box.x + paddingLeft - 6; + const markerY = box.y + paddingTop + (lineHeight - fontSize) / 2 + fontSize * 0.8; + const textEl = createSvgElement(ctx.svgDocument, "text"); + setAttributes(textEl, { + x: markerX.toFixed(2), + y: markerY.toFixed(2), + "font-family": styles.fontFamily, + "font-size": styles.fontSize, + fill: styles.color, + "text-anchor": "end" + }); + textEl.textContent = markerText; + group.appendChild(textEl); +} +function renderFormContent(element, styles, box, ctx, group) { + let text = ""; + let isPlaceholder = false; + if (element instanceof HTMLSelectElement) { + const selected = element.selectedOptions[0]; + text = selected?.text ?? ""; + } else { + text = element.value; + if (!text && element.placeholder) { + text = element.placeholder; + isPlaceholder = true; + } + } + if (!text) return; + const fontSize = parseFloat(styles.fontSize) || 16; + const paddingLeft = parseFloat(styles.paddingLeft) || 0; + const paddingTop = parseFloat(styles.paddingTop) || 0; + const borderTop = parseFloat(styles.borderTopWidth) || 0; + const lineHeight = parseFloat(styles.lineHeight) || fontSize * 1.2; + const fillColor = isPlaceholder ? "gray" : styles.color; + const textX = box.x + paddingLeft; + if (element instanceof HTMLTextAreaElement) { + const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const lineText = lines[i]; + if (!lineText) continue; + const topPadding = (lineHeight - fontSize) / 2; + const y = box.y + borderTop + paddingTop + i * lineHeight + topPadding + fontSize * 0.8; + const textEl2 = createSvgElement(ctx.svgDocument, "text"); + setAttributes(textEl2, { + x: textX.toFixed(2), + y: y.toFixed(2), + "font-family": styles.fontFamily, + "font-size": styles.fontSize, + "font-weight": styles.fontWeight, + "font-style": styles.fontStyle, + fill: fillColor + }); + if (isPlaceholder) textEl2.setAttribute("opacity", "0.54"); + textEl2.textContent = lineText; + group.appendChild(textEl2); + } + return; + } + const borderBottom = parseFloat(styles.borderBottomWidth) || 0; + const innerHeight = box.height - borderTop - borderBottom; + const baselineY2 = box.y + borderTop + innerHeight / 2 + fontSize * 0.35; + const textEl = createSvgElement(ctx.svgDocument, "text"); + setAttributes(textEl, { + x: textX.toFixed(2), + y: baselineY2.toFixed(2), + "font-family": styles.fontFamily, + "font-size": styles.fontSize, + "font-weight": styles.fontWeight, + "font-style": styles.fontStyle, + fill: fillColor + }); + if (isPlaceholder) { + textEl.setAttribute("opacity", "0.54"); + } + textEl.textContent = text; + group.appendChild(textEl); +} +function renderOutline(styles, box, radii, ctx, group) { + const outlineStyle = styles.outlineStyle; + if (!outlineStyle || outlineStyle === "none") return; + const outlineWidth = parseFloat(styles.outlineWidth) || 0; + if (outlineWidth <= 0) return; + const outlineColor = styles.outlineColor || styles.color; + const outlineOffset = parseFloat(styles.outlineOffset) || 0; + const expand = outlineOffset + outlineWidth / 2; + const outlineBox = { + x: box.x - expand, + y: box.y - expand, + width: box.width + expand * 2, + height: box.height + expand * 2 + }; + const outlineRadii = { + topLeft: [Math.max(0, radii.topLeft[0] + expand), Math.max(0, radii.topLeft[1] + expand)], + topRight: [Math.max(0, radii.topRight[0] + expand), Math.max(0, radii.topRight[1] + expand)], + bottomRight: [Math.max(0, radii.bottomRight[0] + expand), Math.max(0, radii.bottomRight[1] + expand)], + bottomLeft: [Math.max(0, radii.bottomLeft[0] + expand), Math.max(0, radii.bottomLeft[1] + expand)] + }; + const shape = createBoxShape(outlineBox, outlineRadii, ctx); + setAttributes(shape, { + fill: "none", + stroke: outlineColor, + "stroke-width": outlineWidth + }); + const dash = borderDashArray(outlineStyle, outlineWidth); + if (dash) shape.setAttribute("stroke-dasharray", dash); + group.appendChild(shape); +} +function splitCssValueList(str) { + const parts = []; + let depth = 0; + let current = ""; + for (const char of str) { + if (char === "(") depth++; + else if (char === ")") depth--; + if (char === "," && depth === 0) { + parts.push(current.trim()); + current = ""; + } else { + current += char; + } + } + if (current.trim()) parts.push(current.trim()); + return parts; +} +function computeBackgroundPlacement(bgSize, bgPosition, box) { + let width = box.width; + let height = box.height; + let par = "none"; + if (bgSize === "contain") { + par = "xMidYMid meet"; + } else if (bgSize === "cover") { + par = "xMidYMid slice"; + } else if (bgSize && bgSize !== "auto") { + const sizeParts = bgSize.split(/\s+/); + const w = parseBgDimension(sizeParts[0], box.width); + const h = parseBgDimension(sizeParts[1] ?? "auto", box.height); + if (w !== null) width = w; + if (h !== null) height = h; + } + let x = box.x; + let y = box.y; + if (bgPosition && bgPosition !== "0% 0%") { + const posParts = bgPosition.split(/\s+/); + x = box.x + parseBgOffset(posParts[0] ?? "0px", box.width, width); + y = box.y + parseBgOffset(posParts[1] ?? "0px", box.height, height); + } + return { x, y, width, height, preserveAspectRatio: par }; +} +function parseBgDimension(value, containerSize) { + if (value === "auto") return null; + if (value.endsWith("%")) return parseFloat(value) / 100 * containerSize; + return parseFloat(value) || null; +} +function parseBgOffset(value, containerSize, imageSize) { + if (value.endsWith("%")) { + const pct = parseFloat(value) / 100; + return pct * (containerSize - imageSize); + } + return parseFloat(value) || 0; +} +async function renderBackgroundImages(styles, box, radii, ctx, group) { + const bgImages = splitCssValueList(styles.backgroundImage); + const bgSizes = splitCssValueList(styles.backgroundSize); + const bgPositions = splitCssValueList(styles.backgroundPosition); + for (let i = bgImages.length - 1; i >= 0; i--) { + const bgImage = bgImages[i]; + if (bgImage === "none") continue; + const bgSize = bgSizes[i] ?? bgSizes[bgSizes.length - 1] ?? "auto"; + const bgPosition = bgPositions[i] ?? bgPositions[bgPositions.length - 1] ?? "0% 0%"; + const placement = computeBackgroundPlacement(bgSize, bgPosition, box); + await renderSingleBackgroundLayer(bgImage, placement, box, radii, ctx, group); + } +} +async function renderSingleBackgroundLayer(bgImage, placement, box, radii, ctx, group) { + const gradient = parseLinearGradient(bgImage); + if (gradient) { + const gradientEl = createSvgLinearGradient(gradient, box, ctx); + const rect = createBoxShape(box, radii, ctx); + rect.setAttribute("fill", `url(#${gradientEl.getAttribute("id")})`); + group.appendChild(rect); + return; + } + const rasterized = rasterizeGradient(bgImage, placement.width, placement.height); + if (rasterized) { + const imgEl = createSvgElement(ctx.svgDocument, "image"); + setAttributes(imgEl, { + x: placement.x, + y: placement.y, + width: placement.width, + height: placement.height, + href: rasterized, + preserveAspectRatio: placement.preserveAspectRatio + }); + if (hasRadius(radii)) { + applyClipMask(imgEl, box, radii, ctx, group); + } else { + group.appendChild(imgEl); + } + return; + } + const url = extractUrlFromCss(bgImage); + if (url) { + const dataUrl = await imageToDataUrl(url); + const imgEl = createSvgElement(ctx.svgDocument, "image"); + setAttributes(imgEl, { + x: placement.x, + y: placement.y, + width: placement.width, + height: placement.height, + href: dataUrl, + preserveAspectRatio: placement.preserveAspectRatio + }); + if (hasRadius(radii)) { + applyClipMask(imgEl, box, radii, ctx, group); + } else { + group.appendChild(imgEl); + } + } +} +function hasVisualProperties(styles) { + if (parseBackgroundColor(styles)) return true; + if (hasBackgroundImage(styles)) return true; + const clipPath = styles.clipPath || styles.webkitClipPath; + if (clipPath && clipPath !== "none") return true; + return false; +} +function measurePseudoBox(element, pseudo, styles, rootElement) { + const marker = document.createElement("span"); + marker.style.cssText = ` + position: ${styles.position}; + display: ${styles.display === "none" ? "none" : styles.display}; + top: ${styles.top}; right: ${styles.right}; + bottom: ${styles.bottom}; left: ${styles.left}; + width: ${styles.width}; height: ${styles.height}; + margin: ${styles.margin}; padding: ${styles.padding}; + box-sizing: ${styles.boxSizing}; + visibility: hidden; + pointer-events: none; + `; + if (pseudo === "::before") { + element.insertBefore(marker, element.firstChild); + } else { + element.appendChild(marker); + } + const rect = marker.getBoundingClientRect(); + element.removeChild(marker); + if (rect.width === 0 && rect.height === 0) return null; + const rootRect = rootElement.getBoundingClientRect(); + return { + x: rect.left - rootRect.left, + y: rect.top - rootRect.top, + width: rect.width, + height: rect.height + }; +} +async function renderPseudoElement(element, pseudo, rootElement, ctx, group) { + const styles = getPseudoStyles(element, pseudo); + const content = styles.content; + if (!content || content === "none" || content === "normal") { + return; + } + const text = content.replace(/^["']|["']$/g, ""); + const hasVisuals = hasVisualProperties(styles); + if (!text && !hasVisuals) return; + if (hasVisuals) { + const pseudoBox = measurePseudoBox(element, pseudo, styles, rootElement); + if (pseudoBox) { + const bgColor = parseBackgroundColor(styles); + if (bgColor) { + const rect = createSvgElement(ctx.svgDocument, "rect"); + setAttributes(rect, { + x: pseudoBox.x, + y: pseudoBox.y, + width: pseudoBox.width, + height: pseudoBox.height, + fill: bgColor + }); + const clipPathValue = styles.clipPath || styles.webkitClipPath; + if (clipPathValue && clipPathValue !== "none") { + const shape = parseClipPath(clipPathValue); + if (shape) { + const clipId = createSvgClipPath(shape, pseudoBox, ctx); + if (clipId) rect.setAttribute("clip-path", `url(#${clipId})`); + } + } + group.appendChild(rect); + } + } + } + if (!text) return; + const rootRect = rootElement.getBoundingClientRect(); + const fontSize = parseFloat(styles.fontSize) || 16; + const marker = document.createElement("span"); + marker.style.cssText = ` + font-family: ${styles.fontFamily}; + font-size: ${styles.fontSize}; + font-weight: ${styles.fontWeight}; + font-style: ${styles.fontStyle}; + letter-spacing: ${styles.letterSpacing}; + visibility: hidden; + pointer-events: none; + `; + marker.textContent = text; + if (pseudo === "::before") { + element.insertBefore(marker, element.firstChild); + } else { + element.appendChild(marker); + } + const markerRect = marker.getBoundingClientRect(); + const markerX = markerRect.left - rootRect.left; + const markerWidth = markerRect.width; + const markerHeight = markerRect.height; + const topPadding = (markerHeight - fontSize) / 2; + const baselineY2 = markerRect.top - rootRect.top + topPadding + fontSize * 0.8; + element.removeChild(marker); + if (!hasVisuals) { + const bgColor = parseBackgroundColor(styles); + if (bgColor) { + const bgRect = createSvgElement(ctx.svgDocument, "rect"); + setAttributes(bgRect, { + x: markerX, + y: markerRect.top - rootRect.top, + width: markerWidth, + height: markerHeight, + fill: bgColor + }); + group.appendChild(bgRect); + } + } + const textEl = createSvgElement(ctx.svgDocument, "text"); + setAttributes(textEl, { + "font-family": styles.fontFamily, + "font-size": styles.fontSize, + "font-weight": styles.fontWeight, + "font-style": styles.fontStyle, + fill: styles.color, + x: markerX.toFixed(2), + y: baselineY2.toFixed(2) + }); + if (styles.letterSpacing && styles.letterSpacing !== "normal") { + textEl.setAttribute("letter-spacing", styles.letterSpacing); + } + textEl.textContent = text; + group.appendChild(textEl); +} + +// src/renderers/svg-element.ts +function renderSvgElement(element, ctx) { + const computedColor = window.getComputedStyle(element).color || "rgb(0, 0, 0)"; + const clone = cloneWithNamespace(element, ctx); + resolveCurrentColor(clone, computedColor); + rewriteIds(clone, ctx); + return clone; +} +function cloneWithNamespace(node, ctx, resolveDepth = 0) { + if (node.localName === "use" && resolveDepth < 5) { + const resolved = resolveUseElement(node, ctx, resolveDepth); + if (resolved) return resolved; + } + const clone = ctx.svgDocument.createElementNS( + node.namespaceURI || SVG_NS, + node.localName + ); + for (const attr of Array.from(node.attributes)) { + if (attr.namespaceURI === XLINK_NS) { + clone.setAttributeNS(XLINK_NS, attr.localName, attr.value); + } else if (attr.namespaceURI) { + clone.setAttributeNS(attr.namespaceURI, attr.localName, attr.value); + } else { + clone.setAttribute(attr.localName, attr.value); + } + } + inlineSvgPresentationStyles(node, clone); + for (const child of Array.from(node.childNodes)) { + if (child.nodeType === Node.ELEMENT_NODE) { + clone.appendChild(cloneWithNamespace(child, ctx, resolveDepth)); + } else if (child.nodeType === Node.TEXT_NODE) { + clone.appendChild(ctx.svgDocument.createTextNode(child.textContent || "")); + } + } + return clone; +} +function resolveUseElement(useEl, ctx, resolveDepth) { + const href = useEl.getAttribute("href") || useEl.getAttributeNS(XLINK_NS, "href"); + if (!href || !href.startsWith("#")) return null; + const refId = href.slice(1); + const refEl = document.getElementById(refId); + if (!refEl) return null; + const group = ctx.svgDocument.createElementNS(SVG_NS, "g"); + const skipAttrs = /* @__PURE__ */ new Set(["href", "xlink:href", "x", "y", "width", "height"]); + for (const attr of Array.from(useEl.attributes)) { + if (skipAttrs.has(attr.localName)) continue; + if (attr.namespaceURI === XLINK_NS) continue; + if (attr.namespaceURI) { + group.setAttributeNS(attr.namespaceURI, attr.localName, attr.value); + } else { + group.setAttribute(attr.localName, attr.value); + } + } + const x = parseFloat(useEl.getAttribute("x") || "0") || 0; + const y = parseFloat(useEl.getAttribute("y") || "0") || 0; + if (x !== 0 || y !== 0) { + const existing = group.getAttribute("transform") || ""; + group.setAttribute("transform", `translate(${x},${y}) ${existing}`.trim()); + } + inlineSvgPresentationStyles(useEl, group); + if (refEl.localName === "symbol") { + const viewBox = refEl.getAttribute("viewBox"); + const width = useEl.getAttribute("width") || refEl.getAttribute("width"); + const height = useEl.getAttribute("height") || refEl.getAttribute("height"); + const wrapper = ctx.svgDocument.createElementNS(SVG_NS, "svg"); + if (viewBox) wrapper.setAttribute("viewBox", viewBox); + if (width) wrapper.setAttribute("width", width); + if (height) wrapper.setAttribute("height", height); + wrapper.setAttribute("overflow", "hidden"); + for (const child of Array.from(refEl.childNodes)) { + if (child.nodeType === Node.ELEMENT_NODE) { + wrapper.appendChild(cloneWithNamespace(child, ctx, resolveDepth + 1)); + } + } + group.appendChild(wrapper); + } else { + group.appendChild(cloneWithNamespace(refEl, ctx, resolveDepth + 1)); + } + return group; +} +function inlineSvgPresentationStyles(source, clone) { + const styles = window.getComputedStyle(source); + if (!clone.hasAttribute("fill")) { + const fill = styles.fill; + if (fill && fill !== "rgb(0, 0, 0)") { + clone.setAttribute("fill", fill); + } + } + if (!clone.hasAttribute("stroke")) { + const stroke = styles.stroke; + if (stroke && stroke !== "none") { + clone.setAttribute("stroke", stroke); + } + } + if (!clone.hasAttribute("opacity")) { + const opacity = styles.opacity; + if (opacity && opacity !== "1") { + clone.setAttribute("opacity", opacity); + } + } +} +function rewriteIds(root, ctx) { + const idMap = /* @__PURE__ */ new Map(); + const allElements = root.querySelectorAll("[id]"); + for (const el of Array.from(allElements)) { + const oldId = el.getAttribute("id"); + const newId = ctx.idGenerator.next("svg"); + idMap.set(oldId, newId); + el.setAttribute("id", newId); + } + if (root.hasAttribute("id")) { + const oldId = root.getAttribute("id"); + if (!idMap.has(oldId)) { + const newId = ctx.idGenerator.next("svg"); + idMap.set(oldId, newId); + root.setAttribute("id", newId); + } + } + if (idMap.size === 0) return; + rewriteUrlReferences(root, idMap); +} +function rewriteUrlReferences(element, idMap) { + for (const attr of Array.from(element.attributes)) { + if (attr.value.includes("url(#")) { + let newValue = attr.value; + for (const [oldId, newId] of idMap) { + newValue = newValue.replace( + new RegExp(`url\\(#${escapeRegex(oldId)}\\)`, "g"), + `url(#${newId})` + ); + } + if (newValue !== attr.value) { + element.setAttribute(attr.localName, newValue); + } + } + if ((attr.localName === "href" || attr.localName === "xlink:href") && attr.value.startsWith("#")) { + const refId = attr.value.slice(1); + if (idMap.has(refId)) { + if (attr.namespaceURI === XLINK_NS) { + element.setAttributeNS(XLINK_NS, "href", `#${idMap.get(refId)}`); + } else { + element.setAttribute(attr.localName, `#${idMap.get(refId)}`); + } + } + } + } + for (const child of Array.from(element.children)) { + if (child instanceof SVGElement) { + rewriteUrlReferences(child, idMap); + } + } +} +function resolveCurrentColor(element, color) { + for (const attr of Array.from(element.attributes)) { + if (attr.value === "currentColor") { + element.setAttribute(attr.localName, color); + } + } + for (const child of Array.from(element.children)) { + if (child instanceof SVGElement) { + resolveCurrentColor(child, color); + } + } +} +function escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// src/assets/fonts.ts +var FONT_TIMEOUT_MS = 1e4; +function createFontCache(mapping) { + const cache = /* @__PURE__ */ new Map(); + let opentypeModule = null; + async function loadOpentype() { + if (opentypeModule) return opentypeModule; + opentypeModule = await import("opentype.js"); + return opentypeModule; + } + function getKey(family, weight, style) { + return `${family}|${weight ?? "normal"}|${style ?? "normal"}`; + } + function normalizeWeight(w) { + if (w === void 0 || w === "normal") return 400; + if (w === "bold") return 700; + return typeof w === "string" ? parseInt(w, 10) || 400 : w; + } + function normalizeStyle(s) { + return s === "italic" || s === "oblique" ? "italic" : "normal"; + } + function findConfig(family, weight, style) { + const entry = mapping[family]; + if (!entry) return null; + if (typeof entry === "string") { + return { url: entry }; + } + if (!Array.isArray(entry)) { + return entry; + } + const targetWeight = normalizeWeight(weight); + const targetStyle = normalizeStyle(style); + let best = null; + let bestScore = -1; + for (const cfg of entry) { + let score = 0; + if (normalizeStyle(cfg.style) === targetStyle) score += 2; + if (normalizeWeight(cfg.weight) === targetWeight) score += 1; + if (score > bestScore) { + bestScore = score; + best = cfg; + } + } + return best ?? entry[0] ?? null; + } + return { + async getFont(family, weight, style) { + const key = getKey(family, weight, style); + if (cache.has(key)) { + return cache.get(key); + } + const config = findConfig(family, weight, style); + if (!config) return null; + const opentype = await loadOpentype(); + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FONT_TIMEOUT_MS); + const response = await fetch(config.url, { signal: controller.signal }); + clearTimeout(timer); + const buffer = await response.arrayBuffer(); + const font = opentype.parse(buffer); + cache.set(key, font); + return font; + } catch (err) { + console.warn(`dom2svg: Failed to load font "${family}" from ${config.url}:`, err); + return null; + } + }, + has(family) { + return family in mapping; + } + }; +} +function textToPath(font, text, x, y, fontSize) { + try { + const path = font.getPath(text, x, y, fontSize); + return path.toPathData(2); + } catch { + return null; + } +} +function cleanFontFamily(fontFamily) { + const first = fontFamily.split(",")[0]?.trim() ?? fontFamily; + return first.replace(/^["']|["']$/g, ""); +} + +// src/assets/text-shadow.ts +function parseTextShadows(value) { + if (!value || value === "none") return []; + const shadows = []; + const parts = splitTopLevelCommas2(value); + for (const part of parts) { + const shadow = parseSingleTextShadow(part.trim()); + if (shadow) shadows.push(shadow); + } + return shadows; +} +function splitTopLevelCommas2(str) { + const parts = []; + let depth = 0; + let current = ""; + for (const char of str) { + if (char === "(") depth++; + else if (char === ")") depth--; + if (char === "," && depth === 0) { + parts.push(current); + current = ""; + } else { + current += char; + } + } + if (current) parts.push(current); + return parts; +} +function parseSingleTextShadow(value) { + const tokens = []; + let current = ""; + let depth = 0; + for (const char of value) { + if (char === "(") depth++; + else if (char === ")") depth--; + if (char === " " && depth === 0 && current) { + tokens.push(current); + current = ""; + } else { + current += char; + } + } + if (current) tokens.push(current); + const numericValues = []; + const colorParts = []; + for (const token of tokens) { + const num = parseFloat(token); + if (!isNaN(num) && (token.endsWith("px") || token.match(/^-?[\d.]+$/))) { + numericValues.push(num); + } else { + colorParts.push(token); + } + } + if (numericValues.length < 2) return null; + return { + offsetX: numericValues[0], + offsetY: numericValues[1], + blur: numericValues[2] ?? 0, + color: colorParts.join(" ") || "rgba(0, 0, 0, 0.5)" + }; +} +function createTextShadowFilter(shadows, ctx) { + if (shadows.length === 0) return null; + const id = ctx.idGenerator.next("tshadow"); + const filter = createSvgElement(ctx.svgDocument, "filter"); + setAttributes(filter, { + id, + x: "-50%", + y: "-50%", + width: "200%", + height: "200%" + }); + if (shadows.length === 1) { + const s = shadows[0]; + const feDrop = createSvgElement(ctx.svgDocument, "feDropShadow"); + setAttributes(feDrop, { + dx: s.offsetX, + dy: s.offsetY, + stdDeviation: s.blur / 2, + "flood-color": s.color, + "flood-opacity": 1 + }); + filter.appendChild(feDrop); + } else { + const mergeInputs = []; + for (let i = 0; i < shadows.length; i++) { + const s = shadows[i]; + const suffix = String(i); + const feOffset = createSvgElement(ctx.svgDocument, "feOffset"); + setAttributes(feOffset, { + in: "SourceAlpha", + dx: s.offsetX, + dy: s.offsetY, + result: `off${suffix}` + }); + filter.appendChild(feOffset); + const feBlur = createSvgElement(ctx.svgDocument, "feGaussianBlur"); + setAttributes(feBlur, { + in: `off${suffix}`, + stdDeviation: s.blur / 2, + result: `blur${suffix}` + }); + filter.appendChild(feBlur); + const feFlood = createSvgElement(ctx.svgDocument, "feFlood"); + setAttributes(feFlood, { + "flood-color": s.color, + "flood-opacity": 1, + result: `color${suffix}` + }); + filter.appendChild(feFlood); + const feComp = createSvgElement(ctx.svgDocument, "feComposite"); + setAttributes(feComp, { + in: `color${suffix}`, + in2: `blur${suffix}`, + operator: "in", + result: `shadow${suffix}` + }); + filter.appendChild(feComp); + mergeInputs.push(`shadow${suffix}`); + } + const feMerge = createSvgElement(ctx.svgDocument, "feMerge"); + for (const input of mergeInputs) { + const node = createSvgElement(ctx.svgDocument, "feMergeNode"); + node.setAttribute("in", input); + feMerge.appendChild(node); + } + const srcNode = createSvgElement(ctx.svgDocument, "feMergeNode"); + srcNode.setAttribute("in", "SourceGraphic"); + feMerge.appendChild(srcNode); + filter.appendChild(feMerge); + } + ctx.defs.appendChild(filter); + return id; +} + +// src/renderers/text-node.ts +async function renderTextNode(textNode, rootElement, ctx) { + const text = textNode.textContent; + if (!text || !text.trim()) return null; + const parent = textNode.parentElement; + if (!parent) return null; + const styles = window.getComputedStyle(parent); + if (styles.visibility === "hidden") return null; + const whiteSpace = styles.whiteSpace; + const rootRect = rootElement.getBoundingClientRect(); + let rects; + try { + const range = document.createRange(); + range.selectNodeContents(textNode); + rects = range.getClientRects(); + } catch { + return null; + } + if (rects.length === 0) return null; + const group = createSvgElement(ctx.svgDocument, "g"); + const usePathMode = ctx.options.textToPath && ctx.fontCache; + const fontFamily = cleanFontFamily(styles.fontFamily); + const fontSize = parseFloat(styles.fontSize) || 16; + const fontWeight = styles.fontWeight; + const fontStyle = styles.fontStyle; + let font = null; + if (usePathMode && ctx.fontCache?.has(fontFamily)) { + font = await ctx.fontCache.getFont(fontFamily, fontWeight, fontStyle); + } + let ascenderRatio = 0.8; + if (font && font.ascender && font.unitsPerEm) { + ascenderRatio = font.ascender / font.unitsPerEm; + } + const lines = getTextLines(textNode, rootRect, ascenderRatio, whiteSpace); + const textTransform = styles.textTransform; + const needsEllipsis = styles.textOverflow === "ellipsis" && styles.overflow !== "visible" && styles.whiteSpace === "nowrap" && parent.scrollWidth > parent.clientWidth; + for (const line of lines) { + let displayText = applyTextTransform(line.text, textTransform); + if (needsEllipsis && line === lines[lines.length - 1]) { + displayText = displayText.trimEnd() + "\u2026"; + } + if (font) { + const pathData = textToPath(font, displayText, line.x, line.y, fontSize); + if (pathData) { + const pathEl = createSvgElement(ctx.svgDocument, "path"); + setAttributes(pathEl, { + d: pathData, + fill: styles.color + }); + group.appendChild(pathEl); + } + } else { + const textEl = createSvgElement(ctx.svgDocument, "text"); + setAttributes(textEl, { + x: line.x.toFixed(2), + y: line.y.toFixed(2) + }); + applyTextStyles(textEl, styles); + textEl.textContent = displayText; + group.appendChild(textEl); + } + } + if (group.childNodes.length === 0) return null; + const textShadowValue = styles.textShadow; + if (textShadowValue && textShadowValue !== "none") { + const shadows = parseTextShadows(textShadowValue); + const filterId = createTextShadowFilter(shadows, ctx); + if (filterId) { + group.setAttribute("filter", `url(#${filterId})`); + } + } + return group; +} +function baselineY(rectTop, rectHeight, fontSize, rootTop, ascenderRatio = 0.8, parentRect) { + let effectiveTop = rectTop; + let effectiveHeight = rectHeight; + if (parentRect && rectHeight > fontSize * 2 && parentRect.height < rectHeight * 0.8) { + effectiveTop = parentRect.top; + effectiveHeight = parentRect.height; + } + const topPadding = (effectiveHeight - fontSize) / 2; + return effectiveTop - rootTop + topPadding + fontSize * ascenderRatio; +} +function getTextLines(textNode, rootRect, ascenderRatio = 0.8, whiteSpace = "normal") { + const lines = []; + const text = textNode.textContent || ""; + if (!text) return lines; + const parent = textNode.parentElement; + if (!parent) return lines; + const styles = window.getComputedStyle(parent); + const fontSize = parseFloat(styles.fontSize) || 16; + const pRect = parent.getBoundingClientRect(); + const parentRect = { top: pRect.top, height: pRect.height }; + const range = document.createRange(); + range.selectNodeContents(textNode); + const rects = range.getClientRects(); + if (rects.length === 0) return lines; + let lineRects; + if (rects.length === 1) { + const rect = rects[0]; + if (text.length > 1 && textActuallyWraps(textNode, range, text.length, fontSize)) { + lineRects = discoverLineRects(textNode, range, text.length, fontSize); + } else { + lines.push({ + text: normalizeWhitespace(text, whiteSpace), + x: rect.left - rootRect.left, + y: baselineY(rect.top, rect.height, fontSize, rootRect.top, ascenderRatio, parentRect) + }); + return lines; + } + } else { + lineRects = Array.from(rects); + } + let charStart = 0; + for (let lineIdx = 0; lineIdx < lineRects.length; lineIdx++) { + const lineRect = lineRects[lineIdx]; + const isLastLine = lineIdx === lineRects.length - 1; + let charEnd; + if (isLastLine) { + charEnd = text.length; + } else { + const currentTop = lineRect.top; + charEnd = binarySearchLineBreak(textNode, range, charStart, text.length, currentTop, fontSize); + } + const lineText = normalizeWhitespace(text.slice(charStart, charEnd), whiteSpace); + if (lineText) { + lines.push({ + text: lineText, + x: lineRect.left - rootRect.left, + y: baselineY(lineRect.top, lineRect.height, fontSize, rootRect.top, ascenderRatio, parentRect) + }); + } + charStart = charEnd; + } + return lines; +} +function binarySearchLineBreak(textNode, range, start, end, currentLineTop, fontSize) { + while (start < end) { + const mid = Math.floor((start + end) / 2); + try { + range.setStart(textNode, mid); + range.setEnd(textNode, mid + 1); + } catch { + start = mid + 1; + continue; + } + const rects = range.getClientRects(); + if (rects.length === 0) { + start = mid + 1; + continue; + } + if (Math.abs(rects[0].top - currentLineTop) > fontSize * 0.5) { + end = mid; + } else { + start = mid + 1; + } + } + return start; +} +function textActuallyWraps(textNode, range, textLength, fontSize) { + if (textLength <= 1) return false; + try { + range.setStart(textNode, 0); + range.setEnd(textNode, 1); + const firstRects = range.getClientRects(); + range.setStart(textNode, textLength - 1); + range.setEnd(textNode, textLength); + const lastRects = range.getClientRects(); + if (firstRects.length > 0 && lastRects.length > 0) { + return Math.abs(lastRects[0].top - firstRects[0].top) > fontSize * 0.5; + } + } catch { + } + return false; +} +function discoverLineRects(textNode, range, textLength, fontSize) { + const lineRects = []; + let currentLineTop = -Infinity; + for (let i = 0; i < textLength; i++) { + try { + range.setStart(textNode, i); + range.setEnd(textNode, i + 1); + const charRects = range.getClientRects(); + if (charRects.length === 0) continue; + const charRect = charRects[0]; + if (Math.abs(charRect.top - currentLineTop) > fontSize * 0.5) { + lineRects.push(charRect); + currentLineTop = charRect.top; + } + } catch { + continue; + } + } + return lineRects; +} +function normalizeWhitespace(text, whiteSpace) { + const preserves = whiteSpace === "pre" || whiteSpace === "pre-wrap" || whiteSpace === "break-spaces"; + if (preserves) return text; + if (whiteSpace === "pre-line") { + return text.replace(/[^\S\n]+/g, " "); + } + return text.replace(/\s+/g, " "); +} +function applyTextTransform(text, transform) { + switch (transform) { + case "uppercase": + return text.toUpperCase(); + case "lowercase": + return text.toLowerCase(); + case "capitalize": + return text.replace(/\b\w/g, (c) => c.toUpperCase()); + default: + return text; + } +} +function applyTextStyles(textEl, styles) { + setAttributes(textEl, { + "font-family": styles.fontFamily, + "font-size": styles.fontSize, + "font-weight": styles.fontWeight, + "font-style": styles.fontStyle, + fill: styles.color + }); + textEl.setAttribute("xml:space", "preserve"); + if (styles.letterSpacing && styles.letterSpacing !== "normal") { + textEl.setAttribute("letter-spacing", styles.letterSpacing); + } + if (styles.wordSpacing && styles.wordSpacing !== "normal") { + textEl.setAttribute("word-spacing", styles.wordSpacing); + } + if (styles.textDecoration && styles.textDecoration !== "none") { + const decs = []; + if (styles.textDecoration.includes("underline")) decs.push("underline"); + if (styles.textDecoration.includes("line-through")) decs.push("line-through"); + if (decs.length > 0) { + textEl.setAttribute("text-decoration", decs.join(" ")); + } + } +} + +// src/core/traversal.ts +async function walkElement(element, rootElement, ctx) { + const styles = window.getComputedStyle(element); + if (isInvisible(styles)) return null; + if (shouldExclude(element, ctx)) return null; + if (ctx.options.handler) { + try { + const result = ctx.options.handler(element, ctx); + if (result !== null) return result; + } catch (err) { + console.warn("dom2svg: Custom handler threw for element:", element, err); + } + } + if (isSvgElement(element) && element !== rootElement) { + const box = getRelativeBox(element, rootElement); + const clone = renderSvgElement(element, ctx); + if (element.tagName.toLowerCase() === "svg") { + clone.setAttribute("x", String(box.x)); + clone.setAttribute("y", String(box.y)); + clone.setAttribute("width", String(box.width)); + clone.setAttribute("height", String(box.height)); + if (styles.overflow === "visible") { + clone.setAttribute("overflow", "visible"); + } + } + return clone; + } + const group = await renderHtmlElement(element, rootElement, ctx); + const childTarget = getChildTarget(group); + const opacity = parseFloat(styles.opacity); + if (opacity < 1) { + group.setAttribute("opacity", String(opacity)); + } + const sortedChildren = sortChildrenByPaintOrder(element); + for (const child of sortedChildren) { + if (isTextNode(child)) { + const textSvg = await renderTextNode(child, rootElement, ctx); + if (textSvg) childTarget.appendChild(textSvg); + } else if (isElement(child)) { + const childSvg = await walkElement(child, rootElement, ctx); + if (childSvg) childTarget.appendChild(childSvg); + } + } + await renderPseudoAfter(element, rootElement, ctx, group); + return group; +} +function sortChildrenByPaintOrder(element) { + const children = Array.from(element.childNodes); + if (!children.some((c) => isElement(c))) return children; + const negativeZIndex = []; + const blocks = []; + const floats = []; + const inlinesAndText = []; + const positioned = []; + const positiveZIndex = []; + for (const child of children) { + if (isTextNode(child)) { + inlinesAndText.push(child); + continue; + } + if (!isElement(child)) continue; + const childStyles = window.getComputedStyle(child); + const z = getZIndex(childStyles); + const hasStackingCtx = createsStackingContext(childStyles); + const pos = isPositioned(childStyles); + if (hasStackingCtx && z < 0) { + negativeZIndex.push({ node: child, z }); + } else if (hasStackingCtx && z > 0) { + positiveZIndex.push({ node: child, z }); + } else if (pos || hasStackingCtx) { + positioned.push(child); + } else if (isFloat(childStyles)) { + floats.push(child); + } else if (isInlineLevel(childStyles)) { + inlinesAndText.push(child); + } else { + blocks.push(child); + } + } + negativeZIndex.sort((a, b) => a.z - b.z); + positiveZIndex.sort((a, b) => a.z - b.z); + const result = []; + for (const { node } of negativeZIndex) result.push(node); + for (const node of blocks) result.push(node); + for (const node of floats) result.push(node); + for (const node of inlinesAndText) result.push(node); + for (const node of positioned) result.push(node); + for (const { node } of positiveZIndex) result.push(node); + return result; +} +function shouldExclude(element, ctx) { + const exclude = ctx.options.exclude; + if (!exclude) return false; + if (typeof exclude === "string") { + return element.matches(exclude); + } + return exclude(element); +} + +// src/index.ts +async function domToSvg(element, options = {}) { + const padding = options.padding ?? 0; + const rect = element.getBoundingClientRect(); + const width = rect.width + padding * 2; + const height = rect.height + padding * 2; + const svgDocument = document.implementation.createDocument(SVG_NS, "svg", null); + const svg = svgDocument.documentElement; + svg.setAttribute("xmlns", SVG_NS); + svg.setAttributeNS(XMLNS_NS, "xmlns:xlink", "http://www.w3.org/1999/xlink"); + setAttributes(svg, { + width, + height, + viewBox: `${-padding} ${-padding} ${width} ${height}` + }); + const defs = createSvgElement(svgDocument, "defs"); + svg.appendChild(defs); + if (options.background) { + const bgRect = createSvgElement(svgDocument, "rect"); + setAttributes(bgRect, { + x: -padding, + y: -padding, + width, + height, + fill: options.background + }); + svg.appendChild(bgRect); + } + const ctx = { + svgDocument, + defs, + idGenerator: createIdGenerator(), + options, + opacity: 1 + }; + if (options.textToPath && options.fonts) { + ctx.fontCache = createFontCache(options.fonts); + } + const rootGroup = await walkElement(element, element, ctx); + if (rootGroup) { + const rootStyles = window.getComputedStyle(element); + const rootRadii = clampRadii(parseBorderRadii(rootStyles), rect.width, rect.height); + if (hasOverflowClip(rootStyles) && hasRadius(rootRadii)) { + const clipId = ctx.idGenerator.next("clip"); + const clipPath = createSvgElement(svgDocument, "clipPath"); + clipPath.setAttribute("id", clipId); + const clipShape = createRootClipShape(svgDocument, rect.width, rect.height, rootRadii); + clipPath.appendChild(clipShape); + defs.appendChild(clipPath); + rootGroup.setAttribute("clip-path", `url(#${clipId})`); + } + svg.appendChild(rootGroup); + } + if (defs.childNodes.length === 0) { + svg.removeChild(defs); + } + return createResult(svg); +} +function createRootClipShape(doc, width, height, radii) { + if (isUniformRadius(radii)) { + const rect = createSvgElement(doc, "rect"); + setAttributes(rect, { x: 0, y: 0, width, height, rx: radii.topLeft[0], ry: radii.topLeft[1] }); + return rect; + } + const path = createSvgElement(doc, "path"); + path.setAttribute("d", buildRoundedRectPath(0, 0, width, height, radii)); + return path; +} +function createResult(svg) { + return { + svg, + toString() { + const serializer = new XMLSerializer(); + const xmlStr = serializer.serializeToString(svg); + return ` +${xmlStr}`; + }, + toBlob() { + const str = this.toString(); + return new Blob([str], { type: "image/svg+xml;charset=utf-8" }); + }, + download(filename = "export.svg") { + const blob = this.toBlob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 6e4); + } + }; +} +export { + domToSvg +}; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/src/lib/export/dom2svg/index.js.map b/src/lib/export/dom2svg/index.js.map new file mode 100644 index 00000000..cf0f37ad --- /dev/null +++ b/src/lib/export/dom2svg/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/utils/dom.ts","../src/utils/id-generator.ts","../src/core/styles.ts","../src/utils/geometry.ts","../src/assets/gradients.ts","../src/assets/images.ts","../src/transforms/parse.ts","../src/transforms/matrix.ts","../src/transforms/svg.ts","../src/assets/filters.ts","../src/assets/box-shadow.ts","../src/assets/clip-path.ts","../src/renderers/html-element.ts","../src/renderers/svg-element.ts","../src/assets/fonts.ts","../src/assets/text-shadow.ts","../src/renderers/text-node.ts","../src/core/traversal.ts","../src/index.ts"],"sourcesContent":["export const SVG_NS = \"http://www.w3.org/2000/svg\";\r\nexport const XLINK_NS = \"http://www.w3.org/1999/xlink\";\r\nexport const XMLNS_NS = \"http://www.w3.org/2000/xmlns/\";\r\n\r\n/** Check if a node is an Element */\r\nexport function isElement(node: Node): node is Element {\r\n return node.nodeType === Node.ELEMENT_NODE;\r\n}\r\n\r\n/** Check if a node is a Text node */\r\nexport function isTextNode(node: Node): node is Text {\r\n return node.nodeType === Node.TEXT_NODE;\r\n}\r\n\r\n/** Check if an element is an SVG element */\r\nexport function isSvgElement(element: Element): element is SVGElement {\r\n return element.namespaceURI === SVG_NS;\r\n}\r\n\r\n/** Check if an element is an HTMLImageElement */\r\nexport function isImageElement(element: Element): element is HTMLImageElement {\r\n return element instanceof HTMLImageElement;\r\n}\r\n\r\n/** Check if an element is an HTMLCanvasElement */\r\nexport function isCanvasElement(element: Element): element is HTMLCanvasElement {\r\n return element instanceof HTMLCanvasElement;\r\n}\r\n\r\n/** Check if an element is a form control with a text value */\r\nexport function isFormElement(\r\n element: Element,\r\n): element is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {\r\n return (\r\n element instanceof HTMLInputElement ||\r\n element instanceof HTMLTextAreaElement ||\r\n element instanceof HTMLSelectElement\r\n );\r\n}\r\n\r\n/** Create an SVG element in the SVG namespace */\r\nexport function createSvgElement(\r\n doc: Document,\r\n tagName: K,\r\n): SVGElementTagNameMap[K];\r\nexport function createSvgElement(doc: Document, tagName: string): SVGElement;\r\nexport function createSvgElement(doc: Document, tagName: string): SVGElement {\r\n return doc.createElementNS(SVG_NS, tagName);\r\n}\r\n\r\n/** Set multiple attributes on an SVG element */\r\nexport function setAttributes(\r\n element: SVGElement,\r\n attrs: Record,\r\n): void {\r\n for (const [key, value] of Object.entries(attrs)) {\r\n element.setAttribute(key, String(value));\r\n // Also set xlink:href for SVG 1.1 compatibility (e.g. Figma, re-parsed SVG)\r\n if (key === \"href\") {\r\n element.setAttributeNS(XLINK_NS, \"xlink:href\", String(value));\r\n }\r\n }\r\n}\r\n\r\n/** Get computed style for pseudo-elements */\r\nexport function getPseudoStyles(\r\n element: Element,\r\n pseudo: \"::before\" | \"::after\",\r\n): CSSStyleDeclaration {\r\n return window.getComputedStyle(element, pseudo);\r\n}\r\n","import type { IdGenerator } from \"../types.js\";\r\n\r\n/** Global counter shared across all generators to avoid ID collisions\r\n * when multiple SVGs are embedded in the same HTML document. */\r\nlet globalCounter = 0;\r\n\r\n/** Creates an ID generator that produces unique IDs with an optional prefix */\r\nexport function createIdGenerator(): IdGenerator {\r\n return {\r\n next(prefix = \"d2s\"): string {\r\n return `${prefix}-${globalCounter++}`;\r\n },\r\n };\r\n}\r\n\r\n/** Reset the global counter (for testing only) */\r\nexport function resetIdCounter(): void {\r\n globalCounter = 0;\r\n}\r\n","import type { BorderSide, Borders, BorderRadii } from \"../types.js\";\r\n\r\n/** Check if an element's entire subtree should be skipped (display:none) */\r\nexport function isInvisible(styles: CSSStyleDeclaration): boolean {\r\n return styles.display === \"none\";\r\n}\r\n\r\n/** Check if element's own visuals are hidden (children may still be visible) */\r\nexport function isVisibilityHidden(styles: CSSStyleDeclaration): boolean {\r\n return styles.visibility === \"hidden\";\r\n}\r\n\r\n/** Parse a single border side from computed styles */\r\nfunction parseBorderSide(\r\n width: string,\r\n style: string,\r\n color: string,\r\n): BorderSide {\r\n return {\r\n width: parseFloat(width) || 0,\r\n style,\r\n color,\r\n };\r\n}\r\n\r\n/** Parse all four borders from computed styles */\r\nexport function parseBorders(styles: CSSStyleDeclaration): Borders {\r\n return {\r\n top: parseBorderSide(\r\n styles.borderTopWidth,\r\n styles.borderTopStyle,\r\n styles.borderTopColor,\r\n ),\r\n right: parseBorderSide(\r\n styles.borderRightWidth,\r\n styles.borderRightStyle,\r\n styles.borderRightColor,\r\n ),\r\n bottom: parseBorderSide(\r\n styles.borderBottomWidth,\r\n styles.borderBottomStyle,\r\n styles.borderBottomColor,\r\n ),\r\n left: parseBorderSide(\r\n styles.borderLeftWidth,\r\n styles.borderLeftStyle,\r\n styles.borderLeftColor,\r\n ),\r\n };\r\n}\r\n\r\n/** Parse border-radius into [horizontal, vertical] pairs in px */\r\nexport function parseBorderRadii(styles: CSSStyleDeclaration): BorderRadii {\r\n return {\r\n topLeft: parseRadiusPair(styles.borderTopLeftRadius),\r\n topRight: parseRadiusPair(styles.borderTopRightRadius),\r\n bottomRight: parseRadiusPair(styles.borderBottomRightRadius),\r\n bottomLeft: parseRadiusPair(styles.borderBottomLeftRadius),\r\n };\r\n}\r\n\r\nfunction parseRadiusPair(value: string): [number, number] {\r\n const parts = value.split(/\\s+/).map((v) => parseFloat(v) || 0);\r\n return [parts[0] ?? 0, parts[1] ?? parts[0] ?? 0];\r\n}\r\n\r\n/** Check if any border has a visible width */\r\nexport function hasBorder(borders: Borders): boolean {\r\n return (\r\n (borders.top.width > 0 && borders.top.style !== \"none\") ||\r\n (borders.right.width > 0 && borders.right.style !== \"none\") ||\r\n (borders.bottom.width > 0 && borders.bottom.style !== \"none\") ||\r\n (borders.left.width > 0 && borders.left.style !== \"none\")\r\n );\r\n}\r\n\r\n/** Check if any border-radius is non-zero */\r\nexport function hasRadius(radii: BorderRadii): boolean {\r\n return (\r\n radii.topLeft[0] > 0 ||\r\n radii.topLeft[1] > 0 ||\r\n radii.topRight[0] > 0 ||\r\n radii.topRight[1] > 0 ||\r\n radii.bottomRight[0] > 0 ||\r\n radii.bottomRight[1] > 0 ||\r\n radii.bottomLeft[0] > 0 ||\r\n radii.bottomLeft[1] > 0\r\n );\r\n}\r\n\r\n/** Check if all four radii corners are identical (uniform) */\r\nexport function isUniformRadius(radii: BorderRadii): boolean {\r\n const [rx, ry] = radii.topLeft;\r\n return (\r\n radii.topRight[0] === rx &&\r\n radii.topRight[1] === ry &&\r\n radii.bottomRight[0] === rx &&\r\n radii.bottomRight[1] === ry &&\r\n radii.bottomLeft[0] === rx &&\r\n radii.bottomLeft[1] === ry\r\n );\r\n}\r\n\r\n/** Check if element has overflow clipping (hidden, clip, scroll, auto all clip) */\r\nexport function hasOverflowClip(styles: CSSStyleDeclaration): boolean {\r\n const clipped = new Set([\"hidden\", \"clip\", \"scroll\", \"auto\"]);\r\n return (\r\n clipped.has(styles.overflow) ||\r\n clipped.has(styles.overflowX) ||\r\n clipped.has(styles.overflowY)\r\n );\r\n}\r\n\r\n/** Parse background-color, return null if transparent */\r\nexport function parseBackgroundColor(\r\n styles: CSSStyleDeclaration,\r\n): string | null {\r\n const bg = styles.backgroundColor;\r\n if (!bg || bg === \"transparent\" || bg === \"rgba(0, 0, 0, 0)\") return null;\r\n return bg;\r\n}\r\n\r\n/** Check if there's a background-image (gradient or url) */\r\nexport function hasBackgroundImage(styles: CSSStyleDeclaration): boolean {\r\n return !!styles.backgroundImage && styles.backgroundImage !== \"none\";\r\n}\r\n\r\n/** Parse opacity value */\r\nexport function parseOpacity(styles: CSSStyleDeclaration): number {\r\n const value = parseFloat(styles.opacity);\r\n return isNaN(value) ? 1 : value;\r\n}\r\n\r\n/** Check if element creates a new stacking context */\r\nexport function createsStackingContext(styles: CSSStyleDeclaration): boolean {\r\n // Positioned with z-index != auto\r\n if (\r\n styles.position !== \"static\" &&\r\n styles.position !== \"\" &&\r\n styles.zIndex !== \"auto\"\r\n ) {\r\n return true;\r\n }\r\n // Opacity less than 1\r\n if (parseFloat(styles.opacity) < 1) return true;\r\n // CSS transforms\r\n if (styles.transform && styles.transform !== \"none\") return true;\r\n // Filter\r\n if (styles.filter && styles.filter !== \"none\") return true;\r\n // Isolation\r\n if (styles.isolation === \"isolate\") return true;\r\n // Mix blend mode\r\n if (styles.mixBlendMode && styles.mixBlendMode !== \"normal\") return true;\r\n\r\n return false;\r\n}\r\n\r\n/** Get the z-index as a number (0 for auto) */\r\nexport function getZIndex(styles: CSSStyleDeclaration): number {\r\n if (styles.zIndex === \"auto\" || !styles.zIndex) return 0;\r\n return parseInt(styles.zIndex, 10) || 0;\r\n}\r\n\r\n/** Check if element is positioned */\r\nexport function isPositioned(styles: CSSStyleDeclaration): boolean {\r\n return styles.position !== \"static\" && styles.position !== \"\";\r\n}\r\n\r\n/** Check if element is a float */\r\nexport function isFloat(styles: CSSStyleDeclaration): boolean {\r\n return styles.cssFloat !== \"none\" && styles.cssFloat !== \"\";\r\n}\r\n\r\n/**\r\n * Clamp border-radii to fit the box, following the CSS spec algorithm:\r\n * compute the ratio for each side, use the minimum to scale all radii.\r\n */\r\nexport function clampRadii(radii: BorderRadii, width: number, height: number): BorderRadii {\r\n // Horizontal sums (top and bottom edges)\r\n const topH = radii.topLeft[0] + radii.topRight[0];\r\n const bottomH = radii.bottomLeft[0] + radii.bottomRight[0];\r\n // Vertical sums (left and right edges)\r\n const leftV = radii.topLeft[1] + radii.bottomLeft[1];\r\n const rightV = radii.topRight[1] + radii.bottomRight[1];\r\n\r\n let f = 1;\r\n if (topH > 0) f = Math.min(f, width / topH);\r\n if (bottomH > 0) f = Math.min(f, width / bottomH);\r\n if (leftV > 0) f = Math.min(f, height / leftV);\r\n if (rightV > 0) f = Math.min(f, height / rightV);\r\n\r\n if (f >= 1) return radii;\r\n\r\n return {\r\n topLeft: [radii.topLeft[0] * f, radii.topLeft[1] * f],\r\n topRight: [radii.topRight[0] * f, radii.topRight[1] * f],\r\n bottomRight: [radii.bottomRight[0] * f, radii.bottomRight[1] * f],\r\n bottomLeft: [radii.bottomLeft[0] * f, radii.bottomLeft[1] * f],\r\n };\r\n}\r\n\r\n/** Check if element is inline-level */\r\nexport function isInlineLevel(styles: CSSStyleDeclaration): boolean {\r\n const d = styles.display;\r\n return (\r\n d === \"inline\" ||\r\n d === \"inline-block\" ||\r\n d === \"inline-flex\" ||\r\n d === \"inline-grid\" ||\r\n d === \"inline-table\"\r\n );\r\n}\r\n","import type { BoxGeometry, BorderRadii } from \"../types.js\";\r\n\r\n/** Get an element's bounding box relative to a root element */\r\nexport function getRelativeBox(element: Element, root: Element): BoxGeometry {\r\n const elRect = element.getBoundingClientRect();\r\n const rootRect = root.getBoundingClientRect();\r\n return {\r\n x: elRect.left - rootRect.left,\r\n y: elRect.top - rootRect.top,\r\n width: elRect.width,\r\n height: elRect.height,\r\n };\r\n}\r\n\r\n/** Build an SVG path d-attribute for a rounded rectangle with non-uniform radii */\r\nexport function buildRoundedRectPath(\r\n x: number, y: number, width: number, height: number,\r\n radii: BorderRadii,\r\n): string {\r\n const [tlx, tly] = radii.topLeft;\r\n const [trx, try_] = radii.topRight;\r\n const [brx, bry] = radii.bottomRight;\r\n const [blx, bly] = radii.bottomLeft;\r\n\r\n return [\r\n `M ${x + tlx} ${y}`,\r\n `L ${x + width - trx} ${y}`,\r\n trx || try_ ? `A ${trx} ${try_} 0 0 1 ${x + width} ${y + try_}` : \"\",\r\n `L ${x + width} ${y + height - bry}`,\r\n brx || bry ? `A ${brx} ${bry} 0 0 1 ${x + width - brx} ${y + height}` : \"\",\r\n `L ${x + blx} ${y + height}`,\r\n blx || bly ? `A ${blx} ${bly} 0 0 1 ${x} ${y + height - bly}` : \"\",\r\n `L ${x} ${y + tly}`,\r\n tlx || tly ? `A ${tlx} ${tly} 0 0 1 ${x + tlx} ${y}` : \"\",\r\n \"Z\",\r\n ].filter(Boolean).join(\" \");\r\n}\r\n","import type { LinearGradient, GradientStop, RenderContext, BoxGeometry } from \"../types.js\";\r\nimport { createSvgElement, setAttributes } from \"../utils/dom.js\";\r\n\r\n/** Parse a CSS linear-gradient() into our LinearGradient structure */\r\nexport function parseLinearGradient(value: string): LinearGradient | null {\r\n // Match linear-gradient(...) - handle both prefix and standard\r\n const match = value.match(/linear-gradient\\((.+)\\)/);\r\n if (!match) return null;\r\n\r\n const body = match[1]!;\r\n const parts = splitGradientArgs(body);\r\n if (parts.length < 2) return null;\r\n\r\n let angle = 180; // default: to bottom\r\n let stopsStart = 0;\r\n\r\n // Check if first part is a direction\r\n const first = parts[0]!.trim();\r\n if (first.startsWith(\"to \")) {\r\n angle = directionToAngle(first);\r\n stopsStart = 1;\r\n } else if (first.match(/^-?[\\d.]+(?:deg|rad|turn|grad)/)) {\r\n angle = parseAngle(first);\r\n stopsStart = 1;\r\n }\r\n\r\n const stops: GradientStop[] = [];\r\n const rawStops = parts.slice(stopsStart);\r\n\r\n for (let i = 0; i < rawStops.length; i++) {\r\n const { color, position } = parseColorStop(rawStops[i]!.trim(), i, rawStops.length);\r\n stops.push({ color, position });\r\n }\r\n\r\n return { angle, stops };\r\n}\r\n\r\n/** Convert a linear-gradient to an SVG element */\r\nexport function createSvgLinearGradient(\r\n gradient: LinearGradient,\r\n box: BoxGeometry,\r\n ctx: RenderContext,\r\n): SVGLinearGradientElement {\r\n const id = ctx.idGenerator.next(\"grad\");\r\n const el = createSvgElement(\r\n ctx.svgDocument,\r\n \"linearGradient\",\r\n ) as SVGLinearGradientElement;\r\n\r\n // Use userSpaceOnUse with pixel coordinates for correct diagonal angles\r\n // on non-square elements (objectBoundingBox distorts the angle).\r\n const cx = box.x + box.width / 2;\r\n const cy = box.y + box.height / 2;\r\n const angleRad = (gradient.angle * Math.PI) / 180;\r\n // CSS angle: 0deg = to top (↑), 90deg = to right (→)\r\n const dx = Math.sin(angleRad);\r\n const dy = -Math.cos(angleRad);\r\n // Gradient line half-length per CSS spec: extends to the perpendicular\r\n // from the farthest corner.\r\n const halfLen = Math.abs(box.width / 2 * dx) + Math.abs(box.height / 2 * dy);\r\n const x1 = cx - dx * halfLen;\r\n const y1 = cy - dy * halfLen;\r\n const x2 = cx + dx * halfLen;\r\n const y2 = cy + dy * halfLen;\r\n\r\n setAttributes(el, {\r\n id,\r\n gradientUnits: \"userSpaceOnUse\",\r\n x1: x1.toFixed(2),\r\n y1: y1.toFixed(2),\r\n x2: x2.toFixed(2),\r\n y2: y2.toFixed(2),\r\n });\r\n\r\n for (const stop of gradient.stops) {\r\n const stopEl = createSvgElement(ctx.svgDocument, \"stop\");\r\n setAttributes(stopEl, {\r\n offset: `${(stop.position * 100).toFixed(1)}%`,\r\n \"stop-color\": stop.color,\r\n });\r\n el.appendChild(stopEl);\r\n }\r\n\r\n ctx.defs.appendChild(el);\r\n return el;\r\n}\r\n\r\n/**\r\n * Rasterize a conic-gradient (or radial-gradient) to a data URL\r\n * using the Canvas 2D API. Returns null if the gradient type is\r\n * not supported or the Canvas API is unavailable.\r\n */\r\nexport function rasterizeGradient(\r\n value: string,\r\n width: number,\r\n height: number,\r\n): string | null {\r\n if (value.includes(\"conic-gradient\")) {\r\n return rasterizeConicGradient(value, width, height);\r\n }\r\n if (value.includes(\"radial-gradient\")) {\r\n return rasterizeRadialGradient(value, width, height);\r\n }\r\n return null;\r\n}\r\n\r\nfunction rasterizeConicGradient(\r\n value: string,\r\n width: number,\r\n height: number,\r\n): string | null {\r\n const match = value.match(/conic-gradient\\((.+)\\)/);\r\n if (!match) return null;\r\n\r\n const scale = 2;\r\n const canvas = document.createElement(\"canvas\");\r\n canvas.width = Math.ceil(width * scale);\r\n canvas.height = Math.ceil(height * scale);\r\n const ctx = canvas.getContext(\"2d\");\r\n if (!ctx || !(\"createConicGradient\" in ctx)) return null;\r\n\r\n ctx.scale(scale, scale);\r\n\r\n const body = match[1]!;\r\n const parts = splitGradientArgs(body);\r\n\r\n let startDeg = 0;\r\n let stopsStart = 0;\r\n\r\n // Parse \"from \" prefix\r\n const first = parts[0]!.trim();\r\n const fromMatch = first.match(/^from\\s+(-?[\\d.]+)(deg|rad|turn|grad)/);\r\n if (fromMatch) {\r\n startDeg = parseAngle(fromMatch[1]! + fromMatch[2]!);\r\n stopsStart = 1;\r\n }\r\n\r\n const cx = width / 2;\r\n const cy = height / 2;\r\n\r\n // CSS 0deg = top (12 o'clock), Canvas 0rad = right (3 o'clock)\r\n const startRad = ((startDeg - 90) * Math.PI) / 180;\r\n const gradient = ctx.createConicGradient(startRad, cx, cy);\r\n\r\n const rawStops = parts.slice(stopsStart);\r\n for (let i = 0; i < rawStops.length; i++) {\r\n const stop = rawStops[i]!.trim();\r\n const { color, position } = parseColorStop(stop, i, rawStops.length);\r\n try {\r\n gradient.addColorStop(position, color);\r\n } catch {\r\n // Invalid color — skip\r\n }\r\n }\r\n\r\n ctx.fillStyle = gradient;\r\n ctx.fillRect(0, 0, width, height);\r\n return canvas.toDataURL(\"image/png\");\r\n}\r\n\r\nfunction rasterizeRadialGradient(\r\n value: string,\r\n width: number,\r\n height: number,\r\n): string | null {\r\n const match = value.match(/radial-gradient\\((.+)\\)/);\r\n if (!match) return null;\r\n\r\n const scale = 2;\r\n const canvas = document.createElement(\"canvas\");\r\n canvas.width = Math.ceil(width * scale);\r\n canvas.height = Math.ceil(height * scale);\r\n const ctx = canvas.getContext(\"2d\");\r\n if (!ctx) return null;\r\n\r\n ctx.scale(scale, scale);\r\n\r\n const body = match[1]!;\r\n const parts = splitGradientArgs(body);\r\n\r\n let isCircle = false;\r\n let stopsStart = 0;\r\n let customCx: number | null = null;\r\n let customCy: number | null = null;\r\n\r\n // Check if the first part is a shape/size descriptor\r\n const first = parts[0]!.trim();\r\n if (first === \"circle\" || first.startsWith(\"circle \")) {\r\n isCircle = true;\r\n stopsStart = 1;\r\n } else if (first === \"ellipse\" || first.startsWith(\"ellipse \")) {\r\n stopsStart = 1;\r\n } else if (first.includes(\"at \") && !first.includes(\"#\") && !first.match(/^(rgb|hsl)/)) {\r\n stopsStart = 1;\r\n }\r\n\r\n // Parse \"at cx cy\" position from shape descriptor\r\n if (stopsStart === 1) {\r\n const atMatch = first.match(/at\\s+(.+)/);\r\n if (atMatch) {\r\n const posParts = atMatch[1]!.trim().split(/\\s+/);\r\n customCx = parseLengthOrPercent(posParts[0]!, width);\r\n customCy = parseLengthOrPercent(posParts[1] ?? posParts[0]!, height);\r\n }\r\n }\r\n\r\n const cx = customCx ?? width / 2;\r\n const cy = customCy ?? height / 2;\r\n\r\n // Use transform to create an elliptical gradient\r\n const rx = width / 2;\r\n const ry = height / 2;\r\n // CSS default: farthest-corner. For a circle, that's the distance to the corner.\r\n const radius = isCircle ? Math.sqrt(rx * rx + ry * ry) : Math.max(rx, ry);\r\n\r\n ctx.save();\r\n if (!isCircle && rx !== ry) {\r\n ctx.translate(cx, cy);\r\n ctx.scale(rx / radius, ry / radius);\r\n ctx.translate(-cx, -cy);\r\n }\r\n\r\n const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);\r\n\r\n const rawStops = parts.slice(stopsStart);\r\n for (let i = 0; i < rawStops.length; i++) {\r\n const stop = rawStops[i]!.trim();\r\n const { color, position } = parseColorStop(stop, i, rawStops.length);\r\n try {\r\n gradient.addColorStop(position, color);\r\n } catch {\r\n // Invalid color — skip\r\n }\r\n }\r\n\r\n ctx.fillStyle = gradient;\r\n // When the elliptical transform compresses one axis, the fillRect must\r\n // be expanded in the transformed space to cover the full canvas.\r\n if (!isCircle && rx !== ry) {\r\n const sx = radius / rx;\r\n const sy = radius / ry;\r\n ctx.fillRect(cx * (1 - sx), cy * (1 - sy), width * sx, height * sy);\r\n } else {\r\n ctx.fillRect(0, 0, width, height);\r\n }\r\n ctx.restore();\r\n\r\n return canvas.toDataURL(\"image/png\");\r\n}\r\n\r\n/**\r\n * Parse a color stop like \"red 50%\" into color and position.\r\n * Handles modern CSS color syntax with spaces (e.g. \"hsl(120deg 50% 50%) 75%\")\r\n * by only looking for a position % after the last closing parenthesis.\r\n */\r\nfunction parseColorStop(\r\n stop: string,\r\n index: number,\r\n total: number,\r\n): { color: string; position: number } {\r\n // Look for a trailing percentage after any function parens\r\n const lastParen = stop.lastIndexOf(\")\");\r\n const tail = lastParen >= 0 ? stop.slice(lastParen + 1) : stop;\r\n const posMatch = tail.match(/\\s+([\\d.]+%)\\s*$/);\r\n if (posMatch) {\r\n const posStr = posMatch[1]!;\r\n const colorEnd = stop.length - posMatch[0].length;\r\n return {\r\n color: stop.slice(0, colorEnd).trim(),\r\n position: parseFloat(posStr) / 100,\r\n };\r\n }\r\n // No parens: try simple \"color position\" format (e.g. \"red 50%\")\r\n if (lastParen < 0) {\r\n const spaceIdx = stop.lastIndexOf(\" \");\r\n if (spaceIdx > 0 && stop.slice(spaceIdx).match(/[\\d.]+%/)) {\r\n return {\r\n color: stop.slice(0, spaceIdx).trim(),\r\n position: parseFloat(stop.slice(spaceIdx)) / 100,\r\n };\r\n }\r\n }\r\n return {\r\n color: stop,\r\n position: total > 1 ? index / (total - 1) : 0,\r\n };\r\n}\r\n\r\n/** Split gradient arguments respecting nested parentheses */\r\nfunction splitGradientArgs(str: string): string[] {\r\n const parts: string[] = [];\r\n let depth = 0;\r\n let current = \"\";\r\n\r\n for (const char of str) {\r\n if (char === \"(\") depth++;\r\n else if (char === \")\") depth--;\r\n\r\n if (char === \",\" && depth === 0) {\r\n parts.push(current);\r\n current = \"\";\r\n } else {\r\n current += char;\r\n }\r\n }\r\n if (current) parts.push(current);\r\n return parts;\r\n}\r\n\r\nfunction directionToAngle(dir: string): number {\r\n const map: Record = {\r\n \"to top\": 0,\r\n \"to right\": 90,\r\n \"to bottom\": 180,\r\n \"to left\": 270,\r\n \"to top right\": 45,\r\n \"to top left\": 315,\r\n \"to bottom right\": 135,\r\n \"to bottom left\": 225,\r\n };\r\n return map[dir] ?? 180;\r\n}\r\n\r\nfunction parseAngle(value: string): number {\r\n if (value.endsWith(\"deg\")) return parseFloat(value);\r\n if (value.endsWith(\"rad\")) return (parseFloat(value) * 180) / Math.PI;\r\n if (value.endsWith(\"turn\")) return parseFloat(value) * 360;\r\n if (value.endsWith(\"grad\")) return parseFloat(value) * 0.9;\r\n return parseFloat(value);\r\n}\r\n\r\n/** Parse a CSS length (px) or percentage relative to a container dimension */\r\nfunction parseLengthOrPercent(value: string, containerSize: number): number | null {\r\n if (value === \"center\") return containerSize / 2;\r\n if (value === \"left\" || value === \"top\") return 0;\r\n if (value === \"right\" || value === \"bottom\") return containerSize;\r\n if (value.endsWith(\"%\")) return (parseFloat(value) / 100) * containerSize;\r\n const num = parseFloat(value);\r\n return isNaN(num) ? null : num;\r\n}\r\n","const IMAGE_TIMEOUT_MS = 10_000;\r\nconst MAX_CANVAS_DIM = 4096;\r\n\r\n/**\r\n * Convert an image URL to a data URL by drawing it onto a canvas.\r\n * Falls back to the original URL if CORS prevents reading or loading times out.\r\n */\r\nexport async function imageToDataUrl(url: string): Promise {\r\n // Already a data URL\r\n if (url.startsWith(\"data:\")) return url;\r\n\r\n return new Promise((resolve) => {\r\n const img = new Image();\r\n img.crossOrigin = \"anonymous\";\r\n\r\n const timer = setTimeout(() => {\r\n console.warn(`dom2svg: Image load timed out after ${IMAGE_TIMEOUT_MS}ms, using original URL: ${url}`);\r\n img.onload = null;\r\n img.onerror = null;\r\n resolve(url);\r\n }, IMAGE_TIMEOUT_MS);\r\n\r\n img.onload = () => {\r\n clearTimeout(timer);\r\n try {\r\n const canvas = document.createElement(\"canvas\");\r\n // Cap dimensions to prevent OOM on very large images\r\n let w = img.naturalWidth;\r\n let h = img.naturalHeight;\r\n if (w > MAX_CANVAS_DIM || h > MAX_CANVAS_DIM) {\r\n const scale = MAX_CANVAS_DIM / Math.max(w, h);\r\n w = Math.round(w * scale);\r\n h = Math.round(h * scale);\r\n }\r\n canvas.width = w;\r\n canvas.height = h;\r\n const ctx = canvas.getContext(\"2d\");\r\n if (ctx) {\r\n ctx.drawImage(img, 0, 0, w, h);\r\n resolve(canvas.toDataURL(\"image/png\"));\r\n } else {\r\n resolve(url);\r\n }\r\n } catch {\r\n console.warn(`dom2svg: CORS prevented inlining image, external URL will remain in SVG: ${url}`);\r\n resolve(url);\r\n }\r\n };\r\n img.onerror = () => {\r\n clearTimeout(timer);\r\n console.warn(`dom2svg: Failed to load image, external URL will remain in SVG: ${url}`);\r\n resolve(url);\r\n };\r\n img.src = url;\r\n });\r\n}\r\n\r\n/** Extract URL from css url() value */\r\nexport function extractUrlFromCss(value: string): string | null {\r\n const match = value.match(/url\\([\"']?([^\"')]+)[\"']?\\)/);\r\n return match?.[1] ?? null;\r\n}\r\n\r\n/** Convert a canvas element to a data URL */\r\nexport function canvasToDataUrl(canvas: HTMLCanvasElement): string {\r\n try {\r\n return canvas.toDataURL(\"image/png\");\r\n } catch {\r\n return \"\";\r\n }\r\n}\r\n","import type { TransformFunction } from \"../types.js\";\r\n\r\n/**\r\n * Parse a CSS transform string into a list of transform functions.\r\n * Supports: matrix, translate, translateX, translateY, scale, scaleX, scaleY,\r\n * rotate, skewX, skewY.\r\n */\r\nexport function parseTransform(value: string): TransformFunction[] {\r\n if (!value || value === \"none\") return [];\r\n\r\n const functions: TransformFunction[] = [];\r\n const regex = /(\\w+)\\(([^)]+)\\)/g;\r\n let match: RegExpExecArray | null;\r\n\r\n while ((match = regex.exec(value)) !== null) {\r\n const name = match[1]!;\r\n const args = match[2]!.split(\",\").map((s) => s.trim());\r\n\r\n switch (name) {\r\n case \"matrix\": {\r\n const vals = args.map(parseFloat);\r\n if (vals.length === 6) {\r\n functions.push({\r\n type: \"matrix\",\r\n values: vals as [number, number, number, number, number, number],\r\n });\r\n }\r\n break;\r\n }\r\n case \"translate\": {\r\n const x = parseLengthValue(args[0]!);\r\n const y = args[1] ? parseLengthValue(args[1]) : 0;\r\n functions.push({ type: \"translate\", x, y });\r\n break;\r\n }\r\n case \"translateX\": {\r\n functions.push({ type: \"translate\", x: parseLengthValue(args[0]!), y: 0 });\r\n break;\r\n }\r\n case \"translateY\": {\r\n functions.push({ type: \"translate\", x: 0, y: parseLengthValue(args[0]!) });\r\n break;\r\n }\r\n case \"scale\": {\r\n const sx = parseFloat(args[0]!);\r\n const sy = args[1] ? parseFloat(args[1]) : sx;\r\n functions.push({ type: \"scale\", x: sx, y: sy });\r\n break;\r\n }\r\n case \"scaleX\": {\r\n functions.push({ type: \"scale\", x: parseFloat(args[0]!), y: 1 });\r\n break;\r\n }\r\n case \"scaleY\": {\r\n functions.push({ type: \"scale\", x: 1, y: parseFloat(args[0]!) });\r\n break;\r\n }\r\n case \"rotate\": {\r\n functions.push({ type: \"rotate\", angle: parseAngleValue(args[0]!) });\r\n break;\r\n }\r\n case \"skewX\": {\r\n functions.push({ type: \"skewX\", angle: parseAngleValue(args[0]!) });\r\n break;\r\n }\r\n case \"skewY\": {\r\n functions.push({ type: \"skewY\", angle: parseAngleValue(args[0]!) });\r\n break;\r\n }\r\n }\r\n }\r\n\r\n return functions;\r\n}\r\n\r\nfunction parseLengthValue(value: string): number {\r\n return parseFloat(value) || 0;\r\n}\r\n\r\nfunction parseAngleValue(value: string): number {\r\n value = value.trim();\r\n if (value.endsWith(\"rad\")) return (parseFloat(value) * 180) / Math.PI;\r\n if (value.endsWith(\"turn\")) return parseFloat(value) * 360;\r\n if (value.endsWith(\"grad\")) return parseFloat(value) * 0.9;\r\n // Default: degrees\r\n return parseFloat(value) || 0;\r\n}\r\n","import type { MatrixTuple } from \"../types.js\";\r\n\r\n/**\r\n * 2D affine transform matrix operations.\r\n * Matrix layout: [a, b, c, d, e, f]\r\n *\r\n * | a c e |\r\n * | b d f |\r\n * | 0 0 1 |\r\n */\r\n\r\n/** Identity matrix */\r\nexport function identity(): MatrixTuple {\r\n return [1, 0, 0, 1, 0, 0];\r\n}\r\n\r\n/** Multiply two matrices: A * B */\r\nexport function multiply(a: MatrixTuple, b: MatrixTuple): MatrixTuple {\r\n return [\r\n a[0] * b[0] + a[2] * b[1],\r\n a[1] * b[0] + a[3] * b[1],\r\n a[0] * b[2] + a[2] * b[3],\r\n a[1] * b[2] + a[3] * b[3],\r\n a[0] * b[4] + a[2] * b[5] + a[4],\r\n a[1] * b[4] + a[3] * b[5] + a[5],\r\n ];\r\n}\r\n\r\n/** Create a translation matrix */\r\nexport function translate(tx: number, ty: number): MatrixTuple {\r\n return [1, 0, 0, 1, tx, ty];\r\n}\r\n\r\n/** Create a scale matrix */\r\nexport function scale(sx: number, sy: number): MatrixTuple {\r\n return [sx, 0, 0, sy, 0, 0];\r\n}\r\n\r\n/** Create a rotation matrix (angle in degrees) */\r\nexport function rotate(angleDeg: number): MatrixTuple {\r\n const rad = (angleDeg * Math.PI) / 180;\r\n const cos = Math.cos(rad);\r\n const sin = Math.sin(rad);\r\n return [cos, sin, -sin, cos, 0, 0];\r\n}\r\n\r\n/** Create a skewX matrix (angle in degrees) */\r\nexport function skewX(angleDeg: number): MatrixTuple {\r\n const rad = (angleDeg * Math.PI) / 180;\r\n return [1, 0, Math.tan(rad), 1, 0, 0];\r\n}\r\n\r\n/** Create a skewY matrix (angle in degrees) */\r\nexport function skewY(angleDeg: number): MatrixTuple {\r\n const rad = (angleDeg * Math.PI) / 180;\r\n return [1, Math.tan(rad), 0, 1, 0, 0];\r\n}\r\n\r\n/** Compute the inverse of a matrix. Returns null if singular. */\r\nexport function inverse(m: MatrixTuple): MatrixTuple | null {\r\n const det = m[0] * m[3] - m[1] * m[2];\r\n if (Math.abs(det) < 1e-10) return null;\r\n\r\n const invDet = 1 / det;\r\n return [\r\n m[3] * invDet,\r\n -m[1] * invDet,\r\n -m[2] * invDet,\r\n m[0] * invDet,\r\n (m[2] * m[5] - m[3] * m[4]) * invDet,\r\n (m[1] * m[4] - m[0] * m[5]) * invDet,\r\n ];\r\n}\r\n\r\n/** Check if a matrix is the identity matrix */\r\nexport function isIdentity(m: MatrixTuple): boolean {\r\n return (\r\n Math.abs(m[0] - 1) < 1e-10 &&\r\n Math.abs(m[1]) < 1e-10 &&\r\n Math.abs(m[2]) < 1e-10 &&\r\n Math.abs(m[3] - 1) < 1e-10 &&\r\n Math.abs(m[4]) < 1e-10 &&\r\n Math.abs(m[5]) < 1e-10\r\n );\r\n}\r\n\r\n/** Format matrix as SVG transform attribute value */\r\nexport function toSvgTransform(m: MatrixTuple): string {\r\n return `matrix(${m.map((v) => v.toFixed(6)).join(\",\")})`;\r\n}\r\n","import type { TransformFunction, MatrixTuple } from \"../types.js\";\r\nimport { parseTransform } from \"./parse.js\";\r\nimport * as mat from \"./matrix.js\";\r\n\r\n/**\r\n * Convert a CSS transform string to an SVG transform attribute value.\r\n * Returns null if no transform or identity transform.\r\n */\r\nexport function cssTransformToSvg(\r\n cssTransform: string,\r\n transformOrigin: string,\r\n box: { x: number; y: number; width: number; height: number },\r\n): string | null {\r\n const functions = parseTransform(cssTransform);\r\n if (functions.length === 0) return null;\r\n\r\n // Parse transform-origin\r\n const [ox, oy] = parseTransformOrigin(transformOrigin, box);\r\n\r\n // Build the combined matrix\r\n let result = mat.identity();\r\n\r\n // Move origin\r\n result = mat.multiply(result, mat.translate(ox, oy));\r\n\r\n // Apply each transform function\r\n for (const fn of functions) {\r\n result = mat.multiply(result, transformFunctionToMatrix(fn));\r\n }\r\n\r\n // Move origin back\r\n result = mat.multiply(result, mat.translate(-ox, -oy));\r\n\r\n if (mat.isIdentity(result)) return null;\r\n\r\n return mat.toSvgTransform(result);\r\n}\r\n\r\n/** Convert a single TransformFunction to a matrix */\r\nfunction transformFunctionToMatrix(fn: TransformFunction): MatrixTuple {\r\n switch (fn.type) {\r\n case \"matrix\":\r\n return fn.values;\r\n case \"translate\":\r\n return mat.translate(fn.x, fn.y);\r\n case \"scale\":\r\n return mat.scale(fn.x, fn.y);\r\n case \"rotate\":\r\n return mat.rotate(fn.angle);\r\n case \"skewX\":\r\n return mat.skewX(fn.angle);\r\n case \"skewY\":\r\n return mat.skewY(fn.angle);\r\n }\r\n}\r\n\r\n/** Parse CSS transform-origin into absolute coordinates */\r\nfunction parseTransformOrigin(\r\n origin: string,\r\n box: { x: number; y: number; width: number; height: number },\r\n): [number, number] {\r\n const parts = origin.split(/\\s+/);\r\n const x = parseOriginValue(parts[0] ?? \"50%\", box.width, box.x);\r\n const y = parseOriginValue(parts[1] ?? \"50%\", box.height, box.y);\r\n return [x, y];\r\n}\r\n\r\nfunction parseOriginValue(\r\n value: string,\r\n size: number,\r\n offset: number,\r\n): number {\r\n if (value === \"left\" || value === \"top\") return offset;\r\n if (value === \"right\" || value === \"bottom\") return offset + size;\r\n if (value === \"center\") return offset + size / 2;\r\n if (value.endsWith(\"%\")) {\r\n return offset + (parseFloat(value) / 100) * size;\r\n }\r\n return offset + parseFloat(value);\r\n}\r\n","import type { RenderContext } from \"../types.js\";\r\nimport { createSvgElement, setAttributes } from \"../utils/dom.js\";\r\n\r\n/** A parsed CSS filter function */\r\ninterface CssFilterFunction {\r\n name: string;\r\n args: string;\r\n}\r\n\r\n/**\r\n * Parse a CSS filter value and create an SVG with the equivalent primitives.\r\n * Supports: blur, brightness, contrast, drop-shadow, grayscale, hue-rotate,\r\n * invert, opacity, saturate, sepia.\r\n * Returns the filter ID, or null if no recognized filter functions found.\r\n */\r\nexport function createSvgFilter(\r\n filterValue: string,\r\n ctx: RenderContext,\r\n): string | null {\r\n const functions = parseCssFilterFunctions(filterValue);\r\n if (functions.length === 0) return null;\r\n\r\n const id = ctx.idGenerator.next(\"filter\");\r\n const filter = createSvgElement(ctx.svgDocument, \"filter\");\r\n setAttributes(filter, {\r\n id,\r\n x: \"-50%\",\r\n y: \"-50%\",\r\n width: \"200%\",\r\n height: \"200%\",\r\n });\r\n\r\n let hasAny = false;\r\n\r\n for (const fn of functions) {\r\n const primitives = createFilterPrimitives(fn, ctx);\r\n for (const prim of primitives) {\r\n filter.appendChild(prim);\r\n hasAny = true;\r\n }\r\n }\r\n\r\n if (!hasAny) return null;\r\n\r\n ctx.defs.appendChild(filter);\r\n return id;\r\n}\r\n\r\n/** Parse a numeric value that may have a % suffix. Returns a ratio (1 = 100%). */\r\nfunction parseFilterAmount(raw: string): number {\r\n const trimmed = raw.trim();\r\n if (trimmed.endsWith(\"%\")) {\r\n return (parseFloat(trimmed) || 0) / 100;\r\n }\r\n return parseFloat(trimmed) || 0;\r\n}\r\n\r\n/** Parse an angle value, returning degrees. Handles deg, rad, grad, turn. */\r\nfunction parseAngle(raw: string): number {\r\n const trimmed = raw.trim();\r\n if (trimmed.endsWith(\"rad\")) return (parseFloat(trimmed) || 0) * (180 / Math.PI);\r\n if (trimmed.endsWith(\"grad\")) return (parseFloat(trimmed) || 0) * 0.9;\r\n if (trimmed.endsWith(\"turn\")) return (parseFloat(trimmed) || 0) * 360;\r\n // deg or bare number\r\n return parseFloat(trimmed) || 0;\r\n}\r\n\r\n/** Create SVG filter primitive(s) for a single CSS filter function */\r\nfunction createFilterPrimitives(\r\n fn: CssFilterFunction,\r\n ctx: RenderContext,\r\n): SVGElement[] {\r\n switch (fn.name) {\r\n case \"blur\": {\r\n // CSS blur() value IS the stdDeviation directly\r\n const radius = parseFloat(fn.args) || 0;\r\n const blur = createSvgElement(ctx.svgDocument, \"feGaussianBlur\");\r\n setAttributes(blur, { stdDeviation: radius });\r\n return [blur];\r\n }\r\n\r\n case \"brightness\": {\r\n const amount = parseFilterAmount(fn.args);\r\n return [createComponentTransfer(ctx, { slope: amount })];\r\n }\r\n\r\n case \"contrast\": {\r\n const amount = parseFilterAmount(fn.args);\r\n const intercept = 0.5 - 0.5 * amount;\r\n return [createComponentTransfer(ctx, { slope: amount, intercept })];\r\n }\r\n\r\n case \"drop-shadow\": {\r\n const parsed = parseDropShadow(`drop-shadow(${fn.args})`);\r\n if (!parsed) return [];\r\n const shadow = createSvgElement(ctx.svgDocument, \"feDropShadow\");\r\n setAttributes(shadow, {\r\n dx: parsed.offsetX,\r\n dy: parsed.offsetY,\r\n stdDeviation: parsed.blur / 2,\r\n \"flood-color\": parsed.color,\r\n \"flood-opacity\": 1,\r\n });\r\n return [shadow];\r\n }\r\n\r\n case \"grayscale\": {\r\n const amount = parseFilterAmount(fn.args);\r\n const s = Math.max(0, Math.min(1, 1 - amount));\r\n const matrix = createSvgElement(ctx.svgDocument, \"feColorMatrix\");\r\n setAttributes(matrix, { type: \"saturate\", values: s });\r\n return [matrix];\r\n }\r\n\r\n case \"hue-rotate\": {\r\n const degrees = parseAngle(fn.args);\r\n const matrix = createSvgElement(ctx.svgDocument, \"feColorMatrix\");\r\n setAttributes(matrix, { type: \"hueRotate\", values: degrees });\r\n return [matrix];\r\n }\r\n\r\n case \"invert\": {\r\n const amount = parseFilterAmount(fn.args);\r\n const lo = amount;\r\n const hi = 1 - amount;\r\n return [createComponentTransfer(ctx, {\r\n type: \"table\",\r\n tableValues: `${lo} ${hi}`,\r\n })];\r\n }\r\n\r\n case \"opacity\": {\r\n const amount = parseFilterAmount(fn.args);\r\n const transfer = createSvgElement(ctx.svgDocument, \"feComponentTransfer\");\r\n const funcA = createSvgElement(ctx.svgDocument, \"feFuncA\");\r\n setAttributes(funcA, { type: \"linear\", slope: amount, intercept: 0 });\r\n transfer.appendChild(funcA);\r\n return [transfer];\r\n }\r\n\r\n case \"saturate\": {\r\n const amount = parseFilterAmount(fn.args);\r\n const matrix = createSvgElement(ctx.svgDocument, \"feColorMatrix\");\r\n setAttributes(matrix, { type: \"saturate\", values: amount });\r\n return [matrix];\r\n }\r\n\r\n case \"sepia\": {\r\n const amount = Math.max(0, Math.min(1, parseFilterAmount(fn.args)));\r\n // Interpolate between identity matrix and sepia matrix\r\n const a = amount;\r\n const b = 1 - amount;\r\n const values = [\r\n b + a * 0.393, a * 0.769, a * 0.189, 0, 0,\r\n a * 0.349, b + a * 0.686, a * 0.168, 0, 0,\r\n a * 0.272, a * 0.534, b + a * 0.131, 0, 0,\r\n 0, 0, 0, 1, 0,\r\n ].map(v => v.toFixed(4)).join(\" \");\r\n const matrix = createSvgElement(ctx.svgDocument, \"feColorMatrix\");\r\n setAttributes(matrix, { type: \"matrix\", values });\r\n return [matrix];\r\n }\r\n\r\n default:\r\n return [];\r\n }\r\n}\r\n\r\n/** Create an feComponentTransfer for RGB channels with uniform settings */\r\nfunction createComponentTransfer(\r\n ctx: RenderContext,\r\n opts: { slope?: number; intercept?: number; type?: string; tableValues?: string },\r\n): SVGElement {\r\n const transfer = createSvgElement(ctx.svgDocument, \"feComponentTransfer\");\r\n for (const channel of [\"feFuncR\", \"feFuncG\", \"feFuncB\"] as const) {\r\n const func = createSvgElement(ctx.svgDocument, channel);\r\n if (opts.type === \"table\" && opts.tableValues) {\r\n setAttributes(func, { type: \"table\", tableValues: opts.tableValues });\r\n } else {\r\n const attrs: Record = {\r\n type: \"linear\",\r\n slope: opts.slope ?? 1,\r\n };\r\n if (opts.intercept !== undefined) attrs.intercept = opts.intercept;\r\n setAttributes(func, attrs);\r\n }\r\n transfer.appendChild(func);\r\n }\r\n return transfer;\r\n}\r\n\r\n/**\r\n * Extract individual CSS filter functions from a filter value string.\r\n * Handles nested parentheses (e.g. drop-shadow with rgba()).\r\n */\r\nexport function parseCssFilterFunctions(value: string): CssFilterFunction[] {\r\n const results: CssFilterFunction[] = [];\r\n const regex = /([a-z-]+)\\(/gi;\r\n let match: RegExpExecArray | null;\r\n\r\n while ((match = regex.exec(value)) !== null) {\r\n const name = match[1]!;\r\n const argsStart = match.index + match[0].length;\r\n\r\n // Find matching closing paren, respecting nesting\r\n let depth = 1;\r\n let i = argsStart;\r\n for (; i < value.length && depth > 0; i++) {\r\n if (value[i] === \"(\") depth++;\r\n else if (value[i] === \")\") depth--;\r\n }\r\n\r\n const args = value.slice(argsStart, i - 1).trim();\r\n results.push({ name: name.toLowerCase(), args });\r\n\r\n // Advance regex past this function\r\n regex.lastIndex = i;\r\n }\r\n\r\n return results;\r\n}\r\n\r\nexport interface DropShadow {\r\n offsetX: number;\r\n offsetY: number;\r\n blur: number;\r\n color: string;\r\n}\r\n\r\n/** @internal Exported for testing */\r\nexport function parseDropShadow(value: string): DropShadow | null {\r\n // Match drop-shadow(...) respecting nested parentheses (e.g. rgba())\r\n const startIdx = value.indexOf(\"drop-shadow(\");\r\n if (startIdx === -1) return null;\r\n\r\n const argsStart = startIdx + \"drop-shadow(\".length;\r\n let depth = 1;\r\n let argsEnd = argsStart;\r\n for (let i = argsStart; i < value.length && depth > 0; i++) {\r\n if (value[i] === \"(\") depth++;\r\n else if (value[i] === \")\") depth--;\r\n if (depth > 0) argsEnd = i + 1;\r\n }\r\n\r\n const args = value.slice(argsStart, argsEnd).trim();\r\n if (!args) return null;\r\n\r\n // Parse: offsetX offsetY [blur] [color]\r\n // Color can be at start or end, with various formats\r\n const parts: string[] = [];\r\n let current = \"\";\r\n let parenDepth = 0;\r\n\r\n for (const char of args) {\r\n if (char === \"(\") parenDepth++;\r\n else if (char === \")\") parenDepth--;\r\n\r\n if (char === \" \" && parenDepth === 0 && current) {\r\n parts.push(current);\r\n current = \"\";\r\n } else {\r\n current += char;\r\n }\r\n }\r\n if (current) parts.push(current);\r\n\r\n if (parts.length < 2) return null;\r\n\r\n // Find numeric values and color\r\n const numericParts: number[] = [];\r\n let color = \"rgba(0,0,0,0.3)\";\r\n\r\n for (const part of parts) {\r\n const num = parseFloat(part);\r\n if (!isNaN(num) && (part.endsWith(\"px\") || part.match(/^-?[\\d.]+$/))) {\r\n numericParts.push(num);\r\n } else {\r\n color = part;\r\n }\r\n }\r\n\r\n return {\r\n offsetX: numericParts[0] ?? 0,\r\n offsetY: numericParts[1] ?? 0,\r\n blur: numericParts[2] ?? 0,\r\n color,\r\n };\r\n}\r\n","import type { RenderContext, BoxGeometry, BorderRadii } from \"../types.js\";\r\nimport { createSvgElement, setAttributes } from \"../utils/dom.js\";\r\nimport { buildRoundedRectPath } from \"../utils/geometry.js\";\r\nimport { hasRadius, isUniformRadius } from \"../core/styles.js\";\r\n\r\nexport interface BoxShadow {\r\n inset: boolean;\r\n offsetX: number;\r\n offsetY: number;\r\n blur: number;\r\n spread: number;\r\n color: string;\r\n}\r\n\r\n/**\r\n * Parse a CSS box-shadow value into an array of BoxShadow objects.\r\n * Supports multiple shadows, inset, spread, blur, and color in various formats.\r\n */\r\nexport function parseBoxShadows(value: string): BoxShadow[] {\r\n if (!value || value === \"none\") return [];\r\n\r\n const shadows: BoxShadow[] = [];\r\n const parts = splitTopLevelCommas(value);\r\n\r\n for (const part of parts) {\r\n const shadow = parseSingleShadow(part.trim());\r\n if (shadow) shadows.push(shadow);\r\n }\r\n\r\n return shadows;\r\n}\r\n\r\n/** Split on commas at depth 0 (respecting parentheses) */\r\nfunction splitTopLevelCommas(str: string): string[] {\r\n const parts: string[] = [];\r\n let depth = 0;\r\n let current = \"\";\r\n\r\n for (const char of str) {\r\n if (char === \"(\") depth++;\r\n else if (char === \")\") depth--;\r\n\r\n if (char === \",\" && depth === 0) {\r\n parts.push(current);\r\n current = \"\";\r\n } else {\r\n current += char;\r\n }\r\n }\r\n if (current) parts.push(current);\r\n return parts;\r\n}\r\n\r\n/** Parse a single box-shadow value */\r\nfunction parseSingleShadow(value: string): BoxShadow | null {\r\n let inset = false;\r\n let working = value;\r\n\r\n // Check for inset keyword\r\n if (working.startsWith(\"inset \")) {\r\n inset = true;\r\n working = working.slice(6).trim();\r\n } else if (working.endsWith(\" inset\")) {\r\n inset = true;\r\n working = working.slice(0, -6).trim();\r\n }\r\n\r\n // Tokenize respecting parentheses\r\n const tokens: string[] = [];\r\n let current = \"\";\r\n let depth = 0;\r\n\r\n for (const char of working) {\r\n if (char === \"(\") depth++;\r\n else if (char === \")\") depth--;\r\n\r\n if (char === \" \" && depth === 0 && current) {\r\n tokens.push(current);\r\n current = \"\";\r\n } else {\r\n current += char;\r\n }\r\n }\r\n if (current) tokens.push(current);\r\n\r\n // Separate numeric (px) tokens from color tokens\r\n const numericValues: number[] = [];\r\n const colorParts: string[] = [];\r\n\r\n for (const token of tokens) {\r\n const num = parseFloat(token);\r\n if (!isNaN(num) && (token.endsWith(\"px\") || token.match(/^-?[\\d.]+$/))) {\r\n numericValues.push(num);\r\n } else {\r\n colorParts.push(token);\r\n }\r\n }\r\n\r\n if (numericValues.length < 2) return null;\r\n\r\n return {\r\n inset,\r\n offsetX: numericValues[0]!,\r\n offsetY: numericValues[1]!,\r\n blur: numericValues[2] ?? 0,\r\n spread: numericValues[3] ?? 0,\r\n color: colorParts.join(\" \") || \"rgba(0, 0, 0, 0.3)\",\r\n };\r\n}\r\n\r\n/**\r\n * Render box-shadows as SVG elements. Non-inset shadows use SVG filters\r\n * for Gaussian blur; inset shadows are approximated similarly.\r\n * Returns an array of SVG elements to prepend before the element's content.\r\n */\r\nexport function renderBoxShadows(\r\n shadows: BoxShadow[],\r\n box: BoxGeometry,\r\n radii: BorderRadii,\r\n ctx: RenderContext,\r\n group: SVGGElement,\r\n): void {\r\n // CSS renders shadows in reverse order (first shadow = topmost)\r\n for (let i = shadows.length - 1; i >= 0; i--) {\r\n const shadow = shadows[i]!;\r\n if (shadow.inset) {\r\n renderInsetShadow(shadow, box, radii, ctx, group);\r\n } else {\r\n renderOuterShadow(shadow, box, radii, ctx, group);\r\n }\r\n }\r\n}\r\n\r\nfunction renderOuterShadow(\r\n shadow: BoxShadow,\r\n box: BoxGeometry,\r\n radii: BorderRadii,\r\n ctx: RenderContext,\r\n group: SVGGElement,\r\n): void {\r\n // Expand box by spread\r\n const spreadBox: BoxGeometry = {\r\n x: box.x + shadow.offsetX - shadow.spread,\r\n y: box.y + shadow.offsetY - shadow.spread,\r\n width: box.width + shadow.spread * 2,\r\n height: box.height + shadow.spread * 2,\r\n };\r\n\r\n // Expand radii by spread\r\n const spreadRadii = expandRadii(radii, shadow.spread);\r\n\r\n // Create shape\r\n const shape = createShadowShape(spreadBox, spreadRadii, ctx);\r\n shape.setAttribute(\"fill\", shadow.color);\r\n\r\n if (shadow.blur > 0) {\r\n // Create SVG filter for blur\r\n const filterId = ctx.idGenerator.next(\"shadow\");\r\n const filter = createSvgElement(ctx.svgDocument, \"filter\");\r\n const margin = shadow.blur * 2 + Math.abs(shadow.offsetX) + Math.abs(shadow.offsetY) + shadow.spread;\r\n // Guard against zero/tiny dimensions to avoid division-by-zero or huge percentages\r\n const safeW = Math.max(spreadBox.width, 1);\r\n const safeH = Math.max(spreadBox.height, 1);\r\n setAttributes(filter, {\r\n id: filterId,\r\n x: `-${((margin / safeW) * 100 + 10).toFixed(0)}%`,\r\n y: `-${((margin / safeH) * 100 + 10).toFixed(0)}%`,\r\n width: `${(200 + (margin / safeW) * 200 + 20).toFixed(0)}%`,\r\n height: `${(200 + (margin / safeH) * 200 + 20).toFixed(0)}%`,\r\n });\r\n\r\n const feGaussianBlur = createSvgElement(ctx.svgDocument, \"feGaussianBlur\");\r\n setAttributes(feGaussianBlur, {\r\n in: \"SourceGraphic\",\r\n stdDeviation: shadow.blur / 2,\r\n });\r\n filter.appendChild(feGaussianBlur);\r\n ctx.defs.appendChild(filter);\r\n\r\n shape.setAttribute(\"filter\", `url(#${filterId})`);\r\n }\r\n\r\n // Insert shadow before existing children (shadows render behind content)\r\n group.insertBefore(shape, group.firstChild);\r\n}\r\n\r\nfunction renderInsetShadow(\r\n shadow: BoxShadow,\r\n box: BoxGeometry,\r\n radii: BorderRadii,\r\n ctx: RenderContext,\r\n group: SVGGElement,\r\n): void {\r\n // For inset shadows, we draw a filled ring clipped to the box.\r\n // The ring is a large rect minus the inner shadow shape.\r\n const clipId = ctx.idGenerator.next(\"inset-clip\");\r\n const clipPath = createSvgElement(ctx.svgDocument, \"clipPath\");\r\n clipPath.setAttribute(\"id\", clipId);\r\n const clipShape = createShadowShape(box, radii, ctx);\r\n clipPath.appendChild(clipShape);\r\n ctx.defs.appendChild(clipPath);\r\n\r\n // Inner shape (shrunk by spread, offset)\r\n const innerBox: BoxGeometry = {\r\n x: box.x + shadow.offsetX + shadow.spread,\r\n y: box.y + shadow.offsetY + shadow.spread,\r\n width: Math.max(0, box.width - shadow.spread * 2),\r\n height: Math.max(0, box.height - shadow.spread * 2),\r\n };\r\n const innerRadii = expandRadii(radii, -shadow.spread);\r\n\r\n // Use a large outer rect and inner cutout path\r\n const g = createSvgElement(ctx.svgDocument, \"g\") as SVGGElement;\r\n g.setAttribute(\"clip-path\", `url(#${clipId})`);\r\n\r\n // Large surrounding fill\r\n const outerRect = createSvgElement(ctx.svgDocument, \"rect\");\r\n const pad = shadow.blur * 3 + Math.abs(shadow.offsetX) + Math.abs(shadow.offsetY) + 100;\r\n setAttributes(outerRect, {\r\n x: box.x - pad,\r\n y: box.y - pad,\r\n width: box.width + pad * 2,\r\n height: box.height + pad * 2,\r\n fill: shadow.color,\r\n });\r\n\r\n // Inner cutout\r\n const innerShape = createShadowShape(innerBox, innerRadii, ctx);\r\n innerShape.setAttribute(\"fill\", shadow.color);\r\n\r\n // Use fill-rule evenodd with combined path for cutout effect\r\n // Simpler: just use the inner shape as a mask\r\n const maskId = ctx.idGenerator.next(\"inset-mask\");\r\n const mask = createSvgElement(ctx.svgDocument, \"mask\");\r\n mask.setAttribute(\"id\", maskId);\r\n\r\n const maskWhite = createSvgElement(ctx.svgDocument, \"rect\");\r\n setAttributes(maskWhite, { x: box.x - pad, y: box.y - pad, width: box.width + pad * 2, height: box.height + pad * 2, fill: \"white\" });\r\n const maskBlack = createShadowShape(innerBox, innerRadii, ctx);\r\n maskBlack.setAttribute(\"fill\", \"black\");\r\n mask.appendChild(maskWhite);\r\n mask.appendChild(maskBlack);\r\n ctx.defs.appendChild(mask);\r\n\r\n outerRect.setAttribute(\"mask\", `url(#${maskId})`);\r\n\r\n if (shadow.blur > 0) {\r\n const filterId = ctx.idGenerator.next(\"inset-blur\");\r\n const filter = createSvgElement(ctx.svgDocument, \"filter\");\r\n setAttributes(filter, { id: filterId, x: \"-50%\", y: \"-50%\", width: \"200%\", height: \"200%\" });\r\n const feBlur = createSvgElement(ctx.svgDocument, \"feGaussianBlur\");\r\n setAttributes(feBlur, { in: \"SourceGraphic\", stdDeviation: shadow.blur / 2 });\r\n filter.appendChild(feBlur);\r\n ctx.defs.appendChild(filter);\r\n outerRect.setAttribute(\"filter\", `url(#${filterId})`);\r\n }\r\n\r\n g.appendChild(outerRect);\r\n group.insertBefore(g, group.firstChild);\r\n}\r\n\r\n/** Create a shape element matching the box (rect or rounded-rect path) */\r\nfunction createShadowShape(\r\n box: BoxGeometry,\r\n radii: BorderRadii,\r\n ctx: RenderContext,\r\n): SVGElement {\r\n if (hasRadius(radii) && !isUniformRadius(radii)) {\r\n const path = createSvgElement(ctx.svgDocument, \"path\");\r\n path.setAttribute(\"d\", buildRoundedRectPath(box.x, box.y, box.width, box.height, radii));\r\n return path;\r\n }\r\n\r\n const rect = createSvgElement(ctx.svgDocument, \"rect\");\r\n setAttributes(rect, { x: box.x, y: box.y, width: box.width, height: box.height });\r\n\r\n if (hasRadius(radii) && isUniformRadius(radii)) {\r\n setAttributes(rect, { rx: radii.topLeft[0], ry: radii.topLeft[1] });\r\n }\r\n\r\n return rect;\r\n}\r\n\r\n/** Expand (or shrink if negative) radii by a given amount */\r\nfunction expandRadii(radii: BorderRadii, amount: number): BorderRadii {\r\n return {\r\n topLeft: [Math.max(0, radii.topLeft[0] + amount), Math.max(0, radii.topLeft[1] + amount)],\r\n topRight: [Math.max(0, radii.topRight[0] + amount), Math.max(0, radii.topRight[1] + amount)],\r\n bottomRight: [Math.max(0, radii.bottomRight[0] + amount), Math.max(0, radii.bottomRight[1] + amount)],\r\n bottomLeft: [Math.max(0, radii.bottomLeft[0] + amount), Math.max(0, radii.bottomLeft[1] + amount)],\r\n };\r\n}\r\n","import type { RenderContext, BoxGeometry } from \"../types.js\";\r\nimport { createSvgElement, setAttributes } from \"../utils/dom.js\";\r\nimport { buildRoundedRectPath } from \"../utils/geometry.js\";\r\n\r\nexport type ClipPathShape =\r\n | { type: \"inset\"; top: number; right: number; bottom: number; left: number; round?: string }\r\n | { type: \"circle\"; radius: number; cx: number; cy: number; cxPct?: boolean; cyPct?: boolean }\r\n | { type: \"ellipse\"; rx: number; ry: number; cx: number; cy: number; cxPct?: boolean; cyPct?: boolean }\r\n | { type: \"polygon\"; points: [number, number][] }\r\n | { type: \"path\"; d: string };\r\n\r\n/** Parse a CSS length value, detecting percentage vs pixel units */\r\nfunction parseLengthValue(raw: string): { value: number; isPct: boolean } {\r\n const trimmed = raw.trim();\r\n if (trimmed.endsWith(\"%\")) {\r\n return { value: parseFloat(trimmed) || 0, isPct: true };\r\n }\r\n return { value: parseFloat(trimmed) || 0, isPct: false };\r\n}\r\n\r\n/**\r\n * Parse a CSS clip-path value into a ClipPathShape.\r\n * Handles both pixel and percentage values (browser may keep center positions as %).\r\n */\r\nexport function parseClipPath(value: string): ClipPathShape | null {\r\n if (!value || value === \"none\") return null;\r\n\r\n const insetMatch = value.match(/^inset\\((.+)\\)$/);\r\n if (insetMatch) return parseInset(insetMatch[1]!);\r\n\r\n const circleMatch = value.match(/^circle\\((.+)\\)$/);\r\n if (circleMatch) return parseCircle(circleMatch[1]!);\r\n\r\n const ellipseMatch = value.match(/^ellipse\\((.+)\\)$/);\r\n if (ellipseMatch) return parseEllipse(ellipseMatch[1]!);\r\n\r\n const polygonMatch = value.match(/^polygon\\((.+)\\)$/);\r\n if (polygonMatch) return parsePolygon(polygonMatch[1]!);\r\n\r\n const pathMatch = value.match(/^path\\([\"']?(.+?)[\"']?\\)$/);\r\n if (pathMatch) return { type: \"path\", d: pathMatch[1]! };\r\n\r\n return null;\r\n}\r\n\r\nfunction parseInset(args: string): ClipPathShape | null {\r\n // inset(top right bottom left round radii)\r\n const roundIdx = args.indexOf(\" round \");\r\n let insetPart = args;\r\n let round: string | undefined;\r\n if (roundIdx >= 0) {\r\n insetPart = args.slice(0, roundIdx);\r\n round = args.slice(roundIdx + 7).trim();\r\n }\r\n\r\n const values = insetPart.trim().split(/\\s+/).map((v) => parseFloat(v) || 0);\r\n const top = values[0] ?? 0;\r\n const right = values[1] ?? top;\r\n const bottom = values[2] ?? top;\r\n const left = values[3] ?? right;\r\n\r\n return { type: \"inset\", top, right, bottom, left, round };\r\n}\r\n\r\nfunction parseCircle(args: string): ClipPathShape | null {\r\n // circle(radius at cx cy)\r\n const atIdx = args.indexOf(\" at \");\r\n let radius = 0;\r\n let cx = 0;\r\n let cy = 0;\r\n let cxPct = false;\r\n let cyPct = false;\r\n\r\n if (atIdx >= 0) {\r\n radius = parseFloat(args.slice(0, atIdx)) || 0;\r\n const center = args.slice(atIdx + 4).trim().split(/\\s+/);\r\n const cxVal = parseLengthValue(center[0]!);\r\n const cyVal = parseLengthValue(center[1]!);\r\n cx = cxVal.value; cxPct = cxVal.isPct;\r\n cy = cyVal.value; cyPct = cyVal.isPct;\r\n } else {\r\n radius = parseFloat(args) || 0;\r\n // CSS spec: default center is 50% 50%\r\n cx = 50; cy = 50;\r\n cxPct = true; cyPct = true;\r\n }\r\n\r\n return { type: \"circle\", radius, cx, cy, cxPct, cyPct };\r\n}\r\n\r\nfunction parseEllipse(args: string): ClipPathShape | null {\r\n // ellipse(rx ry at cx cy)\r\n const atIdx = args.indexOf(\" at \");\r\n let rx = 0;\r\n let ry = 0;\r\n let cx = 0;\r\n let cy = 0;\r\n let cxPct = false;\r\n let cyPct = false;\r\n\r\n if (atIdx >= 0) {\r\n const radii = args.slice(0, atIdx).trim().split(/\\s+/);\r\n rx = parseFloat(radii[0]!) || 0;\r\n ry = parseFloat(radii[1]!) || 0;\r\n const center = args.slice(atIdx + 4).trim().split(/\\s+/);\r\n const cxVal = parseLengthValue(center[0]!);\r\n const cyVal = parseLengthValue(center[1]!);\r\n cx = cxVal.value; cxPct = cxVal.isPct;\r\n cy = cyVal.value; cyPct = cyVal.isPct;\r\n } else {\r\n const parts = args.trim().split(/\\s+/);\r\n rx = parseFloat(parts[0]!) || 0;\r\n ry = parseFloat(parts[1]!) || 0;\r\n // CSS spec: default center is 50% 50%\r\n cx = 50; cy = 50;\r\n cxPct = true; cyPct = true;\r\n }\r\n\r\n return { type: \"ellipse\", rx, ry, cx, cy, cxPct, cyPct };\r\n}\r\n\r\nfunction parsePolygon(args: string): ClipPathShape | null {\r\n // polygon(x1 y1, x2 y2, ...)\r\n // Remove optional fill-rule prefix\r\n let cleaned = args.trim();\r\n if (cleaned.startsWith(\"nonzero,\") || cleaned.startsWith(\"evenodd,\")) {\r\n cleaned = cleaned.slice(cleaned.indexOf(\",\") + 1).trim();\r\n }\r\n\r\n const points: [number, number][] = [];\r\n const pairs = cleaned.split(\",\");\r\n\r\n for (const pair of pairs) {\r\n const parts = pair.trim().split(/\\s+/);\r\n if (parts.length >= 2) {\r\n points.push([parseFloat(parts[0]!) || 0, parseFloat(parts[1]!) || 0]);\r\n }\r\n }\r\n\r\n if (points.length < 3) return null;\r\n return { type: \"polygon\", points };\r\n}\r\n\r\n/**\r\n * Create an SVG element in defs and return its ID.\r\n * The clip shape is positioned relative to the element's box.\r\n */\r\nexport function createSvgClipPath(\r\n shape: ClipPathShape,\r\n box: BoxGeometry,\r\n ctx: RenderContext,\r\n): string | null {\r\n const clipId = ctx.idGenerator.next(\"clip\");\r\n const clipPath = createSvgElement(ctx.svgDocument, \"clipPath\");\r\n clipPath.setAttribute(\"id\", clipId);\r\n\r\n const svgShape = shapeToSvg(shape, box, ctx);\r\n if (!svgShape) return null;\r\n\r\n clipPath.appendChild(svgShape);\r\n ctx.defs.appendChild(clipPath);\r\n\r\n return clipId;\r\n}\r\n\r\nfunction shapeToSvg(\r\n shape: ClipPathShape,\r\n box: BoxGeometry,\r\n ctx: RenderContext,\r\n): SVGElement | null {\r\n switch (shape.type) {\r\n case \"inset\": {\r\n const x = box.x + shape.left;\r\n const y = box.y + shape.top;\r\n const w = Math.max(0, box.width - shape.left - shape.right);\r\n const h = Math.max(0, box.height - shape.top - shape.bottom);\r\n\r\n if (shape.round) {\r\n // Parse border-radius shorthand for inset\r\n const radiiValues = shape.round.split(\"/\").map((part) =>\r\n part.trim().split(/\\s+/).map((v) => parseFloat(v) || 0),\r\n );\r\n const h_values = radiiValues[0] ?? [0];\r\n const v_values = radiiValues[1] ?? h_values;\r\n\r\n const radii = {\r\n topLeft: [h_values[0] ?? 0, v_values[0] ?? 0] as [number, number],\r\n topRight: [h_values[1] ?? h_values[0] ?? 0, v_values[1] ?? v_values[0] ?? 0] as [number, number],\r\n bottomRight: [h_values[2] ?? h_values[0] ?? 0, v_values[2] ?? v_values[0] ?? 0] as [number, number],\r\n bottomLeft: [h_values[3] ?? h_values[1] ?? h_values[0] ?? 0, v_values[3] ?? v_values[1] ?? v_values[0] ?? 0] as [number, number],\r\n };\r\n\r\n const path = createSvgElement(ctx.svgDocument, \"path\");\r\n path.setAttribute(\"d\", buildRoundedRectPath(x, y, w, h, radii));\r\n return path;\r\n }\r\n\r\n const rect = createSvgElement(ctx.svgDocument, \"rect\");\r\n setAttributes(rect, { x, y, width: w, height: h });\r\n return rect;\r\n }\r\n\r\n case \"circle\": {\r\n const resolvedCx = shape.cxPct ? (shape.cx / 100) * box.width : shape.cx;\r\n const resolvedCy = shape.cyPct ? (shape.cy / 100) * box.height : shape.cy;\r\n const circle = createSvgElement(ctx.svgDocument, \"circle\");\r\n setAttributes(circle, {\r\n cx: box.x + resolvedCx,\r\n cy: box.y + resolvedCy,\r\n r: shape.radius,\r\n });\r\n return circle;\r\n }\r\n\r\n case \"ellipse\": {\r\n const resolvedCx = shape.cxPct ? (shape.cx / 100) * box.width : shape.cx;\r\n const resolvedCy = shape.cyPct ? (shape.cy / 100) * box.height : shape.cy;\r\n const ellipse = createSvgElement(ctx.svgDocument, \"ellipse\");\r\n setAttributes(ellipse, {\r\n cx: box.x + resolvedCx,\r\n cy: box.y + resolvedCy,\r\n rx: shape.rx,\r\n ry: shape.ry,\r\n });\r\n return ellipse;\r\n }\r\n\r\n case \"polygon\": {\r\n const polygon = createSvgElement(ctx.svgDocument, \"polygon\");\r\n const pointsStr = shape.points\r\n .map(([x, y]) => `${box.x + x},${box.y + y}`)\r\n .join(\" \");\r\n polygon.setAttribute(\"points\", pointsStr);\r\n return polygon;\r\n }\r\n\r\n case \"path\": {\r\n const path = createSvgElement(ctx.svgDocument, \"path\");\r\n path.setAttribute(\"d\", shape.d);\r\n // Translate path to box position\r\n path.setAttribute(\"transform\", `translate(${box.x}, ${box.y})`);\r\n return path;\r\n }\r\n\r\n default:\r\n return null;\r\n }\r\n}\r\n","import type { RenderContext, BorderRadii, BoxGeometry } from \"../types.js\";\r\nimport {\r\n createSvgElement,\r\n setAttributes,\r\n isImageElement,\r\n isCanvasElement,\r\n isFormElement,\r\n getPseudoStyles,\r\n} from \"../utils/dom.js\";\r\nimport { getRelativeBox, buildRoundedRectPath } from \"../utils/geometry.js\";\r\nimport {\r\n parseBorders,\r\n parseBorderRadii,\r\n clampRadii,\r\n hasBorder,\r\n hasRadius,\r\n isUniformRadius,\r\n hasOverflowClip,\r\n parseBackgroundColor,\r\n hasBackgroundImage,\r\n isVisibilityHidden,\r\n} from \"../core/styles.js\";\r\nimport { parseLinearGradient, createSvgLinearGradient, rasterizeGradient } from \"../assets/gradients.js\";\r\nimport { imageToDataUrl, extractUrlFromCss, canvasToDataUrl } from \"../assets/images.js\";\r\nimport { cssTransformToSvg } from \"../transforms/svg.js\";\r\nimport { createSvgFilter } from \"../assets/filters.js\";\r\nimport { parseBoxShadows, renderBoxShadows } from \"../assets/box-shadow.js\";\r\nimport { parseClipPath, createSvgClipPath } from \"../assets/clip-path.js\";\r\n\r\n/**\r\n * Render an HTML element's visual properties (background, borders, overflow mask).\r\n * Returns a group containing the element's own visuals.\r\n * Children are rendered separately by the traversal engine.\r\n */\r\nexport async function renderHtmlElement(\r\n element: Element,\r\n rootElement: Element,\r\n ctx: RenderContext,\r\n): Promise {\r\n const group = createSvgElement(ctx.svgDocument, \"g\") as SVGGElement;\r\n const styles = window.getComputedStyle(element);\r\n const box = getRelativeBox(element, rootElement);\r\n const radii = clampRadii(parseBorderRadii(styles), box.width, box.height);\r\n\r\n // CSS Transforms (applied even when visibility:hidden for layout)\r\n // When flattenTransforms is enabled, skip — getBoundingClientRect positions\r\n // already include the effect of CSS transforms.\r\n if (!ctx.options.flattenTransforms && styles.transform && styles.transform !== \"none\") {\r\n const svgTransform = cssTransformToSvg(\r\n styles.transform,\r\n styles.transformOrigin,\r\n box,\r\n );\r\n if (svgTransform) {\r\n group.setAttribute(\"transform\", svgTransform);\r\n }\r\n }\r\n\r\n // CSS clip-path (applied even when visibility:hidden, like transforms)\r\n const clipPathValue = styles.clipPath;\r\n if (clipPathValue && clipPathValue !== \"none\") {\r\n const shape = parseClipPath(clipPathValue);\r\n if (shape) {\r\n const clipId = createSvgClipPath(shape, box, ctx);\r\n if (clipId) group.setAttribute(\"clip-path\", `url(#${clipId})`);\r\n }\r\n }\r\n\r\n // Skip own visuals when visibility:hidden, but keep the group\r\n // so visible children can still be rendered inside it.\r\n const hidden = isVisibilityHidden(styles);\r\n\r\n if (!hidden) {\r\n // CSS Filters (blur, brightness, contrast, drop-shadow, grayscale, etc.)\r\n if (styles.filter && styles.filter !== \"none\") {\r\n const filterId = createSvgFilter(styles.filter, ctx);\r\n if (filterId) {\r\n group.setAttribute(\"filter\", `url(#${filterId})`);\r\n }\r\n }\r\n\r\n // Box shadows (rendered behind content)\r\n const boxShadowValue = styles.boxShadow;\r\n if (boxShadowValue && boxShadowValue !== \"none\") {\r\n const shadows = parseBoxShadows(boxShadowValue);\r\n if (shadows.length > 0) {\r\n renderBoxShadows(shadows, box, radii, ctx, group);\r\n }\r\n }\r\n\r\n // Background color\r\n const bgColor = parseBackgroundColor(styles);\r\n if (bgColor) {\r\n const rect = createBoxShape(box, radii, ctx);\r\n rect.setAttribute(\"fill\", bgColor);\r\n group.appendChild(rect);\r\n }\r\n\r\n // Background image (gradients + URLs)\r\n if (hasBackgroundImage(styles)) {\r\n await renderBackgroundImages(styles, box, radii, ctx, group);\r\n }\r\n\r\n // Borders\r\n const borders = parseBorders(styles);\r\n if (hasBorder(borders)) {\r\n renderBorders(group, box, borders, radii, ctx);\r\n }\r\n\r\n // Outline (rendered outside the border box)\r\n renderOutline(styles, box, radii, ctx, group);\r\n\r\n // element\r\n if (isImageElement(element) && element.src) {\r\n const dataUrl = await imageToDataUrl(element.src);\r\n const imgEl = createSvgElement(ctx.svgDocument, \"image\");\r\n setAttributes(imgEl, {\r\n x: box.x,\r\n y: box.y,\r\n width: box.width,\r\n height: box.height,\r\n href: dataUrl,\r\n });\r\n const objectFit = styles.objectFit || element.style.objectFit;\r\n if (objectFit === \"fill\" || objectFit === \"\") {\r\n imgEl.setAttribute(\"preserveAspectRatio\", \"none\");\r\n } else if (objectFit === \"contain\" || objectFit === \"scale-down\") {\r\n imgEl.setAttribute(\"preserveAspectRatio\", \"xMidYMid meet\");\r\n } else if (objectFit === \"cover\") {\r\n imgEl.setAttribute(\"preserveAspectRatio\", \"xMidYMid slice\");\r\n }\r\n // Clip image to border-radius when present\r\n if (hasRadius(radii)) {\r\n const clipId = ctx.idGenerator.next(\"clip\");\r\n const clipPath = createSvgElement(ctx.svgDocument, \"clipPath\");\r\n clipPath.setAttribute(\"id\", clipId);\r\n const clipShape = createSvgElement(ctx.svgDocument, \"path\");\r\n clipShape.setAttribute(\"d\", buildRoundedRectPath(box.x, box.y, box.width, box.height, radii));\r\n clipPath.appendChild(clipShape);\r\n ctx.defs.appendChild(clipPath);\r\n imgEl.setAttribute(\"clip-path\", `url(#${clipId})`);\r\n }\r\n group.appendChild(imgEl);\r\n }\r\n\r\n // element\r\n if (isCanvasElement(element)) {\r\n const dataUrl = canvasToDataUrl(element);\r\n if (dataUrl) {\r\n const imgEl = createSvgElement(ctx.svgDocument, \"image\");\r\n setAttributes(imgEl, {\r\n x: box.x,\r\n y: box.y,\r\n width: box.width,\r\n height: box.height,\r\n href: dataUrl,\r\n });\r\n group.appendChild(imgEl);\r\n }\r\n }\r\n\r\n // Form element content (,
  • Ul# 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:qxsEcm+-~7BpG+%r1QGaM^W6Dur20f~|6>G;qwD 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 IVE@y91Ln1`X8-^I diff --git a/docs/images/step_by_step_guide/3.png b/docs/images/step_by_step_guide/3.png deleted file mode 100644 index 0c14f38791145e57957d901d273f13051cc5148e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 79931 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 T7` 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 f~P8}IF&QaeIphsSADC5e6Xq8MhL zv7&(Uxv~V#v!%r*$VmXz<8t>)jKMmo`dDh^qUU0pY{&x?`>^whmyYLD#W+WW#Aj4~ zIUx&0UCmaqI(mN$zI`rubgP(V7`O38a#9@se3*n2#dw>r*vN1AN@Buh!oUPhx=c}S zft3hP{D&DgzF3-}6t;H%0{PxzZ0lzh<^G1n)OZ#^>wIT2<25EQyy|JroAI?G`Zu@@ zy$8SuAH%4lBx{5xdXtb1!)W5`ikr)wHKPlHOa+QnN zmd)37t^%EFp|lEy$(EJN;s`F= zGD1(@Y#b`2!?op&#ap!xTTY&HJ@yMY5@PCg-uUBdI(yH{-!w8xN>1i60dJ_G=9~Y}5SCNA zCF?N$*od1u1H(aHyCX=hU15DT{tpWMbPkj4bGwml!lEWqLs>J>1ShO^{>yk?sm z_Z@2hNUe%8xJ6#d%c*6><;|84_LKlZ^%UX}8L z-TiJ;GpU#a&@!Y^V+}_dGnx4r2muep5nxtCZ?y}}{M zCgQzFMvLnfY(1i(*}1tlqk_Q@YQBR8#Yc0kkJ0S9vPL+k`d2%`s~$e^Y|T5*b|7b?7hS&>hF5RYxRA5Gt~}nQeBmANly8OZL_~qc z8wvfc(;uk2MJG^L`Q;`Mz1yWiU!eKlIGADD+owV{xvL1H__`P(F_I)7tS0hw#Ld1j zczr@yo;}m9Lfpik851wEA#&#L&Y|Y=0_E97dwY70dAm$90vyoeT(KQGT==pr1os#? zZQCfk6ZR053-XJFumw-x10vkt&XcWW4qyQv^k+nNKpkZ z{N&p&PtT=*EBb7%KS5Rrdy&P@G;Hc~aaHSj>-1KRGin+dMwuS4NM%?Y50Ex*32)2G zOFGprzjBKLDyN_Ejx_~pf&J1!GhT~b_ugk|iUtZ=!S-J{`q1(JW9k3DJ>~yKxBLKm ze8WG!{lS*I;ruQB(Md?S{Tm<0Pm>9Mf*YYnQDAxh|Gvifza2Bm-1|>XnUSB8V(SJ4 zHJ)3o_iSy0i;IgLtqtzOtQueOs3sK`3xl}4d-C?TLHketC|-=V`&w_VaD$PR^>}Fa zNe#~E=qY6w3|3fJ_zuWDw1w`Wq`9C!|EF3O`py3@x)c8^(ftOOfw9DBg1m{;PsH;V zE~F$~|2{YViVggKbKG?-)zn6p9Dfe=B@&~$Mn>3eY;0z_-}m;n2lJ?gE7{nXI66DO zYpMMKmi&fK{>vYeJl=!(mNP$37u>$w_|~gxC;A4D|0(@nzyNTJHfn8c&2is~^@9i3 zm7~7@3_m8&^@#Eb#qrxYEeK<4S+I-an4xjL!~q zmzE}3T7AgPdg|kVoQw5*nvFls`miRyPD%t#n#Sz&3@ld&U&p3oie2SJTDY@`dy@}% zZZKpJXM@57(+ElmX|IsAT4k<_jzxSKs z#K{t$EPB+wT6uMH&V3TEWPd|@bK@87uR2wgo9KLk&dJw~oa^nqQG4=lKFGhxDI9Qx z33327RbAb_7hd>7Zrb2I02G%)`AXfh!yHeD8~L?N%=uh5$mqNz+=QddgK9TRZI3fb zIaffy1U6p%cHnJXh_+gXX#96$gRkamSYRA%@sE!$Hxq_uJ`}|@U480Vq zy@fp;<;OlUpswI@eo`?XM^I^ADTs{0rVz*H@#y4CT>p#UbFps5c-Ywk@ZGOJR~kD+ zb1hvXDp+a6U<5!gU^~(A-`b}~^e$8OBHnFL^r8sjaG$psTMhEcg_6VqTsohb1eQ zJb<_;8%;C^$cF7s_FFRfwJwg6Hoka+Ef?9iZFt^S|KZk=h71Ev*g>;fR}Ut~Ii4SH z80XwMfgAAtp1qshXzbBUv6GcqZ@fsiwtMJU&_^cbSCFLtZOEgl7N`DpmWlUPNJX{A zW=9$G`%-!hw>xrFLEHmvD@ouY5Bj$v8bed@HyUd%OwLqTfRB*74Q)rkjTuahxNZC4 zRYK#rpFSbMUtJ1KG{^Qwu6)c*B1%;IWiZ8ao|T6*kOnW!`YOk#4-<60U$agB zqRqc&r0{@B9W;HXr`Ofhy-Y;l@1>`wH;oE}fP6@Juu_}r1|E_g^^O|0=?f30nR`2s zAH>g5vHV3(uq9uzbZ_zsI~bM+BaT4IBTmvr_rb-njFlbjL$&sgFoD!tt#pQDEk1t! zd?1=l^#bO~qfIle1}QS0ggqULfX(PKYDc(h68gR=cA`&pM9L=US_~#$^y1Q|IrBVb z6BCmeNp5adHntwk)AFJmjpmv;NP9lX7tiYVhbJU#NR`mmgaI zd}rqbNarxW0tQQ6`IN07ZpV(iu!8hJip(y=C)fbntNP8D)r6M%4n43j%ZOK{&`;i#S+GuEJsaW zth`Wex=}G>d07kG7pt^f-kEBUokskcXh*%I;j+F)CK4j#+5e9A{L7wdWWHrfH}sZu zbq~)Fswzjo>;+?3{W*){02XPdsb;)L*RC(IG z@@fyGimc}9CPs8JimP!YNTLzavmz`n#2^44fM+c%RY@HzRhTzo9*^uTTYk~6KbD&9 z52l_ua;1#gWKHP`m^1&XH~_p#)ABk%zpmfy3HrB6c_VC59ry?VC(w4_Eu_eylLnW+ z?pb`lkS0BNV<*`6*}ZSKXLwaHrUr0PmBv#UKrgSsQ*wW}x$BPYU-r|MEjUD^B+*M8 zC+wW~lNeC*ze?W+^X0G73w*Dcdm-9WN}+N?B;p6@o<5Y}OcFgCS!%q4DLE5vIl5yh zAVYBls79$F3|EAbhow!O*$DgMOMGf3y+qEOiBW;*c&&G64lc}T$3>uGsH)+WobvON zmWHdM#o54lH}w10OdVS3XZUySiduE-P++Mz+bOAd)l?DW#z@W6v%5!>p(FCdr4Ni{ zunTLZJ2}ppP6{dvedW(F<*m9cOt+u*R7y{Tw52V+Xy+s}kb$-LuHqe!Jk>qZ!r^e` z&qrcA6b0o{N?JM6I%UDHJQhV~pq`Rody?>FztV`ZSEteRHaPL}@svz@E;%Q>MLcI+V2jV*0aK|Ahel&Wolvzs-7<6EuBigPB>?N`L*(yj1Rro8i4-kCn)b*c zh`-1x+NO2<>Pg)IGSL)j=Jnibhi8`j3=D{df^pkdq4-D@=+kdj_3HOr?t+B3N9~6Lh7FpDRGauZkQqh+nzck?2L2euf7p?#U2xh1KSxf(pK#G&^M#9tqQxYg?P;i5PqqAH(oXC!0I9@PuIb`E%6$V1F_@%d#?BT z1$lEUVgtIvw%F!(-GS4r(7i)Cp?T#cUU{?2-_P5a)xLSe(dpI`#7+!Ra|Jl34cD;_ z^exp~_Nb4JJFbCG0`OieX7kWj*d4A&?d-e73sgp$q^0eZnIsYK1+RC@4b^h%%F?@@ z>RE6xemRf(QiDiQg&kHIcj?yym!62&;y+T4*MlU>#;a+5~JqFM)NIj1 z-02G)S+c9tP@{go5`$bJ89Ab^oOMxQ4E}c4Ik@f^CNA#O(gWbJ**PmE2M343qM~F~ z>TD2~B&B7?a^%K%tH_u{z7`Hj!}ya~>V2zULNN&Nj1n}!x*zEmLqmgkcbdA>va@?NccxMzry=jxdw-{b#1J^WE>fJueCp^} z!`)5@oj}{uw8Thvgn%>6xPF3y4e&{QH;AE80EhoRZQZ-U!~>`RWYdH|KRJc#y5*fXCmoSI`#+7pB0~o+zenYwJt}nn*wx7b5iyX>7vL_u3uS=btefDdYAKj~kJFYOAQDHiUq=Jk-%u^S`FcNYAJ%>ZUdMxf$c% z99e1AXpgqV_+Jw(bJw!VGZIZFa6YZtm;iUbbK7V0bahr`&C4_bV=LSff$nS4eaed) zVUezIR?Iugcbd`N({-zrb{o2sPL{RKcuNC&d2XBtLcA9N9T%;4g(PWD+%0*8h(D} znqNbW>ksO2InzVBfm(_u`44s>Q;sK^g9nx=<3FhBpXSG_qc$r56G%D&@FDJl^qR9X z!RD;x@>`rUm6@sJODDNix(eX^Jx0fw5}9S|FEJcs)YQ}jPNcaG13$pIxK+NXem`EE zKQ7KcEn0_BlV6b!{0?R{KQc%f(#;CYXuh=K=%lERM8#GlgKN?(b+zrC&2|xR!4tFR z+XNLMrR(3^%zK5;zg{bpKO-?;BQP#;*D<4_0wCT%4@dna%j-GQ&v7`vW*`um~76llfVaW@VY_rG-?pv)trnfaCKR`66)@} zCg<*kZ67Fe&}Sg!EX)ppSLssUo)Zgr<{Fd;Ik5;V4%G9-nXkm5hx-^!q$>!rwIx=_ zmb=Ng*sdbdA>OsR_E=A=b==6{n?AVa{QRtc>}ayA*)opskB-?t>c3j8KLGkC1>Ds9cn;X@~v{P>cpf{(=fQEebV|rqXd-*OI?pk-Pn+4~bIdkUB%$_}a_TKZ`C*rZ5 zCMz=!^N}M*Sb;jU%ob>s+J=zU96Zf=H*j=<%lp#Ub%8j@w^@Q zeA1CS6|e4X+c|Wt#@@Y~A`fh3wwpL`F6bPYS8&9{16v(eNJ)%_{PTCUv7G(qb-{&h z5x&H1(>*!0cFC6TB|Cu=zaEh@lwUs1xccnW^!WW%Zc8=lHPNq!6~}&Mrnj9AIQr}A z=i$n$vm1Zk|CzdX+C~?`IH%*4WJSl{<-iEKh7HM@EH8_DKFCqtUmabob=KbF6+pt= z&;5M<$mf)|@3%Xg?NZ6RIG=6be$rTCLxrczxW{*9l*g4|r-3f|{goIl?KJD&u_HrHdNsXfNU+cJ5 zeH$&N56syLWVa~%m4B5J7KSS4+d|$yyK?RNbxuCM(zUfU&n(~1i*3@niAhPw4sdYHTPp1EN@{!L_<@v*kigmk)QPKP8}Uk5>b1I z=@<*ZgsI25)D-ucDRYkH#D7f{6uDVb9aXLcgfEK0iznV#wn%{k<*b?-o*Va~EQI=P z$!>vlmGPymgaq03Bw%Y0A~HG}Jlz~kZj^gDGe0jbD@#xbwoI3$yd@kdxZLF={eaQ7 zz+8LhXtlAGmxco${kZmSFST{~s5+7QjP~5ms{LfqBS-W?5l*LcX}5(ElM+M}6)iMl z`N2+3PLE;WuZ@j^RZfOYO--Uo*RDO&u-J1MWNA!wBH^C$jZeO_6R87Q09JxiaunI# z-K}};(lYJE#cTYfYqp&pkgXj{#%6NsoBe>^aUui@M@B`>urEDkw3n0H2?okjkaJrS zKpoYSP49p9hDX-bGS_x(~u71YZXVTq>ZZejaV7GTx??|TO-%WfThE1#x0-C?sdF)byf===ABL$og`o=7%!m2O%^) zl`3%Kdfnj-%%#a*=dsZADOzlN<2hk&J#MxeS65RCg+mvDL>%g}K@O!GS+taO6g&C{%eUDoR(7YQ7XEZNez4fd7&%=ugja*1`(iBho z_wPzYM^W7$ZCgt4ol^UyX~kSSJfI1lx{70Ca|a;>t?`%EXb&)}rHsVyqUpn73PSYy z6V|coniYu|upbcPqP#po-E=tvour$+WmdIzsR&>4aK2TouTvXbh)2=41P+HU*Mh|q z_sU~<+qO}?&{3>d4rE;WNtilf^4+96(qrDnd1P10VzRCO)298ax_>qI$WoS#=^sTl zTKSU66%`fBLYy0~YioVUeU;rR#a~`B?KO@tDi;zGa$1}Cl6uEBEa|3M4)`bHiTLOc%J0uYH_)~9IJ5Ojgi*Y(dkgQVf|j*BqsLDiIJbi}>?I9dq?N zYPCMvsb82>F~u7F(GA6Q958vniFYa41KrA?B@O?JsP|e=r#C?_g~jkIJKqw$;eKlM zd&=FxY;~ql7d*LXYiP#m*h#$h%)M8!S)W~p=ZSgvNQIcYf%#ne5= z`o0NjTItUkZfF4HV!D_CLbC)MZ!{*_k1lc2wmN||rQxuG_~KO->r*2reSl~$f#7n()!U*blwAND{t1u7&3Mu-pf)43c_B}v1t>soq6 z6pZ=yMhgFMgCT)0$ri6r3fAr9@{7l}otG>nEI&PVQ*j;lyrzVd<&qruT5Sl> zhD&Q3&fgsPdTSiN!$L-XUL?(gj31?ZIXtw|jcq7;&BEopGQdLycNHCGa#KR_*laV<~m#)<-)qgAc<+Se>}anx()XXXLl* zfS8D$iTe_i$FJkTv8a`+ZY$0Qlo2nVm1PO z4=-1-Ao~JdH8;puc+KA$`1*RRBY6`L+9j<#>^_`evd~LEF*5%`N0WP zc>8A9(OF6G>(hm*f2jM=H{FupgGw{7jnb~*n7 z!LXCu^fzg*N|uv9UR6JN^7Wr-9YrD&XeJCgE+HXL=N4{2g*WfRhaO_m(9=;rHwE%R z-|aKvGts04RB_^0Q0b_eipAwjc^ez<@v|C25Y&)8$&{gO`1JeS0=Mc%$yP8F#B!C7N7=xwI|VgF zHzBDuWgW&ioK&5!4K>isq}0?}U2yCi+#m8e{YVWYpiG-@kw)t0JRSKmUU@Fg=b5|* za;H1@=B!4OXuyw`frr)NWbjqHLVj2PX09vLFm_lr8?>(8OzI4c#dbkwMLYZ;tDVNc z2kBGOt4p<68-h(D)z-Uy`Ql7M;(DNMd{e6JxT&sJX1zwTl`nb)COTOE$BoAZ zD_8ki<@1hfZq7u$F?gB`mB!H531RXd1D$8U?9izN59Gq;-=@bAAKO-jGMm5~YM!qA z#;V4<$e}BpTiTfKu4C63bCk*0P+f~52(;|d%Ug8?Pgmkx8f73L&*SSZuR7Q`|Cy&oToSm?U!+KX$z5o)69 z@0(b|-e&H!)*GrP6jTL_?U8XxTYXQ13_8Ev5UY23b!|!N?tyeNmel`Yet%38V#Jq4 z;fXYPns#Y$N?E#!wN?*C)YKY+ZcV+JSRJy+EOYVpUP{Q&^jivZOcvJxjqI8%xCYs@ zzUen3Pjh%7GLn7b+_BB7QbrNygxYC`oXYML)|!4l$hg;A*H1L5(LIwrJ=Xqo4U0qx zQQ>M7x$%aH^F7b>yyy86d)J<^@YGx*kE>YSf+;ZmH~c!=TjMzpc$dIo7X6${05_go ztq{$>7Y53A^Lc#2nYLNaAz7x6Odq#pz5{I71LEN^^rJz^9kbS<1S~H~brm3(0vBz& z&FlFP!1AM#UmA+g(-myXje>!(RhVMxJ1o%dvvsbQ7uu??6gyT}-zLF%d9?7kMXo7& z*&|W7j-o$mDq{}&2m{Qm{JC_gXIfGw8`Zqu@Zz4_w6*r=Y;B(wUr?e(lS^pYHG1CC zcOx7nCDc%8*!g>d!{Y1OQ%Kv*iI(2&zlhQ{_{%);B4q3G%I6XjNiKr~Dn(+n-Btgi zvtPZR4xIzNIXlcv+{$bat{PGGeK93uFOixPj<{ujqfYAKm~<8PBehXXV4_D9QzS)mr2Fdwh6fgPy$`_x(9>~%WE6*~F#b?V;m%OzV> zQJg>P!L+J$P$t!Pz8kL^a(ONLM4NfM-wH$iJ?pFs+EQgfyMLnm@#1~L9*#P`H&0_8 zj_88W1ou>%t;13g^VXXx4fFsbP~Af%fNtgdDWxQOl*wtpTv=ZaQ`uvx zi0Cb!uuOm7%)aH5U~Ne%Z)esfB9rc-p{jf>X0K9Yb}k}y+jVFc=YD93+Z{L!-mo$p zGwDc7VlcV^E{rLPUEsrv9I(N4Fx(mpQ%pSI6>Si92hRtlyy>mCHJf(<$C7r&Nsga` zT!cczL@U^f0>W)--15+_D+zZ(`@FC*{SsE~hwDAFUo9xDrSuEsu&OXEcvRwW$6jnF#>9{}N6_JM}Y{*GH10mMgwYT5kC1}2IB;M0oY zc*x%?*^7-5vyb+Cv8>#lwWB}Pt1H_?w+7Z4$OVAQ=CW@sj-)-LOVJ*;>h;|cqiJNiLxrmf zm01}OWHCf5=&Xf)2u)fJIe^-lb&BdzY8sQ)fN8R*a3BAw0O)ZSP3A_cq~Xmb5k!MdpxI(hHAu5W#iwM0hm zcwC_TK#y{A#T7&i%$nB&^dqh_nNB_t!bn(`G!aXh8-;=h&(?Do70E~x>bgFFq~dRy zmjF~9#dE+FVKG6F*x|!M$O>aFKBDVcum}kWLJR3=zWn;OJ8(r^r~^X#fPZi^HWsA4 zspMjXF@2lt0)`mnP!ZEY%?Au0>JDMd*5kk-g${%w@Y*=@b-nw_6BgU^VY=Hm{r?6>2x3j9WidC=hu{<`@k~acg#z&G(Kq*v8K? z==c%8+-3MEjUzhniri;oBfscN7qd%s@FLgsL6&~efx)RO{2bX)tI1H(m-9tV6%Th9 zle@6UkTOyJK(s&5&9u@aQlC42oi3|Aj5B}BRci1sThJa#x<(3di{}X4G}1jdcr2t) zS+eP)8#W-Uk;{*LJ#nW^JHk9y)K;?Jo?2JP7z`hJxm{C&DQbEO;3O6iWq4Xw|4Bdw`F<4uG)|XFsCoV|slcI7P}l(G%8x3NWeTO5P%1@sb5VFM#d7ZkrfiU(biFJJp8@A5& zij4aM4tE=D&1`danGuCi9r9PkN-eJ=w#OUgord3KB92P}+YKMm9P$xv1pfSI&*>pc zeMapEKDD5JtT^soWb{XYzbF2OkROgKnXv7l`vmCMdX)0LGMVd7@*#~ikkD(Co;MCP zpHi~n*H6+~{z|X7*adh@eJ6`S@sC#l(_~;DfY_{C<8{p=ZvbQU{B$~${r|wf45@5g^Kgm`;L(aJ5$y{9X7X~1TMrvB0DI**Ka|Vd_B)P z8QLeozOcx+yEs0cV?wagITyhrR}neCBt4xr%Ntx)S3I~V1V2AG?Yo+_r@0OGjLI9e zpx7Y1_aqt&o<6|f>w*jT16?B#O4N6Vl6LsD^MfSh#64AVE|d3azv0%_sz9G?kdP=O zf(uA<4nx$HYX-n)y6|CKh-d8`UJ1j>5D&PvR5FP&&$!{GztAW^z8k0&kKAt^m7~N&_Y#$_HQjYpC7E z#m@lIkDG%SV>2(jnBCG~5O!?4cFol)`TX_#?*8KqFolI5cSf0(Rw!Ou?JSoQ$Vquh z$lN=j4u}mjp!3Q_Z5Bt<>Y_`<#&>6)B&g>mY@Byz_!t7XvI?q zLlB&CdGA!3Z!tT0a;;H%<;^AB+@M2Z}MHH^5AevL6$KN=`v ze$;1i*_Y;c5v$94sRzFM0VEqZzZ7_UzO1zF<4$&lx#{e|v%(E9!zU267QPR{gbZb5 zM~!l~%b9wdNlm=dDAQ+UqO!-1lZTegb*G_T(;d+-j2}GcxtUlO#VTg;`nHb1O2yRs zoxF(z+3RG^(8uv;aU%0Nk_JgkNM6K^k9FMA6mlv&PtM*iGS`9a>sTFCM)zWb+z364hC1C{6^E0cRQ!L%51JrCI?jI{RR+2Z=fd=SD zE+}dD&vg%`843)(d0%3V8)LOdox{1KTz97522z8d#*htk?BZ@q>2?)B*#x#kMYDVg z0aX!VoY`$F0CRk-LW*9T&}dQ}m-~BU0f0!@CS;ODAFfmYWGxgmh5m z@x%-ghAG(y!Kn~vYnnExAjg0fsk;doT;<~*>`2>+(LKb&BYi>7t=}#(vw}VU5_GJ1 zSO?x786A^Q1HF2BEHr)2-l>CDQM{Q{VqhG>;n(JmQ})9kl!LHgOWq0vm0-{{;JHZF z_pF(TlR?%W>sQ5RVY(Y3G`+iZUp@)i<1mfPjCC~s=+Rm5;msw_eBt^n#8~EC>&^M( zjPY}EfcJjYhlVbl2^`5KM;niu0}2Y3tj1v_b7Z3fuRPj3EezFO=Fr($-j$uyRsq9n zBfF?H?o9|ZBFd!*-)WBYd6Z`i`FLTg zztg6#eS(&a1a0w4Z!9Tpl291>ASSreW2xKIMgFni>n7;LAU!vN5Q}ehjZ-+^^PK6| z;`%}O>({T#>i3tgx`9BT>T-+%!|N^WyZqzBoxP7_WR2UOBd^hqY+YQRUqGA&Hmr0E zw)n`|jjE504AMx|^X4vzhJmCh8(C7E$?;*faY`8Elj0LT;?z@4fg#iZh0ax^y-cuB zeZFZ8C2krk|0uQ{DjDe%Fd0RfodZ#w=n_h6+$lclSh4HMV zj~9nSHo9~+N`WZ@R*c9wATX&M0r~-Pof2 zWn|CMEOKX7TY0B1jT3uAg>QfVwS!U+%GKYux`HG7n~p2;kcQ=y=R{>t8;@BKZsNIC zafM8-0cu`S-~wv00bLf%4oS@McMJnH&>wmsx_j|r6>6$}5!8eFV`J>;GcIX{E9?3J zlRll51mdjAV7lj?ZZD~YxLzz;vl+)lS)n?$`!GWpE6=_Y2q|lrpOh;fmM1xzRxMy!D z7@_Okz*Jo+99LVSMH-@gHlHg{5O~L6a$6SwV)#gyZ6(PoS)&09X$9UIt7A*b3(Pod zZl5ZQPF^UWRV8KFOK}`m z8ABA_5Nd7+@z+?Di8j!n}L!ODx4YZQ4|gFEnwP|>|7UsK2|uut+jp2Ed@$Bk>pm#b#ShP=EI zwu?*d&3{#b;-+78Yzz#aSgGQnGrBlaLx2gb#$W33UimdfiK zTaJ5C&u345|BgDS4&1O-91lOq9~c-3+3)5OgFdo2pe8@b9l+{D<(+}a&i$bqIH}QT z+HZOXylRRbxM{Bw>6BISsq{;1=z0#|D0Ng4Y&L`sGVmP1dv=eePFJC0R?|y%vKo4# z90htLuJiZQjlxqA3$n8X)?qit&ur}6p9c7NvmP(f9U}-x9)u5A*urV#LVJ5(?lmrt}zS^ zwwmW{?Q!Pc#IO2c>n-FNPH;MM&VCNqm9*rkrwS|ry}Br` zJ-%N(wljmGbs$IE!SBtnWWG9;yMHq)KtqF{9?Ri|11H7p9vf$D>XC=h#UtNGBG2$E znKE|#kma45jBP5i`h78+8)7lq7bcG?>RYz@0zg-Je7RLa6A1Fz)b#1l*7t8EB7tVd z#~*eQiz4z|u?=~|u3MMcy~{rn7ED2Du7P!$6L|9q^mErm5~>c0hLkzHojIB2M{95m zS`@^2kKgl&7xd}>yeI60H>s3vv;Jt!;=3~^g#}~11Vv32pbM6eZhI)ol&~s)~LGySDZvWJG1b;%-4RX_w#t zof7f@0aGtF7~@ZDJv!SzSu-)eKYqcd*3V+;7*x0Y+h(o!Lo@AzPs`N-FdHPpP0yZI zID}G0t?Nhstj&45WF9KVLV2^nT)Mf6NEUWp(zyU|{^ozsxHlXXx^&-)&~$miZXl4- zcuM49M6yz$)22gE78MJis$Nf6t}@4mTpe7rRF+3s`3y`P2)#iaxaH95WeldX0wHeo z`!z1c__L{-w{WG^{o-a&JoK|$7NLs%Xz*J>Gj#thyYs!j1ntF( zHy+4u1yiaHw-c$m+;??Z<^#LQ^zZnQaRjgG59_VSi*(-o)JN$w1S0y8@he4(j`<{s zqgTvS-vadhKzs3 zSe4ekP*x4bp7{jH4Ug@A6^C{HAn1dGu#W_1HM4z!Ff!B*%$v15MX3u8K%&p*%jf;L z-G63**~gSjGmuJr6-GwafD(h7H5LF`YA;XGSj}vEIKzcP^67X%^06ZLLnS8uQs+Fe z`?o*f3u8yT0WW(LpHS2s4TBaMKWFUw*6gj+9$5P=^xW4;mIQ-9aEI^jvwv>pPkoEn z9%X1WrYVRb#%f~pV{CWGF$>L}*#F6+>Jcf1CcNB!`^@U}ZJZ5?LKk8RsaS=G&(nn= zUINajYFqOK&%I_Ixyh@mK~T&tp&%MJaUWLh6t8kgwd6UUMcT>n=lxg56Bk^0HV=he z%-6%3Pb3fagy=#HLoh>K(R4AWxYg^y?tQSp>V^vZp$lL+J9O!#Zd#R7KND?UU3O{z z)kIGqr7y!iwo$YVr%$9qt*pxXgNl2jOEA5PAW9}m#?IcUi$0RW1hFWodKmzERChl9 zeBuw`hhpE2A1-mFuCK~(XB?|5;}&oM`vPB0-Qq=Nb2W)hzl6uj>V>G@6Ps^vufU>B)WoJv3a0o!P)E z(l+LmM6c(ut)l6G~p99}HzCw&` z3QbI9vDb%$$t)*c%ej_^yDWYENwfiM>s6*dv|1`g+aqJ|*+My^9%Vg+_a5mf?>JMk z{pz(1GZoc5l$k{G6B9BL^XpEN(A)|ovF8&@t29v1z6SDMSb5^OQ)l;3yH|5S7S&&a z!$I7oS3GbkLVnDvmTi6s7(hnm(pGPMxkySh>qv~d(d5JbAzQts*se{rO%Lb=DCp%@98~8%QNbHfo9Lpy(m+xstZWkXMw{Gg`oeivFq{bhxzW6F&2R(2EkP z7f~wpSsFpf(6Cj<-YxFbN(Q03{PWLTMyixbZRtr6MRd#pg$AisaRsFv$5%ob^Om)X zQfJg@kyNn55{}fLm($?w?AgEyJHg#_ookWxgzi_=;r8bl`AsPr4C6b{+8bBHNQD-- z68?NDiqWV5DNu}JjqiZIT(#E*L1LIQ)|dK<3P1`%#psQMlzOd7ybdU4F24*En5ko9 zz{tDK=m}!~ za_2vgm5;+FS%Y;&YdoU9HpY&p`Y-n7!~Kj`zC~Q{p^p0PwJ>ZCvFEkRL`VuSz8&Fc zEU2Vp*&A&u*w3{bD7%ALONF0-CQ9^g74A&!XR2!}B<`*?)y8ek4Nb(EuEGr9Yu0@Sc)2{eL)dUdkC-^Q+7o+;Wd4^t49s5|b zh-r)8s{O(@D1|A}^z&G`O19k0gaqtZ)4Ei7f_cd_Q}Z)%_V} zE9xVk>A9M>u_#@|*;5nivS@ezoDGmQfmNUP*6oM7cJ|;~XHO4p3ZGcxnec9uj}Dv5 z(#0?E&flR^EPQ?X@Hvqd>z12465ChPuVm4EFtFCuf8ZD!3PWKf5&T5!jFsDen|3yL z7?`Gtkj}nI^~1$D&YzGbhESZ$8X~22Lai$aErh=6YwBTVilk1s_EzLi5x*sO4pZ)y zB$&e|-P_$?wYfjk>N*V|`_w+IIaNXmU>=m5(a>TUQbk_a$UOV%P67WvV1Ca|2V55Z zC+JWgvDmGJi??CrP zr=t%@>w$Ku{B6@oq#w7$ewfw!Y!%)64aNEaVoHvMkv7cl2CxE4&9QO)i`FJ+5Tgq} zVToJImEFU#;3dW)0%jzUoNEdhdT8g-hhx<>zpy(i76pXlLmq_Y24n*hEZh&~aQ|lb z@*P+9G>W=Z(IJ@&{#$uubmiXhxioGl&0Op<=zV{Yj0+O`iue;QnqL*F>vz}ZuBAn)R! zN&Gxo82z~j6u(&|jJ{_zFk`v-X_PS~k0^Ej;?s2QGmY;SGen^W5zWm0u=}fKjv4u{ zUST{HGQKF{j~Db8F%k+4>gBy4CY%xXmMJmrv!6~}J8){-YQ3#2rHp?LskV|*&X*d^ zZ#Prb(G2z1L=?ATyhc;XuJIjA^39fHG1@EEZzy&rp8DO$+}v5w4Lf^@2lrCV1p zAg4$DGk>{F<-AT{9!Of8#NM=Y!PiwQ2xaBYd3YSU_zWsZ>Se&Y4Jzk4g)H7d+s`-) zSplHJD8L@!dYdg8y8UVYKB@(rv%i(EurvFBbJ^Tg^-za4BGx}CSSc3UVt@q<^_ zV#!4b3o#|75sc&DPuOyfy-tRgp5xEVwYBmW=oh6#X`GEqbO2H2GJLRN=q5{`u3yoN zXyQn|Cnqnym*PhHY|<@8pLd%P{r*v)NIUZ@?<{>knk|@85>pmxQTA6OoGMe2Qimri zSYTZbN??xZW0sQ1XBr5#q3a3*Yz<&G{T$)$;J8yu63P0&+)y zxjH;X?B+Gg&I{%YTv6t-B{kl+Y_E+0u`gxk>a&QzD7W%>_<=vLT{s~Lp-t_!O=z`ln zLl=fyB(tG8a1eZOxkOH0?{n&HN1j+>DT5!;iU8dd;@#!6j_SI$I|rpMW}}3b)P;`+ z5%&SVCef9uT|yH82BoHM91;|#35tIZ7-0vE11}C>F7I`ruI_6p@T;pKhs1Ke512EJ z3K+pg_f~0MDPFcV_x4G5?Jz@6IiW_I*VVs}*Jr}1F+(eFoX{^Z$DWSgw87d1mY_p* zu}WifS>-XGKXreJkn}m{3ma1d|A?KK*qURvJzmdtCJ1)4R5crXFOWp+NOli|F7MGC z=)-OWkP|0x#&4h}URT$Lv#Aa_A}gL69de^-u`^|E_qm^DA4vR_T3vyo6SKp^fUxc#SoSf+j^K-EgKSZzOl08iKOrd{> zm+PYI*n!5*E}^^+Lqb%3kxb7buAA+hE%g+glH-`%5kDZ#sQ9fH*%A=wQbn)X%eic{ zzi0NZdY`g;h_i*hoWB)_y?`!VM zvzJDUIi~PcD@LNV`eU~V7pG2zeGcui*7 z=dbKvhyPsB-&8FC@7Kb54{RaM8U*#8x$XhfKcBGYW}L24VS^WU`6g{ zgkMoXKMC(_@73)4*WGmQ;t&X`lbc9xOk_M}HLw+|X!dW3aIbW~oT2)kbq5X1j1&VU z9><0Gv;8}U<%RhWqlvK7a-zjAnENn z=zx$m{8;8&on|L(PaRHsiM~9P@sJ-NpjY!$-~6#%{v6{GS1F z;mRd(F1AYt`^77}z7@CQe+It(`bpm`Q(^$*?W~xsKo4|$mV#gN*9BCaY94-Ta5q3< zD#+D~O_X3CeQ`D^n(uoh`FvvD+b(Q0uy0p%iNjt%>7{!nUajJxWl)x@d_(d1oa^~; zUec~&>W$>PppVglP22sjWUK6ii+3Wu1F-YFeGJFeO|`ye^047G(Q6Q^8e(i#PNv5l z_(s(Y+~KjYus8o+=%jD%It4EFFXCq#89(QNX>i~Akm2r>ua8fW4UZ2DC>eP{`_2RfiC2@)GC zSWUtq9mhfk0}oYpYlS;$wEz|zRNu#0P&6pxaEiDBi?KEn1r{d+-qr`@4P#chf{(XZ zC7u;3x~-Ge|CelrYr4Mb!B*w={eP121@b1brccF4wzR8%0@KN1oSyH7pQGl9?p!Z ztws!444#+pG0=4fP@`0h_H`p7ufr9rXiUoTJDxtVrU*a^VIl60EU9KC!g*jpY4o=D ztFr1R@4_fb`sB{6#J~?)AVn*8SIs7QmV|1L3{1ePgBq3u@NEUuOy*?sBuQyCc9C;8 z%{57_T^R~Df!e)qCJ7|`p~*rd1b@KyF1@fhK-IbB&gy;hGd&BFvLsAB{>~}yXQjL* zchRwb7=~<1l@60iD;LLn-krZ@r2wy1Q{_D~=IguBq)-4q5FhQPn$M7pYq$&Wy4ME$ z=hN^!5XtjFP*ck)%K$}+3$6aeOFT*^XbZ~@UZ_&iSB&aU z$ToR)FZdPD4eR_`$!g2wf-`*+GbgIIU5p<_@Js|KJi5;0YgFQyP_t}Jv(t~meeR=rw^_m_>f)A{V{dt3{R%JLZ85~60F z*!K0&-+{S|@$6ZfE$jPJ3Hb1?kau#P1CLyA)kE({lM=R{NJ*DA1wj+WJXs9Dt&oNu z4cF*1S;C*1w{vw{W}Zde20D*CvZ}Kc?MwwKME)44n}2qI(bpGx0A#Abi2G1o`P?;;KE#k3AAi-=k}lt+0WnYuo2!X4&EX)y zFt7E!AZy)1pbxwR;2L_@R5|q_W5Y8)tFJ$SFRtWp{eE#nrdMe)ZpDA*#+oedakmf9(@!3@2UFg1#h*| zQIiX&EVALytfXKSE4Sdi2nQIz>XutKoTEsu?f8JDPGZP6`;)H9LPc5U)53}hujose zFD-Kbc}nni;GUHUsh8b@uj{Ky1cCeOGje5BAZ1(Ufi|7qm_*MZ!NrX(U}S<0w`zTM za(!wp1G3hqlZ7%wh-x$!JbP9mC_(tHPj_{(K8$b(snO#eb4R@u%=agJ>Liw#3Djk$ zbc_?k1-|!Q+~cWEj5I2E%Rs!_{&mX)%vle<3R6#&(k%)IR;45f=0AH9e7DFQi$8_& zZEjQ5XX%$F<))-k7&tilX;)4l^IMmKgcoqBf*mCnMlcCbLkP@?aKP*Tv!q$lV237&&b?i0AshZGo#-fChR%&o=PQ#btj$ zvXS?*+IqIrxR{c*zN5fMXGA8vUd3MvlxL1z5%tjqkxnPGEo=23eO(k_sb6HFh-QnA z;nf2N4%y@}7OM4b8O!|Zui+es@`rzFs3<3%6)&>Ok|{V<^``Rxl+q$7%Gkfw0p-8;Ib zeZ(QdJR1h)UgFIwxiW4o28+)q!qM8KWwc{md{E}aS+cmI31)8Ly`X#}$K}U{a~xR( zLXtqx`$`@Gh1SuMlh!ySSA%(;50mGNr;K58LNETiBWjCp&-(%BxuppuX+TjF@)0g| zH<_f>^F>ep-)78*N$9x6A9vu)l~wX0(WPH~#UH(ONh*;fn~UKS^S1^@6+j|QGONh3 z(lk*}fI|7|H>@9@|IoAOh4ehRmaF-zLnW*DvW>C~=RYkkBXgP^z-Z(Smi=<2;Y40gpzg71&yw4E=hm0b-|m0>u`!{?6rPfMJZP^-r$Y-EUbQ=ImWtl@g5hp~ z;?CL6j`LLx|G>o5=G<%rMJfPFo4>bQkZ?)t^U5zz!uc4a&nSGd+B`-)^>VWcxhd@x zC*xQv=vYUymeD@2_`y;~i^U)3qMVV#cnhMmURO9=XxY_dAV^_m#vOC6%7Zd$o_yMh zwp}{^Hs^ZErS{1{)Q;B9+jqGq^w4>R~=r`DRi&HkJC9X+vjy?(zxvDUU} zl*O`}P{ia(>SEkvu6IX%E~OQ4kZ@LbcEze7pgO>_TBs<$#`3N?3AYapuS6e7+B-;Mxe_i6)%gvP{72=KJ!b)l zC7hRY7&?Q0=G?O{efiqAlD$0ejyT9EMH z2WFazkKu>E_x{yAzfgM+ZZz|}NDrtyc)dQ~=zadc;9Y=t>9YIc9$Q=LNOJDW+PYFp zL+auSQJ^H>jt|((LxRt2FU-M3um4Z@Z3gNGnUE)lHqMmlZy2Z&Dl-0z@u+1>(E2D* zBbINgSnXTZpV{}~<&0!s2|CT^gK1=xE+{qz7^zUBC(aJ$%*^1`#6LF)(*aGomX1Cf zH~;ftXFH_DL1M7RtWp~v$!d^Z08rKUGFgn?%`)u{w}_0|Rtb%6oDu6XaEQr^l0NXR z(keT>y4x@PP)t5A3lmb7GR~xAQ-QyrM^zpY+N-Kon4fj}+S=XC{(bDKx?ye#v)hwJWv=%_q=w~tm462u3H#( z9e64LieQoabnn+rA(Cr*qOt~g`CXYgx?ROc;($h}WKXR0oNad=XRobU4yy288YpT$ ziqdcSAMCwnRFhrTE((aCQWOP|YN06zD7^^+B29X4L8SK@TBstRARtY8@6tO25oGfvLZX>-JPPO6ZWqj^vK@@8HiYHm;oaCl~1aGp{@MFj~+qh)AV zvas;Mexgdopw3Rs*|`!{Ge&T2R=ZCQhvdvjK5227ctb-6fjS`P7WR~-1sT|9_+J@ld9l``#Q1NwX7IW7}gX-dwIw@4Octt%29D zx35!DQ*QvBEv{OLz?wldE^pDT6CUfwiDuwP+u~6V<g-@~?+7VXeq%4lA+ z`=xznU}h%fP1ZkVs+<%=eA@;E_hn~iuL5)~^Y*Palv7w(uT+1OQ}z60F{;+2ov52` zU;N+??{Q4#9yx=^S~_IAGGRpCyse---dSs^*yYI-Kk@!dKd?h`gdXLvnhGJKyC^pW z?|8@n{VrB+f19ecW%H6YlgBO^JGEUu8XFUJGFq52jX45=pzo5P*i`tLS*MNsP=%z9 z^whn{1bhSfA8B*_vLDL*!qqTGaI}LmhIt>UM=`z{ z$(7fG7;typ2zoR8UsdE>KPzf%`(XW}0*W zdun)lL&@odGRH~{ih-6yfDvH6^)?grju~}!6WPEvccDgsGB%*4uu$&Gvw%aC7pon6 z`hG}lI?FhLTDrW!m#c*;8RGi2Ht}UpG`o=}NCGB)^a?C?PCo-i8tKElYhz+#x0;Zm z|IUPXd=KDyNkFu!>gy*D4Gq-*y2-RBCZ~TYcwS#8;~K8? z?t8B7T|{EMu?e(=ISm3Cw{AeTd^o?`egHyavdN=-vfi;gUn%9x@_-2FilUM8Hev({ zw)Jl{%-9gbp}9sXj8|;6XUTDmt1gW-*iNmp3<;d1C}wlF-lDI!w|2`2_YVjfc+Y1& z1Z-7zNIrH@Q;P=NM|;)1g{sK9%V78{q>Hatf%}NXNog8d7G@cMqbl$Aa=huKlkpQJa`Qy!~=9+du~l@7vy6 z{ee+Cm0Gnh_G0bQs@qzMEq<4q(w()ADDhfbq%vUWVL-0<)YL1Oz&)P^wD|1RE10(J zn)czO&dNs;uEP2NBoYo)N)hPk=@~Nen8}Nv6SM&uNzsb?G%&9hm&4V8)?0m|J7SlZ z7MzxkPqW|UkxqBLvg7{?z618<_cW-r(Q+W0y;nJAQ*F_Eg~i=;OWN*x6EKKJ>+Fph zkDb}fv7S=>+FLd=%|H||2syRF4N6bO9LNoePv8AxJ0yU}bY~XE&c&q-_}@se4);~S zrHO!tfcS}=`ccp2eS}`V?Oj~ux%Yunuj-dLX*ux5GaeoutF4)4!L4i@YetPO=6R*bbYWrJ#(=XNZ%wVXq^GAdj(ATMWc$+nNKAZy zL&CKO$U~)-3wW*mfFRiwtp9GNq?~kkc&J~$8}aq4Ch*+NcD6q;EQ&S2OmIh8(xEn8 z-yTcy*Q!lbR6j21Zu(dHNZzJVIUS|E-;^{~s95f5`s-Xg&C!tmGfgdd0;4;avX* zdw2hLhX2hX|F25_TNVFn-rY)LhC08;=i_a$`+i@}_vm-64Yk-+Ls7g|n)1xdR&hwy*SNAj_ylt&?_e{a?p&T9Zo39&>Lc zU(x)z+=GyM+M@D{tq4nWvGdT_D|j;SJg3uS>jX=yRlnj7z?_?T&3&iXHg5Xn&SQM@ zeevsA<)`vh23CWvMa32sc@WmA_l^8H|BVhz!>`Bm$yuKfMtGy@#vH(#xOcZz`ZepF z{U6Q!)u}Tx{qJqD*6uogn7B%88Jx}Ocj?IXSoayCV+?sdi<3JTRT=(hv8)ipdScO0 zhD0n&jSxoow(FB%OblL3*&9w&vRDs%A_muLubVs0z%j<7BYaa^wJ>e}hY6YX;bRFT z5ggS!mp9QbJxjc&E7g(BywzjX0e{srub2PEo;fqyl+4JO6_v!yq*=YjHlA85wS2cz z8FTxjE$xy~AC|S`z~ehd&%r9+>$WaB1RSV!bxyZx72m}T9orVzH}h;cq-~~gUJzR^ z=pGfm*M9kz;R@;g#&w}}t`Xx|nj(jukrC0~`n<-(QCd;m8(YD(-!dZ^ShoI@cr~0I zahPK|Jd!FD)b+yG(24`&@=T+gTTHO#pK!9jo;Gs*ZwODlWnVs;nt9}TFSp#dXy&Y5 zlBd?SAUE=+qn;hV5j*mcmF>lox%L_}52Y|0}^*;Z4o}yBzb58L62j?RRs_ zkv2)Nfz^d@w_2W~Q;%c3BWio4SSVNwA>goRK<&93JlX`C9IrN>Vg|pfsgL{nI05)y z2QAsAu>E{JaBX|lHTK#jaBkTc`>XfG1_BfHkvqX>iw%VKDOj6UC_E|I3&oL~A*Sx% za^%@&lW(DXAvYBxi;ki;V2YnfuK}rir>ibn+p~hDr&cK3YWRD)suKTGq5C?IhyMV6 zGdcObnD^0A(wU;_BYjDuzeCTwG*RarrYyyv+r1PS8)Q6qQFRNkeA@;=$e~JJW4EvS zY8Sk#TkjmpU+`mK+)qO5)c!BkIOTIZeOhsQ^j{}GQpZZZd056va{l$EkRFl@_*y-$^RD#Y~YcZ&5ExnKl<|Bc}Q$-*p%^0qgO;K zNS<>~VJ&)M@IUs;@$i;Q02Q-Yt@xdv6v=i$c(!owa(*1G|NFE_s@Gk<=#F(C5Xbp@C7nD|i%aUa}DLW}L zZXnp0v7%CFfgGnAK5FfYKI`$Ii@jG1j%qep{R@n7bsoxv%$Y_njoJ$oRhl*2&&>gt zvGm97wa_N0uKOAY2+tjV?;EL!Jg?QLBA5^rSMTgL$2g-I_^qQ-i;6_K_ZdA>iL7Di z^z@zvvET^5J@J*B$&-7MJ10SCq03R)Y326+Iz$A>956iF%stI^$%C`4zjV+&-b4zR zTNQAd*+ELN0LCmWMV~Ep%$D~%7;N1PKIh@dniL3jT<#wJN+dy)c}HPnajr?3mGz)C|3Bun)aYb{ zt&1pG)Zd2(Y}rLbCdEI60SO~#d%RqN>nOc}Y&_jJ-%nR3H`N?fG!^X_XV?G=v(GQ& zN}~ezGr9b#t=b_NNl`LTx@{pcf(p+Jvrl5&Xi6uaqO-%Pz^gld3G+N=3_Ca2x8qSbp`tia>=d2-m4M%7 zN?T1QO7mAH$6vyne=dsOc*k(!9&wkKy4Z4_hbH0))l3#Fd;20Nm7(cM7^O1@jiQbH z`12W_BcZxC3}FItN{f^;rA^t}UT@yoX`FMthE43|$fjsN)ckc8EKEtxmXIuCn}p{4Z;p}Xe0EyVeHGJM)rBo^ z?{>O;B6Fp&5-p{4>)`rX_ut$eHOEIr{&TiFO3YC;ks6YqS|EIB;jX!L?}Rwkj<-68 z!`9I+C1=mkO%j2Kc-FNq9;~)@Q*)grwW?eOsJbbkofBZhUPhgH6!+T83zv(|dK_;A z)HJWm%hE;fhEdyIZrFA~hra}SEl7nND>rk^F(;k!UrUD=vmbLQzu69)vh0^egxqw! z9o-Y5TS++_E-1}UdS=|wG^4sK9^$MX<$=dauhZ;iEY@QC>7g%e!?>OrTA`lH^W5vx z-g~8j7_JNQy(jffD!h>BV3tjuq{Z24(LoI5WiMi z^Lg*-_+$Ht%qf@rr(-~u-6!(wuobR&kZ*zSgU9(j0!W4PEQ;J!BbP>J&e& zX!f@AhuLPVO9tI|jdWMOro^X$a0> zH&0qr`{GBXu>X)=9@dLYavBy|coG?!E9ZxizI?{c!v~3!rZCW1|DNNo_LZ&kN|45F z#>TRSUh%JYh6iTPdHi4Uol6&-ZKf3=_GhaczcVLQ_Lib9p_n>H0`yWSS;&1~z3dcn zv2sQk)RdrN%(T3bmJ2Gwtj@|5_36$c1gB_4VUGH=s4L)A!Z;ALEHwrVX*MU2LI!Ri zf^*_${3aSai0s~XZICI8o;gT7b*l|+#wXY~9+z6o{MPjSvE#sAb7brwgK;~V7YB9? zuTM^|h1T0NM1y)Wxrm|@r142FxB2*44|EeZ{JR)5LT3*eii!?0qg*K-Y{6zcnZ>4D z-g#Y`hqYSExvc=!S&4;aI%Q3pM2n1hWgbU(>LZAn3k4ThH;}u`G)WN3(^b^7&8GgX zP&^y^Sxa(z+%felH>=dUiuFaz`FW9sPHD$qFREAE{pG7u#EYbuwEM^qRHxIvF1e1C z4+#q6e@aR7;x;sMlsFEX+M_tee05Jt`v=%Ze5)PHVQf-F#V~u67TNE_1 z)mx$XHs=2MS%N5+LR>l$Y`&dxOXrJAESKh31tMcndL;kZ>-mt{W zA6GLfHP{(geMq&D{wFkEy+(6;*NS0lk8Z08>tCCT3r`-r@M0jf<>;K{lToHGuAnlW zyp2CH8TwGP*0lq4`S@HiO$(~gQE4CNI~Xy^?@}d>K8q2zJ8KC0`rD<)+9Wa8tAs8!F7tV_!39wpHn>od;D^e+-KOC8HgEnZ;XUJb zBXex51iV(PLIU;}GexT%!VD>1icGpD9vk6W^v73OdhM{ORcU)J+p}d|5;)~Pup%It zvIJtmgxyRceQcw=Mv-BP^ruI(khVU?~Y7i8hY520R3`u}oe+&n* z(54-v^1H8^x)>RbrL&95CLcs=*n9Y7ZMYa~eJW5bQG-{b182l-LsChaN#wz68WrA@>|GZ&qB+*{t}e>5j|gN1DQUaw+; zaE)h?vf6i-))|ON;MYi+8{$%@GXxu&1&f^j@HD7700uX!Z+~Kf;dUq1*G! zeC)w@jqB^@hf8fu%<^TAk;O|ZRb#m_9YYt_m6uCU*@MFV+OC-L{nQy58JzU^{U5YF z5MxmKA=<6LoBm9_p8hhNlA>r~FsjRUP}RS8-fq2JSUC;mnqDR^82xw3N>9M-Q3n;WTj{y<&2+CEi6B5Lbsu zX-RwG4E9T<5eeVXMbf6gmNkn9W|qCt^5dW1mDuDwlt_)AbKQ2=X|>3AySND=?1GbuP>6*#YX^p<4 zkl1aYbbQ5&AZJE7 zyg6gV-$+11pn<{dBQ&q*XHv5yShC*IpUY<}onzd>--oJqsdneyNX!IOMPt|}xNhp! zhjKEX2(+w`<^ElOoVKB!#$3{^kAyapz}=pL!GY=GJ_*?hQH3W_I5v;^{fzN%xk!)) zyZ4U8CAOkgz-I5&Vi5MxjgdEM(OEkA!Xx_?VC5vWLH1-m4GX)|T3jT3G$@5JUs85< zjH(eA{Uk?=56kbRk3<@nlRkv1dYWTz7@nTCDM!*RuZW=u_jd<+O{nV4+uFwoE*r4SRgw39b8*(j}|o_djdD*^_rgw$7zA zr9E+?o^syH875E>j4^EtCSB~6ck$Y7jZO2IT(Y8&l|EN=R47)0<-0>{le8d}#Ay|b zw6G3LN1u#Il8$0vD5$>Wr@kK1qP1==IE7%AcNKTH!9-15&4qt|DS;%2LWK4wB!k&^ zH(CSYFIKWQ;qmN`R^dVcC|b0r81{}U;Sep+KC*(0hYCVI0{+wXtj6JODZ?w9OU~2O z)E)knF5W9}n8(4N$$am>SLMfl*pE!3b>8GB>&al^%`XbW9zy@*b8hC{!2i_z`UqIn z%`-ZXch16Ax#iapf(Qs^HSBSI(#2y!ZoWWfh-Zv$>UU%(^JG~+PMJB&lUMPbHA`hK z8Wi@+kX za6PnY#!v1z-5U9HZF`O@L4M3L^2=N0C-q`8n4j5ov$^EjS0&(&?r*t-x^KO7lg3I= z&G*D^#BlsII?}*!l6p$fxbtf?vrO|r_ScU-5E@lZML77WVAD9R$+)}sw&sIf1@-Xz zLu5?TXHSknc`}DFYTwE~>v2ya0&LC?T?_6Su3uUEPsI%?)bTgiuX3HRA`(cFckwAs zBe1$CwW;pe#m2-jPo}=hL2fxUEtJ{6tdIRfKE00NFCn0|Ghty-!~Q9k<;+(jT{cfA zu5=g47b@WR(oGQ?e#@1rhyO6-)-{m)Es|J=meJChM6CW@)6$y^dt=7AF;5`RR5@K& zM9l9~qVq3W6k`+|yefVM=)^2cB|@?K^(*|Ru;VvwvC$ry6gzwP9C$*EQlKj`idNmv z@m}Su&_B`|{8$bcW+j@D)7~e%PJr<#6*aq0I@LEk{z{ixX^9=DwVsdllsa*bRSFng z$Qp(~_j4gS8rQgvt+Wp;zc$3c$#c$sG_J|EWBUam)eECOJ(_$^{M9|n8g z72p2KCVJ!zZ!%UdhVuA`m?x6HCIP=wOVezvJt{5i$ZR9z!e1dqK~~}eH_6@mtTTQo z1`tyXzLqaXt|AM*`T{vCKsNBL9?#> z3HVz2m>_Aus~ivq*lmxaHTHzrhHd?N+Ist}q+V9}n4i?mOiY5EfN9pl%TsOQ{Nrp7(?W%f6X*Mji9u1yDcUp>ddS2_?s&ht>>>Wwg2@TH=#6#lm@1UDz~$ z&^fEvlXekpr>gUTe>FZHM+7J^h%x)_#Y>k?pY!oy?7QbxyVIg35bS(Q>QO&a-q<>f z;MnAE;ZBTs4D$ewRo@WMefVNcXSZ>3#W7}r2UN!4Sf8M^hUO5Pq??to{ZJM?&#v}s zT5yx4)wwriXJF8DsJEk>y_rvCTfBW~+XoKZm%&;&G|id+n(7nct8H2d`nUA=}P=c{A-&6m{KK2Xw zbb+m9k-q0WuXT*p2m3xB#7~~^>=36~jw=Q%o_U4xhws-lw{)#@sjs&dFAFz?12V7l z<_2C9)2DY0b)l>M?urx;MsijHGSpgCN(al5fHl%bZ{{54AXvzQ2+RYlqevT1sif%@74KG+YhGa&Z4q!-R>T)Y%;tIokFmK zN1dX`gKJ0Y2Xo9CjBaHdKw*T1Ba=tuTPRC(UvoFlyC_4QY1ZoHJxjGSzOZjx=Ml2D zIprgPg=T=Ub;j#60B}|C#pY?K$q<9V@T2+Wm?p-A)Ug)XA2H*zBVMnRd?!!*8q=b0 zonuMF=f2u-aK?)R{&yOTVsCt(_9v0m#mLe3eVz!B;ilT6#r&Dj+3lFz&%)&g_?DUm zE8HR4WiD%zro&W5-fy=1RuSNN;4%>fBiB4x;-7tEKN2_08WgDZqWSMJ6h@X)3opHx zfi^jXnI*F`ynJhMpnY6*OGY*Yx+%z58pfEl*;Q?RQ6+1F0v?W6c;m{Dv0lpj+_ME) z*LM`iL&G}7p3#KBO4NH(dzAKHg zkDqAHQz$R;;p_=ZMGk8=)#Y2rO0d&}Jh|W8J~>eMy>^4X2f^!R(2cmo(Vb9?nX~E5 zCOu3+CGPWujS}n>D72q`Bl%%H_(2*7Er)(wICIfq2ucfj-`u>6c6$)GvSed5(Qk}a z#<ZBeuGa_5d_aF zzc0e~!as5$+`Y?=rhJIw2U4=V0LXVI0<)+~=3t;MrMd0}_)scPco@{vLisV=F3|90 z=*cZF5%>B81|(HNlh&OL*ew9la!b55Vx+wc_fa!iGr@lTAeO$E&U6YE^uX9}Gc0L2%O`M9KN${!J zvT)=f+18}#+Oa8j*SvGF+gb$Ay_ijNEn2>ToWk)JzLb};-AEC#!k-IyMYIO4T5*U{5vXC`0TEhD!uHeYIPtrY}qM(Ul~|)9;wWikVUY^eOJivSsC$?L7`KB zf_ZXGHARNJaX-E!K3E?LWb_?AAzyy`+u)(oY}IcN5n=a5$3V|0xsmTCD$JcBx=E3{ z@%-X9NIP$PMu~+b`7E1qlKc}HV4-{$8d4=7L)DnurC+#wHjTXw2 zgwm(5Vu+xT>jASzKm54LMqUUp()db=^D|s0hO*8FUy?Y|_Taws>GhB@jyi0Xn#*e+)PHLh20vb_E_YFl!}p=7W}$9ACrVj=-S08XdZJfg~r!QXk_b0a>o?)y|>1V;!Yj%rL&g0E71 zj=s(+5=OYo!C5Ol?PD^Jv{9?lS<$mZfIIAgul*7!z;6{$6hFx1_8eMDFIwNz2u_2M z?w+$C24;Oj%Ggt%$-9s>zfC8f*)$&=D#|HZQpRsN=fvv1m4w4c@%pTQILZW86_=Of zbf^ph->k>ya^01sU3EQT)Htf} zNf6iG)BeGCwx;FL0F1OtxT`hRN(hbQc}D0j?|hqh0xHATnN+mNb$l3A_x4!F!K@h| zGy_$P@$6hYHP+!-iU*|u$Wi|WF?-Q*#E()i$Fs&*S;Z%g-=Bs4{O&I~rLKtvdK2q6 z>reskp;!*nxD^icu9>vHm+ACIarLiVM6|rqYDXmP zk9JlE)hrb+AGjs~3_T@kizR@$d+Yo6ay~bQL60zwKi>+#qK#+zt4j8tg5`a=d1{}z ze7i+k8)w?z@8W=K0};u^_`P}$cB0X?y7c|!+wBCn2OuWr{BsRUwq*U0wfo&KDB+vD zMTM!o3M*bit4>;Ca${^efq5@w&oTyw%A-CRw44X~9>D=i8r09K-s?9>OK6d^ z&}~qV9of2QYRD*(HnBg_*u>kA=0f>>;KmQ&r3LcQZ@xTW-D5OsNtR{F@jK11<~BBA z3SskWJ?;#{U;EOg;!4=)MuW&+fNli5)6j2e8)&@{zJhLX+=nDin^~@>swo2bBFstp zcfG339^5hz$jH1#1ysY6O~?WB>pOBW3fxWdJm_Q7T|iWsHxKm3yUxBTp7>0@&Hg~= z$49A_T%dSMBA!&bjKiTeVPrfYTnV^MpqAg_o2GtAMF$~6;W+dmDVPhCqutSDCSd;< z6D+;S!he^M^=M;p@T?G_MLGB`@|8rj05^MFER+3&jg{Oyv5Tj|GTf=+s%SOmcX7`y zs=Jwg|E>N`j)|hgkgyzX;?=<;2N zUIai|yp7ielM6XYYrXtup8~6$faazhtQVum$q;EQp`Dx_D1_N>zS@LQITtn{@qB~Z zxrG8X>7F4?nL32ViMIRd%!19xdD1c2^79li?N_Yj^|u#EHR@X&n3gst?bn>@_-aPZ zq@C6s9pY&2J8&0Qio=wGgGEbJ;uXSldmbjM%>p&z{ywRZI-g3+5-kYxO?ozS@wF~w z%WAs+mWZ~GH8+0EF>z?0%a*44U=)vOau;~d2_heHimhxCKr2wm@>PEPi}bzT5O@GB z+$9NaB3a&^&`BTL0(3uC8B|ogpv3vLM!!6m6Id>M%FT@$IL;s?dtT8O1!SM1J^eze z^(karr;|;k;M$EWsevzI(Lr2`$~SW5{;W*KQ^c0JWF5>3&kdu0gRR%kkP z`%nHGVOt~}TD@62!bo<>I_Z&zx(-CXl-XndX?|r9)x7qGk>5m6S|?{Iu1&R^U?Zw^ zI}TsnlL?|%dME<4o*I-ZiYQ5P?&B{}jC_B7OeeBioJA5|iedFYnH5y1jQHH~XAY>U9GrJIczL<+sI;Fioh_Rj)WkXZSu{ZA`SQTwjL9 z7K>2(WkYwCu1ekA1Hm4*knYhYKVaE*azy_jSZPEnhS?I0QMayykymN(I@dCCks+R zT_EY)i87e1>d=qvn&WjZKQL^#E&`r~fH|C9J1!r$<}VFnww@ zd{Nf{vA<~`F#eqk`Q68!3xvlj@V*2Q5ZpNKWPkXf_2+}Hl@*oC=O9IkL4wJ*yg5nf zvrV?$Jk-tv=B22L-*0PFeV)SRTiFU9G z_sHU0o^P0%XsBI>52NgcWg4C>`Pg`Im68sON==2Z-((z6ydCY4{Wo`uyNtN zqG&`1+_G(x_DQ+VEby?G2M456qNI+cU!E9lah8fg_!$~}0vByCfj83vo@WG|#WN^^P?=2BnaKE9GK5C*Br#!6`lx$? zdY9KB7U;=qD-kIRYj$lp`ssVGLE^9@A2-b(aV}K6T6r7(ISQtD*~AAwG8k^P3(WU= zb&KCi`d1o+^qgvL%aFdV_P&5H-rNOEq%P@66|3eCO)J)9uVpPV+VXj&^NeeF<2!b} zl#L5L=Hzq~&4WyuXM9TXdU6iZ!W;`H(` z-pcsk`*;l}ajGN!GWR0LFU_5Q-y_U5GLG9>TqrAiKJIw$JXg!G@u!+JsUSqA@BJrM zk~ zC^!ZMwT?gNL>OhK=d|p5wNgYFe{ApzYO4&a7e;R*C!wfiMf&8~hI1LKh9IkjVs6_t z7k6j)#obn2#p~MN2!?NM{5HfH;P+*~mIaV?>MVC&_*+cHD+pR6$+d z^;7A6vn&yufqO?QaiWvqak=9uR9$`jJ!`}waTLW?CRkAE=YgTGG=1H0igB#FZz*ik zTEuDjZs%x&<_M}u;Ridxx7atv5Uuv$eI1I@t)Dca*DRU0K<>|fqr<#U$1iV998-Ke z0uQcvmOW?tGK($c>}IxEQBqKQcyHml_vYO&Bx*plh`_P<%PTdAf$c*mp3`!b>PW&F z7v8dwRe5Z7`W>GwDifq236&_O^QFA*rk6kKBsrkn)h};NV#B-cBYtY0l z)(PFfZyjNlHRkTrS?0^b^50Fo`_U)R1We``p8UkWpu*jbrN5;cpSZnv4?}?0s7nDZ z1Kl&PtKU*AUb&An!kbG{r0?h=9rG?|b+6Ad^4HBuxWQE3(&p3y`dI0kvrI09cPAWw zwco|(6}I0$JU$^$7gN#d_UHhHR(A@LWaY6V!YKem(>3px@lL3;QV!;oc z@V&H#&(AFm@OH)Exc1imi{u90CHOAOwAC5vR2L3hSh&Qy)5;Cs-NpI+>XufT{ia%q zFX+J2Ty&x5JPz_CZ!YMEZH^l@KG)RpIGerc6nR5Q<1U`X>M!O&1KZMiJL?stexZip zBQf?tKPyEqt43kW-6x-#532wSTIEpho&Lc8EG}ETeb41x7%2$btSWdXYjrt+=BCV04`Gq*Y%#sVy5E)pnYDlcMI$IL5 z&)6>7P-WwAIe|z_T!GQ}i*|tTV4RQ0h{(vxHopudTq>K`PAPfO`hjn?U%)p@;`Cl3$1s8U{x62@(mIVp%?CqRwq~VYm=N#KOTBUdI zSDeX^{TEX0A*W6L#DdfQWnKQa9(w+QzIAN~K%K(~u!oS_12v^86#fr(oN+YsG=<%C zcO6g`g=4hZy<0+d`0u?Ho}ycxXlc<>CEpa5r(8bw^u|O53g`xNlo_r#WzNSt#78JZ z%~Z4t@9r0q@}8GxHB}K=GLu>EH$(Ik8(fld?z{@`-R2t~qdhb*_5g$rF@L#4Lb_9U ztJvcF6jL`+ra}?^rR$Thwf z56+?KNR9fqECgDf(0|gm8!ho%iTS+IFv(Q#O#VHsw-9le5ps6~t8=H=p{@H*&GcW| zi?lnm-ux}wRyPJ{sdbW8^#x6Oxg2qVfY6PsXJw1FJY`J#M-urB$WG`QR$!g~YCuP1gUJnRZ08z;Zq z7%coU73#Xz&aJ>*afk5M)`$MmZxn`U32o-cNsIS`1zaj;{En*u6EH!DfEtx zD$z7m-G~Rfd5IN-Bl2ch5Z7KO00ii05zTIC5v zIS0@+C{zt{%&^VEDLEXdNG)9Eu3J<7BAs!7CQmy4&%}{o{n~TTJ zPT<4cO#yx&De6%n2U%Eyiv}f|kD*R?yu*PzIzxlLWiuiN{ndVf4KKqWHz|0^K=I^K zqL|5*M}#!Uy~FE~I!!It$-`eaNjfu_$jX?#GRFG8*skf7yrJaGz)r32|D_M~1kWtJ zGeg**)gLD%d2xP$IN9-_^S?;Pz^@64WBnKJDJjkMw>+(Kf5;qBz_yHYeT-}UwF8+w zC!cOGXzg(H#qOO#jW2|_V0ae`mPKtF#DL!u9CNhxPAP?M@$OhnnS`^-T-JiJCbQof zGi<>{2#M?8Yq$GgW zBg~x~MhmzQY!CIDr;jNV?C3nVz#)}r`@^lJwsVy>Vb=~=iMgSV0y~<&hwVu~@bmVb zTeR-h{eIF0n`e~$hB0AhaK}u*Cj}jOH+2H)McG*;-%ZqoTV{Zr*pBIYjHD@%l5+sJ zV&FHNwk;33qiNXrpc4uFRrG)D(emv8y+NDLu0!Ei9dsvoQG|~QDe2Mjq8ip-QPH*g zJ*=>0G#!@@q?c!9h7@Y{2bYdi&J5iCeE{vC;D$Xvo@`2_)UDmywEqQeR{8}t+W`=d z0&nPKK&AQN$qC6%i_^}7MB%B!&YiOtWiX+@j8g~e_KVru^Gu!_L9xwAnECUT(g9se z@x(cs7yf&mpm0xOI4&s41A2O4*+M#pIQd-V=eHB;v<~B*Mq05pO9T1*C+~DKOS0Y0 zx8U#Y;%@kR0}LY9ePq}Wh6J9A$rT$ZhL=?p8@2~n$V%1I5}(oyfBY^Uv5`2;0|(Tg zlLOB7vv%Fw*9`jJyKF~Qf`ob;w|(V?##5O~ zgOzA~uM2a_8crmBv^?@rmir zzR1v>dRsrqrFHmf@NZlbc4q?z0sUe#ifw4*_PKeo>mfe=$lV!*OFwI|`hhG~jTIzp z_KVL#=>=CF5enB?^eOsT3Qx0P%d;|Fow_MU>Y@gh zi8jWK&WqNmR-lf^ExgT%bhyz3*VyoAg`CZa&3GQxhI1qcJN!nGr!0s_Db*59Lpyd? z_8+?clDvWUR`x257E^?6+z`cB(73zP%yLzCO>sI91t1atb?SimmE=8IDqN0|0l=}4 zg}<6ydz9PYqSpZy<5&;@oo54(bGHm+DyOsa;eGujxl|Z}T4?rj3tip)Aiq^G@Z4wl z9Tjl$X4o+VZ`COsqGG`C3GY$teP;$^t?#|YKvTz$kJ5i3PEMn)`LEDTiQ-ASG1}}C zr2;$vrbpJF&|zW#IcRD-`V7%SG`f6g4-7%(WvA~~{_H-YTezo@VFpn=C*k}Syz;?~ z$Y930qLkkXQ+9K=!WEP@H-V} zpV|!-f~&eQa@+6maxIcSb;kdCCHj79C9l256=ms6Fw0?*nuyCXGriWedX-TF5mOxo zH8X~dPZmYaP-ITZ!*P$7e=AiYXZ7!)GA5@)TrrSiJz|fhS;MG}_rYG6yB{Ah&UyKK z&3C>gf6gKP>&yOcC8ADiN_~+m3!(tC7^as!dsKOa1$K3+E{pep{3~T~bVUn#cig$H zNp!eKWG)jvREgX9CLxWrNB5++(>zL_)qd$Y4#}w@A4Ix2?FH#7Do(z1v5s?=% zxSxZoY@4ug2S|#kU6)B=OU}8%EQeoifT;)(Eck-m-zTd zij?2$THk2#LIuM_Gt1@~m)I(3NFcrOPvxDaudr>3D;^x~F{AhyPh*yNWrb$e5KI5S z<4tsVFF1W%FUWg5qCz}RALSLagzM9pSHv!x)<5k{7 zMN~K++`o3MbJ>0m1Hbnd9lj}ZnTGYikGyuta#t@nBBlO;dad5!&eJA1Vw=T&@rsS# zgD3+)P{!o8sq?87-B|UxXDtq>CrHT&Oik&zW%9ZLfKcyD)BB4}KQe0bGHPOp`4R3Q5-d&sR`{v0{k*!*;qNl#g2e0!sL2s|d@V(Ok$s)0|se9E7K-PJu z;(B+9+Bh$U#o?^SO11SLDRENve=|%G_XP-~i76~;__6P~=Y9C+E40_2B zW@%>FX|eO^^G0IR{MC1F2=ciJHzk6oJz6GHL>8~;lQZ@?KsxrPWVyJq7SF&Yqx41F zKZRY3Oo1YAcf>2wGj%a4sjz9S{IlZ=3i7eBfS8WmX3lNDG|B|2beO=ChDmN2F7EKN z+Z5yu(|7q->I>^-LrapW`avsQZ!>-?*W=gK5pqgwfTZpaQrr?j?lFnL0nx;ss{Q!e#Y_S-iEs6a|251^t&zPmG$^| zhsBz9H}6$f)22`2*bgTeH`l}Ge6ImcvwY!24AFlW76f!`|GR1+=s6~D-@oOQ$KF4S zNP7jrpSuvAb4MP`y$kZgu#eA10?wei-4_>tnpsZ?#ZARWwY&7opUNibY4`$@*_(=20FpzGY2{ajHG5)bb*I`O|L&6>Wj;c>u2Pq8B$73Qs#{{j7ysf=u_4r>Un~vCZ zOVNOJC`Zkj)T2FG3Q%bMK@g91KymovL_m!7!6H*|h|KLM|A-q4d=>@JwwmgCxGYu5`WPN^qB6>3jkY;IRU z8%~+G{Z{*LMYq|$yPu58Y%9DiIxeQQ>-FYTHG7?#KNf`z(x*U6E*p@Ex$udKN=XFD42+q+wV z>l@&}XK64Xq+|R1e7{7_3$>;-wRY_5;Ptg-c-Jigi(;N=`p|1+81D{BHhz(xfi_h} z>PmGSX^t*VV400F%|%Kk#I z-$`C+4|I2UI&GzyGI51C<2pTYbTM^ zBti-W5#!UxB2we>8r^qib@tQ2fYa zhk#luYq>{MC3oO%1VQXQdgKSU~q_4PTeiR|8@#$lKoo0kCh z{$}9ufKN!Mi4!h(T)nV8YKW~RWk zqWp3M!`~{5Ep!cmS7u1?R{MZBnPFUCk|G#xbv;k-`s8;{$1y6_+U51^<*jJm@$jf` zt$fVWjfgwT=3gB@i%iKkJa`S&Rf=@|A4Gj+R21IV_0S#CB`qc0C@q5`B`GZ(I!Y=f z-6b*PfV6;sv~&#JB`AzYh=d3Z9Wumw`Td`@-mk!7Zk*U>pMCCFBx<-{N>QCfgrIyQ zvqmbW{)+Dmmm)FvKQ!<4WHIV_`i%!WDxzSSx|Dh z@RHuk(TS0#e6HX=W@U?i?~3Cz<(<1DzR3)He&;sM!eU5z$1h-9(eEuI*VjwxPgjQn zFc53MRt;-EjMOO`H5OMJd!UH8`vi*D=R`-xtQ#btPBo>o+e`L0pPjXWI>tZ6)OBF{eX_T-sx=Vuh;v|z2d**MhBtX=SK6Bt6 zC_PC?nVOP+v|~Zs9CL3LP5p};-4{Oegg4~bis{hv%pYCk5Khhw+-%rK$%ow77T$fg zmAf8zcd^$u4c62V=X@$}9Uac=fQYgMQ7dCa_SQUZx-0y`CwU5+B>fSHXLlfXR z_F-cY_`z+{PFRq0h3`Qsj&I&^Saw83cko(};_HI&kvgpL&hjNByW^MjXJYMoyqvar zWW2sa-X4mC5PGO^{I|_NO${GwppF61v$}G1Qyx9o8L1p<_%iG#d2cduBtx~{=t-i! zn6JKD&!P9ef~&@mZnUmlaq(s-*C#n-R$4`&4Oi?-y8SVWn)gO#xobX9*fsGI?d+dBXEsF9CZTCgjG8gCgLP^iy4@yIm2G$ zpaX4Iay`W6+pt8*CR*UMjKB_66KNQqEGDu?C#h&JLB#WDecR#pJyK4?*_a`s5_mt1 z5)E(dVYFUH85w4y!nh5;E4mg`xMNqwC+r8){od!4>>D zPM05Z-zh=8X>>*iA;&aB(rSeerYA%)zOTs`Cf&xqEq8dG;AgeSMSdKSa(+kVbU`}x z^{>?zJrHRn$(kMS(sSJNCkiE`2X4ZS!i!XUd8>AW=WNAAmdgdWqJ$hr2}=5$2l<@# z_y0nxn-g8;QiirP1&tZR+5>H^&z6g4rnrmEX~A4q=%~x zVWs1UzV&fx&6c7@Do_IiAQDcVJ!zTjx0du=#lAaK@2#yXO+1dbwWWz0YevS0AeE5X z5BTVd>{p)aJFONL2j`2dtH}o#({{1@&ssK&QEvRrVoANsZ^1_udq?TfI0rpf*@%=G z*rEp_!D1GBv?tU}%o^+!b`I_dA}Thl7aLzh#SB?6iD*m(Tsn5}OA`777yhc#&}KO* zEb*+!xU@HM9=`u~4h)Sr_MW5?kH_Dl?Z3Vy2A9g#d^AtUZLFbFC%4qHgM~aQ&U9S( zGd?A09-Q@_Ub|6(HjC(Y$Zj6OvBIf-k?MLedPPiTdCgYT)~9edJ$(lDniuDuKMv8R z?X~Z4KV)17UxBkxrR!nrIeT3at(X}7I`@Lc0#u-cN7ndtSP2SyVwDrQ!d}nCixQ5; zeA6g=VO#hp28d399SV@dX$#|T4O4rIL4s@JGadbV^;*inB`04JTbqGjvHF~-R zXbrhY8#(hpJ=wG1a9F}%q|8a)$oeN;uc<2%Zjk7UJ`1*%_w)xRGNAA9hK@KcY)J=S zD%A9Vs`96&A^cO7<4Le3+aJ!b64gVYeHVcSpFSwp`_3SQc4>3Ca-pp) zkD*rlL*j9`zV*FjA>wh#aXPJydeW>vmTJ%a-Kgwxo$FL$Ez&s|^_-Vbg)WT{{fd~w zr!m_d9;b0?;X^CAUbWz-)^*K%7No*u@>YpIE_ntbhE`f1V9&5i+y?<_;7xH{$itK6 zweT~`owQ}8*ll(~PBp;TSqlnAtBxKmjY1#`Tv4A8js`95Hy`h~dDG1D+N};fJ?Yi3 z|DmZ9p2`8mKd4oS-B{^vnGbUM1Bb1e|H3nrUU=KoK+2n}vhqZ`G0uX>qTT&um172m zxP!A6jm)$z*txta5MMT}0K%H7V=86GO#fiZH%G?ky8-9a-y24)J^oD^L^Rf|_XHZMw}+ zBc(_j53{azYeo*kjDaoUWt$8%y#*`Wv*363#}SRjxSrf;629ba1taH9ZO*WKJ=eNn z4e150H=tGkzBO0;67eR3m09J%`hG5odWOoXb`%PScUvS1O3qx7uz>0;<~z3SNWb+w zvoL;Aql@ObNzqihg_j7&+xWkbmbXR28z1VQ#*i}2z}D<3B@fDnOVEL=?v&WU?o#8% z-nE7GGR}hbj0`MVJ3gbr^xEECuslEiCq+Wef{u&><=)|OA!0wXc$n7~s~O&h+}^@B z7Y=<{2`E6t5`C-#fDrzmHvv6FN9_*v-WCh-Isq7+734d1S%M9E#MiMUU*~+&=}M-T zUMu*qs(Rm{n7TFG~Lc!Q-BG-hB}A94rVL=dnO)MyngZ&Vc)K-=|th&3_+&qHAL zZ##xJ;)@qGb>eHqT(UHE@I(6E%r5G-GRVI^@J$`p;vcn;8!wqz6nR2JLi*XvE@gaw z3Pl3`5?rQ(jUF)1(?0Kh&E>8~xI^|#5sv34c!?2$56Y!^5wIf0Nq`vxF$_3Wa)17+ z0DI|q6Wq@el6E>_wV(a>X-oUs8WlHP9fH!t<>2qn0v?g#p%0V_IQPGhjho>vpg?bv zFqDuRgmB7;LcRuhAqw;AwH`iTr&8wYTvK*3kH%ULd^(DM}xP$X``7j8jb_CO46Uxw-hG z#$#8a$^miUH1>w9yJJ9zN$r`}4yrjw&H9Szc30JT8(ue}UoX-oIEZb|Gg#B=LR;(`#8rO`&&hsp4w*rCEB;u$5VP^fVW!;Szwj%!DNofv!C zT62+yuZA@(pW`Nj{#VoApA}?d#B@y~9^5;_(tfQ}exLgPwz{#&=$E5?w7UkhJF;0I zDN{Q=54F3K@?5JPsFySe*$NRbr@N|mHl->YC7tTO{_WYSqoI^SZKgOmD+hVP5c%!@isdl~PQ%;IK#F!j7(UB!)G&$3Z`wj2a08Z>-*U;L<|Y97EA#D&WzzMc z5Um*B-G#Zu@#IGyKR818RVX=bK)vmo+@G)y!@>!^yRN&%4cg|vK_Ugt8`_@(c$se< z6@cpgs6n84o|)-rE;KD^uAB+?ZJ+T-nR_9O?@Y%yWyj?~X4BiT)|ZtotU9+Y$!sc35EX$(HUpwj zZM1ZMal@FtmuetFGwc9X0W?M8{*`Ph{suT>>nITnk#|-9P>}`hEzgd8trw@xKS~lQt669 zXJ8@ruR8fTxA|Sy^#p9ikj`~ucS2l5YXmuJ{6_Rv{akZ9X7pFQEjJGI;W7+mt;=ia z@i8rX?Jpu^GIQ75GRf<&#gVuFOUN`JA+z&I9WrKVuK5^Fm%c#yMERgvF-kjkRS{t9^SY)w;v|g}HxoGzglfACwwIpRH8HXfo!_&u8$2a`Z<4eo- zhvf)XG)!6ouuc%s`@Dgrh6Sji=jSFsw_;e3MJyD;o=q!^AKn{mPcSRex%Z<6ZJH7imAt`TI0lYuCh|IiLE#mmm9s@8yfrLpYL92_&Az#||I- zb^`^#L)!-ajEp~lA=Ynn9uzi4J3COnmEF>)aD!wf4@bzb?*`IjC{wBt?m6Dck(E3o zArw!( zP|aKZs9Oi^xSWb;_(X+*{i)T&Ozw_pjFxBBA&QSY3Un`oPexsq7FP2Zd%F1O!#Y2; zQ<^x~Lwm!tblld8oFayTiN`~%6-V84h%4JO2P>lcgY83qRp~@@=GQdh!(1hD>em-~ zi?Hb-nW^8SBH>tzmlqT5G;?{Me1oo~)bOA7Nis`)e>(|q#JsSi1*!U%o23J;i@AkQ z4IkB}{Atxn^(^}D?Z|c7m7HHM-T8}$tbG`oxpo63hcyAevFFGed9!_F)@5~A_bsTS zrMJjF>5K>BWv%Y|!57KwysT`1qk#a^(SKH0UreD}FMceai8fyqus!yy)fuI7 zoVVEFuk+efzI~-Z_!z=DPe`a8t(1I3GT~3_5qRO7hZ@j)8n3Lb=jYpEE8(&b{K_Jt5 ziPa$eF+aHn7d7R38)gH)z9)TTzv7RsCqCV4qNqc!ir)TkRSFJP&A4-8sq(C&67vf! zqwv$3z1x2Kw|CSJ{!eELP?~brkelHjH&S z2=0Lpn&Z9l2OI6mdQUBkkHcIK?1O~@Y>o~=G7W~vU*@XeE2>MOCZraiezW}$?HQOi z=ZJ95gq4y+8&G!27g>fP(39ki>leWi}0Y@}n#z*-KIAQO6<|>1S|D zO5>k6>!CHox}&2V53>*;gC!3k=K$`SFuLHV`GekIU@UNH%&+MT?1~HddtCo?>GG90 zZN^^Y3%sAaKrMFP+r#|rHA|w5FK?;r^XLLeAEKIwsuX#wjaCpDg){Xw-pQ7;QTd9=#B`dTVL5T8Ng z-NL;9Oej*-xS4_U?_>Hfko#jwGgGdQUzMp!#8|u??_m8Zm_}RO)EWT6|M8t-y}mj=#_H`w3MKpzR$#k*>oq?VA zycCecWJW|pB1cX_kmaROcJ>U-Mbnbm%cuoA6SG$%501N0_ql{TU6ucW>f)*Tshj?Zi`Fta&@NnLEGa4Ib+upnB zl&;F1V?qo{gR_&gm;C>O+pzXaPH+mU={6-Ev}3ig_d3f`O$J)m zySLIve!Z0XXzLNmMXx|`{|m5YMMphpUnha`;f=o*oWKj0^c!6ywb#r6LVKy)!l0D{ z!UcVDb!<@3Ny=t6%o*WWk1tF#Y5_hZ<&yY`nQel`$l;Qb ze?bX~FMIrSOeA{w@SUI}#`^lTwTN^R%5pH!<1IO?ozTNfcAY9`lccRnQT)Zg!zi7Zvg07x( z`?Fh|tfP%}qz5rZ^#EyT)i37Xcw8D_2aNJ8PfFG#7lOwei!L`c>*Ux&i6f<7#qlg0 zfwqaRxKw{~oID6t=qClJC^zyo4^UvcKh#RbC|iGB92dLyCDXB}elCwMu|>P-2l5e2|G z@@;)ww}Gq2buI7KHV%+hny1G7d2w>W@K_URYU*7Z;BlIrL&+ItuJwcV(b}r6M;%(G zh4(a1_fH^P-@ZotjK<^>wvhiqM>wdYMW4XuHphllNAb>hGYGX&u-6NctvmlxEN+P2 zNSRmIt>pd#z;M$h-N%y?!Nd$E1!i2)#;~_V(&=1RFf9E^aa7DwR^edB_=~Ha_ zpq!4R#Knl;GphEYL2wZ8)LIyFl7;aDZ<)OICK>&);B|T^MPmBXR%3{VPQ-!~BweY#8gVxjotxn1dc&g4uGy}@>*+nN#|NqiL5&M? zEfQ!*e|xbn zz{xs%fgLvRl3Us}nkQRV*gt6ohRQ@ZwrNHIy0*wCy3%9n#4nDBChX#p z6XD}TAz!zS&3pQ-|AhL-INh3oWgw+)!_Yjmh=Azm7+*3{au&9=eS!0Hwc3A;b%Gx> zJeKt;hdm(^_F;yp9?qf!Ra27L6sSdq`FY&ja@}OiW&ugoWGzF-BjDFikhD-&9%9OH2lUI2 z&fi=?>UITf!o5gJ6S*0nERm1%5O2o%H-86OziTokHn!6xjDSh#Vg^8#1E>gx9$Mgg zRRUs<&2uUCXOtL<;F|K&Tkvrd3`ErXb#bIQIJ)jxgWE-I&z`Zl{h*d6vl6> zFC3P3|JKo04|34`$17UiO3NI$#@g68d4!dKx{NLAX|0v^F^(lg0}_FCAcfpG;}5y9 zVg*JU`?PflsN3{Bd2W(8R=<)A&MMZ3L)Pd|!ZzN`_%33h8Tjfc9X!?#*byxXy6>MG zclq>innn&G==oJLTl!&yqmmw9G|%quZ$gm01fynKk1yS-S{L4qvuk^|Bd~jY){T4n zkq@;SI~7-sJ_1bx@%WXkQ-+Nvwd`R3vP{?fF@K4@^uldRo%-p;$)|!Fj4TAQiQABs zy969V8V5|aCb#7Yj4PC%wS%6FPiBN58-1E~l#}B5SB(`@{hj+ z2cjMs;zRE?N_XAySq^~7n8M-wqR4`TwiQ2Id5OPPt1gC%BD~9;-?6`50wRWIcI{Zq z*B7O^JFI%x&;_Cd0a{f{3iZ48C$ARVW_E%@e<4HyfwkHJ5Q3xgBYA1`y+5@o84AE= z#I?#)<0!fU>=^gwht%zBBT9S&!d!^jhU)8v2LguTA+j%7RKzm6`txU_Yp?4>?1N`z%Aw zw!-~mHW??&R1X=REQL5~P+0dIg34bFs*{Y_E5L&M`bADxSU#*i0}HfyZ8>lJ-T)4p zxk7Lf1gojiA#OiEI8P{q)g`1|-XE?Y9&c%$50V_;W&>wgoc^%55l7KB_>`a@8b=?1(V3_~fFS$`d%Gj>awqLxie>_wj+PVEHj&4%xSypM5(>K4)crNs_A z0NR-z$!uhmuP5E@476y-+N z0k<=&Vn%b>fS57i9a(@OSZPLg<4we;u%&|*IH+z=p%m-OqXZ>OXIMyX{hQBs&2trv zQwE2bdyqnDLdFZ6X3_)^CbmMyj|hlzkwU<(6#V;LJD)n@=-2ks(+EXg#&!YXX*W>w zoZi5j?KRI@(`2=GS3C`4=B0SmFJV(@4Az~hr($A(i$Qjb?|=^T-4foX8i?WPf7t&m zg_Li=$oajR`cXIIjs5fE8f{?xf0-1dFVw2oiG?Ias%E2RSRhFnao0gKnD9rcZFgr; z;Z0(ir0$(z25qM7-|=#Dy+ro$vLqk!i}MqYf8zwp4mGvSG{&yT(gdE1had-F9}hqY z;xqz*g3aX!VKExq0jZ}2H z-Qmwexr@i9(UNf%sujt_rhxM>PI=Uz03{+Q=-laA2jxQkCKMHc;sXFhK@9zA>!w2; z)O8^XB?kt&`gHN~$QNMdV3)`49T!*sn$UyY1vVe9S&+B=`~d=dR5rYA;31yq9f?^G zmSMUTvC-lueCKv!AX1XiYgV+*lfohq$p}PXt0X);Y6Yh2o>O*nY?;u=nDU10>|4*R zl?nwtH7_@@>c)=A`gukLlpJmO!OWlPksyl@c6uHT7+Sj%KKR6UYUW)R$@sqT$`Pe# zMY2P2-jE|>;aj$_1rp^35}koO>glhkntOYxe3BwR zfTU7B@{T3xgr4^w)9Wi{P0#sg>krrRnU*-COYS$TcZgY@t!lXYUf%l}n@vm@ zkU;q$w2{=c3~)zD__A7Gy;UbC$G!|;@#YZX+S)mmzk3(C#M)~Lkiyv6>5frLndcXc zFx||6Na7WVtoy8{wD{bpBr#9- z*>>HYby2z(a{e9XYEnMrZ3u^+dAQ>)iIh?MU`L-!G1-2b-($)0t)9&QE$T;e;RiWR zC5u5jcL%<(km%bh$13g`e8PUQjcgWT(w~^WSq1>DeZN?|Th31O3MMf$ACLkaBAr1K z&6zzXT#WpER`DbC87Fz0{0P#Ie*__f+2@bOZa!#+1!VJ2R~lgO)7ZofoMhhwc`wjL zgmX!IFltB3u4S`xk|ju-x?%&ho@(~)tM+6R7Y>-lf|Fsu+{ z)?R5CEu;Zw;CMjqj(SJ zdu~lUr_bI^SXJAZk!~R_P8=?RPOy~=H2*3gxKG7f+RqfI$tD(E!A$Sv8Ra~he14!D z?2dyNB#e0j%;ctbm*hi5f!Ou<39CuC2zJqD`35sndzHae^qo$Qv^8te<`**yy%akc zjMCDLknvAPxr`K`2Y+oDVRcBWSE(iTgF6-VnP{C|Jl6Yp0#^G z6VLFmTweQQpY;Ng`>D2c5dC?4u@5ZM{olMv{$N#xn06$e$S*@V3f7pO8&%jDG1qWy z39nwlamc5109xF&>OT%|9J;dE70zcQmbtRM@31O;V*lu=QNiRgMuaQ7y}t2Bkm)`D zj`-LZ4Wm@#?D7oDA9T|!;F7t;_rxPOA2ih`oS~^Gp-M}YFztO#e0QY?B+qdw-KX7RSeLo1b4rA^QJEY z&2V&dCPQ(;_x7rv;^(Flm{n2Y`{3-usC2aS3}SVGt9T+jw;Hwgk6tM}ajcUq?`r+@ zC*R;&sp8+2uB&U<#o9S$`g2okNa8iUbLqpy(YO88SN_Z*0k7$m90CdQAG-aKxrSI5 ztL$-hUA9SEU3LftHXmga+>u~7i@5%6wR-=mJJHA{wKIO#{PLFC^3j{4&b@k&ryib_ zDj7W3I0@><$OMwtx?buWT_mspBueWT^Na1;o-C-~JkDqPz_keDe?`~Yc?gEkZ?Rf? zQ=DA|ne<0VUl9rK29hf%?NLW}Z0j~>{EMBuN)8LU&Vyb2Ub>F#K5qMc?bJma@ckO^ zi2Yz>oBZmydfvzVUqG}`lh~Q-HGzq1o_|*LS^u-o?w4P+-_H86nYgmyU;n+n33@%t zmk*7Os+W70&CeBo|C;%IjRG^4`?r^`DJo=}&gCxFkAp&gFLV)sFE|8vi;&`ps9fMq z>GL!4D~$`H!)FEC_Z5TDT_-qbhfdC?D`&jdm62WgmvZv;7t2$Z9YGY9%U7E}MhPkQ z2%fp^W#={Qhh5-tkBbzX5(E)XHw6~mEG;kZ^&Z=GTteRb)#&fVk%H^ZKpek;<(~KX z2i1Y7fr_WMFUAhMgN;TRE{>ORwhyW;Wg!G zAsN$Sbn!l|i|ld?dUQS81z`E@plyN*AID!aXDjErTqg<3?{d2ii_mv)RVaA}(C8|R z(mk5lH3jUvp#2ADgjeKM9eeE;y5w_f(3_C#y}5s>-O?VBrZ89d@X-8PV{rAe)J5}g z;9joP&SmAbi2AdOy2$H&Mlz+w%co*BY5hxySK>YhR%WZUxvP$MSKE2Lrg3ebi5QHTU_NWcSBE z8UeP8`(*($-mMw`-X&fc;y#tW=DqrI9#^w`Fg}01^j~xr?LzKVbd~&Ks}7*hNjsd! z>^}o4svo0op~8cEnqB3;s*RK)HAk;S1eOm^pI{<)-9)&|#%#I*AbU}HGkYU!m8%am zmuWLS{~SJ>+Vc-8`&q;sgtgdqXD8rjuKVG|%U&im>89YIE6-c)MdvPWt^bwUbRC=5 zT+apdb&PpmECiL>ZNI|0cH8ntJ|x`1?vA;#cpPOO8wnvwO(aiG zQC%(9)W9N5cY=0FZuSjk?c(HinkXPw|GJ{{Uq~ggS)H@)CDWWJp6>!wHMo0S5IGs3 z(yRNaonm@K`XungyW8Jy@x~PQpM4RN!|=BU{n~uue+oW)@k*)mYP|YS+4%U+U6CLW zA3rL8rSnmserJBN+;!yEgh?a}bzW`Owqi^cevDc_m(O^R{20spyk-cxXoj<3@*23DG8e@?6U?Nj?7=OY z>;VsZ)TR+}zqP3`ma_P8H-lS~Itpf0>MUNIX6XW{?BSTSg4DDQ*P%wsQ=FvCB7uM{RW_s8y16)Wv)&So_;X*;Kt;KDlSZMwNN`3CcgZ&^M z3;8H^+5a6VcIsx~J3FGVSJ?0*?=G<*bE&(j4zE5wofSh}kj)YIkhqbXXjag;8U9neSmX!49DUv1<~%4Pcd!F;hxOOq6hgLo_bM{!O#TUK&ujG zfe@pbiKPa4|9U2(i*_xS;X73nLsy!Nkf_MxPM6^O4c9lvb5;E$7Z*&f!bNmeoH{WN znvcLd>w=PE$g{Uvo#=VoKQBZ&o^I@a`;<%ZP_I18sloc=R@<7LT}X1_>}Y2CnLX~O zDJSlAyQ0qPM|!)^SZJi)eS$^dUPcWZu^=ZZ{u{JXd0e3$Twi}6wJ3`l~n zwUqH$q##GK4J(k}?{6&0hIfNIuVW{ z27KZSUwuW}h+oBrJuLk1IB;QVHD8sm9Ov?BH-LVey2Q{!3N;XRlbQvFlM@ulAX9Zs zKZ3M;f(!WrXWrEO2`tqiN_GiI0UbD9TeHw3($KUT^U8I!&-f>8>{P!+0zM$5+Z ztGQmfQJw0BXJllbot-f)VykXbLo1HMGFiKkoz`s4uISam!$ix;Zn>IY;_ z$t8g}yEYjd!;z^<&{S9rAExbFtVXY<5KUY$kM8*uM05r$^$CiRe%G8j^+sZ%u;K4z z?u`^|0lv=xpqim|m>TG!d7c_z&xD>|B_n4&Gd)r~LJ8ci8Ck$k(Mg+b*k3ewV+FL$ zc;m}4@0avncR>~}zp(iFo%#V$SM>J3=vyrA0jf~ik31s(5g`PC2qB%J$qL3|?=aC4ai3d$r z^EDr*e;P6QPIUv>3iD`uoGl6Hp~wmPo-zw;sq^-e()L&f zF|Ydz(Rh3mW;VsL>0EYhobG@*fztXWIl2cVEp;Hs<9U26Evn&hlzUD|aA&0VZW&#rODctjDUi!JhT# z+#>%Vf7Lz!_%TH1yd z()zE`@w_EEt-RBBg!h2TSb^eRW@3UN)lmOL$W5#P)W2P(alM0)fM~i=@Q~VV1_<42 zfSv`6;Yg7(Cs*Slm&YQu!x|%Etd%!Wn6jk|DD)xTzcHI)vx~0I52Wv5pIuh85hf!? zJZWNq8k+_AUU^*w2dUiEd_4~8%Cg}N{z@vGP2@kHJzHD$<&OL^&dItv{l|77gE(V! z)oEWIzY9T0)!2eZo!AL`G5}%mP*4cas}imHGX#U;gxZM-g4EK;qxoaH@+ta0jmMG- z(sUYnyBeS&jOiW;GiJb#N+4{+^w~YQJJtqV0mRm%kL9kA~yVl*?}4qcQg2N zUy`2h6xKpeZ?A3POJmemIC9b;Q(Vr$-W=TpSyw62u+Q%df+d%r+QVJMNxzSBQnZ%R zGcW*zEXAJ{*SY}56>zXIf9wkhrF;lwfqZx>AU;9K>+zNl z=1l;530?(|<#?>W@^mtcJQKm{aibA2{C!B(pE4ANMv+>vNV`sW9jz?-6L@q28j%b)cbHi+6v4QZlDHVGC~f>2 z=c@_Z`~Wc!2&zrvQhmk~m0kG=poWT~w7$kWc@(~HN+IKp#z-vzD>KNBJsTx8l?cO# zjbK_c$o^lP;18_dgRCAn=<}R@7_#OJ=FH#*gd{oO3e87w((3$)+*LCj-464ylxISw zZ?Tse6imLL(as`Rq#e_chA@mq*L%MMT$Gen%eA4QYSKYI?PKfK*;5*bhsQoudL`!T zVZOu+NISe~s~`&pKL|6{AR4BVSO+=1^0U1&B z8S2~)o`|_!rllMqASrVC2zq$~T~>=lg(4deoD`W3ahz#uqD3>D^q(_73`RaA^kbD= zv-@?#&pryY1X8KM@$%oaIM)fje;VPaKkJV_W6$tu9m;y-CZ6f76c+ZN_p#DnC6U^8 z?0IQ7VWu$+PJo1lq|DPj2Elz}8ETC+kKmH7pqLZEAg;UMP#LFsgyUpBQ1c&x2y;5B zU)1YKBZMnWVV3+DTbgrzCl9+BM_mt^+fCZs%vy)eNFa`)49LY52H6n@q=x9XSjWBT z1a7hP5Ymr)pVW^?|{TbmIoq-!Z+k%Gdi%@(NCWfXRp}*YbGo z;e%&jYHVc4>Qy6H0a=;l*Mn8b_--w1GJ+&&1dm9E=+vp?c0h(SdhN>Uy(951&`xe( zRCA$&QM3);_(qeg1EDpLtfSs(dp5Gd2Plo0Y=sBY_6!Xf*x3YR5UyTT`+M6%ml+@G z=SW~Ma3739D>vboudlD^{Zi*rz8NiNS8KCke(&wb?%LAh0v0xW0f!m~#&B&)+@E&9 z=cuKd`W#$C3Fd^UDEwhh3MQ#5D`QFWzyTII&?0EYk73G)k|W%bJAxJJC&dm%NMI)) z0nXWZors-QS0i*sC#SGq)R?TwA&t`k@{bSrH`g=gc_{ud+9`g-VwMT~ppY-SefaSn zcqb&k%tO!9(D?+rWPKtm*G+^7_ajYBRbp&e;o|L@1TeT-*b6YyA12X~k|AD^<_uhX zuHo%nD=Ha(9H0ul4JK}b$-T?!PphMdH z8)zv?4?sCMqZ!nH0b#tpYraQ}KCRvv@eX%O14-9iD8Arbru%)v`jQb2M!+s;`Q^rq zgJ4sqlhm0!56ran@sAb={>@wd56M^j&)o}qVHzRJKEc4JlH(9sd!X$Kc85%TK!(H2 z37^aQUqD$$-LFu?2hk6E^!Z4bjyAq9Ftl{nMEyh$kAh3@FPZu)9*T6Z5EA>MPYhX+ zMbP}L|KaC*@gENI0YO@}dj7xkxVEy%uLbAmsUUL?Q05&r(pq8R4Uqu56^JvXN|QhGdCNepFy+aA1fqX~qRHqzQs) zD-~>%9rB2glfcaw$jIz|^?fvsfUES@3Cuf*Km?CJs!FU=zn6p>SjQy>TowJG>L zh96FbJP1pF2Du3UYEiXI_+u{v4l^2<3)wuePukOMuL#{ zr*}!YDAv-SGkhm~#o7RvCYn^F2(Ov~xQvG2@b|Q< z=W8a`{l_>st5;1(K7ly)=oMvct2}E2Db56OAuY*75HyHxIw!}^)RN0IDq;~x#Cnr zdhywR-b`F_=O7;=ps6fKe)AL@6@$J8c+L;->yRH<|IKgRF1Bz4s)2#8O(IBadif{L zC9caFMH5T;PWss3*@1Wg&X7DO(QV%;j%JKTvWiYiS+c`njNN1R0B)r3RBY-Pba6{e zO}IP@RG9bTUFy^qMUcyikfGvi+wZ^84=L3k`8?ISqM+xjKrNd2#w9kDBk=H+fTTa( z%oir@MhLwbI~z0Li{H31ja7xvg5(rUURWZ>!yZen^0`t!-eC&N@-4evi%r{TX_^?5 z?M`Tp3W#k{s;NJelf0ezISm;*u>>DzU=Wvb`O0DqRG*^u1uYB!pyCY`K3Xdj)+D*Js`f;CX{~cUNFIr;1Qsewd~1J_L0dmpK0*HX+kD^w$VYCX_+#J7 z(5rZ$8*WPU5M*PE@XBT?)9KUQVlkmeFQc!Pp#di)r0 z-~^Z86cH_DuH7N{ObvbCv1$7UO}|s`4Vv}4a?h`)QT{2-{l_Xw{>*w%T`b5v3(*cy z=$=e>M=d_#YgG1h@3j4l7rRO#+2j+x*k$(*d}MAs0>v*$AU`~TIulPzf(2NGVG8lM z)vDrXc`y*LDZYuDX8iw5bK!$|>6-R0KkP}~6x?B=ZT!&E8b|WJgEt0I%(9dYPsN%D zQ#UAZ{l>GPC}N#%!bNfJrD~FnpJ}MCM*Ha~HfZ9}nukZ_r(t<#%iIp|$Hv8QG6E^% z%+j(#>f&UAPw2pIMgL#!@2&#h2^M)*)B7k}u3w=(fh6}1~>R~H=DA}~3WGn_`%PozO8yprGg9(c3}Eqjb#HnNQM?pP2RnGdIXal#Vm+r_FG7%tBI5A?SSOE=wih=>2Hot+NK zcp5}a6RTVO8ycq0jW}rAU>;~a;x{|=a@YnzN=hC%N}_C{hL1m(`z>lQm-`8Sm?>z| z72@1*H>6pJ%r(N^C7fU{;)FJ~0E|xjTQFKWF|f8Mf{PK!TAA{S6*$0l)QH`FkV25a z6XK!*rA?0e9%GXp-rVUeL4PLi9AN`tK7Yhj?tBAq!Cl0tFFL52Fc2^f+`Os726u8z zp8zve7utJmQds%UwXK$HlUh-Q!;V9$o_e>NNsi`a`TMv(AnZx5Xyn$e7QW~d{gC2j zWggWx*7QpC$g+76XY+$-lKT!AU6(;QBYyQgNjLf!D;v~PbNvOMz-~Ok@p}+2bQ(MU zkGy0lF)!l|D1*LJb{XfKENj#?2=4Sus`mmb6HQ^c>&Ns4xK^!dOBrwYJ^w+NsWW*u zH?y6xM=IA%i_7mcm*)93*{g9Z4)-U_nsKcpi<2YSR>S9wa&aLe z_)H0ecbLg|t(daHn}YWi?#~yYK`-{?asc`&v9c$>dVq7PAnlAe;I4g{585<*ZC{dT z&S&lHK1Q*&@y3jVvDU9&k#yf|>n@acrG{`mRwH7Ay2s1Z*oJ)`0QUSX@WMq>;*{i| zh8=>G@k0s4IO^_B)^>y=RZ9+`0FPtr$4mNmO;HF(^FP$-KVfK~Hl8M`Oga0ssS>r0 z$-pL_u3wo#jzMDu?r~I7^_IOS(PouoX9))y#vGm9CAiwU%H;2#<(QB4-mK;5hcl_X zDlKC*EipXq0X`F88s$a6D)?qXcV=Q!npBqVjADBfkr?|4MU75!#k|iPk^gVIfji;-+^gd55 zyBx1*B_Yz%4Y#$l5WzFHxi!bzzgTVM%`@r<8iU|9BLy7>p$eLhG=sKk-y7G$Uy@Cm z&N_&+h+LiDhZpWKc}42Ps2*b57i58p3w8DgAFU(S*CU3=)xi|?wBn=$D^&GwBt{j2 z?>=>sW?F*M1dPjm9~&tj3roko%UamBe8%9qR+xggQWF&l?pC>uY`9AnVx$0xe@7@2 zW=eJTo*^f$Ud8~-j&j^;R{kD=qpYG%EA~C;b}9Q|{^aZ7wr7t3bC>W=;ShNOvS-avux@us$*vME?_*!#&%8P}G9OUK0zn^Df_ zpe}O8a&e$#r5Y80h^hl%*LVf0T$fU!7a+?Lk3&tt=xe}cZ81`$Lj;jyLv_ZeVQt9< zmHI}|*oM<6&|x0)PEV3i|FfJZI-G}sNt|BF!EJ3D`|dse`{S`*HTRf_@|xv@gfy9N z%Jer(4n}P1?j0Pb&-is*m*G8iYA$cw42W|ch-(n>iwZ#pegZc8g(i)W7j`CH#hhgi zch#I1yV!RirXY|47pf)O`9pD_A=v&U7E^UdVzKI1dxII6an6C9PONRR)Xw7vlEe6* zi5qKg{czQq`R>vDZ&3jXKSBGf)F+7vfM!DBPums!S|eGNiI3>iGP_u+NG`l8<}(2g z>3AqmAo7^pMTi7P#G7UxV0O9rG8h}1vT=pMKsZ>j*u5Kd%>Pxq)9PRVipBsVBJboX zNlcnoQdS7>lkDH!d*?AtIC)ylYE`u{|9$L6RI;9(ay@7;rhHT!WoE~^&^L{+BcIN&>A+8=XJawaCV4i zdtBW_10BG>a0niO65gZM6U2hpxSP!yZv~DDg+Ym9XC08vOG&+z)J)_==EEcgdj?GBpo$2R5E&b_i!M+D?_988S7{) zi}r|^i6?>)5)@1j{BsAxTX0MwqW$&x{dREU{hDyf<680ZviEm+RYs62=tXK0I?b$bKLij z(v6F0^yLd-``e{sJIs>}-WTBqu?4#esJwJ>(+C!u?r{3$#5*@eMWk{R#=h|*y{UPN z%L;XD5g^q)fyzuIUv3cc@3ZBctXJ@`Oc zBbR|TF~Luoj`sZC<4nK|9L@9fr55IvpzdkZ0v$GGPrXdq>O9DeEJ|dCBR!3&zX{jH zdpCHJYJ0)RFRnhR>2sDl1Cq+Fza!k48q6b)5tYVa*#(PzFT;p=xNq@| zlq${}J`w@l?NW_*QoIDyukV2p@#V zJUCX4y)!c+*&!rkXB;C!wv)XgWE8SRNC;(=WJE@pzx(w4{a(L6Kl(V{@AEv*{oMC; zU)Oa558{CB5In&ccqrLp$kESl?v8A2z6J^_i``-h}M<+w$B^zGSUl zy~S?=Od}{wjn;d3rfb3Epruqt>og|Qe1ls`Ade*nKZ7#5u8(NiM6XxOt3hS7dHbnxj1u2E_82`OPhNN-lEbMq{x&w!VvZZr!WP9_W1Zz%P z&f5k+XK-`~ug`6WUqzHuFr|ag>rD7FU@tRx->bA6lU8t7s=977=?Zfvj$AQD99!3) zzpfcTdd@hwjSaY4W>(Xa|akRRr}rS#-zc$EeKGOlKf+SsKG+n}U0_8|U3Wf}s$u+D@?7slKd zWhcz-I0eEaUq7>R%4-9FEW)&XXkT{l{~O=UwZ3BU~9xEeORg zj})n53Yv2%bQ0&Deg4*YK)WE%@3&5 zSJp_Lh_N^1I*@u^;Uhdeg$=0y8xkE`qBs7&=AMEN+C*a9u^Ph=S1_vIu^zcwo2(tH z{9FE+NDxGufYIP1t$bK8kZS%{)gFrQ`azW5pJa?ty$t&xmJ&>C*EQI1X!XB9Al4{c zB9PW8Sm9J%LfB7Kebajs5#S`cf^f8hgQYyPoaVG8&m-P9Ha$zGO%FIAE8I8n0?Gy= zWs}_A=pRQ@eglq=6Lf4oqZium0Z-gt;Xm1PQOHhX?SF`ML67OqIxbx`wB$@6=qi0i zR3hyZeUlS<%VWq;FPx(Xh*of_mp-_%3|V<=^naBRC)mI(hd z0pxG}>Iduk`OO2dd^bAnQSD;Yw2l^u2z6S3(55@(`B?FXnD8 zf;m(Nck_eUhP>vO9ZPlWZQh$V@r7Q8mZ!R#TNxn}o-`T&N;FU<(T#C)4s>(dyN$O= zY9ufwWyrDnl>P3YV2|nJDNfcyIWo(cgu04?ElyEfbGDN$)e#)}HompXtr~FW5982@ zW^1>8h(tMV9)sFa>N?3$N9#HlsP-Ffq%M^rMat4Z(O@2JM%Mu?uc3dvylvoZ=Crx@ z!kOusWxzNh91LljNqs5D?NUjEQ5P0L1CTzPQ(z;t79zLo|HQFYO+>lx8QVMTeYc^qUSa{wS@Q2|wfPf6UxQbbt@rWrShxqj=|ZouR|eN< z@tfKsDyzMLcD7D03{ka!3)*+EZ0w%7F+QIC;*p6*U2- zLn4lHb#7J{==Ju6F0pdmi~1U#|BV_RfGEfUy?7X}b3~0tX?9S57~v?r0Zx(sQnbE- zSm}GE0+{nji3tvm3NN{bv#>Y1I|iJq*-+!P`NSBE>&iNn_Y%wd&7$O~TVCTue6Azc z;UX-L+P}p|6EONVS74ohx$ndzUc1Ydw#Jd(%^xlu=gUa?K8^A~jvZw!2|+h4)<&1z zjXQiLLq*y~lL(7f2e$-YkPm~@D{TMPbunRD1H<>>QV}{qrf4+sO55Udl}1I61;01V zecU&%kYv#OD?Ih0V|lM$5z#k9v-UGabgo#&m-OM&8ca-0rN(Lcb~F}r@A)z()KDBd z%sDDGn~t4wlfHF=I1|oLoYib=a&c3c0*{3_DK3Zb|3?EPl<~VTBP-&#jychrk}@wJ zR{=LF2aFyRa#Oj6>mX1DfUV%Ks%UPC);=2tQ%V-$ zd$^Y7*PC!lD1pJn=o2|NJ`Hr1xHOKwah(`WeC1Q#uDrH*(*&V%h6A(oZ>qGh&eT0e z6>C58ugeMX&eUn^(nFke$2CJ_MU9&($o&lovQ&v>c)arMv}Ums70xM5x&vQ6jUU#G zV_;6q=uQDXs)1vWK7Ftrl?^K`N&cec-i;E5hHL<6LEI|#ZOs*1csVaFea&6ggJi{G z#wB%0g^PJ|2vlss&<&sB6Huw*H7e2{2479LhPX!q;eM%(P%SjPdBY-V(Ntbq-(-cA zTWvq#`K4jVV4wY+)nwUb0{7JKXJs+-x#* z7aDTxVlE4B9D;Q)Ug4EMbH=PRcVIBhJbKyPDI-c>EUY$i-L=%1x#D$q*h<9zJG>G=xS;a_>e&y7r~Iq)Vgj{`XI} zaS`}F6ApJsT+b3bx2H0M#)&l1vLxu$@!z<|5#SRTXOuzdj;kt;pIZDdSWs|ncu6=; zq;Hf-_LPMX#HA^EDdG?9471NwT(Uz%f3(j$FDd^%nsT{jd}?rPYA|`SdwOsO_fd|q z3yXr1bI&(r=vW%40@njP2oIN-&ehR`%0nr^5t|MpH1!m_D&Swg$*cAN+2&GG4S4TQAc%dkMhWa9xyHhPQr-_MiDe13!aY{{32M^5X>JVtJKaEAnG zu-;*a{FpQDa}Zu3^B?ih)WlpOt}A*9T~Q7d_QrQ3!Vk%V55T@=y*6asKZl^)C_gQt zl5R{E`q2Y}(ncfr+JwML)l!a2UKIVAcwVrfAsoaEsaBkDUeSJg6eA3Zk>48O_DplU zXl5*_5g)stpYi8(%)V)l!H7e`WL(wfzyP5p^Klp9CQsGadcLt195>i71w-LawnTu& z_N5=UE8af+6na}VyA4s}Di5j%IxsfC7~Ln7?7zO%*F=)jCR}={XV}R{vS01!X0k@~ zNt0#A(bz%tIpw0V?H;u6gO0SN9$M0zGg98Zv0g2jo`A^^G{ZeAuOf z!SYiRC*F8~OLrc-h-WwItomhASumpj(^xC%RcnQgn~%}4udg`fOENYH<&5H$)*K^) ze=X=$V|5;h%ts9nw}1?7ZJZ63Tt*AnFM%LZxAq#-V|h%7*Edt}rzqL+?jT$NIMn|w zT{3y_c}kaOjWc}0T|YLbytFC$CYBMspDql9-{s0AA|I%$P9-Tf0w~emCQX%NHxs6$ zIj+u@MkB+*@bmJ@afGx=FwoF9@~ZJFBr1_9;q7-hdwH>(2$z^zC|hn7Mdn50#TNl% zb(N8j#5_IQ(mcT>pMzdyYyJquD?<)LU}7oom_2Nv_^MW^zMp7oo}ew|6fYi=CFX!> z+WwTMe5;%m<2E23O)fmlLbmsbg#@;^7gP%&DCF?`LrCpOieiNd0M6|RTuY~le-^n zkA`!Bzo{n@5GN+QeI4J0QP*iJm<2`B6U|`i*mH}!z!#h?f4B?W%cZ%{KqUI$Fr+mw zcFkEHi2;Iy+42toI#bDHHeVbsse0wnw5ZJk4oxHD`SyJ2z6@(@=>CjY*izpY~K69Z|^|(u=jU zoGDONzjNhzTj$zrYbJ@1k0rx6*mkdu;Y32l~G!h+nm{nO( zac!<}KU;y;55Y6=G6EFA6i*r`N>TIac4?3oy%mn40kHJ*l^jd*w;!HM-^eD^8l?OY z>m!!OJ4=Yb=wMQ{AU27&a%so`AY zW(X7`=K@gyq~ioFIqtjZT3}wFErE$9?(aTHJfHQ=ojtur)Qve0Q2%_|x{5&3epLf0 zCLR4ohVqC*1S|aMz8{w_(c#zWqcz9fD(%7P+445ak~ni5xiGbjM(bfOY8#3l;nq|;?hSS%{0-=~^Ktt?lK?4+(k19f&mWoh9GyPmpFnKk z*xjwS21Sw0#RAk>`_EY=hi|L(_vcMwu@U(?Sw#;RULZi9TySxNul110-i-1`8N)#_ z%JNKW%nR9H6@O zu(EI4Y0-4T^=(~sG75FHWy?aWo-ZN}&5UwUoo$Qv;U&Kh@C~KLDumz9%R%sa3)Elf zE30L9qvcj@e+=tRh*55rudiLu@o9)~J#T8QcCx;b8g-~gsWLP9)5_-A9Z>E8Cyf@S z{y>lo5sGmbpYup=53W?!(SNd;V)eUF19sgLgSq0#r@W&2gDUk`lr0x#fm0Ni+UNMf zB-V2EXbKLiM!$d;-IM}b*B9R~PEfJ!`~6d~$${`sUNs-Mlk>#bzGDr*(XKlU_KL_$ znXAE1OOzMvQ3EcG`iV2Rvvw}s@V}elSq?#LYLCD!izte?(d{3+=}8m|zRm1OJgW(A3 zx?p5T_q35GqGM&bR1r^4^8V+8{F1zt2;{me+Pru#%s z^py|_7h2<~hpvoIBjCt&?b~)QRrM0Da5qFqHlOiAT#;fl=ZLlKvozF^m%@&Xkpu5} z=SV$Zl)6Xs;l((+LJ#shW$!`J{<<|q;A~hL9@qEpc&EnAaK-YLe^)ZdU-=5dLB{xN z`oI@q9Ks^MW|+~t%ghPA`MmwVAwop9r-Oi(x+U~F1>QYgjW5Tdl;MQ~i=Q@8gr8}f zm%D3e14i|2kO8BKh{<)XRL0P@=%ueC;ol@aZ|gmd!nYz}nn1`v9`Qy{>>iaiKgpG{ zBY6f%l=ayudM@)4DW_tSS)vV2#{QXiu#C?ZArq@Vrmgejqh9Jb+Xa_dm~jyG-vg*+ zHb4{&W~<30^3vMy$MTZnG!qky?Xu#9%BesQ@_!k1(1nANtCN0o!G{psFt%#{WU6gX?q2X2|FOAa@>q=7i;TEVndd-usb!C~!BympJs4b#|?x4GzRQiuvPfHfBKtqZR0E!8FXenJ8 z)UTuPAR7)?&L$U5M5KAG6nr?nF4cHms2a~+N=N?&*@}ZrPd}>A2zqo)?Pty@JIRRi zPgeR^*n|bW(Eb9fKVQL|lf1SF4M*1Ko;Oh|eS)5xEl_?17&*fksjY>At+~JFA(2QT z0>ji%PX(bilI|52r5QyOlBha&lD>vU`W##r5lnpGli0m?^R1}#P+w}ipdf5*iW(sh zTJUx65vm}nLW$6fMn#YreLX}1h0zhWRY8hu^u6;-G#AB-OwO3P5lHUrwBT4l8%ZCN zdwH;|*9IZL{BjbYuS<_1!`CDc<$$-b);m2 zI8mU6gu-S@!)8)Y`-hF=`uJ%O-P&g9BHikS=L2nls>CgkIJwp3S3zW#QM`tRV+ci$ z#B@Cgs4aP_N<9{qG%m2kC70s;>wC~+F-x@CT6Y-CHHHsNQ{9cyz0ekq`-)OC@jGKWq%{9H89`U*HW zZ5c(#_dm0g%B{k8(&M3<;ja*@CCzl_4lrw`(>RFI;#wx3g3ulMvqM? z2@_B|=LV4SBI!fn9G@95W~n46&T8rr&)&!>kN|8>m-RdE$LzuV2}CoU8M<)ej|9cB zo0r&qp#*Hfx0I?F-WY-Z9K=9RBI_#j{e}@<0dkL(nz5DL5@XN=$Py~gRg|6VduJ!1L?g5&?oOi-8QI#0}R`kMj*6Vd337MKQjc&LVdUOzR z#DuZZL#a-`*Q?g_VEx0vmZ|B%iqd$~7779+5YQSqK!A2`b{_gpR7x%}mkYCAH=J?A zRZ%2^Tem%;MwThvOH9wNcuAsvUwT3UEzkT?(P_&QIMqr)X3I*wLCC^{`v})F;c`pE z!&7#O@`;)&#r|6uRI&698vWdE5SjNHnkj5FzWXDp-3nbk`!{l(4W-G1UA=I8Hld0X zhA9)^3Bn7goc9;fXbns+J@IGz`0?`@n0{71h6?i1Gv+9D_gu*f$&&jnTk^>f58Q^l!CUHROPXQ$v5~wEoqT4| z=g{*1?OSc778Y&rDOQX8#s_Nao|ZUbM)N1(MsxwAC$lmSiAru3Rc917MRD_aqc4yh zKS7QW=`$Gk*rawEO3GsUgM20!{%WgkDwMU#B`;(DSZy(fR<0h)%a--0myMoY=T}SN zW~N`bk+VNv%MaA^7N=XwO5Wi+d7QmpF3}`h#7`p^ebaMGtB`k#UMvOYcmRVJ@al%-sUpA0txgmjCB{VAuwdgTcmG;!D)3VE&B&^QX8y%a z-jDTQF0jgJp&FXrS?5W9lPu=QvHPlUrw{&t4kd%KHY5n5`@?==9I$j&*OZL?1&jtl zX5Um{V0&D#Si2e9o9A2ywEOqkgz+=@H{WU$h*_#ie&@zqiS>@S6uqx}%a^*w^b4>= zBSiv9RHls?;q5+|@~-Jf2{y3BlX#?{CiOxV zudJvbvu|T=oaWIM7H$UYK=|g5bHtA!r()Lp;?x5wK;A7h);O}S7=8^^K!`iywqjR9 zN{i$0o1Nu=$=1!Uj-H0Ve9*_Rg4l}oswp@*P}SG}a!nnczC?z4<^sxqV;5g9y;)1V zEV?~Zy|<18Lu6tLXSrN6K~*wZO|y-nOy7>{M3gr-AioY zgo?UA;9SzHhis0UItzk_B9?cH4avwJhij*jh)YdcQH3WjgH-C{m9^d7lj|1)0>JR3 zpiK|cq_Q0H^Bm6Tqr0Ph%fLDJXo>CV2i10>(yo0))j}@rFb>wvo-&cU~ux z3`>AtN0MPm;k$xQ>-76;hB2f)fa1Jxv&++Cy+nq zX%DDi#Ip)ySJ&m~kRS$}Pxhb|l;Dh3RbDAbi5v7Z9r_Ke2QNz8jIGNYk6MlxTb)ym zv2J(qfo$^1)oXM*$cB;-hx2p(o1t|DwcxwhDfF#Jp$pPM*&u3d@#$#60*{3;(YXKh zA?T(seJhtHqOP;G9I;0|Lg`pPS|yrT-oSf)ww~d+-L(*N&=-1!8>ahbNbAq*;{!@6 z-HqG$cq2qs`&6;XMV1pwz`y~ny`7PZnWT2Q5lIhp0!0Uk<>;E_)XQ$*lG||5FvB;s z3JEVO>u|ae?0E)o$A6E6Sd@>BY(JEsWjv&cx4l>K17&C*Y}0z(21id>Bzr1OZC;M% z*tNBxWMTh$zBLd+g+$|dQ>|=E$|s6~JRwd$(MW`sj8Hm{=B@F8vfRxO5?-DKj$#hD z)EFI&7W%?Vnzoc6S^_hL)~KOf8;w-&6cR(M3$d`Fe{*}DgrRsf9PY7c|G4CHn0j;* zl;M&S%kJ0D4Yj-v>!%dqiZc^UoAM6u%&<{Rc+&nu6`+ih2_ z(VW$XUfAyVb8T)gy7w31Rm1JZ522mUXO4M%f_q6uPo_dT;h~70AK8<1oX$#x6diZi z-`|iu3|YU$<@Gnaz`aKhC%-d5RPieU_ za#a=*&tR;_HVe*L^>CykVsi`bTgPthKSP|de>N6gZrism#Dyi8 z?FR2M(*~@?23^=DZU5y+(y`C!_FC_BR=UmaeB0To6K(MR#=qbDd#mj0{G{)3#9jx% z7>n-7J5HEXkoYy@G$+BtT4bp-Zh>la?(gV>Wllp+m!I{2a$i2rYni0|6UJP|VsRqg z?y;v%)4Y3o_v8;~plA}mY_j#bmFT>G-#!@TmIO-RC5e8utcYk4_T>+r59^ zT9UthMuwXZA00Y{o5ao$)^c?69A+qQx6u)w_IN03VH6MY9_(~X|KaS|`qJ9|=55p2 zUojxa6ODEMUy}`q{@Kg7>(3p8?&2Lr)O1{?QnQP=%tZK&ZF_1smv6SLtET-;ZQJ-$ zhw)P9lmpRtr-%&=C1y>HAZu+^dJ^vPb@VcAX#KbD9FEb7WR&Aj}rS|D} z@J)VwfK+>l$bE*WLBlw>WJesJ!QS8_8em>Yu!DK={gB;-hoz;T;c@ju z{(JpLjD4Z*fOEU_qr#J!)-wjdg%kgOuX5%2Xzg{2PU@?{)c0>*#hHCT2c-Aj-o>ua zLw0tBnllv^i(s;}t)pJ7{Fx3MA(a~UFVEXq-dy)PWu5QX_OAJVRw}sgvv8yM>xxNO z=k&wnun+7i#FPaI$SFJ03+ z!i3VMol)y3bEYPq+>(8l(GkC86%@uMLAppbim0Z(|2}9TjFt1u19zL^ZrL!8pgV1E zwAk38W|l2Er+S~UbbkkLyc4IV?Y56r3(LPeQ92E`!M$iX=S&J{WJkTzAPxS=#(b3M zB|uH}UC||Gl20JVQ~y6srnF(>wNP*uU4d*S^#L0DCM}M*)Uraw9)WMLQXsMd_b`nq zFV4DZv&|7M8iH+U^;?|(pufX?_Q%S#e>eF?s29IXc%v_G4#QDk16RUA$eqz*l$mie zR~I+{!?1}h`QyHniHZ>;9^uUK-~dS>)b2p%H02Q2Eohezg6AnZLE3anyck6(>FP zW2U5fB}T;&14zovOF&8IF6(c^GcJe!OuTdpPv2XM{=e+d{Dx%te$~xxKi;uq$9$Q4XtdD z3+;tT;?m^kzZ=|P3t<8S^#|I9-43)wW7eyV3zy&B_`_8dyidP)tK;~F-sk&w^v2yl zi6d76Y(SkxN=ca~kCMNDfaNK2+LXlhj@GAnED8)i)wwZ?{AHjouCt|z4RkBI(u9Yn z`{YOkYr;u~0yo)=pWO_(Mu#pXhJe%I~fE*GrmMogm32LO1 zO(_2;D^mgZ<9Or*KSCBH)$%6 zDVMQ=rv(&QP`yFQco_`v9@c+wFyrafk0C7l3Er3LN-`Hl5r4zP;uO_h{CF|yg`oVK zxLyH`EL*rY2C7o`7sm9s!i(=h&PZ&)bVwKUp)8lkSpBUA0c4H9^|AnA0HK{3WuYIH zc_k(2WN+M`!6j9KWg0jpZ;&WX(TFb&IIh96x^hk%LR6kj)ACkzYf|Ty1`AGKvtoTw zM3&MR_4(xRn#)e^C0S@^k=>&Ir_ni=8K;?^Oj76^+hshRHe={4LpRVE>o!iMKE@C* z8r~`chm;tQnmx40nk_g$+wEiul|LB)G_jZsFgmcWxD#{Py<8atf9m?VuR>7bF30dS=81gQ@zs=9H3ATmGys4y0?WzpCYx~VH+jFVPR9?@M`_W8S? zcjA9(h%PCeN;&nM4$TH=HY2uByj$;)#l7z^kl;I*S?|M&A|69^G~sZa1+Xl>-oCoE zX#UwU%ekstRS}YWJvRv%c-mE*sO}Wns_CT_Iechyz;+D&H80h9FvLuS%y@XR8*PNP zW?a;V6X}0`BF9zL?R`{VUHFW_EF;9FS&;Pt)H6@>!%5{frL~z!VMViZ}Dr{5!ZYn%nR0k&szZMY!O@Wa=-uec`+$FAK%Qg+Sh@e*9Yt zEk@D>;+Q>m;lz4Zd+-%0?w__*H6W|RORDjwm#QUInrq|7Dfid?;wam@+!$70D^ zP(hQF;#3*@)Gdth#YoJ7^&B#KWS%d-;%%v-$i?0sjXrF_UFgy+bnxYGhLkq-D3p&( z^c*qMki64?0;(T4j)y>z|N6EeYfS`Er63+3)1(f9Yl05Gx)$E`HNL0E`)Fv&%}~J} zTqtB1HqPc?CFzkcO795Q}NRm<^8CEr4B|P(NO3*NdW1eji!snzXRG&B_{`_(ixFA98EMas=Zt; zc!SAeu%Rx&pw`NDO_wwFOpvX!5$#-rR4z8FNP8Xb7*AZ!S1E>4!{!iLleP`%%mJeS zC) zqb9KX2IuLUZ;q))rrRqeCUn`R!IzrGf};9QTnE19BIg*OpMI!|UH3}Vy~POt-!e?n zX%D`sYC4Ira=0Q&w|K2QhtN4|Bc`H)RPqD?ni(SF-nV+qSiP|o?0vjQ$CzW)&@A_$xA?LgT8 zPXVgYI4Pyk2(AN`RFXM!%CtCHMR*}4Y$lRJ!pxZcx%S0Awa5*&oVJ_1x8NukCu`hG zjx&FbD6K#6oS5AGP?bs~(yO9V&zOoKj-lj;b9tLaB0TTbJtmqWUWNq?Ub*rhu9na- zh0oWgEbOm?Ur2Neo-^{#=iRE+7-$)E)A2=w2%V2EOa{tEJ0rHvt@vRx-J>wO~iTMpR9l%5V=u{mr15vn$Yhqww zVpC2;%XN6IzoZGh#8z;jxwz;U5Bq#-e3W{fIW1P08o8o$Qyd*#>hk1ck~lomB?fSB z%O%l?Y`4c~i6=>;}7VV#7w3cZLv&V$78V z$-teZH8t+g)R6ZJk%^8qFrFR(CmXxIR?lFx%dbrz{Y&t1BQDNjTPWbQGVKgF8g7WG zEFLoDZH5SzMJIRuO@4a2zB6$xr1DaxPYpwV=acR?QpW!hCm5H)6EZn#@;tAVQ^|** z#gQgVQ9zRwcz6;D@jwamuAdf35ecePoGzKv@!(*!U-@XDui=A^Dv%Tg#JcX>$cDtyictK6QUVYy zfX#uW(1ykXi?64=;ann7NM+vUrD@^QKvy#_+CUbbfP?F8+(gC_pOt8GfQhJjDOxSk z*XX1+nK_EgQ=x41+NK6_L0U4vLP-;b>omiZR3J)ZND%1FW}8dZ#ON92mgvV86KdE1IS>~mlwe_AzBkCZg>8#CqV*lKWEfH zk+wjqKHZ;}M7{@rPgp{fV@dB9{Zetse4041I|766=|-t?EOI^;%hhrupPkaPws3>) zS`!s;$KwY4t6!2%2m-@LT@u(|GW}9Ie$ACYON#dY{cc*zw17^I;Bn9? ziU?+XgB^a4oJl3yqtHK;4DZY6Ts05$mI>7;^lb(-9DeD6d^W|GS=X2O(JBjEaRlMX zpzZ~lvM$LcA)K)>b0!!a%_*moZY12TA+b^bb`Qs*vA0!pZf3N76qDuhLT?$N9DeZ| z8^cuE&!GZh0h(-_j%McZ11IV|=_J_iN?UUT*wHEd6tX;+i89Og`k70?l2R;FIGS5^m8%IGmE=QR{~I91-eL4*zU*T1USoL&&o?Zs?ut&# zMSRZ(bOTlGaRspuJj6t$;VTVJQ6;Vf{sGPy+@Bi%C}=dD0#?6QBFxYz=I4)fcb*>% z>SA1{St*^nN{vlnI~?WeL^Cdd2Xv~XxO;>p)%Wpqu_0XM_Y4{y`kd6MN4Z}?y|A3P z3!5pu_?i^H#EOy=8*tt)T9MEdvPJSw6U)aI)K!%AyKvR5-?@O(bC?vmHL9Z}!jyu@ zXj>_}7f01r{>Xc}P0Up@WJKQ1x*de~)$YvAPFAqX*TF{GKNjmHV8MvY(--au$Ye#v|%n-qsIH1^WwLiFD0i zf0W?j0=5*BC+3<1&J$KoGta$RvZixQ4O&js^;LMZDuG*A6@BadZxi<6hDHarIBB1| zmgIjgepo=|2;)J<-3|(O^7u0EyCoG=zeLb0{_`YzRB5VMybhlPyAW84pN9#x32%-| zsF%YzJv@?T0utcJJc+0BwtgjQ?3|+D`!wD=kQ}nBAiWrl#AwC{3#!XC4jchqw`Bf| z9VA|oJtr9u)0xM?YL-;+*H%dQKVTO1@v=^@G%}^dWfk_mQ$b@=3tJ2DyZ-~uJkMhs zZ$#y`y$fz0xw6h1x8=9eg)YA|CcvYNxF1pNWM8{TAdqE+vzuB;#&nhy|MMo8K14tU znuT^f1jZ$VpFY$16<<)7nueDXP}5gqe9_8DroGiATvf{JaYwAEFOPpo zwohp%B=uE$C?ARFdEG3#I?@7zQJoA@nDz(`3Q(Ni&^9=}=!+Dv+BZjuU*=%-hhwV; zxqM$^o>r?q4o6gGBZT4O7EJv# zaIFL7qB#rw_iDigXS3uWZPL`NkOu9eYW5<7g8rcQH%Oqw#s}{VXLs~W8ddngE{^ej z>Bk7N_{5~QF%lCNc<^DadXJ?aDp!8ymtiik;y;V3c&C9iPiI8}18|g8=TBB={W&$i zly>hSMBRlo9Yo)|yjrydOULj*1X%@XLHMYGyK0^&E=PfeJkEVC;AXB&;g_scvK1VXnjh1H+bg@W~NH<4KvEJizO|zcEQJMF!XQHkfl{-kPl zCB?{l@W&SYG!`1tS<96M_dDxi$%QN50cAx915<1FKMI&@&!mkI;$@S8n&G0#x1B0V zjlp{}p}t`FSrSO1XFOa~;voacJ9#!*#PvUL@h>KizrMI(TMCg;veeOVEL&yV?XHm4 zafu^uu@1&fSUrZ4$rrvnAy8k=@^}WfV$~VX54?&HLNW?ym-n}MD8C+#{+Oce<)s%y zsx?@!e~lfDs|UymFr!+}Nf`vv;>yOuewH}nzX0)fQdYAtj`SR?IIR}@*7Xw*s>id2 zir5Ny)vxyzg(=U~rS9jxH3Xq0uu2jW(lJRoQjR9o;|LFSust!Xx4GAOlkcHv6NCkA zN|(%M8M+r6&WIp!Xe4Ga5)jR+ZxSvt6mdO6bh6hCrJ3@$n80ysOwsjRF4VN)*_H9VanSl!GpkU>Vx3LAUX#BL}@)yjICXV#cJzsJ2 zzoVIwbR`*vjI5#2{c%Xexo`6rquXsbThev2?3yeF@ zJ$!)QHvu{tI7)B^!NP>U3`=G}UNPdlxDM`M`=bk23u5c^`~XiQc<2(lu-hIOG&&k-z4lZkBR-S+Fy#tE|DzGZT zcVM2^##yz_Gx-(wi(O*EN$?ak1);Wn8Roup_I^G%O5MxK>20Wokv}(KxYDZH8nGxD zMtUfdK>%Ig;24(@?&m)9aVE8`&VMTXwN?DG*eY$lH)su)2u$<&?SpWOf zZ&MkA3Tu4yUqQmwjRL!+yKw}3CD&NJ&+8g-IvMC{lQx*zi&9+ZYf)>gFjwDjzaz!E z6#m(5)o=K_hrU;BleM`8PXyk`a=^1;F_~fuV=}8RZ@+uYHT;-$Lvct{d)@U`HMx&@ z(AHz8=Z*fklpR-A{DHvzFFs2h-ZYzi*raSMl1Hs87|(+qA8#i>lDfqp@khW?{8^_2DO?{RQ%bnx~d!IGIPPG+mlQ~hm`b_v$gS0n(4MVc4cOi5N|Sb znrT&n62lJ73$(w+G^+HREP0@+u?5=)%4U^St{>}o`j_`M>6acHtn73)dz-QwAg;@B zC1F23T`ul)F<9bHCikxYs!?)o#=FJHJDYWGZ`n=C*md_dx7GZ!^QBqs7CU(!ZDjd( z4S4r%hi|n@3YYI5y?;{gxDe>s-d0{;`{(#_8k2JaFU`nRW#cJDsUK&2W5}rzdUkro zk)!I@nAiGc;op9rGk$PTS!!x%*MmhimeKY(4upuT-PdfoBAFv{(ekJ=;~tCiO!V(N z3iG_?ZKw;w_q4|Xy}uPunLapWdy%TBWy_#;nQ(fP$joX9wn(?xQDtMfQ^ENc5~E+& zgZ^$>nABob#VhDC-~G8aeQQ(W=gV6+O&69oY{~-*?CeuA2zoOUYg(rHf6ub%N(N84 z4qoEet>i0ppD~ntu29k3&*X1jvYqE))l<-^jnx@OT79vcXt--;)RbZ73`mK0APz15KwP$tG`u}RkgWM>OvInm3bD5bDm|b1;FXh&a}OOf^SXZ6b^0%~ zz~^#5TClDVuKND;X~8`a{~;q^f)Bbv}a}EjS~@*zt$nLvcbc%Y8*#~BZ+^y z_=>E#CTle-gc9(QV{fsYi@!kutMyO)E{+?u7K7GpDuy(`A# z$;kU~zq|T7d5&!|F;Dt6IU1#}37C9W`hC*0IkRgyVY#OUUx(URqTXYDfiT`HWc5j5 z6|;ELHDeG=(9)D{Zba`WbC!jF6O?CEoIvRDAF&+3pA9%2IGFu8v^3}DwS^XPZ|Sos zGp{}w_;Zr+;hKPjiK&C05Jvy3B?pBYVVD(>r3fl2x^aMD9!YoLWF9}O_Z^1 z)!tz8o3YhQu{+;XnfHC$Z-?nBT0pd4D{R@#ue2 zA>KZmu1R4y9fuv!R?52E$Kpg#b+3uOQ^IXHr7j3=G=HE|D+V6D4z&TS24&v!(L(;~fij)uMu2r;O zUva)m)pz?`;BK_y1+7;q(O6PA#ERx~ZqkfT|2mw#{xtNK4I zU1dO3UDJh2horQmgd*MDsHAj*NK1F8ptO{9N~d%;0)oiBARygw>F)eC&-?xGk0R&n zeP++BwPwvZpTx|*I-nva&+V|zly=8Pz-VjMLdHv)HZYglmG@e-417TNQFB{D)lxazltRbKCyi&O7a900m z1uMu~30AMh;4pyFUHhLpE$TlVC0@#y#vCdS(J?~au$-qg@7^+r4IJwWgi))$JnRjJ zY~5N&`x*QOYx)zVcj#6L{k$xxz$w70pl&WkGEq|KFua-cu}x0*DJYlujL0*`!FW|4 z8l4%9IR)&>GJ=U!3m1(x)4xF_v(4i3Ud)`?eO13Ywol#h^?|{V*BTp1Gn7%cB97Ud z<+f4-m9Va_BFlQ8S5zj)Ff+rNi%aSxica?I6eQAULFzRt#HizOVcTAb@9N2wNZxzO z@3N6jv{TkvKtzBV8qIT#AKVG9%M96nQ}q&RDP~5^#G?EG>Y|!v|S??X|e! z7~jS^1eK&-4Cq^Obt0?zEj$`0k5#wv#&`g-)~_|NP40nSfds!w|G~CMhcpW*EDXYd zGg5%yTLt3|(up9$lYM2DXPjtkoLo|BFe$BwD=z;=>h{myT+evhRV9*D*wCQq{M8qz zyJ+d#?IBt7mUD%7#hkK|w>lj|Inj&>~L}o?EEaVy?;fr^SP@e&3HrX2*$!+42qLG z5gS~-_Zjs1p~Jx)CXw~>ieFlOd$U`n$ij%wYl3I85hD-&xJ|ICoxJb24bAKyYOVMO$ANUa`6C~n* zl&k|%GQ}xcBQ{tt6Y`1|FQJP2sUxE?zHFXOW~4QJ=#YIaZ9&4CTl_O{Vigo>w=FnS zM&uT_L^dfVS%aal&&wy;EYQY_TZ8fAR=KP!6%;Lsq+bXhB#v{Mt#Ua*GVbV3jnw-J z%MQY^q_X~0;T&1gu4KYej&nZb#8%49Xi={zu;?dC7zrkx1nU=G{!*1KL(ziX#MpG^ zhBuUE4{}jyev>atx1D&EU`^$!pDY~mPp(e0R8{uO&{{*)d4Q6@6O{Z`|BFF^&i1~i zHi>UPpLXP7W8EHm8M_m2)}1ysO5Z(v3X6y#qsC+@$`vGXFD?D;P|L~Z@6VVs-)H^T zBtmw=!n7GYbY*3XDPp)}@+UkASG(40XD~f4w_w#T=X%KKRqYB=%DM=}t3(2;SJ=f~ zBYPPbI*Qd=q9nYzxca|s_mT(E|Ge}GBLW>JcW^t0$sf;7zuJ?SZ zf*?c-5eZRjbu{=(F7IDuHKZ_G37RH4v>t-&lUHRy#U8m6+21TDrmLH^Dr9720CRBQ z?3>Yu*X{{?8~ts59jBvMozdb2+wujASCzy4J)eYjcj`Pxv@xM} z4RV$MCfV_2Bx&w>Q`N1VXlsm;jvI_e9A`wPW#L&>_iGH5_FF)aS?G%F5?vA+pW&vx z2d)qlHra_n+-p?3qJf+?`{S=n6Yr?%zP(nAp-KPY^v{3&>|O!no{FKiL4C~XVio{* zK-%FENB+syWxt0^mk0umslMLYV6=(_+_@NTMkb0-X8Jb}(VB{j^wh7tvRV}d`_BGO z`+zj7$my5h`$Ha}*_bA22x~<`TlEAT(!v@8GZDZ=Rom7HeoQXAwL}a!;l=V*hSz7puSyv*a$ofFmPnt4S$olMV+7-Z z8g0f?Ee9$VS-PwHm|5ol<#nrb3f?y3#V(4}yxR5o-iltovtmIOiS#`TV8e(IIU~$E z=Pd9E0gO60Ori@YisqWt7>IQZZ|S9>l957(>?DIvN51Q8aSm$vLM0csq^{~KmjR1B!%J z_K<#TLo=1vje;9zDJi|yt#AE(B8S0hTh3qQDWfWr1W$Oa*%BGKKEhR1S@UmeN(Cs2 zPgNQPFwv#`0!oTaIE}lf!VEN@#|}yS*_>82r%Bc0X8b~FAfH>IL|3n8tSPo=G^@t% z1$)20bn-?+e(p*=r^24EbclVFv%|gmS8!5%STj5qs@yCM`RGnOU@`dzFi36J#6*H8 zXI&|d%<@B)wQ=LDV?woe2-%(Sj(V?gKha{9#%G~u$-<))QhvOw zqzm??O3BjIH|~?(Pz&q(?ZQ@?V7BvtBsjjLQhlcrAK%WI<73YY zNopRCHP6VlZyD3JrsjMk6LK|ne3`*wAR&FKJr{13oVVck64OfeX;7|4mV5gu!S^9i zMj!j_XK%z068W!xTWF{d0m;Fgi6~mBSFzdWiWHE=Hj z%@EU0RidX$s{Q&Qp!r!tz8Yi?rJxmLo~!dL z=Lqcnq17CA(eF5VT5uDmJ#7Nj`9T_@QJYS#mmEs(&>vZVUMebGVpZPb1*%iXHS07M zHMR00)V59z#Qc2}q|x2Zxt^&t7R#pk`Y;uar=%Dq$Tg^0Pnuf2ga0lYFy&s`_K$JomhF@T{X?PL}wY^PS^wYod++P1p#c0a4 zgm8_j64-gyI>~GEL~;pks-NVX(Yok=Vl9*JEwRdS1>~{&gsH%qVto18Dd(UnS9Ni3 zgleRs(yQ2^u9WeKH;8;$8fdKZJ*f)4(e^Jxes1$!ZG%iEXeC#W8Jr|3LAtx5Mh8_g zD(b2;*vKvXyK<|91460a>mI;NIN$7CMi$zWyS=@Y#zu{q+%E9D4K(w}HC^5RQW6Mk z)m=|F3o!>RE~o)8SX`Ds3RE{eRR#rWr?o6i6T5F3SCgN}U(IiDgO64G;MLz~2eenOE2^AMGX*%6m1XGrWwS59*Mx-d$ zwM}s&*Fsn6;H4dB{j^TGc7;n+Ags{UzJ`H5yHkx&s{JAU!#v-MB^cGeKcGCyA;rlL zSQQC}MC2<4_`Iut_G6wml3ZW8#M^Rpd%S-sXwzB8O~Shr{#DLz#93^xACjzQ{i?RP z_+|UK%}}-B$CA`S`BO&e^^-lN0_5#-vJjWZ*a_ZPrIti=BEvVW*IryZA_3j^=RLYN zM{|9I8TE@YpZ)J4Syt(pZV@|~IM%-R@-K-KQ?MCmKwf);^nR&P2)89CzKvM87(ekD zK=Gb?Apd4uZzA9Ejq0d@H-aciymPcCRMHT#yZLl?TW~6}S!kZ90%m)`fjN9NKQE!X(uJ$Z%SA4$aMhw|(+xfn*Y zNJD6`s`MX~m0lCDBil>j0ctI{*O&XR>U9)N`fascR4t1jq9kqx0X{z6a2OUjf3Xx2 z)8H$}?f%a+_X4FzXfX~k*|)z}&Bfgh=RigScx2OS1Opn(#tD+mpD%k7UD=(F3K><{ z?CrU?EKiFC+qOp48=`` z2|)97#MMJEcEyHULXH_#2L)wOOywm*%gB&_WNo~vY(p>6+AkjGhZTI)5 z+Q*}}_w$p;V<=j&Z${fcGHMMosKw9JNbZZ5N5so%o0Ds0)bZVf1zobkKuASH(R=SIgR}wN`jy9$`*qaN;b})dIRG@hG zdx8`TqYu?iDQr>YA(%l#zMB=MrD3QV9A7kY$72>O>e|+j^x#52l&noKi!{HwKZyW5 zBGLPRViS)0pZd=#`HxqM&(<|mC9W@5!bpQoonkC6qJNGyh%>AA%0;~6M&x@Q<=Y>` zlV}%z1VsxXcKFG5O(fPB6pz((9qntCG*E;`lZMd&^Up-Nt6n#O5t^vBxq|rN08DXD z-lr>AtP(v`bN27(L{eP0?}emz03+s9l|a6pmJ_4#py1+yqyh;U@?{SD= zwFgf&T5($!(ypk5b@2S4 zvAY)0WVQnl)S;3@MxP=6gXJRr5=4ByyHp`tSRZ5GFTCx)C{}HsqG$;A8-drA&zK$N z^eJS(9SRwz>~1oE&2xwe0l~d74drzKQM9~bs1QV)M05DFDmUWZxqUV|XfsyAGoz*Q zyZ%4M@Am~d&sUqXlY31Fu=-!G_0;1HMFz1RDik6RqVTYl&i>|0Xp_n6(&R||oMbT} zw5MUjeh%?YA83{%8!8DF8w@6X_ab{3b;OL}12I=@^l%2oCO-7U!=KIiuvET4LvG>Z zBxjmy;ML45eZO0)cN{rtzV&l5dgUO67m^lw>sk)_SWMXqs@Dzl%3_vG^wej$QY z_Jeu~fBi`ps(bLTTM=683Bxp6q>GVFR537+PC%AJ*cY|kDL~PBM!5P!3SpEu zyt+#06c>Of4SC9fb9AP`{`5AZk%dD4tl!)1ZzLkIGh8~8%ngnE=#bu7joEY_$-7O{ z7Q>;|RHq2ogZ0#L$w06+@1C|5IKhYbi~x-HkL6u4ApMyjNP@nCLm@OMOx{LMfe;n- zL#~E%aG7jW>+H7Rxpyr^*TrIf7S@=JBU{|0j6Mti68~F1yhSJ+-*S(is?CqxHNK*cT)pp1 zNtJlgN5-aX+l<5Z0)n`=#8f~&F`BAfk;V|Y6D|@Ak}t)m&LKRPQ5`~AcH*h4v`w_k zF)~tR!DfwstG5B4Fh~g*PXA^!R_JhyBeb`Tba`9gTILN!KFtD%EaRVxsw@Joe!MrM z@!en2e6mmTS&pG9;R1GJm_)omnUQHg#}wv9^bug}ezm;~(p8CNJlY}O=xLO-_M*); zJZ8}d2CDTF1@O@XPgwR?Uru`$M7`1pQxR<7lcj~{6%jjST7{ZUelD%TA@cdGj86Ze zwp3-uf}Uye@O2b6)g{3FjcAca{K@^{*YZq(fW}Q23(GF*HL(Up$}h%H zSvS3jMz-zQXY*}x`$SN?+_(`wgg`;DO`N0l@sKo(7sfyB-~A`VC%#SJUvcJIeL9I| zvzQJ;s;#xbG+1GAJc>^N0JY~O7y7?OkNTzsGWA~i^dgX1|9-chdQIBjODaQqfOAy; zAB2d;+RlDereDDwR(uK=cg?cKY33i4+T3y7-Fjh{>Xfy!Z+w|+nIkT$NEf#ZK|(hy z%-prsjK&G~XkSHWi4zAR%irB&&7Ck+jmrOgVMfEx zw~1fV4C9&k zG=fv`qLiNki7SPFFhMTpysdWBe2BSHH#1U?ux-{>eXKOszxKN&q$X|J#Y8n+u$C-| zo5XFuT)4L}!@YnK&`FS7<^9_A9-eH{JB-YH2FKGT=`YwZg;4Oj2S8A||MN#oj}*!> zxY=K&t(^pA)^g`l<3(ng8sPJSrY2K;UEo&>SAmxzB-koD`7vEk8T}p4k5J5XKa=#n zkc zKidPTs$+=-FKIu@fw=(DMTqDOQQlQx;K-})UZ|xBEr=ZDJsyFLSHiD^W^t}Z;UH$Qh}b&S}V*ZW7c-n z;!ubz-<2eoEz*#u?KfGnqkXV9KWNKllOq4mg&AeC~7JnrAR`G{|1!c~;r#SPSI;%-ZRcNf*`V4`)utt&hrAH|V>Fn4RZa**qdC zxdtUDcnM}e)+)W%JB#4PX#sIU6$d^6!V#C;>nnvV_cM#Yne*tPz=j?gP*KTtOo9^8 z`f`6X7bAX5Pl6$FTLvkE+z#IEtfvP|EZcZ2Zw(F92)AcvpD<3hm9{J08MF@*=Rnkp zB43Z?`rmtw>Qfd~{wp-cBJ!&WPQ{Mjg!3@bv;I_q15JH#wtkVOvqco==47nN~#^ zP&t*v$wA=-k<*>_`b8u9^gwE$5#V0$;F;-b-IwRYm|bhVGoW~%d#mcFi&_2nd(at3 z@0AHj9##fc)_|<>{Xeo5V=k&i3Hx~Ov(7@1!N3#r>ES{+q(s=l#5XG$~j3t zM_H@GQUkMAeSOeu&+O6v8vH&D+0H?&(rnVM7tV_)bz=%P<5&n-g^Fy(vT-dnXEXJg zbm{a8dSLW-kl18(_A{Kp68hjV1L`Sj?Z$H0O_ONhIJUpgQTq)$1H$R=kMnFN46Mu2 zpIGwgOYpT0=G!pR%qDuPwff%|zVZn|AR>!m0+x`O&rZC!LNi-9w76CdV0W;Hc<~}n zx^BVQPmX^Xq+DbU)PXFG_qgL43fOwx*1Bo`_`@5%D1(s+?@H&ECT!j7S&CTTa=N>% zuY@A6N!F4G`oyG?`{kL^3&7q3it&5>BQK$&X_;_{NKX5c6pZ?<=3@0ApkM&)L9j>@ zG@*>f$B{#%yS5Cf?Bj(S2ly+ACr@T~WTA~7q`>IJktGg9)LBfBF#b^`>fpQS6h|SZ zGLR-zrm`GH=Ti}y>InkhgoJrL(8lH8vaqt2R4%5g7#b=+7&AE?{MzrYe&0!w-C^`# zed|AWG-`R6#XcV_ENrk`xpd^S-}?(qEQC7XzTKEP|5EHI)T<79u}a9@`J~qJ)%N`g zH3-6!D|Q`E`}c_1SxuBZY&pwVxY8O`xzjfN@dN?qBq+WTz8L(B;xQ2ycU-0S6ArCE zec`zG^fCP$67J3C0 z_nYpig{>cwY|SG6?E1p~5W@OgbaOng_x-IBA~oj&nRd*4jcIWit4X5byM#_Bun^yaH^dzmfzjd({v;HPxOwOA#DhnmLvtTGxLmYWa39(N zan~OZCzS$F5<5@Ri*J`In|=-;iQP4#U}r#=A5uM;jMJ?5;3o&;39y-_i&w<=p6iWO z%X4z$_ZOXStcH|5NE1`cOX?RJc8z>(H*oQ1{7|ePY9Bh)1MZq3%Fpz>AKrFi<>nGR zdG9$zDMiJ>$#C@Mp(1cm=bC*=>_J`QVLzMBd3ARFL&wT)(GP=5S}~D}r6k$ov_k0wjP32?%hX$X+;rF)M0j@!EMnvLY z7Y(_S`2}lNFmun2+23<1D5y@*Qeyz4mNr^vn%rzI_+^>x0UG;VaKJApr} zfnya*aOv>yJcLyNwloP_cw+M_aln5i0PL;#N4X{T``^8+%{PZxsSnXT!w0ha$^Mg+ z=MgjMr9gzqUB(Z3I4~mYw0)pt%f4hhroJUTKDb2jNU3}##NxQvsB~+;9D|?jjXB+V zkNq!;;xSrc#4MJWV#qEN)?>c=?_+a493A+IOCJbdqzfE=>MAN)K*@{k{{7w$?pBWl zZ>_w%U#3=4jIuZq|9b$TOVqhfzj=)Kfcpg*&s}7HhwIfULjAa_m;g9f$^gmujDyFy z-0kl_0blBG$jt(7I-7s`G9=(P!N(Zw^{==Vy!IdH0%j=(0yZ1xK2(Msbgl?F$uB9q z=R7JquB;ZmGhExh6u@-2@dArDr6#%jJ*jki^M^Xe)Lopb(*<|eP`eofivMYBP*FDL zAzYy|BJB#_?0%El*y9}R**Y37>C}zLAqipQ&saV!u@j`X+-o>KWF0pV2M6~Hz0TIe z0YXQ#!2VEvJK-@0RG4-|5n!`<%@Ggr55^CH@u&GXE0` z(^-Stl+kJaF>+GVP!&RjTf$B;CHDJf^W|JbwUVK1XPgOQe#vG%!&x@KO{8+ zaH8`8{fl8>l>7f*gQCjVR)$s$tGW%DlYbTGD6#oJX*+n)NIVP}SEc+A5KMKVNe*lC zjc(j@y)_{4BLXkrmvHDBZZwFzhxs|U{n%fP5yNU*EuGMBHhtfI!{FlatK(X5u62WD z>nNZD_s(Xeq)A99WB5xJF8BUH@P?VZguJnhj-M?9iBHzQ=^p^8q(`F!z+{yeCtwZb z+_-75Z0*m|5;l>&o&#PNC`&o)B^D4ZR%O7wmAo$*y_9nm{Cr?fKONnI&K2j&kyMWb zzl`v8bIR)+uTs6c1A1Q|8D|hzu^Iyf%QPslsJ>AygV(f5x;rIun||5^IuqlA3o9*& zE3QZ+#qWMuC0~U6ANy8_J&TTY17?w9boi|Bi(rj4x{)~3_+ebFNq@Il1O$`uMcCrD z$R=KAyFXr}e3VQB5<=9ndSD$mS@H5%fF|lWW{ER;g|~eHh<4?=^>DTI_2H5JelJx2 z)OWu1y&tN+<+ID7n9`|9!go7m3b;~6qE_cu7S9VDYTWdob~sc#KDs`w3%28^t9<5M ztj4*4*Ug9$q_LU%T4-wwdwUYh<471tbfTE51Fh1&=77Jcj!fhm-rL_%xDq82L{kLf ze+M@wZk0NMqJ>XYcPZ(Uel7ScHrN8rX#OS$mQ4+qs>Sx{jKiPB#%bUT1eR%gC+xr2 zlKm}nv}?3`b_69OjP>UVPtaC!nhi;Gy`HC z%sFkGgeMF4pEbYNPm)Cin}X=28gw-y5DV*hi}Sxb#2doxjzyVbrHs|wHI`lDRXpyH z%qH1K3>`9EY^fZ}%%Q0RIMWT0=sDnp>zOX7E;n_wg1}QdQAd^`+)EuVvqa_Ikc$ zSdGRHXsiG>Tc@pOr_}>pL%`0BO-Nb=Heehs*Dzsyolx2p!8#@~{PK{3_e{w-o8+?K zuZ`a4UyTT#-e4NJ`)jB&0Rp-cyz!sdz4_((T#x1Or0pwCxS39X&l}~Yhbirhm20+i z6fJ)sF1;qo1ar5j_xI{2b*!J(eN9*NSA%_^F$5uN451go0PpR=wrm#*h5Oo7}PYat3yA# zfVJxA5vOFMwR}!k8+phasBIB)YrUQk%gM#;SDc|-p^%%eDYZPiT9u5PbZbypdtR?o z5)E{2sXvNM(!M`a6~=ig`C7F;p$!``B9BbaauztKMG$2-4k=;PJ$*f)*rh{)RW9;F zX^~N05X~?0-tcDqq-*UdUSmf3H<8Vg797!Jk5YFE6t%V*}pKj}|J z61@fKjG#n7aB}}kv`nq84!IJk(hcxoxM-Kj0xjmm0-2&-MR%m6vOJF0%H1h?+&t57 z#w8;qV%(l07`v7Af-zysP?-SbI|m}6CB=jDY%?`G*G95mKxpMpXfq;-ln;^WT3#2U zX@#tAo`C%$orJxgUBUlfnr!h*midkl71Ai3k^aSL0|<5e{l8H7^ghaPxd0hcLevuC zZP=zjml;E+KZ>w=`XOxkrAHY1Ta2i$Sf^`eGcgBZFsx7wrf5^$`g3j^k!75tXim_L z5V#1tbG6ecGDG8~V+U^d-)h>)_Nf%AkVP(GSvnlQyDS+= z-P;;SR!jL= zJhO~L8}rd7nFD(%AD9>KZ_j5QlQ14k_y8CZE+y%H_X4;U9I^Qt1AFPxRAUhTW~ta;CG>4J{ds=_>C} zP8ci2fwo~<4{FFrc6bA@m$1|pr&B|Eh^mBrz#T{$8WNPK;M&$g=y)-KeG2bA={rnU zLBO#UYp#Mm77y=NiCGbg-JKUQY6ML*;3YiskAFLb5SY-=p|l0;vvM^I&cHZykxkuu zHp8x)cR>|=?|09rMv-dVbjLggy2yRD|J=Z6mxL^?pkbZ}+*XRv>`rMP+I z6y&LVXW8hjJGV>1%1W?|=U5Ip=>ERfs`r2~ALFAk$;^o&U*w5?eR7(rl!D92x^e&P?}E z&zWi6oLsfs_G=adVC;oy6J&1Zc>O3v;xYpUmL_QtyAP6f|0tqd5cJQllylWhILa+j z!%@yUzh{FN3jw#Y#6ZaWUqlUOwy5_S+6usef>h{#zcIo8=Ut6p&}$nAy7u8boI5<^ z+WiT+aNt{ps9M5(ym#rT_+c{iGa0~+SnkV!pM0IuOol*144DKWqe5U|$KcURh>b%x^Z5kM*WV_@N4Sf=Ri6zzZ%2qV#JB2yYWAXs zJ{=~IiH-WxdyhZS6)bR9Fz79^RaqL!l;p5Six5N2Yu+TjbBU+U9B(F~bvkv7)NDG* zSr8FefIan?<#CHNwaXel77fo!h((SB{Q~#oz8w|xC!*^|^BV+&b4I|;-h<~sz0G=5XA=&C{9e8oIn z&w68JPIb<>uy&a4ncQo9Nq``+DR}neB(mBmD3xqU?}#)OCUwjhS2;e3NWq7I^xaT` z4e6xoaqEPgq<~jvit`2!p=+kx{&T!|Z|1D+cLlI}#-|4^;7;Q5AgrJ9ZBw>F=Vvf< z^$)m&{V~>HP@9?rkIr&(F_s-$Gc9@ZmIiD8^qd-gn-}^d3_*pA2$Mode_`xk@al2 zgNYhVI+8p3S#~Og;lmc#W{mm|1t!mX70FGY;*DN!UY{yd zk#%+a!v(d^%t)9-LZVFHZ{2|nQnR1H!sIpe+An?2>}@+gHnpD*t3FY)?ck2XftJ6| z9b;S-qOse74VCter#LAE3HXLya>@A)akluP96dcSA3y!W95!B68G-%fbcVfpY1a?r zwDbjgW18H~cBZ?Nz0V6iGAGjL@C{ni$CZMM>3H;f!RuNs=l=0i+{KHP;<4(&HBDwL zoVeqPfJNAxb>U^TG2e%3E>unr;{Lh-y+&*t#CE}YiTZ0cmj})(32pe*tnHT=9KFJ# zAX}U%oV#1Q_1BXQdap#zq9#4TjzPEmy+x0PW#C&PjEKD*yb&7% z%!DKW;SvqKRrr3;I=g zm#eKJkPc;?3GKNi2}K)ua|JhBRzLQiQQM zS7W9Fe+mu$OIJr@6|p)m+85Ars7+p z(FlQ*9FX*kyIrn1$Eh1o!-@lp$`|&2pwk@_fLv5w z&x1sSzl3}{(Ez)evTw-fqvEIVU;DF832Y}<;PaRR#X;6SR8NLXV|m?T?LdbHghz7A za1z#}v|^F3OS+wzqH)3x3kwpY2YGs1?2tRR5ia2}%<_h}ceh8gI|>!7+kCSx=eZ_N z2WK3tbQaC@mnE~a8BPrrx-rl0Ih0F#gC?KgB2iS-$=C4OhS-{rAc@QOPS`bk(nT@# z#T)nmYQ(RaAkYRf#P*Dw%MMYI)hg>d1IriUpLQpQrjWW(#M~CJGU8(*7J(@zvBwTv zf3KO2@wO#&#-SW4cyaP(Xd_NFh};K)t$hg5>rO+d63s{J5zq z$OhlQxKrE1ty~G_ZN{T*>PA~+f6ihIw&-*KhCLZGwU)usK-88BBSw90?BSy0=tuQc zHRCA9D)&yhKtf<4*13wuV;5nUk9B2PfPFxdZo;ZPrCN*)T^Jx-aKmwmA|)GL5~Ah!7Uf;RY{s?%;>Vu?1sfvX z(;&m<@j7)xO1h*Ahxq0S18g;ofxr>^6-n@bgp7q1A_{G$Ui9nG1fcdWHa2?V8ZX+{ zW@!;iBZp|cVL61rcDcJ+X_guKth(^)Uoh{MR4sAdUg=iFJ)1Mr#cJdWIEJb7Eych00t?Gu_wKC^R$C zc#*PKw|8=X;&E9OWMoOj?r?OSHpXXHr`GuA?D8Zz3va+IX#UH7Zguz`-5tvU3V6iD zv$kSZsXKFKJO2*EZMbo-Z_CRRRureiZ8|5|P!(Rz_vMUHN{KCJv}U*xP>OBR@o>>q z4eH)hH%(8jV1#EBOVzv{CgK%c;PP!D7sbHT~w3w}+|55=W z6*ot*-e-PR++wX|!*n2gJwH+o*JvWqD#oM}UAdzRMl^L}qyYI+UjQ4p2C-#;X=CC3 zoQ(}bQ5jJ`Q-2MBM?L*?@?^)kLvS3w)6TPT?STI93EWb7Xf7^IG_X!|QO?v#CkL7) zxu-@@Q;5R_g4v(=poWut;YS4aYh|C70U(2V_I6nm8Ll-9fqUR)|CqXScvdE+Ck+s|*NQx${vh*z5BO6pJ?~Skg%2>pWun==7j!Zk<^QAAJ(a(l4SCzhhri!AKDf-`qlrerHCE zTbW}dxYB2-mVq28l1s1KIdfwb5*_ouz`O^X3chCQG3sVjNC?K>zHA32h{iVV;xr7q zS(*PEx)%9TbRu(|BS^FzL{$2TlXDxqy&+f{)!QYM#<(nrwGcE(sY}8TSStuDd!S0r z2gGCo{L>9O+Z=6O=`tP3VP)BOExFS}nb=^4F&p@!k8=7BR$2yVG%IoSMMT&>IF3lv zi^lX9&}N$LxP`{T=n0N*?HZ~P-;t03ERqh$EH{Xy?2ibUnKqb;q9P=2CQ?k(8D4D$ zH&;g5$>0IA0dGKT=mnK>*&#R{z7JPMZ3;wW`DGpmNho7K8()G1TCcEL8YxN9N_q)| zs1?OEwYQ`ks5hd9W{%bfQ}ODC3*{X{^8t!t>iSr=QwSOV3`5!sjCna<#_Hlvv7MbX zMpKT5-2!W3E^p&>KZR@VYKYL=6LO|O_YQMl0F=Z#7;l`G(!?92g%OisZ`0564bDE3 zR5fI+uErNI_wG#SUdjmyGwALwK%D5Ksh$9bcWk17Iqjz4HqdHO& zfxbm!w$5xAXM$@oFTA1Tqp`WHaj0=?2aTL-1;7Nm^Ru?n&zT4RmAzr$7~BZ>9&4>D zj_2OdzR*6*$FsX_G6Lq2X|v_%HAu=jng6-W>~bL{|XWU zKlbc@h-XD7xaOO6`fge4RhR4KC^5WBR7LA^J;f|SvjYCXtR1Kj-9M8iz?V-VeQTM+ zXdV=LgO(4K-9|*5XA%IWDYBHn%e02Ig-9H6TpdfsIqSGuaG@H%ILMJo3PY9X&?*Y+!zLF014H*<_(Rl!h9~a zEJ(uAkAqkH81oIkBO9kTHUfcmVb=h%AM0_~=C+0`DwCUREeRpzK0QTt=TewQDP3^M zlWo3q68_d;=@!k)9x``M^c0yDpb)wArn9mlK%y-e-qVIlg_-PkE*Cxdmhb zl+O0?J_mfW)+E87B{hwS7PrCC-`>{(FactT>yA%O3gHtx@tp3XQ6X>&H_xN0oxlKb zav36=pRe{Y(QKCXw0RLw&*1zbG}5Tz^tO$U?8N*#TFZb6gI?torZ%&T9+1FyCT4Ac zTS$03fK6)*O#%Fk@ZOw-@Onk?Mv!Etnf@l4A|dhYI;y;gz3GcwhuSBPHjoZAvOf~O#v!@!|KrRCo>C`I2rlcl>~`V@KeDXk4_hhS}!|;o{=iO8@qaN4Y7dk zzm$`W)7uT3v$h~fG4QsfnW=QYujdjTQ3iZ{S21L&^%hCi=Rz=l4N5b-_t{x2;TWH)4**J7{Q_bOB9BXep>MMZrX!?Li5f@uXn_h9NjeYQv?cpFYg@8M_3b_)BX1 zuavoN9DDCFUUkvn#l5lueNC-ThS;C-g}?vvmrlICqBw-w84YPsjyITHly&a6D%$-E zA0SCtWB8j9yzxB@JZR_N>Z~p>Y=rPz)f6q|;gFJI=g`=g`bGs(RLA_K_F3C9{%QII zTgmmOWaUt%W0iQ15xJTzt!0!3Oxz4M(e%AJRJZiuXNOSRh9KJS3AVr7NRWtiP?SX2 zoC;S!8O96#CF+)L2hbjW8Q{AY)m_ZS;M(d?J3icsy9}-6Sr8~t=_gqsRLPEi0m%Cf z3ZS(0UgI#Jc86D3X`WK`e?+zO^fuC@nLw;?7q^n~K(oQvYM{KUT*j!J(Mg?01U8gu zTBFRsXUHDhR*F@b3_T=+K=@I{K5#vxpdhSbF>d@0m#o#~j;lPhk*s?y^24HbnE#?= z*4DC3Rg+gVFTqyI&b6Y#T2j9I19|Cyarx_dNj}sz?sSk6>Q2FeGq4CnGKB_x3SHLaO66~XCEmC4ILKS6G7qZRpk5#Zf*ojXEQK`#RXu3srr-a zWmL}r>f6%R*RuSXtyp_9|9)ZE;t0n`x{oT6&e^^(NiYKFixB@|WYMTcnkFJOMcR*z zcZ|1f?SZm76Z4}*<4Jh*7#ZG#U%7yPAS2Zf_t6H>YWq%6<{seSQF751xBhM0hOvd> zQfK=byGj0OTktUQWeiM8R6b8fZ`1sI1>D;Lat&T**`X(%!A#E}Lc;XdnP3j#{iz~| zc)XE>v4f6YAa9`O{9tn%xtPn~rWm0>^V`CBBp;byu3@IthGsMjr$EfGpSJZhGx)?E z%yLrVdGMS4G`5q!CTJ~Y=g!Vx~zu`#-86i>MHn~ zgIkbbB``EZBr&Wcq4a;%y>(QSZS*cY!~oI_0waw|D^ile017Igq_iR+jlj?`f>Kf< zEp5;#T|*-wAR^rQM*dw%P@>-+zl@0+z;j%$t2bLWn0?|tpcb{Az}?0$TX z0phfjWAG|4gTZBe;jWX=^+bYd-K!yW?Zn2$1FZnGFp`ze0>0p3R)ts%$~nN)&-+|&?N)vQ+zcXXkV@}OLE zUD>($4Y)DFADohX{={5;vT_@$X9Ub+!ELgSe{m}3m-~|i-^bM*obOhXX2@Jh;@iA3 z@+0xpCz-6C!rFirQ=E ztSmQTx~~BJAman1f94Y-AZRZQ%T$LkG6C#Jh!NQ5iG$#f!W)LmkM*L1+7o9Mf7w5FNP( zh%hY@DcjuS>J=AVEn{`%HvFna#Fdf>CYsm)Q1Y@=o~O@sHQ%||pU&J+lvsUI!a@9- zReq%KqzX~ui~Rg1WU1q2=|QtUllEYrKT$QPTyfh;WVrm^zhZ-D{E4+L*&U$%Dh+}2 za~st6%G;OO13y=hD}ek>d=QBK@E8SlECN67GhKE}khJd)*drYQ0sP@%6yq1vYp5PF z!N4aK?e;r@oM`_+)kx)E?at4lTRr{|fN)-O5|i7Zn5U)AZ-Gkwe*GqM;r2u1?skl# z<^f5tLrb`mMZq>iqn-DA$_-0?NhLucMV^ppPL*Yz@dLOghSu#fo}0V+hx{5q+`uMs z2I{GUbg|j#UGf-Jg3=j#r#I|HM^?fzDI&Lt>WWQ6>)UQ|MR1ry0qa1E)a~BoE_>`>9 zWLGafFw6V`jnrB5Fbl&rLPT%13llAK{Q{;IED^A*{qXl;VnPB-6&w`%WfXwt^zs`^ z3>O+hhFfeOKa>lAG9E&ocql!+HAM_qn{G%Gm5FXpUe8BzA`eg`P4ZV3kv%PG4J7&Shfdx*qmSpc;2 zRXs~lE9ID0KdA|qt=+<@M6X)sS$V+t{OTltYK6_T99J+Dn zcqY}8+x%Z3@&U-v89tgYh_ze2H^~102V!+)WOvj+3&-Zn_q^2o>Q4|c|KbFot9q_* zJBvG+9V`S&G-GLU_sgNi9{5sroiRm@MD}D)9Xr7rYuQTQ#o92~o_)g)G8d!ZnBl!) zoP+a_*Pn1r%)5@%B3UbZWCsCDVx<3t|E7T)Hud_>jPmTq!rtGjPIN}Ef$CCHQZg_6 z%?}A=mRqC*yv3Bv>`Y@HMv79Z3s^hhOCkVkYJ4S>8t9*U#Fq8NpJeGKCMNsx6^X%k zg{Yo!nhUl!3f5^(dJgoBu!$#OtNO)+kfZ_mCGf1W8}XL)ExzPj^@OR!LZH&TPtMAi z(lWsu6VL|+Z|(LDz1|m%2RUHefB1QBqWn@){DY)|u(rF=C}yb@%Ihp3*&bWtOa%Is*G<$* zU4gz9f97YOQhR;YLh-npUlqOBKQ&Ap=a*drW;wSK@3@kt!rb{{_P3Ql5R@w#ZzK`9 z-?Bdb9ls!O<97l{NMPf*9Udg>3m62&Z`VYP`_uW!(JBkFz;{IueASX7U&_>FxcDc%$@ zB5!?(5Kmbx9U5ic;#+Mnnzz`u+N1e&mtH=qToyMP ze{L(1FrN`~ka~IC)&3w=6h(Y+w-Q^5a7Ht^q0zJq474F1j}KVX(p|wg!s3Ydm$@c@ z?0wTJYa>ixBvHT>bRN2Un&+W;olODHz8^#UL9ahv979c-B$WEoc;+aDGPJ`hiI*iF z$gARr+)DO=vmRxS>PYT*Nr)1M+hOgyqB9~QC?QWB<6cs>gJAL1KiuHTnzmHQi?eP+ z=Xq|cSB%4c-m5|z?gz{++%Yp8n;~9O;qA1=LaHcs*W@pTeVIP@eFSI`s{{LL-#i7h z?N`_O$j7k)7OWP`L1)wg8kiV&5BYCZ>~G>jrt8Vd!-<9N=o=N(U1p;MalZK>P!ZP^~Hz~Ey%vZV-+HibuNNM=NIAR$I%7oaoz;e6ZFbN2^+v>xVdd_7*@jo z!X>4hfHn*KbhQPcU>wnmpkl;!142DPZw7{f)r~*N{S)FS>^bysJl# zx1N$#9pBEZR9WbVk+%_Usb+#aOuwiAwKrf3F3f*3-J5I=W~R44Y5#n!BKE1zfb8l^v3_9~mh?YAxtvkey;|DkO z>gQ?B4gL&bA|i^-J~=3))WbA~5lZhVK0=Fjc)2eT>!l?}&~fXQV9dax@4HB-@bhOI zeC|0|j=h;ho1F;bhv|<%!ir$Nvg5{0_q7Kt)z2&VMPYU|_ZgBB<|MYhJtmsi{h}S4 z8ZtFVKAr#ySQXK0aZ<2aazrj|=k~X3rX(vpLO@1}Sl+9~{$QQrq}SSaSJV)5=GbF| z1RgC62kHoIMlA1v%v~cXfLt_2cb9nDD(>-DVe_IlA{Zp`=1G&@ zD(Rz_St}Y9)2%F_!Z`_!qcBKrm5{&}c~eXbVP5oJHRMSaENu`ROqRh57BDx-K6lmf z8mrO;y3J;BU-2XDN^%Z(3@A1XR-!fHC&Gig)&RtSS0}3`n5mrZROMeP6%76TRmRff z(+^uTlL{?F%luuny^fB0dp*y?U%C5Du|Q)K*8hHiw6P#j?GCip1oT2c94IuO-S`+cSaM&-nVo(EE! z-yg(W#Pp#CvwDEh@|BxA``dXs(I|Q+`Jq-W=cOc6Wi2uFlXT_zn@uM@{ZVL%C_U2> zD*}0hLX3$^NMS17RVX0&tv1Bs9SKq$_uOyL$gWad_%>w}XiSuq!RUdmc522;{${@V zlM2*p0wR%Qxx`#}S4($4HCY9T-0m}v?z&i7&-E80`L+HB^qVsqo)XG;$w-lNmC{Hu zB$`xEkYp<5j1SJ)mHPhq=jtxi=ke`yM@E60q)G~BQD8OE0WFF)&l&yV6!f5x`pee+`-dn+@TCT|vchu*SR|%`E53&!YI5r>1a2D8t(wZf@i0Trgj4ucTsuz_`69a_W8xxyd>6n~CWMvg7 z;2I5q6c#wThiIXsU9&Q%@^I+36aNKDL&>uB(s^5PR%b*HtV);^I%m_2 zdY+uoT1p1w15`(^zU>+c+>If_lrR^Vh~Z|AErnniaX4pQl0N(Lw6t_U^4>?6)w z!jy`?#9JrIyMQvaV$Sbgw&|cK?I%sTY8q%@>K0N&){Fn}19i=hk<>?npT)9 z-mf9yAuXy+CU4=b^DZC`tbkSWTxE__16^4pcabV@{dc)xlMZ8|&ZSRyC;C=@cDgo3 zMqUyWBsEUcCly&?W%(vy6%>FvMipN=ly{23#PCI=mjDW_(ybgB*RW%I;(a}aEv23dN{D2K4 zfN!oKz%?|Ul?Zbt@%%NYBCK+J@O^+FCkJ0&rlViKNk!LJ+i<+`F_#Fxb2;WVs>%W* z^u2cWHy+X#ujX^+`4*r9OBZfzSH1c5Qtya%Xl=z`(BM}`*&;rX{hp8l6mreD_&^@M zq$yj=7H0>JN|VQl9e3-mAqU9FLl^gV-vtnI~m??&a}!lO#op4N4MTz2|~6NJ72X_gjH#t5K~0sXHh#R z$1N@I>Q@2d$}-aS!?<%0XjIr58D;tCB1`UbJZE;bd-Z^r?Cx=<81s&SA&MF$u0E=ixZFeCPx6F zUxCTw$i&v=wbc?572%NRe51Jf1h@LTfCo4lLAe&O z62Mh$&f*&49tS=rNFAhtd{B8v4*@s)jC}Isrxi9PJ+f|Qx9p7?A+Ya@dtUM};!Rd- z^d+H_ie|~nq^K6VcfkN`ad-sD9kPh$X$Zdt71Lra3t<1U5*N&nUMF{t^GFj|&+wj5 z9BAFm{hkptJw-Ww!N8F`)^IPo9N&|Of4p=?$hG1!(mGwtxmJuS26n4w0%Kx}_V}n? z)bi^@V(W%`)2j_oNzY(>5??Vb!~1T&-yjzgC?!epR}Dq1Z_|w2$@TFUF=s9nepcV~ zQrD2T?0H3`vTj{mOUsu)rSC9=HcVcyRBZQF>k$-4mxg;foyS$6;Irb|_uAf{w*Zxi zv+g`6L=lkDm9I>F-Yn7mR=nTT@3UEhIy_9FahURXU#Wo@XQtfJfqq^Lq87VYc-t*` zg4$2`GdHoifgSsB*Gcl4Y+c0hb0OrI5T{?@ZdQf9*bn=6q)JH&GF`G%>sf>=(t@du-ZU+$o?8dK#M zyH--eSY?>Szi$0yij%27i`(|fx~?>^LLwavBUCPIET1$N*g(;ieHBx5F2b(}z%;WM zyD3f`RpDBAz6*>~y!aVl6rm@zz2BAI_Y5$!ZNl^w^+xO3a^G&w@Vb1rzfV67>kc%pycS-&!$w`!O7ZZ*G{pFmyH-mYI_mdo zD04ui0IcB61Tn3L2t;^$$lW&4i5wWQdbCUrRK{BbmY}BXZ4Aj?~rF4_# z9(y%yq21OXF5(SC*jL|R!L+HnleKTszZFvhlT?x5c0SBS#S2j3KWeSp^X8y;3DLgn zvZAD5y&lvQqzMw(!P$0M|BCETaq+*xJGmb(XDz%}QRgu0J!3WLfda)&!u`2bgHS%N z-kB(QR!|2f#=39i0i%QYV99u1s%fXDzOlDQHogE1ay@<4zMB3g6tP1I9?ml09$+XN!P1NJcQLrCAqb1$zbDRXJ6(X58 zeJBwc#D85KezAJQgp{ER!^`>G%!>p-dx6b(>Y|<2XpEtkH1iTL-}Y?!;9yTU@r*Wv zOA;T_7X*}>a7vpRwA>JxN~QpT&^sPWUpSG}&0O%3wkU`76--mlB+}h}`sG?^;B&mu zF*`;dodd*PUik5E3K`@k>071@7cgBBHu~IhrZO+>v$LY!v75P5y80S!UA(4)44+oY z{5JHoajl$4Sh6*Bxt>ma(_iY0WKc-Dx0nC|$?_G~<6a=YT|yT8x&5uhNd#}KQA79V zR*CT}S7MtQAuyvVKdeUYUb{E_`2(fD{peZ$I8bq%t%v{Ikt&rdBBzAf;{q? zs z7MhuR85Yj)JgvX=DxIv+FBJ~*9k0P{FZ^+k1VrgGe85f(3Dl^lGhvskBh0M5=l)}i zUE8jC@H_&CAqZrO&PVvMCcNRaTQ}co0AY#eHmj7nOMl)vvesT%ynXXB@|+IeM-NQ2 zoD~_)4=UTUrez#J+=4kzAFZss;heL!be$miEy_JXJ$pMQnXTUAE?ZF@WrE?Q#XXwP z3UJya(H%J;5a?s*LA@ZWgAWP_JKZ;j7jG*Kn4I#IynjfX<^R*_2dIIvZ~{p&V2VZ= zC$Kyn?vr27jJx{CMQ?FAo2bq%G#=}5v{tffQv>*Z@3u*u8W(K6MaiAGeL?nZ&dC&~ zllo2Gm~`qIek?Ug-TO(-CqoCRgAhk(aG=-P3y%5)N}tE3QXeJiBuQ2?U*D5>Bs^U- zaUdsjaeXCNPbf*?OQcnTvK^)~X7aqkl%U52Pnb z{7tP3DuGcn>i^MxnxBReZveE}@r`Y6ggCF2D#jZtZne4wT5KQX=mMjMp88HeCBJCn zn90wNgr>gQ=GH6MgLfl9l@@?>z#imH*8`}0vqk+Q&C}b^XNMnUmL5opWvW`52<+FA zk&)lEvw?uz`h(lR^6?vl{rQXzxD(l3XSIw6KqEOLaXKjU0^k^4PRt8hS>>{QXt~Cv z|5HXXtoZvz#}%jYA(lil{xVQDU4BkWQV=*urdgvr^S<$f{O3f4O{x#g5Av3YoP6Ri zetzC6r+6u27j959)|s3`UGW%GDrxVc?SK&Jm!kwk;WJ+&(8o5qvy5SY-g|Y=L1MQy zLM9cxO#|}(YK8{yNifz7vLtSTf{Rt| z;#`n&0?~Kwvg*w)10{kH<$ru>S_OR+b0x79yO3T_PI>9VJ2C;G*3*L@UWq38o$d`< z^KRPpHFnK@A|%YEF5k+%iyhKWhFE}e075FHy|H|uzxPyxL@R~nPoG9WQd^P z%K%IjF5dxk_Qubtj}sCK>_fX2rZt`p1%IG(gBAp7M{&6(>Y%4k>r4y6&uxacUU#zT zOIAj6M9nyeih{Ub&5S=u%%jRn*7fpF7Rxnw|77Ez>5k$(;&P)g`lZlp)}Q0{&fb1h z&F(T^sdaSww{C`RxCAQcVJov7n$oB3pgA+4b#jHB`0zAVLz!T3{Cb%}HdTtqAA1y2 zH1pl1sO;Nf6yv2dzkX=|G1G}~)K}1tFcyB=yZz}ovV{|=q3?$5{UiyQCj|$r3|J?C zt%SL-nWYpRF(L8i&;_Z4SJQcU7)atGfYSI>w-5DfXp`u+F|*g?F5aeO*M$WiC!`aB zxA%6cf?CJ25~ZRuB_P@FeG z7A#ORl$n@U_#UW+1+93;RWUidixPhyWlA=!j8_`U#E|dGpY%)JBO>cSgre>Ckc)4# z%o2r+^B_D;Jcv(*0#Yh$-OKNt*Ne^NO7xqr#s|1nhzOw6|9#oq6K-C;FZeblZ*rd( z@RF7F zD`$7!7=g;Dz6&+lzU%U_s9yP%dHghE`Xg@#bqu2KJ&^^EeK_Ts|b-{5z)6rZl{sX^tws{ox=gp>1i{k6Xw|0-rK#nT?%<(P=o=qJyl3mWB zJjtKE^z12pR!H-2%3AaBx2X9X{K=O-Ln+>rXuQ_mI(fYk} zRE4Kzq03Nu$D>S{V3I@=s~j$TS(_QWGO6fptT3pn2)jyQP_eq5a3rE^09|rg-c=Lv zK#NO!CVe3F#D@&-O_-I@bi@ivpg3K+fh^k9%yva6SUx8IRa^kDo|+YY)`8w?4!Uv3 z?%=SfW`1^{cgxNWUp+{m6yh(U(cf@Ion#4si|*3GZ+(QgS5TISetwzuAi^ zC9B#BM7ptf;(8QN-GbTMAV=%>a&et;KQHdy(kdt8>d##d8d-qnfbK`ri?f9H>0Vbi zjKY+HH;QBLPNYfw$gDe5X~GVGrKzeR`Ec7|ZsKiShn<y-5zAWX~MMd)aH#7Hg^-MTEf!$;GYJp(EgJu}z!A&@M!W-?G+TuSz5 zAp)O_41cS~j|LJGuquN0stbSb<*2Cufd6G5?MDEcSjFH(?jSgfzc#QSF@S`G;K<{= z92^D6c0#KCM`bzY+JJ;V6DVYlzABm>7}|Hg&BIR=7`qc{T3oKa5Z>z` zYqBrD)aou8%^r9|Vhs$jjT>J~3^iF{_PSJvzSv8~QaMiMEs2rDq>U|pll~5COl#m# zE1lEvk&k~f+-6$+tKkBewwn!1ft z<65x393P9Ou!Q|&2ITo7exi^9ep^qr^HHRrbC&){^@Hq6s3*rCZE&cF%{(8c850px zT)?!JI1=aZR6{VRg*O}apj0@$P}k&u%k0DmyzjR{men1zblu&4GC{}B`kaK$z5R?J ztzob0=hW0T_FTbZ3@Yv4swnG0t~*{)nqyKbX$I;j!5)OaS)=gWC;}4@rVqMH$-Ou1 zRIhBB2y|Ij#x3XGAESe%q9g@dFURBnj}p89WM%1_y;$sWZ>C&hg>@bV%51tzd-b4D znJ5uqe-=ngGrH|BWck&0z%eXRh~~6rMIY}=_cF!Iwd3D(_}zkw1OoY5pSK<38@Rp2 zt7q&j1RJ0s0#5i49pA-BCd0GsE+X%jA!Uo3%79*_d-l}PgJdWlNMP2I>K&ba;!)?nW9H(=m=J(&$ zwV5U=hd|$`68d``1zcR?0@f6G$;t|=!`M8(tZ@-(Y@1oYqKYt;tR=?>=@6_C&J3b> zzIqM_#nyOdybE5pbUS{?uX{ATa`sE1M%lFbB*4bp=)lWf4l8B(OpH`AZ|TyIzzZ8l zam`exCbtL2<1)O?-`t|uv!AeoIzV5nsqkzLO@@0F9$!&f+z>x~vL=om7aICXbNHs{ zyGdW$K+8qU-p7RU3N7Gjmw!LBDqQW4f+@lAE&vSH(j3jF&u+afxpk@rQCzCW?Y(iN zmpiCY5TfQC#zSL$8}A>U@q ztoijH}q`x>e0+f0WSHCeiam$vUr-{Hp1M;929!xh@xN5^dJm<}JMnVwAJ2B?Q$ zc#PQ{&rp$r^85Lo*X)p3^`%*y9fmqve-DX$Vo>;Y9?7!br{lZR`pcgz0`)AKwZ`SQ zkxe(B1u)v&M~r|N5_$Ys$fdVmf>Ni6f}6EC*x*C+9DFh?4nHKC#Mk^hpe+VUKXXp= zec{vSC#M3qW_5dXYjdzt4NK?mHc`!nIZB@g3jSk?{{ENQ4G#stJ(p{4bydg?TqurO zqnP+9Fq9T^_z>-sL!E!e2KP)fs;TL+N-dl7tjB$j;5U~a&ko|UKr_9X@@xz?G(?u@ z;qd0Bu04@jrSwq&iJod##J7KaD#^T&_-X`@rU=X|fD^3i6p$LJlgkwCq$O6)-*nhK z#aXhRyh=_^LFQ|7NB0NQ%((>!vVhAb0Q#`iV4kpQ>IXpRD*XV|M1q^!pXI|HV9Ccc z1U#|tQtaMa6I0YdQYG&hmuoCE)y=B36vEejv$sgO0&$b?za3~x*JQWz1(dFKH-{9d z|K6`(7Lu@KDcFvmm?Z`$eMdTUnb%`X;I;>)=jJ6KK~G6-%ZE#=OiAsqfQm3%e;XcRaK9BeqCV_H5@04 zWl;sj5YW3L%F#)omexD2y*Ls`H||wk{JlNzDYu}=H$-z}u?m3>s?^?_J{KXNHq#;C z+IVb9mI~D~O^OZV(%jfuN+R?=^T!}qdsXH99~GsiZbLyjEG4Pz?gymI$~md{YeSGZ zW&c={-uwa(x>P-D^=xP?&`Hu4AELE0(u@bn!Nz)had>nU6(|v*HN)JkqtO_5z>Wtm z$GhX`-P4NU@jy$&mF-pk3*UfU*z6o>jQ>*W3SpswE~PHA1Hc1T-r#XNHe54?5w6R zZu&zlY@)qW8?c)JZZJK^{XdMuhi5g52?O)iPOrN~Zr|oPu2Pp}s)gn3C*HaLh30%d zaoR$}Whr;U#@7Z@GL!-fv=8UPMVUE=(45^LHM7eBc|IEq%|S}JL@P5P0V}>e>~gsi zw{R1u0mv86%}rccCF}OyG%QN6LgCvY@c|?dl38rU>SO;ic>+M=;A?qj`n)?}Te4>8 z6Pcd&b(diZz}h2{ZvV65TY&21N^%9znYob|2910UPLB}vA2cSRAd|qOak^7%WUI?_ zR;mUx)y|r2beE3<*Ok%?km>2ry27VI6EH99p5b@+Oxw=j zn`Jh6ulW2xttFFVKAb!TxG6A*De7~&V5)m!)5nagAa8AZgk}2P8fySn3=DUpo^Sm- z#X=C7?N{w{BhG+3CLgS4dpmRCH^(MAfUVT_VgBO4bLufvC(<)@pM`~OZ=MBYh@Ji zjMKAkHYmT!bqS2!^feZ+ivAhMj0~LJSE~V#Afk$iimnIxCNT;D5R3aApFFb;5Pz-g z-F0-(6|bMDrT3_=m2+lJJLs?P1==+j1enOi%iTzciKn!)B@J}D3^$UlQZ-rW?s_y? zXA7rxs;%q`0d7-9OHe@lcFnzZ^HErOaC@RH=+I_2b**PPu*7#7#UMbn@P|QMSsZJ( zKC8B;+OUCOJxT$WRj+8C%j^-cXNjey&RlE;^WogoY&Nd~{y4qze9--ugRFFmjw4##nkY);4HBq!6Y2s`j&5gY`Dmk>0rcR5*;?2lU z;L@t42YIbs_ZZ7BnYGULMlgHy<(;`;5^=DlH&z$(`m?)LO?`>LtWZZqICkrdu6@eT zgE@0m5x3TrAJkGD-^Hm+W4X075j~RxP(p0uXe*sKd#m{pEm$(0)3%KIHa(aepEHP_ zg{lcW)^te`%&B>o;4rN^_3(E!qjP_n zKG%G>fx-0}e`SAdZM?75cmr&CAy7IaS7LK{tmtOcgP0;%feRp;!tD)k8mFD##3T(@ zkIC;D0dv>Ae>QTh^_C=F6;w8D;?;xNps^ZfmX(&P7F#v=<#mqYn6(?oEnn^+s*JN+ z#isrcNX$uNb714jWudCNb^86SXXY6$Brgi( zqDbrXDLMD1xIqo5vZK98Br7XZ4VH}0G5J`n!TL_*a1U++2k|*!0HcL7@i9UfnUHE$ z#Vzs4AzChuY*sjb1HVLqk;(#>6H4a8`D%UV)y<5jMhDW}6#d=jGhZ~kF$)d2_57o= ztKLFKzB6Y9WUV|-H-tX4k0vise2k1#)QD?W=JwIbNLB1oR{(LZ*UW2b-nXLFsMN}~ z%2cLq>}5FbhuhL6<7%V1aK&Z_Tv4d7fm1Y`k4#p|Pwu$fM_WOX#AVyCyG4wdO=1Uy zWPTUT-sN=vRNnig8b?8X?8z8=+j@Q9Sv5buBO=IRlF{6`0iRe;xPv#jODk3uy4!79 zNdCq7P)FSV(3gCFz>kM>^iE?(OZ$FMxc|h?>cwyuZp?#(g5# zw@7SGey}(-D3~rY6s(q>ixTfTXPT+l#**|37MhH5oNVAOk;FTqdpyiUb#(CnYv!!H zJM>AB_E#bH5ZP_E!niXM?&z-L9M2`0-9!Cvn)9T7L6W!`8Ewy#km&${Y%<1(JorR3 zUvWp&R7GwY3~sSCOr1k;ZH%w!)PQX`8rJUg5GTKIw0L3cP4BOYTz_>PSoyCk9+$NL zF0xci{_F=(AN45aNdgRwEs+$fpa8>g)~LUSmOvQ!7d#*S_Z5(;{iU?W`-f? z4GQyKE7s_=&DN(`q(&7d=2xa8Hp>=63;Pxh;dD)>k<>F1+xniXtqNR%uGfrR#9rc# zv1X3~pV<3ip~`k-T1Es@3NOVc$iC2+$X{|!pxCB!H_piU^dv<4Xsmks`|AKD z&2v%9X(G$>voD;i15BLn0gVH^WFYmypYTtgcz>TSzs@f%M&QCtzg^fCIg5wWYFn-S z7=;|gq?ClisYXTw`R8G& zAl`>4=5EpI;AS#fZ1~Yi(XUpKAr7)<=*Nd6;|h0NEvC-8Ac5hN0axOi|2T1~8{AWG zsVK)k-PISG`^`RoMwW3WzHyBb52ACHs+qlj-txeutUZwSS6|pQyEgZ0sJVkeqeM2N z-{ysKfAfiJk`bZ?7rOSqYtDi-T2?f|EamjEea{s)+zpUcwEOac%^GFy!e9aiKJt;s zlO)e`LjUInnxLgby)p#Cuyv%Oa~94bpzKz5KO_FIe!yr=%3Wbb5*I)nE0RGWl%KD? z&x*r~O_e5&7cSm;-)@cj)*5*gceU8$9AIjA5Y?{);<)o>)aLZcId`_?GAcr%>9j-T zbgCWKx-39di(`hEBM><&R5*OquWo54p=+)GL!a#@*16RME4gired7BotS!QB_RP%=lX`o>-az?preq%N;krn%553DEfVRZ@3nno0EAdsH<0Ev*|f(Xd^GmgVFOF3Ts> zLkRkrvl4T3!^D{rXPpgY)3xm>Q-c1*fraq+kTUlp!_a)&%nH^cv(xHQqlU6?%k?5j z00)ZNG76SDq(2apdkbG-E~MxetwJkdxY>#M2dam7>ym&o(0&P-wR&~X^OVLo;ph?z z>0|1Cp%~yX990LjpE+Fzy#BAOEoh{?f1>tW)rO=Gt_d4@(OV3w)>IH2VbA#s9X_Gi z;4Kn2t~)P3g~VUA1wJ9HqY6x;lX=MY1du7u~gc9p0zN`y;( zN2>UnLh#t1OWlz|r6&g*0bNdbvHaNP$hGXGR098PZaP*?xu}BAO>gw1DYbQ>Ui(5Z z`m^};b`>YHk8XZHDQOJD$x=5m@_C(sHnCjUx>K99+R_X4(V;yIvjK3Os6z~Q_RGI9 zwq2{ZhBqfn@c(?4On`CT;qSYiAXB!RgUw z{KlamG?#5>S`EG&zNNs$ujY%T(XM@3ZC%tGn7P94e*F%f9REcjWKw~t%bLBB zNe%qTKsQoVySAvok%_H0d`T33Z8ha!jU(8n{3J0jk1iS-=+EJdw(s>B?zn`|Yc=WT zY>1i2k;bi|zhaabyyM=&Otc>nfyZ>ttPgfBjv&Kpd5M@Nc0ihGT>d=sL=*CMHd+RA zrR8e#$6rW=CObm*)#sAg5#je{XuVE{z#S;Y7CAaPH7!4xYgsk?2JlS}sQlRxHhcJ} zccY}WKDNDVK2a46bD+Dv{C#kHyD&|B9DP(8k@q6{xMXr@ zl5CIaqp=Gdem;5>k)OF-vE_`rBzn*;wieJ8;%psqyzxD?xb0wgV9vtyfTPX^EKC>= z5s`7Tx|_L-swj$y4Lom$hiEVg26{15(?I+FYL9_Ax%+O^+R~RbpF-}PE~|}~zYp#C z#&9@YKd}>3pSHtxvU5f}x%17eu@!a~PH#g%P)tb#L$57Ar}}5Yx6Ovsz;%09gVo$3 zz#LZ>F!;gYEnnF)j@QII2NE&!CsCn2z1vd9k|mc%cGj+LV~8)_@v3C^^F}EQ&3xDe zE?~mI9I>5aISm*hy3qk+Ml+<+&f|26;;r*D4fryDn2!QN^1K{Nceo-_KKD8lAM;#t zTt0{$V&~{VO%qS%USEWpKl{+33dch$1~8XO0NBhU&+Q!$W%%yL@)~M~v)``B(!zW?e<({TgMGLa!;(!p8i)*)T6u z)*RHnoOrxqB`K0Ev=l!50Nml)df z%6s)p5jS#BkD0HbF&LsMna*^gHE=fTk=)F)uH)#-ut<{HKfzdd17 zH|{6Tyu8N$nz#RzXWBH4<2)jh;&lJlB-sDAo(PqW%gPgWY;f zIrOnn?YZx&+=&U^zv@Vb|9>eW=oA&k|H_uvo++Cj_|v$0aljonPuHaUJ^Hu&ov}bp z?>aGq?o_^emm5|!jtAHqi8I&^$>|#Zeb$whfAyX=s_`Tp^c>k`8Z~?E(Y&yg-hthB znCZeD$k4R(Cfn~7L4<{C7qTivsNm16E% z`;Q;*+;D%CboKS?162MU;=g~DnE$(nssDd74gP1D{+-GHT|?#n+-SOSvrRD2>Y`1Q zTp8Ca=~{t(MarSp%wezuh5Y@H(0Jkf^GykE`p=g@>R6_KzHz|+&;2Y$ObiSAcPNK( zS4I9AfnsPk*+1VvH9P)4--1IM{`mso#$gxlzNhjKUaVyP>i+=; CZ=8q# diff --git a/docs/images/step_by_step_guide/5.png b/docs/images/step_by_step_guide/5.png deleted file mode 100644 index 7f52712b5f9968b0937952fc47f2e61eec82095e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43017 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Ⓢ_d9imL)?ZBCY=nUE=glcZvVvu5{SvpHj*`g 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 diff --git a/docs/images/step_by_step_guide/6.png b/docs/images/step_by_step_guide/6.png deleted file mode 100644 index fb8c6a07eb2199c81618d7191656fffcb295cbbc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 114919 zcmeFZcT|&0_dn_pu^{DG5a}WyO+e`#6$BER)BqurLrV4!P$?AWQ0W59Y!#V%&ZUFxS6x3^l;F!gx60aohOzv17GK zXAhm38P8|@3~U3A9b@nQ_4%#Gx6Jj}F?umT_qJuI!x|~T8Bg68N)&+k9J zXp6rDj5hym;ttoiLNAT>AVr}i(9o7CZx<$q2pH(^_xBl@^c_&u8_d--{*6J^$Q@Ult@0ym)4K)nNW$C=9Tcd?S@u7F zK6dPpu=FcO9=*^S7&TxJDl3#(>TEM{vQf37D(9dOtW(0;JDcjP;--=75N6uRJG|N31M>F!%L-F`rtrk5K%R zM3uN_Y<_Gl4t91Gb#-!SY5cDyAVNt!!f2utz+WxrMO<9+XU@{6PxXKX2KVMZCc@gP zg?o6CVV}My)hbp6WXHgbs%P&#oY;7wUR?SIIM~_e-_WC3!T3qMmi`|7n# z(>h%%{~(5^(c8@AA_+SsgW8C(7vaVerybvsQVdeA8ILwUaEZ8Sa<#a)SW-r&k&E~u zDj@U+&y224-p3zhLbSSrH>^Rck=XXjvAyY5+MaU-MiDpv>Ok-7qw?4%L{Td&Nm5*V zs3Y>g($_R%6O>Sui9jGOlKr0ahiyn@Umw|zfHKB4C5&8F z)<22<%|v0_i!bTw$}e^Y?Qqi8Kd5}^I0)qy4*gStRN^v)YRe>g$v-@IH+~UVgSr2@ zs7M6kh0_~&{kn$6*BDCFX0Dfj1^PHO_!!+mAl)b5e1ym}`VLGOUK_6nj}s%{>#31Rh0b zU}&ia`T1xu_udBi|js2ypZFZjCp^R4zGd99Wk$I14m6Y=<3SZlO18h)ztm1hln^m+}mxOeZ(`;zt)a@{|T!{0=%K-i67VB6T2(fxjF(St{jrJvpMaH*7}2SLr+e6TaoAWBm*TE zZ`F{Wn^nF|DmROr+9Bv06|%SEq2FEP_^i7Oo6r7T%}mXir{yJH%&lQzVfu<+*}itd zI~DKNC(K;zr<@IElL$S zUVXlQa1d17BTR>!o$7(^m%600a>rl@DEa(iWL`jJ`+^zqFdKX(2!xy^XJvo&EV0$P82u>Z%j=sv6;Cc+ zJTzZgCe4&VY=uKEBo#V=%+76S(efE%;Q>kSC>q|OcK`8403K0}NwUgYX>rkZQ~JBk z-xcrZuk*y@M;`2qW$}pwzTenh!QpFN^hv=J-hI8jXVRDE=RJoD?m83m!dE4@W6ouN zRIPFFg}}aVOf(JJ=@a3a54U|jl|j0BFff&DXlk&7XQ2z~Xx7K`6STF;uGUhpL*uqG zrN%cfkP^95|LTB}f30X7Y%t>4-q5hxA)D=AU6j8%Q5a{%Pc(^?)sQcOebmsh{m7M|n~;5!WIdC9Nn z{O)OuH|P29gp#^C^WtLBw+H2~4rIFNzVL4_9`+BI6&a5^=AWc>%fbpaxRa|wq9$$! zY&OYkP7YfqWplgu+WJyBLSW}IhIDhN_LV)3w0jp$j={(T)F1}y`^}K*cVu3TjQk#N z<|w6QQ z&r&v()xM8bJufiwd27*YYOR+?0>D$=ThI9Ifrd@0?lgAAk(UKRx@Mi=bW~}qBcUy9 zOCiEv7tB9zBPcIScU`AbdgRuu6w8*jf?nqe@^Lf8YE}UX>1C$|n}~mLkI%&V3eW5Q z=BlbMQ!`gE#)W*R!`Ii`CK|oZwrY6}U18&~jIgn_wsvP5V0$^v#@)ed6F}7VC#Frd zSD=K{ltyNDYN6LHHh>mBd7~=B#kaW{Mre3;^J;MSJJL5!>Su0`rGFs)B#XEDhUohy6SqkyGS6=((8R0SYG}CjDF2IaSW< z+0*omdAwUEw;ye+5G|zF68};Z1@_C^A7fY<2F&SdErX!9D9tkO=L{7D37vhm^X-P4 z+o?mGEV$}m!?c)2S77aC2}x07o>2T04UBAEm4#|-h$smxwD5@NNtB4F*hG_xKzlhFE}ooPwjFh)Dw`oBguDr;X?$b)e{}ODV5xlRyK-554}FZplSZDl+ov(1%~#_T&zf&r~eXl z_Cz6^!a^`ni*G=nOUACj09R_tAyt{Qf-8-!?vbJ|lg67#oJZxayDv?$C1^H|ICKkH zm>c0N-i_@1TfELz+xDcFzo?Tn}m^KwGy(t(ty%lVgvHIk-U8nXTJf z8QXrJU=3THnJgb$&p`0IO;$m`cB35gL6K12K=Bxi^or0}zB=k266kLl%5%YrJ^ql5L%N&4jRh{8`IrIIa~hA18GxTwcc zV^rs_0Z~0q;5Z>I@;RV2bV670d%Ju%TRSjPy}9Ud8g=6PcbAe(@cwk$n+Tasy-xOR@`Ue@VuoXp z`q3ZL%Xx zVMj6cExvZ34$qt@XYmbMg)&%a&eCV&Dn_ z)f;aT$wcqS2jh=Mb=L=f61NBM9o)*zX$AN)Pv~Z}_ORZoD>~dy38arQ_cbY>K4;_c zozgqFOd0YL8{{tkAv-X-Hds*-lS7++z$0*a_6d29RF#z%1_@wVcW{X}Yy&E4(fc?v}gA zR*#^+PiUp>PQ#HMNG~0aPF2yh+R;7x9M+E}wYeFf9*MxapJoDnHd`Sz_I!+U%_wSv zY-A2AbWgKX&&q;rbcZ6?VuP|9^XH0{A!&s;FCQ54$+9$ZBV12*5XtL5krEW}JvaLp z3(4Qmyiiy46nxD*F?<_;BbI`RfTZN@2yP}D-EMvO#R5dJ1*E zwR*SwT#91E6sgZ3wa5~pHn`doeXeZ|yqiGN-uEUvZ@A zyK%q$@T(u*ZshweLlv26Fk5k+~*tE55ll&Nu^@G0~@803il#>3ex zT{%slG()usrjK{Wc@Ae}nz+jE_T*y&o?>J+iiY8%IOUgkwo*`}mkKSLmok$GKV2?Z z{1#CpwLP<}4IMEqRJbv}Q?>F&kdT9yK81@+{g@b#|GH9; zfNP?bz7n9+Y-oIrV){#Mo~qY-Yu$hU{-;+&GDi8V3x`uDb+xjFocugc%>zv&JA#@d zM=_5b=$=zE*w&bY*P#cCY(xz+Q~R{C0h8YLQcJpxi|;*chOP|8VdmVIOXMpb4@yf- z6IcKh;)HZrdSToy1T86kc3Z6?D7lLTq8OybqSrr6j?_mhjlfpkjR<&W3CGI{W-cC36+RI^9qu79UdK6)C z!z0KMPG@Q71aJ4I5XlLjH%H;ecbo1yk^+4ly~Z@W$9TV%VaE4k6G$27v^YXqE$QV= zzpY<<%@@8@Vp{QdwoJ}^#alG2zF~eWZKsb%)FTjdq`tdc5Ra}(rhLD z4C5d%WG#5v=CiLjz{j&`- z6IC@Ueh^VxJ5_kJ^^wgL5IK;I`yfG}F2_iFxkZiJh-SyjVE_aWU(LYO%_DIaM19k( zO$Jk`nnfN00_6#pM8}&$qc2^bDnsa%69Pg_mJ>x`U69Bk!K$UPbt6kEe%EVK6+w_5 zK2ATa%Kr)9$7t1Qmc@-NLa(#4Bgs66xzE1s*4axQ6Dtm$CGs6fL+EP0VW zFq+zxdnrV!jQwMV*(T}J9(ef_vGPSI|PY+b%eKSF#J)j|1+tq{4G z^+JO!^mcU_3w;6e7MG`+N!Ed(T#r%#zy`MFc6HMdz@zL z-y#Kh>koP$FV#S?yl8uuA2sxP>!3WK(0^gE3gkY( z&i~x2I3d7y^!Y`=iZWDpFc5PSn3_}Xkg2LFsdSzsP^;n|X@v0o(sL1_C?D*wWl^=vRut*Cx!V9*XruD+W!>OJJ9CIyj{VR>Ks02&hR_wyT7$|1i=Ak;9c!6NT)+TQJ?{OmPoq@7iL z4jqzj94&Z4VWp}A1RRO%JAKVx9ph!4GmH`t2v0v=OBq04Tdn0#0<=hNzdM!5;@Ywv z?ob&_)s<>Iv0-!pvpEJD@yx;BG%>H;ImsJ>_XH^C5VZU%$G2~jcIWm|D~fahr-0jr zHP>&f%DA4m=k&fJF9OG>DJSe|kFV=oJ%UA5ALP9*0t}iA(cqBshX7QnfV1#t`%`k1 zLEeqw3ygB%rs+b}F@*g}-(`Uj0|e~A1G}`NuP_@I5lDY$4>0%d!G0_gm2|nkOk+<- z?H?jBjVvI*ywh0y5DmH87X(^#cH1z+Jo`3JOdT_e04;>4D;tgk&yo6+?RV;#BW2+2 zN_Lr*B~hebw`TaaF+ljYDcj7mU z7yz_hZ9!<|l>i2PEaF7M&NeKL0g&5=*WH3mtq~$2Q|B2H!zkDmrc&BXm-}nLiNX8S z-S%Dbv;7vy2iGJbmr2OOzWc`eq*oPAqVGDOs@doqFt2vFuJ6%hbIS~wjMa{%*vbgu z-kg+j4lDXD7p=`)R)bgVperl<8;UouNBDxm%CBCcRIU9;N*Jj7NdUueU8oOPj`PSH z;-`BUpQ(12S?Hj^Nc8O!B>MM^KC4f`OOqv)a_5WJRIy7av3EY|wFkFIjp6AIp}~nP zZ3xQ(??IE1B9GD)3%BLQ&+@6u{Ug~Cn?sE88z~ao7|J&GDYp$_V+D6#ag=h*E@BQN zV6?n?{Q;6=+bBjI46Z#$@(JBT+nvV(EP3A)l`yJwTm+?QZ3Fi_k`-UH@I({myY$+_ zHu=NnR{(^ALw#FcenIo{rU}hc&}U2aU)_ zRiVl^L<3L+qb5RM^=8!S5ns87qpxMbmnGjJP1C1Z+A_r+_MGFAWdkm>qEIy3b5JYF zg)(Es_&irvYz|5YyrS=Tz4qn1x0(?ZiS@eL*XgAt4=K^^QHl`xCN98$_3A@Ao^7dcLIy!rCyDd;~MMV&SB>0k-J0*Qi#Xf{q<7j)!H?obeEcQ0u{$2Z=Gk zRXnr28AED&o82P`wYTYK!e}l5wA%qh!ja0u%}f22TTy&y;KB~jANzQ= z!}1M8wT0mcTW#d7&J7+;dNm?8u-oPEjF5G-KZ=m&A95oj-TO7oA}|;|2%NuGz@- zJ1D}{J>h6B!IAGiapt#6N@$y%G_;eB>--A?M+-+TRsfwe;uQf;&2Hh2imsV=dN#&@`C|->M=i zA6cwFqFS^AAcgUo0(zgHTad70h<#>0I$pHANliJPu^oXM+%a}Yh9WH(p*eEG3=D#>X(6_TgeoWxLVC~-N-HP1+i*H8j7&z{J6ufgoB69(;N*@Q(zl^4LdCoRM6sdJ4gwfAo;O(70{co?f<(I7+L;TbDrHkG%0?6+Xq=!-1 z^Sj=o2wyE+g9oi}dUZ&1*lww%Xp|=>!V{RP*aPp}#Oj1h26Y}?k~_|-uh&c9(8=9W z52ObiJurw?6vJW3*o*Xr4(3ei{g|q`5edM)-On`<<2uL@^`p>B{V!Q12b+<)^dk|A zmQ&}`kPpf?A~qIQONF$~9qyHvXa>ci!k}?K7tB}piFkPA#42AraeyuyohZe%jB7e7 ziSP;4t^XNluqi7{SFqiMr02VOpyWFD&Ff1BMn0;^y}N>*phr2f(K(EPO8sifKTN}S zrjahbzMwj@diHT2#1Uj7CeL&PEqItUb$HE?c|fE%4_^OKQ`=UU1NlPTPI@Rtqp8TX z*wGrJw~=MKKMh`VwblNqdgq;1{6Lt&pvJ@clYs!sUJk>C`4lf?L$j9VfQ&`n(JR4h zWZ3-B1`v^o*??5i1sZ95cN{l!#WO_GGHirGUxzcq5+C8+QrAh-PCVFiaogAuFA4Ka zY^lJTL*J>;2P$VHZI3%XM=-DLZ;Ej-1I2@_v8GhTrSZj)8 zwITts)dQA0QAyvjipxXtZiat991@)ROC(40q)^ZdhYnqFB06?MvEDN_0jQg<7bBz!7*(P>YdFvmiqOKnei@PFN+ygkN6$z zWM{|bl;D!rhb$PT_)7(p7*qz=^?lY@RlYRTFlSy;BK;WwmSG4HcOoyJ+5@*pHnSv! z49hyR&8dT@*EA0vPu)*1TRmxgEq6r6s&xIp)2T-U;KO;JP0%+a)UDhpujsucg~!^8 zVfuSvP%YpTRn?<&4kX{RVb{&`%bh@Yj`Joyd8;JKK0+Qs+N>?1NPn{XCMd0J zXsi91#eYDemLSjGa=rJyP1m_mQOHVICXzBKjCGkuQ{F*gJl{Gph;Sf&ZtD3DYL7)X zXE0W)>6WOF@DJW8P|^{MYcIKrW@WR?zBWx?9x^t-)?T5C5@(a(Zx7Ric1bc8`^^LHHm74NyFsrUj-6Zueywsv!kexu9?xiO-Anw}y)L5wI| z5hI>xt)8>Rh)%8Fb%wV2maHTNGVPX$i?s&sYd4aqyX=}$npbCi$R$=6 zC?7GN=n}^M?P0c$T4jaCXl`BC(~pSpU|hH69T@K?CB=ZsE{#4gXn8-}}9Zdb!oI_64k0i}P3Hct_Eif$Si}GkMB%sRZ6X|esqWp4XR zyu9Mkp-68|F|(|-@lKeW!CI)*Uck}x5A2v@#(o@T(RJ09lvei8t;3Nrc^KhP0u&MD z;74c5Y85_6O2qB;%EA^CJg)Ku*_9!ftm&;AEkBTllc9-xS|;@Gol0{8t)`%y4GtfF z-Zt#)197wRCC0v5fIBsBm#BDLYPeC!}+)?wYo$$*~XR-Qq5C@iG)^3cDkBu4zyVlzlC zI=(>67SYce%o}pl-qqGFEcKka51)>5D{-GrGmChmO=p(gyLqU{t-bGN%$+Qg(R9nu z9b`r}ApdZy?xcO>OwY_lZ2>00^3(Nt`af$%G=XG0B)Q!YT+2$*CrX z5%=|-*zQt}W(${-lVt}e!J>hn;Mj+0t)k^=;SNECdXSqy;Zd-%J@wHBZ#c&1vL6Pu zio9P<0XH@wj|u*{KwQaowjtwAo@_(P;hP^gRgbPN%+D`3PWYTXYIW&(Y1iPH%!uAA zc#kW1ldx8EJxNWzB{y9@eo`=&wr{Q(_saei6pk3a&7*Ac%8NAIbqvBCGYjV{(VoW~ zk*8=+BDYSel0m)5NcgFK0g^e_gp_cg?mJSGwGRtQdt!d=V0!=?6z(xOR1pOA*4p<^ z**S?43B)Zo5xqzw847A`woikh-};{DXL#El{Qt8B?}R zCS*c;A)ExS9sr)ap%Bihs(_7%yiV&KCFSRX-1|Z?6C-|hXo)*g|J-5r91a_g;I*0wme|i0;_>%E_)nKdjgKO} zsM@WV5Fh17rsmF@0|t(_fA75Tj=-dOF;1eK5XoV0UY-0YPir64-}fbBBC}{)OGn{N zAy~?>{75S%jKUVV;}SOyG~YcdmpU)~YPH{g#U+^0# zrAV^{lkl)=K%xCdSURvxMwG($CV^mnJE{X{Pk_n0dI}a-+_K%GSD&F{e0e{lM^>k4 zUn+Z_IS-#oSx~$8rfOeyIa%L*|6oUG8(;Wy9IsECc6=v2bXGoHyh#_hDtAlgWJpHD zET6ZAlP~f2OzOm5Cv<>%JWT;;^^w_H+b~J?o{yi;gb^)b0JtFd>O79>dFxaLP}EIp z_vsOJSdWv>z!LiL7~8L|Vnr(6K)a!w^PC3W@(NDNzq7TxX+6&|4_#1)4E!)fQ+_robdMX6}`wEFFO>&1%qZtd5YNoZ2ve=Vi;5vF#(C&5SNA z(B;i|I!R~D+SAlSzn%sl=K@^_nnaTLmh<{FBw?)}JZ_X;GV z3NGJm0g<;8<+rE5^p8V?6umN02?Lt=!k_}nMo<{S(!7Xiz%^&8rBqI>v@#KxnxW|^ zZ*6Fvyyz+e3XapinTQAqFI7++Zvyh}jDY%ZG$ND4T+k+;6JAxyot%Uc4A$ z>K3?Bj>`gk=&T}&Ip#R|Uix$1&ODc6g%81iXA&=P@`4X8zK_2ojW{gy>q!zsPx!P- z+oY$bTd!~TMN^SRukR^duUBJ$rhuN=;+fI6h>qtj*TH~tRrm%YTeg{2)aHZKcE(KLd%Ra^*0m$UT?heS%$Qdn#_QH1BiFIkg$Fj# zoxi;Fob(Vet(j)5U7Q)&}ixXq^POKWAi zTI|TBcKE&(4}7TQ`?ybKmJd?pb|?2Jpc-k}{}y7_1_E1m6mtpeC3Uc;$4tL$>_8jF zBsIO8&vG0Yc@X&oXXU#;a~?u*jdWSm$;h63<&~1e#L-z5)@^Qu*!P8O8F=5mc{MrD zvGd9m=z;OYng2m5nnfNgaB@sL$-X=H^M|ARc>JHN>^jr)$_v>?S9R=MV`Go6BD}uo z!cce(GX_&#%a8+P+i%h3S(Wmk-)Gdir_-zLBwD=v@#3T_-M~^&1|p1AKY_K>&>uTm zMeGNz@zg~v-L$6rahXX&O-n9p2%Mv6j`3q?*I2&)2VvMjL-_43{|}^U=DjQO?fp^h zJ^a19cb`R3SM(!rPoAD^V??ltjouSQc7y@N{wIa~)Dx|Uz;Q3gMJ_Hei;~Arf|+(n zRBi4U3x5Wj)5Yz5^F>@$`?!NrZ?Iv{z`4wH?!U}`#&=imI~M!?S3w~z5E)TX(J@{D zSL*f(cD-?efvG8B>;nUFCC0c0FWg#xMOsRV#io<8gan{g2ISXHsz^`~xz3JiBByv! zMe;zov{uW;g&B#0B+h@MlGxmh1Ndx0h&(MY11PDaI(uM-TCRxHio~h~uaz-c=yR|t z{M8TxsZ#~2AqT4AvBu#2p2d@!rFS+WocO11t-@Yw^};2_z9$=*hJBJ2;4LZ5_T%bl z>72}um%%=l$ow0k_0lCexyIdT)m7)k12uf3H!U6dxwm)t>6r_o4A2q-5A@Z6KXAgk zsrLq}@#`CJ87((@r*O^%0%L-vkGlkQdxY*JG0-k8FlrlOu(EH&u>ro`nxB{yM}tj^ z{u}1z`B>PQNjQ{NIo@ah&A&u>ovV69OsteJ+^ksr0i1FP0DvZs-eKX#kkMx+m+tBL zK^&##(^k?R3Z%Kii|eH0*ZY%@jGGmb#`$kFn@-5kGNjYO1YpL%qE_+Lr_Fu3vwS#3+*YCDE z=1}OW8Cz(Uf>*`p=;(|%gqg^+<5=xJ>T}C)+sAJn1u7X7#*xitN4Tsy$ji)a3#}a> z#)Dh9rrdu4;AEoDwH~>J&~BsCib_gKBqhp=z2m!O88>qa3sHIp)2^^eUGuneYm`u7 zh%qj8WsA8Rk|~fm1>}s{Dqz1)8}C?f4sjwAB@Y+xGD>07Pg}*3?9?SpB$KzF8H^Vk zT@7;(_BzWamAs8J7(X6+716P{6~2B~<@kRfld6|vgmBt&1hu;^%&pOnc2g}(%z!|3 z)trIvY#dCbxboE0V6}x*i=tPNz!**?UP9`y)q>*bY&*F~IiozIJjHO3Lz0nWv5{}_ z3+urlhWGhuMWV_3;j)ow%TDBO45RSXN_FZ=Wony1?~>@Grmdy5^?1A=;zg0H?#Lwy zSMSy@D5p(aQQj|L>BZ!23X%N&s?j zrWRY5q(#7wV12Ava#$b2CkU(oZHgE~rjIS1IoYO_1TG$nZ&<4TAvuA4AH~FG-53>sK3$0nq#)cpcvs7NDB*a>lOn=*K(j8rXDA2P--1lpZaHb%o7@ zvbJSeb`_q(e&11gzwHIU0CA3YO!eM$^wl(Pn%>C`8k+MO0lGmLdI*%O<_&gk$CP*DQYS{kTM_@B~Hs%hjhgsS+Lj3e=h0dHaaiP-3j))<^Gr zb*{tu4HS7QshJIxLFRbU-E=0Q4pZW!b^Wc+ts66GtiPbNHn-8Usok@wtnja}y(f;{ zLfw8g&c04%{^B^~j%~>xa!0iWgkMx7;tog0iNy|WiKj{m(}s30B--T&07 zqce5u2Cou}@~iQajVwjTBc&a0CgCUR2ddZa9CwgvQ+#)4@>ZEtERKgnMx)gy6-Alv zkSbm&U#23h)ulcu{g+M&vpxpq42+3s%NO=#qN7Zl8hn>OIYyLKLU)2}F6(`AU?uPH zTR-CE8zy(2(A)1T6Pi@)pYgPpzagjm`)Xh6!{{01-xusAe*ZU}&^3!|v5sh#(8>jl zV5WSJ^`;;Zo3qrD`ae$Iap;~!qZn{h=7A&TM>;(hvX0D}&A%?&S7I4}1_3vx_jMR$ zy2IanJ~s2|>{ImHv5f9_U#SU()liEAZ~ik!W=$+7Y6z$8aZ4CI617b(QXsPf*bA1x zfh2IS)~AGh4T6$TlkA`=obK|SdH1%m-Pht1I3g6B@oj9gKVC_ELs$0n@|M$vs zh2`IWzjg7-zt+x2;NR5KaQKp%t=q2G#N8r!h@cY`0RG=n>e}5Rkc8{035;wrmQ3eiAG@Fb#QUp5Z0w6Z==ntOs#bXim$Yw%O6NwdnR^{>cvz9+e-^sjj6{< ziV8XSbJ-3~cbpH){sBENRu0f6g$;}Ld1jV{CUtf&z~dl;n=$~HGAs*>kg_ceX_}6T z3BM)^lpJnc>*iV$K>xG8bk%~8#Y++H4Ae%HZa0 zmbE&LvPY%44-fJPTf1+k6`em$SDP(CA}Rv1iym811Kv#0`8sQi5b06@rg24~81j(p z!&57ev=M`m=!eyVENHcJs(GQ`;!4;4B|f%o4>H@AhZ?jjo{TGkjP7=HOdh&e?MG`w zb_$!3T1(1o9~*XHrdPxO=5k_9JMNC}Et z&K{H<86C5uVZr}JFs-H4-aH~FQcp}&^>b=daGKKoraQ2yQqlc8Cw6gChzI!~Iwp5m z-=h(5Z#G?;dKZzJKiiEWB+_JuMcbe_gS-VcG4vjW- z@*5}oqxO1Azb%YIuJ(GOQa2Ez_Dr#fBi{#lp#|-I1GZ?k{fLDJn2{AtG$BVb%aAw3 zvq|6l4=Lt9GUF`}oWg*_EOH>+R&Q$&u_Ngl;OA?8KZ3G3ZvR1DNvgHk{)HC`H;A=+ zqJw-29S%s{SjAaLBo>kO8IZt~Lef+y#*1jG%uZes{4nN_1MY#*a~b>A4rsv_eVlo( zOQM$?ss|95n055Ya{cir;MVU@1q5*I+P4!Z+9n;yK8hPv2Y zI60YJLlA2(oHU&S?^@H}pQqJG>p%06xONl5#c}x_zH9OI4lcJBC?~?{L-+#icI^CO zM7wuQqPFtmq=K~z)m8k%)HPW9Pj&-nYE|w^^t-OqFL%dxaJ|E_|2Py~4=M}<96#Yx zZ(F~fAIpCxXo@#v0H3(?Oz~S;TcRH-1E|L^Ls#9n!mHj?mx@pH+-G%gr;Qi%9pR#=@IcGakNK5k#4zv`5t;2)RSp*Yx{@gb;_3ZBbwH zvk-T$Cy{KEJRd5zTHvEa!?PKmS6N7_uiCE}hLwz7ZOIEaM93-JXlY?!yryGJW)ljh zO*ve>K1Sda=PJ3WTjP9bhA+HE9JqyyEBNN3#;=?GK=hG<6u*on3~CJ#_wQPto?2&N z092ZI0<8TFvM5yQ(p5kl5U`~^&S2Yt|Mp!c@y#$NRhJNb zyFLUiA!pER-^2CvBE5dR#Ryl#4)6siOG&eMzzaI}({tCX=X+A5P z*@wb%_d<%i-wr?fY!!M7@yEprT;t+zO3jf5{+mP8FR#j-;Ls$`M36U+z(9-V7>!DaW%Q;JZl(&Zs`bx4E0&)|Z0 zHN5H%&i(CLKAN+LodN<<1FtxeyUo9<-EX|paIGH~6n1qo{x9n#Y z@@dkl^Q`Hw=(qVe8~*#~&WoE{n6Un2_&@R>yM zIx)`wQ`{k^mG!47zqEv`#l#CH3;A6OJ+f}^7hp!}7*ePQw?bKw{UcH)S7Gdx3}|M7y0V?8Hp&c^Ggk9;}eDvpK|NhhB?ZFrQmg zYw`-{Z3m4dpLlSp_Kr#mBUJI}(;Y?biqwyYFIU&6rc6tlUSu?Y$kTcG+=f~NF7bmbbg!03&o%@i zoE$=YIa2l;1E)onHCwWQsZF?i7A)ev`65|AEX zJ-36q8d>L=TqjlZ`Wi&>@P#(?Fz9H>_kDMG_$U*$zEwoOz`WJ5THp7u+A4&?6i#gm zR7}=y-RdqamYHnOVW<`n3ELk(#eJ6wUX5Q6{KrOC+4WsvQFQ6h#FwLUTePhVS z0--TZ+xl7YklgO^C0m9KX4UUy#)HZ-;iw$dxz z`FY>A*gH)~*<8a>z5{|Lahg9&QJ^fRmgpT=dyP$D@6;C-`YpIGgp2Th8g7EEWAM*m z4iq&d-(scyEMm>2`>6$adQ+zH8RX&?L7Tx?&$DoQy+B4M zUBNJQGJ?|mF-eh@^-9c$5dL;7T5O_ShfK!FnqJZK^78WBVE>ODey^}}nwG_II-t32 z8$uCgnB`IxzSW88m)G?E)(6Lq{lC7sajTu-lI>ibDSKYDd3WSe*}#wS*ndpZTkRM_ zwGaQdzE4?Gy?Tz9?D3Z`45$u_lL%J%(2>voQTn6c!BNyIaeV?iZFSt6Ak(_UibNHV`%45gwWl>tPD5UgF48t4KV5GqFsJPxVDIFKhDPS}+HuEMi z>IGGekGAgl-y1$%4PXAl)MLl~KiIhWs~a|gXU?42x%v+!w-gyimaN@9=Q98Q$o9cs z&b4!-h=|D7zk-LqntD`bZfRMh2qSos3DuI~mD}fZ{6W-L{=P6awYB>ZwSlkSK}y_y zou2dO)pZl&(!2S$pn&e+)4ID;BVNl8mH zvwSvQCC}~{wu-W{8fEXAA*Uvcyw={Pbt3X4om60J{(U-58hnaQ8oJlq@sDgD^E1L< znmPHnt=o&iySGleE2Vj;A6Eg>k-IsJLnNTB%qM=aEc6V2EtUaCsln=Sp7syfoZ>&c zd5aM*{dEim?O=BzOYl;4&X4iP)oQHjO$Y=r?)3pJMs5p+;K>PE;Y*#4WiC(;3U^rj z5f!&l^Jv%L4?Fk&boCgYRm9HP@JoL6KaaLSh*4exC%?&#$cSD+0YjFlqcM-X$gd@v?2wSX#rJ{jRtRZ}5|maoT5HeinTXdi zbB5Q^H&fTkF$4S$QmhBU<(rL-$^7!t@_$oYU}9}exIOF52;2tkjCoWt8d$|wW`u7q z=a(2)mb^{VzV)TAk3|ah+(7@^rAwE_<9{z;oZ-Sa@r2_f>ZfU*y@Ny45bSa}gLN>* zkRi0c_V@LTFgP&U65tVm6aE$v5^ZGbypF2Ke2sCNFg3sOsb6zj%hvH3A?-qt^lkKNq&W_9Vr-2R^K&isQgOG{gzSB(u2KHbcom`-d` zxHt*e;2S8}OxF~OdG65?p7!5t#w3#CV6bqrkk^;#b~SzFJq_^yi$Q`cJ69l z(sorJ_o%4l=_s5P{zJ7OGhg2krp2jU^3KPn34Fv9@ZXe()1~?G$y%zQZG1w)kCKuX z_OlJVB@Vze@FPy`e*)Vu9UfVDjk(0K&2PJRyuPC8g_1ORp0`_{kwf3uHdyS7`}iT!eD^yf_?H# zf<8(c3O9hB!;v<9by)!#FDWP}X6itE|C>z++D~f&J7=WwaS|Rm z?P3}gux+8`&bFPW-$K_r)ay(79N%;zssUem_`2V0td@~c8Bjm8LbifKT8zaJ-}!gt zV)N>DW!9`fVK>cK?!rv}cb@#N3h)m>GXwyWQml}}EG79qFsd;y1@vAsN?K2UH6-Wf z&o@xawlRyMniQr6CbrSTnibDH2f5r0kzA;i!pHsGrL6-tOyn_k;3-OiV9- zoSBih$Ii{X5&)YD3kzHLIRkvdDK6gp+IB_zY#On$Q#}N-1InJMTfmhE4k;<)`gxz? zi890DT3dw8WQ9p=-jEd5fQpJr?5LmrWZJfzT~t(83VT)ke3^}i&^>+;|2a)9%V zm;<_>Fw}JJ#e7hz^*&mo<}(jnA#t*8y1lZp^2ILV-AuhBNt$${`}Ty1hJfQ_#p`BI zRGs6*)6cajG6+-9)PGbr7Luo1!OZ~8jR7`%vNf&**o_WAptXSA=@qOw3d>Rn^tN z^B}uZ)e1MfT453hn=%;%YO(;Me}H2#sPST*@`ai{pn!WrFvpijEa=6Rq#iD+>QkRh zdhCVQ`#B6J4-W#sZvdjYy1G4olK7{?%(9lLsp&rQGkX>taY(wV<%bm5Zp@$5|CN%% z5h((OrjgKc1WK4bCtII*K&CehbO4^eT~0#9CmV=QLTo$wkcdb$Dn!sTiA!G_b!kGe zS(l9q_4D&{+MQ4BB?)OuZH~Pmk585WfJR0~fy$@*U%1T7#=Y{!|L8#EpX&hlz8e=o zfhYvF{xpY)TVMfpI&Dj$)S%qpd$l#&SY2l~Wy({Qq?#cXbO)CGA9mrN`w{Rwzu41q zU#sLQY(EyWseOL>v8k*=2r!Fj1qN^)KE2n|CI5Pj0#!7yKV_WdEh9!O|KUDyUb%_T zE|#_bN9hkoD~X6*lWA@v_Ac%1)RIs1>^NB_4r_}@j@|KkzK|2LGj z{af8QiHVN>$n(!E(`7wcFb%;f0|Y$E4B11*P3pVtO8?y964?Z}VJe)lv-_X3eTBDT zeeJgyBb;yBp3}ApGR!>53?@n~>Ns8dNA4^{{}-`4oxjK^qK{hNiV=Qpqb%;r^3ShX z%iDQflKyq=k*N|Y@FQoH!3SJQm3OkoYc~tn!#lSE+Qxrqj4vOoYK`No1hwcz5&OK> z?^GQ_)k&23w%&`MIs2q5;jH|Irf_ERYpT21TdO#q>U+z^!>p(TAqRpQ-BJa7x><|fj>hS#QJc!;-7XB18Qux$N$C7&g}Bs6WHd~1 zB)>3fd--*CeOy6L{TJ1g^ny*#$YDu+>C>~$d)8aa)E9O3TyyBjix~Rk)B*=Hs8R*N z&GOnF{TCZ3v=5G>MUc(h{wdO;D6!Qm_hFq5NMsT zIC=Xu<`orw-{z4sd;aZReaYGJmAWt8KTi|U{8ybi)zmbrSO1=vQHg2w$~M`a3GNw9 zY& zetkd`XpMM?k!WXlk zX8kQftACd|?DD^iUXlt7gc{J*2l zwjNf%PE*Tth4UwY$C<&f%Rz#N4PDx7~T1I(ZCtU z4Mx^8=tR9X3rJE(+WNlv%@bf+pV^C;e6^Z-jhE1(CT^}wN)XxMQuMCRmxxyL*|CMlJNUTimK<5oE9&)o!1qnAN`!Zh>@RgVX*b{1IViTb(Af7Mguc*_wI*a zRPk2j?OXc3-tHyQzU?`eZjG=l5$OisKk2zrJgH?wj^Ex1`;z_7dErE|mFuJ)rJ#}x zifWITBwq?@X^^WR?m~m}W~qO7mYmf%N5kj-KvFNgBrU%hifNtMQDqO|T_z_NMa-;_ zh#LP*tRxM*oIms;`n5AsNG+u0vl3B+(dikz;=%;hV=pG-sZldaU!Ps-3m9sM?T(T6 zJTP_iR?B#dlKI?G6aj5EbN_s^bJ>@vKRIjqu*nRF71gtgK9oj5Ar|Py z(Z%{7+B-dZ&2LL`E2+`gYuB^A%DzB|T}S`cqG>bd$$DzOv?@b3GGiTkR=B$*8SSLt zP!>;i?>$R~ur?H=HgZGBoN_vZ$EfnT1oT|T$gEtkOkLdzqZ)%IuP>c7)rfBDIrMpg z4GKO1`3Q9<04u-tp01LcuKA=>u@cbLwRKBT4_&%elvQ?5qV*_vJLXsdeC1B``s}s( zarcI6Q5o71<@sx!PtMFig7rMPcsetelRMHJSOL&+bF7A9LP1(O0CsPD3OK^~Dh;^) z3aWzjXBD4yyG0fsB2BNpr=W_j_-gDlSS+7vE8c=Fw?O88#Ew4D49--`qAYs7`A5-} zLt+2OW5w8NSK$t~&1aVDWnsILujHk3z#2H?-GjWf4!LUE!YREywexHGwE}MPp4%UG zhQ})$A9o%t`*^qN=l2Tst<0fwnR|xoGE9d=)hc0V&b1QIsS3{6_Xb%#cuzsUd%lp} zX<>_WSVTforo~489&yH0`@6op)F?$)XbguAG`AO(*c;a582)Q)!al!lC3#x^BUnD4nPzKhKy!_5M&{JOQ&-f2_1sEDN>^Hjtc2t@X% z*f>N&yG*yNm&Ld{uJ(Paq4}ePe!ZjO)rhCkhfnE9eSknPTOpSywFI4 z9QQ0IOxN(m&kz4ik)+*%`0RG&TDDVbmFYeo%2&Sd*o~G*aH&DDSFh`wgOwn>7a6r^zrPbN=hPI$C4#oK_p`~2L*rrRcY16T$vOEJ-1oezCp6Umqasf{xqFk$}ip3;NsaBV+nJC-Iu~!X~Y8vDb z<9Dtf*rgZCEUz;nqRJ)tR6ZR62=8WthoPc`VsuF{(Y4qtOO4?O=vk zRVpaz;@mBwH=Z<`)Vf@r`3H;2IxW1F9DZeMr+Hg@1uR}tHviC}+MLXpb8`HhTQxGT z8hrD#Tq|y`U|ia?AA>o1iMR~xtTqp~ka^OAHs&__ckFk0p9yxnNT!>p z6SFYY&@$rH%OnVyDaJX+?8jvft&7%rmcyQ>_pzLa^%dZ*UyPOLYBjp<3WtHQETSIx zI-s9MegP8eL>jAdh(5ddUL*b7_{0d+&@<3?P~93O+9D;J0a|p)U*)T#L1Db2r5;|y zx4tGAmLjDzNW>|VrrqZ{c&DfZ&7D#jyQ?Kayg%=`laN4_THp3}3$+EJPZQvEA1?Rn z6{+5`jWwGg3B&m1r>AKvt=2;C^Xu0nUZ#q#N_vLE9*Aseo?YwcvV(b%*@CuoF3s&l z#3MOD0_W0>lLuDuJ39R>o=>W;2&gUz+NWny27sH=2$DWTZawUQKQgDBR`0qryQ*rs zeP;4Q;n@_(@R&2p)Kt3H5}r5N!7z@wn9K{BMMOxlgBXf^QFvQs?ea1Na2K01df$Ox zgL|L0V#EEyqRP?VzUjg~@G`(T6d(>C^u_r*SsansMYIPrDPLMdzTHAW*|}^lPH>5< zyp|~EaxaM==#U7`mOg%5M{EFo$EG)WUEGUdGLmUGgnpc6ufSGRjMMK0-?OhDASVwA znjs?eYO$*{Vx2;8!S4CpK_JumEype~LEn!h*A1iG0^Lku+28z6P?j)$B6jZH)-gP); z#jMAst=NvL%J)DT=OM41HM*3@u3`u$!2*bE;rXfnL!A;gc@hmo#Qs(kbbkYi?aHl$ zA8b{bfZQeL;%B%@&nL2>n6ARv-H9YG#5Xv*!ycst1{~R&->-4e z0PO+KVdLs8jSE|pc?m2yuTv^r*6iw#WD9v`C&I)kz5|5}P#3mAmJ>-t!M42D$zN2- z^1&~px7%h1C?nMd{0P}F^%rRzI*cZoIH0`TqzSw1%5`!0z|Q~dedcSO9`HJY`+}Nd zv0bkOofq0&E3!eqbx@KSn{+OZ^({z8@(?sL;N)Fp5vg3<>|ws=>1J#=xapF3+i?Qx zJ)4{6xX-ctLgOGpnT&$Q2a8uQ4?(aihGkqfEn$!KMG<~n2}wQ5b{s?ANk5k|v9;G0 zm@b8)hD&yDv>`KpcICI}ZeId)^(FEYP4z@)V?`}rA79S|A;*;U+gTK4NUg=dD_s3Vl-_7sAw4A$^i@f%E zw33>$Vl5{l4r_Oxnjv|@uvVWxbtxNEt6T+1uCxo1;Zcnko`=Q0Z%{ZG`lhu*!CyMmm1wW!-BG&OwT!xEJ|luB zl!FzhI)WzFm%qUk6Ua`R@zTd;6GpZj#ROav$pGAO5jFk=N_|q>)dxM!z@K&7!o&)a zPUsGs1%vai5QTHiM1?Jy&QPdkSyE;qlN16Pd6Hk`S~3JJIh4B6AghmxTW?V^_nHm6 zSR?IFxq|X--3!2_#Z@ZMhIiV_s=hySvsU_8I-kL?dC|sZs3<<4sfS6e+8rDw+w@Mo zDuPm5U_FvZ9;D1qq5@uH@_1T_%5s~%hF$c-pSVknV~6a;NvNr8dq$7HG89gm$gXD; z_thE;Gov!!=fTR7FhFQg+figtHu~m`9~S#t zU6UElbIX*&Ou)ES6Ut4;jO9%LJ964jT5W`1sFIN@N-hg;P8de@zBgj5)~cFi>g-K( zZUx7^9MmFM^}$N!MDHc&sUU<>7tT@Dq~x+wwYKS;2JrEQ(}sDjvsj+*1qu zMT&kAD!=neCQ8PW?d8$G&p?d^r`lS*y@X()q3r%z?TX8fzq7`;FI^SYSIgypx|~z? zkU7p2g}pamLo{owUU__GE_C@bS41l|GEdm`5C!fUY$6Jt2M;~4Zcbktm6K7fwbwsF zmXNImYnKTLpp>>L>6b5?eXg&vnR`Z2rrv{a&DP`0y3!)bPb^`&BKnmK@aKw$9=kun zDoL61v;Qb^9AkJa)3WQ4wXHHjiURI^Dxe?f>PH7`iYc~4V04fSJ~1UaVcE>E>8L!G z*?67a1&I#zv$w(Chi$i8hh6NbD8}+&2(@M}zVusg?G6BHeI7P)OvO75H?~0zK5!Fv zFTr^A&pB)yT@c$N9exJ7rtQt0RhWjI_0CkLN$4P-IVM%_q7OHn6v>G~*nqA^wMa=F-Hr3{=_QJ7SEDIm)E_itVK%hS5-^5I2i5&6Bxd-S}E z_@U-f{yD<4bE#$<5%uXVI(oV8jr$o2xa3W*X*DH2R zz?{BUkFrB9%=aM5#qRR8g|t~C%1?dX0Uuj*TSRwLFGpW1wa8ccc4*GX1g!s3g1MVaBu^89alTzcLg05|1^y?Gib%N(_jGXIW}ug5zTm zh|4jNYzVYEF^NX+~FL*}8yP02w zU)8AjkO(H$j?R|5Je?5DI2Z9P-tvZxwCGd`zBu9)Jl)mM4dKrV$%-*J5&!DA!x_De zRO@MUk6Q^#@;zocOgF?1A*_BOAwSE(mwDFZ9Y4Rnvq@o|15M{XSJ?rM%;*^yWR?_M z>aD3b@0TE;l3+G#Ug#YQv#$MCrQT?CEhQ>k7R#(<Q9p<&x^ z=__ky6%fwY*_FWVnW(!BCl|XrZhZQ9eznwrFJXen{e=e1+-rutTJ=U#Um^HNvdbabI6-aRZl$+GQ}%g#J|R~-XcwP|a={MSSGEepr{ z>H8k5G(O&`8giF}wSX9j%>as_#THjBm$<#DBQ1J~S2EzE~UDw&a(aQ!QUV zpd{r~%Q99Z!(W-LdOtXT?3p|ZZ?QbBuP3yLeMpml0>3g03(MOq@3OMY%sv;{%%^D) zJNo8BuN3>u7#KD!ip?4s#o@`k^&?MFkB@ik-zqLl9xfzOp#$A^L zT@Gp>xIuzN>!J^;?R_tNj$`;;ztZ}8^ju*0yobcpOjY>P%~P5G*mO37z_35hM&r?E zC!6RPP$T2HRhY4$nqg~Iz?^5AWX@T5x%zaWdbP!A;yAy42BRZZX{CRLv1_n2O!wl4 zUk{ymzFUp(9aXO>eL?_7g<%h53{{V;#|A(h<9zMk43$sM#Fpcvy0vt3hz{1b8Scl8 zKNpoR)t}Lyc-k7%vTn?gYo0}`FVuZ8X9|1_4XTEJmn)YVdYKJ^Tu=S&1KPzq!n5n_ zRpxj*3;xLZ7N<6Pd}24-LnXXB2>1BoF?^(wHF^r>a+4qOfsjPhPRu1Rud5;ZE|&GE zE@oCFFQt8zzRv^lRpl|`xfzMbeXW~!IvM2~dTw9!RxUvGtsw%6DiSE~ebJ zf&KA2DqJ$EZjJXv8Agt?#&q%|7`r`#GY zz3$2__jOEBv|qV`q|i1>^{`FaXS9{wHFj}kdNv0sR@h}qDfk4< zrpBz|ik`0YAo5{{@>s2K&-5}$UqVdsC$?o}4WNhUd}>eOp@+D_V1M~)_ssM4POZaZ zOu|ZurSvzZfV0-z?SYgD^*a%y%5$f-SxxEM*dYPMFfa-khB=h%AuA7<$r>K{U9I(j ztyJIfr@oHH{SzWD{hC;wWj|3wX|$PIrl~epEUM(1kA^>hf;B40ShQxtwug)}qGh7^ z2(92DA*KT@2YT$f$OvRxU-`Dsl*L1>< z%Q$3^FHU2Z>oe)Zjnhuwj2~h>*)iG5-v+zr!2^qul2?ybd^V}5_FTL#uil0ao-^#u zT3cb{L#SEb>@tcLj8>QTC)%$XG;0nr@UPm=7+ExvuH#c&?S?e%Oqy8ve7p@7*Qm!% z7sy9!K?y~xCOEGzy3FGfx|1OJb;zrd340aI>!IMV@E@biW7s-B!@<~cGaXR`R}#Ol zlDuwZizIq74fjO4fnbbe(IGxOU+VY}Ds((eGm)|i#FcKVt0!3&>anyl5JFWI9q&!9 zqLxJ?K;du2n`>+fi5zPrW)trYgv!&QRmC;d*sWb4^&bXNNj-?w7b#qEfEFMfYz2>S zmh1g;*bgCpv?A0wZy1KXWa8`XTiNuioHff*PmBMMk?3HvY#{@nj4F0ygz=p3@3XlF z@e?l+UAHZI$XJcn0};$CO5Caf^ijuKG^)Gv_8ViG8uiF6jZvRSW`JO|*??uQbPpxe zD+SMJ|A4Ye;<(t+6c6zRDsSX8Xur7|~*xQIJf;xjLD^*;QtLcuH^Q7@Qjl z0B1dJhzrCq+leE<2O~*c@JFzPF3wcL%J<#f1oS7@l1^Lzt0a z())fWZqZ^n|1eWrc5uM9B*jw5&o&}DG~#3OlZQWq>;#UlX>Gf&0NK4Y47<+y6uV+j zE$hr9#iZyG1(5}kUz`2>%Db43zh!nGt!+y#q_{QLS72Knxsx90I%?B_u?!#tS`-kJ#a|-eh)qM~#<`JGx(SlU zGj+U7=-CtZH%UvRE3-9vytauSM)`%U9URFM06|p?+~UlKZD~&7$^bz~lY6{Q*}ZE4 zfm2M*`8tFLxaK~e3D$B(joJ(gsqU)h^`lB_eVOQF&FZXKs{zRea3if~+9x4fc4VR9 z&VaudP;NevX`ornStXJ5{Bhh{%*(-(6l}0DS{^KW%JORUhjz5v_c6V=qpJ76inL4f zk}0kGv}FKN%^2mZnqmU8PfULw%xX8=CuBk8Zhe3c)k*F1Ro^;BEQ7De8-k);CwW7XET5{d{zSsm+L&7ara zw{1@{NVi(!ak9eP4T()vx&e*fZFpTGGvX2(!+%6`p;xcbArlv?2g*fSUU?o zgnD$xkMRaD7_e2Q0H7&PUN!EnWYV`#F7ey6oYY@ryHPsl?0rD+?#d=4PwiT)!^Lzy z{GC}80WmAp-wo5b0LKM%DchB}sqz3Yq?)en`}-L$%uc|`phW`1ljw?hE9zk%$8^T( zeQ9X!cqjecDTPuw{1H=>PSe(dtU)pU0JG=&%L^1(w)XXe%fyfsQB>?l!zhP*$VJCH zqt2$kZe{w+{zN+AcRTn;hwq0r@%1HX6$Jju2M|u}x?n%m7=~AT{{eYWsU3Bt| z`}qfuKLcrw;GOh$KX-sDAf$@cY^_v z6n4kAh6lzzVjE|IX`|-n16LbYEU4(~4a%>2fS341B4w|+?y)9hL4|7E8pl<*;R1(J zVu1f=w-^rLGc#~8hL*#`tp1EoP<%rnXr`}ZzOeQ&Yt8bi#y+XuaTT3_+Lqr{z1P9= zAhQJs<+GNSnIxPI8x$%TU>u4r#-346bTG6Yfjr0Tg+ZE{%wQ1DVbuLIt@=K@DeXu} zaWZksCIAyAe&;dne#gBp-EC}uZDww?xKfE-p>j&`qidcjW>Ex4uUDk6X?Mz%QHmZW zdD%Pa8x24YZM-|)cHeBzk{C!U*(8|??R15fW3)JU5-j(}50A?sPmD*Ok5pt&9Gw4B zP}PzbdD>++thZ;2if8T^Oa-(a9CqJH`d)g2!?b48UQxH&i!R7{?6)Op&Noe~&wOi{ z*#}wX+ya3V%(f%#bOOVa=bLIpf<^_5QS6h;d8qk{{f+hBqY=_$vPol3ZCbrsZp!pw zP$4$Gn7p^qy0(9i+Q2yqcVDFPrU66PJ2VwN#l*su*Xf37W673iZsek~`b8Ub2_5jcHMF2E&bs#`Rq)nt*UbSw{kq!vdp1+*O>W8V zjC<`-v|>(AfAg&O6cim&Z=37R+Cme1m3CG9f1XXPt7y^Zq7WA!=N}SHCvu8ZV8L3uK$@~HWL-5dst{r=@L~0nk0JTU<)L6CoK1UtLYBq z&YgF51)rV^JI~fwntL&(SKSpE1e`Hz)&-Q-uaYN+#gn;*jT6YQyuGo2esV#cpJ~w_ zZT<)X4`|_`zL%@Dpe}8FOr({QgzP8wE8@a*!3JZGJxMOYC z!cT3U76C+1;}G~PM5bvCK(vAQ1e9}q`Ujtx>FV`K`{`C-jjd%7s&T?uY8y{sWt6|* zEPnl}uY_lP^tmE^J?#F&+EY|gtHDOr_|oI7C2GE~d2IN0FAbD)6h7#nvb3aP=$cR) zb%%hC<3$y)1yc=QXN9j-V^`1%pQz+~Z!n}~^laezub9bA#ldF0cRq)B^^?m*(uq@4 zi-OBI?A^k6JIqDw$0{cy0~cHSyVYa`mI4jlzV#j;O!6goYU2$a=|4+9^eNTl5_A8y zbMzjRWl)Zli!C8V6c$YB)0zXuAZX9A3*22{c%?P#0`Ip_blI6KqQnvkw|!j-zaS<{ zq6A`a&7sUk^ii>ZJdY-C?0I8p;nC%#dFn_rN}+WnQMk02 z11c!Z2X?`nVMPk?aJsU);h2TIcR+F|+yl)u=a86p&5WwV#W)7`0x2!p>K`Kj~8jTpVE4@s+HyKqu zNjp1&SQQTqe8T&+drry6o2-7GZ0{|Z%4C5bU33NcuFUZP(a}NQkjnrB;C9(k5|B&p zZsLe|npiF_Ywyy$pK4J_^jZJ)A@EWxtVs_DjW>b(IfvxP4hbXXU1AFizsGmNTfGA} z-?H=mr#)8HghZ+vuwh$cve?2g=Yl}l2UD&0K_eOQLyo}uZ_q?f)tQC(N`zB=tI)2N zchA7%(I3mVtF2;cL4M132W=i*IXjtZG-9gx#yd9S%UVaK5dm^BSe&;95n9gMOn~R- zvEW-KRHgK8CImN6KP%8^vhc!#(5BZK@gn^=Iy%Qi!$4y!6#9Mm_LJspoXxmE?(s6P z|BGzL3#{~Dto(VZvtwU>m@P~Jc*SQu($(r4boN`t__iL3| zT@7x^a471tj)C$4UMvn%~YlU0rkB@+l78y+;$r ziDgtnJtA$Ipiqc0VD7JD;>Qfo;|twU3J6xmUba~#W0xWhp5_iC!`#W_qW8axwe@Wa zR z{%#50Kzz1d;wd001|y1Bfc!H+rDAE&mo*+4&yxDkpT9p^G+CT%)T%UYWmPQtw(r&A zKw|1 zOBnEVh#3sG-TFQ5-_Pf6Wvc+em<~@iNz;13aVIG|J{TUpUdD7Jl?XBTs!)cEOujv7 z(r4Kb0HJqthQ{_|eGk8oq z)z_1;LHFMSHrq1i+vhsZ0!UNa2?5_%HAPNJu2Qv;UEv|}cIJuHTt% zHWv!FqlDzj=a;qgi_uGAe^XuPS*YlBgWF-TP$Gwa#K=RfHFSYri(Rtt((?OQ14oR^ zgBQEQb%C%ZRRnQ4HCICcZgoa8)E-BSZpIq3j}yxwfYRPpv$bCMc+K{8Mi(A&Ur)7*DNy`DJk|IrbKS@;B;v=-#N=Wk~O zk+CU|95fY0=<<^`IYQci9Xj1r0b&3jhI@Y63cnGJ3j5%v6Y%)^{-mRkvj&DB=O&>S zz{Q;K=Ub0p1t#Afg39Es%OXkV)1yjBKLv7syZTeh+|g>1!RAT=SFxyO_H~CDKnhPn zx{6TL9#tiPYIqxKN)CByhT;aMAqYBTh_cKW_IK}N?zo6j7`jO6O`0DyGURCwMvqnf z0ICgK`T#k~uZC7mXo8wOe=dbj0bbIk|8gW7J2aowg>cU}gY7%+f{8<8nBP2niu>^B z6;3+)`y7jz!{|xXrAfuv2J3hs>)kp@*dOT$6Z+F z=9&J7k3Y*Iz17nmqPL}P>n8^yCqHj2GmNyS<-js>OB2l82dX}JpsmkC7@l-g!Lj_3 z$`7g9i_1lm;rN# zV7%W$?lUrl%72D?tEP8#*}bTn(0+Iw+rT6by6Vs8s&kvaq-Xfi93%ycxTU$0i=}O; zAsjq^I_t`_VG{7Pq|sjuKdmb3*ZMJH4xCnF+@_@Wf%p2y&}_Z)rS+!GOW?~E>&w#& z=8R&g>ldKQP_%J^7kcyUN2}O6&r!;wqNg$AEmvVy`H2bl9n~~`L$UCM*;IeyasQ2G zshkjDl}(3dp7=OOpZS~*+C~xGqYQXBsmIKGiG$q1zn%qO`9+d^I8SUWhY}37Y3(l= zhF(=fbJjoOAvtfmlr>rCNTQmUI6-+?dYL?891*rZ>4OrMlx`rUA6gInx!vA)v^0Gz zL#}^3_>ST?50of$W}>I z+j-w#EWgIZd3wl{b?XrLgqA4*6l6ppJ6;L3Tg{e;X_LBhNU-Q(>_3B3A=$+1ZHzX2MP7!fdMnvtJt(n~+mvc%Pv><(c678+EA{lr_ce=0u_(KM zZh=f}UHF?PB9L#nb)A0a>{mL_+c=>T*RQ?YLyq_J3)$Aab{;iyovR#;Fd>ZtH+&(K zIMHYI0#2{ZpOT+r_-%i8ee4OX|GQZjR4=IYY8szy3CFmqk zWlB*(%Iz-lybRs?)^C?T=aB*CAZjTP&+_2v%hkl!_5BM+?WH8hVf?9Oi=d25+$$ND zq7$4Lp-IW%uO&AC-3wR0J_%e4vK8_rU0lDujq?qB8yVKcKrtU62__US=#M+<#)sZ& z@nV&xFuL6=S_A;KR5uF8__9eP3I$Eg{n`D3*OF7I`!b9T(3;(>zX-xl9C>qUQD%kSo+})`e4Qt_?HnjCb!!VD0=$bRfhC`5PVideXCS7o7>~+&P1E^O02ilV-!k0u{QHt8Kn>(1-6 z;;>yHf;}Ydl#M7wj-t=c&gVJF;YAlgp`d3!_F7LqNxpq(Jurpn_+sf!)>Rls9#pjs zzf~tf0^ek78O`|`$AnQ>O>YdMCY@bU)+kpB>4islHy!x{cG|=!ZYZCD^I>a8r$$qwXorZJO85 zZ+1tDXD?_Q%UsFj)3J#Uj*oZhpS{IxEXZS|fIND?AFD319C**r%e6`M;buQ4pPKKM z^O5{xM%#PX(s(FlL2ctbXK$ZqB*Sv3JXgJ^J^(`dJI#`EBXP2w2TMw(-5W9diRAjm zq=|IHp3pzjrkt5{7qn^;&t1CL;r2$Oiyh4mC67QjujUb0brMopy(6?YeqSHqfC|XQ zu5JU4(fP>Pw?EC`kDSa7DfOx^*=#Z8tsudPA^Dn3QdIdksW%9t`b%V=#T5d2ZvmQBFr+w;QB&S1=3+nTeejxRDH6J&hf83-g#?ou(V!2Q5!S zl@|^_y=SwvIe!1%7d|7yg(Wb#`ai*U zUz^_|B_(7sBkbs)WsOYB>e>&75(q(QTB^v0`B8}WiI(mAF+-W~g^%{*Ex%|kS^>K4 zzPu|K7uvr#m_9i@_o#r)B!C|*W4BBGgs~~eb0UP{@V)wrde5$bA+uwcY1+Ib*1n4L z8DCg+c{uXSQ|c3h{u!4W;3#C4A{)pAgukW^?%01XKwL@qD9{JP;dkv^ZmX`-_#{6a zp%Fe_>$DBK_VFDH-qjyH?Xmn}UX9-7N-ZTFS~LR=jyf{|d9=68sxn#1s!X@wBNsLb3XjS zmY4w^xI!c(?Z_WjqVRFPSm{T1KOD?-uW0)Ht}!dOvB!^-{rI=e=UTG!2s(h{HW$Bm z9e+DNyO=LtTYrReDD@)j(;`J{PRKn*u1&z!(oNae7DO=q7v63>FhU{GTSn?(8>9?l z?e`BtC8CnJ`Cq)a!AR?w0uD}gHS)H8O94Q{T<=j?3PZ^hy?>t1AL00K;MbKo5#cBh zyY5WiS61Qw_AQm7FUNA8q}7%reb@+)OE-HIo3!e!X`__?r4^%tEiJwDE}d&!yG8D+{Pum4&NQdzkwu#==DE@L zo}!NddN-kd!yNkOr&th)pvn@;DDw%{_l7y2l0%jXLxwRy$aae5R5*_$XNwzBnPu8` z6&b18V%>iBwSZp!tc`g(6Ld0o-B=F6rOU#FV4KG#2cGO?uE=Whx)N;ckdtPrz3rhT zO+R?ufH21A;ds3QHZ5}Dd_S*3!5tQy#Qv*exdVPjJLzV1bmbXI`pqalN9Jc$!GFVn z!+yJF$I=z$e8X4+xRT(tb>6EL{I+n<&nl6>H**x#7%|?v?#^01SMMWRZp6sjV2Q(D zP{J>s8;f5ijeiZleF53%P2mcP_^aOiro^^s-V*(+7>fZWPoSA*0(@2@g_ z0R$Us7lx3o^~)^5N;7HlWs#V6z|=ftKW980NfCE_r6Vk3MCr93|6kA$CTMLY9M*80 ze*Gpnxa^cV{ z;^XrXf@bBAZjN}zDZGLgtP(#o-yHWa;P9}GwBOgzdqBIlFk&`vwX~>1`^EE^5OB@= zXRY}+lBU5g!NfZyJ3lzLc4|o?(-#*Im-E@A`@|Vz?fL=JgI*n`zDYqx55Y;%N~~Gr zjB|~G1cb!AJCnZ-{syjnZR7{tvs|F|I#89(y26!gVfne+;p+Io#{{lG_^QxT^mQIT z4_K|mliqjiO2QOOmaX$g@}1!O`cw_pFBxxu9nBC0Krr0(#{;_YTj^tc!40a&w&=aK z;-jwoj>K(WUd88~nHdswT|bEQJ@em247k2QWuFD0*hBcI3F_BlZ{+dPm0a+f+g7%# zlhyiB4$2#=>uovPp_x9n{L~uhRg(j5MUr-borqgZv%A+2b#0EI2jmgaj5IN)gi$X6 zW8ay*@8*^aXP7G!UQmwsoRs)Wyfp?=L|2q=R@_bQ{SR?CUP}S`=pJbi=vz5k&#Dxf z8FZ;&{6}YrrXtGk5Td&_bPqnu3L?F{T!7+32dmG+>b%7PO|lRtwZGO`(FumC?o-$w zt0*!Z% zQI1Wt6kjbRO9AH|r)ViFs3fHB8$QE+B@+oHomq0G5Kp397W&!K7CeU`Ig#b|8*8~+ z3-&y`tsqjOAS_Ro)uIq2`HIg9@KtpbA9PI`+$5iy8sk?yp$$mhMc$ZyrBA1U!RaO9X8nQLqft3vgoRs7j?I( z!h6J1+w$c*_BGfDQ3oWOi(E2O6q9e&gnOy~=0Wx7Gi(=E&rBedlKQ zU+1^m2iSvuOGmZ?d;&`(DrBxW?8c0C7b}7^1vo%|8t!mP|S`kC{j^xyFAeYvZ+A@-tEr z{W8S%dh7KEZ{JqS?bY2S?{gE~mR38RmdMu25Zum(kX43m)h}=KjeHFWXX!T=CUtWZ zgr8q=Jrmvpq64p(4u%TFNZK_vj+Nw83(95_Ba~o{=toT}x<1jqcM>x8*^@@8wy~ zXvv8*H73U>(+7q9ucT!eF#f>SX{v;{F03XE3^CoTzY>MlpcZC#JLWX~x25%^kS#AX zfK(T6z4HFhC`l?S?gB^}iJ^I_*!n^&3dDF#XAJ4c@gjPP28nuONCdo9@>t9Jj?UZI zn}54I(Pv%#%2V;;pch?kclHNjY+r&KZLJp0u6Y)L^5^>SE$p#uY@-+Zuf>9-Yi&X) zIw*5tyBdzuQgYPQt;L|ZIa{3;dC0T$O`4qKR}ZZ_cV}K1!vpEO+XBhk$sJHE6@|P{ z;ScBAQRD6Uy_s{bYzY*YZ>2-quMGRo5N(c9cS^XKj(ZWnzS)V#uUh{_5xc1;TG3wQdq-h-QRjt%$?(G=zmKVaK;}u!#+3Tx`3;2I5 z?D*cRSCmf^GT-_Dh0o4|uQ~I&#^G_g*kVNr&zQSK4}X z%o&EVjrNNX=I-QS4*m0c0Z-Na<$`6*2?AvZU0vJCGF~agT?|aaPQ@l?xz0Z&C?1`D zD`-@Fqc%8K`@$&8i?GE`Dm}$n1TnO25-_Q)txZ|x&iC>zr=wck%aFgTIyeZGBIasS z%de9Y3$y?LFN(h;?J6AqF_XT#<6Q}R{c!Wv`hkm{j~l5ILpJK?BI_b=hO&WhnV0>u zJK_70ZKNVsw^WQsH@m`lnsNL`NznH~jyF2l8hg4)8SawkT9we`nDZKoTk5tz>aDzj zjL-Sn22i(e$0&0fI05qR@4KNJwM?+rdgW<)Ubrf9eyu0jvYHNwZg>B@=TJFk_4e41 z0oUV68|;)iJ>@=Ni8H;E$@`}-I95blCYK~m4TJf4ZyQz7%zuqYKYv_M7?WE<*x*UB zvBz7!=~a+<3t6kfRt3A}`sY8V#hetU=cfquD}LN*D2Hx?KK(Gyu@TF}PY2IkNgU37 zy73U9B{3QIUO)UJWbz)Dxhe=T6dr|rro<5XQE7jUVOU1-Gw;HILWkR0AGvzP$H+}Vt)Y$d6wb%}qnJsfc#c22wCy{{^PYt*7a!Wz^?|LA@njpK*6YQ6U8 zeWjro$Hy_+$EV@9oU98cfv)my2cIf}MECJ(|IxR3`+xCryd;G>U-FtDi<9*dCrfNFP1r zj`8>jv=vp;=aA}oHnTCxKsp-0zVKcY$9-Z=qKTW)#`lZ{OEUHKp>;>&k@Q93j})^Z z2f%4(Q)m=i<-HTkaCLn7PY|4g!aC!;+L(dfA&OJ5e9a^XDqceBUBd0KFa6!q8IjLG`ElLFyY45qzVhxL6_m z=6#7TMrd)ISIlxjwr_ZL^5rAnjH&vJl-ma(-8A2*K~Sh0gQa=Pi1~K1A2<8huRhvE z`Hq|%ZCBLW%Ufyo=-9bX(7A*E-S43S%L&!QeheZW@j(6t(2?jQCkE=8Y%=K* zYf9?eqwzoEi;3W`w#a4CPOvx=lg~Zk-!C!*-8Ovz$kq_f4~LvM{STtPGOo(*iS`iE z(kUTvK)OMYE@?>#kuGVFP6=tGJEc*&4j|njAt4|%>)ee>J(z!o;UuiQ8@hTwS}Rv>;Y?{u3Q z15k)sldG-5W6I=U4dLOzzTDM)JgC4eq{?if_OX}&_h`Iqk`yV1nHp-Ux0Aa1#*K~u z?e1#NblV7G!&=ZT_g?~@1s6HaY&d68TI=|qCk=u7Ihw3-t|u+7I>C(PGUhtT&T7Hx zs}4HLp7tZ)%Dep+;*Ms;2Fa~_2&SSBv&(G`HFgTmts^X{d?^-lSeVj5yb0HS+2yeR zt6P3UO}9n#=2ehoT(|Vp-wK~1+#WY)8?9O5&)ps05;ohXMtg8G1NPug08MgEC6cL0 zcQUACnW45WYO6u-WS% zCmDHoI9<~}rn(cY_EbV5kxoAz(p8&O8&TKj8v8^Be?RRKzjr_uvxivx9KZs8B-K^Q zP9c%=FC05+fwRP~nn^*q9~$!i_v-A7^woo1RdDmMP5SCf%IR)-nbt`|za(9@ZrSBa zQ}IlE$m2_X#cHoET}1E&k%WDat#gt%{Q4h;q-Ais{CfD59Xd0WaBo}+>iId>BCJG& zMZWD9`3m4z3IV9z$V||yv@Y#PeC+Rg?(B=>`%q{^nyNZ}&6yjt{EZey_WP=(6m9q~ z8{n?Qv-HJz5!Wu+*{*aRQa`VKDRGZbsyEE$-oaubTW(0Iey$=dFb#-5=QRVKRfi|j z&C23>zHKGdQtGcJk`Aq8nOxT1Zj}n8Oiww@fwF=ZQl0^p?x_*x($6@ByEu79RMqh{ zA~-wAo`>)_5)J$ey&Sj!5v;a#;+k=WMtz!iT;y(e!vOC0!sz^~4HHH_(@OT=9nf83>t;_rwN0QKHM$AuRJq<|?A0h~@ z=c{Xj=2zjny_P- z&j895PlOHnR%7H(OvERL3=8}_fmdl8ZWy!CzSC9p$ChDh7)!ywdog!mh4 z2#*o_Y(INm`gJ}m-WFp9f}0%7twNj$3qmz>zTD-Dn@Y{QUy`cyB?zkk=s=FFGK*yE zM|BiobpcB%c#tS-h;ZZQI6^$0^yFK`x{IK^Z^_=QSpjx4MW za>;k{CcI72w!i_^vOr;!g?RZK#MnMqVv>6lekmehOJcbpw6Qt~m?5WbmGR4hX^=WM zls_G|WRbv%QT~p73Pr&Wx07MZ5NmNksUsW2uE2`Vs**O^<}=Xf7o^ET~s;HZlimAkWkjH)l=2@+xfa5?kZm4EYzm z21Hid31wAt?Q!YuYJK*vZYIsbWpgmh&z$%LpNKm4wk`|iPPnW`OX{De0kU%DyyR{H4^4!wF4 zltKwU)JA~Dny^9jEFrRgN&N3T3;b&3^9!NW3?v1`;PzrBP~AE=Ejlk}Y;AI}AoB}? zr!rT9jHi?}0fw&@g0t4+&K=a<@Pnt2}6{)k<^*`Khde`+dU+ zSE*fq;co~agxCS@Tj-HVbI`@nE1~$95t`FQgY4HyVu$io5@0{H!Qta3hds4GIdq5j zgze2R1zYg$5#)l-&>%QjS_{p@TX^E@)nLtuWTjXoU@z$z6?3{NDT0GH-KT%qtRO-> zUQi)%D7BhP2;*eOBi(54kD8vV=}vmS6*dKLENtC$hKPQO&L-=?V=f^?n4`8*1-^8I;AO~_b*&5zgvOJK&PtUL$v;1oV7BN{0P;_5 zDYEl25o-MSw&l6deDHodleOxA&>gSGpuI@uM=DLE@MKYo@fV`V5VTgMg=Xnpvgekm z&6tEFPNi&}+x0Iy4u}1ZmT)TX5Wu%jb$}qvbAM;g&?+&aPkXVb2SGvUZC(#+*-PuN zD2D%ij|l##m$i3`$R8`4)q%0C)TUleL32Nb);}=;%{0bGJno7hW+$2)2TjCAn@NCcLkPZ>qgc)gT-S;NjdkreXkBGfB1p*nL zJR0v_2>f?c#cq(G`KtQM`-Eh2SsXoVy^`_*+1}Sqnsk9ehgrP?^CLw zpaplbQLLv>{BIl9#aCJNR#1eFe+>`V5L{og8_b#5-n~-{!PpPg%18ASsG#@Z9enKt zQV%rA=<3^-{I4CiN+AX$dV%+Y{Q>5Xho4bVR8j zIZlo1R#UxeeCmUrrK>l4;K&NfNQh>8MFs1*dTn6&h>BIV&x z(u2XmSN@*({sP}J!WOCIP@`Q2BK!H84L6Kip|Sn{%HNWCj)k(RBTsFqkN5!E8a{Hf6bIlEfsWzC=j30Msd zNHW201m>egFJ=#zjwiUUMr1FJVdDVVef9q{O0jR|15l!Y4l+L<3cp|;xWr6ll}K41 zG1m%YOh%Ja;B7SVGuu)-lZzuOcs9q(mC^(r##}AkH~}&ejFonm`l zu_jMAp!jx4pIPX930}tjsI^VizF#8kmrCwK?y5B`O6M~ zBF~o8TRQvxVnY^7D-j`iF?Q_-StghEa3elhUTf{i6HslQ+ahYE;N_)bOW;aRRF|^j zlJ%PlCizG!ET`td-12e4r1`V9W8-1g-xmpwdI$jRNS>HU@NLz)I7p7LKs9DX4X@)ONb86A3{0nFvS>broWU zZroItMs{8Tl;nXEwrRh;_NbJ{hs0-Uj;PX2YnybdSk)yVqZ$4K9bQv&$JOU+p$V~D zYGC>RM3f^#2OJC4oftM9U9kE+kr{1E+t;>vcKwK8YtLSc;7*YB}IFIv#TFW_}%L_Wt6(5asN97dHQI;(B)L{E338@YPJWr%iS9Q%20 zEa*C77p*TQdTd^1M_@z)#5@1$ml!Zq+yyDa%~3m&iQz7-py-(VOiQ{Z^q-f z1P;;st_f$7z)lM}k~Q!CB?dS0oVy+UK9wVllwth}Sps*kr-J;o86i>-7Lc^PFVP0c zH5NymjebVLZ*1V&eA}yb#TCiGRe zm+5P9jIrGpV?#1kHG4mXCB-!=5sKu<&jC>muGWV3e=J0G@a$XSuqesa zD3z?bMwcFCkJl3esFGV%tjW5Ho!NwwAED%u&f7kjy#xW08$6J;tYk%)XzSe~{6t zfsAUMF6?qb&pEb+Lyd-1p@2S1$3L<(@Jw>1XS2d9rhwd)kD^z`g!-hf@$nM6;J1_@ zkCvkgyhH(?*p_Kmm`cPpMJi z6UFXTBbm_oq6RfH=ghD2MeA^MTHW)foWNOtit~RtKYedX(8bw^q8MoAg-#;=u4^;V zn~cKkx z26mN;CmpY<7f5O6W#;^uJ<=GHFE%#1uFmnZRL3@H_gcIsH=I%g=`aKUBV;{;DR3vv z-zO}TQBqP2-)a-l&^x4r5X?k~6iQ~#c@gA&N4RfeHgZiXNDD9cXqst!Po?_4N6oT2 ziSP$AhPj`e&AdD}3n|jP`JZ`(^f@OuC5c{aow5ov;RE6J4pDo)Y~lOwc$b@SECZx2 zAzaZo1HbbV3VVVFyP>WD!unS7yfwZ!v&1LAjWbqXjIJ6GB6aOB4wk8&9<}-=PYf4t z@ZU8ZsVaxcF%OZ?YsJs0-OpHouM13ct~N?ij(;yYgEu4e2P}Ap|92ndKc_eAj?O-% z8GLWhS?%2bU_rwJ#_R+la0(skd!IYbJ65He&b%>l3ODxX0V3v0Dg+LuCwj5LXn}Hw zEG$@b`B>3Nbqi3$$B*oW7#&o?dRRoa8hgCFvG_4Vv>o?;Jhayl9WdAO>t39;#!gf` zP%hcbAHmGhsrZDIRvFoHwBMUP&VHQjYD(1II+=HE4v<#Qpi!5wd@04t%<8M4c(L90 znY|Uuk-dyx(pV&uzYZ1=&=aQ?l0^ zue2-Db7T#4uqrP;m+WMEOW+WIyruV9KPhH_bZXkZpXLk-aWar!K9Rjpop+K}2+>tG z;0f8b+F@a&??E#u-Py+rUYvgx9f)|wLq<4+ouveRd9Z>@*{JEFreKyVt?ptQ(y$Vy z{APO4=GOQlH^f5&O|#LvaD3Qgp@xgj6yHUS3^6%#4% ztkGg>>3@tZ5HtfA(MJ#+72jZEQ@eog!olR(e7=m2?62SKKZGQi-Xx3dn+88BMXW3% zni$p|)Q+9?dj0zI`a(rk)iao5Wllo;Fx%rt#m%5P9c4ax@>&49<)4p~c^ z6a|Hn6NN&=+K=iFeOhaG@x_Pvahk$3yY%L;gutg+#o%^PylSpBjMvSba`EyyC`fBX z?d@pu-l6}#gDX#?CU2pAZJ49*a5n1}Mn{OqP)xeXs=y6EU0^c}aH@cm{=fYEMAi9a zWLl<4qc_QHtKQW?+8UO7&{9T_p%bppr*f({LfHFTvl`48_;*2@gH77*XduJSp3-J! zM*=n$-^7lR^cnG~h+XI~fikR)dcANh;>S+Zp9nJuq(Z?^gASwyk$+W;a=+I3;*ekc zfl=G4#;@+|f1*^OeqzHkQU;@WD-lQyi#JkKwMR?MyRGEe5dOY3dhtflY7@2K#}g2S z=WyNw+_&>Kgb+SVy^aodZG~{W9xcG|xi+a)XzC~#jiW7szF_Eq8>u8b!Gem6 z2V5eyek3ak_eNO&-X-5BoksElPV2wVwxSToFk%bt4`$?Qy%^f23{xl+Mp3VpjTt|T z@piI0-U0O3o3?)nycM`jaVOw0WW}{!v zW~bn$ve_Toy4Psth|O;34vZ^EKiCS> z?si{O*PvVuJqjJuSlEI(`o<@DMadb;1L625dV-M%6 zKDaP%{1zv0mieQ6ylz|-?^(AB5+qY`1u2i1;0y8z|N8_a zR9O75HH47>nnuL-g~HRbW0AtqdFYmsOvDXEI1$y-1nvJ(d-#t9XK3FJKFzB7eppT4 z-0#G(FSOHMCFp1#sl7-Srs2=J=K zY#tL=GT^!J=aT06kg)6&fZf#Uz485eFQl2q129aG#w~+i-N?pY=7$AsGg48zRnlk> zN>zg;7VQ3&uu8`gvQ{URtn}y0vMt9zBuUn*fQf~hC6V`xPtnT4FspxN=T6R+a;74D zrN0m5`Sp{{_Ed%VIU%1{bl?#c(mnS>3VF{4`#YsP9lbe%*mRV5@q6@QZNO1Rcvx9r zHnX*t;rqQs(s7|YG|%0azzxR_NA#`f`#?WfW*tRFJ6PuU9xg{4VVf&_^+M+9Pmvoo zV0s1d5$0K+HGnqt*>ZuVxg2^K0ZzZ594g~@Qgu#r{swanH~u#*41>@0y}tR26@nk< z-0+)Yav}e$LN9@G!9{lb=NX&q5a|sbMwe`Uny_*Jep6eRKyk6dFy|pKO32Ssod&ki z$@F|c0E^oxq_2rUU(!^xVwbT4UYe7^FX6UP#yLfX&atu;#Vi(DD=E)YS;iR^^)6GE zex!vH)v^RIKc9)Te^>S5on6wcLDTYUVl$XNONrMeTEN&1o`2G`RT#=akbn-k3PzATECRw;LbjrS&F&L7F@_^RE5Y)Lg0!)K(UNW@GC=ERrq| z5vfs~_8V9m!R%;-LXGnKd1Mi_oZfp$x?Jl1!%&DJ27c6a7CBpVeTBCE*Kb(f#0yS7 z;_=*Ux^~dPXU1Cc_a1rR4sZ~1i#9NTd`y~;^6Az5K8Cr%$8n)+={};Nh)4lz?2lJ| zO3cg120w88>@ZVFg1wW#JL}nrbFT;Ol?ZXI;+q-rj$Q8C^v$R^v*@ib+pqeyae-eJ zZodLDjs;Mi3=CwFG+)bquZ=R(r+ESg&Mci5@4*hJy5Dmx6pW6fMxZ8 zxJ@3bF*7mtGwipyfCx!IBipnR?Q-pi1wmkQuF=3}29fY`ae3|qK&MLTZa&qcezVjC z^C%}2ky{F!#h9uJ{@2P&4;?dQARdCi!+fw^-MFrZwUIAY4zeHE%e0u#XroJKdOU5d z@b0~e0Lj_@r!y+vOpC&L!%-g&E=TPOP2`M}{{QMBDVJz=#^c?H=qX6^+z|!lQX>Q2 zStABCJz6!c*s#X(k91loz2YVz|6yb77qlb$0>|rlh@O)GeA%*1EsjWP=I;M`yc2wP zN0i>Ie_-*1=$?|0;}56lv$S3tbxs?8(M!z$DmH451cLdl_yUYt*-{t(r~*~Way7GqPJJ0zP9Si@!-U8vRGNTQe{6Q&FDEymM-91;>PNe)^m~N#>1g-cf z*h+#y8y?XT02n73#s2-3GAUgo)P5?KHMA(H>l8N&Iq?-@(gD}Pz&B&|bF5whvawXd zIeY1M{HJd;074;S1L3@Uo<+Q+X6qkzBe6|u$AI@&Nrea4PC@RcnS=Av8pL03lZm8r zFG&5Qvkw%br@#L5uh zeVErfM)RgG)T};CT2;MgI(|5%=U9W`#b#JWOPjr3i`Fi2N=cLAtnc~VT-Ro zB~aL`sO`5WpjB;XWDMy<>k27VKPYMa((UwLxAKXDVKCFMB$Nk*eneQ52I{1I@-8~) zh&>3*BmAf9nP^1CU|KnXLw^HWuXiYqrZ7valZjy@{)u7w!sB-;VHTH9e$?W-m6i9~ z!fc^wb3^!mDS<#Zf=E6<;3b;#YVIg>q*CW~I(anS&q0Kg2Z74^+PzY>@q;~H_%|kn z`rP4>Jiy3MaTNfYRX?*Hl77iUJ}uGL9(4U#yPgx@{x z`pnNhqd{6w3o5DQ3ki~0S;I_p{Sag|@~@9*GO_}lO(88FBrVCNUIO_a#Gs>pj!UvC zzrIL9ejG(fCV6y&4D2#XRi3ifCi=T_L|RxBDReM@XBJd&Ur^+^Q4Z~NPYD=VDN;u3 zO5gj)vQr|n`V8`3(1%1MZ7z7vkrCEZHYKWb@31$=W8d)rf=M#LThT z8+2kZA{d+~?r`QR1wP6y3fGrHwz`w(OnNz3&;0`Di&ikpD$p^7HE!p%j$4VqG)8A@ zz~dXiIxFUig8@3l_il~OuKHNPL=p4x28N!(Dg9J&EGdA0=fZVu>zCM9;9(`KjDbKh zNB?u?h0QBiH-K7LLFb};yKG*wVlO~#6hf+DSJZC)k6PlXMLgZGqDpSCLoGQ^AbRUj`9$g`$a+-LCM0m<4(cwGJ zDeP3AY3`7)wPyTlTE(wL0Xt^DMcy%wal7kvwE=tubZQIdOCrfzYHylE(!gVG*AlGGvOqK;z;wGQ&MjWX_()Ybi_*C>)Am!8H+`|JzRP}%MF zpCpSKBNlYXW4BmK4iLOnuT6U{B^bp7?qNGg+&)H6nNnlv9%x#v1R^oyw5wWZ&sC2} z=UX5VEt5_cE}u)WAFA>o187E`;lh$Fk{8Z3Cux*+4&SK8Ur$^@Rze@^*ns4BhNo5u zL1}OTspYm{o? zO{eJGGIr=*FKV`grzzHFbrFYGkcx^%#tLS<5L>Fy00{`ppWxISB2PzZmlfhCx`$*r z$Y=3hw9j!gz?9(h&7k=zKFb7zOoO>m7!5w2L2a|!C=*)`?_I?A&7_G>bmIJW1x&cK|>!>@~ku-^(DQF%(M&DKyq)yKZU@b`^m*vunH;mY?%7$+p4E`{ruZq z`5jd&1(1axqnyA>)+fDYC5eM-IvrkSC&=~+YULm!U|SJ2dXApTza78;7Swvd6|E@R0y8h-{AVz!DM3yJxPt79 zvZ5D91a87X4q93%*c*oMyY%3 zuZ43y3E6`gDEJxNMXSRTsJ~pf1@kNNvExyfgDk|AD=eMIr=;||8 zVXCU?08;gR^m*yC4#mrH24v%&UsYH7wVqmpWxAsoOfj%VJVu;zyn9pxD2~hbyhkLj_=#3{^jI zW^_K8Yv*s}C$@P7q$TBeVkr{3@RtEcOe7h&HY|?K(g;D+%i(~)50(kh7YbG|xyx&mO+N^E*?%7y@Q;_JH^Y#+ z4sZ}56kcNZ83nR-6EAn-Msh>gZn00AQn9>4o)U9ehSfQ*0oq;&+1on6!XW#COdm<2)7kZ}oZETTNrO zbEArVo>w`)a6F>obXEic#iuHC;I9JOL)BacxN*zUm5U1Xe-+}x#rfapBZ(&{#s0Hn z5Rvla`qpd@Dh+u)3^^m+b|q%0hWZBI86p4;?fPfiu(Q$H*?RwjiZ<+JE)4ni>rAFY zz7ua^k=rV&Aq;H!Z`L+e7IQ{)dx$1HN=n>iIJB z^YbP5kf50zQL7S+%)^b-aHOi8v#ut?{p;JPy}{s{ysW#o*W+yMs1suw*y7`1R}6vJ zzXUJ~bpD8Z?zR44upJ1D-xZ?uo53MA&KMv~Yr$VqB5=%=ht>uXr%^xE@u-8K6$mg1 zzTh`GEt*xB*TA5dbpYu}}FY#-!{3XYGsU_W77feLRuC>6aJ7jV)Sv2PW zC-%paYO5(v+bWS5Yei1Qu|K2uUnS>DOLCIX{XF@umN9@;$+6Gw`-(Jsu4dn}XTxW* zJ$l4uJHA9GNU9$lC(-)g?qNcu3o`CnYguWTgyi33f+>nZEBe{l43`mEKV0Mz8JVaG(R875$OAKdt6pSsRPqA4rbl9tawxls8 z4(}wota64f1HMd=S?x#NE_an;&h)*?q0l}T#)u#wvVJDt!Xx24Q*IW7k)3&S^S$FIwm;hVO&}rmf&07k2mYdaofCqY@_Vtkh<1x@Udu28)xuJ#a`IKvKlFNC&Z&sB5 z2%i7E-BtpOR~={420f&|jH)%)vD^a7S%Xpz#^%eRJ$&(%{cc~kkPlaN;5t!y7pX8s)slK1QsYXc~8KmbYV2J(xZ}nx@P^ca;YY)L4WUl z@yGHPza#OO%JxdyJNqcirdVHd@i~J>RCh!VSneyn5xDozh2wNA32}yAHi=@U61-Pv zbq0pGSL*0pqVu|iPf-yIcuR6)3$-V1DS_tMfr$ZZdgI-?!T+wYbnGW5*ZHc+5|uPd zH;6xz+fG;(uNUMsNo4lZDE^lFZA+)iww5I~<)BS8G{q?ao1lG3gA#H+uz9>AjGtj- ztAy2}MFk&dP>02SDKL%M%`G$e6ioWM$sMQBR~BT>8D>qC#kQZokO1tH;?pj|Dz|OA zS^b@FApY9!j5bLYxZ0Bl{(hKG7!6u5?>iaPCdnFsxfC(BTgCKc&vC-CFbm*2E! z8eCkqUzS6JTu%Ii_&8LienY(DPm3vrLSJWX zWrImi=yH0qdq9)m*Q!{(Ys1=aTvbiFKy(DA!R^(uJT?16Y&|S78iOTC`!BdXdR(mZ zY2>U8)KiXG?%SWKhL`~v^Y`$Hmi9hB&F!f*5Uh>P8af$PxYTGqrlzh`9C#9rd5irV zqg$I|L$PbwIl~CFOtuaq_&n;^wROS`@!#t5O!WNwweUwUhqD|2gAV=RavUKgB~f@9 z|9A96{?1nylyI6zG3p>R>sQIsPlnuIPAPB2fgmD=)zz7~2C=fTPCck;olVq^!OxzK zt+70>Ong{J6up}|LLBqM%kh=I*=&FpTIO^XhloZv+>VE;m7@~Cwf)#4^lB;E|0&W; zX=Svl9{KLl9XqT@4j$cdH2!w=5-265+`6hClToXVS)F;1lEZP3tGj$g{OLl&5obDo zz2QL#$ddP4h8`XpuFoH~^VyfrK6`dG-LBjdOdEPq1wX7!4tS5@fcN;v@LnBxZ?EH% zFMpItH;r=}EbuzVg9BnTL(-0|rBn6^GwzSi5#MveTSXk4Y{z4cY^RIkkTTytl~tz? zHkz$ZgDBBGo7}$ak=_rNnB7s?Dj zOrb&`&G)wpMytYidH@F1q;G8WB>9#!HOm`#{U!6k5Q`igEBRTY>rC?Bkbi2&Snu`W zn`MLXw!tW{31sWR8!b4PqWv~<^K}iF{WyK7{Dc5zs9D-iQoICMu18irR@V(5JAC4g zm{1q_fsmOGC-FlAu6LHP`zIbdqIW`J6kE0J2PRJ8tA@T4QZh}~`SoxTQA99k`1|2j z=IW*Z#V1*Q4IcNWK| z-@Wy3LU2C)>JKU&{>|p*vntlrddfWgFvvmcwRz_yu;;NVgl*t6*GY$S1GexCe=_j= zbk>jNd&hz2xe`Nj8kaNOEvRx^%R#cU=YHINKhfD~9i4Ms3_Q5UXND42rJww_O;@>g$4>}6;>O639JwBKR-wlQr#*a_2qZzySmb@kgY zyo;|)Unt9g`r*>=iGeTqwCJ6u$Sni+-E~7<&v8ck0WAmC?NEDU7hh(BQ34&~RecTu z2V3`0ZBv{xL58IWqVP?I$?+}E1G{w_Q)63T*TWxFg#ONwmv*Bj&Bn^IiY;yOS+pFT98}maQ1S`3Y+A8 z4iZG8UUQ+wzk9|GJFzh9H(eps{SnV@L;Z}1&WDgUThiAVjA0|iwkDmCa+^?5z`>h0ZP9JLh zTHs)>9Z@C@wt?jCO{|;^NO>{ZSjCI6=&k6t>%SLHe90`q7*e2Z?%PT=MyFxI_Le!n!bsiByj&=<}SkhO`kLEOWez z9Uwzz?aeqqAWBp;dPLgM!1_t5#N~8DIJ=F_^?OuDuz3> z&ib(L+Gj$XHkXH&b{8KA>LEE4o>Z-y`Cz`C8<&rIak3O}nBR8wWy#78e=<8r3R~DYN$9cfge+EI`a8^MzNks~>S$L>Pnv^PxB14z zKnn$|04OUDN7vQ3-F*!fd4cUpj(z6lpQ#~OLua##W#H4jit&rKMMw1C>xn;G24oxh zdlG{T(l<_(?G>`&6c(<*#@6p_C8j8N+iPES>jQ!R_Pf_H#i_PK;#Tzv%!BPE%_2MK z7%zEvp$ZWGZenl|z$P;fMy$${FsjjaQ#a$Vv~{NXw7n@M+QS#gQosPz!{_d7CEh5r zd?eeSs;|72di>T|XiP&nv@zy$2;LDnY#@uGCNDF;B-RwbOs_s*!kwW6Q|W;`z%~46 zj|Xu`Ec;*>^ys7ifU}6i{9X6vX6-!Jjl*u$i164e}I7PWU@T z21x_sU3_a}`CpRc7SC9wd?i5W4tE7s;m^gPYHFvg=w>Y|(uNM_mP5g~>mRgW{|pT* z#h;t*T9x{lvJIE`xVn?w($O__!<-@`xEKho{PFS65V`DHxO zndQ^poBTAZm+K3SReFPtK0BdqG{X*H8Ccj>1w%B@#oQl$imnsv6>_0B@P@WR=XD0I zv@l&wlb)gFbu>jIHssj(Ljn~f-Hhk`HBkosfc1qcPk0#Kg2Dt{hSXEN^$B8Rh~^=n zP}~m@-5)m@osiFWVq$=GS{<7Xd&ab~YD2`~_UN1Qp3<4G#=edSeUuIO5Y~T(g>IsQ zf%AK2?kU~(o8*+BEwyiYjJh@&fHK`?W#<}RCY~mJn*)eQ7m8$zx|FPlKC5% zkMifUQuTxaghxr&xuhv0mr;;yud6#)02UuuyYVrfXg3^jH=qaL(dVFS%A!M!?9%1;SR6x|KkvIoeu!Gf|5C#* zVARyQ_guY~o7sRMkUF$ZFb0wdA5GT-L;k;}7~AHv`%B^#+FwsU`7HiaZ)yf`L#x2@ zQ%Qe0YFaBC6BOjOrKU?FaP9|%A_cA4NFkx}s06`F&QgOgd6h4^?(NMIjYS?8C{(rY z3FEMoyMj)iqVJGF+Lk3!5v$=SEWBn22`n{nwxP9r0W@c@$t{|af~SG>aA`81{14xA zq|?*YD4amHQE)h@bKI2$6t;((!M$ng!VF7Rk2lY767foO(H;yTnsgKYy`SOc8skFO zk#ROPQQz?kU0kNmQ9doEOx9NC(;j%ESss8$Ta|fLgMtK_Et-Ny5w>f5J>Ofl_JnjMeF@nUmYp_Tqo7 z+@KP4?S*oA*fph~QIBd@t$aM4*T}#HI~{BT*3GJt5`)48*RoAPP23e$cTNdodH-@K z+)rb1c~LZGM0suR7jBT{a%7}X%oc+Er4qCzRIk=VuCX!9)G!S=K()UUd)mD?z;+4I zfRW5LXU5{^)Eix`c>->t&)2kh~J(-(Hlzvy?= z|5~ASyZFz{cNnc7-ACe%w&>Idr6 zXU%Gbgb-vhCIoXE8dx~v)u&B4G^0y!i+(oSM+w?OAR?jwCLGI&on}N2suqeR1<6bt zH-}14hOK$RfrK_jU>VQ#lP&Cmh~~xq4>()@`G+^ZX5nC!#fg)UE?fHrGRXFpC78CJ z@;C&7ARM`1?z08oF|shPF;>ELV$cRXH2`j=Nos3BcAq+>S2TOd99l>A8hH(B z{R3P#79+1G)wBS$Zp+^8)_69cfJ>wo2D-hv49Sv zV2uUuCvXZsUsR8QHJky&_auNVR^Q=YJ_lp+D=x@z3VR&+&dpZ}_A3gAZvVdqR#A7Cb>|Ku123Uvk6F9Q(%` zHhpkP{P4sD`QDoM3~p2h7`)Yzx`)IScn#=WAO6$mHCsll05=3w20#q_=Nc|CmTtc; zNqse-rpaUVYx9n@(tccdiz{VScvF4_y3wX=q>;~sK6}^n0-^MSH9kx(-&C@xpRn?d zu!A(Sjm1a}(-Vu$#5(?UMW!>MW22O?m@lG~MBx#vWLFV`g%TOWoF?bXuM+@lTYp$QcFx z&k^QLLte35KZ!oJ3tmDx2cSb?duY^I~n_h@U(6FkInNBXZ? z;4v@+Px;Lmd~F=KRbrO65DRUHhfs51foaPtKUR*H0VIT-4j&#j^_)itp~iVD{ah_x zqmBMgPACcR1%|fAjZULX@80#yAb4(Z(m%63QpZj!b)?dewX&bi&*(Vom=aRo6mL>c zos7HhEh@3|s$mDh*{tv;8G3a&aNSyftQqp-pqxRr{t1dmucOh? zVfz>odfGybvX*~JBEn7_M}cu*s$*z7HEXQ6*zAQB0j;I3KAy&ng4Qe{m{kGW=GHg5 zD}@R|og?l(609wYtV)<;RF*vf^CDnbBtKXi8ct#SLE5K&DbO*o-7 zOJLxoG0TO@IcYE>@4m`CX6ZPAXYncVmkyx!aj=5Uwl~*el}40zU+0@1vKH;sB0YF? zUJ&ophY6E4bBh0XibVelVhzCz&qmL2BR&&B+@X+X>&@M+=?@p}&0$LI>3Sz_Z z24tjbDuv-l3_<40Qf0Krvl6rVyf~we$pdU5uY0{~Howk(xb5@mEB`UpUt{oHjY9*2s+QEbQZHTP-u0vBaKyt-?R z26aM?sz~ie%oBg~`UmerAs&?0;;G9qO$w}`b>uKrcP+#DiFNJ~<-9arukMfHjT-{7 zDI7kcB##{n5|nk20!wfGFWAJlKd@CI+_CD}Q8kS8IHTOD_!G1>tBa(-g$Y)?=o#JD z(Zjw-Z`5VxzTr~SQYJ8mzy+!iW!rmOAA1J9Xn7U1^z&gE{3QRkdh#uN4fIAt=nj)C zWI61wVw&Vs8)1q5Pj5w-xKJUyFC!59zn*{Qk&!PcjjoYP8e`5 zt=?eF`El}LP5;q;x0wE`TRNFQ>~@L~)jcYVeD-plF?2X(1PV$x8_MTZf#Y{Ek?Gh^ zp_|a}MUx+NC!Gp=+GP1CsqG;{@0Qk%yCEB~;Ndeu(woVosf*1;)Ly4xQ~p+}$!ful zFo%-IgE41LB^6Bc*`VK>;b5*Zh{qQRY>CjG`5x*V&)`YreS=Hf88LCpXQ@PbB` z@+{P`xw(hnp5hw_isX2nRKBlbAU(}$d>gi{JHJtQ?T$972nc{N#V`-)<_m8XhgEz~ z*3iq}i5*)Bqo$*)*K3r6q`y=vWnYE>V`xY-!c6dtmnO~@bmV)}g2S~RbVVNk7`pvz zSPv{VHdAX+I!z^b&wrYDobl*@uR3ZKGfl0QBud7}#!BmjqQ? z?!7!jXm9{~d`>cA0>MqN=|1{A2-7`Uh4kw6DFM3=W24M`SNan@ znwnGOIUgsPE0lvI-k!6z{=Rahy!Mox2m#k{vB*SC#) zq)SCI+eF!ChFRVne6}q_1yoJj#f_0R1S7qH`bYWLMoHHkZsE)AvwtRBx0WxoJot$t zPwigoGeZkc{Datz#1stGttEu%67J^+@!CJ{Q~68e5cKw}aFUofZ0z|Sa^hT`on}`< zw%k2_`o7;B)wiW4f3nl!EJA)&w8(W%MAHI zRV(?x%w4r;!882Tm`vM={KJ*2ah5HQ-nO1-$_`x*F5|&Qu6U%UIK-1sP);8Czi*$~ zXrwq?X0t^&&^6b)yqFZRLp>8rnhtXw=+~4G76Bt?Bj~tVxe`#{#E3%Mjo0=x&axgL z0~FQm3*u0hIK-4<8|-T1GA%7bB&LePB82T9B3x5OM<3RG0OzRkc<{iF495igR`le4 zldwgYqw-%}tc@c00s&Vp{s?<97rVCODr}6sILgl&ZfZM*kow z5i!8pQ+8CgbxmB;>2P~cc8=ku$##a(BaOG5U9p!yrGJ`2rQ6ArsppfuM{P3Mm@MUi z;yotjY%3EpDIKB)bYkF-xGM-MF;RYw%xF(9ulhya!{31ZBqc|5|DLDaGfo0c2*83S zmLZcm88r1R>u&S)X5gN)aEDc=d{ z@(?L>t}2)*Q<+#dmH(DEGf0L-glkvvv%hKYPWNA+yGV1Vhw)?bzC+Txx`V)N>v7Fo z-1#@wjwb$YRr0Z4S`;bG8X{VVC-nt0YuWLsL*`@l1IvWrEFx#&aF}B+Dn_#f=u6#c zUScji3luIaipy&VwpHLVT7;YGes2z=f#Hi7coSZXTnJrt;>0yGcY`Z*%P;W2NUX>} zAIe7-pNy-RRejpxZNf|MA_r_gyq$@c4)|k|<#A;wkU&3|Xy<#beb#W8o?4WxdP7fE%r zEet%aU;jzXb45hb0q4g1`rG2G50DQzh}Q@5uG!a2xkI^EcEz;*mPd7WOA977UMSNS zd}j3@Xl>v7D=vLY>vA7HuB`k5WrW0dOFcp3xeH$LzFCU?YM3?_PVM~rC4~_%QhNYn zrkb$z17HQ}hqZqM0bKh_l@R!h{GKs|g$ETy_x)_zs4q&7i7tCazOjOo|3dbF_m$ht z4gi#BQsDa@ZD&vc)-W*h_2272^s=}xk&Q>*^}CQ^Yf<6esAS3VhXdav8J>|R!0*h^ zMplu>J13Q=q>o%8cha-8Lx_aDn{(#t)O5uUvrDD`OEzi9ggozxU5efgR?Cp>VGs z(c+ed0e5W`EdW4Hm<2Z{IK$BWJf8PGKO+SdCc!G|S(Ac)jN)nxLI0eMY5Al)b5m1K z5d#xm0OMdDr zbV8uND2P4#qdpv?LM$4n*`9+YO#TxpFrz0>a=JW|*ZCS9v#`&6(S26>i7|@|ok!%loF#r^~i0C;m z#%M)J*Bvjo)gX~+dXTLKapgn{p=ROh2udX>(-aN!-R2nv)H8j`=jplYTkNbf5SHm< zww_gi=+O-s{v_MWbGhz`?z|s2>*6;I+JrH2Bo}enY+gRV!#LKR>YYwkf}e<_=c#s) zNFt|zwZp4&|B7Rc&MO(D19@&a!anMQ6&-SV#w&o`u%%?sSoDfrZcytQymr^IjGiS( zaB6sLExwQxM9Iqd?v8!6Hf12)xO(o{n5!4r`fmox^#^z(nyeq)Hs3=_zkXlp zMa18Y_d(CzS2uAx*Y!=H^39Jnfk%O1X43j~5RCvgctPK(_k{dcV5??mqbR@7B5~5))U9RJ-wWvXbs+aeozQk7IMV->*Bb#1PwLO0zyG4FqP9*jr_qO|I!X}bHS zFjdqSfD*f0jsV7PmP>_L96P-B%VwuqeiW|EyxruK=hJHtG;t?^d^{ZHDg0v@dFr}H zZSrdiyr1WdN8sgvUKmbhLIS!9#$B)QNiJTQvVK~&I&fqw_kE2*dsin`vQcCaub0nt zT?1>gxar58waLH+JOxch{%jqWG&xTWCkX);mZUTLJ6l~N1#@ zqaL96^+e%&yF|$A^-xO@7umLHco*FL9oX zDAZ;*gE6C?6UN3vvL|>k&XqZpE~9}_?JB~H)^%edOC9c<88Rc+)LnI-dcyqTi{-0F zR`+=!W~W3*Q-f%06bi@hp7|>R^I8oO2ZJcPCsEeVU~g>{t9agCYGaXiet)KsMvFF# zl+NT%QS97G*?I5v0e*{yG_r=bB*2T~v|OzF0NtT7yv!36N*%MN-DoQ{=158sYRru3 zl5S+rbi_KB^oiYB0AlLxd+%`R)$1?buNGy07O)6o*T|O>!jAJc-jkgA!RfBf6@A4o zZWC+yxThmBK0BwUq<BjB5NaiNT}q8~(pRa31tpyam{Bj6O}EE7~HCEGHR1EZw2Y{Hg`1 zkHaIcyz^P*CAgYN0@UP`DQ8OU{8c4Sd;dV@8a56Vo#1%rG*ra*X+ye^RpW^reQXzp zdp}-eBn1RW{R=XKG(0#HLDBW4m+GhWf{0^6KhNY|5m6-+_)MOB$u{3Uv@+EPx~(Yv z9QMlka=dNX_Rx>F?{Hy9n+WrcHyWhxbsaocapq9X<`Z;N@={U@3u21@D4L_~^p{C* zB_3WhG5g+zAj;$0DG`*}6-jwD1!EUZ^QtJa#XTW`Zhoi=O%)ZJk*yB68kPySXBnU@ zexzeZxV9#CbTV-lCKNLZhGzQti$P4nm15EQO{LFUHvbkiusO|f9qU>oj!JV4x+J6{ zGt`jI4b&f&3Q!}bW_ z$b18>`_&+*E>}~d$ns6UOjK!Z+$COapmik!QZYVzjSKYO;8tk)D|m7$1vkSl)rTDQ zs-t3j;#0w=j!khaozx34*ZDy*Q0Hl_j5E3QTj#OC=5Hl2kz!86uzzmtPtI#79LFXYcyjb)1C}W|f7pXJxvPVY;3w) zr_U}>88ORzk2r|!xW6%!XXd9>C(qM9;#(6r>BXE}1Vh22u!PC{_D;!ehrzc?6(I>I z>hHdNvP5Yewf5!C`q`>all268B9XcBD~%@lW9c*vqfp98$Ck78{}3Xjo z7tLgTtxyu78M4ffUqa=y!6;{tp@y(9i6VSmS&C^RS%hD#Rt0HAO*v_P3E$F>6%E&= zlQ~z9bK2a{5gPm?0%5oBY3SMc~90*;kAXEtv=N{%mNRmxeU|vEpjfr`v++ZH|Z_KyWe5y_$C>29tM}Xad z_tKn>Pp`Y^Mo<58j=AUyKTrd2l!G@}k;mRgOV685iUwt;2s%v2tet6W8KA@R#a37T z@x|(N6`_C}mcM{W;fpdCsI7^<#dhakpXBdHa6cq2$Bp{V*U0HvQ&sO`HsmBjB}xRl z75?d2NP@d;*2Ss0R(S+**92umK0RSJFz1ywx_>{2klaWZwi5a9Qx6=9Vip=W_{3>= z<1H+|(o%uQR`1NT;edJW6TCgdUW*=vMiDD>srx2UK}?Ko>7 zk&~M`7{G13A@M^wfD=qx3YxBg=?&V3cOv6I-5eDyBBFBxX~^=`Y13#u@$6A&2{#*@ zSLoI1Fkovj6F1-RNKlew4C%g3^$aSP_FlTLY^V6^6yqpYQ64Jt^VCt(tt?iT1e%gp zK81$|ZlIolRVkR@5P70_uc%lF9}Zo;FY%UESYEUo4|vf+LM$)ZE*ICVMv=)mV9|{Y zMJq=e4L|8Rivh+`u`l~QVi2UdCX8BaZs>&3JMBqVaLQN1Ie4X2GKIs7UiLA}_%Xb- zF&(90=qX)#>PXH-%StC7Yf+NkP-tT+Ey}<2(b>g~+}^NG`=!Gb|PVdXhP&h6trAQW~cs`4gEd z;5L|HaEEA->7npd@u{XVY_sA-bd5jhTZb7GbY(*-(+6p4pHO&rW-`P3>8UzGm6I9D zX+Myco5ELe7n0K$cDQ}Y5my#_(fMZ|Pyy7+@t+8x+C76YhLYtoCtuHRGwr1MXM?W` zD)BKY^!Cz45yPB%Nd0L`bVyZhHiy<&PLGJr5-A7v(k-*CGsYldz z2!tgNcQR{01)asfOZ%E=4Yq&CmPFlEU0ZAJ1qj_K>765X4$9=gbx})9`peEy*~|HC zX-~lDd3ayPUb}gO*xT~Olg&twqQ^}K$yae8NdTm&a$Q-C&aNE52{2|4xK2UdF_7Ppp)$pab z{IjIhsF;YLQ+yaxOaG8T?E5dOc!E;i_gM}K71x$3R!>upuoAN$s!oM7t-#-3Yh_s@ z1|%m@Huu880Yv6OEQP*c^x7KlplK)*@+J0lySe2IXtf^|mN(0xtU1Vrts0t|i~YklgC+-0r^nw2eNK=3I4 z(e%r2wtpaOwo5dLr+Oo>f;+oANo~U5+R|#6+hz%l|B)@!sqUa^82(6U?iZ^ET@XTMnb&LH_QP;@6SaQf4OAY5aiqd)U7w>YQzf=qlux)?k(MmY&ZV|L;w;$fGapbFXB-otu2arln~*bHpWiurEmso@oNi^uzFcF>yh~gb7woQ|njhRtsS5 zzb8;qP`oNfM+C|y_Ed*eaBC2_+Sdf{X|yq>Mg2Wt6Zyu?yKJWBR_3ELgB9e!?v&04 zGDLM(r@mVNDr+_Da5wYhndnW*=6C{E8;IXCMDgNh*s#FUQ(hW*BuB^6-T1U+Lrys$ zVExY*f_FNomQi#9fom`Upt9bhF~qp-r77Wkww{!5NPn}fDR#hvXf$wK1ARZ{V?s?& zBq`to=oI(DV;|>D2Wc2ki9GMH(rAa9c#LhHb`?~SWU9kZ#_XkI$mPTzYwWTmUeEjU zdjYp~dV2d<4kwZOBVr1=pbvZSgwa5JCpkGq$4&O7rR8P<*T)ErmXsvWDUP(@mnif+&~x0h(neVWM4Grs0HvP zy}2)l|D_@XbQwX=tMKw_KFiIZ`GUjHJ!LP5|Kk0_GAQ`2%Bcgkxkd6bXY#LbB|YzR zipSCxOB!1C(xP=AQlh;wVh39Sf+aO9|Kg^prRM%}hMX5`XKg$75+Mqhgg%gi(C?gL zyp{b;vDoEnmn_Un;3+MVzw9hq3&WcYj>x5fz8jcUu#-aNMuO1Fp?&cww(E7{)w9Xs zhi`2EGzeQ_oWQYYR|B9jpNJ;J^Q&tyjGASuuk!L=*(QC;9I53;Me*~y*4!r>Gn&Am zn@z!KUH^ZCoe*F9l9RO8x!56LKZ#pB_9gnVypAgw-uC3`#Wa4w2Nlzr`Wz)sI3!-H z_}q2ptw;Xt#~q=;9zZ`nKZ+4G~0Zc{>{rmK*~k-($h>qK4~ucQt&=k1RH z^Nw`;^+;g#rX9okN=wVH^LGtfrnU9tARLkACTS~cXW*@Rt=U;1=x}Yf^Qr46k+&tG zX9GczU*#vRhF;Kte2~^5z&h*XBp_O3yM*mP)LKQ<*G;H@iZjt8fwEh}Cd4IDVhJK;Bc7tx4r8{&L0rbcgKGY-H zuQLQhFMW{$hi9x-&n^sT#6klV*m6EmRbOJr;E1tt(H-m?^&b6gpnT6=$BvPdk{j>g z;vAdEf3B32Uwq-EC-^_~6~}qAkm>X>O-1FG+H`)8{Es;bj8{(2o!}_H@wJtWJ-?$?tZgV} zispcg`bC(qq+P`IoJ~Qrj2$?e@O4?fF3`1=nOa-x8QEhE%Q81=`Py3oqL&cdk1mN7 zKLNfvNmAm--+>P`-ioegbKOihm`a?*8kNzWI9G+WTOlyGLA9o~Y;`jnDLfTnAa; zZSXkDi2$b0-T?+FJGGTZYZ}8V220c*`mTicaq8KVAEYJ1U85~B!@O52UXY^XabSOM zt!I4ydc~H-(~CGSGbvyOChf6tde{|o|B;*CiwW)73noCex$9%JsA@S1tn_XnS$jlASkq^a&^YqU{n^_8OOA^Oewk+zxm~AYX45S z*EJdskEU;6+n=C#y}iX(|42~{>}|XQ2{Qs#@L)wN2(eu1w3*xoT$sP}%FE#*6p_{= z;&PCh{<@VAIITJ?n*K}zS!_m@zN4y2 zb#d=B_1qrD7=I#<3Dkr*>l(t+eLZw3@ZlsfP^|97*q2si{h3)_qe`xCZRXW0ucX z1S*%N>e=?LPCocy?q!SfPtz~sGmfjE$rI>|E{V($sI*~mQ?;WbeOk?5&V%Z!O+s8x z#7mPCw19jM)9;S;yc#QFGCaRtlr8p8t*z|S#CJ|5=}#3Dc=a&4&lbfgkNNx@I|yZH`ELb9T{fal&&d^Yij34BR7 zu%(M`t0&&nk2$E{;uPDVS{adD<6daj8-%Zf5F8TopUt?5>7%9 zN1Nh81s+EHk3J2oadJfi!y!WNq0Sco5MT|qL$Yjnfhz?Hb3f4cL5!7yDmA?m3i2o$ zd5g&GpK#OyUve!4X14hpQCNM5bD>xK6=|_6$d^rwQ|Ww2Ib^MCM-dGol%&gX@^KD@ z?BWJsz4O^zW_oC7La|<*krzZzYWYZ)nw~W7r?QQvTj)$|8bY-PbYtHvS!rhOCPlaZ z7iX@e{*1$YjIgSin}lnn^7A;2S&U%PIOB&A-bAu7Gaa{Rn14A+3lGb_;=-Bf32$-e zTb^M-LBx+%hWpCUE=UgD8Dy+>VXAn?V1nfqMFwnrYqlLtEnZ4k>z(rH$E-w1beojw zI=BBNyC;C#Tkut=Qc9FaayX*7_D-$ia~d4zt?AzD<%G^^MjSmI%=9eL6Mx&5(%~}l z(3sMP>y-4dM(CyT$Hn;=YZ|Grw=E91{_~_n(#&nCrbSO>o zx%#fc()J%C(&NvSir}>(h&RC4pS7n*i1IYubyM--Fe#67(Z8v0vj&RN<;%n?Epy>; zVnHzHxm6-&nH>y_5t2NB!Sjv*WZIphg4&yKSM|AAm4@h8i?Xsy`tm2J2Nly$0sja* zzCix@amt`vMIzi)btMF^gS!?MFiZ zaV3PMRZj&uA85EL1W8ugR|>~b=0vLBq7h2M8u72DE+!|j8{!UqsxR@cSo+NzKf`mc zjw%U2BOM}2&)?z||6NbixFfVt&zUUpds5I$1L@zql$x5wjb=Eg2XOU&DtBkIpa%aN z5C%u?0VlqMJT)^LQxD?-BM8?2-nxw2E93%X++}F83?p%^A*m@2wep7Os(9d`%68$c z$e?Efw)m|d>oXYWeos?6%QC_75!xOqB0OMwPb+L_+dw}&Qym)&r^tj2;*DR!j?N?v zy~0Z$@2xJO6luD-M>VVD!~=x1lI_XXXy+}Lv19&S=E^3neCvBm0egB1C|A%;D}rFj z)E@!Veri#)Hdo{Ldl3>pb-u7Fan7Q zE`D-Ue*=H4{UaM-WHC1J2lQtBZ{M(gOt6cYMCG>2`|`{D2kFWkynA-aZbdh2pFtzN z92FI=YMN}H)3?Dmc|R*jpBtS8$LHyVd#>}lu|7#bjBll{7w#Or zB*0tr?dxdX%?sK^KAAHV(XYB)jH1LkR+D=e`_++n(h>ai)rjky8q-{j5yt3>_&Qr} z2iQ}A5LCgM2n^lRc+E!*(9`~2zf5+roN9OApiG<#~?`}#( zGWIbJTF|%o0cBw#8qp%FfS(wb5MAB>$@kFwP(&T)Q+1(&8RRoQX5f;Myn*IL1hKxOrFG;;TQQ0IkM-v^HJ}U637J zVy31j?Pcofk9;>`r9;*Zt;uC$kp;-#tD&K($%FN?XP0qWRzIs&-F51Na1wu)_Zy)i z_r`_y&0-^qWw->^r-jgl0P+;R>wi7~3#uV!j{lM-#PxG&K>|=}I%5wbF?G6&S6g=h{5U4|8jYWe4LNVNpkr3;hWUO1OW?Zdn%P5M`-8_ zi$?EuiFa%;_VetbP*-8}dip24f1n$=xA+DNg1%?!=%3eC(99<#r8eUyHJJ$3kaeDG z1=6_4tgBy}>r^*m{J@j_!_8Zlst-8N*X)K;!>PUQWtB_IMeN(Ihi*re7X6Pal*J!= zN#Lp_0dqwf0-9w@KWDxIk*q5NJhsZVc}Kq3dRQFOW#)w8N*f-7UEGWL=2~HwWU1;t z95!I_%$V$SK^&ZxUL-=rFr2+(^Ol+`%P)PW$tlW3RYxI#)g)wdOb2gh8q1zd8s@_Q zaRw{5X_=(Gm9BqT`Cb{BeT*|t4M$#CK2IL&_8a^*>5jDqJWkIQUr=cN(&M58A7*MM z;clz4QNfX1LjMfGX9;e$FiJo|Y=Uw5GxjvFMaz-@i=4}tZ=m$5fvTpUd8})UvRmk| z$ROj=meVs1`yVs!aOd;$h-_R-p9Q8;qkAO6_icNVvKCzv2F*S9755eRi3#FIb(!VUViN+wpo|N9b*AfkI7nZ_ID?>&@YV)Fq8`nhDD zCF}6T1V;&^6myq^fP!8dBjv%A^BpcwjxD=iSUSemOI^eT;PjtNX&62( zJyw*sJISl`Wf&`Dv{It>{}9zJMDv?4i@5eYor4#h27|l)y!`ebp6>3X@0T+u(D=HF zZmRhr_%9%EbAXVi&{ZV_KEb9qP#3%7$Q7|uW|nyTWASTZ9UDy~52?!a7I_i7q{G>Z zbLj#o#YtW%^haATckc4NSi`H4T9)B4OU2J%$KBn%X`#xKDOkTQ>Cp>lE+`inh@fB{ z%fX052Qew!q&i6g&vhWJkp+&NV>-;s?UJ<+_1;!?-;en$_*IAFCijg2CAgM3d-j%t zE8!*Ebn?V{g9bLua<0uWv1J8&H{;~a=^SFyIV=p}QwYWfkeoSWz3IT8Ov9vdc ztjct=i${*d+%dtSv<6?B#P!AiC9RW!PC4}TMVwg!?O%*eUH=vNARtKX&O&I=pk}o$ z16P8Mzt&ec7d^i$?ImuO{$EMrOL-lXVPKQHKBuyWXi3Y&9w}&ok|kMzcsiTJtdtJ% zT}R|?JfuT!yF6^@77{2R80P?VDYlrN;O z{Shi6=o%{WPfmDvf%wa96tsjqtcw*B7)rU~CzjL3#~)gLQD08D)hrw!_?a0Nv9zZ> zF^AN>eg1eJ)a@Yi&+T`{hd;tW2ZQ&lB|dmpFRqTjLzGb0dYHa{lfk<}wTiJQ&iMQ^(%>$Y^{h9D)+YxMb`4E2W`3_DI^T}nO6V)T z<#uu=tFS2P@Wocp5lz(hDGpH*~{3)>TYqNvzP2ai5dEkxu`lmFtZsSApb?C zR}#p43aN?*F6J~E5&(eRrNxAsP@8q(TK8}oph^q#0Koa%0N0#}>Dsu0C26o|TW*<# zE8+CBg{1->3N4so&0Fs#6;@tvu188?Z_6vZyH=uiy^jWcd&Wu z+v!yJIP9o_AtK(j60)4a|LzR_&e^4ybkKLD4<2?HBrISbo`RH1Vu60wODYoGchXHw znf~+DWJ}`laEqQk)|2`LUUI*14e|bd5ZcgG>&qJ7(JXxpRVxI_<@hAmrQ-LJ=ek=4 z>ZJ)K(+E^ioK$8AVd$hcmb^BiY(3ljEeQY=&?5LRxY?hdvLiF-5rTkS(qWzY|LqJ* zER<&lQ2lsRxoPoeyh89E{=?5RI7JT|MX=INR3)jHyiLH%%{@n!T6QfMb&U93s)T`K z|NJreqljepl*@m&I>Rbc8C>7_e4v}`?H8y_&WB5@M_18MmBM3Hi|bil>Bd+5loAeZ z;y3tek1?ulnQ1NmC&7}8tE1%c=NLJT4;2{<1@oo31hRYP>Ec$}!B4D(1Yk6fEOwJiFw_59EOf}V~kEjy00qmlgmeXqLn zv65DKFhHRj_#3~? zim2m`C96q*<5)us+ucv}=0|=Rw8ql=&tPg!(1cU+NRjFr{cpm5B$yK}3`*xlFvsEJ zzu1+(3#p5NlfE2pap&E*(OM54aj3wh===|&4fTqN;-kBMzg4_G$v*gcC4M-2@C8KW z-Gxn4%P=@18}_Hi{`=j1sfB*`xzf&XTg&xtKMLx$oQhtIAWY}XZv}jw?7SnDIJ_@k z`RpN8R2L;ID8|ldI$dHN-4e9LH>Gfi;fopCXlHzOQgJ13y(;!jBz$AZN(!bOi9x18 zhUQOtUC#fyG0+<@&{LT4{Ccj{#yA1NaU^=4ud z`2N8}cX5?}>0OHL!s{0rGVgr=OK0DkF2F6?aB!!BxVW=rVnN}7A6O=W-3KY)W1q>v zrysa$UsJYvxVV>gr(N26W3$w~HVOGoZVgTIz}$*A`RWDa@QQ}(r7Y@w%DevI1S%$= zsm~XU;B4&HH4BlBvUcS99a~C1rjir2xPgvLXVq)Cvzt@MKaL#rNRz*dPTL{X;};U5 zr#~pX@&9rV0FdNM^56PJ)&>W%uiHlFVPxr=fjn=Eg_;SPWyNYA+ITBNR(Lkz>nWhN4)Hr zrRr||Vw{ZqOTqSQgXs5&L^AxJ90r*OcwJPL&vN^WS<{4s-r2_sHI*qkL=TG5DWXZ!d6 zJJ4wk|E9=w{=MDf5OZZv4!^#AtZDVf^XL6-pD%fD|F|SEYh!Y{d&%~o z_j%OCODAFsCyU?U1-p6DZRzK8By~yYuiDj_KUeqm>P|kNv#ecvPEUBgzEb()R0&=j zhVR$AcE-P{KdN{&uM{AgA9kI`Z;74p`RU5MwD;S^v2$g(@C$6Szjv0JcedNPsm{7% z)0B+InhwTHj@o8bXYzFNwr5YSz(^FAeRf^7_s?E4ZRqMq_B9c!vv*C@I2Fg|r?9$M zzr98D>+_>>J;TlWhu@lQcJ6I_it0GuFP|T+eY<%59LPd}#p_C!Zk}KM`t5X&CdMn3 zNpb6RJ@!j>mU8!lGqI_^jPN@GN2KN~pWl@?eturtN)mRkg<&>Q#~dh~3rKl$wH?M>OPdIwyHA8UHis&h)VJ9D4{KP3G8q@KUCQzl^bzM7+(`121} z&wjn5*=P*SwLBAUy<)Tzt06UelohLVUKJJwDMLd_*6~l}v7t@l5ZW|-Np=3pb5rau z(`jCuFQ_IReiCqgz2YphJZJ98b4EGELt8j`Xumyc`#O8l81_egb?(w>#gQ*h_&pn6 zU(<8-&Qv9l2rUUo>EiP|kv0J(<>+a*!m^v}*_(dq8qL?Jxl&I*vuP_W z>yS^w5Bw>``G`qYQ+#z2X;yN0GQ8sS>M#5k=}H}u^)>wDq*a~cKQ#oIUbdxo`Sh_E zMAI$qu0fgOR5)GOD^ci*V*89k(|A?7TqV=YDvlMFj5V)o>FLkaU`(?H_FtEAdCFse#Kk? z*4B8?aNmesihFW@`p$4>P05&en7p8C8h_Z7h2em8hT5(2Azw}6V0Q8E=>tZxv6B(I zlL~f^EO|*<*(av7=&mUoQz*`E3pefVpmwM&PVr!yA0rqfiuG@layw{$09t$cld zB>d%3+xhB3CuQjKlUVEyRBE}W*z8^IoP*8_Sx;H0{ClEDxA zL7Q!D$rMkqT4a+`>T~>nv=EBmcKgZg0NzdPc1*;43`-Dx2QN4 z57V;6$EYbAcd5IMEt~PT+JBfFubjVwUEpblC%5^2jsN}0a%t;%`BAmh{y{ThX7)rZ zS0{jZ?b5jn_BDde@BEDTDhzsxr{vhBPHXCC4^D0$+9#Ww)+m7xluYm2C`i_xpXrDw z_6E6hoo{4^SsKNToNp^_Mt-lpCe^-jG5d`0+mv(fp6=nE5vVlNhEEF&+5> z(NE3WwLZwRt=CP*tlLe*FFLQrA3=``GH+O3vAhC$BcHGs{3yb#GT0c)V!!=t$248q zi*_Y#eGIK{>zyHRb)krV1)>pRsSOUZ9v>dRf2rPU(u_T_|DS1H+Yg-%zAS7q2iSPo zWI^Ap%8bwYt9_Q50t$a(!bEEPX*8&d|B=x{+uxrI~nqKJzeDWoNd6qWO&^)7GPqJe^F|dWOwq{ z3;K_Nc2Uf3F81SKrAHS+8=oSyS{Uxvv4Mlk(ntr+0lwxyFlhnN#wIP5nV=8EVawn5 z+pT>1u=7mMR>7nr&JuL*(0G5n+IB^HIbLUhEt|dVU6vowMID6~I+V{CW)CU4(GK*a zOy#hXcBBS`aidRf#?lEW^Dd)9T#OlIigi;s+F#kjEag+xvhZ8K(p8R`0qnMNba z!z-x#lpNZf`isu!;}b}_{`z6zmg}ouu@F(W6`*%=pnmCA%^4-4lU^ug{37p+Af4)sG%Ig5%=maQ;aiI*m<4Kv9RuEfe^R)Yj9FjTPis&%htqmgSrGdxNBMpuiB zu7|E9r{*KMn?XY^3b(}nIJKOS<@`fJDt{6lNr_aYNvibcyMY68 z@%Fhc@RJh^Q;MV9s;yri1;QdZ#AOS-Q5ShA?`~jqdLhO+Z#zV;A0iHcXHtpiAOv)n z23dt4hF(-PsL5(UCj&x;y>( zK)&+F+x-n|9}D%H#XP6lS<&m6rk{-cv_`>qw}H=TIVo@PY40CXR11|ScBe@R8=&D@ zjwCa&=oLM8GA~Q0PzgK!)-@d_>mHJj2?OSAod21#=_i$+T@1t8Ho;+%dYb7oN>{Ab z?Ic0O?p#$@K}TRsm?<5s5!hJeQ^xm8Yln@RJT_%V1x7_N{0Mhm&)Y{?%Xu~g*%&V@ zVtFBK3;%`Ma`|;2-nhLNiLr1$nA25nY3#w2#jyDELHOzxZC3DPG)+*mzf!zkL4}rop8~`qz@UjWORfUb>FzKmOVHHndEB5(cQ{8vXm}}vMg(e34X>!@ z6O%sRBW(H%KLZ5xcQ3~mI_8p_lH~#$Kj2ahFrjE-TMn5JAF(ygSwmg0w8Ty-jS{m zH!cxaEL?T>pD|{+8%OC0`$O|OtYHB#>;yz^8zTxYh3Cppy-ykIA!1i=rBXlz))^(w#ID}bbnE=ULvO;Q zOO+-bs?N?SBXGT3>(SG4GE&T6>Fv0VOUgWyo*cx&v$4)|8UEe|0M`0g$!WXJw-TTm zNI+lu|Ac#!;Z@qzWvg+g=D$oWnU0!%-~gP7y@^lP#X<6bhee40boVaR9d)#oxDkS? z0#wh~g*aTKu%g^=vMk@F93Ka=;dpQ}9O|faja{z&Xv5<6Zu*^Z>BwTM01H>Xad%!q zx{80t&VguUfBUe=dR8*(?@W$^eh-PXcL5@Y_D!2n8>4=1oW>7p2kZPG@hKAAW#AMWiHv1#3`GQn$NA^&hn4oQlL zMG4{LAm{x1RRZp{>;cX)L?udr8mu4a6MxrK(nr)b=kcZwi;4?|X2bTE-Q9hD*!~ME z6bLE_i|~q)8H8Jo^9z(rP#T)tCV1GOy!xA=)k{ zJ#GOJr=*B_08RrWA_i z>E6`JuqOW}t6`|WB{yw46utY*M#BdJQAe@jC*WC|7cRrI?4556rKG#eG(0@X*4u>>wD9N; zX@LJ0TUjal*Ju&2%PbX3n}qLkrI2767Yv88=z@md(s@j(nrh;u4A{!%rvJ{frO0}R z6_Cnu+10pH#KCK*OQY^w{OPPS z-?{6?Cj-p&6Zctw9^oJWw;L0+mxrV_9vURkP7o|I|HAXWw#Hd{u73s8Y-GW^z<`=$ zyZK~-0eqy$*0z%ZDjyf+KDifIDYj?B0>r}gCmN~$XbrRAJ;zB+*?eh25O}*1slT*Raf{9*C>`?Tz-ghrc!d-r zc~gJ~)763(ID0jJ&~)pcoJ^}4bOIicoY)FsHvD9>4~vn5*6Mi$WPmxf1f8l1o(`CQ zPNy25Er>brwAwA)o-|@~B2dYvf@{#u7{^5P{{0OI;QWYTTqJe|7#u`TErt16gz*f+ zi(R8^m86! zl>-Wp3$Tb)e6EG?oIQLE=QYhiKxx^*eEREIyrnD62X&d(XyTvzU&oH2P>w$}?}1nd z@6;D5XJfFIAhg3J4s4!WaXax$S_b}}J3kw9!@@;)miuk$2|2GD0jqJ7DYkC*;iEO-;*k(QRU__`m+A;pV>ckre8XM~!Q0vFmsa~{5u zOZrU-B-I#AH)n-Nh1(`7XcB!C6d*qqVyy&ILVG-6G+7)$nwG7yL0LgyW6+j-4g0;}yFb`%ZEb4# zV|!-8x7}(3NH>V%h9Q(2&Gb)nMv>VYeU?ZH0b-JBgvxjcWCFvxYMtZ!C<| zlirJa)q^MURpS{}-VTcn?+W}G;^_>v#U0bs-oJVIxN9*t$-$6vAjwMEgsPNwHKGUk zml#$mT|WLi{B}5ywd=v_%V@%XPx7Pfw{dS%6U6fheFVj}K3)D4*Ph~G5Fq{3D-!8z z0P1xCTr(}Y3MXyTG>p6%R<;jeA_1P$xrp%yBmY|fgIE${%+7%nzc;5 z*|vhz#v&|JTGg#r5hAmFuJrWJMUFAgnTYeJn~f4bc~pXNNCSUHMoS|+`U{YlF>iGwpHl5u+GQU+lWlzHHFRg7`E^kAzV5hjofd2jVph? z*5AjmNF*3wNfOvv@!kC|c7~Q)?}o>g)9iI_dTp2PByHe4a;1>}=R9|_Q^2@6aT`5j|CPs@@B z3az}Z;};4rN+S))|4fh6)koG}Pn@D8uR3h4P2h@tlkYuRbmg4Q`_Ipt1#|<5c*0*N={<$kgUfelr%M@Dn$c z^=9sE6SnL&S0+VLJ9XG?-Ox5j2nK(%K*?}-H`T#d$tE3aCtBf!a#I?Lb*#nCF0nAq z*!-YQMAs6is{hbK9mkeHVLhJ5M7b!JIk?biGQ7jWVB=VzpYbx{!U3GPHBT&#zp$Bh zGA<+N!VBkvM@P5nuVbR_yircxLz!!mEW&3pdfa z#6>OQxlX(h2R`9QL$CN+!M9m0n3ndRvK@^#-1})U0>@aYy0ct~F;cVCsXx5Cct0s< z`CDLs$WC-Re{rKY?iKCSK?C=c<6jmJg!=r_Q?eN7uH%%ZaTpzy(e=*0D4v+LnPF7j zG4-u)9!7U-P3kk!P@|Evi%_VHz=02qnq;>C9s)c+f?D5D-2J)Kq~Im$z|WiY`QAPh zy<}`&0UX0t;R;2f&UrL&NbmVEKObVehV{9+3?XOaj6?$O;0iYx%7=38aH|ns7e+*0 z6Y<3@7!ED#_nz)7xtgEtishLdktUNA(UngEcCPED1qF?1D{Cv*?b}mvXu5D_p!MB4 zZ;&A-nq)IhSBaxFY12&Eabk3>7=}(_h4_IzisdsBX z^vGteNcB)-E(QGYJdPoF`}O94l}P~al@s5RS~*b?T1Tesi@(y{H1fzqYOX?({2aM~ z71PvEVlx~1-gcr$hF9tqcuvmYZ-@NlsRg0m@4OwEYQh@z@Ci;%I#!bK`0Q1`t9(SV zzShTb939M-;1>DK?&p+XxCE@+f^n}A%c+ReZ+PZstL3hU@9gjw**kh&jr|xC7Fc}0gmqfr zbIFEvM7opq7c&Xr{meU)cy=xC?uO}4zgbQ@I*H*h{+N+E>e1D)T`-eqGcDQ5X%^psB%(Rj^Q|74C zlw(`5`0Up=|-ovztnajM66eT&7le{I3#H&G}tu=1zu#;7$v4@HhZQiA|D z;UqpZ=)f8&ip%4J#P=CbZSmHIT2<-4(h4inb$;zN5`<+;X|d>O&{w>$hNMR0%+J=y zmY@A@#6`thosmH7lu!dp(;x!jmo>Ko{KCqe49&$9A?f!yG{P~d<*`Wqr~0dZnB6xr>*HZp3ZT@6CwY8&_y2C<5cJHq__qpWw^OgJzQD||INv1r^m<< zO3t(ILwsR#8J3oWmU&Z9`sdV&Np?$GUmW;06LJy?ZCPhEw##&Bd@foIqS+= z$%#-Bcem6o;-MfNl&2os%=t%1#INuEAcIB~E^cESiEICXu5;C~^g#S&qoyPu6_G>- zfrE>fWbI1>l@0>GBiHQERs+o{G|i-h?rek0>*)>324yC_D-nb(I}U|EO_0`Bz`bEGj!{R$;7r6|?_LkIZ9u zG=YVXW$Vh6Gg_o>L-`&f$~!OnA(b(S@zlS`64cZlKXxcMOt>U)xtkRrsveD8$LLm^}WY}EQ=8oztT zKHby5E-6yIV^X{;z({Y*nLERSSz*d9%o!A{qarS3N9)7_h+?+cIo7OsFT8N&j_2KEt|aBs>}hqPmDTD` z2^k6So+^0qUSOa*3_==dr4f?MNXy$B89rp)bj%M=6kp@OCzb+TQ9%({QANx)PTrC} z#M2!lt|mk&e?4syJ3?%+icDVP)tB2Sy^alWfit35 z+7?rZ)LL^*PL{BTdnblXPWAq6^GwZRv;dmX z>Zfn(@pc?Pu;&!(AQkCigGNa#?80an22%o8N&4xDe*O2)H5kz#%Xhf#>$LApa;^?D z`pin7V4^IqKl_?%afeE4P6)r(fzP|ptDU5Nzwz8nMu*%Z9jsP^C?+4(zHLFw*C}fb zqU`%N2kF7%SB58F zC593dex>%tzT%ifG#SppMuVhTdvMdK@{C&Df~(V9A}{Mb>$fjaQZ5-E)BG9A3%N%f z!_6;E@Fln`_H1D+RWzduffnu}UCkj`i5ui`*7RRs!NXyg0?hr;R6YK<`o^f)n~pkZ zn*)%15giUuPjv_ZY=)&D9C zV8|k;*30Xm0*`5b+=@CghaWL?tC!%ia&nMFJyZi>dKhU*IJ}(5&e#66a$qr8S*d*L z_&vUKZ(_5rZcR`xM{AN8#Bb{-@RpdVZiAqyZ0Dk+CYnJa=gr9)U6=r^Zzt?b z9shhT@`b%njYVUr!BAtT+1n>Xqx_%c+SbZ79fY1AtXP85;Q5aLcwB~3 z5@y1Hk%?^<7)s1iAaJ_o+*CXt2wL;?)X3mTP3W5bds#8*hdCCYLN)2Qe#x!uu)}G4 z&J>d}P);RvNGgM-_33%Eq*#yGU|QpaI+1Vr3~Y*!EK{N@kkI17pM6#ww2z018bCwI zzAt8w7Si1wjEaj!-#M_covIVj&p*sIQArvkdy4Eza`jW1aGFHsk&@u%Q)_97x+mRm zjc!|`B91MSVYG5_f?CaR{j?lD=UZ%N_ifaK+VG9&kGZdnat!psguWx*I1qgmve<-L z>*6cjw8z>yM0P8G#}=cn)8u3DRsBoz^*4nePJg}G?|OC}F>ZZGAOev%EE1#^zTurtTLp5Bj%g37leQJ$2>*iCn5igNRV{&4K*5#IiFtZ;(tMA zX8G0`e8wI1_nh4CCHtRJ5&G*SW`2QW-yIo}CaXoxL@ApWU#*bH?D^wV#+S4rP4E;C zzfD497(uRmx3RIRaW9Z9Wc%*0sGzG$Vm4x_loa8OJ6=~H!V%yBQ4V~h@yATYSkAiE z{vy1RO&+1TE!?#}2(mdCCFf)vxL7z>C+c#3`Zqapb<005HXmx7WK{#;MD$~D5(`8j z7kR-hG{7#9FG6a&Zt{_!$u+OR8JmOx<>Nwt_*{WgeZ}aL1?2*3S6>0em z5;DU&#~Em9x{4-3S{%lj*xTvvcs7XxSt^UNqoKYiCN%mxMB<+Sw(MDG#lsk+>`*rT z5l&`)A}*rEFTnAmNnI^bzG8_rvfEV|f5)#QtQf+gYk~g4dz2#jL>-@Qup@G25@oTk zt>>Ot1jCY)6=g&WOTL8sFgENa=!SR6s4fr0MkHBi>Ja_jlgp6H=;br6#tTc<4POA zj14;Wj6$Emy+q#dSYry#xGj?=>aR`dW0?&d5U5(fuiRfxgw`VI|LG87?M;8OuUU*y zt|`eNJmGlBiHx|a{!zQ&M!eNh1RdFQnef}!+|rYHnTWV#nFcSH_8p=FNg6Hw`iznZ zMRsX zMNBJt#yAJ${o_|KmhCc$dCjKhJp+%`>Cn?JLNt%?wg560G((S57#Fw`6PzA)HoFmY zs|T;;k@0-x@-!SQ?4EV1ucIvAu&~$%2H1^^5ehjQ)x>&aM{eoC*fr~hb2kkVb>L5a zzS1<_KM)t?V->ev3^aFk`#>8sxESz zp`R;-2OPU;I_Gy{FpaO>$Ys1iTr+_R>(ANB79hNpXzc=mnu|kiFcsm6`WCUZjwt4o zEF!|s;mEehdd8lv(%;*MklMSlg}E`>3*&60nZ3^8V1-*mP#*rUM&J*g5r4v}+GCn+ zja(#0kTIBj^>Md^al555On{cc_v6^az9gpw15}AD)|Gb3v4OigOJyTUO4jEib$WDz zMNvS^uXoAt0A>yWj=#2K!-W;E88;QAOoLsIU$KQ-Qj3E3AaP-~Gv0{CW_W;A`|w&T zf!9UITuOnL0tpgO&?z3#5$uo1!SVOdGyxqj2bkUFI?bZx+8AxMFS&_JjMr%;IJjRC+HEx|zMJ2*$HG8wi0!xnJ`xTDC|6ek?@~Rf=7>8ZqDfo=)Had@)nBmmx zXJutpE6QR>Db(C>#bKZO>NH_BGyRV+G|&w7fXKQC4VCQtA^#}h5qzcQ{eo$QTrzx4 zGQfJY`&oU?pOV-pGuk&H^Wdj5o9a?B1Pa;ey!eE2=4;n7bURIG(mq(fs+9TYUdu;uw4;cB;9e@gOq zkb9G{JrWFt^>vHXGk$aPpjw&aUPts}SO_VtUHc&c*>lLBg)9+D4%4qAW&mko(h<`; z*}Ut4GMo@$guXCdF*C6-{=DwW4gDg-s@kzuKCv+WOM0`8W+|}>Fa=el4UBdAC+s!O zw3TGXJAWiU&NDjr&^I)YT1I+F=GLJCdTtw>k4%0U-;0g)^zr89>|gdq#i#09re`&N z@(Qj0kxBb3Fp?VcXJytb>|8XHq^bg1^~;G`);FP>`47lk1Q2)k1*A~HU=}bzL61=n zHTLX!y>JQyG)NA^XJ;Od{&m#ZjZn@(C_J95*HS};)YtFaziP;ngdy9e@P?_UgGX+(5_U+=KVb%h~I2AC;jbS=X&d)heEH3DazKU;p(JFGkax!&!>U8CJ7Cja{-&E{KhI{}~&0UL?a^R?h+ZDe8IY3Wzb;LMQau8vzG zpl^vV+5o&`N1d~BkX#LqkUcyYiI8*bOm6u#l$ zn;)blHI`geyfXP%dvl7H5cz~xdpp%h#C}DaRjB~uvL%O?+V1tdi-wC(-yrQS{0*Di zl?)}K8V^~oTrbgaL78Y{>BTt*GUDRy9Uz2`&eZgOu=(mdKZ)-p_qxZb!M5yKkycRm zG35~)@(c6_IPbU07{JU=9{GIhKhd&Vq>0P*5~BLr?^PHu#C&e+l)p80GB#B7T_ zrT~I^*J7tpE`etXpXo$lr*75t<;)vNm{ax+tvOJCJC;#R7E+XcGoi(}zu&XrN|IzU zp;Y99MFNzjHuNJVqs5<^nCQ2@-##?J(}Kz6m{U}85NbGlN!SZ+YW4ovYGpC9 zkaZp5=p(Xz$yZa(-YuRxji1gIX zKRSvt#f}YT=L$&+)#|bJnaQuky_MO~9P0>JihFun2_s5SM_%U#@xoSTy0FRf^U1Ta zTDKpQqmUO5K!_m|=5M!H2sDDpdW*)QDZ!0gYmmGpYwB0_e6V#th?=t&3#2t$B%SZs z4hve=yR)p^W0D7I`Q-A2DpK&o4)%t8Z-QxP+5sSz^R|EF(Mm7kWynL%a(>GB#2u=x z6U)nSro1pXy7l!JJLjR`^?qhqMh5Cm#*)X+#mZoU_09F%LeP0by*y`ZLexUA_9e9X zn?+7Jo>+YRA}axS;pR+I3OG_P(Ht)e6j4)71Y5a3pVc7xG1??T%$$h(9BK3i=}8gb z-B4;fda#l~sb{J}s`*MfS%z<9W3g>up(i#jQ-p&rSplI2JJ*$KS@AetCWVoeu%$1p ztiUMxjdo)*$Bi2J!cI2d1u-jr5t36A;AdOC{|VnZPoDDl0L`otoLgi2UdMs?DG1qnruu9|6lnL zb;!Eq<)gqHI?Re;RUfyW*GxDIQj9luULIf)>!glIwCv|zs#zPNY=L@_JGok z)Zl#-e}p_*sL1nZS+S53+lc5cd9+86IEk1NtoQ`B*V|*U5Xjjs{O&_6ER4IEC#*ypzv~=4%)Ss|71-Ffl%8`C^DpD$Jq9L}~8?I@_lF;)iCFl1wEJXbzV~r?y3H~E95E98Nb^1^}Km|N##T*N!ljd1(`P z5YAr)e`%YsQ(HL8^JqTKIg<~EkIahuqprvqHfB-pw82^#KZh-s+P*7#;^Cxl$-19e7!4)ujmUH_yDMkMxxXGfB zqLAQ4QE@Ez5JOSBfG4c${r25In#OKCY#Hcd&aI3%*7&+d^<<62(#fA;_pjCv}}ctmot>P%z?^ zKW8AdLr_sk5T9EN+XHY8Z^?rP|E`fhwyfw9Bef9023e4C5i2celLf|4Xji1(sSftR zMU|3jLwB`BYbRd0l6skpm(X+KC#{lFttw;U%v_-!dy@z|U&tRhW#aMftdNNUK&mC1 zxhU%FDGV5Q=!D=vX(LWR^oiV|Hgv$D9hFBbwH0r;V0j1791F`QhOnd6Y7^ClCN036 z3S?^}&rRmmCA9y!TC0A(&hlI5&keTpK;r(_;>)GEe4?cVkdf`gQUkElOdp#-lk=gS(oEt6Y@f5h+=``kY=(!C#DKJ68dQOtV1;e-gu?3-QY)nBTFR^y*4 z$Y4wSTJrt$YHa_z5qB8Dl)?s%opB&7@(i2K<$5(uR^k_~9YL-!0O>nBKJBTG>hn!s zsU4k7fXoS2y-F-lFZo;;neK@}#dWb+GB0P(hbDaoTjR(~hDZE1E6sJpjFx^1B>`0A zilyTi3#%SNiSD`jR=3L7%K9_l4k)3w5y)^7Pv9o7-g}R?gWk?f{lWL~$r9)OMQz4R zeQ}xUk^OKs>4-+v|22q#s^9Wry}HXF@k?9DJjOtxG=9e5{pt8VunEClA@Y_ps}~V1 z1k~wzxC*(y#w18f7;}|-MQ7dOxNy9!R5XdrQyZE9AH{!?vjcKf+?w5UZ3c z|N2)ZwtTmMjlky_oIeXJ@f`(B<9sm9S7=#k*@1sPhj;uY0dUg^=0K@-LMoFiGX=8} zuX+lt3r-i~rX-O4Li>daVri~p`@Z6#+L8@eI~3fPJOgm8@RkDdzVX4f=>xbqe%pRd z2(*zbG{_?UjCKAI*%C2~Tm6FqvYCSm(Uxd1K&FkVUKv<56#q`Mp}3(El&IUg1^|uH z@lXS>mTFMFY4&sp|4f}olJ%QF@=loqb?*SOdMWCElt3Z4&3NYek-jj@(tj_!Xy0aT zjki`_@rf;=&7LN4zrhQX><*nPNTErGMb?6NgH)#HXCdk`dy_j7t$j5jLbWl}`UC@j zF4=VbW+C{^8jlDK5u~Re1VZ9`a7gnyGC+v7{ZDkMSjA&Ha20C*2wDE#_%Cx)BwA*8 zz|wFlgw7Vl(WqumP6dEqM_}M7cE4d*V~W2p|9$;~lnnY*7$*7>{(Pf~&9BG~X3|0) z!2;sKjsG|={r63+JYlj=#1)T0cg#d#he0A!Rv`kKTziJD3_62wB#lL?<~%8h>I3U0 zSW<2j0LQTpnHmbc20=YT^h#YOFKCvCHi^>>BI{5tNBM^%|EaTPVT3Qs6iKuL4s^QO zqmd4OYuyUegl#7|FD){X{o^e6R7y@gk@b1P1&s2Nx(qWzX;FzFfBPO&tRv4y)rP{R zRB&H)8)CU`PM?_&hAmF6!RxwT$7C@H)xUMd>fN8!d3J)_ucL}Wo7+~$4sn*Cp`^jV z#(u0=i?mUUz7*{o?J&~b_v^Voa8enen^}Iv6{G(AJo70X#L*dq-?e&)<@_D}EE;xJ zEZ2VYeEF_GGbzsS(E9iH&ICh1`#BhnWtv-%%S0mf|Fr(6p&GN*+_pOljwXZufgeNN z;dr)$aAaV-L6k&@jeZj_=kjuDCz7EmAF*k3-TG;VdXJ|k_Cd9`79|DiqIv1h_-BN1 zNvmlB1x@a^r7$)-(E*17zSLEjM6`*jd;PQ=CmibA`mHu4M%&m&aj-{bEhX9V-Muiu zP=nK|(kf6}k5D3A)O|2gGMQRyesA*%+b(>@jAY}7z-t|U{hSHo2yC`J!0q= z-XtwU1DsZ8VogBSn7On}$?`C4o|K_Pc6X?f%jn(gxhIUc26$j1;?2u)Zm_)D^+}D{ z>}s8T{#L98zVewjb$l)|jB7y{6b(U?89!}YzL*THvTyO_KAPDzK6NP@&7UHTm z%WlAZQR`Xu!J>0MH*_Y>7EhWCFFo2 zvp#lRb^j;YH1e|@*D5~vT6t*KS|{G}EYC}p?=%cj_oQF29Q`BpP=*H(mz0)SV8}|V zl%rp!K8=+ZzPgvGu=<&+QM9wYnEf=>d+GKEU|a$o85aZx53x@Te~X9MJo{kun`W?M z;Uqh%6ZJS1`PhL_^MzS~a_#nAm;*zprueVuNP16Icjz0WEm2)cDfCFvw<9El57l3eZ{`?DkR6Mj1J)_=r zzb@REbM;MZ@5R$+4YhGYl!&5WS7~LhjXVMrgca$zn*bP#nGiiQY3b4g-R9eZl-1|9 z{S3VH4Bbr|0i&CXv&DHwK)Vp4v;dzfs7|C_F#!HOX+!y7n^(xgJ;;ylo!#Sj0$I}^ z2;B%Lr{_0%mA|d|f3W&L76M=vd>?0ls-iYpK@lyj8V{I9fTA8D!UyAj=hOqmZ6I*C zG@_JHpszWxKQ07ZYj=|bY+HGUqQWKdz}1l^@jb{b+SxlqHFIx;NBCf~ZzS-ol`}Sh z$saFwL-i1EDqltnL+aOFl+>lDL1ymYCphY-Iy723JImQ@fZ$lMd?TZLb4!C%mYb_$ z0%-VP4IT;VstqcQr2jz*ddCKoEm1%*Ep!nAzjIX7A|w^ba69Glh^`<*T>92J#2 zEj9-|*P!q!9o-wM6eYEQbc}k=Z8TP2<0id zIeH?lDXx#Zi>3rFqnHd2E$WCooAee@@6#;qpacp*obzFY?SqAEjkFQCMI{2k>mG>X z-{%mz^s7>z3D}?)Oi)Ds`sHp)x87DQBqs@s4q+k8)mA@C71y4Gmd}`G-%_N9prVRH z)71)n|ECz6BH<Q(RSGhHu#T=&Qyl)Md=FSNeC|&JIu+mBhqBez&kn?C{nnY3j#u=Oi78knLf{%?~CN6dYsT5VL?W_VyMER-0q*oL?ey*Vodxi zleot3s>&sNlq@cW^BKnpN5I?U;K1i8%J28|uZt5|nbm!bh{@@f)|wSU4HalQGQ*Uk zyE`!C%qMtN$`ldMXIf18#^SvcNTgv?TpI?#r*a=lpQLPuRHmmwDLsQgjHq*WqIH%h zlcB>cG7a}+UBp5FR>nsKgf?en%dc-ZTx{OKI=-iMm5camvNZFO30Zs%kv(+Lh`^VF zH&#QLtljcClh=!B!+hl(a~N^ZY$<=sqg}R2WL`WYr!+}OAgqn6;9-3hXhXO0U8#i@ zUmxL@pnnBt@D6y9A?4C z6w!$U@IQv&^y9+dLRS_90uQ1-kX|p>4)soMZc@1e0<>5c(nn-wgvjoY>iYZAYIk(R zvbLuF?Q7}HGxl&tA$qeHe;2)D#bQ$1W8(|Jme(HkeWb{kT2)3HjpOvgsO4*WSg>-T zAf+wZ&nKsukdTD@EFQj6Bk|f_jt8qAmp0dS-I{QRXhAq>Seh?s#!_ye8GhnxTW?|u z(|Qnrv}Sn2kt>(>8V-w2JQk$?Ahl6&evw?e2F65z@-B0$-1d+FX5vR34MpN+HMS?* zc&Y>;D~p?&G1=3f_~#pCg5UflBb7?yS@&kWV71P#fwl`eD?=h=hY6BpbQX5k@F3(aIEf*LMf()1)j#!Ny3iN(w);xZ7pq}s zkZ&qG)(7nYwH%&Z)qKxvYHWx;H@&WE13<>FC%`ef!FUU9 z{8V(E2J*SJdN%UiC9?>?V0BNfDGi`((D_qv<1kmzFrs=fknp;L+}na#2E+s{-}TsM z4Czlhv0RRl!o`f&touS$YlvKk;IX~z*JR=JpI4K?LGKok%SJ$lDEXF#q9keW~*e!(K+A74J@3G zfWM=P>%^CUe=?jPkBEyG}mR z1g%Hx?f-MFC`3J)G^|jPQy~9=K;R=mrW<47ey9%IR{Ym`TST z?7`+rP`J~~S<%gsf3!Y`H+L>hw%U8|AT1UT>muahXgZzzZ250AxIMYjWzmc5sE?x| z^aC~hEyk`jy}(MS=<0mH_91buL8Sx#=;%1Zrpc`qFd7c!a2}0w+a%JK^%z#;mrC__ zAh-W13-SH#W+Z7fIx1mj=*0wOvfe8h&2P^(t6|2<6-L`-w zEdnzf#w7+v-Z)=@UPT^IRK_h+i@Z$n^(xy)QUE+H@B?s-nw1h;2%>CM<-9K;*hz{K zvW%79Q5+k?x<|sw5lBw^V&oNzZg(P8BI=0BSNy2=Nodt#sFqoYq69Uqj2=k8si8# z=vi=5G^yZqw!cMURph?Dzl8>*HlwcOrF__e&&%fOmZ=l+a@;{TW%OC|!?;6+Es z#PfP&03Ne{h1EO-@(!)37p3c_JD6tnE^v6V*`+#hEBEh>Hx^@_J5tAf4nHUED+`L0 z^j>{kYXx-Xb{WcXnB%dw^+Dg+H*xm42W>;4Ave=sgF35pP~z#wy z4etElI2jgIje?e<$omn3GLPBVe=q(KsYilCWvK6?TQD=~BiHy^%nZ8KJCD)y?}nQr zls*WL{@d4BnWK8~{CF?D*=p^`?n$&)PY2D@yazY=yNMR5qaLXA^_>mP&WIu?%YS>V z=~<>&*&gjFCQPdr{yXJ4U+`%i7;X+Zf-daWmj~ zlh43$-~JGu{nQhSvE}A=kyFODv}JAiR&i#!rMs2*?c*1n&Oc>8-*5T)@b4+j`Bcj) zOLuAsWz^%yc#XL3uG6&_Pa4G{b2HQIdg<7mHO z0UGsY=_Vu61xMhjLrTtbS@-;HX}-@tK@(l?=e_CwoM_)(px7kh+7rNFu*H__2ws;b zY394@bK9}wSOtCqZCv_1$Me!A?kdA2~&_>F|Yx94ur zy}OmnhIG1i>i7E>N^fOS&T1cUOxElM?rt8Qc-&Gf+J8z+F}mu1_}pfS;B&!1W#gu6 z`4{73^9lM(Mikh?9=JS;xqRMaP3&=@p14D9g($|(6DLTf2ZEKe2d7-6Po|_mf`5ruAXFx%Px1jVmgnqPUmsH zV!@ITd$J<+PU!=Qv)wb3=9AjRlM1bp!{$TJB~goHKSdGitQtu#A^L}j!*;XM?g5(z zjRy30-oHUXZ}YGy_8FnOR3t-$GSW|h_Tk#5wPZFbap}GQyqZPeWG1sAn|4Y58ceC` zlYaE@_u;qu&MlhP(HNTNdibLKfM&^Ii})F_9uJ4`;L&XhL0b|JP*CC?=4{%wt=b0; z@1IWFEk4gGUT6hw*PlOq*QU6eTLx4PsdgUj7^a`iv7dVAoyMc1w|o`;mk?PDtvhi^ ze_(ik?49T0-TfiP^;|!FoZB>$D;qrm3&Dqj==bc;F5>`Koqadb4O(94TmD6udCK*W z+sm6?vOb9xDf)-A>9O+WNcfUg>{2ACmBD)qED@jcDWVcx6z@Gg%i^>%MxP6mvRH6D z_n1v#*BuNLYJ{4RA}!nehU$CVarbhU#hjLRZoPk+nC++PP@X??Q7)%{|p&| zgGjr&NI!n8o9rqXnL*>_K~F0W;&txD{{ACeuln{N-byAxvseku%;|PN{=0i2CN2<{ z>~PrSr#Fs$3Pgxy< zmH`3j>m%6xE6az$i?A7EWi9Sr|K?ELm^bX-lrvAHl)d4vgQS&>eqgGb{+>CUn+Wl8 zfzAP8MTSFT5nKg)IzR>S)u{}Be7s(sRMx>fM|hhR-6;9PaQBBFzJ(foXO1P+5Ao1Hj(HIi6ALM(3gRGq#A!s?HJohuEKypJ6O z7d1!_$X?3yt56M~6?xsS4(H`5c?ngNE0Z9lirO<#NYnDEDIe&&qw+&bYl~>MVv6XR z)1wo(f|*~dyoiSAH{1)SYRgu?tFAnqSYobMDMof>aUoHqV(sC1gvdl-8X^Xml^ZfS{lGYeuLk%qMqpxHKQrKmEjOES#| zrV|Yg(n>ALg`IX<2vGLVcS@+OJT}hI)V?G(aO%#|ffFZG;u4n5&QBdze0&n`{$~8h zwQ6CIPs}qV?ukYRljUt;W2?ZV9GUWAS6MV4gYs#__yrT(= zmGv)aX0&!}uQ7XimPuZil8}$OO5Ug}6RH8BdeXiN8u|K!-pe?!>&@$}l>laV(xO)H z;P)R0;Ks&C`R8jRUQY}Sv&s}+oW-xof+eKpBHwKf_&gk$o<2}(qgFChmoF)Y8g5Q&TiT0`S)xX-w{JkCYGEC~#HoJ#LwEEaE z`{PrmgOtsVd0`>_v~Sl;$1;nu1%HH`tawAAxK#g5qT#{K4LGcQqB~oM&X7S7k?WZ3 zJFCsR?Wpb}7PMV2d21>jwQMNz*UBAi*}LA#eev1drir?d)f(pMPmH|nYKf5tlK7EQ zoY8~dS4n4P3y!}A2`KN_%`a}<#b}OcC8HNU(3fS(I9RI_WvOAs0b|Z*XnEN zz@fmzz7kv%3uh`B2aj*0}(>F45&d_^C zwu=JyMDUa+axEd#5$(agDw?@0Qvs7j6Zf%=tZ{N=>Txbs7XANtnevcrY_c|0#z6}m zpHBC>#TD2#NRVGX!(xDn^HZjp%X(ASsw=a4TTLv;*^fP6tv@UwkG>g}x@4yoe$gvr zG+-MnDM$@MNQWOKR}`O1aBye3ye_MhdBF|&sHHGw9mk{)9cc3lU`7Ju2CeM6kwbQ_ z!>1;CA~Ura>xUOv_sr9(o?Yg>KPvh#&VS$xXjP$2d}KqjwPIaL6 zp3z1)AGeX&7_}_y4!W<6*xH~A&RJi^qg|sUFQjvUcB-gb5KWbrk8{*6WSJhF+Zapu z*@2v#ErV*S8vCHH7qUBWyZG@F3Aq6YnW-imRkL;o2u~v;FRke|h(?QPIoW33U(^8x z8T6v)kb~C|BqXGx{kND|)RrEQd}EueiBfju(`Aot)^W43oo`bJ&9sjKG=U*VwI(^T7H#bTr|WD>yPnF zr}KEeI8fY*%rg@RpnRzqQgjxczvmL*A^%F1m9^Rn3P+!}UyTudSsRsLIG^K#{&EoP za2Dq2FkjjnnqNNq7CvR#iD}cA7+LpU9Gwg&D9aGUa}n<;9#;=fue?Ex+@gWZ-3xFH zQG!Gv1{?-}e+wI<#w@Fsjp&Y22-;yhH_dL+A>ZX7i|R5?A9fa?T;LZ~t9T(Skqry% z>EF<7!azd?I=Yz&u)erzqYm8A=+kU9Z6xryru{DKK=jd0x_MJLGKT}~?73Az)^xFM z*UWgJ%0+g($a`o+@YT^qqRsui?Laox727KlG-ld)sroqs*mi+uckc>HjnW}c@p4^# zy)!1{DH{CA=QVjlSOT=45LYc{E?V=S0H6TU7coewjAXvmF$l9ogPPdT1}}VSa^*+k zuY{?`DE)fnNDHL7ZE;d%H7!POt1lBCszK(RUkv*1XTlkXS=m3;BNpkRtgq+&&y`pB z12_hOso*Rg{Tf9gko?P|uc`7_nQ7gnWyW2Qb&O@51+7?D1~Oe%hw|Ab2P$La4L&0g zxC(E{2ZH4bJs1iQ#31^U-Ai02s}dZP)i)zZ`dc0l2oBg!ClNMZp z`Y>=>hQSKm@sPpYtcHU;g@>oHx6(B= z<$09Kx8tzm%q-Z-sIi^Lhb{HE8Cyb2BWX|5Utl{`u=5;P-E^4@x=il7Kp;gb$H3CY z%SwH~jQNUD{YA9p8Fdlt5Yqc)Ww9e*Ah)YYe(C3U8EWC9cTa_RB}=-qc1I0%0HS)Jq@kG69Zr(43eIS6~6>Jq7jxuU0(}{Y|vV z8bQk+=wkzpH2truM>>sW&TJfJ(cN4ILVeh_Vm*C-k|Tj9u%iI0od1n5QdRx^sVZpG zEDM*Q;dWEup_EfB7a(u|aoF$npC2-JE{#Pcdy~#!p`w;Z#G*h>TLb`_zdNyx2QQQyN=&E-@wy zY%KPONRA?1IvlIV24CPIYy;k~C21KhqtC$0*(WRD|L&(e4Mu$FAW`iy=)~jh820Pf zNt{8tt+??xxR*7)0HU%;tYk2$6Grge<7(OAq{)|x(b zEEVnVx3yy{8%7gsEY1jB1OVaHA1tye@hriijE2|%h}tB^Br~zDKK8-@#-t9ox!D>M z$GyQ#JM+*eEzrLYj*9@DEaxrsOzOsvWZuy9`Wkhh5$OFu$}K}=CiHb<^}b2qsXN3} zdyY8;(N7aW2CVVO%crz`>F?tX5b+@oJOt^lge99 zODkivK)^T7%yn#*j5tq1!?YL_0Ghq8R5qsYMEJSRkV(b%!|>c(-;t2yxw_t%9S9E^ zrAPo{kcCB?Z8~gq!2O+1#?b8&dlu;zDbT21OC1iT<5n2i+joRZz3i#7rayk%Qw6PJ z2%6{RJ_JDh4Wud9$ZW>o^>*TIHht3MitBW;r2vmkgu7a$g+$N6u@7>RK8dRuH{>G% zAD`pEe_b?o&dJ{TO4hXb>2;{MB93CUaUoH=gR5H#F2}R6!`)n~HReTo2PkoI{Jd79 za!Fr=u|YP18<4qTfOTXJ4u%4|L$Va|b!#F$_M~aRQW(kkyrP!eAt;Cx6_qp&CLvzJ zl$DDjsrSYeD1Mx>NmS+^RRnl7*pdB33njTLEAz1cgB^i+zfv8cNrVRUov8-AdZtHD zkNe1E_o9r0bf$vj|D)-;P%$LzFZsLo*WOJL60v@SHwfMl zat&MMA?A`!Lu((h+?Ge&_c#y;~Np)rltakHXXUDm5h{+Fq(<*mZr=?aH##a7b(4*+bX zDNPrIw%)OmH0NIV_0444f-k;JqoV5NyN)LiFXvwsWzW3t_bX`IjweX`=pi{!DXgU# zIEytUB2wr*b*Z5hKYDhsQqgV~*sdK*_EwEZYz`CU*tTDCJA5^|h9+k=y}Vex2j`SV zJ^MQ#bxEsPd^`@vgAN0b8u7bAuA-^$-D~O@zR528lP0P1wK0+T=#8SM^XLHGc z!Gxt5hF7uoowug%u!fp^c**sz;zQ%ns*KNcsR9mgx$?L0>(JL3dpm&RQWn(E$a~wKy6sHvA|FWliF z7v|7R+TGdc*}PXt*z+wUw+N#B_1vn+G>4lZGX;k-aFvZS9&$ zAVAQsPv>7r)y3x!^WJ};A901TtcN-(0dx^497P-=lYJr2MK|SJlm)?toEC!GKXCL?2XyvvAKjl3kei5O?2o8vDnsbej0-)Q`#(oYPKZtSH2!_Y8;Jo33@d-GZR~GxS#x^a$C)% zXK7y8zKqe_zIl=WO#h!x*;x}^(8`2nY%D=o8o{GfUoUvKONq!jS49eqcZEDHKKd@* z0urK+^3ig)Lgp5bA4QV$UWx=>hpqJ3D!0+MF`FGDaBK4xkRxGxr^@Sez)Ou=*mP$5 ztX}T!??p?5Fn5^GO|tJlC})b)7R>)9Qya6&*|9qi{~N3fPIH=%P7Yp(CXSQ`&TCRi zYeu47B_qNx#ht2y*sY!Kp}tm+o20QV4j1MF;H2~EXj!w`(!A36yLR7=RbL0AI=JDh z>r*+Mnsh-xe^r&s8Xa9Hje8^A8kQNOpzwYqx*;Z!0O!(!aJh)?F-!BAF*Q5UoOt5#8+Azy9r>n6F60KV zMw$Ou356Tbe!5ZK%=#NWwLdPgmpYUf%D2!J>b((bjMqk^D6lOTuU<{4@OUepK<%0S zpQ#cbK>2v|plNI|xP~XpodC4AOseX+uY;p4ypr|Nsm*E_y7ng<3mG=YdG{e%TM83t zL9?vzt6vfkJ?QlFuI0RL|G4KwPgH+fim!9aOAMPw@Ku`<3JKjGH4kSoR1BcT7Lw-= z79=YAMo=fdAB3WzP^m=4zl}}fT}aXF`a-~b2cvZkH6BIrs0cc8p`Tb;l2*4v-@3G;wx zv!ed$n7*;!q<&57mtSDFu670mBXDN?qLS&bEKcm-=!VVgOt5pB>I^~ zrTg{86${>u;d0mO%}rRayC&nMk~beJWWi$D`uaggz(u@y$eetkWFzkdDwYl}KB$w4$Y>%1WQyRDU0CHgvxG%Sg)=|=4dy(5o2&KZSmjQT0+S9eSP2?Aqz0^r~s z8Nr<+=Ab(m50PQr0}QO#c@$B8yPW|QOtekTmf};D$vMw(L%R9;ouamiLI-xQY*w2ODUbdrz*RF|@6?a{En`vk!=tyM4ey?In;>%4H)0mE*NZ8gGTdemA zy&e4f@vz@ZZ*i0Lc0d!_WCl~giLC>7IFiGd{gddmOD|qD9Ykr>jwjxtzS@F7q>mrC ze-E%6E%!C)q%nM*Y-IQVvaf4eeg%JUDg}u$3!n}6V*AWPDZd#Aa13785r%!-4~s)Q zD4*){kg!>C9UaD!=2l(}DT4SPKdCj|pt|lcjOMU?OkS)#i^Z9b{TuNb$VN_f#m#;} za=lSCyi)2ztx^$L@D=aWu@E!L}y!G z^o)a)D{__UAKJ(eNJ0Fh@A~gMa++r(-hz1cJ>&PV<@`A?`bK4EU2U)j*{gE->L zRzqU)ZORBb4s%;VA;D0gtH75_OoRuLH)H2J%q(K|b24!S7+WmQR^$X$vN{dBe6d$d zUq`gGXHJ`Eb$?oO$o0GG9IhLqcx#FwCx9#7O8C<+HAnH0H^ets@dD`aL>A5~2VRXm zHg@J5%dSU13QlXCj>YqtWp>s~uRg$vsK37>Nt(PBpBWqBlb_#vNQG-yPCHz@z{?!| zZqNVw-Y<1o1Q=htiAT5=Hqd7DAvIv;lZ%IUE+EY+bXhlkz56TLl zlDFeCyPiCFZMw40Q^^zSMhmHA(_1~kk}l)nWXpN&U&?o9Fl#}Eb$Fpf4a3_?r?(X> z5i^Q(wpaldl}@x7zhs0vLF^YM-B*_5*KiE&sCql(BE|wlR?vrEbFYz==XyX_$?}AL zGP~LCK`Xb0mVH9DS4E(@OHznA!(vz{W+^+6-sebS71IUNMRoCHzcf3F)wpAVl z(&8cW6OUyHX*0nRx7Dk5x0av6K6f_zlF@BukcOWAIi?XBXgLo@`#Tlvh|U_SuOO9x zEIIrU`?aW z4Go=j@!{cOFpzAE{Tj1Y<^&8%?BDa>78#??uuEb(nq-%|TkY8#<=B(|a>FTQ8o1;c z4$Hhh-ij=xcf5gDGQCEdJu@+1eIu+Rmy;6MAjt{sS6|<_Dc5Qo*W2;DAdJdPv*=pq zlb8;+k;Lrsdo7|pLs?soXdd-;K<+!0^ZOE}P_xK#dr9Y2{N33nCmY!wz;)&5Jo{}@ zD&LUU7eLtsJ|oh@t{s@k;?=jHEu*nFJtG+jdv~FBZ>lG z&jK{cc!eU`PLHKxbHSZ?dX`VWq>eKa%?j*({mA?ChV}27o(td^%xVfI+E7C1zPTc= z^9{Sp_t^>^nJKyoM(ab~6pCM*!?}*e?t* z<;hQnQ?4yfpW90=C!4yW0(uv!%E5X?%5-ufo{*~2JTJI}jr|0v+)^&WMEsx+4m{}~ zK2%CbvumkG;mST1zXWesio~7svr?WOd*YpP?ULBF2z=(=K=asuM*JhD_!#xv+4-=y zx7{I|fGnA|88fk&i~ z^2Lp&*YehF+()aG@0v7b#)#9s)XDyp%}OF=GY%H9ri%TqDQlNYc@RE5#77{&q$p9xnJqasCx_VJhOTRfL8Kanus-RUI-N(i_@-T-76%8km+SPt{ z`0}C19>#vPOKoap{;p$c=(m1gm8&qIX67Uq5?Fjs$NtX>q#KkU#-7~wMgy7bKo z68Ax6aP*gFTFXqsk>(5oA?9T$21M*L@Xq{QPMrezkl}OXdxI}xi&Vn4s7H{D`Aeka zUj*Z=A@gN!Bhk=m9j#n`^juD$B$O{fjk;4@ptENLoM@dcBs2@*pSW|d49HhU#xXbd z<3(!KKsf;x({g5Fcg2Lj8?~nd3ngTQ>!DiG@v$$t`skzhKMlVz$;pU}@afm%EE#)` zfB+xwA3<#Q3i?&-rMh%~1ypPC>?19D$m6)5m8M1^@@C$MKWo2KyesrP%pfXF(B7s4 zHp$HXD-!uHa*1WmWNk0C>x!&C0NSv#YQ!YMdfc01ZF-*~gUK;6#5^^@ROEXfOkBCT zk4c?ZN*5{Um3g8rfh7qS;nyDRJ~Fb+l=XV0tqsN2XSqMfYnQWh5kA$jGQqTJ71@0~ zoC8iQk*4uB>acUPQKi!~NlgX)N%v-uDmB>l#K=*s zunp#RmNAz>Pb00H!a5P0@D8OKdGFso_WT%+X}VC!gX#E0vuTOxz?Yl11$O! zqQ3jR9mN)Zf3X^>_r9f=-oF0X`vKQ`P0idWd7*!-?l;+UdK;6u!j+F8(O^-jDpB$j zzd!eO(z>L+`A6O2*Ms=y$sX$~@^xK`WlW}_U$Wo4c21+W7!z^08N`P~+`d9pFqdvI zj-hf6ucwns$A3E2>3`?84ue?_WIGD)dyq*?0eK76*^TXZPEVj?)g;UENtAT5-2R~_zv1&W z(%z1~8IbXzx5Ji~u3t#rua>~7bK(%*C$9J)6W=lx*&S!`?|*IFwD~E>mal7W?DLG~ z{d#In?}+@UR-jiLQ8o~I@JnD_y5Kgl+Y>>$u1w24&-4EJE3Ws6ogC0E@UyT9(3{n# zVm1w38)9yrY3qY-s)MkLncavb8BrJb0gY#+r6vQ*6ml;9Z*NIfg?|Ep8KjXTZ%^vD zYek}|&8+Y5*^ZT-x@g>-nSex*>*F>*-i;+Xy>YH8+>GC7cZ9g-^$5on!(B_c0j`*4 z(2%=Ib}5!#`L*^8R+8ez8t5F2b92Yy25G|0pivDtBLcA#5b`7v_hSLV({oxYR*P-t zX;cV-lFHSI8WvO0e>PJI$)+Oi(w;1cg8^dNTHG|G2=fa%akd%*)6(qQEB%ZB!67%G zA*{pyhIuIqsjcu~^``~BSHJEHwQb7Acy4z>v-nk%5}}#t4V$;Djd+`my z-0%A<$(AYz&A@E~T?KzlBR(8E^dk=z7));DInOzR#MhAZk=i|W-4{2cnLn4xoSuB+ z@5amqM6KhgDgqZvhx|LgPpt95zHJnh$VM1^r`@05xplV0U{hoB0&5qtVTHW4sI z|8d?RciCO1P6k;0H%n~3AG%3+$~h9>B4Mk!C`OfLbfr;spK#v?-YvA25uMBgq3-Sz zFM(H}zQ7aT)}*hQ?n!b9(ZT7y6-~JH^WG;^EO8AE0{>YurH;86DxAs;nBP5cX^1B- zJ2z>ZocLc>qqi&b=nMZ6*3)cj(t~!l-K(Zul5)a9@XSCq$pO2n zc&w}fZ~ypYZ@_6a#dy47(RM3d>j^SANl?AX=oQ;--AE{=_ML%?&~AOA#mCTnj~C}2 zFZDq=+&zvAT}M{|7a(g)LSql1ghm(fOubRU>}e zwko$6MUPNb3>@w!!gK(tSh~tNW}RR6B_-&}H)f=*)o73vzXaRAH^OhgW?E1v_inss z;C1`$FxWix;*LGK`egjYc9rX{;=3$}x|$gyyU^&HsQzjjug4sphPPQ~_v=d0RKY=7 zFXV#{55D$_a~J{5YO(R24l`@m7!$@J9FJB_Q1_M0+y4-pzm;C1wPQLYdi9v+6B%pV+k4 zE3JPnfnSM;&kvqG7cGujjl3lh4Bl$zCQIiMH|Jbob3;RCBg9nnq)Q$!e}(sI7XvH- zdF2%^iatBFMyhBUA2k#)*L^1 z$%uo1v$Fb&85RDeYR~BjlO&{{X4@_t2IfBO^QM8TAjpLr$4x`CNx=gmS4%9ww@TOLs}=!M~XPTG*T zDg|);>Ui=wW9%U>PO^BuBty#YE1QvlYK}}>yE+Gyp4{@!ih-YUpN-fd(0TkS#vU2q z^*7{&oe~PjM$bWY<0GTWt@DA04u#G)-yZNn6H}{zoNp<&V?GWLxGB@Q-?s`doBj>b zSq$(VVe~U^$x9Cw*P|}=QEZ-_ilP4&)6(M6B#n3K3BOVSHV$+0U$Tlwo*q*2H9<5B zG|6lRiMMeP}H@ve~QV%=+iik4MrWoD0!iMg;8lDdFkd(6w%ATghy> z`jP?o<`x^(jAPpk?mIUMV}LKKLoa1JxvcrQs-yltP#9JeuJc~30)A^N5F2#ffKdfh z=0p!1zk_=`F!U`RVb#%7U!5LUdHl*+*nTdo{3a)|kY*&ktk#s-=IKAuHt@}c&WlSVr&-OhWJk@D%@VI!j{`UoEZ?Eoupj`i2TN1SH zl$l6UjU&W2&wV#ZeO&CR@8F`QXFs=cQ+_OMxE}!Q+YJ%e8w3y!i zHss$31kSjNy|jW;-(fRLfQ-N1y1C_}xMS%XaKGpHg<$ydu3TcM%xltn;t-9*N=V&}|0rt`7t;4fzgtZ!x8|ui0}^|6 z*{s(wJ7T)D_0O;thsrGyBCFIYG?LNvMRQncRVo3ZliRj9koF$9o*DQLyVAh|M=PP2 zTL0*L!20)^QmTPNPZX7C0>5}b8#AHn2uN#Yg9Is*7~FGBEGzzO@ih* zHabo{qq2K7+!|R3T+Bq8dgXx?W91ff1*3;@Jm#6lB&A=CBpMX#q6k_ix6Yf78LVsm z;o9Cl{Sq5Gi*-QPn`YT_8QA=!@=)}q6d5_|E}9AU_>Zy|jX|YlH>jyK(vn9Zc`6aA zqrMRUjy-m6ZI}H^P_g@!v?jNQ{^4-l$ay<4gV}giHtbJz>dA<(TJBL6q$DfbTRCG1 zsBh*!EsY0^BGO8;=D9Z!0>6T`KOiqlqO!?!Kf0^U8fp-+Mn$s;>pPCy$Dfm|)Sll- z-OJ5;?hV^?JcL(TEB0lTr29({lvGz&O2f9&rG|djKt6>Hp@t3zI*6|ayz6nGH-fp_ zks?iT^%pO0$lV(W#bG>U18T_W6);IgZ^r^)EwaM?d=32#;0!`+OxUnJYCz@T>)g`3 zPM2uy6QlH?a`eYz*~L0M-&w(g$QC0aC--(GTX%CI7S9VCNTs1a8K1;PRh0(vravz} zFlR)h57+ou*!Wb>2_by;x*Xx9MLV{ho%~CSLQEoMJDB|spuA@;l0F*^BPGAiML4v= zqu-8Kp4+3xh&!Jg|57DKg%-SGIC+58t(Atajc0&!sjtv_zpkwd6Qi9^4!TmS%I=}R zU>}4Lj^PO7|hbois9aaP2FoG_D zIuH2^54-b!y83VMNaW*BuSuSkUSPjJmC5jXZNM~f_MYtvlWS$UFORTUd|#V-hJpRS zeao9tIve7II2Mz$RO;Ilp;wBj_KPTp%GQ8Wz2cM~CTTj9+>vpK=!v~VfjOHIH#7`(J;q2SXh1#sg zlhF|p@5|0+jk@~$W!Q8c1`KllsH4)E@`Tf{5I9nsuOJP(t_C%>Bq8*yM85PAj>5YY zw`K*4T;O%Mwkthm*IFBIFv`8i3`SnY+dw9RJ5MMxO+yMPIX7@loL^Ig4+aZT7Q`gu zo|NJFn7vDVZDUgIaTyq>Updtc%F9%0FzQ}7nxV~#x%v5bXU4`S>qtyP%joivaW&l! zJqkRTClo`KQ1lkZ?C^G1k=P#nRx5*mjcwa_*cOND_Yim&^>|IwKabXT44ES*9yE+C z9MR*!$@N++xtYwv$964tvB>i~b{fgz@_<-0^~eR&q-xNx)1~It&!{TX*_~4%@U4$W z#@%+3;(i~q7*pxXDwSsPA79^#srSb?Vd_ta??k9J9b)+?0h~uH3=-BUfIC1^S|?p^ zk7SX~cnCREb(daWuO-LtLDNuO=d!ETHx{BJBZ*8*rZ?47Er!qesDTKocd4;`G#>EN z44nkIbSJV{TZq6%02Ro}t?bD%iQLISxR8z(|Bv8{?+#Oy6F zdwD0Q&g$YnJ(-5SKIa|H+MAa2aG?FcPp&dvUQ^5n>&9ChV)x2^OTvbGfW`x;G2f!P z|0du4D}q0H59dpz)r^P^>R*doVas80Jz>SXUaJk;1EDju$gNgtKN7Di5aedQnZ4pL z9<4i(Z>;>OVyB8@@mu%;!X90$%tue#il%Ss6?o+1TZd@Ey4@^P6&uo$E;hl+>HO&nBIUZLWL*$>>1G4 zYJ^j9MdU3fuphk1h303d@h;9ywq~;*Sh?Rt{FoB_^Wu*jzHFEuHyD4{OGMsBC|U6(c159> zx{fkHXG`UyTpC5VMki!$am(U|3ZWQDNj4kg z6SXF?HjRgAyF*NRl@`YzvBcUA5)-FkNWNp6SH_PxF~&%wfcQ>~WU~iZ4?;UMaQ*!2 zVv^G`J?t9vcE)@oDL_Kzi(F`Tmst-y=t&|$i!UQojY%TR~II|fLOD#A6lcx zx?m&-_P$;I~#-7n3@ zJg=UUy$2QMaw%5xF0n5B*}``;v8R(_^&9ewILS$7@#)$lNO0wxTU zqZa5HFL@|p_5>WaG5nmmf4*Ho?@-qzj6Pe3diASH@-^%Gb7scq@3*V6)hj<@0_#I} zyQ=}wUt>SELr~`9YqNhmla*8bmJxWq5RpM7=h_}_ZtOSJp@ZAd6W)ih2}^a=x?$Dxq#||DN>|#Hjw?CjKtpN5QkwfXv{yn8QGX8h-dxv$-9i)t<63uHV5Q zxIOa6Hrh%$-HaNiBvC&vC*5K@yhXSPh?xUzaYi=*u#j{g6c;2DJQZZkfhLT4sX;4cLrPc-H z84h)?Y*gQUL8)Ok-`W8ra}MO%NaZ?~L8e1zbhH@@Yl~_!|Nm-Oyny_JRD)7sg6SdF>mfE~hPfHFa7AHdR-lsL% z{fTT3mzMK-wdV`=Y#s-0hzFbc_toSwbobqhjP6q)R4UF?H9~3>N9))*4C(bhEw()h zZaN64>i_zNpSYp_XyR0@v)SJmLM&`=56nhZO~4V5&?jlU@CQm(q6YTCj2mrVZ> zF{|a3N3((nEGa@I^;y~@7&kJR*A2o|`S^LE>g3Vof~9hl^DF4yCww_y15dCQc>AH~ z+Z*&6j3eUQtX1{a)qtGR6~*OHv$NlT0VYDWPty`~H)=1zq`Vw+szW)@jP=@0=x=?G zXj8kXs;{d=Dk19Oo1Phm$^WfWz$I(ZIv6%&YD{R4WW`N|jEPje>4no>ouDlT$g7GA zKJTH!4QFUNOYMw2JocOaF}TF&VS4X7L$p43ZzcbJs3166(*6)^X5~2^Jx3i+zuiQ) zz%n)xq6_Tpo#d8Zt+sFfZxCJl>dnK+96-}DRMeQ|^?xKJ(Mx6kLbd7vI!tURqFK){DwEK zUatWXOTOMuY!&@b2srG&fYF2Q`2Mx3RLt z-iv^-YsAtDtG!5FrU02QYoj86`($^sx)i%VzpV1wxj{{JTs;>ehzcu6^87?-W`0J7 zNhGN;4K<{M{n3@4fgk7PKdleKY7{>P842>Nx2?qGj7Mha(MiG-nY2ALA@n8nqTH3> z$0M8!byE^{g;2ndPW{D@o&u#%$MFIDKAq$;&&v(_=os)!R<&gdB!)TOo*yu`v2|jB ztgb~qJ@dTY_H7EADx6+oc&L@v{W4ghsJ+O*;0vDgpd?wHz73=k%$i+7S2lGYr)NI7 z>;4Rl_B^;;_>I6$XLOOXr)QSE({gv;^pV7rsOcmFq;JLz^r-N(y&(1=LMlj%(kmq| zAwVSz#`&A^tZxp#9xjM}c2wdO!3OZC>7ptPZhffGG1$NVyG)pjr+>3+)fqDsB8H`d z4dR%#bgQ*ph&Myx(=UGxR)wL?t88+?h3xfPfh}aOBQ@32^*pMg-zR?s;*ak$A?n=s z4XVs+AUp2lCIXvP&Fe7EYQ9L=5Wv4~Za=;1c3pNhtMRuv<;oY+cQ68FKN4?TkUv~3 zUCeO5qOGahJU7CYwBA3F4g~CdC1t@U>l&r`9G}b6thOWP{f?J|LFunnQg#-#zxZC$dlK#lhro_P!Jfc#u zRiJAnqE)aDm?izSR~mo;BeY%#;(^1F=?}844upN9r@RJ?P+)d#VTI9*zrxdt5{*3k zjBMCJm*z?Q3NS_xtJD;cs$2zG&yd4~r!nzSSLJfIytWq(tcIttG1N;T*KH{A0V&Cc|Nlxw!*u67*f6rv|P~Piwea)%!?2Rt>Nar&6b4aJ-&{ z6#G5|IxmbS^o1m$TOJx5tAE|VFu4##4^)y+D-ubj<{6~|>i;}Rs5}lG5r_$a3!EoF z@A%W);_DCNM$Vw6#B;Ub;FGHs-bPM?DqGo@Td=mUsPkCQW7CrH^F%-jcwVrK65fSj zo?^Cx+I<9Fbr&<YumLFYh2>GAwfrS20S1Eij3$WF%|zT}}2T;s)w z9lE%9a6-_2iN8=%>9y0h?X%r9(8bg_hLess%4xCVjPtUlBO zS|PePw#Z>JgtpBir!$ ztliYJ(;4ng72t)`_<$a>8Dnb>r0PM(=OOd@4H>x-oI6m01xVL9-i7!Eeej_sO_+i+ z={R~>W;zl@1pjCLF=SKSQUnO7g>7RnX<_?I?6J9K0jUB$1_1l;*Dja}6+&NcFbdhP zeG=ikWq19*_Ri|aR}s!%3t)dA{q>;rk+FCU^63UbV^aMBcJ|4UOP$3S~VB2T1^dorWS@jesNQZo#=TAZ!cVj;BedNMZKr=hA0 zTU4m2Y0QnM+=Gwjf9%Z*K!PDz@+cu;G!AssV^;5 zch_aS9%wHj!9r|A3Dq$ySS*2U%qOg3%!F^X4d6%3oE!*x>ny?`rFu!DCLUi}n8MOv@ zsmQ!nHKb9TIIBR37xY=kYJ^PSc^r~~6n$kfocBHr!khn6+BrSuNft|5J8vB)E`SSq zoI%)UT7NDQiLWs1OaHCOh}5dB;CC>U4X`7?mAt^iEX$kRkuy_xA!Q8nPGJt-V_Qd{yN?`^Sy&rb8gl9L6%E#G4)$@p39C37kw90tA9zspbAIwGyop! zlW#w!Iq$sX6>Z!6Lrc@$A}@&t23AA;N}CdUSHYPl-v(DD#k`QKS~SaT{~aOo)A( zpM;rh|66T7g|u-qhm@^9-{c_ywnR1NTyCkw0cB9;RQX@yVHiWBR1i||5s#6S(E0Yz z;l0Dub9uyr$v6XCgZzqZWrf|C@IiouTgGXTxBVE8u5Wg5QPW?Aoj=Px5xbgFW?kv; zr@Tx!DCzP@)agKe@|qSVmL=QhiiTa0v8$)jC#TK7Ese(zq^`x0@zX1>OnZNM1s4q2 z`2~FOHU?jo*LYw7g73$8mVsGwTRL^D>uXsb4es$uwI)~K8! z+1@kuJ31N1W0e)oXUy8^qQ_Qke=xb@y~s^L9w&MH6}#ETq~$76>T}vCkilN6-AzIu zE=bNPU$2H8unOPfg=~&&tLr$1#DR^&P0GPq?nG1@N^b=I={6(Q`LzvjeimhPZ}fe2 zi9^MT?SYOLj@RAoFNh<_t)hMGeduDS zP8h!Jc62WYr2ePk@5aUYseX8>p0R>d50g2igJudK47(5<0kb`sN_U(=VfywXuWQUwhwXug=L-ICZo)r8 z=cnE@$q)YuUpzbQ7Gv<$`!x;^)A;Ymy*f;${_nBR?K~om2S;hA%YJr*HM_aKMSMY# zv?8{n-$fX(4!w74F&sCZAJU6m@|c5bK%VisW9tmZKavMnbsbcVbbsAJM&dpSfAzle z*aL5=M^?96@ED&mMgi0t)H}#C;KXq991{kEzSZ6!l{Wr!b|3-;l*8PW&D^fG;x(5# zbn)}C?6WN?*Y?`a1!+@_idz>8CXCmGgEgsBBH`48fpWY%jf{{~Hk=jp_PBSN|E?3rWT&6T=GIZ2lLA==tjFiKP*rmVo6WkoJW*jH^TFdur?*eLPX&g) z7{OQ~{EDKF0gd4&>&}JdkqSQU3le)<)TgZln4xKv{KcOe6T5y43lT(lYmCi4H%b+| zw6nMZh-f{>IW*}Np6LF(QhvYyhiJ+2OpJxi14vp!UD`Nm!;Vy*Mn;C3(jr_JvEhgU zF3Cxv$RYLVtR1Gu=^%He8$wF%D7jUhz{DtN_4zq#s_5qM1Rn4q_RiZ;RVjiJO`Gb% zw`b3h{U`Xvp?rsFr!)wr-53Kb19?vz#Z$LMY@ygwmMg_I4^70qmJNV+%kpC7UnP3` zZK_*Ww(p07@RHvbs>az&RRm`H1V~!rYrNm+pLyEnya_A8Q%8o7w!#Tc&$fy}uENR8M^vf|VBu`2CGKhslf7 z5WlKKV61>=GaHAacs5zfD)Q_Wgl87N_~`^iy2sn$vZe^vHD&CyoE5F|5z*S(80#zxybAyUSniv_`req)e+g@ z2Pw*nok>DoT~(wM%v(T6oh54b{aWCm9UivoR)7&^7cMnW<-&S{Dy|9>0qKW0czTJh z3P88-Eal#AeYXz`nW9|{g1i#*piW=}D=D;-mxIZ$4r|YzKd{Gz&SZcydWjoOt>j~2 zQuqz;^qIon5hkRNM}?r zx(sf0zoAs*G0nnew9j-(JxcsGCZ@<0e+DmcDaqh6?dp~n4q_9{jJ-fZ?LoOYCI8Rv z!J+;s4=B7~j69CyP9^MOheyp{FstK>rj5At{vP%ua;=4zHVWB}@abZB_bB+PHs$7_ z$?^x|);FlvUzas#dct#E%R8 zo%%)Q(-{s^k1=Phz(o+et+A134QkIi$cQc#RDbJDJ5Ozye6QgTlidzZo#zx$~ zf8*2m(ULr)X4<1qd-8@tDWu#rl4o|n;!>c)I7E{-+92t#Q8~P1xa_sbx|hy4a`ABJ z!zi>AS(h%(f1x5Ma;PDffniI>S!3=8ZVB@G6ts%9(vW)9tc@&v&h=xZzT~y7j@{R1 zMK}bg5D}n=qzROK`F(>26ZF)ku1(c}hv~sKrsn$3Q$K<`-9*U*4x$6H@;ZS*q?EM6 z7^oZ%C;g{??e$q)jTm#a^;GZ5F1DAlx`;CfV8Oz#}I=Y9cVfnqzGeV3Ca(kZq zJdCkaH{JjOWf?Z+vCpwGl2>0=O)~;_0Z(Gl9k(J^o9Z*JyhnH4+Fj@FVJ`$z^Ye>H z9)3Rl{qsWpg7)=SDv$9XSZUJJ{f|iQx3*DQ-&gf&kx|&{foG=*BnoU=QAU*~N#D~$ zfeF?jCO=xon2BCHCdj*h^<8?@#^+}yY zp=--YCDXc3K89oz-D(o%lV;I)*65I(3Vk^kJ{HueX+`(~CclGC36NJVgD=u*g(t!X z5MZx;D4|^8#F#U)sx8=C@1D9q8(<^MYc_-JZ=>9#q^D)%a-(WaUe1}-4ZCB1 zB)rV@TA95n;)eNbJSB zDprf!V%;N_>DXuy)U7lVLsDuQp`M|}r}|g2zPL%fbyU&e2Ch5xSqg?GV*!}@Za*|5 z4?l8nga4Jgt<<#KGWRuFTG{Qvi>Km_s8!ib3Ilis(d8vpA6yxno%oZ*&6w^|X`^hX zHsU>Ohz%0}nyhm_SO6_p-QdC{M5z5w={q14R4Ul3Z&?3#ux_}JVBnjJQK~2KoV3a^Oa_09 z&z#{iJLDAlWc~^?nk8@O;eDDzo%H&njj!80X+*j{wf}gb%Q!Xy{=i}FMYl8){=fb% z1{r-$Fx7r`zKueN1@xSgupGwRb(bp6nmsU(yS+wF_U6vResyMH7<_fdvdCZ;%$45o zKaD0y7PkSikC8bpwC`Py6qNDqqKQ^E;Yvs0O7RNTfA8E4Q>^p#Bwl|SVN#}lj&&+U zDa_ zwQgnQ-@k`66iN$#6JmZ#m`|&aP|D*bq5mTyT1RThgB!H*b7z&d@APVXvb@g=V3c6J zV>s_w!3C{h?}@M?(d8r&o_D-2%UkXD=^Ao1!5y$VtwJ=XidfDY4+bV`%j-Ylb!4qM z(-);lB04;)EAT+enP>kN+uX&8t=(e8Fge+BjE)a#U|=Jm%!XIdU1YVZ-P$TS9=Q!3 z=Pmui!>M0QK1Ow5mr`=hYnb4g?M91sJh|qzZ6fsN8VTMBBp@Dk;#Uy~M|)EK>JNkc z6`gc)H)^uj3?0AtH?upeR2M|0m&qCV3!7YBJ>}PK(GMQ(ML5^sZQ$+CEG;y>%7L&k z=9x3{>D@*@WBtV$;5WcJyBWZ6nuST#Z}#Ebz9Z5#AV74Lc5&7CYc7Ja%FOr(leWBF zeT$I^>vn(U+jBl`KTw|($>?6Ukor=VZNlJ9*miNEiiJ?=+`K6p|LX`gQK9&8{aOlF9;{#S|!iT0vL`<4_Mi{xcb?IwZ3Fu(zT*1bqTrp z#W9lT!*!ULh*m|*G}8=;ZTPvZbHZC;OY@|SR6J4pDS(}Jm@vXt=e{9}NyiXAlk`zc zhmDMwBjjZXm&1s=Vf56rY|ybIu8_@i>&uz5S>?kx(h<*s@_J+qRdbQBgYg^MLLpl3 z!%@nFUz;0}nAmiFe1|vaq<+xcuK0pNr!w5ip;#l&Ir$AkDv0n56A=as002HA;I( zU7%qtQ4J(}t6NWDh?MKd?S!i>Ko)Cmth*`5zR}VmX?17Z$#`Zk`gG8MEU6Jru02(w z4X|eClhzu0=5=tB16xy*xGRMs__KzvtD7@J^6x9!E$9G;8a%+E1lbtt)(+UFd{6!j zIGsuz>K^JJOG4c>RKR?ge%BTAWRz5J|2+1c2Ppt8&DHmmJApY5zGOEHSE-LpN=#r+ zd~@lEEv1#c_kHuI`)mHvq5hGP$n{?I+bD%Z9W2aXz~4Rdwi;r-1!HF%hr+7DMmR@@ zq%e<#v)D`GsakXI-)3I^-}cKy4cQ)somLwhb`cBLXV$EdG2`C&qUj8Db>BJ5QRY*g zqN2eUVg|t*KrHTS?4Y`!Uma!K_X=xF|B^i0oSVh=*_N zB!IiUUA_o7LnVS!qTY!Ry74W{?-giqF6_7CB5bl53!F9Cu zWz)78XZ+e?`04acDkKz63^rA}Qb4E*??G?874uV9T=-)7=ubW`z>=&nMGOjjkw z%mAO{hSJXyy&z1{SvJf%@_K%(p`H41`4G2?HpPkGbGP~mGEgt{dGWb*FFgSN=>TdI zR&I@^Fsc%H9jpVyK>&ClSiIlNr>hnLDh2W5f!<{@jc-3yUr@^3IFDXsVXpYWD$iwC z7_n@E9K1pRK3!b29!pO)v1dD7`j+ETdpE@y>9#CZxc%!O$UH?H z?q6j0NcHyD5(9o_68+#vlpY)Ux9HV;USLoalSvYixLkqYr_Y!we#{JX-{CrFZ(px_ z;Is~NbJV@*CC^K0)KJ&H_ttYxy4-k|v+2`NO%YCb1bZsOFc>0dZO+-Y$3w@Wh&>n* zP3m6tfDi1#2yOyz4njN~*U%4ufx#wXaj|@cLR--cqUpFB1q0?pLhVP-4 zeE6N58V*&&j^6aZIWE5Lyjhfzl;I{pXB4fh*%h#R zuya>FDLvgmya@)owKEbXuU3RLu$pff`=$q_e>n#}hsin4OvO2*eTPa=S5jihpQeE6 zX9cPm`yLUI{9~IaC3%H%8-3SLbaqzXS+Da!WRgDQ!&LaP*4(pRwA`Bqn&GEdlC8uI zuWcK?Zu!g(xEz5N5p8vZV6jf~a|PNoC?$3rXxqT|pQ#&1m~Cry6G1VI5aC=HC0cwAJR_FM1(@xHb(LZhq#2B)xJ^W5?`kybv$RfPOK5IQ|LAHKDWHt zCEW0E+*)8s=|_DBRQSaQ58Xhdc4t#bOLnX)YqO8ez45^ZaK2UXwP)@NH!LO8=nC28 za)8j*d>I)8lM@ENumdCnC?6At-%|oqY}#}+$>ML7W~HRo2yJqF+|3?O)ml*30Jz*` zPaXgnL6we&`C|dJ-$JN?KP6M!`=*&fG9Bfd3{Vi$ZTN9(R^aIZ1{Io1u3hmE;MDFo zv%`lO8J;=+v8;g7A>NLQiP|9@PlS+~eF(K0-QkY)g0ixNF~_Bn*Y`WCyX-_o008z# z_*Mure~#NF-Jc}pn$cJU7bAWF0U)P%tLw~B6F;izS6Cnbg3w;+$b`~lgftJT#~gwv zZ^O4x z4l?0oDVVrwy4=yrDCwl6?qL5d?~=%x1mHt7WD#xW9l-YtXrKqa^ntp7y1){ctw9Fe zQxAq-1o{UE8bcvFUwsqn@X)A<7yu~+*(n~L$L+$nfX^22CO{#;C5nlr=q0=u0uy>F zZv#d83R~S&^$3TvL%z9ltNR0GGvMj7z|AdHz$;edqXtrGb#oqYL{Ha9yf%9Tbbgvl zK_5|5t|HExpMc|ZD2;B!nCSzF@iMlJq1XtLkv*M z4up>wfixlDOlb#BSGc6MwONqKdZS^8?WCw&)FuGfiC#U}*^dJ%gN zP&4y$L!HPAL&myXc?S67iIXO`kM|5?Cl*B-w!#m@em70R(z8>xmOW%P+HaEFcG>7c zq0S2-$d=#mbuA_oTLk;feIx%AUM64x%$6;P7QI5YvDshK>Xgy-eyofL)&6?>Q2`C7 z!K)p(*o4LXbq&RT!gwD}A!JQasbjf~H{L zndw9x7g0(3l(pWCPq=oCQK-gIsf9>bcr#Y;aNDM`UN>#Jc;b+DICceUBzDkco+9ox z{{qj1Y~7MBcq<`(QG5rMEmoR%!~i}&c7iQAGq^}AY&pyZ$+LsjVHJx+Ul=NiEqIWR zG$K5E;li_t+22uex%km!ch$4&VZQ=gfG_()Q_psW}lHj{%r)fN1Ca z8j(--m*BXiWue@YPVpSmu0(V3eBz#`zs3R$j+1+j+4Muxk7Wa{Z< zdt#G75os|YWHST^a~2VSG!F)LH9lwzq-c7R(e}uNeFsntPMHC~hI#pbnXtH#l9+b6 zP?J+5C2X`%C5h@%e3o-|GHdm+)J~3TzHgG%)#Gr5YgS6wZhcbBniA~58Pt*M4o3hi z(ebc^zz6KYOZiz@x!DiBEfnfWreWnm^A3gsx$%jMtq}xbe)06mLmeQg?$nfFsA0eH zeX&_b`Ab98)K3-ZcqSsVK*jXj!}^Ae34pJ0&SB-FFtd~Lcb?8OVF$n(g~WQkjsvML zV+KhUN z1>IwlkNUbK5_t{S|FPq%1(kF^7NKp4F-)hCw>d9srvK#3r_Cgi&;Qri)yD*So8x64 z-{H;nk}Xc<&HifW@+U!%x5l`-4$eFyi zahB~MtX{c7Hi4G{X(Dmjpd`qhF+ z1xynN1gp0@3PwgosZxLUw$7}sR@v>s&-`zfE`H17lX2-%-%+QHWs0RE#8Ru}-8_}t zLaS|Xa%8p)oRA|T&;d($^4-c@-@^;glb#xB%%m3nzNs0eSkrOues_-wTlpyNMAc)H zI%X4cjWt0ky+3TWzgEbPBEQ{b(b|QTZog^&^XKzQ3ta>Av@)y!rDS^o40dlt<*!pPbLko*cna|C}6) zbshTgEay5crc4uRsWP>N!L)vIg0d-JYHIm&s5i&nA&04R&NRmPrTddL3heHbHvaXu zLA^==AtvkVFRaGiy1dJ4lIHpOOrh&6;%MdX&$ixpe(dn?abAU555omLoxc&ihxfT* zilnCTHthgOW2)nb%-OspHQpoILCmK@oqir%|MA*_*MtpmpvN?xwIx7U} z{E)Y~I?EA;#SGeuIERn+#V9@}%ITJ@o5xSJB>9!aN+?GvA;iKew$6@bU(#@hh;C_s zl^7^ycv!lCx*@P;dwvi0{ZgY(I=Cd|TXr}o|s^={r@&@bsQ z@+tF_L5TXF!}s>)>xkDu&U>!BuTA&YH7k!&qpgmXuQ<2nIS5Y@terG9HFvD#W74v+ zvfNVqS6wTz&dTkxwe*rVW|nk#BJ^v))KI0$Q@eNloSMg zH}*#&s?MGD*SeI2PAmRu{a;u8u}PNy?@RudATb}b30CA$Py2E%#~x14?vFlh@tvn; zwBf2FW9U6eZXTYrXC!IHoygvqk~de7>a!7ZR_bR+Gdk4Hs5Sk0=I4wH&|WgUALg6pdoSJkv&2)I1#A3|vzkc};t~fWp(7sjvfadKd zgQZTP9Ow~w@_TuWgIF%_2h@dI)s$UH2;+_P=Q0GU%i`A1(%ef_Sb)R)d-FgEa3J9B z&lf0p@x9Q)eT4^XZ{pz2pqt>x!H}m(Hx#xatML@OBGC8uxR9V=?^ENBpD@?`OPUJ1 zDO=I7myDwe#tf0JA9PzHs+^po)Ia59x-gQPet482(-&qs+S}V-xUD&)r>6^;G0q2D dboZ}0L}TN^5EK2l3}}qri-zVGDh*uX{{u$Jw2A-# diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 766b2c62..00000000 --- a/docs/index.rst +++ /dev/null @@ -1,51 +0,0 @@ -.. pathview documentation master file, created by - sphinx-quickstart on Wed Jul 23 14:07:29 2025. - -======== -PathView -======== - -An interactive visual tool for modeling and simulating nuclear fuel cycle components using React Flow frontend and Python Flask backend. - -.. image:: https://img.shields.io/badge/License-MIT-blue.svg - :target: https://opensource.org/licenses/MIT - :alt: License: MIT - -.. image:: https://img.shields.io/badge/Python-3.8%2B-blue.svg - :target: https://www.python.org/downloads/ - :alt: Python 3.8+ - -.. image:: https://img.shields.io/badge/Node.js-18%2B-green.svg - :target: https://nodejs.org/ - :alt: Node.js 18+ - -Overview -======== - -PathView is a powerful, user-friendly tool designed for modeling and simulating complex systems through an intuitive visual interface. Whether you're working on nuclear fuel cycle analysis, control systems, or general dynamic modeling, PathView provides the tools you need to build, simulate, and analyze your models effectively. - -**Key Features:** - -- **Visual Node-Based Interface**: Drag-and-drop nodes to build complex models -- **Real-Time Simulation**: Execute simulations and see results immediately -.. - **Extensible Architecture**: Support for custom nodes and plugins -- **Multiple Solvers**: Various numerical methods for different problem types -- **Import/Export Capabilities**: Save and share your models easily -- **Cross-Platform**: Runs on Windows, macOS, and Linux - -Quick Start -=========== - -1. **Install PathView** following our :doc:`installation guide ` -2. **Try the examples** in the :doc:`usage guide ` -3. **Build your first model** with our step-by-step tutorials - -.. toctree:: - :maxdepth: 2 - :hidden: - - installation - usage - contributing - roadmap - support diff --git a/docs/installation.rst b/docs/installation.rst deleted file mode 100644 index 80eaf658..00000000 --- a/docs/installation.rst +++ /dev/null @@ -1,74 +0,0 @@ -=============================== -Installation Guide -=============================== - -System Requirements -------------------- - -Before installing PathView, ensure your system meets the following requirements: - -**Required Software:** - - Node.js 18+ and npm - - Python 3.8 or higher - - pip for Python package management - - Git (for development) - - -Installation Steps ------------------- - -1. **Clone the Repository** - - .. code-block:: bash - - git clone https://github.com/festim-dev/pathview.git - cd pathview - -2. **Install Frontend Dependencies** - - .. code-block:: bash - - npm install - -3. **Set Up Python Environment** - - We recommend using a virtual environment: - - .. code-block:: bash - - # Create virtual environment - python -m venv venv - - # Activate virtual environment - # On Linux/macOS: - source venv/bin/activate - - # On Windows: - venv\Scripts\activate - - Alternatively, you can use Conda: - - .. code-block:: bash - - conda create -n pathview python=3.8 - conda activate pathview - pip install --upgrade pip - pip install -e . - -4. **Install Backend Dependencies** - - .. code-block:: bash - - pip install -r requirements.txt - -5. **Verify Installation** - - .. code-block:: bash - - # Run both frontend and backend - npm run start:both - - The application should be available at: - - - Frontend: http://localhost:5173 - - Backend API: http://localhost:8000 diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 32bb2452..00000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.https://www.sphinx-doc.org/ - exit /b 1 -) - -if "%1" == "" goto help - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/docs/roadmap.rst b/docs/roadmap.rst deleted file mode 100644 index 1184381a..00000000 --- a/docs/roadmap.rst +++ /dev/null @@ -1,34 +0,0 @@ -=============================== -Roadmap -=============================== - -Future Development Plans ------------------------- - -**Core Functionality & Solvers** - - Support more PathSim solvers - - User-defined block class (ie. users writing their own Python classes for blocks) - - Support for user plugins (eg. Chem.Eng., fuel cycle blocks, thermodynamic models, etc.) - -**Graph Management & Import/Export** - - Export graph as Subsystem and load it back - -**User Interface & Experience** - - Improved UI/UX - - Capability to rotate/flip nodes - - Enhanced visualization options - - More styling options for nodes and edges - -**Documentation & Examples** - - More example scenarios - - Annotations and comments on graph - -Contributing to the Roadmap ----------------------------- - -We welcome community input on our development priorities. You can: - -- **Vote on features** in our GitHub Discussions -- **Submit feature requests** through GitHub Issues -- **Contribute code** for features you'd like to see -- **Share use cases** that might influence our roadmap diff --git a/docs/support.rst b/docs/support.rst deleted file mode 100644 index 0b44c4bb..00000000 --- a/docs/support.rst +++ /dev/null @@ -1,18 +0,0 @@ -=============================== -Support -=============================== - -If you need help with PathView, here are the best ways to get support: - -**GitHub Discussions** - - General questions and help - - Feature discussions - - Visit: https://github.com/festim-dev/pathview/discussions - -**GitHub Issues** - - Bug reports - - Feature requests - - Documentation issues - - Visit: https://github.com/festim-dev/pathview/issues - - diff --git a/docs/usage.rst b/docs/usage.rst deleted file mode 100644 index 86f56113..00000000 --- a/docs/usage.rst +++ /dev/null @@ -1,237 +0,0 @@ -=============================== -Usage and Examples -=============================== - -Getting Started ---------------- - -Here's a simple example to get you started with PathView: - -#. **Start the Application** - - After installation, launch PathView: - - .. code-block:: bash - - npm run start:both - - Navigate to http://localhost:5173 in your browser. - -#. **Load Example Files** - - PathView includes several pre-built example graphs in the `example_graphs/ `_ directory that demonstrate different functionality: - - * ``harmonic_oscillator.json`` - Simple oscillator simulation - * ``pid.json`` - PID controller example - * ``festim_two_walls.json`` - Two-wall diffusion model - * ``linear_feedback.json`` - Linear feedback system - * ``spectrum.json`` - Spectral analysis example - - To load an example: - - * Use the file import functionality in the application - * Select any ``.json`` file from the `example_graphs/ `_ directory - * The graph will load with pre-configured nodes and connections - * Click the Run button to run the example - -#. **Create Your Own Graphs** - - * Drag and drop nodes from the sidebar - * Connect nodes by dragging from output handles to input handles - * Configure node parameters in the properties panel - * Use the simulation controls to run your model - -Step by step guide ------------------- - -#. **Start the Application** - - After installation, launch PathView: - - .. code-block:: bash - - npm run start:both - - Navigate to http://localhost:5173 in your browser. - - .. figure:: images/step_by_step_guide/0.png - :width: 600px - :align: center - - PathView main interface after starting the application. - -#. **Add nodes** - - In the left sidebar, drag and drop: - - * under Sources: Sinusoidal source - * under Processing: Delay - * under Output: Scope - - .. figure:: images/step_by_step_guide/1.png - :width: 600px - :align: center - - Dragging nodes from the sidebar to the canvas. - - .. figure:: images/step_by_step_guide/2.png - :width: 600px - :align: center - - PathView interface after adding the three nodes. - -#. **Connect Nodes** - - * Connect the Sinusoidal source to the Delay - * Connect the Sinusoidal source to the Scope - * Connect the Delay to the Scope - - .. figure:: images/step_by_step_guide/3.png - :width: 600px - :align: center - - PathView interface after connecting the nodes. - -#. **Run and visualise results** - - * Click the Run button - * The graph will display the sinusoidal signal and its - slightly - delayed version - - .. figure:: images/step_by_step_guide/4.png - :width: 600px - :align: center - - If you zoom in you can see the delay, but it is very small. - -#. **Configure Nodes** - - Let's change the parameters of the nodes to see how it affects the simulation - - * Select the Graph Editor tab - * Select the Delay node and set the ``tau`` parameter to ``0.1`` - * Select the Sinusoidal source and set the ``frequency`` to ``0.7 Hz`` - * Click the Run button again to see the updated results - - .. figure:: images/step_by_step_guide/5.png - :width: 600px - :align: center - - - .. figure:: images/step_by_step_guide/6.png - :width: 600px - :align: center - - After changing the parameters, the delay is now clearly visible. - -#. **Save Your Graph** - - * Click the Save File button - * Choose a location and filename for your graph - * Click Save to export your graph as a JSON file - -#. **Export graph to python script** - - * Click the Save to Python button - * Choose a location and filename for your Python script - * Click Save to export your graph as a Python script - -Global variables ----------------- - -Global variables can be defined in the Global Variables tab. These variables can be used across multiple nodes in your graph, allowing for easier management of common parameters. - -Let's take the [previous example](#step-by-step-guide). - -#. Go to the Global Variables tab. - - .. figure:: images/global_vars/0.png - :width: 600px - :align: center - -#. Click the "Add Variable" button to create a new global variable. -#. Name the variable ``a`` and set its value to ``0.1``. - - .. figure:: images/global_vars/1.png - :width: 600px - :align: center - - Note: you can remove a variable by clicking the red icon next to it. - -#. Go back to the Graph Editor tab. -#. Select the Delay node. -#. In the node panel, set the ``amplitude`` parameter to ``12 * a`` and the ``frequency`` to ``7 * a``. - - .. figure:: images/global_vars/2.png - :width: 200px - :align: center - - -#. Select the Delay node. -#. Set the ``tau`` parameter to ``a + 0.05``. -#. Click the Run button to see the results. - - .. figure:: images/global_vars/3.png - :width: 600px - :align: center - - - -Solver parameters ------------------ - -The Solver Parameters tab allows you to configure the simulation parameters such as time step, maximum simulation time, and numerical solver settings. - -.. figure:: images/solver_prms/0.png - :width: 600px - :align: center - -Visualisation and post-processing ---------------------------------- - -In the Results tab, you can visualize the simulation results. -Each scope node will have its own plot in the Results tab. -You can toggle the visibility of each line by clicking on it in the legend. - -- **Download CSV**: You can download the simulation results as a CSV file for further analysis. -- **Download HTML**: You can download the simulation results as an HTML file for easy sharing and viewing in a web browser. - -Export to python ------------------- - -For advanced users, PathView allows you to export your graph as a Python script. This feature is useful for integrating your simulation into larger Python projects or for further analysis using Python libraries. - -This is useful for instance for performing parametric studies or sensitivity analysis, where you can easily modify parameters in the Python script and rerun the simulation. - -Sharing Graphs via URL ----------------------- - -PathView supports sharing complete graph configurations through URLs, making collaboration and graph distribution easy. - -**How to share a graph:** - -1. Create and configure your graph with all necessary nodes, connections, and parameters -2. Click the "🔗 Share URL" button in the floating action buttons (top-right area) -3. The complete graph URL is automatically copied to your clipboard -4. Share this URL with others - when they visit it, your exact graph configuration will load automatically - -**What's included in shared URLs:** - -- All node positions and configurations -- Edge connections and data flow -- Solver parameters and simulation settings -- Global variables and their values -- Event definitions -- Custom Python code - -**Best practices:** - -- URLs work best for moderately-sized graphs. For very complex graphs with many nodes, consider using the file save/load functionality instead -- URLs contain all graph data encoded in base64, so they can become quite long -- The shared graph state is completely self-contained - no server storage required - -**Example use cases:** - -- Sharing example configurations with students or colleagues -- Creating bookmarks for frequently-used graph templates -- Collaborating on model development -- Including interactive models in documentation or presentations diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index ec2b712d..00000000 --- a/eslint.config.js +++ /dev/null @@ -1,33 +0,0 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' - -export default [ - { ignores: ['dist'] }, - { - files: ['**/*.{js,jsx}'], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - parserOptions: { - ecmaVersion: 'latest', - ecmaFeatures: { jsx: true }, - sourceType: 'module', - }, - }, - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - }, - rules: { - ...js.configs.recommended.rules, - ...reactHooks.configs.recommended.rules, - 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - }, - }, -] diff --git a/example_graphs/bouncing_ball.json b/example_graphs/bouncing_ball.json deleted file mode 100644 index b679a4d6..00000000 --- a/example_graphs/bouncing_ball.json +++ /dev/null @@ -1,167 +0,0 @@ -{ - "nodes": [ - { - "id": "0", - "type": "constant", - "position": { - "x": 219.5, - "y": 219.5 - }, - "data": { - "label": "constant 0", - "nodeColor": "#DDE6ED", - "value": "-g" - }, - "measured": { - "width": 206, - "height": 54 - }, - "selected": false, - "dragging": false - }, - { - "id": "1", - "type": "integrator", - "position": { - "x": 367.8036110436579, - "y": 330.00000000000006 - }, - "data": { - "label": "integrator_v", - "nodeColor": "#DDE6ED", - "initial_value": "v0", - "reset_times": "" - }, - "measured": { - "width": 200, - "height": 48 - }, - "selected": false, - "dragging": false - }, - { - "id": "2", - "type": "integrator", - "position": { - "x": 639.8036110436578, - "y": 322.00000000000006 - }, - "data": { - "label": "integrator_x", - "nodeColor": "#DDE6ED", - "initial_value": "x0", - "reset_times": "" - }, - "measured": { - "width": 200, - "height": 48 - }, - "selected": false, - "dragging": false - }, - { - "id": "3", - "type": "scope", - "position": { - "x": 903.7342215603948, - "y": 264.84964717890483 - }, - "data": { - "label": "scope 3", - "nodeColor": "#DDE6ED", - "labels": "", - "sampling_rate": "", - "t_wait": "" - }, - "measured": { - "width": 120, - "height": 140 - }, - "selected": false, - "dragging": false - } - ], - "edges": [ - { - "id": "e0-1", - "source": "0", - "target": "1", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e1-2", - "source": "1", - "target": "2", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e2-3", - "source": "2", - "target": "3", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - } - ], - "nodeCounter": 4, - "solverParams": { - "dt": "0.01", - "dt_min": "1e-16", - "dt_max": "0.04", - "Solver": "RKBS32", - "tolerance_fpi": "1e-16", - "iterations_max": "100", - "log": "true", - "simulation_duration": "10", - "extra_params": "{\"tolerance_lte_rel\": 1e-5, \"tolerance_lte_abs\": 1e-7}" - }, - "globalVariables": [], - "events": [ - { - "name": "bounce", - "type": "ZeroCrossing", - "func_evt": "def func_evt(t):\n *_, x = integrator_x_2() #get block outputs and states\n return x", - "func_act": "def func_act(t):\n *_, x = integrator_x_2()\n *_, v = integrator_v_1()\n integrator_x_2.engine.set(abs(x))\n integrator_v_1.engine.set(-b*v)", - "tolerance": "1e-8", - "id": 1755015764685 - } - ], - "pythonCode": "#gravitational acceleration\ng = 9.81\n\n#elasticity of bounce\nb = 0.9\n\n#initial conditions\nx0, v0 = 1, 5" -} \ No newline at end of file diff --git a/example_graphs/festim_two_walls.json b/example_graphs/festim_two_walls.json deleted file mode 100644 index 63391b0b..00000000 --- a/example_graphs/festim_two_walls.json +++ /dev/null @@ -1,413 +0,0 @@ -{ - "version": { - "pathsim_version": "0.8.2", - "pathview_version": "0.1.dev470+g017515800.d20250812" - }, - "nodes": [ - { - "id": "4", - "type": "wall", - "position": { - "x": 358, - "y": 240 - }, - "data": { - "label": "wall 4", - "D_0": "0.1", - "E_D": "0", - "n_vertices": "10", - "surface_area": "", - "temperature": "300", - "T": "0.1", - "tau": "0", - "thickness": "2", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 70, - "height": 200 - }, - "selected": false, - "dragging": false - }, - { - "id": "19", - "type": "integrator", - "position": { - "x": 506, - "y": 417 - }, - "data": { - "label": "presure", - "initial_value": "0", - "reset_times": "", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 200, - "height": 48 - }, - "selected": false, - "dragging": false - }, - { - "id": "20", - "type": "wall", - "position": { - "x": 797, - "y": 244 - }, - "data": { - "label": "wall 20", - "D_0": "0.1", - "E_D": "0", - "n_vertices": "100", - "surface_area": "", - "temperature": "300", - "thickness": "2", - "T": "0.1", - "tau": "0", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 70, - "height": 200 - }, - "selected": false, - "dragging": false - }, - { - "id": "21", - "type": "constant", - "position": { - "x": 867.040214603105, - "y": 119.34307354934961 - }, - "data": { - "label": "constant 21", - "value": "0", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 206, - "height": 54 - }, - "selected": false, - "dragging": false - }, - { - "id": "22", - "type": "scope", - "position": { - "x": 857.819281205265, - "y": 634.6385624105301 - }, - "data": { - "label": "Pressure scope", - "labels": "", - "sampling_rate": "", - "t_wait": "", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 120, - "height": 140 - }, - "selected": false, - "dragging": false - }, - { - "id": "23", - "type": "stepsource", - "position": { - "x": 214, - "y": 121 - }, - "data": { - "label": "stepsource 23", - "amplitude": "1", - "tau": "0.5", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 120, - "height": 120 - }, - "selected": false, - "dragging": false - }, - { - "id": "24", - "type": "scope", - "position": { - "x": 983, - "y": 351 - }, - "data": { - "label": "scope 24", - "labels": "", - "sampling_rate": "", - "t_wait": "", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 120, - "height": 140 - }, - "selected": false, - "dragging": false - }, - { - "id": "25", - "type": "scope", - "position": { - "x": 470.81928120526504, - "y": 512.6385624105301 - }, - "data": { - "label": "Wall 1 scope", - "labels": "", - "sampling_rate": "", - "t_wait": "", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 120, - "height": 140 - }, - "selected": false, - "dragging": false - } - ], - "edges": [ - { - "id": "e4-19-from_flux_L", - "source": "4", - "target": "19", - "sourceHandle": "flux_L", - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - }, - "selected": false - }, - { - "id": "e19-20-to_c_0", - "source": "19", - "target": "20", - "sourceHandle": null, - "targetHandle": "c_0", - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - }, - "selected": false - }, - { - "id": "e20-19-from_flux_0", - "source": "20", - "target": "19", - "sourceHandle": "flux_0", - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - }, - "selected": false - }, - { - "id": "e19-22", - "source": "19", - "target": "22", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - }, - "selected": false - }, - { - "id": "e21-20-to_c_L", - "source": "21", - "target": "20", - "sourceHandle": null, - "targetHandle": "c_L", - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e19-4-to_c_L", - "source": "19", - "target": "4", - "sourceHandle": null, - "targetHandle": "c_L", - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e23-4-to_c_0", - "source": "23", - "target": "4", - "sourceHandle": null, - "targetHandle": "c_0", - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e23-22", - "source": "23", - "target": "22", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - }, - "selected": false - }, - { - "id": "e20-24-from_flux_L", - "source": "20", - "target": "24", - "sourceHandle": "flux_L", - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e23-25", - "source": "23", - "target": "25", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e4-25-from_flux_L", - "source": "4", - "target": "25", - "sourceHandle": "flux_L", - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - } - ], - "nodeCounter": 26, - "solverParams": { - "dt": "0.1", - "dt_min": "1e-6", - "dt_max": "1.0", - "Solver": "SSPRK22", - "tolerance_fpi": "1e-6", - "iterations_max": "100", - "log": "true", - "simulation_duration": "3", - "extra_params": "{}" - }, - "globalVariables": [], - "events": [], - "pythonCode": "# Define your Python variables and functions here\n# Example:\n# my_variable = 42\n# def my_function(x):\n# return x * 2\n" -} \ No newline at end of file diff --git a/example_graphs/fmcw_radar.json b/example_graphs/fmcw_radar.json deleted file mode 100644 index be0bdc09..00000000 --- a/example_graphs/fmcw_radar.json +++ /dev/null @@ -1,417 +0,0 @@ -{ - "version": { - "pathsim_version": "0.8.2", - "pathview_version": "0.1.dev470+g017515800.d20250812" - }, - "nodes": [ - { - "id": "0", - "type": "chirpsource", - "position": { - "x": 333.5804481746493, - "y": 283.5 - }, - "data": { - "label": "chirpsource 0", - "nodeColor": "#DDE6ED", - "BW": "B", - "T": "T", - "amplitude": "", - "f0": "f_min", - "phase": "", - "sampling_rate": "", - "sig_cum": "", - "sig_white": "" - }, - "measured": { - "width": 206, - "height": 54 - }, - "selected": false, - "dragging": false - }, - { - "id": "1", - "type": "adder", - "position": { - "x": 512.9257029406897, - "y": 374.38787342763374 - }, - "data": { - "label": "adder 1", - "nodeColor": "#DDE6ED", - "operations": "" - }, - "measured": { - "width": 64, - "height": 64 - }, - "selected": false, - "dragging": false - }, - { - "id": "2", - "type": "delay", - "position": { - "x": 710.3880930005229, - "y": 327.82565902611174 - }, - "data": { - "label": "delay 2", - "nodeColor": "#DDE6ED", - "tau": "tau" - }, - "measured": { - "width": 96, - "height": 76 - }, - "selected": false, - "dragging": false - }, - { - "id": "3", - "type": "multiplier", - "position": { - "x": 236.0364230167882, - "y": 507.36942008161725 - }, - "data": { - "label": "multiplier 3", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 64, - "height": 64 - }, - "selected": false, - "dragging": false - }, - { - "id": "4", - "type": "butterworthlowpass", - "position": { - "x": 350.42271490473155, - "y": 559.0551849946265 - }, - "data": { - "label": "butterworthlowpass 4", - "nodeColor": "#DDE6ED", - "Fc": "f_trg*3", - "n": "2" - }, - "measured": { - "width": 200, - "height": 48 - }, - "selected": false, - "dragging": false - }, - { - "id": "5", - "type": "spectrum", - "position": { - "x": 915.6736822552208, - "y": 421.4792618661594 - }, - "data": { - "label": "spectrum 5", - "nodeColor": "#DDE6ED", - "alpha": "", - "freq": "np.logspace(6, 10, 500)", - "labels": "", - "t_wait": "" - }, - "measured": { - "width": 120, - "height": 140 - }, - "selected": false, - "dragging": false - }, - { - "id": "6", - "type": "scope", - "position": { - "x": 708.2559315424497, - "y": 597.9077332699177 - }, - "data": { - "label": "scope 6", - "nodeColor": "#DDE6ED", - "labels": "", - "sampling_rate": "", - "t_wait": "" - }, - "measured": { - "width": 120, - "height": 140 - }, - "selected": false, - "dragging": false - } - ], - "edges": [ - { - "id": "e0-1", - "source": "0", - "target": "1", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e1-2", - "source": "1", - "target": "2", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e1-3", - "source": "1", - "target": "3", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e1-6", - "source": "1", - "target": "6", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e1-5", - "source": "1", - "target": "5", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e3-4", - "source": "3", - "target": "4", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e3-6", - "source": "3", - "target": "6", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e3-5", - "source": "3", - "target": "5", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e4-6", - "source": "4", - "target": "6", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e4-5", - "source": "4", - "target": "5", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e2-3", - "source": "2", - "target": "3", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e2-6", - "source": "2", - "target": "6", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e2-5", - "source": "2", - "target": "5", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - } - ], - "nodeCounter": 7, - "solverParams": { - "dt": "1e-11", - "dt_min": "1e-16", - "dt_max": "", - "Solver": "SSPRK22", - "tolerance_fpi": "1e-10", - "iterations_max": "200", - "log": "true", - "simulation_duration": "T", - "extra_params": "{}" - }, - "globalVariables": [], - "events": [], - "pythonCode": "#natural constants (approximately)\nc0 = 3e8\n\n#chirp parameters\nB, T, f_min = 5e9, 5e-7, 1e9\n\n#delay for target emulation\ntau = 2e-9\n\n#for reference, the expected target distance\nR = c0 * tau / 2\n\n#and the corresponding frequency\nf_trg = 2 * R * B / (T * c0)" -} \ No newline at end of file diff --git a/example_graphs/harmonic_oscillator.json b/example_graphs/harmonic_oscillator.json deleted file mode 100644 index 69054135..00000000 --- a/example_graphs/harmonic_oscillator.json +++ /dev/null @@ -1,326 +0,0 @@ -{ - "version": { - "pathsim_version": "0.8.2", - "pathview_version": "0.1.dev470+g017515800.d20250812" - }, - "nodes": [ - { - "id": "1", - "type": "integrator", - "position": { - "x": 501.13753581661877, - "y": 120.37249283667622 - }, - "data": { - "label": "integrator x(t)", - "initial_value": "x0", - "reset_times": "", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 200, - "height": 48 - }, - "selected": false, - "dragging": false - }, - { - "id": "2", - "type": "scope", - "position": { - "x": 725.3982808022922, - "y": -45.558739255014345 - }, - "data": { - "label": "scope 2", - "labels": "", - "sampling_rate": "", - "t_wait": "", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 120, - "height": 140 - }, - "selected": false, - "dragging": false - }, - { - "id": "3", - "type": "adder", - "position": { - "x": 113.96275071633238, - "y": 109.59598853868192 - }, - "data": { - "label": "adder 3", - "operations": "", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 64, - "height": 64 - }, - "selected": false, - "dragging": false - }, - { - "id": "4", - "type": "integrator", - "position": { - "x": 239, - "y": 122 - }, - "data": { - "label": "integrator v(t)", - "initial_value": "v0", - "reset_times": "", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 200, - "height": 48 - }, - "selected": false, - "dragging": false - }, - { - "id": "7", - "type": "amplifier_reverse", - "position": { - "x": 272.4928366762178, - "y": 292.1404011461318 - }, - "data": { - "label": "Amp 1", - "gain": "-c/m", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 90, - "height": 80 - }, - "selected": false, - "dragging": false - }, - { - "id": "8", - "type": "amplifier_reverse", - "position": { - "x": 457.80802292263616, - "y": 373.9398280802293 - }, - "data": { - "label": "Amp 2", - "gain": "-k/m", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 90, - "height": 80 - }, - "selected": false, - "dragging": false - } - ], - "edges": [ - { - "id": "e1-2", - "source": "1", - "target": "2", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e4-1", - "source": "4", - "target": "1", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e1-8", - "source": "1", - "target": "8", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e8-3", - "source": "8", - "target": "3", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e7-3", - "source": "7", - "target": "3", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e3-4", - "source": "3", - "target": "4", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e4-2", - "source": "4", - "target": "2", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e4-7", - "source": "4", - "target": "7", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - } - ], - "nodeCounter": 9, - "solverParams": { - "dt": "0.01", - "dt_min": "1e-6", - "dt_max": "1.0", - "Solver": "SSPRK22", - "tolerance_fpi": "1e-6", - "iterations_max": "100", - "log": "true", - "simulation_duration": "25", - "extra_params": "{}" - }, - "globalVariables": [ - { - "id": "1754159829210", - "name": "x0", - "value": "2", - "nameError": false - }, - { - "id": "1754159834189", - "name": "v0", - "value": "5", - "nameError": false - }, - { - "id": "1754159838558", - "name": "m", - "value": "0.8", - "nameError": false - }, - { - "id": "1754159842904", - "name": "c", - "value": "0.2", - "nameError": false - }, - { - "id": "1754159846822", - "name": "k", - "value": "1.5", - "nameError": false - } - ], - "events": [], - "pythonCode": "# Define your Python variables and functions here\n# Example:\n# my_variable = 42\n# def my_function(x):\n# return x * 2\n" -} \ No newline at end of file diff --git a/example_graphs/linear_feedback.json b/example_graphs/linear_feedback.json deleted file mode 100644 index ca9a4c10..00000000 --- a/example_graphs/linear_feedback.json +++ /dev/null @@ -1,255 +0,0 @@ -{ - "version": { - "pathsim_version": "0.8.2", - "pathview_version": "0.1.dev470+g017515800.d20250812" - }, - "nodes": [ - { - "id": "1", - "type": "stepsource", - "position": { - "x": 283.4511234320089, - "y": -14.228948131394702 - }, - "data": { - "label": "Source s(t)", - "amplitude": "1", - "tau": "tau", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 120, - "height": 120 - }, - "selected": true, - "dragging": false - }, - { - "id": "2", - "type": "scope", - "position": { - "x": 884.901286500961, - "y": 70.57000250921425 - }, - "data": { - "label": "Scope", - "labels": "", - "sampling_rate": "", - "t_wait": "", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 120, - "height": 140 - }, - "selected": false, - "dragging": false - }, - { - "id": "4", - "type": "amplifier_reverse", - "position": { - "x": 539.9858345687064, - "y": 394.2551349419692 - }, - "data": { - "label": "Amp a", - "gain": "a", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 90, - "height": 80 - }, - "selected": false, - "dragging": false - }, - { - "id": "5", - "type": "integrator", - "position": { - "x": 522.9520384414344, - "y": 208.06441872308315 - }, - "data": { - "label": "Integrator x(t)", - "initial_value": "x0", - "reset_times": "", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 200, - "height": 48 - }, - "selected": false, - "dragging": false - }, - { - "id": "6", - "type": "adder", - "position": { - "x": 400, - "y": 200 - }, - "data": { - "label": "adder 6", - "operations": "", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 64, - "height": 64 - } - } - ], - "edges": [ - { - "id": "e5-4", - "source": "5", - "target": "4", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e5-2", - "source": "5", - "target": "2", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e1-6", - "source": "1", - "target": "6", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e4-6", - "source": "4", - "target": "6", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e1-2", - "source": "1", - "target": "2", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e6-5", - "source": "6", - "target": "5", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - } - ], - "nodeCounter": 7, - "solverParams": { - "dt": "0.01", - "dt_min": "1e-6", - "dt_max": "1.0", - "Solver": "SSPRK22", - "tolerance_fpi": "1e-6", - "iterations_max": "100", - "log": "true", - "simulation_duration": "4*tau", - "extra_params": "{}" - }, - "globalVariables": [ - { - "id": "1754160227284", - "name": "a", - "value": "-1", - "nameError": false - }, - { - "id": "1754160231388", - "name": "x0", - "value": "2", - "nameError": false - }, - { - "id": "1754160248756", - "name": "tau", - "value": "3", - "nameError": false - } - ], - "events": [], - "pythonCode": "# Define your Python variables and functions here\n# Example:\n# my_variable = 42\n# def my_function(x):\n# return x * 2\n" -} \ No newline at end of file diff --git a/example_graphs/pendulum.json b/example_graphs/pendulum.json deleted file mode 100644 index 5137e083..00000000 --- a/example_graphs/pendulum.json +++ /dev/null @@ -1,236 +0,0 @@ -{ - "nodes": [ - { - "id": "0", - "type": "integrator", - "position": { - "x": 31.949874679482008, - "y": 259.0878749359728 - }, - "data": { - "label": "In2", - "nodeColor": "#DDE6ED", - "initial_value": "phi0", - "reset_times": "" - }, - "measured": { - "width": 200, - "height": 48 - }, - "selected": false, - "dragging": false - }, - { - "id": "1", - "type": "integrator", - "position": { - "x": -57.80185604593987, - "y": 375.7296757198792 - }, - "data": { - "label": "In1", - "nodeColor": "#DDE6ED", - "initial_value": "omega0", - "reset_times": "" - }, - "measured": { - "width": 200, - "height": 48 - }, - "selected": false, - "dragging": false - }, - { - "id": "2", - "type": "amplifier", - "position": { - "x": 668.5474040864929, - "y": 284.57731162361034 - }, - "data": { - "label": "amp", - "nodeColor": "#DDE6ED", - "gain": "-g/l" - }, - "measured": { - "width": 90, - "height": 80 - }, - "selected": false, - "dragging": false - }, - { - "id": "3", - "type": "function", - "position": { - "x": 347.434213365941, - "y": 292.6194354154026 - }, - "data": { - "label": "function 3", - "nodeColor": "#DDE6ED", - "inputCount": 1, - "outputCount": 1, - "func": "np.sin" - }, - "measured": { - "width": 200, - "height": 80 - }, - "selected": false, - "dragging": false - }, - { - "id": "4", - "type": "scope", - "position": { - "x": 349.12259412272545, - "y": 495.40186575788914 - }, - "data": { - "label": "scope 4", - "nodeColor": "#DDE6ED", - "labels": "[ \"angle\", \"angular velocity\"]", - "sampling_rate": "", - "t_wait": "" - }, - "measured": { - "width": 120, - "height": 140 - }, - "selected": false, - "dragging": false - } - ], - "edges": [ - { - "id": "e1-0", - "source": "1", - "target": "0", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e1-4", - "source": "1", - "target": "4", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e0-3-to_target-0", - "source": "0", - "target": "3", - "sourceHandle": null, - "targetHandle": "target-0", - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e0-4", - "source": "0", - "target": "4", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e3-2-from_source-0", - "source": "3", - "target": "2", - "sourceHandle": "source-0", - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e2-1", - "source": "2", - "target": "1", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - } - ], - "nodeCounter": 5, - "solverParams": { - "dt": "0.1", - "dt_min": "1e-6", - "dt_max": "1.0", - "Solver": "RKCK54", - "tolerance_fpi": "1e-6", - "iterations_max": "100", - "log": "true", - "simulation_duration": "15", - "extra_params": "{\"tolerance_lte_rel\":1e-6, \"tolerance_lte_abs\": 1e-8}" - }, - "globalVariables": [], - "events": [], - "pythonCode": "import numpy as np\n#initial angle and angular velocity\nphi0, omega0 = 0.9*np.pi, 0\n\n#parameters (gravity, length)\ng, l = 9.81, 1" -} \ No newline at end of file diff --git a/example_graphs/pid.json b/example_graphs/pid.json deleted file mode 100644 index 171071e7..00000000 --- a/example_graphs/pid.json +++ /dev/null @@ -1,336 +0,0 @@ -{ - "version": { - "pathsim_version": "0.8.2", - "pathview_version": "0.1.dev470+g017515800.d20250812" - }, - "nodes": [ - { - "data": { - "label": "Error", - "nodeColor": "#DDE6ED" - }, - "dragging": false, - "id": "error_summer", - "measured": { - "height": 64, - "width": 64 - }, - "position": { - "x": 225, - "y": 223 - }, - "selected": false, - "type": "adder" - }, - { - "data": { - "gain": "0.4", - "label": "Feedback Gain", - "nodeColor": "#DDE6ED" - }, - "dragging": false, - "id": "feedback_gain", - "measured": { - "height": 80, - "width": 90 - }, - "position": { - "x": 416, - "y": 445 - }, - "selected": false, - "type": "amplifier" - }, - { - "data": { - "gain": "-1", - "label": "x -1", - "nodeColor": "#DDE6ED" - }, - "dragging": false, - "id": "minus_one", - "measured": { - "height": 80, - "width": 90 - }, - "position": { - "x": 598, - "y": 382 - }, - "selected": false, - "type": "amplifier" - }, - { - "id": "1", - "type": "integrator", - "position": { - "x": 657, - "y": 101 - }, - "data": { - "label": "integrator 1", - "initial_value": "", - "reset_times": "", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 200, - "height": 48 - }, - "selected": false, - "dragging": false - }, - { - "id": "3", - "type": "pid", - "position": { - "x": 392, - "y": 116 - }, - "data": { - "label": "pid 3", - "Kp": "1.5", - "Ki": "0.5", - "Kd": "0.1", - "f_max": "10", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 200, - "height": 48 - }, - "selected": false, - "dragging": false - }, - { - "id": "4", - "type": "stepsource", - "position": { - "x": 90.83795441551936, - "y": 44.05817319772359 - }, - "data": { - "label": "Reference Input", - "amplitude": "1", - "tau": "1", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 120, - "height": 120 - }, - "selected": false, - "dragging": false - }, - { - "id": "5", - "type": "scope", - "position": { - "x": 1020.7686932100712, - "y": 141.28116398185887 - }, - "data": { - "label": "scope 5", - "labels": "", - "sampling_rate": "", - "t_wait": "", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 120, - "height": 140 - }, - "selected": false, - "dragging": false - } - ], - "edges": [ - { - "id": "eerror_summer-3", - "source": "error_summer", - "target": "3", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e3-1", - "source": "3", - "target": "1", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "efeedback_gain-minus_one", - "source": "feedback_gain", - "target": "minus_one", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "eminus_one-error_summer", - "source": "minus_one", - "target": "error_summer", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e1-feedback_gain", - "source": "1", - "target": "feedback_gain", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - }, - "selected": false - }, - { - "id": "e4-error_summer", - "source": "4", - "target": "error_summer", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e4-5", - "source": "4", - "target": "5", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "eerror_summer-5", - "source": "error_summer", - "target": "5", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "efeedback_gain-5", - "source": "feedback_gain", - "target": "5", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - } - ], - "nodeCounter": 6, - "solverParams": { - "dt": "0.01", - "dt_min": "1e-6", - "dt_max": "1.0", - "Solver": "SSPRK22", - "tolerance_fpi": "1e-6", - "iterations_max": "100", - "log": "true", - "simulation_duration": "35", - "extra_params": "{}" - }, - "globalVariables": [], - "events": [], - "pythonCode": "# Define your Python variables and functions here\n# Example:\n# my_variable = 42\n# def my_function(x):\n# return x * 2\n" -} \ No newline at end of file diff --git a/example_graphs/spectrum.json b/example_graphs/spectrum.json deleted file mode 100644 index ce0c7fa6..00000000 --- a/example_graphs/spectrum.json +++ /dev/null @@ -1,199 +0,0 @@ -{ - "version": { - "pathsim_version": "0.8.2", - "pathview_version": "0.1.dev470+g017515800.d20250812" - }, - "nodes": [ - { - "id": "0", - "type": "spectrum", - "position": { - "x": 487.91666412353516, - "y": 247.4166660308838 - }, - "data": { - "label": "spectrum 0", - "alpha": "", - "freq": "np.linspace(0, 1*np.pi, 600)", - "labels": "", - "t_wait": "", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 120, - "height": 140 - }, - "selected": false, - "dragging": false - }, - { - "id": "1", - "type": "source", - "position": { - "x": 101.91666412353516, - "y": 210.4166660308838 - }, - "data": { - "label": "High frequency", - "func": "lambda t: np.sin(1.5*t *2*np.pi)", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 206, - "height": 54 - }, - "selected": false, - "dragging": false - }, - { - "id": "2", - "type": "scope", - "position": { - "x": 479.5833282470703, - "y": 446.0833320617676 - }, - "data": { - "label": "scope 2", - "labels": "", - "sampling_rate": "", - "t_wait": "", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 120, - "height": 140 - }, - "selected": false, - "dragging": false - }, - { - "id": "3", - "type": "source", - "position": { - "x": -34.083335876464844, - "y": 299.4166660308838 - }, - "data": { - "label": "Low frequency", - "func": "lambda t: np.sin(0.5*t * 2*np.pi)", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 206, - "height": 54 - }, - "selected": false, - "dragging": false - }, - { - "id": "4", - "type": "adder", - "position": { - "x": 283.1666603088379, - "y": 449.4166650772095 - }, - "data": { - "label": "adder 4", - "operations": "", - "nodeColor": "#DDE6ED" - }, - "measured": { - "width": 64, - "height": 64 - } - } - ], - "edges": [ - { - "id": "e3-4", - "source": "3", - "target": "4", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e4-0", - "source": "4", - "target": "0", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e4-2", - "source": "4", - "target": "2", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e1-4", - "source": "1", - "target": "4", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - } - ], - "nodeCounter": 5, - "solverParams": { - "dt": "0.01", - "dt_min": "1e-6", - "dt_max": "1.0", - "Solver": "SSPRK22", - "tolerance_fpi": "1e-6", - "iterations_max": "100", - "log": "true", - "simulation_duration": "2*2*np.pi", - "extra_params": "{}" - }, - "globalVariables": [], - "events": [], - "pythonCode": "# Define your Python variables and functions here\n# Example:\n# my_variable = 42\n# def my_function(x):\n# return x * 2\n" -} \ No newline at end of file diff --git a/example_graphs/stick_slip.json b/example_graphs/stick_slip.json deleted file mode 100644 index b30d6c11..00000000 --- a/example_graphs/stick_slip.json +++ /dev/null @@ -1,586 +0,0 @@ -{ - "version": { - "pathsim_version": "0.8.2", - "pathview_version": "0.2.dev12+gf6e0ac326" - }, - "nodes": [ - { - "id": "4", - "type": "switch", - "position": { - "x": 287.58553688651915, - "y": 295.276810064864 - }, - "data": { - "label": "switch", - "nodeColor": "#DDE6ED", - "state": "1" - }, - "measured": { - "width": 200, - "height": 48 - }, - "selected": false, - "dragging": false - }, - { - "id": "5", - "type": "integrator", - "position": { - "x": 606.8593557699816, - "y": 345.51545415024714 - }, - "data": { - "label": "integrator 2", - "nodeColor": "#DDE6ED", - "initial_value": "", - "reset_times": "" - }, - "measured": { - "width": 200, - "height": 48 - }, - "selected": false, - "dragging": false - }, - { - "id": "6", - "type": "amplifier_reverse", - "position": { - "x": 352.6666717529297, - "y": 410.6666660308838 - }, - "data": { - "label": "amp 1", - "nodeColor": "#DDE6ED", - "gain": "-d" - }, - "measured": { - "width": 90, - "height": 80 - }, - "selected": false - }, - { - "id": "7", - "type": "amplifier_reverse", - "position": { - "x": 519.9064229187702, - "y": 477.3246406593927 - }, - "data": { - "label": "amp 2", - "nodeColor": "#DDE6ED", - "gain": "-k" - }, - "measured": { - "width": 90, - "height": 80 - }, - "selected": false, - "dragging": false - }, - { - "id": "8", - "type": "adder", - "position": { - "x": -244.1463565072987, - "y": 380.748898887575 - }, - "data": { - "label": "add", - "nodeColor": "#DDE6ED", - "operations": "" - }, - "measured": { - "width": 64, - "height": 64 - }, - "selected": false, - "dragging": false - }, - { - "id": "9", - "type": "amplifier", - "position": { - "x": -126.87085819778244, - "y": 388.65952949607794 - }, - "data": { - "label": "amp 3", - "nodeColor": "#DDE6ED", - "gain": "1/m" - }, - "measured": { - "width": 90, - "height": 80 - }, - "selected": false, - "dragging": false - }, - { - "id": "10", - "type": "integrator", - "position": { - "x": 36.22081843250754, - "y": 388.0656128814263 - }, - "data": { - "label": "integrator 1", - "nodeColor": "#DDE6ED", - "initial_value": "v0", - "reset_times": "" - }, - "measured": { - "width": 200, - "height": 48 - }, - "selected": false, - "dragging": false - }, - { - "id": "11", - "type": "function", - "position": { - "x": 124.20448463464157, - "y": 166.46144190180163 - }, - "data": { - "label": "Function", - "nodeColor": "#DDE6ED", - "inputCount": "2", - "outputCount": 1, - "func": "f_coulomb" - }, - "measured": { - "width": 200, - "height": 100 - }, - "selected": false, - "dragging": false - }, - { - "id": "12", - "type": "source", - "position": { - "x": -267.3083069831488, - "y": 56.69689434802376 - }, - "data": { - "label": "source", - "nodeColor": "#DDE6ED", - "func": "v_belt" - }, - "measured": { - "width": 206, - "height": 54 - }, - "selected": false, - "dragging": false - }, - { - "id": "18", - "type": "scope", - "position": { - "x": 195.9877730435556, - "y": -66.655183047029 - }, - "data": { - "label": "scope 18", - "nodeColor": "#DDE6ED", - "labels": "", - "sampling_rate": "", - "t_wait": "" - }, - "measured": { - "width": 120, - "height": 140 - }, - "selected": false, - "dragging": false - }, - { - "id": "19", - "type": "scope", - "position": { - "x": 763.7772276046377, - "y": 100.40917312631821 - }, - "data": { - "label": "scope 19", - "nodeColor": "#DDE6ED", - "labels": "", - "sampling_rate": "", - "t_wait": "" - }, - "measured": { - "width": 120, - "height": 140 - }, - "selected": false, - "dragging": false - } - ], - "edges": [ - { - "id": "e5-7", - "source": "5", - "target": "7", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e6-8", - "source": "6", - "target": "8", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e7-8", - "source": "7", - "target": "8", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e8-9", - "source": "8", - "target": "9", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e9-10", - "source": "9", - "target": "10", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e11-8-from_source-0", - "source": "11", - "target": "8", - "sourceHandle": "source-0", - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - }, - "selected": false - }, - { - "id": "e11-18-from_source-0", - "source": "11", - "target": "18", - "sourceHandle": "source-0", - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - }, - "selected": false - }, - { - "id": "e8-18", - "source": "8", - "target": "18", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - }, - "selected": false - }, - { - "id": "e5-19", - "source": "5", - "target": "19", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e12-19", - "source": "12", - "target": "19", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - }, - "selected": false - }, - { - "id": "e4-5", - "source": "4", - "target": "5", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e4-6", - "source": "4", - "target": "6", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - }, - "selected": false - }, - { - "id": "e4-19", - "source": "4", - "target": "19", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e10-4-to_target-1", - "source": "10", - "target": "4", - "sourceHandle": null, - "targetHandle": "target-1", - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e10-11-to_target-0", - "source": "10", - "target": "11", - "sourceHandle": null, - "targetHandle": "target-0", - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e12-4-to_target-0", - "source": "12", - "target": "4", - "sourceHandle": null, - "targetHandle": "target-0", - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e12-11-to_target-1", - "source": "12", - "target": "11", - "sourceHandle": null, - "targetHandle": "target-1", - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - } - ], - "nodeCounter": 20, - "solverParams": { - "dt": "0.01", - "dt_min": "1e-16", - "dt_max": "0.1", - "Solver": "RKBS32", - "tolerance_fpi": "1e-10", - "iterations_max": "200", - "log": "true", - "simulation_duration": "100", - "extra_params": "{\"tolerance_lte_abs\":1e-6, \"tolerance_lte_rel\":1e-4}" - }, - "globalVariables": [], - "events": [ - { - "name": "E_slip_to_stick", - "type": "ZeroCrossing", - "func_evt": "slip_to_stick_evt", - "func_act": "slip_to_stick_act", - "tolerance": "1e-3", - "id": 1755090589822 - }, - { - "name": "E_stick_to_slip", - "type": "ZeroCrossing", - "func_evt": "stick_to_slip_evt", - "func_act": "stick_to_slip_act", - "tolerance": "1e-3", - "id": 1755090603806 - } - ], - "pythonCode": "import numpy as np\n#initial position and velocity\nx0, v0 = 0, 0\n\n#system parameters\nm = 20.0 # mass\nk = 70.0 # spring constant\nd = 10.0 # spring damping\nmu = 1.5 # friction coefficient\ng = 9.81 # gravity\nv = 3.0 # belt velocity magnitude\nT = 50.0 # excitation period\n\nF_c = mu * m * g # friction force\n\n#function for belt velocity\ndef v_belt(t):\n return v * np.sin(2*np.pi*t/T)\n\n#function for coulomb friction force\ndef f_coulomb(v, vb):\n return F_c * np.sign(vb - v)\n\ndef slip_to_stick_evt(t):\n _1, v_box , _2 = switch_4()\n _1, v_belt, _2 = source_12()\n dv = v_box - v_belt\n\n return dv\n\ndef slip_to_stick_act(t):\n\n #change switch state\n switch_4.select(0)\n\n integrator_1_10.off()\n function_11.off()\n\n E_slip_to_stick.off()\n E_stick_to_slip.on()\n\ndef stick_to_slip_evt(t):\n _1, F, _2 = add_8()\n return F_c - abs(F)\n\ndef stick_to_slip_act(t):\n\n #change switch state\n switch_4.select(1)\n\n integrator_1_10.on()\n function_11.on()\n\n #set integrator state\n _1, v_box , _2 = switch_4()\n integrator_1_10.engine.set(v_box)\n\n E_slip_to_stick.on()\n E_stick_to_slip.off()\n" -} \ No newline at end of file diff --git a/example_graphs/thermostat.json b/example_graphs/thermostat.json deleted file mode 100644 index 9270319a..00000000 --- a/example_graphs/thermostat.json +++ /dev/null @@ -1,288 +0,0 @@ -{ - "nodes": [ - { - "id": "0", - "type": "constant", - "position": { - "x": 362.0580206745773, - "y": 113.03456573089142 - }, - "data": { - "label": "Heater", - "nodeColor": "#DDE6ED", - "value": "H" - }, - "measured": { - "width": 206, - "height": 54 - }, - "selected": false, - "dragging": false - }, - { - "id": "1", - "type": "constant", - "position": { - "x": 358.8241979165283, - "y": 278.1947482777457 - }, - "data": { - "label": "ambient", - "nodeColor": "#DDE6ED", - "value": "a*Ta" - }, - "measured": { - "width": 206, - "height": 54 - }, - "selected": false, - "dragging": false - }, - { - "id": "2", - "type": "adder", - "position": { - "x": 701.2462888781952, - "y": 219.1770926972872 - }, - "data": { - "label": "adder 2", - "nodeColor": "#DDE6ED", - "operations": "" - }, - "measured": { - "width": 64, - "height": 64 - }, - "selected": false, - "dragging": false - }, - { - "id": "3", - "type": "integrator", - "position": { - "x": 848.807989889327, - "y": 225.51709663815075 - }, - "data": { - "label": "integ", - "nodeColor": "#DDE6ED", - "initial_value": "Ta", - "reset_times": "" - }, - "measured": { - "width": 200, - "height": 48 - }, - "selected": false, - "dragging": false - }, - { - "id": "4", - "type": "amplifier_reverse", - "position": { - "x": 796.3023843801966, - "y": 344.45184195466436 - }, - "data": { - "label": "amp", - "nodeColor": "#DDE6ED", - "gain": "-a" - }, - "measured": { - "width": 90, - "height": 80 - }, - "selected": false, - "dragging": false - }, - { - "id": "5", - "type": "scope", - "position": { - "x": 1157.8007854929265, - "y": 66.31785200000635 - }, - "data": { - "label": "scope 5", - "nodeColor": "#DDE6ED", - "labels": "", - "sampling_rate": "", - "t_wait": "" - }, - "measured": { - "width": 120, - "height": 140 - }, - "selected": false, - "dragging": false - } - ], - "edges": [ - { - "id": "e0-2", - "source": "0", - "target": "2", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e1-2", - "source": "1", - "target": "2", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e2-3", - "source": "2", - "target": "3", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e4-2", - "source": "4", - "target": "2", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e3-4", - "source": "3", - "target": "4", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e3-5", - "source": "3", - "target": "5", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - }, - { - "id": "e0-5", - "source": "0", - "target": "5", - "sourceHandle": null, - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - } - ], - "nodeCounter": 6, - "solverParams": { - "dt": "0.01", - "dt_min": "1e-6", - "dt_max": "0.05", - "Solver": "SSPRK22", - "tolerance_fpi": "1e-6", - "iterations_max": "100", - "log": "true", - "simulation_duration": "30", - "extra_params": "{}" - }, - "globalVariables": [], - "events": [ - { - "name": "heater_off", - "type": "ZeroCrossingUp", - "func_evt": "def func_evt(t):\n *_, x = integ_3()\n return x - Kp", - "func_act": "def func_act(t):\n heater_0.off()", - "tolerance": "1e-8", - "id": 1755016407956 - }, - { - "name": "heater_on", - "type": "ZeroCrossingDown", - "func_evt": "def func_evt(t):\n *_, x = integ_3()\n return x - Km", - "func_act": "def func_act(t):\n heater_0.on()", - "tolerance": "1e-8", - "id": 1755016519588 - } - ], - "pythonCode": "a = 0.3 #thermal capacity of room\nTa = 10 #ambient temperature\nH = 5 #heater power\nKp = 25 #upper temperature threshold\nKm = 23 #lower temperature threshold" -} \ No newline at end of file diff --git a/example_graphs/van_der_pol.json b/example_graphs/van_der_pol.json deleted file mode 100644 index a17ae047..00000000 --- a/example_graphs/van_der_pol.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "version": { - "pathsim_version": "0.8.2", - "pathview_version": "0.1.dev470+g017515800.d20250812" - }, - "nodes": [ - { - "id": "0", - "type": "ode", - "position": { - "x": 556.9108963492985, - "y": 352.7864675462546 - }, - "data": { - "label": "ode 0", - "nodeColor": "#DDE6ED", - "inputCount": "2", - "outputCount": 1, - "func": "lambda x,u,t: np.array([x[1], mu*(1 - x[0]**2)*x[1] - x[0]])", - "initial_value": "np.array([2, 0])", - "jac": "lambda x,u,t: np.array([[0, 1], [-mu*2*x[0]*x[1]-1, mu*(1 - x[0]**2)]])" - }, - "measured": { - "width": 200, - "height": 100 - }, - "selected": false, - "dragging": false - }, - { - "id": "1", - "type": "scope", - "position": { - "x": 899.75, - "y": 331 - }, - "data": { - "label": "scope 1", - "nodeColor": "#DDE6ED", - "labels": "", - "sampling_rate": "", - "t_wait": "" - }, - "measured": { - "width": 120, - "height": 140 - } - } - ], - "edges": [ - { - "id": "e0-1-from_source-0", - "source": "0", - "target": "1", - "sourceHandle": "source-0", - "targetHandle": null, - "type": "smoothstep", - "data": {}, - "style": { - "strokeWidth": 2, - "stroke": "#ECDFCC" - }, - "markerEnd": { - "type": "arrowclosed", - "width": 20, - "height": 20, - "color": "#ECDFCC" - } - } - ], - "nodeCounter": 2, - "solverParams": { - "dt": "0.01", - "dt_min": "1e-16", - "dt_max": "", - "Solver": "GEAR52A", - "tolerance_fpi": "1e-8", - "iterations_max": "200", - "log": "true", - "simulation_duration": "4*mu", - "extra_params": "{\"tolerance_lte_abs\": 1e-5, \"tolerance_lte_rel\": 1e-3}" - }, - "globalVariables": [ - { - "id": "1755103820369", - "name": "mu", - "value": "1000", - "nameError": false - } - ], - "events": [], - "pythonCode": "" -} \ No newline at end of file diff --git a/index.html b/index.html deleted file mode 100644 index 67d4631b..00000000 --- a/index.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - PathView - - - -