Files
breedr/server/routes/pedigree.js

218 lines
7.8 KiB
JavaScript
Raw Normal View History

2026-03-08 22:45:07 -05:00
const express = require('express');
const router = express.Router();
const { getDatabase } = require('../db/init');
/**
* getAncestorMap(db, dogId, maxGen)
* Returns Map<id, [{ id, name, generation }, ...]>
* INCLUDES dogId itself at generation 0 so direct parent-offspring
* pairings are correctly detected by calculateCOI.
*/
function getAncestorMap(db, dogId, maxGen = 6) {
const map = new Map();
function recurse(id, gen) {
if (gen > maxGen) return;
const dog = db.prepare('SELECT id, name FROM dogs WHERE id = ?').get(id);
if (!dog) return;
if (!map.has(id)) map.set(id, []);
map.get(id).push({ id: dog.id, name: dog.name, generation: gen });
// Only walk parents on first encounter to prevent exponential blowup
if (map.get(id).length === 1) {
const parents = db.prepare(`
SELECT p.parent_id FROM parents p WHERE p.dog_id = ?
`).all(id);
parents.forEach(p => recurse(p.parent_id, gen + 1));
2026-03-08 22:45:07 -05:00
}
}
recurse(parseInt(dogId), 0);
return map;
}
/**
* isDirectRelation(db, sireId, damId)
* Checks within 3 generations whether one dog is a direct ancestor
* of the other. Returns { related, relationship }.
*/
function isDirectRelation(db, sireId, damId) {
const sid = parseInt(sireId);
const did = parseInt(damId);
const sireMap = getAncestorMap(db, sid, 3);
const damMap = getAncestorMap(db, did, 3);
if (damMap.has(sid)) {
const gen = damMap.get(sid)[0].generation;
const label = gen === 1 ? 'parent' : gen === 2 ? 'grandparent' : `generation-${gen} ancestor`;
return { related: true, relationship: `Sire is the ${label} of the selected dam` };
}
if (sireMap.has(did)) {
const gen = sireMap.get(did)[0].generation;
const label = gen === 1 ? 'parent' : gen === 2 ? 'grandparent' : `generation-${gen} ancestor`;
return { related: true, relationship: `Dam is the ${label} of the selected sire` };
}
return { related: false, relationship: null };
}
/**
* calculateCOI(db, sireId, damId)
* Wright Path Coefficient method.
* sire/dam included at gen 0 in their own maps so parent x offspring
* yields the correct 25% COI instead of 0%.
*
* Per path: COI += (0.5)^(sireGen + damGen + 1)
* Parent x offspring: sireGen=0, damGen=1 => (0.5)^2 = 0.25 = 25%
*/
function calculateCOI(db, sireId, damId) {
const sid = parseInt(sireId);
const did = parseInt(damId);
const sireMap = getAncestorMap(db, sid);
const damMap = getAncestorMap(db, did);
// Common ancestors excluding the pair themselves
const commonIds = [...sireMap.keys()].filter(
id => damMap.has(id) && id !== sid && id !== did
);
let coi = 0;
const processedPaths = new Set();
const commonAncestorList = [];
commonIds.forEach(ancId => {
const sireOccs = sireMap.get(ancId);
const damOccs = damMap.get(ancId);
sireOccs.forEach(so => {
damOccs.forEach(do_ => {
const key = `${ancId}-${so.generation}-${do_.generation}`;
if (!processedPaths.has(key)) {
processedPaths.add(key);
coi += Math.pow(0.5, so.generation + do_.generation + 1);
}
});
});
const closestSire = sireOccs.reduce((a, b) => a.generation < b.generation ? a : b);
const closestDam = damOccs.reduce((a, b) => a.generation < b.generation ? a : b);
commonAncestorList.push({
id: ancId,
name: sireOccs[0].name,
sireGen: closestSire.generation,
damGen: closestDam.generation
});
2026-03-08 22:45:07 -05:00
});
2026-03-08 22:45:07 -05:00
return {
coefficient: Math.round(coi * 10000) / 100,
commonAncestors: commonAncestorList
2026-03-08 22:45:07 -05:00
};
}
// ── GET pedigree tree ─────────────────────────────────────────────────────────
2026-03-08 22:45:07 -05:00
router.get('/:id', (req, res) => {
try {
const db = getDatabase();
const generations = parseInt(req.query.generations) || 5;
2026-03-08 22:45:07 -05:00
function buildTree(dogId, currentGen = 0) {
if (currentGen >= generations) return null;
const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId);
if (!dog) return null;
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
const parents = db.prepare('SELECT parent_type, parent_id FROM parents WHERE dog_id = ?').all(dogId);
2026-03-08 22:45:07 -05:00
const sire = parents.find(p => p.parent_type === 'sire');
const dam = parents.find(p => p.parent_type === 'dam');
2026-03-08 22:45:07 -05:00
return {
...dog,
generation: currentGen,
sire: sire ? buildTree(sire.parent_id, currentGen + 1) : null,
dam: dam ? buildTree(dam.parent_id, currentGen + 1) : null
2026-03-08 22:45:07 -05:00
};
}
2026-03-08 22:45:07 -05:00
const tree = buildTree(req.params.id);
if (!tree) return res.status(404).json({ error: 'Dog not found' });
2026-03-08 22:45:07 -05:00
res.json(tree);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ── GET descendants ───────────────────────────────────────────────────────────
2026-03-08 22:45:07 -05:00
router.get('/:id/descendants', (req, res) => {
try {
const db = getDatabase();
const generations = parseInt(req.query.generations) || 3;
2026-03-08 22:45:07 -05:00
function buildDescendantTree(dogId, currentGen = 0) {
if (currentGen >= generations) return null;
const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId);
if (!dog) return null;
dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : [];
const offspring = db.prepare(`
SELECT DISTINCT d.id, d.name, d.sex, d.birth_date
FROM dogs d JOIN parents p ON d.id = p.dog_id
2026-03-08 22:45:07 -05:00
WHERE p.parent_id = ? AND d.is_active = 1
`).all(dogId);
return {
...dog,
generation: currentGen,
offspring: offspring.map(c => buildDescendantTree(c.id, currentGen + 1))
2026-03-08 22:45:07 -05:00
};
}
2026-03-08 22:45:07 -05:00
const tree = buildDescendantTree(req.params.id);
if (!tree) return res.status(404).json({ error: 'Dog not found' });
2026-03-08 22:45:07 -05:00
res.json(tree);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ── GET direct-relation check (used by frontend before simulate) ──────────────
// GET /api/pedigree/relations/:sireId/:damId
router.get('/relations/:sireId/:damId', (req, res) => {
try {
const db = getDatabase();
res.json(isDirectRelation(db, req.params.sireId, req.params.damId));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ── POST trial pairing ────────────────────────────────────────────────────────
2026-03-08 22:45:07 -05:00
router.post('/trial-pairing', (req, res) => {
try {
const { sire_id, dam_id } = req.body;
if (!sire_id || !dam_id) {
return res.status(400).json({ error: 'Both sire_id and dam_id are required' });
}
2026-03-08 22:45:07 -05:00
const db = getDatabase();
const sire = db.prepare("SELECT * FROM dogs WHERE id = ? AND sex = 'male'").get(sire_id);
const dam = db.prepare("SELECT * FROM dogs WHERE id = ? AND sex = 'female'").get(dam_id);
2026-03-08 22:45:07 -05:00
if (!sire || !dam) {
return res.status(404).json({ error: 'Invalid sire or dam — check sex values in database' });
2026-03-08 22:45:07 -05:00
}
const relation = isDirectRelation(db, sire_id, dam_id);
const result = calculateCOI(db, sire_id, dam_id);
2026-03-08 22:45:07 -05:00
res.json({
sire: { id: sire.id, name: sire.name },
dam: { id: dam.id, name: dam.name },
coi: result.coefficient,
2026-03-08 22:45:07 -05:00
commonAncestors: result.commonAncestors,
directRelation: relation.related ? relation.relationship : null,
recommendation: result.coefficient < 5 ? 'Low risk'
: result.coefficient < 10 ? 'Moderate risk'
: 'High risk'
2026-03-08 22:45:07 -05:00
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;