kingraph
Version:
Plots family trees using JavaScript and Graphviz
384 lines (338 loc) • 11.1 kB
JavaScript
const join = require('./join');
const slugify = require('./slugify');
const normalize = require('./normalize');
const applyStyle = require('./apply_style');
const idGenerator = require('./id_generator');
const COLORS = require('./defaults/colors');
const getId = idGenerator();
const LINE = Array(76).join('#');
const LINE2 = '# ' + Array(74).join('-');
function render(data) {
data = normalize(data);
return join(
[
'digraph FamilyGraph {',
{
indent: [
applyStyle(data, [':bgcolor']),
'edge [',
{ indent: applyStyle(data, [':edge']) },
']',
'',
'node [',
{ indent: applyStyle(data, [':node']) },
']',
'',
applyStyle(data, [':digraph']),
renderHouse(data, data, []),
],
},
'}',
],
{ indent: ' ' }
);
}
function renderHouse(data, house, path) {
const people = house.people || {};
const families = house.families || [];
const houses = house.houses || {};
const peopleMeat = []
Object.keys(people).forEach((key, idx)=>{
peopleMeat.push(renderPerson(data, house, people[key] || {}, path.concat([key])))
})
const meat = [
// People and families
families.map((home, idx) =>renderFamily(data, house, home || {}, path.concat([idx])) ),
peopleMeat
];
if (path.length === 0) {
return meat;
} else {
const name = house.name || path[path.length - 1];
return [
'',
LINE,
`# House ${path}`,
LINE,
'',
`subgraph cluster_${slugify(path)} {`,
{
indent: [
`label=<<b>${name}</b>>`,
applyStyle(data, [':house', `:house-${path.length}`]),
'',
meat,
],
},
'}',
];
}
}
function renderPerson(data, house, person, path) {
let id = path[path.length - 1];
let label;
let href = person.links && person.links[0];
let lifespan;
if (
person.name ||
person.fullname ||
person.gender ||
person.born ||
person.died ||
person.picture
) {
let sex = '';
if (person.sex) {
if (person.sex.toLowerCase() === 'f') {
sex = ' ♀';
} else if (person.sex.toLowerCase() === 'm') {
sex = ' ♂';
}
}
picture = '';
if (person.picture) {
picture = `<tr><td><img scale='true' src='${person.picture}' /></td></tr>`;
}
label =
'<<table align="center" border="0" cellpadding="0" cellspacing="2" width="4">' +
`${picture}` +
'<tr><td align="center">' +
`${person.name || person.display || id}${sex}</td></tr>`;
if (person.fullname) {
label +=
'<tr><td align="center">' +
'<font point-size="10" color="#aaaaaa">' +
`${person.fullname || person.name}` +
`</font></td></tr>`;
}
if (person.date_of_birth || person.date_of_death) {
if (person.date_of_birth && person.date_of_death) {
lifespan = `${
'*' + new Date(person.date_of_birth).getFullYear()
} — ${'†' + new Date(person.date_of_death).getFullYear()}`;
} else if (person.date_of_birth) {
lifespan = `${
'*' + new Date(person.date_of_birth).getFullYear()
}`;
} else {
lifespan = `${
'†' + new Date(person.date_of_death).getFullYear()
}`;
}
label +=
'<tr><td align="center">' +
'<font point-size="10" color="#aaaaaa">' +
`${lifespan}</font></td></tr>`;
}
label += '</table>>';
} else {
label = id;
}
return [
`"${id}" [`,
'tooltip=' + '"' + renderPersonTooltip(person) + '"',
{
indent: [
applyStyle(data, person.class || [], {
before: {
label,
href,
},
}),
],
},
']',
];
}
function renderPersonTooltip(person) {
let txt = '';
txt += person.first_name + ' ' || '';
txt += person.last_name || '';
txt += '\n';
txt +=
'Added ' +
new Date(person.created_at).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
txt += '\n';
txt += person.date_of_birth
? 'Born ' +
new Date(person.date_of_birth).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}) +
' '
: '';
txt +=
person.date_of_birth && person.date_of_death
? ' -- died ' +
new Date(person.date_of_death).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
: '';
txt +=
!person.date_of_birth && person.date_of_death
? ' Died ' +
new Date(person.date_of_death).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
: '';
txt += '\n';
txt += person.comment ? person.comment.slice(0, 50) + '...' : '';
txt += '\n';
return txt;
}
/*
* For comments
*/
function summarizeFamily(family) {
const parents = []
.concat(family.parents || [])
.concat(family.parents2 || [])
.filter(Boolean);
const children = []
.concat(family.children || [])
.concat(family.children2 || [])
.filter(Boolean);
return `[${parents.join(', ')}] -> [${children.join(', ')}]`;
}
/*
* Renders a family subgraph
*/
function renderFamily(data, house, family, path) {
const slug = slugify(path);
const color = COLORS[getId('family') % COLORS.length];
const parents = family.parents || [];
const parents2 = family.parents2 || [];
const children = family.children || [];
const children2 = family.children2 || [];
const housename = family.house;
const city = family.city || ""
const country = family.country || ""
const hasParents = parents.length + parents2.length > 0;
const hasChildren = children.length + children2.length > 0;
const hasManyChildren = children.length + children.length > 1;
const union = `union_${slug}`;
const kids = `siblings_${slug}`;
return [
'',
`subgraph cluster_family_${slug} {`,
style([':family']),
{
indent: [
housename && renderHousePrelude(),
// renderPeople(),
renderSubFamilies(),
'',
`# Family ${summarizeFamily(family)}`,
LINE2,
'',
hasParents && renderParents(),
hasParents && hasChildren && renderLink(),
hasChildren && renderKids(),
hasManyChildren > 1 && renderKidLinks(),
],
},
'}',
];
function style(classes, before) {
return { indent: applyStyle(data, classes, { before: before || {} }) };
}
function renderPeople() {
const list = [].concat(parents).concat(children);
return `{${list.map(escape).join('; ')}}`;
}
function renderHousePrelude() {
let location_label = city && country ? ` |<i>${city}, ${country}</i>`: ""
let label = `<<b>${housename}</b>${location_label}>`;
let labelhref = family.links && family.links[0];
return [applyStyle(data, [':house'], { before: { label, labelhref } })];
}
function renderSubFamilies() {
// Reverse the families, because we assume people put "deeper" families last.
// You want to render the deeper families first so that their parents are placed
// in those families, rather than the parent families.
const families = [].concat(family.families || []).reverse();
return families.map((f, idx) =>
renderFamily(data, house, f, path.concat(idx))
);
}
function renderParents() {
return [
`${union} [`,
style([':union'], { fillcolor: color }),
']',
'',
parents.length > 0 && [
`{${parents.map(escape).join(', ')}} -> ${union} [`,
style([':parent-link'], { color }),
']',
],
parents2.length > 0 && [
`{${parents2.map(escape).join(', ')}} -> ${union} [`,
style([':parent-link', ':parent2-link'], { color }),
']',
],
];
}
function renderLink() {
return [
`${union} -> ${kids} [`,
style([':parent-link', ':parent-child-link'], { color: color }),
']',
];
}
function renderKids() {
return [
`${kids} [`,
style([':children'], { fillcolor: color }),
`]`,
children.length > 0 && [
`${kids} -> {${children.map(escape).join(', ')}} [`,
style([':child-link'], { color }),
']',
],
children2.length > 0 && [
`${kids} -> {${children2.map(escape).join(', ')}} [`,
style([':child-link', ':child2-link'], { color }),
']',
],
];
}
function renderKidLinks() {
return [
`{"${children.concat(children2).join('" -> "')}" [`,
{
indent: [
applyStyle(data, [':child-links'], {
before: {
style: 'invis',
},
}),
],
},
']',
];
}
}
/*
* Escapes a name into a node name.
*/
function escape(str) {
if (/^[A-Za-z]+$/.test(str)) {
return str;
} else {
return JSON.stringify(str);
}
}
/*
* Export
*/
module.exports = render;