Add relationship child creation, auto-align, delete shortcut, and improve UX
Features: - Add "Add Child" buttons (Male/Female/Unknown) in RelationshipPanel to create children directly from a selected relationship - Add "Auto Align" button in toolbar to reset all element positions - Add Delete/Backspace keyboard shortcut to delete selected elements - Add text labels to all toolbar buttons for better discoverability Documentation: - Update README with Node.js installation instructions for beginners - Add npm install step for first-time setup - Document keyboard shortcuts and relationship editing features - Add Windows-specific instructions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
78
README.md
78
README.md
@@ -18,30 +18,70 @@ A professional pedigree (family tree) drawing tool for genetic counselors and bi
|
|||||||
|
|
||||||
You need **Node.js** (version 18 or higher) installed on your computer.
|
You need **Node.js** (version 18 or higher) installed on your computer.
|
||||||
|
|
||||||
Check if you have it:
|
#### Check if Node.js is installed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node --version
|
node --version
|
||||||
|
npm --version
|
||||||
```
|
```
|
||||||
|
|
||||||
If not installed, download from: https://nodejs.org/ (choose LTS version)
|
If both commands show version numbers (e.g., `v20.10.0` and `10.2.0`), you're ready to go!
|
||||||
|
|
||||||
|
#### If Node.js is NOT installed:
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
1. Go to https://nodejs.org/
|
||||||
|
2. Download the **LTS** version (recommended)
|
||||||
|
3. Run the installer, click "Next" through all steps
|
||||||
|
4. Restart your terminal/command prompt after installation
|
||||||
|
|
||||||
|
**macOS:**
|
||||||
|
```bash
|
||||||
|
# Using Homebrew (recommended)
|
||||||
|
brew install node
|
||||||
|
|
||||||
|
# Or download from https://nodejs.org/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux (Ubuntu/Debian):**
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Step 1: Start the Server
|
### Step 1: Install Dependencies (First Time Only)
|
||||||
|
|
||||||
Open your terminal and navigate to the project folder:
|
Open your terminal and navigate to the project folder:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/gbanyan/projects/pedigree-draw
|
cd /path/to/pedigree-draw
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Install the required packages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
This will download all necessary dependencies. You only need to do this once (or after updating the code).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Step 2: Start the Server
|
||||||
|
|
||||||
Run the start script:
|
Run the start script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./start.sh
|
./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**On Windows**, use:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
You should see output like:
|
You should see output like:
|
||||||
```
|
```
|
||||||
==========================================
|
==========================================
|
||||||
@@ -57,7 +97,7 @@ You should see output like:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Step 2: Open in Browser
|
### Step 3: Open in Browser
|
||||||
|
|
||||||
Open your web browser and go to:
|
Open your web browser and go to:
|
||||||
|
|
||||||
@@ -66,7 +106,7 @@ Open your web browser and go to:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Step 3: Stop the Server
|
### Step 4: Stop the Server
|
||||||
|
|
||||||
When you're done, stop the server:
|
When you're done, stop the server:
|
||||||
|
|
||||||
@@ -74,6 +114,8 @@ When you're done, stop the server:
|
|||||||
./stop.sh
|
./stop.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**On Windows**, press `Ctrl+C` in the terminal to stop the server.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How to Use the Application
|
## How to Use the Application
|
||||||
@@ -145,11 +187,27 @@ You can also:
|
|||||||
| Export PNG | Raster image (for documents, presentations) |
|
| Export PNG | Raster image (for documents, presentations) |
|
||||||
| Export PED | GATK PED format file |
|
| Export PED | GATK PED format file |
|
||||||
|
|
||||||
### Undo/Redo
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
Use the Undo/Redo buttons in the toolbar, or:
|
| Shortcut | Action |
|
||||||
- **Ctrl+Z** to Undo
|
|----------|--------|
|
||||||
- **Ctrl+Y** to Redo
|
| **Delete** or **Backspace** | Delete selected person or relationship |
|
||||||
|
| **Escape** | Clear selection |
|
||||||
|
| **Ctrl+Z** | Undo |
|
||||||
|
| **Ctrl+Y** or **Ctrl+Shift+Z** | Redo |
|
||||||
|
|
||||||
|
### Editing Relationships
|
||||||
|
|
||||||
|
1. Click on the **connection line** between two people to select the relationship
|
||||||
|
2. The right panel will show relationship options:
|
||||||
|
- **Add Child**: Add a Male/Female/Unknown child to this couple
|
||||||
|
- **Partnership Status**: Married, Unmarried, Separated, Divorced
|
||||||
|
- **Consanguinity**: Mark as consanguineous (blood relatives)
|
||||||
|
- **Children Status**: Infertility or No children by choice
|
||||||
|
|
||||||
|
### Auto Align
|
||||||
|
|
||||||
|
If elements get messy after dragging, click the **Auto Align** button in the toolbar to automatically reposition all family members according to their generation and relationships.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,24 @@ import { usePedigreeStore, useTemporalStore } from '@/store/pedigreeStore';
|
|||||||
import styles from './App.module.css';
|
import styles from './App.module.css';
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const { clearSelection, selectedRelationshipId } = usePedigreeStore();
|
const {
|
||||||
|
clearSelection,
|
||||||
|
selectedPersonId,
|
||||||
|
selectedRelationshipId,
|
||||||
|
deletePerson,
|
||||||
|
deleteRelationship,
|
||||||
|
} = usePedigreeStore();
|
||||||
const temporal = useTemporalStore();
|
const temporal = useTemporalStore();
|
||||||
|
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Don't handle shortcuts when typing in inputs
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Undo: Ctrl/Cmd + Z
|
// Undo: Ctrl/Cmd + Z
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -36,11 +48,21 @@ export function App() {
|
|||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
clearSelection();
|
clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete or Backspace: Delete selected element
|
||||||
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedPersonId) {
|
||||||
|
deletePerson(selectedPersonId);
|
||||||
|
} else if (selectedRelationshipId) {
|
||||||
|
deleteRelationship(selectedRelationshipId);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [clearSelection, temporal]);
|
}, [clearSelection, selectedPersonId, selectedRelationshipId, deletePerson, deleteRelationship, temporal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.app}>
|
<div className={styles.app}>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
RelationshipType,
|
RelationshipType,
|
||||||
PartnershipStatus,
|
PartnershipStatus,
|
||||||
ChildlessReason,
|
ChildlessReason,
|
||||||
|
Sex,
|
||||||
|
createPerson,
|
||||||
} from '@/core/model/types';
|
} from '@/core/model/types';
|
||||||
import styles from './RelationshipPanel.module.css';
|
import styles from './RelationshipPanel.module.css';
|
||||||
|
|
||||||
@@ -16,9 +18,12 @@ export function RelationshipPanel() {
|
|||||||
const {
|
const {
|
||||||
pedigree,
|
pedigree,
|
||||||
selectedRelationshipId,
|
selectedRelationshipId,
|
||||||
|
addPerson,
|
||||||
|
updatePerson,
|
||||||
updateRelationship,
|
updateRelationship,
|
||||||
deleteRelationship,
|
deleteRelationship,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
|
recalculateLayout,
|
||||||
} = usePedigreeStore();
|
} = usePedigreeStore();
|
||||||
|
|
||||||
const selectedRelationship = selectedRelationshipId && pedigree
|
const selectedRelationship = selectedRelationshipId && pedigree
|
||||||
@@ -55,6 +60,48 @@ export function RelationshipPanel() {
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddChild = (sex: Sex) => {
|
||||||
|
if (!pedigree || !selectedRelationship) return;
|
||||||
|
|
||||||
|
const parent1 = pedigree.persons.get(selectedRelationship.person1Id);
|
||||||
|
const parent2 = pedigree.persons.get(selectedRelationship.person2Id);
|
||||||
|
if (!parent1 || !parent2) return;
|
||||||
|
|
||||||
|
// Determine father and mother based on sex
|
||||||
|
const father = parent1.sex === Sex.Male ? parent1 : (parent2.sex === Sex.Male ? parent2 : null);
|
||||||
|
const mother = parent1.sex === Sex.Female ? parent1 : (parent2.sex === Sex.Female ? parent2 : null);
|
||||||
|
|
||||||
|
// Create child
|
||||||
|
const childId = `P${Date.now().toString(36)}`;
|
||||||
|
const child = createPerson(childId, pedigree.familyId, sex);
|
||||||
|
child.metadata.label = childId;
|
||||||
|
child.fatherId = father?.id ?? null;
|
||||||
|
child.motherId = mother?.id ?? null;
|
||||||
|
|
||||||
|
// Position child below parents
|
||||||
|
const parentX = ((parent1.x ?? 0) + (parent2.x ?? 0)) / 2;
|
||||||
|
const parentY = Math.max(parent1.y ?? 0, parent2.y ?? 0);
|
||||||
|
child.x = parentX;
|
||||||
|
child.y = parentY + 120;
|
||||||
|
|
||||||
|
addPerson(child);
|
||||||
|
|
||||||
|
// Update parents' childrenIds
|
||||||
|
updatePerson(parent1.id, {
|
||||||
|
childrenIds: [...parent1.childrenIds, childId],
|
||||||
|
});
|
||||||
|
updatePerson(parent2.id, {
|
||||||
|
childrenIds: [...parent2.childrenIds, childId],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update relationship's childrenIds
|
||||||
|
updateRelationship(selectedRelationship.id, {
|
||||||
|
childrenIds: [...selectedRelationship.childrenIds, childId],
|
||||||
|
});
|
||||||
|
|
||||||
|
recalculateLayout();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.panel}>
|
<div className={styles.panel}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
@@ -150,6 +197,31 @@ export function RelationshipPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Add Child Section */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<div className={styles.sectionTitle}>Add Child</div>
|
||||||
|
<div className={styles.buttonGroup}>
|
||||||
|
<button
|
||||||
|
className={styles.optionButton}
|
||||||
|
onClick={() => handleAddChild(Sex.Male)}
|
||||||
|
>
|
||||||
|
Male
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.optionButton}
|
||||||
|
onClick={() => handleAddChild(Sex.Female)}
|
||||||
|
>
|
||||||
|
Female
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.optionButton}
|
||||||
|
onClick={() => handleAddChild(Sex.Unknown)}
|
||||||
|
>
|
||||||
|
Unknown
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Childlessness Section */}
|
{/* Childlessness Section */}
|
||||||
<div className={styles.section}>
|
<div className={styles.section}>
|
||||||
<div className={styles.sectionTitle}>Children Status</div>
|
<div className={styles.sectionTitle}>Children Status</div>
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toolButton {
|
.toolButton {
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
height: 36px;
|
||||||
|
padding: 0 10px;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -32,8 +32,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
color: #333;
|
color: #333;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolButton svg {
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolButton:hover:not(:disabled) {
|
.toolButton:hover:not(:disabled) {
|
||||||
|
|||||||
@@ -151,26 +151,26 @@ export function Toolbar() {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
<div className={styles.toolGroup}>
|
<div className={styles.toolGroup}>
|
||||||
<span className={styles.groupLabel}>Tools</span>
|
|
||||||
<button
|
<button
|
||||||
className={`${styles.toolButton} ${currentTool === 'select' ? styles.active : ''}`}
|
className={`${styles.toolButton} ${currentTool === 'select' ? styles.active : ''}`}
|
||||||
onClick={() => setCurrentTool('select')}
|
onClick={() => setCurrentTool('select')}
|
||||||
title="Select (V)"
|
title="Select (V)"
|
||||||
>
|
>
|
||||||
<SelectIcon />
|
<SelectIcon />
|
||||||
|
Select
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.divider} />
|
<div className={styles.divider} />
|
||||||
|
|
||||||
<div className={styles.toolGroup}>
|
<div className={styles.toolGroup}>
|
||||||
<span className={styles.groupLabel}>Add Person</span>
|
|
||||||
<button
|
<button
|
||||||
className={styles.toolButton}
|
className={styles.toolButton}
|
||||||
onClick={() => handleAddPerson(Sex.Male)}
|
onClick={() => handleAddPerson(Sex.Male)}
|
||||||
title="Add Male"
|
title="Add Male"
|
||||||
>
|
>
|
||||||
<MaleIcon />
|
<MaleIcon />
|
||||||
|
Male
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={styles.toolButton}
|
className={styles.toolButton}
|
||||||
@@ -178,6 +178,7 @@ export function Toolbar() {
|
|||||||
title="Add Female"
|
title="Add Female"
|
||||||
>
|
>
|
||||||
<FemaleIcon />
|
<FemaleIcon />
|
||||||
|
Female
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={styles.toolButton}
|
className={styles.toolButton}
|
||||||
@@ -185,13 +186,13 @@ export function Toolbar() {
|
|||||||
title="Add Unknown"
|
title="Add Unknown"
|
||||||
>
|
>
|
||||||
<UnknownIcon />
|
<UnknownIcon />
|
||||||
|
Unknown
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.divider} />
|
<div className={styles.divider} />
|
||||||
|
|
||||||
<div className={styles.toolGroup}>
|
<div className={styles.toolGroup}>
|
||||||
<span className={styles.groupLabel}>Relationships</span>
|
|
||||||
<button
|
<button
|
||||||
className={styles.toolButton}
|
className={styles.toolButton}
|
||||||
onClick={handleAddSpouse}
|
onClick={handleAddSpouse}
|
||||||
@@ -199,6 +200,7 @@ export function Toolbar() {
|
|||||||
title="Add Spouse"
|
title="Add Spouse"
|
||||||
>
|
>
|
||||||
<SpouseIcon />
|
<SpouseIcon />
|
||||||
|
Spouse
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={styles.toolButton}
|
className={styles.toolButton}
|
||||||
@@ -207,6 +209,7 @@ export function Toolbar() {
|
|||||||
title="Add Child"
|
title="Add Child"
|
||||||
>
|
>
|
||||||
<ChildIcon />
|
<ChildIcon />
|
||||||
|
Child
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={styles.toolButton}
|
className={styles.toolButton}
|
||||||
@@ -215,27 +218,27 @@ export function Toolbar() {
|
|||||||
title="Add Parents"
|
title="Add Parents"
|
||||||
>
|
>
|
||||||
<ParentsIcon />
|
<ParentsIcon />
|
||||||
|
Parents
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.divider} />
|
<div className={styles.divider} />
|
||||||
|
|
||||||
<div className={styles.toolGroup}>
|
<div className={styles.toolGroup}>
|
||||||
<span className={styles.groupLabel}>Edit</span>
|
|
||||||
<button
|
<button
|
||||||
className={styles.toolButton}
|
className={styles.toolButton}
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={!selectedPersonId}
|
disabled={!selectedPersonId}
|
||||||
title="Delete Selected"
|
title="Delete Selected (Del)"
|
||||||
>
|
>
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.divider} />
|
<div className={styles.divider} />
|
||||||
|
|
||||||
<div className={styles.toolGroup}>
|
<div className={styles.toolGroup}>
|
||||||
<span className={styles.groupLabel}>History</span>
|
|
||||||
<button
|
<button
|
||||||
className={styles.toolButton}
|
className={styles.toolButton}
|
||||||
onClick={() => undo()}
|
onClick={() => undo()}
|
||||||
@@ -243,6 +246,7 @@ export function Toolbar() {
|
|||||||
title="Undo (Ctrl+Z)"
|
title="Undo (Ctrl+Z)"
|
||||||
>
|
>
|
||||||
<UndoIcon />
|
<UndoIcon />
|
||||||
|
Undo
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={styles.toolButton}
|
className={styles.toolButton}
|
||||||
@@ -251,6 +255,21 @@ export function Toolbar() {
|
|||||||
title="Redo (Ctrl+Y)"
|
title="Redo (Ctrl+Y)"
|
||||||
>
|
>
|
||||||
<RedoIcon />
|
<RedoIcon />
|
||||||
|
Redo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.divider} />
|
||||||
|
|
||||||
|
<div className={styles.toolGroup}>
|
||||||
|
<button
|
||||||
|
className={styles.toolButton}
|
||||||
|
onClick={() => recalculateLayout()}
|
||||||
|
disabled={!pedigree || pedigree.persons.size === 0}
|
||||||
|
title="Auto Align - Reset all positions"
|
||||||
|
>
|
||||||
|
<AutoAlignIcon />
|
||||||
|
Auto Align
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,3 +366,18 @@ function ParentsIcon() {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AutoAlignIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
{/* Grid lines */}
|
||||||
|
<line x1="4" y1="4" x2="4" y2="20" strokeDasharray="2,2" />
|
||||||
|
<line x1="12" y1="4" x2="12" y2="20" strokeDasharray="2,2" />
|
||||||
|
<line x1="20" y1="4" x2="20" y2="20" strokeDasharray="2,2" />
|
||||||
|
<line x1="4" y1="12" x2="20" y2="12" strokeDasharray="2,2" />
|
||||||
|
{/* Alignment arrows */}
|
||||||
|
<path d="M7 8 L12 4 L17 8" fill="none" />
|
||||||
|
<path d="M7 16 L12 20 L17 16" fill="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user