Initial commit
This commit is contained in:
54
.github/workflows/deploy.yml
vendored
Normal file
54
.github/workflows/deploy.yml
vendored
Normal file
@@ -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
|
||||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -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?
|
||||||
229
README.md
Normal file
229
README.md
Normal file
@@ -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
|
||||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>pedigree-draw</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4019
package-lock.json
generated
Normal file
4019
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
public/sample.ped
Normal file
10
public/sample.ped
Normal file
@@ -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
|
||||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
48
src/components/App/App.module.css
Normal file
48
src/components/App/App.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
68
src/components/App/App.tsx
Normal file
68
src/components/App/App.tsx
Normal file
@@ -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 (
|
||||||
|
<div className={styles.app}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<h1 className={styles.title}>Pedigree Draw</h1>
|
||||||
|
<span className={styles.subtitle}>Professional Pedigree Chart Editor</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Toolbar />
|
||||||
|
|
||||||
|
<main className={styles.main}>
|
||||||
|
<FilePanel />
|
||||||
|
<div className={styles.canvasArea}>
|
||||||
|
<PedigreeCanvas />
|
||||||
|
</div>
|
||||||
|
{selectedRelationshipId ? <RelationshipPanel /> : <PropertyPanel />}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className={styles.footer}>
|
||||||
|
<span>Pedigree Draw - For genetic counselors and bioinformatics professionals</span>
|
||||||
|
<span>NSGC Standard Symbols</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/components/FilePanel/FilePanel.module.css
Normal file
109
src/components/FilePanel/FilePanel.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
211
src/components/FilePanel/FilePanel.tsx
Normal file
211
src/components/FilePanel/FilePanel.tsx
Normal file
@@ -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<HTMLInputElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [importError, setImportError] = useState<string | null>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<div className={styles.header}>File Operations</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<button className={styles.button} onClick={handleNewPedigree}>
|
||||||
|
New Pedigree
|
||||||
|
</button>
|
||||||
|
<button className={styles.button} onClick={() => fileInputRef.current?.click()}>
|
||||||
|
Import PED
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".ped,.txt"
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`${styles.dropZone} ${isDragging ? styles.dragging : ''}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<div className={styles.dropZoneText}>
|
||||||
|
Drag & Drop PED file here
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importError && (
|
||||||
|
<div className={styles.error}>{importError}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.divider} />
|
||||||
|
|
||||||
|
<div className={styles.header}>Export</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<button
|
||||||
|
className={styles.button}
|
||||||
|
onClick={handleExportSvg}
|
||||||
|
disabled={!pedigree}
|
||||||
|
>
|
||||||
|
Export SVG
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.button}
|
||||||
|
onClick={handleExportPng}
|
||||||
|
disabled={!pedigree}
|
||||||
|
>
|
||||||
|
Export PNG
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.button}
|
||||||
|
onClick={handleExportPed}
|
||||||
|
disabled={!pedigree}
|
||||||
|
>
|
||||||
|
Export PED
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pedigree && (
|
||||||
|
<>
|
||||||
|
<div className={styles.divider} />
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span>Family ID:</span>
|
||||||
|
<span>{pedigree.familyId}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span>Persons:</span>
|
||||||
|
<span>{pedigree.persons.size}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.infoItem}>
|
||||||
|
<span>Relationships:</span>
|
||||||
|
<span>{pedigree.relationships.size}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/components/PedigreeCanvas/PedigreeCanvas.module.css
Normal file
100
src/components/PedigreeCanvas/PedigreeCanvas.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
110
src/components/PedigreeCanvas/PedigreeCanvas.tsx
Normal file
110
src/components/PedigreeCanvas/PedigreeCanvas.tsx
Normal file
@@ -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 (
|
||||||
|
<div className={styles.canvasContainer}>
|
||||||
|
<div className={styles.zoomControls}>
|
||||||
|
<button onClick={zoomIn} title="Zoom In">+</button>
|
||||||
|
<span className={styles.zoomLevel}>{Math.round(zoomLevel * 100)}%</span>
|
||||||
|
<button onClick={zoomOut} title="Zoom Out">-</button>
|
||||||
|
<button onClick={resetZoom} title="Reset Zoom">Reset</button>
|
||||||
|
<button onClick={fitToContent} title="Fit to Content">Fit</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
className={styles.canvas}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!pedigree && (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<p>No pedigree loaded</p>
|
||||||
|
<p>Import a PED file or create a new pedigree</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pedigree && pedigree.persons.size === 0 && (
|
||||||
|
<div className={styles.emptyState}>
|
||||||
|
<p>Pedigree: {pedigree.familyId}</p>
|
||||||
|
<p>Use the toolbar to add persons (Male/Female/Unknown)</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
539
src/components/PedigreeCanvas/hooks/useD3Pedigree.ts
Normal file
539
src/components/PedigreeCanvas/hooks/useD3Pedigree.ts
Normal file
@@ -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<string, LayoutNode>;
|
||||||
|
selectedPersonId: string | null;
|
||||||
|
selectedRelationshipId: string | null;
|
||||||
|
options?: Partial<RenderOptions>;
|
||||||
|
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<SVGSVGElement>(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<SVGGElement>('.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<SVGGElement, unknown, null, undefined>,
|
||||||
|
pedigree: Pedigree,
|
||||||
|
layoutNodes: Map<string, LayoutNode>,
|
||||||
|
renderer: ConnectionRenderer,
|
||||||
|
selectedRelationshipId: string | null,
|
||||||
|
onRelationshipClick?: (relationshipId: string) => void
|
||||||
|
) {
|
||||||
|
// Render spouse connections
|
||||||
|
const processedPairs = new Set<string>();
|
||||||
|
|
||||||
|
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<SVGGElement, unknown, null, undefined>,
|
||||||
|
pedigree: Pedigree,
|
||||||
|
layoutNodes: Map<string, LayoutNode>,
|
||||||
|
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<SVGGElement, unknown, null, undefined>,
|
||||||
|
layoutNodes: Map<string, LayoutNode>
|
||||||
|
) {
|
||||||
|
// Find unique generations and their Y positions
|
||||||
|
const generations = new Map<number, number>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
125
src/components/PedigreeCanvas/hooks/useDragBehavior.ts
Normal file
125
src/components/PedigreeCanvas/hooks/useDragBehavior.ts
Normal file
@@ -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<SVGSVGElement | null>;
|
||||||
|
layoutNodes: Map<string, LayoutNode>;
|
||||||
|
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<string | null>(null);
|
||||||
|
const startPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||||
|
const hasDragged = useRef<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!svgRef.current || !isEnabled) return;
|
||||||
|
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
const persons = svg.selectAll<SVGGElement, unknown>('.person');
|
||||||
|
|
||||||
|
const drag = d3.drag<SVGGElement, unknown>()
|
||||||
|
.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
222
src/components/PedigreeCanvas/hooks/useZoomPan.ts
Normal file
222
src/components/PedigreeCanvas/hooks/useZoomPan.ts
Normal file
@@ -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<SVGSVGElement | null>;
|
||||||
|
minZoom?: number;
|
||||||
|
maxZoom?: number;
|
||||||
|
onZoomChange?: (state: ZoomPanState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useZoomPan({
|
||||||
|
svgRef,
|
||||||
|
minZoom = 0.1,
|
||||||
|
maxZoom = 4,
|
||||||
|
onZoomChange,
|
||||||
|
}: UseZoomPanProps) {
|
||||||
|
const zoomBehavior = useRef<d3.ZoomBehavior<SVGSVGElement, unknown> | null>(null);
|
||||||
|
const currentTransform = useRef(d3.zoomIdentity);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!svgRef.current) return;
|
||||||
|
|
||||||
|
const svg = d3.select(svgRef.current);
|
||||||
|
const mainGroup = svg.select<SVGGElement>('.pedigree-main');
|
||||||
|
|
||||||
|
if (mainGroup.empty()) return;
|
||||||
|
|
||||||
|
// Create zoom behavior
|
||||||
|
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||||
|
.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<SVGGElement>('.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<SVGGElement>('.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<SVGGElement>('.persons');
|
||||||
|
const connectionsGroup = svg.select<SVGGElement>('.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
114
src/components/PropertyPanel/PropertyPanel.module.css
Normal file
114
src/components/PropertyPanel/PropertyPanel.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
190
src/components/PropertyPanel/PropertyPanel.tsx
Normal file
190
src/components/PropertyPanel/PropertyPanel.tsx
Normal file
@@ -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 (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<div className={styles.header}>Properties</div>
|
||||||
|
<div className={styles.empty}>
|
||||||
|
Select a person to edit properties
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
Properties
|
||||||
|
<span className={styles.personId}>{selectedPerson.id}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>Label</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={selectedPerson.metadata.label ?? ''}
|
||||||
|
onChange={(e) => handleLabelChange(e.target.value)}
|
||||||
|
placeholder="Enter label..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>Sex</div>
|
||||||
|
<div className={styles.buttonGroup}>
|
||||||
|
<button
|
||||||
|
className={`${styles.optionButton} ${selectedPerson.sex === Sex.Male ? styles.active : ''}`}
|
||||||
|
onClick={() => handleSexChange(Sex.Male)}
|
||||||
|
>
|
||||||
|
Male
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.optionButton} ${selectedPerson.sex === Sex.Female ? styles.active : ''}`}
|
||||||
|
onClick={() => handleSexChange(Sex.Female)}
|
||||||
|
>
|
||||||
|
Female
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.optionButton} ${selectedPerson.sex === Sex.Unknown ? styles.active : ''}`}
|
||||||
|
onClick={() => handleSexChange(Sex.Unknown)}
|
||||||
|
>
|
||||||
|
Unknown
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>Phenotype</div>
|
||||||
|
<div className={styles.buttonGroup}>
|
||||||
|
<button
|
||||||
|
className={`${styles.optionButton} ${selectedPerson.phenotypes[0] === Phenotype.Unaffected ? styles.active : ''}`}
|
||||||
|
onClick={() => handlePhenotypeChange(Phenotype.Unaffected)}
|
||||||
|
>
|
||||||
|
Unaffected
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.optionButton} ${selectedPerson.phenotypes[0] === Phenotype.Affected ? styles.active : ''}`}
|
||||||
|
onClick={() => handlePhenotypeChange(Phenotype.Affected)}
|
||||||
|
>
|
||||||
|
Affected
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.optionButton} ${selectedPerson.phenotypes[0] === Phenotype.Carrier ? styles.active : ''}`}
|
||||||
|
onClick={() => handlePhenotypeChange(Phenotype.Carrier)}
|
||||||
|
>
|
||||||
|
Carrier
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.optionButton} ${selectedPerson.phenotypes[0] === Phenotype.Unknown ? styles.active : ''}`}
|
||||||
|
onClick={() => handlePhenotypeChange(Phenotype.Unknown)}
|
||||||
|
>
|
||||||
|
Unknown
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>Status</div>
|
||||||
|
<div className={styles.checkboxGroup}>
|
||||||
|
<label className={styles.checkbox}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedPerson.status.isDeceased}
|
||||||
|
onChange={(e) => handleStatusChange('isDeceased', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Deceased
|
||||||
|
</label>
|
||||||
|
<label className={styles.checkbox}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedPerson.status.isProband}
|
||||||
|
onChange={(e) => handleStatusChange('isProband', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Proband
|
||||||
|
</label>
|
||||||
|
<label className={styles.checkbox}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedPerson.status.isAdopted}
|
||||||
|
onChange={(e) => handleStatusChange('isAdopted', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Adopted
|
||||||
|
</label>
|
||||||
|
<label className={styles.checkbox}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedPerson.status.isMiscarriage}
|
||||||
|
onChange={(e) => handleStatusChange('isMiscarriage', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Miscarriage
|
||||||
|
</label>
|
||||||
|
<label className={styles.checkbox}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedPerson.status.isStillbirth}
|
||||||
|
onChange={(e) => handleStatusChange('isStillbirth', e.target.checked)}
|
||||||
|
/>
|
||||||
|
Stillbirth
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>Relationships</div>
|
||||||
|
<div className={styles.infoText}>
|
||||||
|
{selectedPerson.fatherId && <div>Father: {selectedPerson.fatherId}</div>}
|
||||||
|
{selectedPerson.motherId && <div>Mother: {selectedPerson.motherId}</div>}
|
||||||
|
{selectedPerson.spouseIds.length > 0 && (
|
||||||
|
<div>Spouse(s): {selectedPerson.spouseIds.join(', ')}</div>
|
||||||
|
)}
|
||||||
|
{selectedPerson.childrenIds.length > 0 && (
|
||||||
|
<div>Children: {selectedPerson.childrenIds.join(', ')}</div>
|
||||||
|
)}
|
||||||
|
{!selectedPerson.fatherId && !selectedPerson.motherId &&
|
||||||
|
selectedPerson.spouseIds.length === 0 && selectedPerson.childrenIds.length === 0 && (
|
||||||
|
<div className={styles.noRelations}>No relationships defined</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
src/components/RelationshipPanel/RelationshipPanel.module.css
Normal file
163
src/components/RelationshipPanel/RelationshipPanel.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
218
src/components/RelationshipPanel/RelationshipPanel.tsx
Normal file
218
src/components/RelationshipPanel/RelationshipPanel.tsx
Normal file
@@ -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 (
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span>Relationship</span>
|
||||||
|
<button
|
||||||
|
className={styles.closeButton}
|
||||||
|
onClick={clearSelection}
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Parties Section */}
|
||||||
|
<div className={styles.partiesSection}>
|
||||||
|
<div className={styles.party}>
|
||||||
|
{person1?.metadata.label || person1?.id || 'Unknown'}
|
||||||
|
</div>
|
||||||
|
<div className={styles.connector}>⟷</div>
|
||||||
|
<div className={styles.party}>
|
||||||
|
{person2?.metadata.label || person2?.id || 'Unknown'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Partnership Status Section */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>Partnership Status</div>
|
||||||
|
<div className={styles.buttonGroup}>
|
||||||
|
<button
|
||||||
|
className={`${styles.optionButton} ${
|
||||||
|
(!selectedRelationship.partnershipStatus ||
|
||||||
|
selectedRelationship.partnershipStatus === PartnershipStatus.Married)
|
||||||
|
? styles.active : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handlePartnershipStatusChange(PartnershipStatus.Married)}
|
||||||
|
>
|
||||||
|
Married
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.optionButton} ${
|
||||||
|
selectedRelationship.partnershipStatus === PartnershipStatus.Unmarried
|
||||||
|
? styles.active : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handlePartnershipStatusChange(PartnershipStatus.Unmarried)}
|
||||||
|
>
|
||||||
|
Unmarried
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.optionButton} ${
|
||||||
|
selectedRelationship.partnershipStatus === PartnershipStatus.Separated
|
||||||
|
? styles.active : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handlePartnershipStatusChange(PartnershipStatus.Separated)}
|
||||||
|
>
|
||||||
|
Separated
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.optionButton} ${
|
||||||
|
selectedRelationship.partnershipStatus === PartnershipStatus.Divorced
|
||||||
|
? styles.active : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handlePartnershipStatusChange(PartnershipStatus.Divorced)}
|
||||||
|
>
|
||||||
|
Divorced
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consanguinity Section */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>Consanguinity</div>
|
||||||
|
<label className={styles.checkbox}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedRelationship.type === RelationshipType.Consanguineous}
|
||||||
|
onChange={(e) => handleTypeChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Consanguineous relationship (related by blood)
|
||||||
|
</label>
|
||||||
|
{selectedRelationship.type === RelationshipType.Consanguineous && (
|
||||||
|
<div className={styles.degreeSelector}>
|
||||||
|
<label>Degree:</label>
|
||||||
|
<select
|
||||||
|
value={selectedRelationship.consanguinityDegree || 1}
|
||||||
|
onChange={(e) => handleConsanguinityDegreeChange(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={1}>First cousins</option>
|
||||||
|
<option value={2}>Second cousins</option>
|
||||||
|
<option value={3}>Third cousins</option>
|
||||||
|
<option value={4}>Fourth cousins or more distant</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Childlessness Section */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>Children Status</div>
|
||||||
|
<div className={styles.buttonGroup}>
|
||||||
|
<button
|
||||||
|
className={`${styles.optionButton} ${
|
||||||
|
(!selectedRelationship.childlessReason ||
|
||||||
|
selectedRelationship.childlessReason === ChildlessReason.None)
|
||||||
|
? styles.active : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleChildlessReasonChange(ChildlessReason.None)}
|
||||||
|
>
|
||||||
|
Has/May have children
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.optionButton} ${
|
||||||
|
selectedRelationship.childlessReason === ChildlessReason.ByChoice
|
||||||
|
? styles.active : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleChildlessReasonChange(ChildlessReason.ByChoice)}
|
||||||
|
>
|
||||||
|
No children (by choice)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`${styles.optionButton} ${
|
||||||
|
selectedRelationship.childlessReason === ChildlessReason.Infertility
|
||||||
|
? styles.active : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleChildlessReasonChange(ChildlessReason.Infertility)}
|
||||||
|
>
|
||||||
|
Infertility
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Children List */}
|
||||||
|
{selectedRelationship.childrenIds.length > 0 && (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>
|
||||||
|
Children ({selectedRelationship.childrenIds.length})
|
||||||
|
</div>
|
||||||
|
<div className={styles.childrenList}>
|
||||||
|
{selectedRelationship.childrenIds.map(childId => {
|
||||||
|
const child = pedigree?.persons.get(childId);
|
||||||
|
return (
|
||||||
|
<div key={childId} className={styles.childItem}>
|
||||||
|
{child?.metadata.label || childId}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Button */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<button
|
||||||
|
className={styles.deleteButton}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
Delete Relationship
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/components/RelationshipPanel/index.ts
Normal file
1
src/components/RelationshipPanel/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { RelationshipPanel } from './RelationshipPanel';
|
||||||
116
src/components/Toolbar/Toolbar.module.css
Normal file
116
src/components/Toolbar/Toolbar.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
349
src/components/Toolbar/Toolbar.tsx
Normal file
349
src/components/Toolbar/Toolbar.tsx
Normal file
@@ -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 (
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<div className={styles.toolGroup}>
|
||||||
|
<span className={styles.groupLabel}>Tools</span>
|
||||||
|
<button
|
||||||
|
className={`${styles.toolButton} ${currentTool === 'select' ? styles.active : ''}`}
|
||||||
|
onClick={() => setCurrentTool('select')}
|
||||||
|
title="Select (V)"
|
||||||
|
>
|
||||||
|
<SelectIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.divider} />
|
||||||
|
|
||||||
|
<div className={styles.toolGroup}>
|
||||||
|
<span className={styles.groupLabel}>Add Person</span>
|
||||||
|
<button
|
||||||
|
className={styles.toolButton}
|
||||||
|
onClick={() => handleAddPerson(Sex.Male)}
|
||||||
|
title="Add Male"
|
||||||
|
>
|
||||||
|
<MaleIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.toolButton}
|
||||||
|
onClick={() => handleAddPerson(Sex.Female)}
|
||||||
|
title="Add Female"
|
||||||
|
>
|
||||||
|
<FemaleIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.toolButton}
|
||||||
|
onClick={() => handleAddPerson(Sex.Unknown)}
|
||||||
|
title="Add Unknown"
|
||||||
|
>
|
||||||
|
<UnknownIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.divider} />
|
||||||
|
|
||||||
|
<div className={styles.toolGroup}>
|
||||||
|
<span className={styles.groupLabel}>Relationships</span>
|
||||||
|
<button
|
||||||
|
className={styles.toolButton}
|
||||||
|
onClick={handleAddSpouse}
|
||||||
|
disabled={!selectedPersonId}
|
||||||
|
title="Add Spouse"
|
||||||
|
>
|
||||||
|
<SpouseIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.toolButton}
|
||||||
|
onClick={handleAddChild}
|
||||||
|
disabled={!selectedPersonId}
|
||||||
|
title="Add Child"
|
||||||
|
>
|
||||||
|
<ChildIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.toolButton}
|
||||||
|
onClick={handleAddParents}
|
||||||
|
disabled={!selectedPersonId}
|
||||||
|
title="Add Parents"
|
||||||
|
>
|
||||||
|
<ParentsIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.divider} />
|
||||||
|
|
||||||
|
<div className={styles.toolGroup}>
|
||||||
|
<span className={styles.groupLabel}>Edit</span>
|
||||||
|
<button
|
||||||
|
className={styles.toolButton}
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={!selectedPersonId}
|
||||||
|
title="Delete Selected"
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.divider} />
|
||||||
|
|
||||||
|
<div className={styles.toolGroup}>
|
||||||
|
<span className={styles.groupLabel}>History</span>
|
||||||
|
<button
|
||||||
|
className={styles.toolButton}
|
||||||
|
onClick={() => undo()}
|
||||||
|
disabled={pastStates.length === 0}
|
||||||
|
title="Undo (Ctrl+Z)"
|
||||||
|
>
|
||||||
|
<UndoIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.toolButton}
|
||||||
|
onClick={() => redo()}
|
||||||
|
disabled={futureStates.length === 0}
|
||||||
|
title="Redo (Ctrl+Y)"
|
||||||
|
>
|
||||||
|
<RedoIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple SVG icons
|
||||||
|
function SelectIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M7 2l10 10-4 1 3 7-2 1-3-7-4 4V2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaleIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="4" y="4" width="16" height="16" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FemaleIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnknownIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 2l10 10-10 10L2 12z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UndoIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RedoIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpouseIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="2" y="8" width="8" height="8" />
|
||||||
|
<circle cx="18" cy="12" r="4" />
|
||||||
|
<line x1="10" y1="12" x2="14" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChildIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="8" y="2" width="8" height="8" />
|
||||||
|
<line x1="12" y1="10" x2="12" y2="14" />
|
||||||
|
<path d="M12 14 L12 18 M8 18 L16 18" />
|
||||||
|
<circle cx="12" cy="20" r="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ParentsIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="2" y="2" width="6" height="6" />
|
||||||
|
<circle cx="19" cy="5" r="3" />
|
||||||
|
<line x1="8" y1="5" x2="16" y2="5" />
|
||||||
|
<line x1="12" y1="5" x2="12" y2="10" />
|
||||||
|
<circle cx="12" cy="18" r="4" />
|
||||||
|
<line x1="12" y1="10" x2="12" y2="14" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/components/Toolbar/tools/RelationshipTool.tsx
Normal file
128
src/components/Toolbar/tools/RelationshipTool.tsx
Normal file
@@ -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<string>('');
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={styles.relationshipTool}>
|
||||||
|
<p>Select a person first to create relationships</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.relationshipTool}>
|
||||||
|
<div className={styles.relationshipRow}>
|
||||||
|
<label>Type:</label>
|
||||||
|
<select
|
||||||
|
value={relationshipType}
|
||||||
|
onChange={(e) => setRelationshipType(e.target.value as 'spouse' | 'parent' | 'child')}
|
||||||
|
>
|
||||||
|
<option value="spouse">Spouse</option>
|
||||||
|
<option value="parent">Parent of {selectedPerson.id}</option>
|
||||||
|
<option value="child">Child of {selectedPerson.id}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.relationshipRow}>
|
||||||
|
<label>Target:</label>
|
||||||
|
<select
|
||||||
|
value={targetPersonId}
|
||||||
|
onChange={(e) => setTargetPersonId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">-- Select --</option>
|
||||||
|
{availableTargets.map(person => (
|
||||||
|
<option key={person.id} value={person.id}>
|
||||||
|
{person.metadata.label || person.id} ({person.sex})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={styles.button}
|
||||||
|
onClick={handleCreateRelationship}
|
||||||
|
disabled={!targetPersonId}
|
||||||
|
>
|
||||||
|
Create Relationship
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
395
src/core/layout/PedigreeLayout.ts
Normal file
395
src/core/layout/PedigreeLayout.ts
Normal file
@@ -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<LayoutOptions> = {}) {
|
||||||
|
this.options = { ...DEFAULT_LAYOUT_OPTIONS, ...options };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main layout function - calculates positions for all persons
|
||||||
|
*/
|
||||||
|
layout(pedigree: Pedigree): Map<string, LayoutNode> {
|
||||||
|
const result = new Map<string, LayoutNode>();
|
||||||
|
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<number, Person[]> {
|
||||||
|
const generations = new Map<number, Person[]>();
|
||||||
|
const personGenerations = new Map<string, number>();
|
||||||
|
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<number, Person[]>,
|
||||||
|
pedigree: Pedigree
|
||||||
|
): Map<number, Person[]> {
|
||||||
|
const sorted = new Map<number, Person[]>();
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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<number, Person[]>,
|
||||||
|
pedigree: Pedigree,
|
||||||
|
result: Map<string, LayoutNode>
|
||||||
|
): 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<string, LayoutNode>): 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<number, Person[]>,
|
||||||
|
positions: Map<string, LayoutNode>,
|
||||||
|
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<string, Person[]>();
|
||||||
|
|
||||||
|
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<string, LayoutNode>): 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<LayoutOptions>): void {
|
||||||
|
this.options = { ...this.options, ...options };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current options
|
||||||
|
*/
|
||||||
|
getOptions(): LayoutOptions {
|
||||||
|
return { ...this.options };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pedigreeLayout = new PedigreeLayout();
|
||||||
308
src/core/model/types.ts
Normal file
308
src/core/model/types.ts
Normal file
@@ -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<string, Person>;
|
||||||
|
relationships: Map<string, Relationship>;
|
||||||
|
|
||||||
|
// 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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
265
src/core/parser/PedParser.ts
Normal file
265
src/core/parser/PedParser.ts
Normal file
@@ -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<string>();
|
||||||
|
const fullIdSet = new Set<string>();
|
||||||
|
|
||||||
|
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<string, Set<string>>();
|
||||||
|
|
||||||
|
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();
|
||||||
134
src/core/parser/PedWriter.ts
Normal file
134
src/core/parser/PedWriter.ts
Normal file
@@ -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<string, number>();
|
||||||
|
|
||||||
|
// 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();
|
||||||
345
src/core/renderer/ConnectionRenderer.ts
Normal file
345
src/core/renderer/ConnectionRenderer.ts
Normal file
@@ -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<ConnectionConfig> = {}) {
|
||||||
|
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<ConnectionConfig>): void {
|
||||||
|
this.config = { ...this.config, ...config };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const connectionRenderer = new ConnectionRenderer();
|
||||||
270
src/core/renderer/SymbolRegistry.ts
Normal file
270
src/core/renderer/SymbolRegistry.ts
Normal file
@@ -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();
|
||||||
142
src/index.css
Normal file
142
src/index.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -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(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
178
src/services/exportService.ts
Normal file
178
src/services/exportService.ts
Normal file
@@ -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<ExportOptions> = {
|
||||||
|
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<void> {
|
||||||
|
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 = '<?xml version="1.0" encoding="UTF-8"?>\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<void> {
|
||||||
|
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();
|
||||||
381
src/store/pedigreeStore.ts
Normal file
381
src/store/pedigreeStore.ts
Normal file
@@ -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<string, LayoutNode>;
|
||||||
|
|
||||||
|
// 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<Person>) => void;
|
||||||
|
deletePerson: (id: string) => void;
|
||||||
|
updatePersonPosition: (id: string, x: number, y: number) => void;
|
||||||
|
|
||||||
|
// Actions - Relationship
|
||||||
|
addRelationship: (relationship: Relationship) => void;
|
||||||
|
updateRelationship: (id: string, updates: Partial<Relationship>) => 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<PedigreeState>()(
|
||||||
|
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;
|
||||||
52
start.sh
Executable file
52
start.sh
Executable file
@@ -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
|
||||||
28
stop.sh
Executable file
28
stop.sh
Executable file
@@ -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
|
||||||
32
tsconfig.app.json
Normal file
32
tsconfig.app.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
18
vite.config.ts
Normal file
18
vite.config.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user