Initial commit
Some checks failed
Deploy to GitHub Pages / build (push) Has been cancelled
Deploy to GitHub Pages / deploy (push) Has been cancelled

This commit is contained in:
gbanyan
2025-12-14 21:53:34 +08:00
commit 8b07e483d2
43 changed files with 9813 additions and 0 deletions

54
.github/workflows/deploy.yml vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View 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
View 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
View 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
View 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

View 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;
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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;
}

View 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,
};
}

View 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,
};
}

View 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;
}

View 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>
);
}

View 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;
}

View 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>
);
}

View File

@@ -0,0 +1 @@
export { RelationshipPanel } from './RelationshipPanel';

View 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;
}

View 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>
);
}

View 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>
);
}

View 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
View 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: [],
};
}

View 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();

View 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();

View 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();

View 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
View 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
View 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>,
)

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View 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
View 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,
},
})