commit 8b07e483d2a8c5cf05b212e52e93db2e6ff8505c Author: gbanyan Date: Sun Dec 14 21:53:34 2025 +0800 Initial commit diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..08848b6 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,54 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: 'dist' + + 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/.gitignore b/.gitignore new file mode 100644 index 0000000..8852271 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* +.server.log +.server.pid + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea4cf9e --- /dev/null +++ b/README.md @@ -0,0 +1,229 @@ +# Pedigree Draw + +A professional pedigree (family tree) drawing tool for genetic counselors and bioinformatics professionals. + +## Features + +- Import/Export GATK PED files +- Interactive web-based editor +- Support for standard pedigree symbols (NSGC standards) +- Export to SVG, PNG, or PED format +- Full pedigree features: affected/unaffected/carrier status, twins, consanguinity, adoption markers, etc. + +--- + +## Quick Start + +### Prerequisites + +You need **Node.js** (version 18 or higher) installed on your computer. + +Check if you have it: + +```bash +node --version +``` + +If not installed, download from: https://nodejs.org/ (choose LTS version) + +--- + +### Step 1: Start the Server + +Open your terminal and navigate to the project folder: + +```bash +cd /home/gbanyan/projects/pedigree-draw +``` + +Run the start script: + +```bash +./start.sh +``` + +You should see output like: +``` +========================================== + Pedigree Draw Server Started! +========================================== + + Local: http://localhost:5173/pedigree-draw/ + Network: http://192.168.x.x:5173/pedigree-draw/ + + To stop the server, run: ./stop.sh +========================================== +``` + +--- + +### Step 2: Open in Browser + +Open your web browser and go to: + +- **On this computer:** http://localhost:5173/pedigree-draw/ +- **From another device on the same network:** Use the Network URL shown above + +--- + +### Step 3: Stop the Server + +When you're done, stop the server: + +```bash +./stop.sh +``` + +--- + +## How to Use the Application + +### Creating a New Pedigree + +1. Click **"New Pedigree"** in the left panel +2. Enter a Family ID (e.g., "FAM001") + +### Adding Persons + +Use the toolbar at the top: + +| Button | Description | +|--------|-------------| +| Square | Add Male | +| Circle | Add Female | +| Diamond | Add Unknown Sex | + +### Selecting a Person + +- **Click** on a person to select them +- A blue dashed border will appear around the selected person +- The right panel will show editable properties + +### Adding Relationships + +First **select a person**, then use these toolbar buttons: + +| Button | Description | +|--------|-------------| +| Add Spouse | Creates a spouse next to selected person with connection line | +| Add Child | Creates a child below the selected person | +| Add Parents | Creates both parents above the selected person | + +### Editing Properties + +When a person is selected, use the right panel to edit: + +- **Label**: Display name +- **Sex**: Male / Female / Unknown +- **Phenotype**: Unaffected / Affected / Carrier / Unknown +- **Status**: Deceased, Proband, Adopted, Miscarriage, Stillbirth + +### Canvas Controls + +| Button | Description | +|--------|-------------| +| + | Zoom in | +| - | Zoom out | +| Reset | Reset to 100% zoom, centered | +| Fit | Fit all content to screen | + +You can also: +- **Drag** persons to reposition them +- **Pan** the canvas by dragging the background +- **Scroll** to zoom in/out + +### Importing a PED File + +1. Click **"Import PED"** in the left panel, OR +2. Drag and drop a `.ped` file onto the drop zone + +### Exporting + +| Button | Description | +|--------|-------------| +| Export SVG | Vector image (for editing in Illustrator, etc.) | +| Export PNG | Raster image (for documents, presentations) | +| Export PED | GATK PED format file | + +### Undo/Redo + +Use the Undo/Redo buttons in the toolbar, or: +- **Ctrl+Z** to Undo +- **Ctrl+Y** to Redo + +--- + +## PED File Format + +The tool supports standard GATK PED format (6 columns, whitespace-separated): + +``` +FamilyID IndividualID PaternalID MaternalID Sex Phenotype +FAM001 father 0 0 1 1 +FAM001 mother 0 0 2 1 +FAM001 child1 father mother 1 2 +``` + +- **Sex**: 1 = Male, 2 = Female, 0 = Unknown +- **Phenotype**: 1 = Unaffected, 2 = Affected, 0 = Unknown +- **PaternalID/MaternalID**: Use "0" for unknown/founder + +--- + +## Troubleshooting + +### "Permission denied" when running ./start.sh + +Make sure the scripts are executable: +```bash +chmod +x start.sh stop.sh +``` + +### Server won't start + +1. Check if port 5173 is already in use: + ```bash + lsof -i :5173 + ``` + +2. Kill any existing process and try again: + ```bash + ./stop.sh + ./start.sh + ``` + +### Page won't load + +- Make sure you're using the correct URL with `/pedigree-draw/` at the end +- Try clearing your browser cache and refreshing + +### Can't access from another device + +- Make sure both devices are on the same network +- Check your firewall settings allow port 5173 +- Use the Network URL (not localhost) + +--- + +## Development + +### Build for Production + +```bash +npm run build +``` + +Output will be in the `dist/` folder. + +### Deploy to GitHub Pages + +```bash +npm run build +# Then upload the dist/ folder contents to your GitHub Pages +``` + +--- + +## License + +MIT diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 0000000..8d7ca13 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + pedigree-draw + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5b6dfa2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4019 @@ +{ + "name": "pedigree-draw", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pedigree-draw", + "version": "0.0.0", + "dependencies": { + "d3": "^7.9.0", + "html-to-image": "^1.11.13", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zundo": "^2.3.0", + "zustand": "^5.0.9" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/d3": "^7.4.3", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } + }, + "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.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "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.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "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-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "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.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.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.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "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.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "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/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.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "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.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "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.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "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.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "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.1", + "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.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "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.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "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.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "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.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "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/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT" + }, + "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/node": { + "version": "24.10.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", + "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", + "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", + "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/type-utils": "8.49.0", + "@typescript-eslint/utils": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.49.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", + "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", + "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.49.0", + "@typescript-eslint/types": "^8.49.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", + "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", + "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", + "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", + "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", + "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.49.0", + "@typescript-eslint/tsconfig-utils": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/visitor-keys": "8.49.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", + "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.49.0", + "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", + "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.49.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", + "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.53", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "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-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/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/baseline-browser-mapping": { + "version": "2.9.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz", + "integrity": "sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "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.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "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": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "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.30001760", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", + "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "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/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/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-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "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/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/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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "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==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "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==", + "license": "ISC", + "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==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "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==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "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==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "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==", + "license": "ISC", + "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==", + "license": "ISC", + "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==", + "license": "ISC", + "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.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "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/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "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/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", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "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": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "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.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "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/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/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==", + "dev": true, + "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==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.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-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.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "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/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/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/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/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "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-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/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "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/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/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/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.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "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/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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "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/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/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "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/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/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.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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/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/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/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "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", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "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.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "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==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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/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/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/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/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/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "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/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", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.49.0.tgz", + "integrity": "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.49.0", + "@typescript-eslint/parser": "8.49.0", + "@typescript-eslint/typescript-estree": "8.49.0", + "@typescript-eslint/utils": "8.49.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "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/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/vite": { + "version": "7.2.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "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/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/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/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/zod": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zundo": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/zundo/-/zundo-2.3.0.tgz", + "integrity": "sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/charkour" + }, + "peerDependencies": { + "zustand": "^4.3.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "zustand": { + "optional": false + } + } + }, + "node_modules/zustand": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..831eb55 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "pedigree-draw", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "d3": "^7.9.0", + "html-to-image": "^1.11.13", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "zundo": "^2.3.0", + "zustand": "^5.0.9" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/d3": "^7.4.3", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^7.2.4" + } +} diff --git a/public/sample.ped b/public/sample.ped new file mode 100644 index 0000000..dc49933 --- /dev/null +++ b/public/sample.ped @@ -0,0 +1,10 @@ +# Sample Pedigree File +# This is a three-generation family with affected individuals +# Format: FamilyID IndividualID PaternalID MaternalID Sex Phenotype +FAM001 GF 0 0 1 1 +FAM001 GM 0 0 2 2 +FAM001 F1 GF GM 1 1 +FAM001 M1 0 0 2 1 +FAM001 P1 F1 M1 1 2 +FAM001 P2 F1 M1 2 1 +FAM001 P3 F1 M1 1 2 diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/App/App.module.css b/src/components/App/App.module.css new file mode 100644 index 0000000..e3dc079 --- /dev/null +++ b/src/components/App/App.module.css @@ -0,0 +1,48 @@ +.app { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +.header { + display: flex; + align-items: center; + gap: 15px; + padding: 10px 20px; + background: #1976D2; + color: white; +} + +.title { + font-size: 20px; + font-weight: 600; + margin: 0; +} + +.subtitle { + font-size: 13px; + opacity: 0.85; +} + +.main { + flex: 1; + display: flex; + overflow: hidden; +} + +.canvasArea { + flex: 1; + overflow: hidden; +} + +.footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 20px; + background: #f5f5f5; + border-top: 1px solid #ddd; + font-size: 12px; + color: #666; +} diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx new file mode 100644 index 0000000..00ff9d8 --- /dev/null +++ b/src/components/App/App.tsx @@ -0,0 +1,68 @@ +/** + * App Component + * + * Main application layout + */ + +import { useEffect } from 'react'; +import { PedigreeCanvas } from '../PedigreeCanvas/PedigreeCanvas'; +import { Toolbar } from '../Toolbar/Toolbar'; +import { PropertyPanel } from '../PropertyPanel/PropertyPanel'; +import { RelationshipPanel } from '../RelationshipPanel/RelationshipPanel'; +import { FilePanel } from '../FilePanel/FilePanel'; +import { usePedigreeStore, useTemporalStore } from '@/store/pedigreeStore'; +import styles from './App.module.css'; + +export function App() { + const { clearSelection, selectedRelationshipId } = usePedigreeStore(); + const temporal = useTemporalStore(); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Undo: Ctrl/Cmd + Z + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + temporal.getState().undo(); + } + + // Redo: Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y + if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { + e.preventDefault(); + temporal.getState().redo(); + } + + // Escape: Clear selection + if (e.key === 'Escape') { + clearSelection(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [clearSelection, temporal]); + + return ( +
+
+

Pedigree Draw

