Initial commit: webtrees Family Navigator Graph sidebar module
SVG-based sidebar widget displaying immediate family relationships (parents, siblings, spouses, children) with compact card layout, multi-spouse routing, wrapped rows, and ancestor/descendant indicators.
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
31
composer.json
Normal file
31
composer.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "alex/webtrees-family-navigator-graph",
|
||||||
|
"description": "A sidebar module for webtrees showing an interactive SVG graph of the individual's immediate family.",
|
||||||
|
"license": "GPL-3.0-or-later",
|
||||||
|
"type": "webtrees-module",
|
||||||
|
"keywords": [
|
||||||
|
"webtrees",
|
||||||
|
"module",
|
||||||
|
"sidebar",
|
||||||
|
"family-tree",
|
||||||
|
"graph"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Alex",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"minimum-stability": "dev",
|
||||||
|
"prefer-stable": true,
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2",
|
||||||
|
"ext-json": "*",
|
||||||
|
"fisharebest/webtrees": "~2.2.0 || dev-main"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"FamilyNavGraph\\": "src/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
module.php
Normal file
21
module.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Family Navigator Graph module for webtrees.
|
||||||
|
*
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FamilyNavGraph;
|
||||||
|
|
||||||
|
use Composer\Autoload\ClassLoader;
|
||||||
|
use Fisharebest\Webtrees\Registry;
|
||||||
|
|
||||||
|
// Register PSR-4 autoloader for our namespace
|
||||||
|
$loader = new ClassLoader();
|
||||||
|
$loader->addPsr4('FamilyNavGraph\\', __DIR__ . '/src');
|
||||||
|
$loader->register();
|
||||||
|
|
||||||
|
return Registry::container()->get(Module::class);
|
||||||
885
package-lock.json
generated
Normal file
885
package-lock.json
generated
Normal file
@@ -0,0 +1,885 @@
|
|||||||
|
{
|
||||||
|
"name": "webtrees-family-navigator-graph",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "webtrees-family-navigator-graph",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@rollup/plugin-commonjs": "^29.0.2",
|
||||||
|
"d3-selection": "^3.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@rollup/plugin-node-resolve": "^16.0.3",
|
||||||
|
"@rollup/plugin-terser": "^0.4.4",
|
||||||
|
"rollup": "^4.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
|
"version": "0.3.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/source-map": {
|
||||||
|
"version": "0.3.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
|
||||||
|
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.25"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
|
"version": "1.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
|
"version": "0.3.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/plugin-commonjs": {
|
||||||
|
"version": "29.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz",
|
||||||
|
"integrity": "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rollup/pluginutils": "^5.0.1",
|
||||||
|
"commondir": "^1.0.1",
|
||||||
|
"estree-walker": "^2.0.2",
|
||||||
|
"fdir": "^6.2.0",
|
||||||
|
"is-reference": "1.2.1",
|
||||||
|
"magic-string": "^0.30.3",
|
||||||
|
"picomatch": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0 || 14 >= 14.17"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"rollup": "^2.68.0||^3.0.0||^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/plugin-node-resolve": {
|
||||||
|
"version": "16.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
|
||||||
|
"integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rollup/pluginutils": "^5.0.1",
|
||||||
|
"@types/resolve": "1.20.2",
|
||||||
|
"deepmerge": "^4.2.2",
|
||||||
|
"is-module": "^1.0.0",
|
||||||
|
"resolve": "^1.22.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"rollup": "^2.78.0||^3.0.0||^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/plugin-terser": {
|
||||||
|
"version": "0.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz",
|
||||||
|
"integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"serialize-javascript": "^6.0.1",
|
||||||
|
"smob": "^1.0.0",
|
||||||
|
"terser": "^5.17.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"rollup": "^2.0.0||^3.0.0||^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/pluginutils": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0",
|
||||||
|
"estree-walker": "^2.0.2",
|
||||||
|
"picomatch": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@types/estree": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/resolve": {
|
||||||
|
"version": "1.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||||
|
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/acorn": {
|
||||||
|
"version": "8.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"acorn": "bin/acorn"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/buffer-from": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "2.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||||
|
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/commondir": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/d3-selection": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/deepmerge": {
|
||||||
|
"version": "4.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fdir": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"picomatch": "^3 || ^4"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"picomatch": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-core-module": {
|
||||||
|
"version": "2.16.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||||
|
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-module": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/is-reference": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-parse": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/picomatch": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/randombytes": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/resolve": {
|
||||||
|
"version": "1.22.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
|
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-core-module": "^2.16.1",
|
||||||
|
"path-parse": "^1.0.7",
|
||||||
|
"supports-preserve-symlinks-flag": "^1.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"resolve": "bin/resolve"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rollup": {
|
||||||
|
"version": "4.59.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "1.0.8"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rollup": "dist/bin/rollup"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||||
|
"@rollup/rollup-android-arm64": "4.59.0",
|
||||||
|
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||||
|
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||||
|
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||||
|
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||||
|
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||||
|
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||||
|
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||||
|
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||||
|
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||||
|
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||||
|
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||||
|
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||||
|
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||||
|
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||||
|
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||||
|
"fsevents": "~2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/serialize-javascript": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"randombytes": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/smob": {
|
||||||
|
"version": "1.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/smob/-/smob-1.6.1.tgz",
|
||||||
|
"integrity": "sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/source-map": {
|
||||||
|
"version": "0.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
|
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/source-map-support": {
|
||||||
|
"version": "0.5.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||||
|
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"source-map": "^0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/supports-preserve-symlinks-flag": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/terser": {
|
||||||
|
"version": "5.46.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz",
|
||||||
|
"integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
|
"acorn": "^8.15.0",
|
||||||
|
"commander": "^2.20.0",
|
||||||
|
"source-map-support": "~0.5.20"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"terser": "bin/terser"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "webtrees-family-navigator-graph",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "rollup -c",
|
||||||
|
"watch": "rollup -c --watch",
|
||||||
|
"prepare": "npm run build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@rollup/plugin-node-resolve": "^16.0.3",
|
||||||
|
"@rollup/plugin-terser": "^0.4.4",
|
||||||
|
"rollup": "^4.9.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@rollup/plugin-commonjs": "^29.0.2",
|
||||||
|
"d3-selection": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
128
resources/css/family-nav-graph.css
Normal file
128
resources/css/family-nav-graph.css
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/* Family Navigator Graph — Sidebar Styles
|
||||||
|
*
|
||||||
|
* Uses the same class names as the full-diagram plugin so that
|
||||||
|
* a single theme override applies to both visualizations.
|
||||||
|
* The graph renders inline — no viewport, no zoom, no borders.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Sidebar container — seamless, no viewport ── */
|
||||||
|
.wt-sidebar-family-nav-graph {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fng-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fng-container .full-diagram-chart svg {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Person cards (SVG) — shared with full-diagram ── */
|
||||||
|
.person-card rect {
|
||||||
|
fill: #e8e8e8;
|
||||||
|
stroke: #b0b0b0;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-card.sex-m rect {
|
||||||
|
fill: #d4e6f9;
|
||||||
|
stroke: #7bafd4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-card.sex-f rect {
|
||||||
|
fill: #f9d4e6;
|
||||||
|
stroke: #d47ba8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-card.is-root rect {
|
||||||
|
stroke: #495057;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-card:hover rect {
|
||||||
|
filter: drop-shadow(0 3px 8px rgba(0, 0, 0, 0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Photo placeholder */
|
||||||
|
.person-card .photo-placeholder {
|
||||||
|
fill: #ddd;
|
||||||
|
stroke: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-card.sex-m .photo-placeholder {
|
||||||
|
fill: #b8d4ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-card.sex-f .photo-placeholder {
|
||||||
|
fill: #edb8d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-card .silhouette {
|
||||||
|
fill: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card text */
|
||||||
|
.person-card .person-name {
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
fill: #212529;
|
||||||
|
dominant-baseline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-card .person-dates {
|
||||||
|
font-size: 9px;
|
||||||
|
fill: #6c757d;
|
||||||
|
dominant-baseline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Connector lines — shared with full-diagram ── */
|
||||||
|
.link {
|
||||||
|
fill: none;
|
||||||
|
stroke: #adb5bd;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.couple-link {
|
||||||
|
stroke: #868e96;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ancestor-link {
|
||||||
|
stroke: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.descendant-link {
|
||||||
|
stroke: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── More ancestors / descendants indicators — shared with full-diagram ── */
|
||||||
|
.more-ancestors-indicator rect,
|
||||||
|
.more-descendants-indicator rect {
|
||||||
|
fill: #dee2e6;
|
||||||
|
stroke: #adb5bd;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-ancestors-indicator line,
|
||||||
|
.more-descendants-indicator line {
|
||||||
|
stroke: #adb5bd;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-card.sex-m .more-ancestors-indicator rect,
|
||||||
|
.person-card.sex-m .more-descendants-indicator rect {
|
||||||
|
fill: #c4d9f0;
|
||||||
|
stroke: #7bafd4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-card.sex-f .more-ancestors-indicator rect,
|
||||||
|
.person-card.sex-f .more-descendants-indicator rect {
|
||||||
|
fill: #f0c4d9;
|
||||||
|
stroke: #d47ba8;
|
||||||
|
}
|
||||||
1
resources/js/family-nav-graph.min.js
vendored
Normal file
1
resources/js/family-nav-graph.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
9
resources/js/modules/index.js
Normal file
9
resources/js/modules/index.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Family Navigator Graph — entry point.
|
||||||
|
*
|
||||||
|
* Reads embedded data from the sidebar and initializes the compact chart.
|
||||||
|
*/
|
||||||
|
import Chart from "./lib/chart.js";
|
||||||
|
|
||||||
|
// Expose for sidebar inline script
|
||||||
|
window.FamilyNavGraphChart = Chart;
|
||||||
116
resources/js/modules/lib/chart.js
Normal file
116
resources/js/modules/lib/chart.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Compact sidebar chart orchestrator.
|
||||||
|
*
|
||||||
|
* Renders an inline, auto-sized SVG that flows naturally in the page.
|
||||||
|
* No zoom/pan — the graph fits the sidebar width, growing vertically as needed.
|
||||||
|
* Wide generations (many children) are wrapped into multiple rows.
|
||||||
|
*/
|
||||||
|
import { computeLayout } from "./layout/layout.js";
|
||||||
|
import { renderPersonCard } from "./chart/box.js";
|
||||||
|
import { select } from "./d3.js";
|
||||||
|
|
||||||
|
export default class Chart {
|
||||||
|
constructor(containerSelector, data) {
|
||||||
|
this.containerSelector = containerSelector;
|
||||||
|
this.data = data;
|
||||||
|
this.config = {
|
||||||
|
cardWidth: 130,
|
||||||
|
cardHeight: 42,
|
||||||
|
horizontalSpacing: 18,
|
||||||
|
verticalSpacing: 45,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
const ctr = this.containerSelector;
|
||||||
|
const chartEl = select(`${ctr} .full-diagram-chart`);
|
||||||
|
const containerWidth = chartEl.node().getBoundingClientRect().width || 320;
|
||||||
|
|
||||||
|
// Pass sidebar width so layout can wrap wide generations
|
||||||
|
this.config.targetWidth = containerWidth;
|
||||||
|
|
||||||
|
const layout = computeLayout(
|
||||||
|
this.data.persons,
|
||||||
|
this.data.mainId,
|
||||||
|
this.config
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compute bounding box of all content
|
||||||
|
const pad = 12;
|
||||||
|
const bounds = this.computeBounds(layout, pad);
|
||||||
|
|
||||||
|
// Create auto-sized SVG
|
||||||
|
const svgWidth = bounds.width;
|
||||||
|
const svgHeight = bounds.height;
|
||||||
|
|
||||||
|
const svg = chartEl
|
||||||
|
.append("svg")
|
||||||
|
.attr("viewBox", `${bounds.minX} ${bounds.minY} ${svgWidth} ${svgHeight}`)
|
||||||
|
.attr("width", "100%")
|
||||||
|
.attr("preserveAspectRatio", "xMidYMid meet")
|
||||||
|
.style("display", "block");
|
||||||
|
|
||||||
|
const canvas = svg.append("g").attr("class", "full-diagram-canvas");
|
||||||
|
|
||||||
|
// Click navigates to person's individual page
|
||||||
|
const onNodeClick = (data) => {
|
||||||
|
if (data.data.url) {
|
||||||
|
window.location.href = data.data.url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Draw connections first (behind cards)
|
||||||
|
const linkGroup = canvas.append("g").attr("class", "edges");
|
||||||
|
for (const conn of layout.connections) {
|
||||||
|
linkGroup
|
||||||
|
.append("path")
|
||||||
|
.attr("class", conn.cssClass)
|
||||||
|
.attr("d", conn.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw person cards
|
||||||
|
for (const person of layout.persons) {
|
||||||
|
renderPersonCard(canvas, person, this.config, onNodeClick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
computeBounds(layout, pad) {
|
||||||
|
const hw = this.config.cardWidth / 2;
|
||||||
|
const hh = this.config.cardHeight / 2;
|
||||||
|
|
||||||
|
let minX = Infinity, maxX = -Infinity;
|
||||||
|
let minY = Infinity, maxY = -Infinity;
|
||||||
|
|
||||||
|
for (const p of layout.persons) {
|
||||||
|
minX = Math.min(minX, p.x - hw);
|
||||||
|
maxX = Math.max(maxX, p.x + hw);
|
||||||
|
// Expand for ancestor/descendant indicators (14px above / 14px below card)
|
||||||
|
const topExtra = p.data.hasMoreAncestors ? 14 : 0;
|
||||||
|
const bottomExtra = p.data.hasMoreDescendants ? 14 : 0;
|
||||||
|
minY = Math.min(minY, p.y - hh - topExtra);
|
||||||
|
maxY = Math.max(maxY, p.y + hh + bottomExtra);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include connector endpoints in bounds
|
||||||
|
for (const c of layout.connections) {
|
||||||
|
const coords = c.path.match(/-?[\d.]+/g);
|
||||||
|
if (coords) {
|
||||||
|
for (let i = 0; i < coords.length; i += 2) {
|
||||||
|
const x = parseFloat(coords[i]);
|
||||||
|
const y = parseFloat(coords[i + 1]);
|
||||||
|
minX = Math.min(minX, x);
|
||||||
|
maxX = Math.max(maxX, x);
|
||||||
|
minY = Math.min(minY, y);
|
||||||
|
maxY = Math.max(maxY, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: minX - pad,
|
||||||
|
minY: minY - pad,
|
||||||
|
width: (maxX - minX) + pad * 2,
|
||||||
|
height: (maxY - minY) + pad * 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
207
resources/js/modules/lib/chart/box.js
Normal file
207
resources/js/modules/lib/chart/box.js
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Compact person card renderer for the sidebar graph.
|
||||||
|
*
|
||||||
|
* Smaller cards (130x42) with circular photo and name only.
|
||||||
|
* Birth/death years shown as a second line.
|
||||||
|
* Indicators show when more ancestors/descendants exist beyond the graph.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a compact person card as an SVG group.
|
||||||
|
*/
|
||||||
|
export function renderPersonCard(parent, person, config, onClick) {
|
||||||
|
const data = person.data;
|
||||||
|
const w = config.cardWidth;
|
||||||
|
const h = config.cardHeight;
|
||||||
|
|
||||||
|
const sexClass = `sex-${(data.gender || "u").toLowerCase()}`;
|
||||||
|
const rootClass = person.isMain ? "is-root" : "";
|
||||||
|
|
||||||
|
const g = parent
|
||||||
|
.append("g")
|
||||||
|
.attr("class", `person-card ${sexClass} ${rootClass}`.trim())
|
||||||
|
.attr("transform", `translate(${person.x - w / 2}, ${person.y - h / 2})`)
|
||||||
|
.style("cursor", "pointer")
|
||||||
|
.on("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClick({ id: person.id, data });
|
||||||
|
});
|
||||||
|
|
||||||
|
// "More ancestors" indicator — drawn first so card renders on top
|
||||||
|
if (data.hasMoreAncestors) {
|
||||||
|
const ig = g.append("g")
|
||||||
|
.attr("class", "more-ancestors-indicator")
|
||||||
|
.style("cursor", "pointer")
|
||||||
|
.on("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClick({ id: person.id, data });
|
||||||
|
});
|
||||||
|
|
||||||
|
const bw = 10, bh = 7, gap = 4;
|
||||||
|
const cx = w - 25;
|
||||||
|
const topY = -14;
|
||||||
|
const leftX = cx - gap / 2 - bw;
|
||||||
|
const rightX = cx + gap / 2;
|
||||||
|
const barY = topY + bh;
|
||||||
|
|
||||||
|
ig.append("line")
|
||||||
|
.attr("x1", leftX + bw / 2).attr("y1", barY)
|
||||||
|
.attr("x2", rightX + bw / 2).attr("y2", barY);
|
||||||
|
ig.append("line")
|
||||||
|
.attr("x1", cx).attr("y1", barY)
|
||||||
|
.attr("x2", cx).attr("y2", 0);
|
||||||
|
|
||||||
|
ig.append("rect")
|
||||||
|
.attr("x", leftX).attr("y", topY)
|
||||||
|
.attr("width", bw).attr("height", bh)
|
||||||
|
.attr("rx", 1).attr("ry", 1);
|
||||||
|
ig.append("rect")
|
||||||
|
.attr("x", rightX).attr("y", topY)
|
||||||
|
.attr("width", bw).attr("height", bh)
|
||||||
|
.attr("rx", 1).attr("ry", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// "More descendants" indicator — drawn first so card renders on top
|
||||||
|
if (data.hasMoreDescendants) {
|
||||||
|
const ig = g.append("g")
|
||||||
|
.attr("class", "more-descendants-indicator")
|
||||||
|
.style("cursor", "pointer")
|
||||||
|
.on("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClick({ id: person.id, data });
|
||||||
|
});
|
||||||
|
|
||||||
|
const bw = 10, bh = 7, gap = 4;
|
||||||
|
const cx = w - 25;
|
||||||
|
const boxTop = h + 7;
|
||||||
|
const leftX = cx - gap / 2 - bw;
|
||||||
|
const rightX = cx + gap / 2;
|
||||||
|
const barY = boxTop;
|
||||||
|
|
||||||
|
ig.append("line")
|
||||||
|
.attr("x1", cx).attr("y1", h)
|
||||||
|
.attr("x2", cx).attr("y2", barY);
|
||||||
|
ig.append("line")
|
||||||
|
.attr("x1", leftX + bw / 2).attr("y1", barY)
|
||||||
|
.attr("x2", rightX + bw / 2).attr("y2", barY);
|
||||||
|
|
||||||
|
ig.append("rect")
|
||||||
|
.attr("x", leftX).attr("y", boxTop)
|
||||||
|
.attr("width", bw).attr("height", bh)
|
||||||
|
.attr("rx", 1).attr("ry", 1);
|
||||||
|
ig.append("rect")
|
||||||
|
.attr("x", rightX).attr("y", boxTop)
|
||||||
|
.attr("width", bw).attr("height", bh)
|
||||||
|
.attr("rx", 1).attr("ry", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card background
|
||||||
|
g.append("rect")
|
||||||
|
.attr("width", w)
|
||||||
|
.attr("height", h)
|
||||||
|
.attr("rx", 6)
|
||||||
|
.attr("ry", 6);
|
||||||
|
|
||||||
|
// Photo area (left side)
|
||||||
|
const photoSize = 28;
|
||||||
|
const photoX = 6;
|
||||||
|
const photoY = (h - photoSize) / 2;
|
||||||
|
const textXOffset = photoX + photoSize + 7;
|
||||||
|
|
||||||
|
// Clip path for circular photo
|
||||||
|
const clipId = `fng-clip-${person.id}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
g.append("clipPath")
|
||||||
|
.attr("id", clipId)
|
||||||
|
.append("circle")
|
||||||
|
.attr("cx", photoX + photoSize / 2)
|
||||||
|
.attr("cy", photoY + photoSize / 2)
|
||||||
|
.attr("r", photoSize / 2 - 1);
|
||||||
|
|
||||||
|
if (data.avatar) {
|
||||||
|
g.append("image")
|
||||||
|
.attr("href", data.avatar)
|
||||||
|
.attr("x", photoX)
|
||||||
|
.attr("y", photoY)
|
||||||
|
.attr("width", photoSize)
|
||||||
|
.attr("height", photoSize)
|
||||||
|
.attr("preserveAspectRatio", "xMidYMid slice")
|
||||||
|
.attr("clip-path", `url(#${clipId})`);
|
||||||
|
} else {
|
||||||
|
// Silhouette placeholder
|
||||||
|
g.append("circle")
|
||||||
|
.attr("cx", photoX + photoSize / 2)
|
||||||
|
.attr("cy", photoY + photoSize / 2)
|
||||||
|
.attr("r", photoSize / 2 - 1)
|
||||||
|
.attr("class", "photo-placeholder");
|
||||||
|
|
||||||
|
const cx = photoX + photoSize / 2;
|
||||||
|
const cy = photoY + photoSize / 2;
|
||||||
|
g.append("circle")
|
||||||
|
.attr("cx", cx)
|
||||||
|
.attr("cy", cy - 3)
|
||||||
|
.attr("r", 5)
|
||||||
|
.attr("class", "silhouette");
|
||||||
|
g.append("ellipse")
|
||||||
|
.attr("cx", cx)
|
||||||
|
.attr("cy", cy + 8)
|
||||||
|
.attr("rx", 7)
|
||||||
|
.attr("ry", 5)
|
||||||
|
.attr("class", "silhouette");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name
|
||||||
|
const firstName = data["first name"] || "";
|
||||||
|
const lastName = data["last name"] || "";
|
||||||
|
const displayName = formatDisplayName(firstName, lastName, data.fullName);
|
||||||
|
const maxTextWidth = w - textXOffset - 5;
|
||||||
|
|
||||||
|
g.append("text")
|
||||||
|
.attr("class", "person-name")
|
||||||
|
.attr("x", textXOffset)
|
||||||
|
.attr("y", h / 2 - 4)
|
||||||
|
.text(truncateText(displayName, maxTextWidth, 6.5));
|
||||||
|
|
||||||
|
// Dates line (compact)
|
||||||
|
const dates = formatDates(data.birthYear, data.deathYear, data.isDead);
|
||||||
|
if (dates) {
|
||||||
|
g.append("text")
|
||||||
|
.attr("class", "person-dates")
|
||||||
|
.attr("x", textXOffset)
|
||||||
|
.attr("y", h / 2 + 9)
|
||||||
|
.text(dates);
|
||||||
|
}
|
||||||
|
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDisplayName(firstName, lastName, fullName) {
|
||||||
|
const cleanFirst = firstName && !firstName.match(/^@[A-Z]\.N\.$/) ? firstName : "";
|
||||||
|
const cleanLast = lastName && !lastName.match(/^@[A-Z]\.N\.$/) ? lastName : "";
|
||||||
|
|
||||||
|
if (!cleanFirst && !cleanLast) {
|
||||||
|
const cleanFull = fullName ? fullName.replace(/@[A-Z]\.N\./g, "\u2026").trim() : "";
|
||||||
|
return cleanFull || "???";
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstOnly = cleanFirst ? cleanFirst.split(/\s+/)[0] : "";
|
||||||
|
|
||||||
|
if (firstOnly && cleanLast) {
|
||||||
|
return `${firstOnly} ${cleanLast}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstOnly || cleanLast || "???";
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateText(text, maxWidth, charWidth) {
|
||||||
|
const maxChars = Math.floor(maxWidth / (charWidth || 6.5));
|
||||||
|
if (!text || text.length <= maxChars) return text || "";
|
||||||
|
return text.substring(0, maxChars - 1) + "\u2026";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDates(birth, death, isDead) {
|
||||||
|
if (!birth && !death) return "";
|
||||||
|
if (birth && death) return `${birth}\u2013${death}`;
|
||||||
|
if (birth && isDead) return `${birth}\u2013?`;
|
||||||
|
if (birth) return `* ${birth}`;
|
||||||
|
return `\u2020 ${death}`;
|
||||||
|
}
|
||||||
162
resources/js/modules/lib/chart/overlay.js
Normal file
162
resources/js/modules/lib/chart/overlay.js
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Bio card tooltip on hover for the sidebar graph.
|
||||||
|
*
|
||||||
|
* Shows full details: name, photo, birth, death, marriage, occupation, age.
|
||||||
|
* Uses window.familyNavGraphI18n for translated labels.
|
||||||
|
*/
|
||||||
|
import { select } from "../d3.js";
|
||||||
|
|
||||||
|
let activeTooltip = null;
|
||||||
|
let hideTimer = null;
|
||||||
|
|
||||||
|
function t(key, ...args) {
|
||||||
|
const i18n = window.familyNavGraphI18n || {};
|
||||||
|
let str = i18n[key] || key;
|
||||||
|
for (const arg of args) {
|
||||||
|
str = str.replace("__AGE__", arg);
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showBioCard(data, cardElement, containerSelector) {
|
||||||
|
hideTooltip();
|
||||||
|
|
||||||
|
const container = select(containerSelector);
|
||||||
|
const containerRect = container.node().getBoundingClientRect();
|
||||||
|
const cardRect = cardElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Position tooltip below the card, centered
|
||||||
|
const left = cardRect.left - containerRect.left + cardRect.width / 2;
|
||||||
|
const top = cardRect.bottom - containerRect.top + 6;
|
||||||
|
|
||||||
|
const tooltip = container
|
||||||
|
.append("div")
|
||||||
|
.attr("class", "bio-card")
|
||||||
|
.style("left", `${left}px`)
|
||||||
|
.style("top", `${top}px`)
|
||||||
|
.style("transform", "translateX(-50%)")
|
||||||
|
.on("mouseenter", () => clearTimeout(hideTimer))
|
||||||
|
.on("mouseleave", () => scheduleHide());
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const header = tooltip.append("div").attr("class", "bio-header");
|
||||||
|
|
||||||
|
if (data.avatar) {
|
||||||
|
header
|
||||||
|
.append("img")
|
||||||
|
.attr("src", data.avatar)
|
||||||
|
.attr("alt", data.fullName || "")
|
||||||
|
.attr("class", "bio-photo");
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerText = header.append("div").attr("class", "bio-header-text");
|
||||||
|
headerText.append("div").attr("class", "bio-name").text(data.fullName || "???");
|
||||||
|
|
||||||
|
const ageText = computeAge(data);
|
||||||
|
if (ageText) {
|
||||||
|
headerText.append("div").attr("class", "bio-age").text(ageText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Facts
|
||||||
|
const facts = tooltip.append("div").attr("class", "bio-facts");
|
||||||
|
|
||||||
|
addFact(facts, t("Born"), data.birthDate, data.birthPlace);
|
||||||
|
addFact(facts, t("Baptism"), data.baptismDate);
|
||||||
|
addFact(facts, t("Marriage"), data.marriageDate);
|
||||||
|
addFact(facts, t("Died"), data.deathDate, data.deathPlace);
|
||||||
|
addFact(facts, t("Occupation"), data.occupation);
|
||||||
|
addFact(facts, t("Residence"), data.residence);
|
||||||
|
|
||||||
|
// Profile link
|
||||||
|
tooltip
|
||||||
|
.append("a")
|
||||||
|
.attr("href", data.url)
|
||||||
|
.attr("class", "bio-link")
|
||||||
|
.text(t("View profile") + " \u2192");
|
||||||
|
|
||||||
|
activeTooltip = tooltip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFact(container, label, value, place) {
|
||||||
|
if (!value && !place) return;
|
||||||
|
|
||||||
|
const row = container.append("div").attr("class", "bio-fact");
|
||||||
|
row.append("span").attr("class", "bio-fact-label").text(label);
|
||||||
|
|
||||||
|
let display = value || "";
|
||||||
|
if (place) {
|
||||||
|
display += display ? `, ${place}` : place;
|
||||||
|
}
|
||||||
|
row.append("span").attr("class", "bio-fact-value").text(display);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(dateStr) {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (!isNaN(d.getTime())) return d;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeAge(data) {
|
||||||
|
if (!data.birthYear) return "";
|
||||||
|
|
||||||
|
const birthYear = parseInt(data.birthYear, 10);
|
||||||
|
if (isNaN(birthYear)) return "";
|
||||||
|
|
||||||
|
if (data.isDead) {
|
||||||
|
const birthDate = parseDate(data.birthDate);
|
||||||
|
const deathDate = parseDate(data.deathDate);
|
||||||
|
if (birthDate && deathDate) {
|
||||||
|
let age = deathDate.getFullYear() - birthDate.getFullYear();
|
||||||
|
const monthDiff = deathDate.getMonth() - birthDate.getMonth();
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && deathDate.getDate() < birthDate.getDate())) {
|
||||||
|
age--;
|
||||||
|
}
|
||||||
|
return t("Died at age %s", age);
|
||||||
|
}
|
||||||
|
if (data.deathYear) {
|
||||||
|
const deathYear = parseInt(data.deathYear, 10);
|
||||||
|
if (!isNaN(deathYear)) {
|
||||||
|
return t("Died at age %s", deathYear - birthYear);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t("Deceased");
|
||||||
|
}
|
||||||
|
|
||||||
|
const birthDate = parseDate(data.birthDate);
|
||||||
|
const now = new Date();
|
||||||
|
if (birthDate) {
|
||||||
|
let age = now.getFullYear() - birthDate.getFullYear();
|
||||||
|
const monthDiff = now.getMonth() - birthDate.getMonth();
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birthDate.getDate())) {
|
||||||
|
age--;
|
||||||
|
}
|
||||||
|
return t("Age ~%s", age);
|
||||||
|
}
|
||||||
|
|
||||||
|
const age = now.getFullYear() - birthYear;
|
||||||
|
return t("Age ~%s", age);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleHide() {
|
||||||
|
hideTimer = setTimeout(hideTooltip, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideTooltip() {
|
||||||
|
clearTimeout(hideTimer);
|
||||||
|
if (activeTooltip) {
|
||||||
|
activeTooltip.remove();
|
||||||
|
activeTooltip = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachHoverBioCard(cardGroup, data, containerSelector) {
|
||||||
|
cardGroup
|
||||||
|
.on("mouseenter", function () {
|
||||||
|
clearTimeout(hideTimer);
|
||||||
|
showBioCard(data, this, containerSelector);
|
||||||
|
})
|
||||||
|
.on("mouseleave", () => {
|
||||||
|
scheduleHide();
|
||||||
|
});
|
||||||
|
}
|
||||||
4
resources/js/modules/lib/d3.js
vendored
Normal file
4
resources/js/modules/lib/d3.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* D3 re-exports used by the family navigator graph.
|
||||||
|
*/
|
||||||
|
export { select } from "d3-selection";
|
||||||
684
resources/js/modules/lib/layout/layout.js
Normal file
684
resources/js/modules/lib/layout/layout.js
Normal file
@@ -0,0 +1,684 @@
|
|||||||
|
/**
|
||||||
|
* Custom family layout for the sidebar graph.
|
||||||
|
*
|
||||||
|
* Purpose-built for immediate-family display: parents, siblings,
|
||||||
|
* self + spouse(s), and children. Handles wrapping for large families
|
||||||
|
* and half-siblings from multiple parent couples.
|
||||||
|
*
|
||||||
|
* No external dependencies (replaces ELK).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array} persons - Flat array of { id, data, rels }
|
||||||
|
* @param {string} mainId - Root person ID
|
||||||
|
* @param {object} config - { cardWidth, cardHeight, horizontalSpacing, verticalSpacing, targetWidth }
|
||||||
|
* @returns {{ persons: Array, connections: Array }}
|
||||||
|
*/
|
||||||
|
export function computeLayout(persons, mainId, config) {
|
||||||
|
const personById = new Map();
|
||||||
|
for (const p of persons) personById.set(p.id, p);
|
||||||
|
|
||||||
|
const cw = config.cardWidth;
|
||||||
|
const ch = config.cardHeight;
|
||||||
|
const hGap = config.horizontalSpacing;
|
||||||
|
const vGap = config.verticalSpacing;
|
||||||
|
const halfH = ch / 2;
|
||||||
|
const targetWidth = config.targetWidth || 320;
|
||||||
|
const maxPerRow = Math.max(2, Math.floor(targetWidth / (cw + hGap)));
|
||||||
|
|
||||||
|
// 1. Assign generations via BFS
|
||||||
|
const gen = assignGenerations(personById, mainId);
|
||||||
|
|
||||||
|
// 2. Identify family units (couple → shared children)
|
||||||
|
const families = identifyFamilies(personById, gen);
|
||||||
|
|
||||||
|
// 2a. Identify root's spouse families with children (max 2 shown)
|
||||||
|
const rootChildFamilies = families.filter(
|
||||||
|
(f) => f.parents.includes(mainId) && f.children.length > 0
|
||||||
|
);
|
||||||
|
const activeRootFamilies = rootChildFamilies.slice(0, 2);
|
||||||
|
|
||||||
|
// 2b. Move siblings to their own row (gen -0.5) above root+spouse(s)
|
||||||
|
// But if root has no spouses, keep siblings at gen 0 on the same row
|
||||||
|
const root = personById.get(mainId);
|
||||||
|
const rootSpouses = new Set(root ? root.rels.spouses || [] : []);
|
||||||
|
if (rootSpouses.size > 0) {
|
||||||
|
for (const [id, g] of gen) {
|
||||||
|
if (g === 0 && id !== mainId && !rootSpouses.has(id)) {
|
||||||
|
gen.set(id, -0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2c. When multiple spouse families have children, move those spouses
|
||||||
|
// to gen 0.5 (row below root). Childless spouses stay at gen 0 with root.
|
||||||
|
const activeSpouseIds = new Set();
|
||||||
|
for (const f of activeRootFamilies) {
|
||||||
|
for (const pid of f.parents) {
|
||||||
|
if (pid !== mainId) activeSpouseIds.add(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (activeRootFamilies.length >= 2) {
|
||||||
|
for (const sid of activeSpouseIds) {
|
||||||
|
if (gen.has(sid)) gen.set(sid, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Group by generation
|
||||||
|
const genGroups = new Map();
|
||||||
|
for (const [id, g] of gen) {
|
||||||
|
if (!genGroups.has(g)) genGroups.set(g, []);
|
||||||
|
genGroups.get(g).push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Order + position each generation row
|
||||||
|
const positions = new Map();
|
||||||
|
const sortedGens = [...genGroups.keys()].sort((a, b) => a - b);
|
||||||
|
let currentY = 0;
|
||||||
|
|
||||||
|
for (const g of sortedGens) {
|
||||||
|
const ids = genGroups.get(g);
|
||||||
|
const ordered = orderGeneration(ids, g, mainId, families, personById, activeRootFamilies);
|
||||||
|
|
||||||
|
// Multi-spouse children: position each family's children as
|
||||||
|
// two side-by-side columns (1 card wide each) to stay within 2-card width
|
||||||
|
if (g >= 1 && activeRootFamilies.length >= 2) {
|
||||||
|
const leftKids = ordered.filter((id) => activeRootFamilies[0].children.includes(id));
|
||||||
|
const rightKids = ordered.filter((id) => activeRootFamilies[1].children.includes(id));
|
||||||
|
|
||||||
|
// Each group gets 1 card per row, aligned to the standard 2-wide grid
|
||||||
|
const leftRows = wrapIntoRows(leftKids, 1);
|
||||||
|
const rightRows = wrapIntoRows(rightKids, 1);
|
||||||
|
const maxRowCount = Math.max(leftRows.length, rightRows.length);
|
||||||
|
const rowWidth = 2 * cw + hGap;
|
||||||
|
const leftX = -rowWidth / 2 + cw / 2;
|
||||||
|
const rightX = leftX + cw + hGap;
|
||||||
|
|
||||||
|
for (let r = 0; r < maxRowCount; r++) {
|
||||||
|
const lRow = leftRows[r] || [];
|
||||||
|
const rRow = rightRows[r] || [];
|
||||||
|
|
||||||
|
for (const id of lRow) {
|
||||||
|
positions.set(id, { x: leftX, y: currentY });
|
||||||
|
}
|
||||||
|
for (const id of rRow) {
|
||||||
|
positions.set(id, { x: rightX, y: currentY });
|
||||||
|
}
|
||||||
|
|
||||||
|
currentY += ch + (r < maxRowCount - 1 ? vGap * 0.5 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentY += vGap;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gen-0 (root + spouses): never wrap — all on one row
|
||||||
|
const rowLimit = (g === 0) ? ordered.length : 2;
|
||||||
|
const rows = wrapIntoRows(ordered, rowLimit);
|
||||||
|
|
||||||
|
for (let r = 0; r < rows.length; r++) {
|
||||||
|
const row = rows[r];
|
||||||
|
const rowWidth = row.length * cw + (row.length - 1) * hGap;
|
||||||
|
let startX = -rowWidth / 2 + cw / 2;
|
||||||
|
|
||||||
|
// Sibling row with odd count: offset left so no card
|
||||||
|
// sits dead center — the connector drops through the gap
|
||||||
|
if (g === -0.5 && row.length % 2 === 1) {
|
||||||
|
startX -= (cw + hGap) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let c = 0; c < row.length; c++) {
|
||||||
|
positions.set(row[c], {
|
||||||
|
x: startX + c * (cw + hGap),
|
||||||
|
y: currentY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentY += ch + (r < rows.length - 1 ? vGap * 0.5 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Less vertical space for tightly coupled rows
|
||||||
|
currentY += (g === -0.5 || g === 0.5) ? vGap * 0.6 : vGap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Center on root
|
||||||
|
const rootPos = positions.get(mainId);
|
||||||
|
if (rootPos) {
|
||||||
|
const ox = rootPos.x;
|
||||||
|
const oy = rootPos.y;
|
||||||
|
for (const pos of positions.values()) {
|
||||||
|
pos.x -= ox;
|
||||||
|
pos.y -= oy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Build output
|
||||||
|
const outPersons = [];
|
||||||
|
for (const [id, pos] of positions) {
|
||||||
|
const p = personById.get(id);
|
||||||
|
outPersons.push({
|
||||||
|
x: pos.x, y: pos.y,
|
||||||
|
id, isMain: id === mainId,
|
||||||
|
data: p.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Build connectors
|
||||||
|
const connections = buildConnectors(families, positions, config, mainId, activeRootFamilies, personById);
|
||||||
|
|
||||||
|
return { persons: outPersons, connections };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Generation assignment ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function assignGenerations(personById, mainId) {
|
||||||
|
const gen = new Map();
|
||||||
|
gen.set(mainId, 0);
|
||||||
|
const queue = [mainId];
|
||||||
|
const visited = new Set([mainId]);
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const id = queue.shift();
|
||||||
|
const g = gen.get(id);
|
||||||
|
const p = personById.get(id);
|
||||||
|
if (!p) continue;
|
||||||
|
|
||||||
|
// Spouses = same generation
|
||||||
|
for (const sid of p.rels.spouses || []) {
|
||||||
|
if (!visited.has(sid) && personById.has(sid)) {
|
||||||
|
gen.set(sid, g);
|
||||||
|
visited.add(sid);
|
||||||
|
queue.push(sid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Parents = one up
|
||||||
|
for (const pid of p.rels.parents || []) {
|
||||||
|
if (!visited.has(pid) && personById.has(pid)) {
|
||||||
|
gen.set(pid, g - 1);
|
||||||
|
visited.add(pid);
|
||||||
|
queue.push(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Children = one down
|
||||||
|
for (const cid of p.rels.children || []) {
|
||||||
|
if (!visited.has(cid) && personById.has(cid)) {
|
||||||
|
gen.set(cid, g + 1);
|
||||||
|
visited.add(cid);
|
||||||
|
queue.push(cid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return gen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Family identification ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify family units from children's parent sets.
|
||||||
|
* Each family = { parents: [id, ...], children: [id, ...] }
|
||||||
|
*/
|
||||||
|
function identifyFamilies(personById, gen) {
|
||||||
|
const familyMap = new Map(); // "pA|pB" → { parents, children }
|
||||||
|
|
||||||
|
for (const [id, person] of personById) {
|
||||||
|
const parents = (person.rels.parents || []).filter((pid) => personById.has(pid));
|
||||||
|
if (parents.length === 0) continue;
|
||||||
|
|
||||||
|
const key = [...parents].sort().join("|");
|
||||||
|
if (!familyMap.has(key)) {
|
||||||
|
familyMap.set(key, { parents: [...parents].sort(), children: [] });
|
||||||
|
}
|
||||||
|
familyMap.get(key).children.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...familyMap.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Generation ordering ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function orderGeneration(ids, g, mainId, families, personById, activeRootFamilies) {
|
||||||
|
if (g === -0.5) return orderSiblings(ids, families, personById);
|
||||||
|
if (g < 0) return orderParents(ids, families);
|
||||||
|
if (g === 0) return orderSelfGeneration(ids, mainId, families, personById, activeRootFamilies);
|
||||||
|
if (g === 0.5) return orderActiveSpouses(ids, activeRootFamilies);
|
||||||
|
return orderChildren(ids, mainId, families, activeRootFamilies);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parents: group by couple, bridging through shared parents.
|
||||||
|
* e.g. [Dad, Mom, StepDad] when Mom is in two families.
|
||||||
|
*/
|
||||||
|
function orderParents(ids, families) {
|
||||||
|
const parentFamilies = families.filter((f) =>
|
||||||
|
f.parents.some((pid) => ids.includes(pid))
|
||||||
|
);
|
||||||
|
if (parentFamilies.length === 0) return [...ids];
|
||||||
|
|
||||||
|
const ordered = [];
|
||||||
|
const placed = new Set();
|
||||||
|
const remaining = [...parentFamilies];
|
||||||
|
|
||||||
|
// Start with first family
|
||||||
|
const first = remaining.shift();
|
||||||
|
for (const pid of first.parents) {
|
||||||
|
if (ids.includes(pid) && !placed.has(pid)) {
|
||||||
|
ordered.push(pid);
|
||||||
|
placed.add(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process remaining, preferring those that share a parent (bridge)
|
||||||
|
while (remaining.length > 0) {
|
||||||
|
const idx = remaining.findIndex((f) =>
|
||||||
|
f.parents.some((pid) => placed.has(pid))
|
||||||
|
);
|
||||||
|
const next =
|
||||||
|
idx >= 0 ? remaining.splice(idx, 1)[0] : remaining.shift();
|
||||||
|
|
||||||
|
for (const pid of next.parents) {
|
||||||
|
if (ids.includes(pid) && !placed.has(pid)) {
|
||||||
|
ordered.push(pid);
|
||||||
|
placed.add(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any remaining
|
||||||
|
for (const id of ids) {
|
||||||
|
if (!placed.has(id)) ordered.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Siblings row: sorted chronologically by birth year.
|
||||||
|
* Grouped by parent family, with each group sorted by birth.
|
||||||
|
*/
|
||||||
|
function orderSiblings(ids, families, personById) {
|
||||||
|
const ordered = [];
|
||||||
|
const placed = new Set();
|
||||||
|
|
||||||
|
// Group siblings by parent family
|
||||||
|
for (const family of families) {
|
||||||
|
const famSiblings = family.children.filter(
|
||||||
|
(cid) => ids.includes(cid) && !placed.has(cid)
|
||||||
|
);
|
||||||
|
// Sort by birth year (unknown years go last)
|
||||||
|
famSiblings.sort((a, b) => {
|
||||||
|
const ya = personById.get(a)?.data?.birthYear || 9999;
|
||||||
|
const yb = personById.get(b)?.data?.birthYear || 9999;
|
||||||
|
return ya - yb;
|
||||||
|
});
|
||||||
|
for (const cid of famSiblings) {
|
||||||
|
ordered.push(cid);
|
||||||
|
placed.add(cid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any remaining
|
||||||
|
for (const id of ids) {
|
||||||
|
if (!placed.has(id)) ordered.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self generation: root + spouse(s).
|
||||||
|
* Multi-spouse: active spouses are at gen 0.5, so gen 0 has root + childless spouses.
|
||||||
|
* Single spouse: root + spouse on same row.
|
||||||
|
*/
|
||||||
|
function orderSelfGeneration(ids, mainId, families, personById, activeRootFamilies) {
|
||||||
|
const root = personById.get(mainId);
|
||||||
|
const ordered = [];
|
||||||
|
const placed = new Set();
|
||||||
|
|
||||||
|
// Root first
|
||||||
|
ordered.push(mainId);
|
||||||
|
placed.add(mainId);
|
||||||
|
|
||||||
|
// Remaining spouses (childless ones in multi-spouse, or all in single-spouse)
|
||||||
|
for (const sid of root.rels.spouses || []) {
|
||||||
|
if (ids.includes(sid) && !placed.has(sid)) {
|
||||||
|
ordered.push(sid);
|
||||||
|
placed.add(sid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any remaining gen-0 persons
|
||||||
|
for (const id of ids) {
|
||||||
|
if (!placed.has(id)) ordered.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active spouses (gen 0.5): ordered to match activeRootFamilies (left, right).
|
||||||
|
*/
|
||||||
|
function orderActiveSpouses(ids, activeRootFamilies) {
|
||||||
|
const ordered = [];
|
||||||
|
const placed = new Set();
|
||||||
|
|
||||||
|
for (const family of activeRootFamilies) {
|
||||||
|
for (const pid of family.parents) {
|
||||||
|
if (ids.includes(pid) && !placed.has(pid)) {
|
||||||
|
ordered.push(pid);
|
||||||
|
placed.add(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
if (!placed.has(id)) ordered.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Children: grouped by spouse family.
|
||||||
|
* Uses activeRootFamilies order so left family's kids come first.
|
||||||
|
*/
|
||||||
|
function orderChildren(ids, mainId, families, activeRootFamilies) {
|
||||||
|
const ordered = [];
|
||||||
|
const placed = new Set();
|
||||||
|
|
||||||
|
// Active root families first (left, then right)
|
||||||
|
for (const family of activeRootFamilies) {
|
||||||
|
for (const cid of family.children) {
|
||||||
|
if (ids.includes(cid) && !placed.has(cid)) {
|
||||||
|
ordered.push(cid);
|
||||||
|
placed.add(cid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any remaining children from other families
|
||||||
|
const rootFamilies = families.filter((f) => f.parents.includes(mainId));
|
||||||
|
for (const family of rootFamilies) {
|
||||||
|
if (activeRootFamilies.includes(family)) continue;
|
||||||
|
for (const cid of family.children) {
|
||||||
|
if (ids.includes(cid) && !placed.has(cid)) {
|
||||||
|
ordered.push(cid);
|
||||||
|
placed.add(cid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
if (!placed.has(id)) ordered.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Row wrapping ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function wrapIntoRows(ids, maxPerRow) {
|
||||||
|
const rows = [];
|
||||||
|
for (let i = 0; i < ids.length; i += maxPerRow) {
|
||||||
|
rows.push(ids.slice(i, i + maxPerRow));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Connector drawing ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildConnectors(families, positions, config, mainId, activeRootFamilies, personById) {
|
||||||
|
const connections = [];
|
||||||
|
const halfH = config.cardHeight / 2;
|
||||||
|
const cw = config.cardWidth;
|
||||||
|
const vGap = config.verticalSpacing;
|
||||||
|
const multiSpouse = activeRootFamilies.length >= 2;
|
||||||
|
const rootPos = positions.get(mainId);
|
||||||
|
const margin = 12;
|
||||||
|
|
||||||
|
for (const family of families) {
|
||||||
|
const rootFamIdx = activeRootFamilies.indexOf(family);
|
||||||
|
const isActiveRootFamily = rootFamIdx >= 0;
|
||||||
|
|
||||||
|
// Multi-spouse active families get custom routing below
|
||||||
|
if (isActiveRootFamily && multiSpouse) continue;
|
||||||
|
|
||||||
|
const parentPos = family.parents
|
||||||
|
.map((pid) => positions.get(pid))
|
||||||
|
.filter(Boolean);
|
||||||
|
const childPos = family.children
|
||||||
|
.map((cid) => ({ id: cid, ...positions.get(cid) }))
|
||||||
|
.filter((c) => c.x !== undefined);
|
||||||
|
|
||||||
|
if (parentPos.length === 0 || childPos.length === 0) continue;
|
||||||
|
|
||||||
|
// Standard family connector (parents, siblings, single-spouse children)
|
||||||
|
const parentBottomY = Math.max(...parentPos.map((p) => p.y)) + halfH;
|
||||||
|
const childTopY = Math.min(...childPos.map((c) => c.y)) - halfH;
|
||||||
|
const gap = childTopY - parentBottomY;
|
||||||
|
const coupleBarY = parentBottomY + gap * 0.3;
|
||||||
|
const unionX =
|
||||||
|
parentPos.reduce((s, p) => s + p.x, 0) / parentPos.length;
|
||||||
|
|
||||||
|
if (parentPos.length >= 2) {
|
||||||
|
const xs = parentPos.map((p) => p.x).sort((a, b) => a - b);
|
||||||
|
connections.push({
|
||||||
|
path: `M ${xs[0]} ${coupleBarY} L ${xs[xs.length - 1]} ${coupleBarY}`,
|
||||||
|
cssClass: "link couple-link",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const p of parentPos) {
|
||||||
|
connections.push({
|
||||||
|
path: `M ${p.x} ${p.y + halfH} L ${p.x} ${coupleBarY}`,
|
||||||
|
cssClass: "link ancestor-link",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
drawChildConnectors(connections, childPos, unionX, coupleBarY, halfH, vGap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-spouse: custom routing for active root families ──
|
||||||
|
if (multiSpouse && rootPos) {
|
||||||
|
for (let fi = 0; fi < activeRootFamilies.length; fi++) {
|
||||||
|
const family = activeRootFamilies[fi];
|
||||||
|
const spouseId = family.parents.find((p) => p !== mainId);
|
||||||
|
const spousePos = spouseId ? positions.get(spouseId) : null;
|
||||||
|
const childPos = family.children
|
||||||
|
.map((cid) => ({ id: cid, ...positions.get(cid) }))
|
||||||
|
.filter((c) => c.x !== undefined);
|
||||||
|
|
||||||
|
if (!spousePos || childPos.length === 0) continue;
|
||||||
|
|
||||||
|
const spouseBottomY = spousePos.y + halfH;
|
||||||
|
const childTopY = Math.min(...childPos.map((c) => c.y)) - halfH;
|
||||||
|
const gap = childTopY - spouseBottomY;
|
||||||
|
const singleChild = childPos.length === 1;
|
||||||
|
|
||||||
|
// Outer spine X: outside edge for routing from root
|
||||||
|
let outerX;
|
||||||
|
if (fi === 0) {
|
||||||
|
outerX = Math.min(...childPos.map((c) => c.x)) - cw / 2 - margin;
|
||||||
|
} else {
|
||||||
|
outerX = Math.max(...childPos.map((c) => c.x)) + cw / 2 + margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Couple bar below spouse — marriage bar that connects everything
|
||||||
|
const coupleBarY = spouseBottomY + gap * 0.3;
|
||||||
|
|
||||||
|
// ── Root down outer edge to couple bar ──
|
||||||
|
const junctionY = (rootPos.y + halfH + spousePos.y - halfH) / 2;
|
||||||
|
|
||||||
|
connections.push({
|
||||||
|
path: `M ${rootPos.x} ${rootPos.y + halfH} L ${rootPos.x} ${junctionY}`,
|
||||||
|
cssClass: "link couple-link",
|
||||||
|
});
|
||||||
|
connections.push({
|
||||||
|
path: `M ${rootPos.x} ${junctionY} L ${outerX} ${junctionY}`,
|
||||||
|
cssClass: "link couple-link",
|
||||||
|
});
|
||||||
|
// Outer edge down to couple bar
|
||||||
|
connections.push({
|
||||||
|
path: `M ${outerX} ${junctionY} L ${outerX} ${coupleBarY}`,
|
||||||
|
cssClass: "link couple-link",
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Spouse bottom down to couple bar ──
|
||||||
|
connections.push({
|
||||||
|
path: `M ${spousePos.x} ${spouseBottomY} L ${spousePos.x} ${coupleBarY}`,
|
||||||
|
cssClass: "link couple-link",
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Horizontal couple bar connecting outer spine and spouse ──
|
||||||
|
connections.push({
|
||||||
|
path: `M ${outerX} ${coupleBarY} L ${spousePos.x} ${coupleBarY}`,
|
||||||
|
cssClass: "link couple-link",
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Children from couple bar ──
|
||||||
|
if (singleChild) {
|
||||||
|
// Single child: straight down from spouse X on the couple bar
|
||||||
|
connections.push({
|
||||||
|
path: `M ${spousePos.x} ${coupleBarY} L ${spousePos.x} ${childPos[0].y - halfH}`,
|
||||||
|
cssClass: "link descendant-link",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Multiple children: add a second horizontal bar below the couple bar,
|
||||||
|
// connected by a short vertical at the couple bar midpoint —
|
||||||
|
// visually separates marriage connector from children connector
|
||||||
|
const firstChildTopY = Math.min(...childPos.map((c) => c.y)) - halfH;
|
||||||
|
const childBarY = firstChildTopY - 6;
|
||||||
|
const coupleBarMidX = (outerX + spousePos.x) / 2;
|
||||||
|
// Short vertical from couple bar midpoint down to children bar
|
||||||
|
connections.push({
|
||||||
|
path: `M ${coupleBarMidX} ${coupleBarY} L ${coupleBarMidX} ${childBarY}`,
|
||||||
|
cssClass: "link descendant-link",
|
||||||
|
});
|
||||||
|
// Children spine at outerX
|
||||||
|
drawChildConnectors(connections, childPos, outerX, childBarY, halfH, vGap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Couple links for childless spouses (same row as root) ──
|
||||||
|
const rootPerson = personById.get(mainId);
|
||||||
|
const childlessActiveIds = new Set();
|
||||||
|
for (const f of activeRootFamilies) {
|
||||||
|
for (const pid of f.parents) {
|
||||||
|
if (pid !== mainId) childlessActiveIds.add(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rootPerson && rootPos && multiSpouse) {
|
||||||
|
for (const sid of rootPerson.rels.spouses || []) {
|
||||||
|
if (childlessActiveIds.has(sid)) continue;
|
||||||
|
const spPos = positions.get(sid);
|
||||||
|
if (!spPos) continue;
|
||||||
|
// Horizontal couple bar on same row
|
||||||
|
const barY = rootPos.y + halfH + 8;
|
||||||
|
connections.push({
|
||||||
|
path: `M ${rootPos.x} ${rootPos.y + halfH} L ${rootPos.x} ${barY}`,
|
||||||
|
cssClass: "link couple-link",
|
||||||
|
});
|
||||||
|
connections.push({
|
||||||
|
path: `M ${rootPos.x} ${barY} L ${spPos.x} ${barY}`,
|
||||||
|
cssClass: "link couple-link",
|
||||||
|
});
|
||||||
|
connections.push({
|
||||||
|
path: `M ${spPos.x} ${barY} L ${spPos.x} ${spPos.y + halfH}`,
|
||||||
|
cssClass: "link couple-link",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw connectors from a spine X down to a set of children.
|
||||||
|
* Handles single-row and wrapped (multi-row) children.
|
||||||
|
*/
|
||||||
|
function drawChildConnectors(connections, childPos, spineX, coupleBarY, halfH, vGap) {
|
||||||
|
// Group children by Y level (detects wrapping)
|
||||||
|
const rowMap = new Map();
|
||||||
|
for (const c of childPos) {
|
||||||
|
const ry = Math.round(c.y * 10) / 10;
|
||||||
|
if (!rowMap.has(ry)) rowMap.set(ry, []);
|
||||||
|
rowMap.get(ry).push(c);
|
||||||
|
}
|
||||||
|
const rowYs = [...rowMap.keys()].sort((a, b) => a - b);
|
||||||
|
const wrapped = rowYs.length > 1;
|
||||||
|
|
||||||
|
if (!wrapped) {
|
||||||
|
const computedBusY = rowYs[0] - halfH - 6;
|
||||||
|
// Use whichever is closer to the children — if the caller already
|
||||||
|
// positioned childBarY near the cards, honour that instead of recalculating
|
||||||
|
const busBaseY = Math.max(computedBusY, coupleBarY);
|
||||||
|
|
||||||
|
// Vertical stem from couple bar to bus
|
||||||
|
if (coupleBarY < busBaseY) {
|
||||||
|
connections.push({
|
||||||
|
path: `M ${spineX} ${coupleBarY} L ${spineX} ${busBaseY}`,
|
||||||
|
cssClass: "link descendant-link",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (childPos.length === 1) {
|
||||||
|
if (Math.abs(childPos[0].x - spineX) > 1) {
|
||||||
|
connections.push({
|
||||||
|
path: `M ${spineX} ${busBaseY} L ${childPos[0].x} ${busBaseY}`,
|
||||||
|
cssClass: "link descendant-link",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
connections.push({
|
||||||
|
path: `M ${childPos[0].x} ${busBaseY} L ${childPos[0].x} ${childPos[0].y - halfH}`,
|
||||||
|
cssClass: "link descendant-link",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const xs = childPos.map((c) => c.x).sort((a, b) => a - b);
|
||||||
|
const busL = Math.min(xs[0], spineX);
|
||||||
|
const busR = Math.max(xs[xs.length - 1], spineX);
|
||||||
|
connections.push({
|
||||||
|
path: `M ${busL} ${busBaseY} L ${busR} ${busBaseY}`,
|
||||||
|
cssClass: "link descendant-link",
|
||||||
|
});
|
||||||
|
for (const c of childPos) {
|
||||||
|
connections.push({
|
||||||
|
path: `M ${c.x} ${busBaseY} L ${c.x} ${c.y - halfH}`,
|
||||||
|
cssClass: "link descendant-link",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Wrapped children — one continuous vertical spine with
|
||||||
|
// horizontal buses branching off per sub-row
|
||||||
|
const rowBuses = rowYs.map((ry) => ry - halfH - 6);
|
||||||
|
// If caller positioned coupleBarY below the first bus, use it instead
|
||||||
|
if (coupleBarY > rowBuses[0]) rowBuses[0] = coupleBarY;
|
||||||
|
|
||||||
|
// Draw one continuous spine from couple bar to last bus
|
||||||
|
const spineEnd = rowBuses[rowBuses.length - 1];
|
||||||
|
connections.push({
|
||||||
|
path: `M ${spineX} ${coupleBarY} L ${spineX} ${spineEnd}`,
|
||||||
|
cssClass: "link descendant-link",
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let ri = 0; ri < rowYs.length; ri++) {
|
||||||
|
const rowChildren = rowMap.get(rowYs[ri]);
|
||||||
|
const rowBusY = rowBuses[ri];
|
||||||
|
|
||||||
|
const xs = rowChildren.map((c) => c.x).sort((a, b) => a - b);
|
||||||
|
const busL = Math.min(xs[0], spineX);
|
||||||
|
const busR = Math.max(xs[xs.length - 1], spineX);
|
||||||
|
|
||||||
|
connections.push({
|
||||||
|
path: `M ${busL} ${rowBusY} L ${busR} ${rowBusY}`,
|
||||||
|
cssClass: "link descendant-link",
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const c of rowChildren) {
|
||||||
|
connections.push({
|
||||||
|
path: `M ${c.x} ${rowBusY} L ${c.x} ${c.y - halfH}`,
|
||||||
|
cssClass: "link descendant-link",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
resources/views/modules/family-nav-graph/sidebar.phtml
Normal file
89
resources/views/modules/family-nav-graph/sidebar.phtml
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Fisharebest\Webtrees\Individual;
|
||||||
|
use Fisharebest\Webtrees\Tree;
|
||||||
|
use FamilyNavGraph\Module;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Module $module
|
||||||
|
* @var Individual $individual
|
||||||
|
* @var Tree $tree
|
||||||
|
* @var string $tree_data
|
||||||
|
* @var string $javascript_url
|
||||||
|
* @var string $stylesheet_url
|
||||||
|
*/
|
||||||
|
|
||||||
|
$containerId = 'family-nav-graph-' . $individual->xref();
|
||||||
|
?>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="<?= e($stylesheet_url) ?>">
|
||||||
|
|
||||||
|
<div class="wt-sidebar-content wt-sidebar-family-nav-graph">
|
||||||
|
<div id="<?= $containerId ?>" class="fng-container">
|
||||||
|
<div class="full-diagram-chart"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var data = <?= $tree_data ?>;
|
||||||
|
|
||||||
|
function expandAccordion() {
|
||||||
|
var container = document.getElementById('<?= $containerId ?>');
|
||||||
|
if (!container) return;
|
||||||
|
var collapse = container.closest('.accordion-collapse');
|
||||||
|
if (collapse && !collapse.classList.contains('show')) {
|
||||||
|
var bsCollapse = new bootstrap.Collapse(collapse, { toggle: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSidebar() {
|
||||||
|
if (typeof window.FamilyNavGraphChart === 'undefined') {
|
||||||
|
setTimeout(initSidebar, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expandAccordion();
|
||||||
|
|
||||||
|
var container = document.getElementById('<?= $containerId ?>');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
var chartEl = container.querySelector('.full-diagram-chart');
|
||||||
|
if (chartEl && chartEl.getBoundingClientRect().width > 0) {
|
||||||
|
renderChart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accordion may be animating — wait for it to finish
|
||||||
|
var accordion = container.closest('.accordion-collapse');
|
||||||
|
if (accordion) {
|
||||||
|
accordion.addEventListener('shown.bs.collapse', function handler() {
|
||||||
|
accordion.removeEventListener('shown.bs.collapse', handler);
|
||||||
|
renderChart();
|
||||||
|
});
|
||||||
|
// Already open (no animation)
|
||||||
|
if (accordion.classList.contains('show')) {
|
||||||
|
renderChart();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
renderChart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart() {
|
||||||
|
var chart = new window.FamilyNavGraphChart('#<?= $containerId ?>', data);
|
||||||
|
chart.render().catch(function(err) {
|
||||||
|
console.error('Family Nav Graph: render failed', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initSidebar);
|
||||||
|
} else {
|
||||||
|
initSidebar();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script src="<?= e($javascript_url) ?>"></script>
|
||||||
14
rollup.config.js
Normal file
14
rollup.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import resolve from "@rollup/plugin-node-resolve";
|
||||||
|
import commonjs from "@rollup/plugin-commonjs";
|
||||||
|
import terser from "@rollup/plugin-terser";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
input: "resources/js/modules/index.js",
|
||||||
|
output: {
|
||||||
|
file: "resources/js/family-nav-graph.min.js",
|
||||||
|
format: "iife",
|
||||||
|
name: "FamilyNavGraph",
|
||||||
|
sourcemap: false,
|
||||||
|
},
|
||||||
|
plugins: [resolve(), commonjs(), terser()],
|
||||||
|
};
|
||||||
252
src/Facade/DataFacade.php
Normal file
252
src/Facade/DataFacade.php
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FamilyNavGraph\Facade;
|
||||||
|
|
||||||
|
use Fisharebest\Webtrees\Individual;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a flat person array for the sidebar family graph.
|
||||||
|
*
|
||||||
|
* Collects immediate family: parents, siblings, spouses, and children.
|
||||||
|
*/
|
||||||
|
class DataFacade
|
||||||
|
{
|
||||||
|
/** @var array<string, Individual> Collected individuals keyed by xref */
|
||||||
|
private array $individuals = [];
|
||||||
|
|
||||||
|
public function buildFamilyTree(Individual $root, int $ancestorGens, int $descendantGens): array
|
||||||
|
{
|
||||||
|
$this->individuals = [];
|
||||||
|
|
||||||
|
// Collect root
|
||||||
|
$this->collectPerson($root);
|
||||||
|
|
||||||
|
// Collect ancestors
|
||||||
|
$this->collectAncestors($root, $ancestorGens);
|
||||||
|
|
||||||
|
// Collect siblings (other children of root's parents)
|
||||||
|
foreach ($root->childFamilies() as $family) {
|
||||||
|
foreach ($family->children() as $child) {
|
||||||
|
if (!isset($this->individuals[$child->xref()])) {
|
||||||
|
$this->collectPerson($child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect descendants (spouses + children)
|
||||||
|
$this->collectDescendants($root, $descendantGens);
|
||||||
|
|
||||||
|
// Build flat person array
|
||||||
|
$persons = [];
|
||||||
|
foreach ($this->individuals as $individual) {
|
||||||
|
$persons[] = $this->buildPersonData($individual);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'persons' => $persons,
|
||||||
|
'mainId' => $root->xref(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectPerson(Individual $individual): void
|
||||||
|
{
|
||||||
|
$this->individuals[$individual->xref()] = $individual;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectAncestors(Individual $individual, int $generations): void
|
||||||
|
{
|
||||||
|
if ($generations <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($individual->childFamilies() as $family) {
|
||||||
|
$husband = $family->husband();
|
||||||
|
$wife = $family->wife();
|
||||||
|
|
||||||
|
if ($husband !== null && !isset($this->individuals[$husband->xref()])) {
|
||||||
|
$this->collectPerson($husband);
|
||||||
|
$this->collectAncestors($husband, $generations - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($wife !== null && !isset($this->individuals[$wife->xref()])) {
|
||||||
|
$this->collectPerson($wife);
|
||||||
|
$this->collectAncestors($wife, $generations - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectDescendants(Individual $individual, int $generations): void
|
||||||
|
{
|
||||||
|
if ($generations <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($individual->spouseFamilies() as $family) {
|
||||||
|
$spouse = $family->spouse($individual);
|
||||||
|
if ($spouse !== null && !isset($this->individuals[$spouse->xref()])) {
|
||||||
|
$this->collectPerson($spouse);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($family->children() as $child) {
|
||||||
|
if (!isset($this->individuals[$child->xref()])) {
|
||||||
|
$this->collectPerson($child);
|
||||||
|
$this->collectDescendants($child, $generations - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPersonData(Individual $individual): array
|
||||||
|
{
|
||||||
|
$xref = $individual->xref();
|
||||||
|
|
||||||
|
$parents = [];
|
||||||
|
$spouses = [];
|
||||||
|
$children = [];
|
||||||
|
|
||||||
|
foreach ($individual->childFamilies() as $family) {
|
||||||
|
$husband = $family->husband();
|
||||||
|
$wife = $family->wife();
|
||||||
|
|
||||||
|
if ($husband !== null && isset($this->individuals[$husband->xref()])) {
|
||||||
|
$parents[] = $husband->xref();
|
||||||
|
}
|
||||||
|
if ($wife !== null && isset($this->individuals[$wife->xref()])) {
|
||||||
|
$parents[] = $wife->xref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($individual->spouseFamilies() as $family) {
|
||||||
|
$spouse = $family->spouse($individual);
|
||||||
|
if ($spouse !== null && isset($this->individuals[$spouse->xref()])) {
|
||||||
|
$spouses[] = $spouse->xref();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($family->children() as $child) {
|
||||||
|
if (isset($this->individuals[$child->xref()])) {
|
||||||
|
$children[] = $child->xref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = $individual->getAllNames();
|
||||||
|
$primaryName = $names[0] ?? [];
|
||||||
|
$firstName = self::cleanGedcomName(trim($primaryName['givn'] ?? ''));
|
||||||
|
$lastName = self::cleanGedcomName(trim($primaryName['surn'] ?? ''));
|
||||||
|
|
||||||
|
$thumbnailUrl = '';
|
||||||
|
$media = $individual->findHighlightedMediaFile();
|
||||||
|
if ($media !== null) {
|
||||||
|
$thumbnailUrl = $media->imageUrl(60, 60, 'crop');
|
||||||
|
}
|
||||||
|
|
||||||
|
$marriageDate = '';
|
||||||
|
$spouseFamily = $individual->spouseFamilies()->first();
|
||||||
|
if ($spouseFamily !== null) {
|
||||||
|
$marriageFact = $spouseFamily->facts(['MARR'])->first();
|
||||||
|
if ($marriageFact !== null && $marriageFact->date()->isOK()) {
|
||||||
|
$marriageDate = strip_tags($marriageFact->date()->display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $xref,
|
||||||
|
'data' => [
|
||||||
|
'gender' => $individual->sex() === 'M' ? 'M' : 'F',
|
||||||
|
'first name' => $firstName,
|
||||||
|
'last name' => $lastName,
|
||||||
|
'fullName' => str_replace('@N.N.', "\u{2026}", strip_tags($individual->fullName())),
|
||||||
|
'birthDate' => self::extractDate($individual, 'BIRT'),
|
||||||
|
'birthYear' => self::extractYear($individual, 'BIRT'),
|
||||||
|
'birthPlace' => self::extractPlace($individual, 'BIRT'),
|
||||||
|
'deathDate' => self::extractDate($individual, 'DEAT'),
|
||||||
|
'deathYear' => self::extractYear($individual, 'DEAT'),
|
||||||
|
'deathPlace' => self::extractPlace($individual, 'DEAT'),
|
||||||
|
'baptismDate' => self::extractDate($individual, 'BAPM') ?: self::extractDate($individual, 'CHR'),
|
||||||
|
'marriageDate' => $marriageDate,
|
||||||
|
'occupation' => self::extractFactValue($individual, 'OCCU'),
|
||||||
|
'residence' => self::extractFactValue($individual, 'RESI'),
|
||||||
|
'isDead' => $individual->isDead(),
|
||||||
|
'avatar' => $thumbnailUrl,
|
||||||
|
'url' => $individual->url(),
|
||||||
|
'hasMoreAncestors' => $this->hasMoreAncestors($individual),
|
||||||
|
'hasMoreDescendants' => $this->hasMoreDescendants($individual),
|
||||||
|
],
|
||||||
|
'rels' => [
|
||||||
|
'parents' => array_values(array_unique($parents)),
|
||||||
|
'spouses' => array_values(array_unique($spouses)),
|
||||||
|
'children' => array_values(array_unique($children)),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasMoreAncestors(Individual $individual): bool
|
||||||
|
{
|
||||||
|
foreach ($individual->childFamilies() as $family) {
|
||||||
|
foreach ([$family->husband(), $family->wife()] as $parent) {
|
||||||
|
if ($parent !== null && !isset($this->individuals[$parent->xref()])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasMoreDescendants(Individual $individual): bool
|
||||||
|
{
|
||||||
|
foreach ($individual->spouseFamilies() as $family) {
|
||||||
|
foreach ($family->children() as $child) {
|
||||||
|
if (!isset($this->individuals[$child->xref()])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function cleanGedcomName(string $name): string
|
||||||
|
{
|
||||||
|
if (preg_match('/^@[A-Z]\.N\.$/', $name)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function extractDate(Individual $individual, string $tag): string
|
||||||
|
{
|
||||||
|
$fact = $individual->facts([$tag])->first();
|
||||||
|
if ($fact === null || !$fact->date()->isOK()) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return strip_tags($fact->date()->display());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function extractYear(Individual $individual, string $tag): string
|
||||||
|
{
|
||||||
|
$fact = $individual->facts([$tag])->first();
|
||||||
|
if ($fact === null || !$fact->date()->isOK()) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return (string) $fact->date()->minimumDate()->year();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function extractPlace(Individual $individual, string $tag): string
|
||||||
|
{
|
||||||
|
$fact = $individual->facts([$tag])->first();
|
||||||
|
if ($fact === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return $fact->place()->gedcomName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function extractFactValue(Individual $individual, string $tag): string
|
||||||
|
{
|
||||||
|
$fact = $individual->facts([$tag])->first();
|
||||||
|
if ($fact === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return trim($fact->value());
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/Module.php
Normal file
113
src/Module.php
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Family Navigator Graph — sidebar module for webtrees.
|
||||||
|
*
|
||||||
|
* Shows an interactive SVG graph of the individual's immediate family
|
||||||
|
* (parents, siblings, spouses, children) in the individual page sidebar.
|
||||||
|
*
|
||||||
|
* @license GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace FamilyNavGraph;
|
||||||
|
|
||||||
|
use Fisharebest\Webtrees\I18N;
|
||||||
|
use Fisharebest\Webtrees\Individual;
|
||||||
|
use Fisharebest\Webtrees\Module\AbstractModule;
|
||||||
|
use Fisharebest\Webtrees\Module\ModuleCustomInterface;
|
||||||
|
use Fisharebest\Webtrees\Module\ModuleCustomTrait;
|
||||||
|
use Fisharebest\Webtrees\Module\ModuleSidebarInterface;
|
||||||
|
use Fisharebest\Webtrees\Module\ModuleSidebarTrait;
|
||||||
|
use Fisharebest\Webtrees\View;
|
||||||
|
use FamilyNavGraph\Facade\DataFacade;
|
||||||
|
|
||||||
|
class Module extends AbstractModule implements ModuleSidebarInterface, ModuleCustomInterface
|
||||||
|
{
|
||||||
|
use ModuleSidebarTrait;
|
||||||
|
use ModuleCustomTrait;
|
||||||
|
|
||||||
|
private const ANCESTOR_GENERATIONS = 1;
|
||||||
|
private const DESCENDANT_GENERATIONS = 1;
|
||||||
|
|
||||||
|
public function title(): string
|
||||||
|
{
|
||||||
|
return I18N::translate('Family Graph');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return I18N::translate('An interactive graph showing the individual\'s immediate family relationships.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customModuleAuthorName(): string
|
||||||
|
{
|
||||||
|
return 'Alex';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customModuleVersion(): string
|
||||||
|
{
|
||||||
|
return '1.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customModuleSupportUrl(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resourcesFolder(): string
|
||||||
|
{
|
||||||
|
return __DIR__ . '/../resources/';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
View::registerNamespace($this->name(), $this->resourcesFolder() . 'views/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Translations ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function customTranslations(string $language): array
|
||||||
|
{
|
||||||
|
$translations = [
|
||||||
|
'de' => [
|
||||||
|
'Family Graph' => 'Familiengraph',
|
||||||
|
'An interactive graph showing the individual\'s immediate family relationships.' => 'Ein interaktiver Graph der direkten Familienbeziehungen.',
|
||||||
|
],
|
||||||
|
'nl' => [
|
||||||
|
'Family Graph' => 'Familiegrafiek',
|
||||||
|
'An interactive graph showing the individual\'s immediate family relationships.' => 'Een interactieve grafiek van de directe familierelaties.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $translations[$language] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sidebar interface ───────────────────────────────────────────
|
||||||
|
|
||||||
|
public function defaultSidebarOrder(): int
|
||||||
|
{
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasSidebarContent(Individual $individual): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSidebarContent(Individual $individual): string
|
||||||
|
{
|
||||||
|
$dataFacade = new DataFacade();
|
||||||
|
$treeData = $dataFacade->buildFamilyTree($individual, self::ANCESTOR_GENERATIONS, self::DESCENDANT_GENERATIONS);
|
||||||
|
|
||||||
|
return view($this->name() . '::modules/family-nav-graph/sidebar', [
|
||||||
|
'module' => $this,
|
||||||
|
'individual' => $individual,
|
||||||
|
'tree' => $individual->tree(),
|
||||||
|
'tree_data' => json_encode($treeData, JSON_THROW_ON_ERROR),
|
||||||
|
'javascript_url' => $this->assetUrl('js/family-nav-graph.min.js'),
|
||||||
|
'stylesheet_url' => $this->assetUrl('css/family-nav-graph.css'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user