commit 0369f781fac587ee31db69261d56fe7a9ef9b77d Author: Alexander Bocken Date: Mon Mar 16 14:02:13 2026 +0100 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..398b56f --- /dev/null +++ b/composer.json @@ -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/" + } + } +} diff --git a/module.php b/module.php new file mode 100644 index 0000000..05a6e71 --- /dev/null +++ b/module.php @@ -0,0 +1,21 @@ +addPsr4('FamilyNavGraph\\', __DIR__ . '/src'); +$loader->register(); + +return Registry::container()->get(Module::class); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..19d8505 --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e06d388 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/resources/css/family-nav-graph.css b/resources/css/family-nav-graph.css new file mode 100644 index 0000000..6f96260 --- /dev/null +++ b/resources/css/family-nav-graph.css @@ -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; +} diff --git a/resources/js/family-nav-graph.min.js b/resources/js/family-nav-graph.min.js new file mode 100644 index 0000000..1d72d01 --- /dev/null +++ b/resources/js/family-nav-graph.min.js @@ -0,0 +1 @@ +!function(){"use strict";function t(t,s,o){const i=new Map;for(const n of t)i.set(n.id,n);const a=o.cardWidth,c=o.cardHeight,u=o.horizontalSpacing,l=o.verticalSpacing;o.targetWidth;const h=function(t,n){const e=new Map;e.set(n,0);const r=[n],s=new Set([n]);for(;r.length>0;){const n=r.shift(),o=e.get(n),i=t.get(n);if(i){for(const n of i.rels.spouses||[])!s.has(n)&&t.has(n)&&(e.set(n,o),s.add(n),r.push(n));for(const n of i.rels.parents||[])!s.has(n)&&t.has(n)&&(e.set(n,o-1),s.add(n),r.push(n));for(const n of i.rels.children||[])!s.has(n)&&t.has(n)&&(e.set(n,o+1),s.add(n),r.push(n))}}return e}(i,s),f=function(t){const n=new Map;for(const[e,r]of t){const s=(r.rels.parents||[]).filter(n=>t.has(n));if(0===s.length)continue;const o=[...s].sort().join("|");n.has(o)||n.set(o,{parents:[...s].sort(),children:[]}),n.get(o).children.push(e)}return[...n.values()]}(i),p=f.filter(t=>t.parents.includes(s)&&t.children.length>0).slice(0,2),d=i.get(s),g=new Set(d&&d.rels.spouses||[]);if(g.size>0)for(const[t,n]of h)0!==n||t===s||g.has(t)||h.set(t,-.5);const y=new Set;for(const t of p)for(const n of t.parents)n!==s&&y.add(n);if(p.length>=2)for(const t of y)h.has(t)&&h.set(t,.5);const m=new Map;for(const[t,n]of h)m.has(n)||m.set(n,[]),m.get(n).push(t);const _=new Map,x=[...m.keys()].sort((t,n)=>t-n);let v=0;for(const t of x){const r=n(m.get(t),t,s,f,i,p);if(t>=1&&p.length>=2){const t=r.filter(t=>p[0].children.includes(t)),n=r.filter(t=>p[1].children.includes(t)),s=e(t,1),o=e(n,1),i=Math.max(s.length,o.length),h=-(2*a+u)/2+a/2,f=h+a+u;for(let t=0;t=2,h=n.get(s),f=12;for(const e of t){if(o.indexOf(e)>=0&&l)continue;const t=e.parents.map(t=>n.get(t)).filter(Boolean),s=e.children.map(t=>({id:t,...n.get(t)})).filter(t=>void 0!==t.x);if(0===t.length||0===s.length)continue;const i=Math.max(...t.map(t=>t.y))+c,u=i+.3*(Math.min(...s.map(t=>t.y))-c-i),h=t.reduce((t,n)=>t+n.x,0)/t.length;if(t.length>=2){const n=t.map(t=>t.x).sort((t,n)=>t-n);a.push({path:`M ${n[0]} ${u} L ${n[n.length-1]} ${u}`,cssClass:"link couple-link"})}for(const n of t)a.push({path:`M ${n.x} ${n.y+c} L ${n.x} ${u}`,cssClass:"link ancestor-link"});r(a,s,h,u,c)}if(l&&h)for(let t=0;tt!==s),l=i?n.get(i):null,p=e.children.map(t=>({id:t,...n.get(t)})).filter(t=>void 0!==t.x);if(!l||0===p.length)continue;const d=l.y+c,g=Math.min(...p.map(t=>t.y))-c-d,y=1===p.length;let m;m=0===t?Math.min(...p.map(t=>t.x))-u/2-f:Math.max(...p.map(t=>t.x))+u/2+f;const _=d+.3*g,x=(h.y+c+l.y-c)/2;if(a.push({path:`M ${h.x} ${h.y+c} L ${h.x} ${x}`,cssClass:"link couple-link"}),a.push({path:`M ${h.x} ${x} L ${m} ${x}`,cssClass:"link couple-link"}),a.push({path:`M ${m} ${x} L ${m} ${_}`,cssClass:"link couple-link"}),a.push({path:`M ${l.x} ${d} L ${l.x} ${_}`,cssClass:"link couple-link"}),a.push({path:`M ${m} ${_} L ${l.x} ${_}`,cssClass:"link couple-link"}),y)a.push({path:`M ${l.x} ${_} L ${l.x} ${p[0].y-c}`,cssClass:"link descendant-link"});else{const t=Math.min(...p.map(t=>t.y))-c-6,n=(m+l.x)/2;a.push({path:`M ${n} ${_} L ${n} ${t}`,cssClass:"link descendant-link"}),r(a,p,m,t,c)}}const p=i.get(s),d=new Set;for(const t of o)for(const n of t.parents)n!==s&&d.add(n);if(p&&h&&l)for(const t of p.rels.spouses||[]){if(d.has(t))continue;const e=n.get(t);if(!e)continue;const r=h.y+c+8;a.push({path:`M ${h.x} ${h.y+c} L ${h.x} ${r}`,cssClass:"link couple-link"}),a.push({path:`M ${h.x} ${r} L ${e.x} ${r}`,cssClass:"link couple-link"}),a.push({path:`M ${e.x} ${r} L ${e.x} ${e.y+c}`,cssClass:"link couple-link"})}return a}(f,_,o,s,p,i);return{persons:w,connections:M}}function n(t,n,e,r,s,o){return-.5===n?function(t,n,e){const r=[],s=new Set;for(const o of n){const n=o.children.filter(n=>t.includes(n)&&!s.has(n));n.sort((t,n)=>(e.get(t)?.data?.birthYear||9999)-(e.get(n)?.data?.birthYear||9999));for(const t of n)r.push(t),s.add(t)}for(const n of t)s.has(n)||r.push(n);return r}(t,r,s):n<0?function(t,n){const e=n.filter(n=>n.parents.some(n=>t.includes(n)));if(0===e.length)return[...t];const r=[],s=new Set,o=[...e],i=o.shift();for(const n of i.parents)t.includes(n)&&!s.has(n)&&(r.push(n),s.add(n));for(;o.length>0;){const n=o.findIndex(t=>t.parents.some(t=>s.has(t))),e=n>=0?o.splice(n,1)[0]:o.shift();for(const n of e.parents)t.includes(n)&&!s.has(n)&&(r.push(n),s.add(n))}for(const n of t)s.has(n)||r.push(n);return r}(t,r):0===n?function(t,n,e,r){const s=r.get(n),o=[],i=new Set;o.push(n),i.add(n);for(const n of s.rels.spouses||[])t.includes(n)&&!i.has(n)&&(o.push(n),i.add(n));for(const n of t)i.has(n)||o.push(n);return o}(t,e,0,s):.5===n?function(t,n){const e=[],r=new Set;for(const s of n)for(const n of s.parents)t.includes(n)&&!r.has(n)&&(e.push(n),r.add(n));for(const n of t)r.has(n)||e.push(n);return e}(t,o):function(t,n,e,r){const s=[],o=new Set;for(const n of r)for(const e of n.children)t.includes(e)&&!o.has(e)&&(s.push(e),o.add(e));const i=e.filter(t=>t.parents.includes(n));for(const n of i)if(!r.includes(n))for(const e of n.children)t.includes(e)&&!o.has(e)&&(s.push(e),o.add(e));for(const n of t)o.has(n)||s.push(n);return s}(t,e,r,o)}function e(t,n){const e=[];for(let r=0;rt-n);if(a.length>1){const n=a.map(t=>t-s-6);r>n[0]&&(n[0]=r);const o=n[n.length-1];t.push({path:`M ${e} ${r} L ${e} ${o}`,cssClass:"link descendant-link"});for(let r=0;rt.x).sort((t,n)=>t-n),l=Math.min(u[0],e),h=Math.max(u[u.length-1],e);t.push({path:`M ${l} ${c} L ${h} ${c}`,cssClass:"link descendant-link"});for(const n of o)t.push({path:`M ${n.x} ${c} L ${n.x} ${n.y-s}`,cssClass:"link descendant-link"})}}else{const o=a[0]-s-6,i=Math.max(o,r);if(r1&&t.push({path:`M ${e} ${i} L ${n[0].x} ${i}`,cssClass:"link descendant-link"}),t.push({path:`M ${n[0].x} ${i} L ${n[0].x} ${n[0].y-s}`,cssClass:"link descendant-link"});else{const r=n.map(t=>t.x).sort((t,n)=>t-n),o=Math.min(r[0],e),a=Math.max(r[r.length-1],e);t.push({path:`M ${o} ${i} L ${a} ${i}`,cssClass:"link descendant-link"});for(const e of n)t.push({path:`M ${e.x} ${i} L ${e.x} ${e.y-s}`,cssClass:"link descendant-link"})}}}function s(t,n,e,r){const s=n.data,o=e.cardWidth,i=e.cardHeight,a=`sex-${(s.gender||"u").toLowerCase()}`,c=n.isMain?"is-root":"",u=t.append("g").attr("class",`person-card ${a} ${c}`.trim()).attr("transform",`translate(${n.x-o/2}, ${n.y-i/2})`).style("cursor","pointer").on("click",t=>{t.stopPropagation(),r({id:n.id,data:s})});if(s.hasMoreAncestors){const t=u.append("g").attr("class","more-ancestors-indicator").style("cursor","pointer").on("click",t=>{t.stopPropagation(),r({id:n.id,data:s})}),e=10,i=7,a=4,c=o-25,l=-14,h=c-a/2-e,f=c+a/2,p=l+i;t.append("line").attr("x1",h+e/2).attr("y1",p).attr("x2",f+e/2).attr("y2",p),t.append("line").attr("x1",c).attr("y1",p).attr("x2",c).attr("y2",0),t.append("rect").attr("x",h).attr("y",l).attr("width",e).attr("height",i).attr("rx",1).attr("ry",1),t.append("rect").attr("x",f).attr("y",l).attr("width",e).attr("height",i).attr("rx",1).attr("ry",1)}if(s.hasMoreDescendants){const t=u.append("g").attr("class","more-descendants-indicator").style("cursor","pointer").on("click",t=>{t.stopPropagation(),r({id:n.id,data:s})}),e=10,a=7,c=4,l=o-25,h=i+7,f=l-c/2-e,p=l+c/2,d=h;t.append("line").attr("x1",l).attr("y1",i).attr("x2",l).attr("y2",d),t.append("line").attr("x1",f+e/2).attr("y1",d).attr("x2",p+e/2).attr("y2",d),t.append("rect").attr("x",f).attr("y",h).attr("width",e).attr("height",a).attr("rx",1).attr("ry",1),t.append("rect").attr("x",p).attr("y",h).attr("width",e).attr("height",a).attr("rx",1).attr("ry",1)}u.append("rect").attr("width",o).attr("height",i).attr("rx",6).attr("ry",6);const l=28,h=(i-l)/2,f=`fng-clip-${n.id}-${Math.random().toString(36).slice(2,6)}`;if(u.append("clipPath").attr("id",f).append("circle").attr("cx",20).attr("cy",h+14).attr("r",13),s.avatar)u.append("image").attr("href",s.avatar).attr("x",6).attr("y",h).attr("width",l).attr("height",l).attr("preserveAspectRatio","xMidYMid slice").attr("clip-path",`url(#${f})`);else{u.append("circle").attr("cx",20).attr("cy",h+14).attr("r",13).attr("class","photo-placeholder");const t=20,n=h+14;u.append("circle").attr("cx",t).attr("cy",n-3).attr("r",5).attr("class","silhouette"),u.append("ellipse").attr("cx",t).attr("cy",n+8).attr("rx",7).attr("ry",5).attr("class","silhouette")}const p=function(t,n,e){const r=t&&!t.match(/^@[A-Z]\.N\.$/)?t:"",s=n&&!n.match(/^@[A-Z]\.N\.$/)?n:"";if(!r&&!s){return(e?e.replace(/@[A-Z]\.N\./g,"…").trim():"")||"???"}const o=r?r.split(/\s+/)[0]:"";if(o&&s)return`${o} ${s}`;return o||s||"???"}(s["first name"]||"",s["last name"]||"",s.fullName),d=o-41-5;u.append("text").attr("class","person-name").attr("x",41).attr("y",i/2-4).text(function(t,n,e){const r=Math.floor(n/e);return!t||t.length<=r?t||"":t.substring(0,r-1)+"…"}(p,d,6.5));const g=(y=s.birthYear,m=s.deathYear,_=s.isDead,y||m?y&&m?`${y}–${m}`:y&&_?`${y}–?`:y?`* ${y}`:`† ${m}`:"");var y,m,_;return g&&u.append("text").attr("class","person-dates").attr("x",41).attr("y",i/2+9).text(g),u}var o="http://www.w3.org/1999/xhtml",i={svg:"http://www.w3.org/2000/svg",xhtml:o,xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace",xmlns:"http://www.w3.org/2000/xmlns/"};function a(t){var n=t+="",e=n.indexOf(":");return e>=0&&"xmlns"!==(n=t.slice(0,e))&&(t=t.slice(e+1)),i.hasOwnProperty(n)?{space:i[n],local:t}:t}function c(t){return function(){var n=this.ownerDocument,e=this.namespaceURI;return e===o&&n.documentElement.namespaceURI===o?n.createElement(t):n.createElementNS(e,t)}}function u(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function l(t){var n=a(t);return(n.local?u:c)(n)}function h(){}function f(t){return null==t?h:function(){return this.querySelector(t)}}function p(){return[]}function d(t){return function(){return null==(n=t.apply(this,arguments))?[]:Array.isArray(n)?n:Array.from(n);var n}}function g(t){return function(n){return n.matches(t)}}var y=Array.prototype.find;function m(){return this.firstElementChild}var _=Array.prototype.filter;function x(){return Array.from(this.children)}function v(t){return new Array(t.length)}function $(t,n){this.ownerDocument=t.ownerDocument,this.namespaceURI=t.namespaceURI,this._next=null,this._parent=t,this.__data__=n}function w(t,n,e,r,s,o){for(var i,a=0,c=n.length,u=o.length;an?1:t>=n?0:NaN}function S(t){return function(){this.removeAttribute(t)}}function L(t){return function(){this.removeAttributeNS(t.space,t.local)}}function b(t,n){return function(){this.setAttribute(t,n)}}function N(t,n){return function(){this.setAttributeNS(t.space,t.local,n)}}function E(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttribute(t):this.setAttribute(t,e)}}function B(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttributeNS(t.space,t.local):this.setAttributeNS(t.space,t.local,e)}}function P(t){return t.ownerDocument&&t.ownerDocument.defaultView||t.document&&t||t.defaultView}function D(t){return function(){this.style.removeProperty(t)}}function H(t,n,e){return function(){this.style.setProperty(t,n,e)}}function Y(t,n,e){return function(){var r=n.apply(this,arguments);null==r?this.style.removeProperty(t):this.style.setProperty(t,r,e)}}function q(t){return function(){delete this[t]}}function O(t,n){return function(){this[t]=n}}function R(t,n){return function(){var e=n.apply(this,arguments);null==e?delete this[t]:this[t]=e}}function W(t){return t.trim().split(/^|\s+/)}function I(t){return t.classList||new j(t)}function j(t){this._node=t,this._names=W(t.getAttribute("class")||"")}function z(t,n){for(var e=I(t),r=-1,s=n.length;++r=0&&(this._names.splice(n,1),this._node.setAttribute("class",this._names.join(" ")))},contains:function(t){return this._names.indexOf(t)>=0}};var ht=[null];function ft(t,n){this._groups=t,this._parents=n}ft.prototype={constructor:ft,select:function(t){"function"!=typeof t&&(t=f(t));for(var n=this._groups,e=n.length,r=new Array(e),s=0;s=$&&($=v+1);!(x=m[$])&&++$=0;)(r=s[o])&&(i&&4^r.compareDocumentPosition(i)&&i.parentNode.insertBefore(r,i),i=r);return this},sort:function(t){function n(n,e){return n&&e?t(n.__data__,e.__data__):!n-!e}t||(t=C);for(var e=this._groups,r=e.length,s=new Array(r),o=0;o1?this.each((null==n?D:"function"==typeof n?Y:H)(t,n,null==e?"":e)):function(t,n){return t.style.getPropertyValue(n)||P(t).getComputedStyle(t,null).getPropertyValue(n)}(this.node(),t)},property:function(t,n){return arguments.length>1?this.each((null==n?q:"function"==typeof n?R:O)(t,n)):this.node()[t]},classed:function(t,n){var e=W(t+"");if(arguments.length<2){for(var r=I(this.node()),s=-1,o=e.length;++s=0&&(n=t.slice(e+1),t=t.slice(0,e)),{type:t,name:n}})}(t+""),i=o.length;if(!(arguments.length<2)){for(a=n?at:it,r=0;r{t.data.url&&(window.location.href=t.data.url)},l=c.append("g").attr("class","edges");for(const t of r.connections)l.append("path").attr("class",t.cssClass).attr("d",t.path);for(const t of r.persons)s(c,t,this.config,u)}computeBounds(t,n){const e=this.config.cardWidth/2,r=this.config.cardHeight/2;let s=1/0,o=-1/0,i=1/0,a=-1/0;for(const n of t.persons){s=Math.min(s,n.x-e),o=Math.max(o,n.x+e);const t=n.data.hasMoreAncestors?14:0,c=n.data.hasMoreDescendants?14:0;i=Math.min(i,n.y-r-t),a=Math.max(a,n.y+r+c)}for(const n of t.connections){const t=n.path.match(/-?[\d.]+/g);if(t)for(let n=0;n { + 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, + }; + } +} diff --git a/resources/js/modules/lib/chart/box.js b/resources/js/modules/lib/chart/box.js new file mode 100644 index 0000000..86d1eac --- /dev/null +++ b/resources/js/modules/lib/chart/box.js @@ -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}`; +} diff --git a/resources/js/modules/lib/chart/overlay.js b/resources/js/modules/lib/chart/overlay.js new file mode 100644 index 0000000..767f3cc --- /dev/null +++ b/resources/js/modules/lib/chart/overlay.js @@ -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(); + }); +} diff --git a/resources/js/modules/lib/d3.js b/resources/js/modules/lib/d3.js new file mode 100644 index 0000000..b79320b --- /dev/null +++ b/resources/js/modules/lib/d3.js @@ -0,0 +1,4 @@ +/** + * D3 re-exports used by the family navigator graph. + */ +export { select } from "d3-selection"; diff --git a/resources/js/modules/lib/layout/layout.js b/resources/js/modules/lib/layout/layout.js new file mode 100644 index 0000000..7517a62 --- /dev/null +++ b/resources/js/modules/lib/layout/layout.js @@ -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", + }); + } + } + } +} diff --git a/resources/views/modules/family-nav-graph/sidebar.phtml b/resources/views/modules/family-nav-graph/sidebar.phtml new file mode 100644 index 0000000..ff82fa3 --- /dev/null +++ b/resources/views/modules/family-nav-graph/sidebar.phtml @@ -0,0 +1,89 @@ +xref(); +?> + + + +
+
+
+
+
+ + + diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..c32745c --- /dev/null +++ b/rollup.config.js @@ -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()], +}; diff --git a/src/Facade/DataFacade.php b/src/Facade/DataFacade.php new file mode 100644 index 0000000..0d436c9 --- /dev/null +++ b/src/Facade/DataFacade.php @@ -0,0 +1,252 @@ + 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()); + } +} diff --git a/src/Module.php b/src/Module.php new file mode 100644 index 0000000..e25a2bc --- /dev/null +++ b/src/Module.php @@ -0,0 +1,113 @@ +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'), + ]); + } +}