+ Professional Pedigree Chart Editor +
+ + + +
+ +
+ +
+ {selectedRelationshipId ? : } +
+ +
+ Pedigree Draw - For genetic counselors and bioinformatics professionals + NSGC Standard Symbols +
+
+ ); +} diff --git a/src/components/FilePanel/FilePanel.module.css b/src/components/FilePanel/FilePanel.module.css new file mode 100644 index 0000000..849d2d9 --- /dev/null +++ b/src/components/FilePanel/FilePanel.module.css @@ -0,0 +1,109 @@ +.panel { + width: 220px; + background: white; + border-right: 1px solid #ddd; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.header { + padding: 12px 15px; + font-weight: 600; + font-size: 13px; + color: #555; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.section { + padding: 0 15px 15px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.button { + width: 100%; + padding: 10px 15px; + border: 1px solid #ddd; + background: white; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + text-align: left; + transition: all 0.15s ease; +} + +.button:hover:not(:disabled) { + background: #f5f5f5; + border-color: #bbb; +} + +.button:active:not(:disabled) { + background: #eee; +} + +.button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.dropZone { + margin: 0 15px 15px; + padding: 25px; + border: 2px dashed #ddd; + border-radius: 8px; + text-align: center; + transition: all 0.2s ease; +} + +.dropZone.dragging { + border-color: #2196F3; + background: #e3f2fd; +} + +.dropZoneText { + font-size: 13px; + color: #888; +} + +.error { + margin: 0 15px 15px; + padding: 10px; + background: #ffebee; + border: 1px solid #ffcdd2; + border-radius: 4px; + color: #c62828; + font-size: 12px; +} + +.divider { + height: 1px; + background: #ddd; + margin: 10px 0; +} + +.info { + padding: 0 15px 15px; +} + +.infoItem { + display: flex; + justify-content: space-between; + padding: 5px 0; + font-size: 13px; + border-bottom: 1px solid #eee; +} + +.infoItem:last-child { + border-bottom: none; +} + +.infoItem span:first-child { + color: #888; +} + +.infoItem span:last-child { + font-weight: 500; +} diff --git a/src/components/FilePanel/FilePanel.tsx b/src/components/FilePanel/FilePanel.tsx new file mode 100644 index 0000000..f55d04a --- /dev/null +++ b/src/components/FilePanel/FilePanel.tsx @@ -0,0 +1,211 @@ +/** + * FilePanel Component + * + * Handles file import and export operations + */ + +import { useRef, useCallback, useState } from 'react'; +import { usePedigreeStore } from '@/store/pedigreeStore'; +import { PedParser } from '@/core/parser/PedParser'; +import { exportService } from '@/services/exportService'; +import styles from './FilePanel.module.css'; + +export function FilePanel() { + const { + pedigree, + loadPedigree, + createNewPedigree, + clearPedigree, + } = usePedigreeStore(); + + const fileInputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [importError, setImportError] = useState(null); + + const handleFileSelect = useCallback(async (file: File) => { + setImportError(null); + + try { + const content = await file.text(); + const parser = new PedParser(); + const { pedigree: newPedigree, result } = parser.parseToPedigree(content); + + if (result.errors.length > 0) { + setImportError(`Parse errors: ${result.errors.map(e => e.message).join(', ')}`); + return; + } + + if (result.warnings.length > 0) { + console.warn('Parse warnings:', result.warnings); + } + + loadPedigree(newPedigree); + } catch (error) { + setImportError(`Failed to parse file: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }, [loadPedigree]); + + const handleFileInputChange = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + handleFileSelect(file); + } + // Reset input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, [handleFileSelect]); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + const file = e.dataTransfer.files?.[0]; + if (file) { + handleFileSelect(file); + } + }, [handleFileSelect]); + + const handleExportSvg = useCallback(async () => { + const svg = document.querySelector('.pedigree-main')?.closest('svg') as SVGSVGElement; + if (!svg) { + alert('No pedigree to export'); + return; + } + + try { + await exportService.exportSvg(svg, { filename: pedigree?.familyId ?? 'pedigree' }); + } catch (error) { + alert('Failed to export SVG'); + } + }, [pedigree]); + + const handleExportPng = useCallback(async () => { + const svg = document.querySelector('.pedigree-main')?.closest('svg') as SVGSVGElement; + if (!svg) { + alert('No pedigree to export'); + return; + } + + try { + await exportService.exportPng(svg, { filename: pedigree?.familyId ?? 'pedigree' }); + } catch (error) { + alert('Failed to export PNG'); + } + }, [pedigree]); + + const handleExportPed = useCallback(() => { + if (!pedigree) { + alert('No pedigree to export'); + return; + } + + try { + exportService.exportPed(pedigree, { filename: pedigree.familyId ?? 'pedigree' }); + } catch (error) { + alert('Failed to export PED'); + } + }, [pedigree]); + + const handleNewPedigree = useCallback(() => { + const familyId = prompt('Enter Family ID:', 'FAM001'); + if (familyId) { + createNewPedigree(familyId); + } + }, [createNewPedigree]); + + return ( +
+
File Operations
+ +
+ + + +
+ +
+
+ Drag & Drop PED file here +
+
+ + {importError && ( +
{importError}
+ )} + +
+ +
Export
+ +
+ + + +
+ + {pedigree && ( + <> +
+
+
+ Family ID: + {pedigree.familyId} +
+
+ Persons: + {pedigree.persons.size} +
+
+ Relationships: + {pedigree.relationships.size} +
+
+ + )} +
+ ); +} diff --git a/src/components/PedigreeCanvas/PedigreeCanvas.module.css b/src/components/PedigreeCanvas/PedigreeCanvas.module.css new file mode 100644 index 0000000..749f57c --- /dev/null +++ b/src/components/PedigreeCanvas/PedigreeCanvas.module.css @@ -0,0 +1,100 @@ +.canvasContainer { + position: relative; + width: 100%; + height: 100%; + background-color: #fafafa; + overflow: hidden; +} + +.canvas { + display: block; + width: 100%; + height: 100%; + cursor: default; +} + +.canvas :global(.person) { + cursor: pointer; +} + +.canvas :global(.person.dragging) { + cursor: grabbing; +} + +.canvas :global(.person:hover) { + filter: brightness(1.05); +} + +.canvas :global(.selection-highlight) { + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.zoomControls { + position: absolute; + top: 10px; + right: 10px; + display: flex; + align-items: center; + gap: 5px; + background: white; + border: 1px solid #ddd; + border-radius: 4px; + padding: 5px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + z-index: 10; +} + +.zoomControls button { + width: 30px; + height: 30px; + border: 1px solid #ddd; + background: white; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; +} + +.zoomControls button:hover { + background: #f5f5f5; +} + +.zoomControls button:active { + background: #eee; +} + +.zoomLevel { + min-width: 45px; + text-align: center; + font-size: 12px; + color: #666; +} + +.emptyState { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + color: #999; +} + +.emptyState p { + margin: 5px 0; +} + +.emptyState p:first-child { + font-size: 18px; + font-weight: 500; +} diff --git a/src/components/PedigreeCanvas/PedigreeCanvas.tsx b/src/components/PedigreeCanvas/PedigreeCanvas.tsx new file mode 100644 index 0000000..3d72ffd --- /dev/null +++ b/src/components/PedigreeCanvas/PedigreeCanvas.tsx @@ -0,0 +1,110 @@ +/** + * PedigreeCanvas Component + * + * Main canvas for rendering and interacting with the pedigree diagram + */ + +import { useCallback, useState } from 'react'; +import { usePedigreeStore } from '@/store/pedigreeStore'; +import { useD3Pedigree } from './hooks/useD3Pedigree'; +import { useZoomPan } from './hooks/useZoomPan'; +import { useDragBehavior } from './hooks/useDragBehavior'; +import styles from './PedigreeCanvas.module.css'; + +export function PedigreeCanvas() { + const { + pedigree, + layoutNodes, + selectedPersonId, + selectedRelationshipId, + currentTool, + selectPerson, + selectRelationship, + clearSelection, + updatePersonPosition, + } = usePedigreeStore(); + + const [zoomLevel, setZoomLevel] = useState(1); + + const handlePersonClick = useCallback((personId: string) => { + if (currentTool === 'select') { + selectPerson(personId); + } + }, [currentTool, selectPerson]); + + const handlePersonDoubleClick = useCallback((personId: string) => { + // Could open edit dialog + selectPerson(personId); + }, [selectPerson]); + + const handleBackgroundClick = useCallback(() => { + clearSelection(); + }, [clearSelection]); + + const handleRelationshipClick = useCallback((relationshipId: string) => { + if (currentTool === 'select') { + selectRelationship(relationshipId); + } + }, [currentTool, selectRelationship]); + + const { svgRef } = useD3Pedigree({ + pedigree, + layoutNodes, + selectedPersonId, + selectedRelationshipId, + onPersonClick: handlePersonClick, + onPersonDoubleClick: handlePersonDoubleClick, + onRelationshipClick: handleRelationshipClick, + onBackgroundClick: handleBackgroundClick, + }); + + const { resetZoom, zoomIn, zoomOut, fitToContent } = useZoomPan({ + svgRef, + onZoomChange: (state) => setZoomLevel(Math.round(state.k * 100) / 100), + }); + + const { isDragging } = useDragBehavior({ + svgRef, + layoutNodes, + isEnabled: currentTool === 'select', + onDragEnd: (personId, x, y) => { + updatePersonPosition(personId, x, y); + }, + onClick: (personId) => { + selectPerson(personId); + }, + }); + + return ( +
+
+ + {Math.round(zoomLevel * 100)}% + + + +
+ + + + {!pedigree && ( +
+

No pedigree loaded

+

Import a PED file or create a new pedigree

+
+ )} + + {pedigree && pedigree.persons.size === 0 && ( +
+

Pedigree: {pedigree.familyId}

+

Use the toolbar to add persons (Male/Female/Unknown)

+
+ )} +
+ ); +} diff --git a/src/components/PedigreeCanvas/hooks/useD3Pedigree.ts b/src/components/PedigreeCanvas/hooks/useD3Pedigree.ts new file mode 100644 index 0000000..1b51991 --- /dev/null +++ b/src/components/PedigreeCanvas/hooks/useD3Pedigree.ts @@ -0,0 +1,539 @@ +/** + * useD3Pedigree Hook + * + * Integrates D3.js with React for rendering the pedigree chart + */ + +import { useRef, useEffect, useCallback } from 'react'; +import * as d3 from 'd3'; +import type { Pedigree, Person, LayoutNode, RenderOptions } from '@/core/model/types'; +import { Sex, Phenotype, PartnershipStatus, ChildlessReason } from '@/core/model/types'; +import { SymbolRegistry } from '@/core/renderer/SymbolRegistry'; +import { ConnectionRenderer } from '@/core/renderer/ConnectionRenderer'; + +export const DEFAULT_RENDER_OPTIONS: RenderOptions = { + width: 800, + height: 600, + padding: 50, + symbolSize: 40, + lineWidth: 2, + showLabels: true, + showGenerationNumbers: true, +}; + +interface UseD3PedigreeProps { + pedigree: Pedigree | null; + layoutNodes: Map; + selectedPersonId: string | null; + selectedRelationshipId: string | null; + options?: Partial; + onPersonClick?: (personId: string) => void; + onPersonDoubleClick?: (personId: string) => void; + onRelationshipClick?: (relationshipId: string) => void; + onBackgroundClick?: () => void; +} + +export function useD3Pedigree({ + pedigree, + layoutNodes, + selectedPersonId, + selectedRelationshipId, + options = {}, + onPersonClick, + onPersonDoubleClick, + onRelationshipClick, + onBackgroundClick, +}: UseD3PedigreeProps) { + const svgRef = useRef(null); + const renderOptions = { ...DEFAULT_RENDER_OPTIONS, ...options }; + const symbolRegistry = useRef(new SymbolRegistry(renderOptions.symbolSize)); + const connectionRenderer = useRef(new ConnectionRenderer({ + symbolSize: renderOptions.symbolSize, + lineWidth: renderOptions.lineWidth, + })); + + const render = useCallback(() => { + if (!svgRef.current || !pedigree) return; + + const svg = d3.select(svgRef.current); + + // Preserve current transform if main group exists + let existingTransform: string | null = null; + const existingMainGroup = svg.select('.pedigree-main'); + if (!existingMainGroup.empty()) { + existingTransform = existingMainGroup.attr('transform'); + } + + // Clear previous content + svg.selectAll('*').remove(); + + // Create main group for zoom/pan + const mainGroup = svg + .append('g') + .attr('class', 'pedigree-main'); + + // Restore transform if it existed + if (existingTransform) { + mainGroup.attr('transform', existingTransform); + } + + // Background (for click handling) + mainGroup + .append('rect') + .attr('class', 'pedigree-background') + .attr('x', -10000) + .attr('y', -10000) + .attr('width', 20000) + .attr('height', 20000) + .attr('fill', 'transparent') + .on('click', () => { + onBackgroundClick?.(); + }); + + // Render connections first (so they appear behind symbols) + const connectionsGroup = mainGroup + .append('g') + .attr('class', 'connections'); + + renderConnections( + connectionsGroup, + pedigree, + layoutNodes, + connectionRenderer.current, + selectedRelationshipId, + onRelationshipClick + ); + + // Render persons + const personsGroup = mainGroup + .append('g') + .attr('class', 'persons'); + + renderPersons( + personsGroup, + pedigree, + layoutNodes, + symbolRegistry.current, + renderOptions, + selectedPersonId, + onPersonClick, + onPersonDoubleClick + ); + + // Render generation labels + if (renderOptions.showGenerationNumbers) { + renderGenerationLabels(mainGroup, layoutNodes); + } + + }, [pedigree, layoutNodes, selectedPersonId, selectedRelationshipId, renderOptions, onPersonClick, onPersonDoubleClick, onRelationshipClick, onBackgroundClick]); + + useEffect(() => { + render(); + }, [render]); + + return { svgRef, render }; +} + +function renderConnections( + group: d3.Selection, + pedigree: Pedigree, + layoutNodes: Map, + renderer: ConnectionRenderer, + selectedRelationshipId: string | null, + onRelationshipClick?: (relationshipId: string) => void +) { + // Render spouse connections + const processedPairs = new Set(); + + for (const [, relationship] of pedigree.relationships) { + const node1 = layoutNodes.get(relationship.person1Id); + const node2 = layoutNodes.get(relationship.person2Id); + + if (!node1 || !node2) continue; + + const pairKey = [relationship.person1Id, relationship.person2Id].sort().join(':'); + if (processedPairs.has(pairKey)) continue; + processedPairs.add(pairKey); + + const paths = renderer.renderSpouseConnection(node1, node2, relationship); + const isSelected = relationship.id === selectedRelationshipId; + + // Create a group for the clickable connection + const connectionGroup = group + .append('g') + .attr('class', `connection-group${isSelected ? ' selected' : ''}`) + .attr('data-relationship-id', relationship.id); + + // Render clickable hit area if available + const pathWithArea = paths.find(p => p.clickableArea); + if (pathWithArea?.clickableArea) { + const area = pathWithArea.clickableArea; + connectionGroup + .append('rect') + .attr('class', 'connection-hit-area') + .attr('x', area.x) + .attr('y', area.y) + .attr('width', area.width) + .attr('height', area.height) + .attr('fill', 'transparent') + .attr('cursor', 'pointer') + .on('click', (event) => { + event.stopPropagation(); + onRelationshipClick?.(relationship.id); + }); + } + + for (const path of paths) { + connectionGroup + .append('path') + .attr('d', path.d) + .attr('class', path.className) + .attr('fill', 'none') + .attr('stroke', '#333') + .attr('stroke-width', 2); + } + + // Render partnership status indicators (separation/divorce) + if (relationship.partnershipStatus === PartnershipStatus.Separated) { + const separationPaths = renderer.renderSeparationIndicator(node1, node2); + for (const path of separationPaths) { + connectionGroup + .append('path') + .attr('d', path.d) + .attr('class', path.className) + .attr('fill', 'none') + .attr('stroke', '#333') + .attr('stroke-width', 2); + } + } else if (relationship.partnershipStatus === PartnershipStatus.Divorced) { + const divorcePaths = renderer.renderDivorceIndicator(node1, node2); + for (const path of divorcePaths) { + connectionGroup + .append('path') + .attr('d', path.d) + .attr('class', path.className) + .attr('fill', 'none') + .attr('stroke', '#333') + .attr('stroke-width', 2); + } + } + + // Render childlessness indicators + if (relationship.childlessReason === ChildlessReason.Infertility) { + const infertilityPaths = renderer.renderInfertilityIndicator(node1, node2, false); + for (const path of infertilityPaths) { + connectionGroup + .append('path') + .attr('d', path.d) + .attr('class', path.className) + .attr('fill', 'none') + .attr('stroke', '#333') + .attr('stroke-width', 2); + } + } else if (relationship.childlessReason === ChildlessReason.ByChoice) { + const byChoicePaths = renderer.renderInfertilityIndicator(node1, node2, true); + for (const path of byChoicePaths) { + connectionGroup + .append('path') + .attr('d', path.d) + .attr('class', path.className) + .attr('fill', 'none') + .attr('stroke', '#333') + .attr('stroke-width', 2); + } + // Add "(c)" text for by-choice + connectionGroup + .append('text') + .attr('x', (node1.x + node2.x) / 2) + .attr('y', node1.y + 20 + 30 + 12) + .attr('text-anchor', 'middle') + .attr('font-size', '10px') + .attr('font-family', 'sans-serif') + .text('(c)'); + } + + // Render parent-child connections if there are children + if (relationship.childrenIds.length > 0) { + const childNodes = relationship.childrenIds + .map(id => layoutNodes.get(id)) + .filter((n): n is LayoutNode => n !== undefined); + + if (childNodes.length > 0) { + const parentChildPaths = renderer.renderParentChildConnection( + [node1, node2], + childNodes + ); + + for (const path of parentChildPaths) { + group + .append('path') + .attr('d', path.d) + .attr('class', path.className) + .attr('fill', 'none') + .attr('stroke', '#333') + .attr('stroke-width', 2); + } + } + } + } + + // Render parent-child connections for persons with parents but no explicit relationship + for (const [, person] of pedigree.persons) { + if (!person.fatherId && !person.motherId) continue; + + const childNode = layoutNodes.get(person.id); + if (!childNode) continue; + + const fatherNode = person.fatherId ? layoutNodes.get(person.fatherId) : null; + const motherNode = person.motherId ? layoutNodes.get(person.motherId) : null; + + // Skip if already handled by relationship + if (fatherNode && motherNode) { + const pairKey = [person.fatherId, person.motherId].sort().join(':'); + if (processedPairs.has(pairKey)) continue; + } + + // Single parent case + if (fatherNode || motherNode) { + const parentNode = fatherNode ?? motherNode; + if (!parentNode) continue; + + const paths = renderer.renderParentChildConnection( + [parentNode], + [childNode] + ); + + for (const path of paths) { + group + .append('path') + .attr('d', path.d) + .attr('class', path.className) + .attr('fill', 'none') + .attr('stroke', '#333') + .attr('stroke-width', 2); + } + } + } +} + +function renderPersons( + group: d3.Selection, + pedigree: Pedigree, + layoutNodes: Map, + symbolRegistry: SymbolRegistry, + options: RenderOptions, + selectedPersonId: string | null, + onPersonClick?: (personId: string) => void, + onPersonDoubleClick?: (personId: string) => void +) { + for (const [personId, node] of layoutNodes) { + const person = pedigree.persons.get(personId); + if (!person) continue; + + const personGroup = group + .append('g') + .attr('class', 'person') + .attr('data-id', personId) + .attr('transform', `translate(${node.x}, ${node.y})`) + .attr('cursor', 'pointer') + .on('click', (event) => { + event.stopPropagation(); + onPersonClick?.(personId); + }) + .on('dblclick', (event) => { + event.stopPropagation(); + onPersonDoubleClick?.(personId); + }); + + // Selection highlight + if (personId === selectedPersonId) { + const highlightSize = options.symbolSize / 2 + 5; + personGroup + .append('rect') + .attr('class', 'selection-highlight') + .attr('x', -highlightSize) + .attr('y', -highlightSize) + .attr('width', highlightSize * 2) + .attr('height', highlightSize * 2) + .attr('fill', 'none') + .attr('stroke', '#2196F3') + .attr('stroke-width', 3) + .attr('stroke-dasharray', '5,3'); + } + + // Main symbol + const symbolPath = symbolRegistry.getSymbolPath(person.sex); + const phenotype = person.phenotypes[0] ?? Phenotype.Unknown; + + // Determine fill based on phenotype + let fillColor = '#fff'; + if (phenotype === Phenotype.Affected) { + fillColor = '#333'; + } + + personGroup + .append('path') + .attr('d', symbolPath) + .attr('class', 'person-symbol') + .attr('fill', fillColor) + .attr('stroke', '#333') + .attr('stroke-width', 2); + + // Carrier pattern (half-filled) + if (phenotype === Phenotype.Carrier) { + const carrierPath = symbolRegistry.getCarrierPath(person.sex); + personGroup + .append('path') + .attr('d', carrierPath) + .attr('class', 'person-carrier') + .attr('fill', '#333'); + } + + // Multiple phenotypes + if (person.phenotypes.length > 1) { + const quadrantPaths = symbolRegistry.getQuadrantPaths(person.sex, person.phenotypes.length); + person.phenotypes.forEach((pheno, index) => { + if (pheno === Phenotype.Affected && quadrantPaths[index]) { + personGroup + .append('path') + .attr('d', quadrantPaths[index]) + .attr('class', 'person-phenotype-quadrant') + .attr('fill', '#333'); + } + }); + } + + // Deceased overlay + if (person.status.isDeceased) { + const deceasedPath = symbolRegistry.getDeceasedPath(); + personGroup + .append('path') + .attr('d', deceasedPath) + .attr('class', 'person-deceased') + .attr('stroke', '#333') + .attr('stroke-width', 2) + .attr('fill', 'none'); + } + + // Proband arrow + if (person.status.isProband) { + const probandPath = symbolRegistry.getProbandArrowPath(); + personGroup + .append('path') + .attr('d', probandPath) + .attr('class', 'person-proband') + .attr('stroke', '#333') + .attr('stroke-width', 2) + .attr('fill', 'none'); + } + + // Adopted brackets + if (person.status.isAdopted || person.status.isAdoptedIn) { + const [leftBracket, rightBracket] = symbolRegistry.getAdoptionBracketPaths(); + personGroup + .append('path') + .attr('d', leftBracket) + .attr('class', 'person-adopted') + .attr('stroke', '#333') + .attr('stroke-width', 2) + .attr('fill', 'none'); + personGroup + .append('path') + .attr('d', rightBracket) + .attr('class', 'person-adopted') + .attr('stroke', '#333') + .attr('stroke-width', 2) + .attr('fill', 'none'); + } + + // Label + if (options.showLabels && person.metadata.label) { + personGroup + .append('text') + .attr('class', 'person-label') + .attr('x', 0) + .attr('y', options.symbolSize / 2 + 15) + .attr('text-anchor', 'middle') + .attr('font-size', '12px') + .attr('font-family', 'sans-serif') + .text(person.metadata.label); + } + + // ID label (if no custom label) + if (options.showLabels && !person.metadata.label) { + personGroup + .append('text') + .attr('class', 'person-id') + .attr('x', 0) + .attr('y', options.symbolSize / 2 + 15) + .attr('text-anchor', 'middle') + .attr('font-size', '11px') + .attr('font-family', 'sans-serif') + .attr('fill', '#666') + .text(person.id); + } + } +} + +function renderGenerationLabels( + group: d3.Selection, + layoutNodes: Map +) { + // Find unique generations and their Y positions + const generations = new Map(); + + for (const [, node] of layoutNodes) { + if (!generations.has(node.generation)) { + generations.set(node.generation, node.y); + } + } + + // Find leftmost X position + let minX = Infinity; + for (const [, node] of layoutNodes) { + minX = Math.min(minX, node.x); + } + + // Render Roman numerals + const labelsGroup = group + .append('g') + .attr('class', 'generation-labels'); + + const sortedGens = Array.from(generations.entries()).sort((a, b) => a[0] - b[0]); + + sortedGens.forEach(([gen, y]) => { + const romanNumeral = toRomanNumeral(gen + 1); + + labelsGroup + .append('text') + .attr('x', minX - 60) + .attr('y', y + 5) + .attr('text-anchor', 'end') + .attr('font-size', '14px') + .attr('font-family', 'serif') + .attr('font-weight', 'bold') + .text(romanNumeral); + }); +} + +function toRomanNumeral(num: number): string { + const romanNumerals: [number, string][] = [ + [10, 'X'], + [9, 'IX'], + [5, 'V'], + [4, 'IV'], + [1, 'I'], + ]; + + let result = ''; + let remaining = num; + + for (const [value, numeral] of romanNumerals) { + while (remaining >= value) { + result += numeral; + remaining -= value; + } + } + + return result; +} diff --git a/src/components/PedigreeCanvas/hooks/useDragBehavior.ts b/src/components/PedigreeCanvas/hooks/useDragBehavior.ts new file mode 100644 index 0000000..a1fd440 --- /dev/null +++ b/src/components/PedigreeCanvas/hooks/useDragBehavior.ts @@ -0,0 +1,125 @@ +/** + * useDragBehavior Hook + * + * Enables drag functionality for person nodes in the pedigree + */ + +import { useEffect, useCallback, useRef } from 'react'; +import * as d3 from 'd3'; +import type { LayoutNode } from '@/core/model/types'; + +interface UseDragBehaviorProps { + svgRef: React.RefObject; + layoutNodes: Map; + isEnabled: boolean; + onDragStart?: (personId: string) => void; + onDrag?: (personId: string, x: number, y: number) => void; + onDragEnd?: (personId: string, x: number, y: number) => void; + onClick?: (personId: string) => void; +} + +export function useDragBehavior({ + svgRef, + layoutNodes, + isEnabled, + onDragStart, + onDrag, + onDragEnd, + onClick, +}: UseDragBehaviorProps) { + const draggedNode = useRef(null); + const startPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const hasDragged = useRef(false); + + useEffect(() => { + if (!svgRef.current || !isEnabled) return; + + const svg = d3.select(svgRef.current); + const persons = svg.selectAll('.person'); + + const drag = d3.drag() + .clickDistance(4) // Distinguish click from drag - must move at least 4px to be a drag + .on('start', function (event) { + const personId = d3.select(this).attr('data-id'); + if (!personId) return; + + draggedNode.current = personId; + hasDragged.current = false; + + // Get current position from the transform attribute + const currentTransform = d3.select(this).attr('transform'); + const match = currentTransform?.match(/translate\(([^,]+),\s*([^)]+)\)/); + if (match) { + startPos.current = { x: parseFloat(match[1]), y: parseFloat(match[2]) }; + } + + d3.select(this).classed('dragging', true); + onDragStart?.(personId); + }) + .on('drag', function (event) { + const personId = d3.select(this).attr('data-id'); + if (!personId) return; + + hasDragged.current = true; + + // Get the zoom transform to account for scale + const transform = d3.zoomTransform(svg.node()!); + + // Calculate new position: start position + delta adjusted for zoom scale + const newX = startPos.current.x + event.dx / transform.k; + const newY = startPos.current.y + event.dy / transform.k; + + // Update start position for next drag event + startPos.current = { x: newX, y: newY }; + + // Update visual position + d3.select(this).attr('transform', `translate(${newX}, ${newY})`); + + onDrag?.(personId, newX, newY); + }) + .on('end', function (event) { + const personId = d3.select(this).attr('data-id'); + if (!personId) return; + + d3.select(this).classed('dragging', false); + + // If we didn't actually drag, treat it as a click + if (!hasDragged.current) { + onClick?.(personId); + } else { + const finalX = startPos.current.x; + const finalY = startPos.current.y; + onDragEnd?.(personId, finalX, finalY); + } + + draggedNode.current = null; + hasDragged.current = false; + }); + + persons.call(drag); + + return () => { + persons.on('.drag', null); + }; + }, [svgRef, layoutNodes, isEnabled, onDragStart, onDrag, onDragEnd, onClick]); + + const enableDrag = useCallback(() => { + if (!svgRef.current) return; + d3.select(svgRef.current) + .selectAll('.person') + .style('cursor', 'grab'); + }, [svgRef]); + + const disableDrag = useCallback(() => { + if (!svgRef.current) return; + d3.select(svgRef.current) + .selectAll('.person') + .style('cursor', 'pointer'); + }, [svgRef]); + + return { + enableDrag, + disableDrag, + isDragging: draggedNode.current !== null, + }; +} diff --git a/src/components/PedigreeCanvas/hooks/useZoomPan.ts b/src/components/PedigreeCanvas/hooks/useZoomPan.ts new file mode 100644 index 0000000..1d40114 --- /dev/null +++ b/src/components/PedigreeCanvas/hooks/useZoomPan.ts @@ -0,0 +1,222 @@ +/** + * useZoomPan Hook + * + * Adds zoom and pan functionality to the pedigree canvas + */ + +import { useEffect, useRef, useCallback } from 'react'; +import * as d3 from 'd3'; + +export interface ZoomPanState { + x: number; + y: number; + k: number; +} + +interface UseZoomPanProps { + svgRef: React.RefObject; + minZoom?: number; + maxZoom?: number; + onZoomChange?: (state: ZoomPanState) => void; +} + +export function useZoomPan({ + svgRef, + minZoom = 0.1, + maxZoom = 4, + onZoomChange, +}: UseZoomPanProps) { + const zoomBehavior = useRef | null>(null); + const currentTransform = useRef(d3.zoomIdentity); + + useEffect(() => { + if (!svgRef.current) return; + + const svg = d3.select(svgRef.current); + const mainGroup = svg.select('.pedigree-main'); + + if (mainGroup.empty()) return; + + // Create zoom behavior + const zoom = d3.zoom() + .scaleExtent([minZoom, maxZoom]) + .on('zoom', (event) => { + currentTransform.current = event.transform; + mainGroup.attr('transform', event.transform.toString()); + onZoomChange?.({ + x: event.transform.x, + y: event.transform.y, + k: event.transform.k, + }); + }); + + zoomBehavior.current = zoom; + svg.call(zoom); + + // Double-click to reset zoom + svg.on('dblclick.zoom', null); + + // Auto-center content on initial render + const personsGroup = svg.select('.persons'); + if (!personsGroup.empty()) { + const bounds = personsGroup.node()?.getBBox(); + if (bounds && bounds.width > 0) { + const svgNode = svgRef.current; + if (svgNode) { + const svgWidth = svgNode.clientWidth; + const svgHeight = svgNode.clientHeight; + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + const translateX = svgWidth / 2 - centerX; + const translateY = svgHeight / 2 - centerY; + const initialTransform = d3.zoomIdentity.translate(translateX, translateY); + svg.call(zoom.transform, initialTransform); + } + } + } + + return () => { + svg.on('.zoom', null); + }; + }, [svgRef, minZoom, maxZoom, onZoomChange]); + + const resetZoom = useCallback(() => { + if (!svgRef.current || !zoomBehavior.current) return; + + const svg = d3.select(svgRef.current); + const personsGroup = svg.select('.persons'); + + if (personsGroup.empty()) { + // No content, just reset to identity + svg.transition() + .duration(300) + .call(zoomBehavior.current.transform, d3.zoomIdentity); + return; + } + + // Reset to 100% zoom but centered on content + const bounds = personsGroup.node()?.getBBox(); + if (!bounds) { + svg.transition() + .duration(300) + .call(zoomBehavior.current.transform, d3.zoomIdentity); + return; + } + + const svgWidth = svgRef.current.clientWidth; + const svgHeight = svgRef.current.clientHeight; + + // Center content at 100% zoom + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + const translateX = svgWidth / 2 - centerX; + const translateY = svgHeight / 2 - centerY; + + const transform = d3.zoomIdentity.translate(translateX, translateY); + + svg.transition() + .duration(300) + .call(zoomBehavior.current.transform, transform); + }, [svgRef]); + + const zoomIn = useCallback(() => { + if (!svgRef.current || !zoomBehavior.current) return; + + const svg = d3.select(svgRef.current); + svg.transition() + .duration(200) + .call(zoomBehavior.current.scaleBy, 1.3); + }, [svgRef]); + + const zoomOut = useCallback(() => { + if (!svgRef.current || !zoomBehavior.current) return; + + const svg = d3.select(svgRef.current); + svg.transition() + .duration(200) + .call(zoomBehavior.current.scaleBy, 0.77); + }, [svgRef]); + + const fitToContent = useCallback(() => { + if (!svgRef.current || !zoomBehavior.current) return; + + const svg = d3.select(svgRef.current); + + // Get bounding box from the actual content (persons group), not the whole main group + // which includes a huge background rectangle + const personsGroup = svg.select('.persons'); + const connectionsGroup = svg.select('.connections'); + + if (personsGroup.empty()) return; + + // Calculate combined bounds from persons and connections + const personsBounds = personsGroup.node()?.getBBox(); + const connectionsBounds = connectionsGroup.node()?.getBBox(); + + if (!personsBounds) return; + + // Combine bounds using simple object + let boundsX = personsBounds.x; + let boundsY = personsBounds.y; + let boundsWidth = personsBounds.width; + let boundsHeight = personsBounds.height; + + if (connectionsBounds && connectionsBounds.width > 0) { + const minX = Math.min(personsBounds.x, connectionsBounds.x); + const minY = Math.min(personsBounds.y, connectionsBounds.y); + const maxX = Math.max(personsBounds.x + personsBounds.width, connectionsBounds.x + connectionsBounds.width); + const maxY = Math.max(personsBounds.y + personsBounds.height, connectionsBounds.y + connectionsBounds.height); + boundsX = minX; + boundsY = minY; + boundsWidth = maxX - minX; + boundsHeight = maxY - minY; + } + + const svgWidth = svgRef.current.clientWidth; + const svgHeight = svgRef.current.clientHeight; + + const padding = 50; + const contentWidth = boundsWidth + padding * 2; + const contentHeight = boundsHeight + padding * 2; + + // Calculate scale to fit + const scale = Math.min( + svgWidth / contentWidth, + svgHeight / contentHeight, + 1 // Don't zoom in beyond 100% + ); + + // Calculate translation to center + const translateX = (svgWidth - boundsWidth * scale) / 2 - boundsX * scale; + const translateY = (svgHeight - boundsHeight * scale) / 2 - boundsY * scale; + + const transform = d3.zoomIdentity + .translate(translateX, translateY) + .scale(scale); + + svg.transition() + .duration(300) + .call(zoomBehavior.current.transform, transform); + }, [svgRef]); + + const setZoom = useCallback((scale: number) => { + if (!svgRef.current || !zoomBehavior.current) return; + + const svg = d3.select(svgRef.current); + const currentScale = currentTransform.current.k; + const scaleFactor = scale / currentScale; + + svg.transition() + .duration(200) + .call(zoomBehavior.current.scaleBy, scaleFactor); + }, [svgRef]); + + return { + resetZoom, + zoomIn, + zoomOut, + fitToContent, + setZoom, + currentTransform: currentTransform.current, + }; +} diff --git a/src/components/PropertyPanel/PropertyPanel.module.css b/src/components/PropertyPanel/PropertyPanel.module.css new file mode 100644 index 0000000..a12d172 --- /dev/null +++ b/src/components/PropertyPanel/PropertyPanel.module.css @@ -0,0 +1,114 @@ +.panel { + width: 280px; + background: white; + border-left: 1px solid #ddd; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.header { + padding: 12px 15px; + font-weight: 600; + border-bottom: 1px solid #ddd; + display: flex; + justify-content: space-between; + align-items: center; +} + +.personId { + font-size: 12px; + color: #666; + font-weight: normal; + font-family: monospace; +} + +.empty { + padding: 20px; + color: #999; + text-align: center; + font-size: 14px; +} + +.section { + padding: 12px 15px; + border-bottom: 1px solid #eee; +} + +.sectionTitle { + font-size: 11px; + text-transform: uppercase; + color: #888; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.input { + width: 100%; + padding: 8px 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.input:focus { + outline: none; + border-color: #2196F3; +} + +.buttonGroup { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.optionButton { + padding: 6px 12px; + border: 1px solid #ddd; + background: white; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + transition: all 0.15s ease; +} + +.optionButton:hover { + background: #f5f5f5; +} + +.optionButton.active { + background: #e3f2fd; + border-color: #2196F3; + color: #1976D2; +} + +.checkboxGroup { + display: flex; + flex-direction: column; + gap: 8px; +} + +.checkbox { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 14px; +} + +.checkbox input { + width: 16px; + height: 16px; + cursor: pointer; +} + +.infoText { + font-size: 13px; + color: #555; + line-height: 1.6; +} + +.noRelations { + color: #999; + font-style: italic; +} diff --git a/src/components/PropertyPanel/PropertyPanel.tsx b/src/components/PropertyPanel/PropertyPanel.tsx new file mode 100644 index 0000000..c3fdaeb --- /dev/null +++ b/src/components/PropertyPanel/PropertyPanel.tsx @@ -0,0 +1,190 @@ +/** + * PropertyPanel Component + * + * Panel for editing properties of the selected person or relationship + */ + +import { usePedigreeStore } from '@/store/pedigreeStore'; +import { Sex, Phenotype } from '@/core/model/types'; +import styles from './PropertyPanel.module.css'; + +export function PropertyPanel() { + const { + pedigree, + selectedPersonId, + updatePerson, + } = usePedigreeStore(); + + const selectedPerson = selectedPersonId && pedigree + ? pedigree.persons.get(selectedPersonId) + : null; + + if (!selectedPerson) { + return ( +
+
Properties
+
+ Select a person to edit properties +
+
+ ); + } + + const handleSexChange = (sex: Sex) => { + updatePerson(selectedPerson.id, { sex }); + }; + + const handlePhenotypeChange = (phenotype: Phenotype) => { + updatePerson(selectedPerson.id, { phenotypes: [phenotype] }); + }; + + const handleStatusChange = (key: keyof typeof selectedPerson.status, value: boolean) => { + updatePerson(selectedPerson.id, { + status: { ...selectedPerson.status, [key]: value }, + }); + }; + + const handleLabelChange = (label: string) => { + updatePerson(selectedPerson.id, { + metadata: { ...selectedPerson.metadata, label }, + }); + }; + + return ( +
+
+ Properties + {selectedPerson.id} +
+ +
+
Label
+ handleLabelChange(e.target.value)} + placeholder="Enter label..." + /> +
+ +
+
Sex
+
+ + + +
+
+ +
+
Phenotype
+
+ + + + +
+
+ +
+
Status
+
+ + + + + +
+
+ +
+
Relationships
+
+ {selectedPerson.fatherId &&
Father: {selectedPerson.fatherId}
} + {selectedPerson.motherId &&
Mother: {selectedPerson.motherId}
} + {selectedPerson.spouseIds.length > 0 && ( +
Spouse(s): {selectedPerson.spouseIds.join(', ')}
+ )} + {selectedPerson.childrenIds.length > 0 && ( +
Children: {selectedPerson.childrenIds.join(', ')}
+ )} + {!selectedPerson.fatherId && !selectedPerson.motherId && + selectedPerson.spouseIds.length === 0 && selectedPerson.childrenIds.length === 0 && ( +
No relationships defined
+ )} +
+
+
+ ); +} diff --git a/src/components/RelationshipPanel/RelationshipPanel.module.css b/src/components/RelationshipPanel/RelationshipPanel.module.css new file mode 100644 index 0000000..2b6e3f8 --- /dev/null +++ b/src/components/RelationshipPanel/RelationshipPanel.module.css @@ -0,0 +1,163 @@ +.panel { + width: 280px; + background: white; + border-left: 1px solid #ddd; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.header { + padding: 12px 15px; + font-weight: 600; + border-bottom: 1px solid #ddd; + display: flex; + justify-content: space-between; + align-items: center; +} + +.closeButton { + background: none; + border: none; + cursor: pointer; + font-size: 16px; + color: #666; + padding: 2px 6px; + border-radius: 4px; +} + +.closeButton:hover { + background: #f0f0f0; + color: #333; +} + +.partiesSection { + padding: 15px; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + background: #f8f9fa; + border-bottom: 1px solid #eee; +} + +.party { + font-weight: 500; + font-size: 14px; + padding: 6px 12px; + background: white; + border: 1px solid #ddd; + border-radius: 4px; + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.connector { + color: #888; + font-size: 16px; +} + +.section { + padding: 12px 15px; + border-bottom: 1px solid #eee; +} + +.sectionTitle { + font-size: 11px; + text-transform: uppercase; + color: #888; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.buttonGroup { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.optionButton { + padding: 6px 12px; + border: 1px solid #ddd; + background: white; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + transition: all 0.15s ease; +} + +.optionButton:hover { + background: #f5f5f5; +} + +.optionButton.active { + background: #e3f2fd; + border-color: #2196F3; + color: #1976D2; +} + +.checkbox { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 14px; +} + +.checkbox input { + width: 16px; + height: 16px; + cursor: pointer; +} + +.degreeSelector { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 5px; +} + +.degreeSelector label { + font-size: 12px; + color: #666; +} + +.degreeSelector select { + padding: 6px 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 13px; +} + +.childrenList { + display: flex; + flex-direction: column; + gap: 5px; +} + +.childItem { + font-size: 13px; + color: #555; + padding: 4px 8px; + background: #f8f9fa; + border-radius: 4px; +} + +.deleteButton { + width: 100%; + padding: 10px; + background: #fff; + border: 1px solid #e53935; + color: #e53935; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: all 0.15s ease; +} + +.deleteButton:hover { + background: #e53935; + color: white; +} diff --git a/src/components/RelationshipPanel/RelationshipPanel.tsx b/src/components/RelationshipPanel/RelationshipPanel.tsx new file mode 100644 index 0000000..bca3f87 --- /dev/null +++ b/src/components/RelationshipPanel/RelationshipPanel.tsx @@ -0,0 +1,218 @@ +/** + * RelationshipPanel Component + * + * Panel for editing properties of the selected relationship + */ + +import { usePedigreeStore } from '@/store/pedigreeStore'; +import { + RelationshipType, + PartnershipStatus, + ChildlessReason, +} from '@/core/model/types'; +import styles from './RelationshipPanel.module.css'; + +export function RelationshipPanel() { + const { + pedigree, + selectedRelationshipId, + updateRelationship, + deleteRelationship, + clearSelection, + } = usePedigreeStore(); + + const selectedRelationship = selectedRelationshipId && pedigree + ? pedigree.relationships.get(selectedRelationshipId) + : null; + + if (!selectedRelationship) { + return null; + } + + const person1 = pedigree?.persons.get(selectedRelationship.person1Id); + const person2 = pedigree?.persons.get(selectedRelationship.person2Id); + + const handleTypeChange = (isConsanguineous: boolean) => { + updateRelationship(selectedRelationship.id, { + type: isConsanguineous ? RelationshipType.Consanguineous : RelationshipType.Spouse, + }); + }; + + const handlePartnershipStatusChange = (status: PartnershipStatus) => { + updateRelationship(selectedRelationship.id, { partnershipStatus: status }); + }; + + const handleConsanguinityDegreeChange = (degree: number) => { + updateRelationship(selectedRelationship.id, { consanguinityDegree: degree }); + }; + + const handleChildlessReasonChange = (reason: ChildlessReason) => { + updateRelationship(selectedRelationship.id, { childlessReason: reason }); + }; + + const handleDelete = () => { + deleteRelationship(selectedRelationship.id); + clearSelection(); + }; + + return ( +
+
+ Relationship + +
+ + {/* Parties Section */} +
+
+ {person1?.metadata.label || person1?.id || 'Unknown'} +
+
+
+ {person2?.metadata.label || person2?.id || 'Unknown'} +
+
+ + {/* Partnership Status Section */} +
+
Partnership Status
+
+ + + + +
+
+ + {/* Consanguinity Section */} +
+
Consanguinity
+ + {selectedRelationship.type === RelationshipType.Consanguineous && ( +
+ + +
+ )} +
+ + {/* Childlessness Section */} +
+
Children Status
+
+ + + +
+
+ + {/* Children List */} + {selectedRelationship.childrenIds.length > 0 && ( +
+
+ Children ({selectedRelationship.childrenIds.length}) +
+
+ {selectedRelationship.childrenIds.map(childId => { + const child = pedigree?.persons.get(childId); + return ( +
+ {child?.metadata.label || childId} +
+ ); + })} +
+
+ )} + + {/* Delete Button */} +
+ +
+
+ ); +} diff --git a/src/components/RelationshipPanel/index.ts b/src/components/RelationshipPanel/index.ts new file mode 100644 index 0000000..c3ddade --- /dev/null +++ b/src/components/RelationshipPanel/index.ts @@ -0,0 +1 @@ +export { RelationshipPanel } from './RelationshipPanel'; diff --git a/src/components/Toolbar/Toolbar.module.css b/src/components/Toolbar/Toolbar.module.css new file mode 100644 index 0000000..b8ce68c --- /dev/null +++ b/src/components/Toolbar/Toolbar.module.css @@ -0,0 +1,116 @@ +.toolbar { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 15px; + background: white; + border-bottom: 1px solid #ddd; + min-height: 50px; +} + +.toolGroup { + display: flex; + align-items: center; + gap: 5px; +} + +.groupLabel { + font-size: 11px; + color: #888; + margin-right: 5px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.toolButton { + width: 36px; + height: 36px; + border: 1px solid #ddd; + background: white; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #333; + transition: all 0.15s ease; +} + +.toolButton:hover:not(:disabled) { + background: #f5f5f5; + border-color: #bbb; +} + +.toolButton:active:not(:disabled) { + background: #eee; +} + +.toolButton.active { + background: #e3f2fd; + border-color: #2196F3; + color: #2196F3; +} + +.toolButton:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.divider { + width: 1px; + height: 30px; + background: #ddd; + margin: 0 5px; +} + +.relationshipTool { + display: flex; + align-items: center; + gap: 10px; + padding: 5px 10px; + background: #f9f9f9; + border-radius: 4px; +} + +.relationshipTool p { + margin: 0; + font-size: 13px; + color: #666; +} + +.relationshipRow { + display: flex; + align-items: center; + gap: 5px; +} + +.relationshipRow label { + font-size: 12px; + color: #666; +} + +.relationshipRow select { + padding: 4px 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 13px; +} + +.button { + padding: 6px 12px; + border: 1px solid #1976D2; + background: #1976D2; + color: white; + border-radius: 4px; + cursor: pointer; + font-size: 13px; +} + +.button:hover:not(:disabled) { + background: #1565C0; +} + +.button:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/src/components/Toolbar/Toolbar.tsx b/src/components/Toolbar/Toolbar.tsx new file mode 100644 index 0000000..ae0ae9c --- /dev/null +++ b/src/components/Toolbar/Toolbar.tsx @@ -0,0 +1,349 @@ +/** + * Toolbar Component + * + * Main toolbar with tools for editing the pedigree + */ + +import { useState } from 'react'; +import { usePedigreeStore, useTemporalStore } from '@/store/pedigreeStore'; +import { createPerson, createRelationship, Sex, RelationshipType } from '@/core/model/types'; +import styles from './Toolbar.module.css'; + +export function Toolbar() { + const { + pedigree, + currentTool, + selectedPersonId, + setCurrentTool, + addPerson, + addRelationship, + updatePerson, + deletePerson, + createNewPedigree, + recalculateLayout, + } = usePedigreeStore(); + + const [showRelationshipMenu, setShowRelationshipMenu] = useState(false); + + const temporal = useTemporalStore(); + const { undo, redo, pastStates, futureStates } = temporal.getState(); + + const handleAddPerson = (sex: Sex) => { + let currentPedigree = pedigree; + + if (!currentPedigree) { + createNewPedigree('FAM001'); + // Get the latest state after creating + currentPedigree = usePedigreeStore.getState().pedigree; + } + + if (!currentPedigree) return; // Safety check + + const id = `P${Date.now().toString(36)}`; + const person = createPerson(id, currentPedigree.familyId, sex); + person.metadata.label = id; + + // Set initial position + const existingNodes = currentPedigree.persons.size; + person.x = 100 + (existingNodes % 5) * 100; + person.y = 100 + Math.floor(existingNodes / 5) * 150; + + addPerson(person); + }; + + const handleDelete = () => { + if (selectedPersonId) { + deletePerson(selectedPersonId); + } + }; + + const handleAddSpouse = () => { + if (!pedigree || !selectedPersonId) return; + + const selectedPerson = pedigree.persons.get(selectedPersonId); + if (!selectedPerson) return; + + // Create a new person of opposite sex + const newSex = selectedPerson.sex === Sex.Male ? Sex.Female : + selectedPerson.sex === Sex.Female ? Sex.Male : Sex.Unknown; + const id = `P${Date.now().toString(36)}`; + const newPerson = createPerson(id, pedigree.familyId, newSex); + newPerson.metadata.label = id; + newPerson.x = (selectedPerson.x ?? 0) + 80; + newPerson.y = selectedPerson.y ?? 100; + + addPerson(newPerson); + + // Create spouse relationship + const relationship = createRelationship(selectedPersonId, id, RelationshipType.Spouse); + addRelationship(relationship); + }; + + const handleAddChild = () => { + if (!pedigree || !selectedPersonId) return; + + const selectedPerson = pedigree.persons.get(selectedPersonId); + if (!selectedPerson) return; + + // Create a new child + const id = `P${Date.now().toString(36)}`; + const child = createPerson(id, pedigree.familyId, Sex.Unknown); + child.metadata.label = id; + child.x = selectedPerson.x ?? 0; + child.y = (selectedPerson.y ?? 0) + 120; + + // Set parent based on selected person's sex + if (selectedPerson.sex === Sex.Male) { + child.fatherId = selectedPersonId; + } else if (selectedPerson.sex === Sex.Female) { + child.motherId = selectedPersonId; + } + + addPerson(child); + + // Update selected person's children + updatePerson(selectedPersonId, { + childrenIds: [...selectedPerson.childrenIds, id], + }); + + recalculateLayout(); + }; + + const handleAddParents = () => { + if (!pedigree || !selectedPersonId) return; + + const selectedPerson = pedigree.persons.get(selectedPersonId); + if (!selectedPerson) return; + + // Create father + const fatherId = `P${Date.now().toString(36)}F`; + const father = createPerson(fatherId, pedigree.familyId, Sex.Male); + father.metadata.label = fatherId; + father.x = (selectedPerson.x ?? 0) - 40; + father.y = (selectedPerson.y ?? 0) - 120; + father.childrenIds = [selectedPersonId]; + + // Create mother + const motherId = `P${Date.now().toString(36)}M`; + const mother = createPerson(motherId, pedigree.familyId, Sex.Female); + mother.metadata.label = motherId; + mother.x = (selectedPerson.x ?? 0) + 40; + mother.y = (selectedPerson.y ?? 0) - 120; + mother.childrenIds = [selectedPersonId]; + + addPerson(father); + addPerson(mother); + + // Update child's parent references + updatePerson(selectedPersonId, { + fatherId, + motherId, + }); + + // Create spouse relationship between parents + const relationship = createRelationship(fatherId, motherId, RelationshipType.Spouse); + relationship.childrenIds = [selectedPersonId]; + addRelationship(relationship); + + recalculateLayout(); + }; + + return ( +
+
+ Tools + +
+ +
+ +
+ Add Person + + + +
+ +
+ +
+ Relationships + + + +
+ +
+ +
+ Edit + +
+ +
+ +
+ History + + +
+
+ ); +} + +// Simple SVG icons +function SelectIcon() { + return ( + + + + ); +} + +function MaleIcon() { + return ( + + + + ); +} + +function FemaleIcon() { + return ( + + + + ); +} + +function UnknownIcon() { + return ( + + + + ); +} + +function DeleteIcon() { + return ( + + + + ); +} + +function UndoIcon() { + return ( + + + + ); +} + +function RedoIcon() { + return ( + + + + ); +} + +function SpouseIcon() { + return ( + + + + + + ); +} + +function ChildIcon() { + return ( + + + + + + + ); +} + +function ParentsIcon() { + return ( + + + + + + + + + ); +} diff --git a/src/components/Toolbar/tools/RelationshipTool.tsx b/src/components/Toolbar/tools/RelationshipTool.tsx new file mode 100644 index 0000000..caf538d --- /dev/null +++ b/src/components/Toolbar/tools/RelationshipTool.tsx @@ -0,0 +1,128 @@ +/** + * RelationshipTool Component + * + * Tool for creating relationships between persons + */ + +import { useState, useCallback } from 'react'; +import { usePedigreeStore } from '@/store/pedigreeStore'; +import { createRelationship, RelationshipType } from '@/core/model/types'; +import styles from '../Toolbar.module.css'; + +interface RelationshipToolProps { + onClose?: () => void; +} + +export function RelationshipTool({ onClose }: RelationshipToolProps) { + const { + pedigree, + selectedPersonId, + addRelationship, + updatePerson, + } = usePedigreeStore(); + + const [relationshipType, setRelationshipType] = useState<'spouse' | 'parent' | 'child'>('spouse'); + const [targetPersonId, setTargetPersonId] = useState(''); + + const persons = pedigree ? Array.from(pedigree.persons.values()) : []; + const selectedPerson = selectedPersonId ? pedigree?.persons.get(selectedPersonId) : null; + + const availableTargets = persons.filter(p => p.id !== selectedPersonId); + + const handleCreateRelationship = useCallback(() => { + if (!selectedPersonId || !targetPersonId || !pedigree) return; + + const selectedPerson = pedigree.persons.get(selectedPersonId); + const targetPerson = pedigree.persons.get(targetPersonId); + + if (!selectedPerson || !targetPerson) return; + + switch (relationshipType) { + case 'spouse': { + const relationship = createRelationship( + selectedPersonId, + targetPersonId, + RelationshipType.Spouse + ); + addRelationship(relationship); + break; + } + case 'parent': { + // Make target person a parent of selected person + if (targetPerson.sex === 'male') { + updatePerson(selectedPersonId, { fatherId: targetPersonId }); + } else if (targetPerson.sex === 'female') { + updatePerson(selectedPersonId, { motherId: targetPersonId }); + } + // Add selected person as child of target + updatePerson(targetPersonId, { + childrenIds: [...targetPerson.childrenIds, selectedPersonId], + }); + break; + } + case 'child': { + // Make target person a child of selected person + if (selectedPerson.sex === 'male') { + updatePerson(targetPersonId, { fatherId: selectedPersonId }); + } else if (selectedPerson.sex === 'female') { + updatePerson(targetPersonId, { motherId: selectedPersonId }); + } + // Add target person as child of selected + updatePerson(selectedPersonId, { + childrenIds: [...selectedPerson.childrenIds, targetPersonId], + }); + break; + } + } + + setTargetPersonId(''); + onClose?.(); + }, [selectedPersonId, targetPersonId, relationshipType, pedigree, addRelationship, updatePerson, onClose]); + + if (!selectedPerson) { + return ( +
+

Select a person first to create relationships

+
+ ); + } + + return ( +
+
+ + +
+ +
+ + +
+ + +
+ ); +} diff --git a/src/core/layout/PedigreeLayout.ts b/src/core/layout/PedigreeLayout.ts new file mode 100644 index 0000000..3fe8a85 --- /dev/null +++ b/src/core/layout/PedigreeLayout.ts @@ -0,0 +1,395 @@ +/** + * Pedigree Layout Algorithm + * + * Calculates x, y positions for each person in the pedigree. + * Uses a generation-based approach: + * 1. Assign generations (founders = 0, children = parent generation + 1) + * 2. Sort within each generation (spouses adjacent, siblings grouped) + * 3. Calculate x positions avoiding overlaps + * 4. Handle special cases (consanguinity, twins) + */ + +import type { Pedigree, Person, Relationship, LayoutOptions, LayoutNode } from '@/core/model/types'; + +export const DEFAULT_LAYOUT_OPTIONS: LayoutOptions = { + nodeWidth: 50, + nodeHeight: 50, + horizontalSpacing: 30, + verticalSpacing: 100, + siblingSpacing: 40, + spouseSpacing: 60, +}; + +interface GenerationInfo { + persons: Person[]; + width: number; +} + +export class PedigreeLayout { + private options: LayoutOptions; + + constructor(options: Partial = {}) { + this.options = { ...DEFAULT_LAYOUT_OPTIONS, ...options }; + } + + /** + * Main layout function - calculates positions for all persons + */ + layout(pedigree: Pedigree): Map { + const result = new Map(); + const persons = Array.from(pedigree.persons.values()); + + if (persons.length === 0) { + return result; + } + + // Step 1: Assign generations + const generations = this.assignGenerations(pedigree); + + // Step 2: Sort within each generation + const sortedGenerations = this.sortGenerations(generations, pedigree); + + // Step 3: Calculate positions + this.calculatePositions(sortedGenerations, pedigree, result); + + // Step 4: Center the layout + this.centerLayout(result); + + return result; + } + + /** + * Assign generation numbers to each person using BFS from founders + */ + private assignGenerations(pedigree: Pedigree): Map { + const generations = new Map(); + const personGenerations = new Map(); + const persons = Array.from(pedigree.persons.values()); + + // Find founders (no parents in pedigree) + const founders = persons.filter(p => !p.fatherId && !p.motherId); + + // BFS from founders + const queue: Array<{ person: Person; generation: number }> = []; + + for (const founder of founders) { + queue.push({ person: founder, generation: 0 }); + personGenerations.set(founder.id, 0); + } + + while (queue.length > 0) { + const { person, generation } = queue.shift()!; + + // Add to generation map + if (!generations.has(generation)) { + generations.set(generation, []); + } + if (!generations.get(generation)!.find(p => p.id === person.id)) { + generations.get(generation)!.push(person); + } + + // Process children + for (const childId of person.childrenIds) { + const child = pedigree.persons.get(childId); + if (child && !personGenerations.has(childId)) { + const childGen = generation + 1; + personGenerations.set(childId, childGen); + queue.push({ person: child, generation: childGen }); + } + } + + // Ensure spouses are in the same generation + for (const spouseId of person.spouseIds) { + const spouse = pedigree.persons.get(spouseId); + if (spouse && !personGenerations.has(spouseId)) { + personGenerations.set(spouseId, generation); + queue.push({ person: spouse, generation: generation }); + } + } + } + + // Handle disconnected individuals + for (const person of persons) { + if (!personGenerations.has(person.id)) { + // Try to infer from children or parents + let gen = 0; + + if (person.fatherId) { + const fatherGen = personGenerations.get(person.fatherId); + if (fatherGen !== undefined) { + gen = fatherGen + 1; + } + } + + personGenerations.set(person.id, gen); + if (!generations.has(gen)) { + generations.set(gen, []); + } + generations.get(gen)!.push(person); + } + } + + // Store generation in person objects + for (const [personId, gen] of personGenerations) { + const person = pedigree.persons.get(personId); + if (person) { + person.generation = gen; + } + } + + return generations; + } + + /** + * Sort persons within each generation + * - Spouses should be adjacent + * - Siblings should be grouped together + */ + private sortGenerations( + generations: Map, + pedigree: Pedigree + ): Map { + const sorted = new Map(); + + for (const [gen, persons] of generations) { + const sortedGen = this.sortGeneration(persons, pedigree); + sorted.set(gen, sortedGen); + } + + return sorted; + } + + private sortGeneration(persons: Person[], pedigree: Pedigree): Person[] { + if (persons.length <= 1) { + return [...persons]; + } + + // Group by family units (couples with their children's parents) + const familyGroups: Person[][] = []; + const processed = new Set(); + + for (const person of persons) { + if (processed.has(person.id)) continue; + + const group: Person[] = [person]; + processed.add(person.id); + + // Add all spouses + for (const spouseId of person.spouseIds) { + const spouse = persons.find(p => p.id === spouseId); + if (spouse && !processed.has(spouse.id)) { + group.push(spouse); + processed.add(spouse.id); + } + } + + familyGroups.push(group); + } + + // Flatten groups, keeping couples together + return familyGroups.flat(); + } + + /** + * Calculate x, y positions for each person + */ + private calculatePositions( + generations: Map, + pedigree: Pedigree, + result: Map + ): void { + const { nodeWidth, horizontalSpacing, verticalSpacing, spouseSpacing } = this.options; + + // Sort generation keys + const genKeys = Array.from(generations.keys()).sort((a, b) => a - b); + + for (const gen of genKeys) { + const persons = generations.get(gen) ?? []; + const y = gen * (this.options.nodeHeight + verticalSpacing); + + let currentX = 0; + + for (let i = 0; i < persons.length; i++) { + const person = persons[i]; + const prevPerson = i > 0 ? persons[i - 1] : null; + + // Determine spacing + let spacing = horizontalSpacing; + if (prevPerson) { + if (prevPerson.spouseIds.includes(person.id) || + person.spouseIds.includes(prevPerson.id)) { + spacing = spouseSpacing; + } + } + + if (i > 0) { + currentX += spacing + nodeWidth; + } + + // Try to center under parents if they exist + const parentX = this.getParentCenterX(person, result); + if (parentX !== null && gen > 0) { + // Check if this position doesn't overlap + const desiredX = parentX; + if (desiredX >= currentX) { + currentX = desiredX; + } + } + + const node: LayoutNode = { + person, + x: currentX, + y, + generation: gen, + order: i, + }; + + result.set(person.id, node); + + // Update person position + person.x = currentX; + person.y = y; + } + } + + // Second pass: adjust children to center under parents + this.adjustChildrenPositions(generations, result, pedigree); + } + + /** + * Get the center X position of a person's parents + */ + private getParentCenterX(person: Person, positions: Map): number | null { + const fatherNode = person.fatherId ? positions.get(person.fatherId) : null; + const motherNode = person.motherId ? positions.get(person.motherId) : null; + + if (fatherNode && motherNode) { + return (fatherNode.x + motherNode.x) / 2; + } else if (fatherNode) { + return fatherNode.x; + } else if (motherNode) { + return motherNode.x; + } + + return null; + } + + /** + * Adjust children positions to be centered under their parents + */ + private adjustChildrenPositions( + generations: Map, + positions: Map, + pedigree: Pedigree + ): void { + const genKeys = Array.from(generations.keys()).sort((a, b) => a - b); + + // Skip first generation (founders) + for (let i = 1; i < genKeys.length; i++) { + const gen = genKeys[i]; + const persons = generations.get(gen) ?? []; + + // Group siblings + const siblingGroups = this.groupSiblings(persons); + + for (const siblings of siblingGroups) { + if (siblings.length === 0) continue; + + // Find parent center + const firstSibling = siblings[0]; + const parentCenter = this.getParentCenterX(firstSibling, positions); + + if (parentCenter === null) continue; + + // Calculate current sibling group center + const siblingPositions = siblings.map(s => positions.get(s.id)!); + const currentCenter = (siblingPositions[0].x + siblingPositions[siblingPositions.length - 1].x) / 2; + + // Calculate offset needed + const offset = parentCenter - currentCenter; + + // Apply offset to all siblings (if it doesn't cause overlap) + // For now, we'll skip overlap checking for simplicity + for (const sibling of siblings) { + const node = positions.get(sibling.id); + if (node) { + node.x += offset; + sibling.x = node.x; + } + } + } + } + } + + /** + * Group persons by their parents + */ + private groupSiblings(persons: Person[]): Person[][] { + const groups: Person[][] = []; + const parentMap = new Map(); + + for (const person of persons) { + const parentKey = `${person.fatherId ?? ''}:${person.motherId ?? ''}`; + if (!parentMap.has(parentKey)) { + parentMap.set(parentKey, []); + } + parentMap.get(parentKey)!.push(person); + } + + for (const group of parentMap.values()) { + groups.push(group); + } + + return groups; + } + + /** + * Center the entire layout around (0, 0) + */ + private centerLayout(positions: Map): void { + if (positions.size === 0) return; + + const nodes = Array.from(positions.values()); + + // Find bounding box + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + + for (const node of nodes) { + minX = Math.min(minX, node.x); + maxX = Math.max(maxX, node.x); + minY = Math.min(minY, node.y); + maxY = Math.max(maxY, node.y); + } + + // Calculate center offset + const offsetX = -(minX + maxX) / 2; + const offsetY = -minY + 50; // Start 50px from top + + // Apply offset + for (const node of nodes) { + node.x += offsetX; + node.y += offsetY; + node.person.x = node.x; + node.person.y = node.y; + } + } + + /** + * Update layout options + */ + setOptions(options: Partial): void { + this.options = { ...this.options, ...options }; + } + + /** + * Get current options + */ + getOptions(): LayoutOptions { + return { ...this.options }; + } +} + +export const pedigreeLayout = new PedigreeLayout(); diff --git a/src/core/model/types.ts b/src/core/model/types.ts new file mode 100644 index 0000000..1431369 --- /dev/null +++ b/src/core/model/types.ts @@ -0,0 +1,308 @@ +/** + * Core type definitions for the pedigree drawing tool + * Following NSGC (National Society of Genetic Counselors) standards + */ + +// ============================================ +// Enums +// ============================================ + +export enum Sex { + Male = 'male', + Female = 'female', + Unknown = 'unknown', +} + +export enum Phenotype { + Unknown = 'unknown', + Unaffected = 'unaffected', + Affected = 'affected', + Carrier = 'carrier', +} + +export enum RelationshipType { + Spouse = 'spouse', + Consanguineous = 'consanguineous', +} + +export enum PartnershipStatus { + Married = 'married', + Separated = 'separated', + Divorced = 'divorced', + Unmarried = 'unmarried', // Living together but not married +} + +export enum ChildlessReason { + None = 'none', + ByChoice = 'by-choice', + Infertility = 'infertility', +} + +export enum TwinType { + None = 'none', + Monozygotic = 'monozygotic', // Identical twins + Dizygotic = 'dizygotic', // Fraternal twins +} + +// ============================================ +// Status & Metadata +// ============================================ + +export interface PersonStatus { + isDeceased: boolean; + isProband: boolean; + isAdopted: boolean; + isAdoptedIn: boolean; // Adopted into family + isAdoptedOut: boolean; // Adopted out of family + isMiscarriage: boolean; + isStillbirth: boolean; + isPregnancy: boolean; + isInfertile: boolean; +} + +export interface PersonMetadata { + label?: string; + notes?: string; + birthYear?: number; + deathYear?: number; + age?: number; +} + +// ============================================ +// Core Entities +// ============================================ + +export interface Person { + id: string; + familyId: string; + + // Basic attributes + sex: Sex; + phenotypes: Phenotype[]; + status: PersonStatus; + metadata: PersonMetadata; + + // Family relationships (IDs) + fatherId: string | null; + motherId: string | null; + spouseIds: string[]; + childrenIds: string[]; + + // Twin information + twinType: TwinType; + twinGroupId: string | null; + + // Layout position (calculated by layout algorithm) + x?: number; + y?: number; + generation?: number; +} + +export interface Relationship { + id: string; + type: RelationshipType; + person1Id: string; + person2Id: string; + + // Partnership details + partnershipStatus?: PartnershipStatus; + consanguinityDegree?: number; // 1 = first cousins, 2 = second cousins, etc. + + // LGBTQ+ relationship support + isSameSex?: boolean; + + // Children from this union + childrenIds: string[]; + + // Childlessness indicator + childlessReason?: ChildlessReason; + + // Legacy (keep for backward compatibility) + isSeparated?: boolean; +} + +export interface Pedigree { + id: string; + familyId: string; + name?: string; + + // Core data + persons: Map; + relationships: Map; + + // Metadata + metadata: { + createdAt: Date; + modifiedAt: Date; + version: string; + }; +} + +// ============================================ +// PED File Format Types +// ============================================ + +export interface PedRecord { + familyId: string; + individualId: string; + paternalId: string; // '0' means unknown/founder + maternalId: string; // '0' means unknown/founder + sex: Sex; + phenotype: Phenotype; + rawSex: string; // Original value from file + rawPhenotype: string; // Original value from file +} + +export interface PedParseResult { + records: PedRecord[]; + errors: PedParseError[]; + warnings: string[]; +} + +export interface PedParseError { + line: number; + message: string; + rawLine: string; +} + +// ============================================ +// Layout Types +// ============================================ + +export interface LayoutOptions { + nodeWidth: number; + nodeHeight: number; + horizontalSpacing: number; + verticalSpacing: number; + siblingSpacing: number; + spouseSpacing: number; +} + +export interface LayoutNode { + person: Person; + x: number; + y: number; + generation: number; + order: number; // Order within generation +} + +// ============================================ +// Render Types +// ============================================ + +export interface RenderOptions { + width: number; + height: number; + padding: number; + symbolSize: number; + lineWidth: number; + showLabels: boolean; + showGenerationNumbers: boolean; +} + +export interface ConnectionPath { + type: 'spouse' | 'parent-child' | 'sibling' | 'twin' | 'indicator'; + from: { x: number; y: number }; + to: { x: number; y: number }; + isConsanguineous?: boolean; + twinType?: TwinType; + // For clickable connections + relationshipId?: string; + clickableArea?: { + x: number; + y: number; + width: number; + height: number; + }; +} + +// ============================================ +// Utility Functions +// ============================================ + +/** + * Generate a UUID that works in non-secure contexts (HTTP) + */ +function generateUUID(): string { + // Use crypto.randomUUID if available (secure context) + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + // Fallback for non-secure contexts + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +// ============================================ +// Factory Functions +// ============================================ + +export function createDefaultPersonStatus(): PersonStatus { + return { + isDeceased: false, + isProband: false, + isAdopted: false, + isAdoptedIn: false, + isAdoptedOut: false, + isMiscarriage: false, + isStillbirth: false, + isPregnancy: false, + isInfertile: false, + }; +} + +export function createDefaultPersonMetadata(): PersonMetadata { + return {}; +} + +export function createPerson( + id: string, + familyId: string, + sex: Sex = Sex.Unknown +): Person { + return { + id, + familyId, + sex, + phenotypes: [Phenotype.Unknown], + status: createDefaultPersonStatus(), + metadata: createDefaultPersonMetadata(), + fatherId: null, + motherId: null, + spouseIds: [], + childrenIds: [], + twinType: TwinType.None, + twinGroupId: null, + }; +} + +export function createPedigree(familyId: string): Pedigree { + return { + id: generateUUID(), + familyId, + persons: new Map(), + relationships: new Map(), + metadata: { + createdAt: new Date(), + modifiedAt: new Date(), + version: '1.0.0', + }, + }; +} + +export function createRelationship( + person1Id: string, + person2Id: string, + type: RelationshipType = RelationshipType.Spouse +): Relationship { + return { + id: generateUUID(), + type, + person1Id, + person2Id, + childrenIds: [], + }; +} diff --git a/src/core/parser/PedParser.ts b/src/core/parser/PedParser.ts new file mode 100644 index 0000000..a0d122c --- /dev/null +++ b/src/core/parser/PedParser.ts @@ -0,0 +1,265 @@ +/** + * GATK PED File Parser + * + * Parses the standard 6-column PED format: + * Column 1: Family ID + * Column 2: Individual ID + * Column 3: Paternal ID (0 = unknown/founder) + * Column 4: Maternal ID (0 = unknown/founder) + * Column 5: Sex (1 = male, 2 = female, other = unknown) + * Column 6: Phenotype (0/-9 = unknown, 1 = unaffected, 2 = affected) + * + * @see https://gatk.broadinstitute.org/hc/en-us/articles/360035531972-PED-Pedigree-format + */ + +import { + type PedRecord, + type PedParseResult, + type PedParseError, + type Person, + type Pedigree, + type Relationship, + Sex, + Phenotype, + RelationshipType, + createPerson, + createPedigree, + createRelationship, +} from '@/core/model/types'; + +export class PedParser { + /** + * Parse PED file content into records + */ + parse(content: string): PedParseResult { + const lines = content.split(/\r?\n/); + const records: PedRecord[] = []; + const errors: PedParseError[] = []; + const warnings: string[] = []; + + lines.forEach((line, index) => { + const lineNumber = index + 1; + const trimmedLine = line.trim(); + + // Skip empty lines and comments + if (!trimmedLine || trimmedLine.startsWith('#')) { + return; + } + + try { + const record = this.parseLine(trimmedLine, lineNumber); + records.push(record); + } catch (error) { + errors.push({ + line: lineNumber, + message: error instanceof Error ? error.message : 'Unknown error', + rawLine: line, + }); + } + }); + + // Validate relationships + const validationWarnings = this.validateRecords(records); + warnings.push(...validationWarnings); + + return { records, errors, warnings }; + } + + /** + * Parse a single line of PED file + */ + private parseLine(line: string, lineNumber: number): PedRecord { + const fields = line.split(/\s+/); + + if (fields.length < 6) { + throw new Error( + `Invalid PED format: expected at least 6 columns, got ${fields.length}` + ); + } + + const [familyId, individualId, paternalId, maternalId, rawSex, rawPhenotype] = fields; + + // Validate IDs don't start with # + if (familyId.startsWith('#') || individualId.startsWith('#')) { + throw new Error('IDs cannot start with #'); + } + + return { + familyId, + individualId, + paternalId, + maternalId, + sex: this.parseSex(rawSex), + phenotype: this.parsePhenotype(rawPhenotype), + rawSex, + rawPhenotype, + }; + } + + /** + * Parse sex field + * 1 = male, 2 = female, other = unknown + */ + private parseSex(value: string): Sex { + switch (value) { + case '1': + return Sex.Male; + case '2': + return Sex.Female; + default: + return Sex.Unknown; + } + } + + /** + * Parse phenotype field + * 0, -9 = unknown + * 1 = unaffected + * 2 = affected + */ + private parsePhenotype(value: string): Phenotype { + switch (value) { + case '1': + return Phenotype.Unaffected; + case '2': + return Phenotype.Affected; + case '0': + case '-9': + default: + return Phenotype.Unknown; + } + } + + /** + * Validate records for consistency + */ + private validateRecords(records: PedRecord[]): string[] { + const warnings: string[] = []; + const idSet = new Set(); + const fullIdSet = new Set(); + + for (const record of records) { + const fullId = `${record.familyId}:${record.individualId}`; + + // Check for duplicate IDs within family + if (fullIdSet.has(fullId)) { + warnings.push( + `Duplicate individual ID: ${record.individualId} in family ${record.familyId}` + ); + } + fullIdSet.add(fullId); + idSet.add(record.individualId); + } + + // Check parent references + for (const record of records) { + if (record.paternalId !== '0' && !idSet.has(record.paternalId)) { + warnings.push( + `Father ${record.paternalId} of ${record.individualId} not found in pedigree` + ); + } + if (record.maternalId !== '0' && !idSet.has(record.maternalId)) { + warnings.push( + `Mother ${record.maternalId} of ${record.individualId} not found in pedigree` + ); + } + } + + return warnings; + } + + /** + * Convert parsed records to a Pedigree structure + */ + recordsToPedigree(records: PedRecord[]): Pedigree { + if (records.length === 0) { + return createPedigree('unknown'); + } + + const familyId = records[0].familyId; + const pedigree = createPedigree(familyId); + + // First pass: create all persons + for (const record of records) { + const person = createPerson(record.individualId, record.familyId, record.sex); + person.phenotypes = [record.phenotype]; + pedigree.persons.set(person.id, person); + } + + // Second pass: establish relationships + for (const record of records) { + const person = pedigree.persons.get(record.individualId); + if (!person) continue; + + // Set parent references + if (record.paternalId !== '0') { + person.fatherId = record.paternalId; + const father = pedigree.persons.get(record.paternalId); + if (father) { + father.childrenIds.push(person.id); + } + } + + if (record.maternalId !== '0') { + person.motherId = record.maternalId; + const mother = pedigree.persons.get(record.maternalId); + if (mother) { + mother.childrenIds.push(person.id); + } + } + } + + // Third pass: create spouse relationships + const spouseMap = new Map>(); + + for (const person of pedigree.persons.values()) { + if (person.fatherId && person.motherId) { + const key = [person.fatherId, person.motherId].sort().join(':'); + if (!spouseMap.has(key)) { + spouseMap.set(key, new Set()); + } + spouseMap.get(key)!.add(person.id); + } + } + + for (const [key, childrenIds] of spouseMap) { + const [person1Id, person2Id] = key.split(':'); + + // Check if relationship already exists + const existingRel = Array.from(pedigree.relationships.values()).find( + r => + (r.person1Id === person1Id && r.person2Id === person2Id) || + (r.person1Id === person2Id && r.person2Id === person1Id) + ); + + if (!existingRel) { + const relationship = createRelationship(person1Id, person2Id); + relationship.childrenIds = Array.from(childrenIds); + pedigree.relationships.set(relationship.id, relationship); + + // Update spouse references + const person1 = pedigree.persons.get(person1Id); + const person2 = pedigree.persons.get(person2Id); + if (person1 && !person1.spouseIds.includes(person2Id)) { + person1.spouseIds.push(person2Id); + } + if (person2 && !person2.spouseIds.includes(person1Id)) { + person2.spouseIds.push(person1Id); + } + } + } + + return pedigree; + } + + /** + * Convenience method to parse content directly to Pedigree + */ + parseToPedigree(content: string): { pedigree: Pedigree; result: PedParseResult } { + const result = this.parse(content); + const pedigree = this.recordsToPedigree(result.records); + return { pedigree, result }; + } +} + +export const pedParser = new PedParser(); diff --git a/src/core/parser/PedWriter.ts b/src/core/parser/PedWriter.ts new file mode 100644 index 0000000..17cb9f2 --- /dev/null +++ b/src/core/parser/PedWriter.ts @@ -0,0 +1,134 @@ +/** + * PED File Writer + * + * Exports Pedigree structure to GATK PED format + */ + +import { + type Pedigree, + type Person, + Sex, + Phenotype, +} from '@/core/model/types'; + +export class PedWriter { + /** + * Convert Pedigree to PED file content + */ + write(pedigree: Pedigree): string { + const lines: string[] = []; + + // Add header comment + lines.push(`# Pedigree: ${pedigree.familyId}`); + lines.push(`# Generated: ${new Date().toISOString()}`); + lines.push(`# Format: FamilyID IndividualID PaternalID MaternalID Sex Phenotype`); + lines.push(''); + + // Sort persons by generation (founders first) then by ID + const sortedPersons = this.sortPersonsByGeneration(pedigree); + + for (const person of sortedPersons) { + const line = this.formatPersonLine(person); + lines.push(line); + } + + return lines.join('\n'); + } + + /** + * Format a single person as a PED line + */ + private formatPersonLine(person: Person): string { + const fields = [ + person.familyId, + person.id, + person.fatherId ?? '0', + person.motherId ?? '0', + this.formatSex(person.sex), + this.formatPhenotype(person.phenotypes[0] ?? Phenotype.Unknown), + ]; + + return fields.join('\t'); + } + + /** + * Convert Sex enum to PED format + */ + private formatSex(sex: Sex): string { + switch (sex) { + case Sex.Male: + return '1'; + case Sex.Female: + return '2'; + default: + return '0'; + } + } + + /** + * Convert Phenotype enum to PED format + */ + private formatPhenotype(phenotype: Phenotype): string { + switch (phenotype) { + case Phenotype.Unaffected: + return '1'; + case Phenotype.Affected: + return '2'; + case Phenotype.Carrier: + // Carrier is typically represented as unaffected in PED + // The carrier status is usually stored in additional columns + return '1'; + default: + return '-9'; + } + } + + /** + * Sort persons so founders come first, then by generation + */ + private sortPersonsByGeneration(pedigree: Pedigree): Person[] { + const persons = Array.from(pedigree.persons.values()); + const generationMap = new Map(); + + // Calculate generations using BFS from founders + const founders = persons.filter(p => !p.fatherId && !p.motherId); + const queue: Array<{ person: Person; generation: number }> = []; + + for (const founder of founders) { + queue.push({ person: founder, generation: 0 }); + generationMap.set(founder.id, 0); + } + + while (queue.length > 0) { + const { person, generation } = queue.shift()!; + + for (const childId of person.childrenIds) { + const child = pedigree.persons.get(childId); + if (child && !generationMap.has(childId)) { + const childGen = generation + 1; + generationMap.set(childId, childGen); + queue.push({ person: child, generation: childGen }); + } + } + } + + // Handle any persons not reached (disconnected individuals) + for (const person of persons) { + if (!generationMap.has(person.id)) { + generationMap.set(person.id, 999); // Put at the end + } + } + + // Sort by generation, then by ID + return persons.sort((a, b) => { + const genA = generationMap.get(a.id) ?? 999; + const genB = generationMap.get(b.id) ?? 999; + if (genA !== genB) { + return genA - genB; + } + return a.id.localeCompare(b.id); + }); + } +} + +export const pedWriter = new PedWriter(); diff --git a/src/core/renderer/ConnectionRenderer.ts b/src/core/renderer/ConnectionRenderer.ts new file mode 100644 index 0000000..9fc66d4 --- /dev/null +++ b/src/core/renderer/ConnectionRenderer.ts @@ -0,0 +1,345 @@ +/** + * Connection Renderer + * + * Renders lines connecting family members: + * - Spouse connections (horizontal line between spouses) + * - Parent-child connections (vertical + horizontal lines) + * - Sibling connections (horizontal line above siblings) + * - Twin connections (converging lines for identical, angled for fraternal) + * - Consanguineous marriages (double line) + */ + +import type { Person, Relationship, TwinType, LayoutNode, PartnershipStatus } from '@/core/model/types'; +import { RelationshipType, ChildlessReason } from '@/core/model/types'; + +export interface ConnectionConfig { + lineWidth: number; + doubleLineGap: number; + childDropHeight: number; + symbolSize: number; +} + +export const DEFAULT_CONNECTION_CONFIG: ConnectionConfig = { + lineWidth: 2, + doubleLineGap: 4, + childDropHeight: 30, + symbolSize: 40, +}; + +export interface ConnectionPath { + d: string; + className: string; + isDouble?: boolean; + // For clickable connections + relationshipId?: string; + connectionType?: 'spouse' | 'parent-child' | 'sibling' | 'twin' | 'indicator'; + clickableArea?: { + x: number; + y: number; + width: number; + height: number; + }; +} + +export class ConnectionRenderer { + private config: ConnectionConfig; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONNECTION_CONFIG, ...config }; + } + + /** + * Generate spouse connection path + */ + renderSpouseConnection( + person1: LayoutNode, + person2: LayoutNode, + relationship?: Relationship + ): ConnectionPath[] { + const halfSymbol = this.config.symbolSize / 2; + const hitPadding = 8; // Padding for click target + + // Ensure person1 is on the left + const [left, right] = person1.x < person2.x + ? [person1, person2] + : [person2, person1]; + + const y = left.y; + const x1 = left.x + halfSymbol; + const x2 = right.x - halfSymbol; + + const isConsanguineous = relationship?.type === RelationshipType.Consanguineous; + const relationshipId = relationship?.id; + + // Calculate clickable area + const clickableArea = { + x: x1, + y: y - hitPadding, + width: x2 - x1, + height: hitPadding * 2, + }; + + if (isConsanguineous) { + const gap = this.config.doubleLineGap; + return [ + { + d: `M ${x1} ${y - gap} L ${x2} ${y - gap}`, + className: 'connection-spouse connection-consanguineous', + relationshipId, + connectionType: 'spouse', + clickableArea, + }, + { + d: `M ${x1} ${y + gap} L ${x2} ${y + gap}`, + className: 'connection-spouse connection-consanguineous', + relationshipId, + connectionType: 'spouse', + // Only one clickable area needed + }, + ]; + } + + return [{ + d: `M ${x1} ${y} L ${x2} ${y}`, + className: 'connection-spouse', + relationshipId, + connectionType: 'spouse', + clickableArea, + }]; + } + + /** + * Generate parent-child connection paths + * Returns paths for the vertical drop line and the child connection + */ + renderParentChildConnection( + parents: [LayoutNode, LayoutNode] | [LayoutNode], + children: LayoutNode[] + ): ConnectionPath[] { + if (children.length === 0) return []; + + const paths: ConnectionPath[] = []; + const halfSymbol = this.config.symbolSize / 2; + const dropHeight = this.config.childDropHeight; + + // Calculate parent connection point + let parentX: number; + let parentY: number; + + if (parents.length === 2) { + // Two parents - connect from the middle of the spouse line + parentX = (parents[0].x + parents[1].x) / 2; + parentY = parents[0].y; + } else { + // Single parent + parentX = parents[0].x; + parentY = parents[0].y; + } + + const childY = children[0].y; + const midY = parentY + halfSymbol + dropHeight; + + // Vertical line from parent connection point + paths.push({ + d: `M ${parentX} ${parentY + halfSymbol} L ${parentX} ${midY}`, + className: 'connection-parent-child', + }); + + // Sort children by x position + const sortedChildren = [...children].sort((a, b) => a.x - b.x); + + if (sortedChildren.length === 1) { + // Single child - direct vertical line + const child = sortedChildren[0]; + paths.push({ + d: `M ${parentX} ${midY} L ${child.x} ${midY} L ${child.x} ${childY - halfSymbol}`, + className: 'connection-parent-child', + }); + } else { + // Multiple children - horizontal line connecting all + const leftX = sortedChildren[0].x; + const rightX = sortedChildren[sortedChildren.length - 1].x; + + // Horizontal line + paths.push({ + d: `M ${leftX} ${midY} L ${rightX} ${midY}`, + className: 'connection-sibling', + }); + + // Connect parent line to sibling line if needed + if (parentX < leftX || parentX > rightX) { + // Parent is outside the children span + const closestX = parentX < leftX ? leftX : rightX; + paths.push({ + d: `M ${parentX} ${midY} L ${closestX} ${midY}`, + className: 'connection-parent-child', + }); + } + + // Vertical lines to each child + for (const child of sortedChildren) { + paths.push({ + d: `M ${child.x} ${midY} L ${child.x} ${childY - halfSymbol}`, + className: 'connection-parent-child', + }); + } + } + + return paths; + } + + /** + * Generate twin connection paths + */ + renderTwinConnection( + twins: LayoutNode[], + twinType: TwinType + ): ConnectionPath[] { + if (twins.length < 2) return []; + + const paths: ConnectionPath[] = []; + const halfSymbol = this.config.symbolSize / 2; + + // Sort by x position + const sorted = [...twins].sort((a, b) => a.x - b.x); + + // Calculate the convergence point + const midX = (sorted[0].x + sorted[sorted.length - 1].x) / 2; + const topY = sorted[0].y - halfSymbol - 20; // Above the symbols + + if (twinType === 'monozygotic') { + // Identical twins - lines converge to a single point + for (const twin of sorted) { + paths.push({ + d: `M ${twin.x} ${twin.y - halfSymbol} L ${midX} ${topY}`, + className: 'connection-twin connection-twin-monozygotic', + }); + } + } else if (twinType === 'dizygotic') { + // Fraternal twins - lines stay separate at top + const spread = 10; + sorted.forEach((twin, index) => { + const offset = (index - (sorted.length - 1) / 2) * spread; + paths.push({ + d: `M ${twin.x} ${twin.y - halfSymbol} L ${midX + offset} ${topY}`, + className: 'connection-twin connection-twin-dizygotic', + }); + }); + } + + return paths; + } + + /** + * Generate no-children indicator (double line beneath spouse line) + */ + renderNoChildrenIndicator( + person1: LayoutNode, + person2: LayoutNode + ): ConnectionPath[] { + const halfSymbol = this.config.symbolSize / 2; + const y = person1.y + halfSymbol + 15; + + const [left, right] = person1.x < person2.x + ? [person1, person2] + : [person2, person1]; + + const midX = (left.x + right.x) / 2; + const lineWidth = 20; + + return [{ + d: `M ${midX - lineWidth / 2} ${y} L ${midX + lineWidth / 2} ${y}`, + className: 'connection-no-children', + }]; + } + + /** + * Generate separation indicator (single diagonal line through spouse connection) + */ + renderSeparationIndicator( + person1: LayoutNode, + person2: LayoutNode + ): ConnectionPath[] { + const midX = (person1.x + person2.x) / 2; + const y = person1.y; + const size = 8; + + return [{ + d: `M ${midX - size} ${y - size} L ${midX + size} ${y + size}`, + className: 'connection-separation', + }]; + } + + /** + * Generate divorce indicator (double diagonal line through spouse connection) + */ + renderDivorceIndicator( + person1: LayoutNode, + person2: LayoutNode + ): ConnectionPath[] { + const midX = (person1.x + person2.x) / 2; + const y = person1.y; + const size = 8; + const gap = 4; + + return [ + { + d: `M ${midX - size - gap} ${y - size} L ${midX + size - gap} ${y + size}`, + className: 'connection-divorce', + }, + { + d: `M ${midX - size + gap} ${y - size} L ${midX + size + gap} ${y + size}`, + className: 'connection-divorce', + }, + ]; + } + + /** + * Generate infertility indicator (vertical line + horizontal bar) + * Per NSGC standard: vertical line down from spouse line with horizontal bar + */ + renderInfertilityIndicator( + person1: LayoutNode, + person2: LayoutNode, + isByChoice: boolean = false + ): ConnectionPath[] { + const halfSymbol = this.config.symbolSize / 2; + const midX = (person1.x + person2.x) / 2; + const y = person1.y; + const verticalLength = 20; + const barWidth = 16; + + const paths: ConnectionPath[] = [ + // Vertical line + { + d: `M ${midX} ${y + halfSymbol} L ${midX} ${y + halfSymbol + verticalLength}`, + className: 'connection-infertility', + }, + // Horizontal bar + { + d: `M ${midX - barWidth / 2} ${y + halfSymbol + verticalLength} L ${midX + barWidth / 2} ${y + halfSymbol + verticalLength}`, + className: 'connection-infertility', + }, + ]; + + // Add "c" text indicator for by-choice (this will be rendered as a path hint) + if (isByChoice) { + paths.push({ + d: `M ${midX - 4} ${y + halfSymbol + verticalLength + 12} L ${midX + 4} ${y + halfSymbol + verticalLength + 12}`, + className: 'connection-no-children', + connectionType: 'indicator', + }); + } + + return paths; + } + + /** + * Update configuration + */ + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } +} + +export const connectionRenderer = new ConnectionRenderer(); diff --git a/src/core/renderer/SymbolRegistry.ts b/src/core/renderer/SymbolRegistry.ts new file mode 100644 index 0000000..c4b2faf --- /dev/null +++ b/src/core/renderer/SymbolRegistry.ts @@ -0,0 +1,270 @@ +/** + * Symbol Registry + * + * Manages standard pedigree symbols following NSGC guidelines: + * - Square: Male + * - Circle: Female + * - Diamond: Unknown sex + * - Filled: Affected + * - Half-filled: Carrier + * - Diagonal line: Deceased + * - Arrow: Proband + */ + +import { Sex, Phenotype } from '@/core/model/types'; + +export interface SymbolDimensions { + width: number; + height: number; +} + +export interface SymbolPaths { + outline: string; + fill?: string; +} + +export class SymbolRegistry { + private size: number; + + constructor(size: number = 40) { + this.size = size; + } + + /** + * Get symbol path for a given sex + */ + getSymbolPath(sex: Sex): string { + const s = this.size; + const half = s / 2; + + switch (sex) { + case Sex.Male: + // Square centered at origin + return `M ${-half} ${-half} L ${half} ${-half} L ${half} ${half} L ${-half} ${half} Z`; + + case Sex.Female: + // Circle centered at origin + return this.circlePath(0, 0, half); + + case Sex.Unknown: + default: + // Diamond (rotated square) centered at origin + return `M 0 ${-half} L ${half} 0 L 0 ${half} L ${-half} 0 Z`; + } + } + + /** + * Get SVG path for a circle + */ + private circlePath(cx: number, cy: number, r: number): string { + // Using arc commands to draw a circle + return `M ${cx - r} ${cy} + A ${r} ${r} 0 1 0 ${cx + r} ${cy} + A ${r} ${r} 0 1 0 ${cx - r} ${cy}`; + } + + /** + * Get carrier pattern (half-filled) + * Returns the path for the filled half + */ + getCarrierPath(sex: Sex): string { + const s = this.size; + const half = s / 2; + + switch (sex) { + case Sex.Male: + // Right half of square + return `M 0 ${-half} L ${half} ${-half} L ${half} ${half} L 0 ${half} Z`; + + case Sex.Female: + // Right half of circle using clip path approach + // We'll return a half-circle path + return `M 0 ${-half} A ${half} ${half} 0 0 1 0 ${half} Z`; + + case Sex.Unknown: + default: + // Right half of diamond + return `M 0 ${-half} L ${half} 0 L 0 ${half} Z`; + } + } + + /** + * Get quadrant paths for multiple phenotypes + * @param numPhenotypes Number of phenotypes (2, 3, or 4) + */ + getQuadrantPaths(sex: Sex, numPhenotypes: number): string[] { + const s = this.size; + const half = s / 2; + + if (numPhenotypes === 2) { + // Left and right halves + return [ + this.getLeftHalfPath(sex), + this.getRightHalfPath(sex), + ]; + } + + if (numPhenotypes === 4) { + // Four quadrants + return [ + this.getQuadrantPath(sex, 'top-left'), + this.getQuadrantPath(sex, 'top-right'), + this.getQuadrantPath(sex, 'bottom-right'), + this.getQuadrantPath(sex, 'bottom-left'), + ]; + } + + // Default to full shape + return [this.getSymbolPath(sex)]; + } + + private getLeftHalfPath(sex: Sex): string { + const half = this.size / 2; + + switch (sex) { + case Sex.Male: + return `M ${-half} ${-half} L 0 ${-half} L 0 ${half} L ${-half} ${half} Z`; + case Sex.Female: + return `M 0 ${-half} A ${half} ${half} 0 0 0 0 ${half} Z`; + default: + return `M 0 ${-half} L ${-half} 0 L 0 ${half} Z`; + } + } + + private getRightHalfPath(sex: Sex): string { + const half = this.size / 2; + + switch (sex) { + case Sex.Male: + return `M 0 ${-half} L ${half} ${-half} L ${half} ${half} L 0 ${half} Z`; + case Sex.Female: + return `M 0 ${-half} A ${half} ${half} 0 0 1 0 ${half} Z`; + default: + return `M 0 ${-half} L ${half} 0 L 0 ${half} Z`; + } + } + + private getQuadrantPath(sex: Sex, quadrant: 'top-left' | 'top-right' | 'bottom-right' | 'bottom-left'): string { + const half = this.size / 2; + + // For simplicity, use square-based quadrants + switch (quadrant) { + case 'top-left': + return `M ${-half} ${-half} L 0 ${-half} L 0 0 L ${-half} 0 Z`; + case 'top-right': + return `M 0 ${-half} L ${half} ${-half} L ${half} 0 L 0 0 Z`; + case 'bottom-right': + return `M 0 0 L ${half} 0 L ${half} ${half} L 0 ${half} Z`; + case 'bottom-left': + return `M ${-half} 0 L 0 0 L 0 ${half} L ${-half} ${half} Z`; + } + } + + /** + * Get deceased overlay path (diagonal line) + */ + getDeceasedPath(): string { + const s = this.size; + const half = s / 2; + const extend = s * 0.2; // Extend beyond symbol + + return `M ${-half - extend} ${half + extend} L ${half + extend} ${-half - extend}`; + } + + /** + * Get proband arrow path + */ + getProbandArrowPath(): string { + const s = this.size; + const half = s / 2; + const arrowSize = s * 0.3; + + // Arrow pointing to bottom-left corner + const startX = -half - s * 0.5; + const startY = half + s * 0.5; + const endX = -half - s * 0.1; + const endY = half + s * 0.1; + + return ` + M ${startX} ${startY} + L ${endX} ${endY} + M ${endX} ${endY} + L ${endX - arrowSize * 0.3} ${endY + arrowSize * 0.1} + M ${endX} ${endY} + L ${endX + arrowSize * 0.1} ${endY - arrowSize * 0.3} + `; + } + + /** + * Get adoption bracket paths + * Returns [left bracket, right bracket] + */ + getAdoptionBracketPaths(): [string, string] { + const s = this.size; + const half = s / 2; + const bracketWidth = s * 0.15; + const bracketHeight = s * 0.2; + + const left = ` + M ${-half - bracketWidth} ${-half - bracketHeight} + L ${-half - bracketWidth * 2} ${-half - bracketHeight} + L ${-half - bracketWidth * 2} ${half + bracketHeight} + L ${-half - bracketWidth} ${half + bracketHeight} + `; + + const right = ` + M ${half + bracketWidth} ${-half - bracketHeight} + L ${half + bracketWidth * 2} ${-half - bracketHeight} + L ${half + bracketWidth * 2} ${half + bracketHeight} + L ${half + bracketWidth} ${half + bracketHeight} + `; + + return [left, right]; + } + + /** + * Get miscarriage/stillbirth triangle path + */ + getMiscarriageTrianglePath(): string { + const s = this.size * 0.4; // Smaller than regular symbols + const half = s / 2; + + return `M 0 ${-half} L ${half} ${half} L ${-half} ${half} Z`; + } + + /** + * Get pregnancy symbol path (diamond with P) + */ + getPregnancyPath(): string { + const s = this.size * 0.6; + const half = s / 2; + + return `M 0 ${-half} L ${half} 0 L 0 ${half} L ${-half} 0 Z`; + } + + /** + * Get infertility line path + */ + getInfertilityPath(): string { + const s = this.size; + const lineOffset = s * 0.7; + + return `M ${-s * 0.3} ${lineOffset} L ${s * 0.3} ${lineOffset}`; + } + + /** + * Update symbol size + */ + setSize(size: number): void { + this.size = size; + } + + /** + * Get current symbol size + */ + getSize(): number { + return this.size; + } +} + +export const symbolRegistry = new SymbolRegistry(); diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..280b6fe --- /dev/null +++ b/src/index.css @@ -0,0 +1,142 @@ +/* Reset and base styles */ +*, *::before, *::after { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + color: #333; + background: #f5f5f5; +} + +#root { + height: 100%; +} + +/* Button reset */ +button { + font-family: inherit; +} + +/* Pedigree SVG styles */ +.pedigree-main { + font-family: sans-serif; +} + +.person { + transition: transform 0.1s ease; +} + +.person:hover .person-symbol { + filter: brightness(0.95); +} + +.person.dragging { + opacity: 0.8; +} + +.connection-spouse, +.connection-parent-child, +.connection-sibling { + stroke: #333; + stroke-width: 2; + fill: none; +} + +.connection-consanguineous { + stroke: #333; + stroke-width: 2; +} + +.connection-twin-monozygotic, +.connection-twin-dizygotic { + stroke: #333; + stroke-width: 2; +} + +.person-deceased { + stroke: #333; + stroke-width: 2.5; +} + +.person-proband { + stroke: #333; + stroke-width: 2; +} + +.person-adopted { + stroke: #333; + stroke-width: 2; +} + +.generation-labels text { + fill: #333; +} + +/* Connection interaction styles */ +.connection-group { + cursor: pointer; +} + +.connection-group .connection-hit-area { + fill: transparent; + cursor: pointer; +} + +.connection-group:hover path { + stroke: #1976D2; + stroke-width: 3; +} + +.connection-group.selected path { + stroke: #2196F3; + stroke-width: 3; +} + +.connection-separation { + stroke: #333; + stroke-width: 2; +} + +.connection-divorce { + stroke: #333; + stroke-width: 2; +} + +.connection-no-children { + stroke: #333; + stroke-width: 2; +} + +.connection-infertility { + stroke: #333; + stroke-width: 2; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; +} + +::-webkit-scrollbar-thumb { + background: #ccc; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #bbb; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..23f07a2 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import { App } from './components/App/App' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/src/services/exportService.ts b/src/services/exportService.ts new file mode 100644 index 0000000..73e70d3 --- /dev/null +++ b/src/services/exportService.ts @@ -0,0 +1,178 @@ +/** + * Export Service + * + * Handles exporting pedigree diagrams to various formats: + * - SVG (vector graphics) + * - PNG (raster graphics) + * - PED (GATK format) + */ + +import { toPng, toSvg } from 'html-to-image'; +import type { Pedigree } from '@/core/model/types'; +import { PedWriter } from '@/core/parser/PedWriter'; + +export interface ExportOptions { + filename?: string; + scale?: number; + backgroundColor?: string; + padding?: number; +} + +const DEFAULT_OPTIONS: Required = { + filename: 'pedigree', + scale: 2, + backgroundColor: '#ffffff', + padding: 20, +}; + +export class ExportService { + private pedWriter: PedWriter; + + constructor() { + this.pedWriter = new PedWriter(); + } + + /** + * Export SVG element as SVG file + */ + async exportSvg( + svgElement: SVGSVGElement, + options: ExportOptions = {} + ): Promise { + const { filename, backgroundColor, padding } = { ...DEFAULT_OPTIONS, ...options }; + + try { + // Clone the SVG to avoid modifying the original + const clonedSvg = svgElement.cloneNode(true) as SVGSVGElement; + + // Get bounding box + const bbox = svgElement.getBBox(); + + // Update viewBox to include padding + const viewBox = `${bbox.x - padding} ${bbox.y - padding} ${bbox.width + padding * 2} ${bbox.height + padding * 2}`; + clonedSvg.setAttribute('viewBox', viewBox); + clonedSvg.setAttribute('width', String(bbox.width + padding * 2)); + clonedSvg.setAttribute('height', String(bbox.height + padding * 2)); + + // Add background + const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bgRect.setAttribute('x', String(bbox.x - padding)); + bgRect.setAttribute('y', String(bbox.y - padding)); + bgRect.setAttribute('width', String(bbox.width + padding * 2)); + bgRect.setAttribute('height', String(bbox.height + padding * 2)); + bgRect.setAttribute('fill', backgroundColor); + clonedSvg.insertBefore(bgRect, clonedSvg.firstChild); + + // Inline styles for standalone SVG + this.inlineStyles(clonedSvg); + + // Serialize to string + const serializer = new XMLSerializer(); + let svgString = serializer.serializeToString(clonedSvg); + + // Add XML declaration + svgString = '\n' + svgString; + + // Create and download blob + const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' }); + this.downloadBlob(blob, `${filename}.svg`); + } catch (error) { + console.error('Failed to export SVG:', error); + throw new Error('Failed to export SVG'); + } + } + + /** + * Export SVG element as PNG file + */ + async exportPng( + svgElement: SVGSVGElement, + options: ExportOptions = {} + ): Promise { + const { filename, scale, backgroundColor } = { ...DEFAULT_OPTIONS, ...options }; + + try { + const dataUrl = await toPng(svgElement as unknown as HTMLElement, { + pixelRatio: scale, + backgroundColor, + cacheBust: true, + }); + + // Convert data URL to blob and download + const response = await fetch(dataUrl); + const blob = await response.blob(); + this.downloadBlob(blob, `${filename}.png`); + } catch (error) { + console.error('Failed to export PNG:', error); + throw new Error('Failed to export PNG'); + } + } + + /** + * Export pedigree as PED file + */ + exportPed(pedigree: Pedigree, options: ExportOptions = {}): void { + const { filename } = { ...DEFAULT_OPTIONS, ...options }; + + try { + const pedContent = this.pedWriter.write(pedigree); + const blob = new Blob([pedContent], { type: 'text/plain;charset=utf-8' }); + this.downloadBlob(blob, `${filename}.ped`); + } catch (error) { + console.error('Failed to export PED:', error); + throw new Error('Failed to export PED file'); + } + } + + /** + * Download a blob as a file + */ + private downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Clean up + setTimeout(() => URL.revokeObjectURL(url), 100); + } + + /** + * Inline computed styles for standalone SVG + */ + private inlineStyles(svg: SVGSVGElement): void { + const elements = svg.querySelectorAll('*'); + + elements.forEach((el) => { + const element = el as SVGElement; + const computed = window.getComputedStyle(element); + + // Only inline essential styles + const essentialStyles = [ + 'fill', + 'stroke', + 'stroke-width', + 'stroke-dasharray', + 'font-family', + 'font-size', + 'font-weight', + 'text-anchor', + 'dominant-baseline', + ]; + + essentialStyles.forEach((prop) => { + const value = computed.getPropertyValue(prop); + if (value && value !== 'none' && value !== 'normal' && value !== '0px') { + element.style.setProperty(prop, value); + } + }); + }); + } +} + +export const exportService = new ExportService(); diff --git a/src/store/pedigreeStore.ts b/src/store/pedigreeStore.ts new file mode 100644 index 0000000..ad7adb0 --- /dev/null +++ b/src/store/pedigreeStore.ts @@ -0,0 +1,381 @@ +/** + * Pedigree Store + * + * Global state management using Zustand with Zundo for undo/redo + */ + +import { create } from 'zustand'; +import { temporal } from 'zundo'; +import type { Pedigree, Person, Relationship, LayoutNode } from '@/core/model/types'; +import { + createPerson, + createPedigree, + createRelationship, + Sex, + Phenotype, + RelationshipType, +} from '@/core/model/types'; +import { PedigreeLayout } from '@/core/layout/PedigreeLayout'; + +interface PedigreeState { + // Data + pedigree: Pedigree | null; + layoutNodes: Map; + + // Selection + selectedPersonId: string | null; + selectedRelationshipId: string | null; + + // UI State + isEditing: boolean; + currentTool: 'select' | 'add-person' | 'add-relationship' | 'delete'; + + // Actions - Pedigree + loadPedigree: (pedigree: Pedigree) => void; + clearPedigree: () => void; + createNewPedigree: (familyId: string) => void; + + // Actions - Person + addPerson: (person: Person) => void; + updatePerson: (id: string, updates: Partial) => void; + deletePerson: (id: string) => void; + updatePersonPosition: (id: string, x: number, y: number) => void; + + // Actions - Relationship + addRelationship: (relationship: Relationship) => void; + updateRelationship: (id: string, updates: Partial) => void; + deleteRelationship: (id: string) => void; + + // Actions - Selection + selectPerson: (id: string | null) => void; + selectRelationship: (id: string | null) => void; + clearSelection: () => void; + + // Actions - UI + setCurrentTool: (tool: PedigreeState['currentTool']) => void; + setIsEditing: (isEditing: boolean) => void; + + // Actions - Layout + recalculateLayout: () => void; + + // Helpers + getSelectedPerson: () => Person | null; + getSelectedRelationship: () => Relationship | null; +} + +const layout = new PedigreeLayout(); + +export const usePedigreeStore = create()( + temporal( + (set, get) => ({ + // Initial state + pedigree: null, + layoutNodes: new Map(), + selectedPersonId: null, + selectedRelationshipId: null, + isEditing: false, + currentTool: 'select', + + // Pedigree actions + loadPedigree: (pedigree) => { + const layoutNodes = layout.layout(pedigree); + set({ pedigree, layoutNodes }); + }, + + clearPedigree: () => { + set({ + pedigree: null, + layoutNodes: new Map(), + selectedPersonId: null, + selectedRelationshipId: null, + }); + }, + + createNewPedigree: (familyId) => { + const pedigree = createPedigree(familyId); + set({ pedigree, layoutNodes: new Map() }); + }, + + // Person actions + addPerson: (person) => { + set((state) => { + if (!state.pedigree) return state; + + const newPersons = new Map(state.pedigree.persons); + newPersons.set(person.id, person); + + const newPedigree = { + ...state.pedigree, + persons: newPersons, + metadata: { + ...state.pedigree.metadata, + modifiedAt: new Date(), + }, + }; + + const layoutNodes = layout.layout(newPedigree); + return { pedigree: newPedigree, layoutNodes }; + }); + }, + + updatePerson: (id, updates) => { + set((state) => { + if (!state.pedigree) return state; + + const person = state.pedigree.persons.get(id); + if (!person) return state; + + const newPersons = new Map(state.pedigree.persons); + newPersons.set(id, { ...person, ...updates }); + + const newPedigree = { + ...state.pedigree, + persons: newPersons, + metadata: { + ...state.pedigree.metadata, + modifiedAt: new Date(), + }, + }; + + return { pedigree: newPedigree }; + }); + }, + + deletePerson: (id) => { + set((state) => { + if (!state.pedigree) return state; + + const newPersons = new Map(state.pedigree.persons); + const person = newPersons.get(id); + if (!person) return state; + + // Remove from other persons' references + for (const [, p] of newPersons) { + if (p.fatherId === id) p.fatherId = null; + if (p.motherId === id) p.motherId = null; + p.spouseIds = p.spouseIds.filter((sid) => sid !== id); + p.childrenIds = p.childrenIds.filter((cid) => cid !== id); + } + + newPersons.delete(id); + + // Remove related relationships + const newRelationships = new Map(state.pedigree.relationships); + for (const [relId, rel] of newRelationships) { + if (rel.person1Id === id || rel.person2Id === id) { + newRelationships.delete(relId); + } + } + + const newPedigree = { + ...state.pedigree, + persons: newPersons, + relationships: newRelationships, + metadata: { + ...state.pedigree.metadata, + modifiedAt: new Date(), + }, + }; + + const layoutNodes = layout.layout(newPedigree); + return { + pedigree: newPedigree, + layoutNodes, + selectedPersonId: state.selectedPersonId === id ? null : state.selectedPersonId, + }; + }); + }, + + updatePersonPosition: (id, x, y) => { + set((state) => { + if (!state.pedigree) return state; + + const person = state.pedigree.persons.get(id); + if (!person) return state; + + const newPersons = new Map(state.pedigree.persons); + newPersons.set(id, { ...person, x, y }); + + const newLayoutNodes = new Map(state.layoutNodes); + const node = newLayoutNodes.get(id); + if (node) { + newLayoutNodes.set(id, { ...node, x, y }); + } + + return { + pedigree: { + ...state.pedigree, + persons: newPersons, + }, + layoutNodes: newLayoutNodes, + }; + }); + }, + + // Relationship actions + addRelationship: (relationship) => { + set((state) => { + if (!state.pedigree) return state; + + const newRelationships = new Map(state.pedigree.relationships); + newRelationships.set(relationship.id, relationship); + + // Update spouse references + const newPersons = new Map(state.pedigree.persons); + const person1 = newPersons.get(relationship.person1Id); + const person2 = newPersons.get(relationship.person2Id); + + if (person1 && !person1.spouseIds.includes(relationship.person2Id)) { + newPersons.set(person1.id, { + ...person1, + spouseIds: [...person1.spouseIds, relationship.person2Id], + }); + } + if (person2 && !person2.spouseIds.includes(relationship.person1Id)) { + newPersons.set(person2.id, { + ...person2, + spouseIds: [...person2.spouseIds, relationship.person1Id], + }); + } + + const newPedigree = { + ...state.pedigree, + persons: newPersons, + relationships: newRelationships, + metadata: { + ...state.pedigree.metadata, + modifiedAt: new Date(), + }, + }; + + const layoutNodes = layout.layout(newPedigree); + return { pedigree: newPedigree, layoutNodes }; + }); + }, + + updateRelationship: (id, updates) => { + set((state) => { + if (!state.pedigree) return state; + + const relationship = state.pedigree.relationships.get(id); + if (!relationship) return state; + + const newRelationships = new Map(state.pedigree.relationships); + newRelationships.set(id, { ...relationship, ...updates }); + + return { + pedigree: { + ...state.pedigree, + relationships: newRelationships, + metadata: { + ...state.pedigree.metadata, + modifiedAt: new Date(), + }, + }, + }; + }); + }, + + deleteRelationship: (id) => { + set((state) => { + if (!state.pedigree) return state; + + const relationship = state.pedigree.relationships.get(id); + if (!relationship) return state; + + const newRelationships = new Map(state.pedigree.relationships); + newRelationships.delete(id); + + // Remove spouse references + const newPersons = new Map(state.pedigree.persons); + const person1 = newPersons.get(relationship.person1Id); + const person2 = newPersons.get(relationship.person2Id); + + if (person1) { + newPersons.set(person1.id, { + ...person1, + spouseIds: person1.spouseIds.filter((sid) => sid !== relationship.person2Id), + }); + } + if (person2) { + newPersons.set(person2.id, { + ...person2, + spouseIds: person2.spouseIds.filter((sid) => sid !== relationship.person1Id), + }); + } + + const newPedigree = { + ...state.pedigree, + persons: newPersons, + relationships: newRelationships, + metadata: { + ...state.pedigree.metadata, + modifiedAt: new Date(), + }, + }; + + const layoutNodes = layout.layout(newPedigree); + return { + pedigree: newPedigree, + layoutNodes, + selectedRelationshipId: state.selectedRelationshipId === id ? null : state.selectedRelationshipId, + }; + }); + }, + + // Selection actions + selectPerson: (id) => { + set({ selectedPersonId: id, selectedRelationshipId: null }); + }, + + selectRelationship: (id) => { + set({ selectedRelationshipId: id, selectedPersonId: null }); + }, + + clearSelection: () => { + set({ selectedPersonId: null, selectedRelationshipId: null }); + }, + + // UI actions + setCurrentTool: (tool) => { + set({ currentTool: tool }); + }, + + setIsEditing: (isEditing) => { + set({ isEditing }); + }, + + // Layout actions + recalculateLayout: () => { + set((state) => { + if (!state.pedigree) return state; + const layoutNodes = layout.layout(state.pedigree); + return { layoutNodes }; + }); + }, + + // Helpers + getSelectedPerson: () => { + const state = get(); + if (!state.pedigree || !state.selectedPersonId) return null; + return state.pedigree.persons.get(state.selectedPersonId) ?? null; + }, + + getSelectedRelationship: () => { + const state = get(); + if (!state.pedigree || !state.selectedRelationshipId) return null; + return state.pedigree.relationships.get(state.selectedRelationshipId) ?? null; + }, + }), + { + // Zundo configuration + partialize: (state) => ({ + pedigree: state.pedigree, + }), + limit: 50, + } + ) +); + +// Export temporal actions for undo/redo +export const useTemporalStore = () => usePedigreeStore.temporal; diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..e954799 --- /dev/null +++ b/start.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Pedigree Draw - Start Server Script + +cd "$(dirname "$0")" + +# Check if node_modules exists +if [ ! -d "node_modules" ]; then + echo "Installing dependencies..." + npm install +fi + +# Check if another instance is running +if [ -f ".server.pid" ]; then + OLD_PID=$(cat .server.pid) + if kill -0 "$OLD_PID" 2>/dev/null; then + echo "Server is already running (PID: $OLD_PID)" + echo "Access at: http://localhost:5173/pedigree-draw/" + exit 0 + fi +fi + +echo "Starting Pedigree Draw server..." +npm run dev > .server.log 2>&1 & +SERVER_PID=$! +echo $SERVER_PID > .server.pid + +# Wait for server to start +sleep 2 + +if kill -0 "$SERVER_PID" 2>/dev/null; then + # Get local IP address (works on both Linux and macOS) + LOCAL_IP=$(ip route get 1 2>/dev/null | awk '{print $7; exit}' || hostname -I 2>/dev/null | awk '{print $1}' || echo "YOUR_IP") + + echo "" + echo "==========================================" + echo " Pedigree Draw Server Started!" + echo "==========================================" + echo "" + echo " Local: http://localhost:5173/pedigree-draw/" + if [ "$LOCAL_IP" != "YOUR_IP" ] && [ -n "$LOCAL_IP" ]; then + echo " Network: http://${LOCAL_IP}:5173/pedigree-draw/" + else + echo " Network: Check 'ip addr' for your IP address" + fi + echo "" + echo " To stop the server, run: ./stop.sh" + echo "==========================================" +else + echo "Failed to start server. Check .server.log for details." + rm -f .server.pid + exit 1 +fi diff --git a/stop.sh b/stop.sh new file mode 100755 index 0000000..544196b --- /dev/null +++ b/stop.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Pedigree Draw - Stop Server Script + +cd "$(dirname "$0")" + +if [ -f ".server.pid" ]; then + PID=$(cat .server.pid) + if kill -0 "$PID" 2>/dev/null; then + echo "Stopping server (PID: $PID)..." + kill "$PID" + rm -f .server.pid + echo "Server stopped." + else + echo "Server process not found. Cleaning up..." + rm -f .server.pid + fi +else + # Try to find and kill any running vite process for this project + PIDS=$(pgrep -f "vite.*pedigree-draw") + if [ -n "$PIDS" ]; then + echo "Found running server process(es): $PIDS" + echo "Stopping..." + kill $PIDS 2>/dev/null + echo "Server stopped." + else + echo "No server is running." + fi +fi diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..9274597 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "erasableSyntaxOnly": false, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..a733db3 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { resolve } from 'path' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + base: '/pedigree-draw/', + resolve: { + alias: { + '@': resolve(__dirname, './src'), + }, + }, + server: { + host: true, // 監聽所有網路介面 (0.0.0.0) + port: 5173, + }, +})