Add "more descendants" indicator and fix indicator draw order

Show small child-box indicators at bottom-right of person cards when
descendants exist beyond the current view. Fix both ancestor and
descendant indicators to draw connecting lines behind boxes.
This commit is contained in:
2026-03-14 20:37:42 +01:00
parent 272ee41df6
commit 7528d30de1
17 changed files with 2187 additions and 21 deletions

View File

@@ -0,0 +1,278 @@
/* Full Diagram Chart Styles */
/* ── Container ── */
.full-diagram-container {
position: relative;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
background: #f8f9fa;
overflow: hidden;
/* Break out of webtrees content column to use full viewport width */
width: 100vw;
margin-left: calc(-50vw + 50%);
border-radius: 0;
border-left: none;
border-right: none;
}
/* Block variant: stay within card boundaries */
.full-diagram-block {
width: 100%;
margin-left: 0;
border-radius: 0.5rem;
border: 1px solid #dee2e6;
}
.full-diagram-chart {
width: 100%;
height: 100%;
}
.full-diagram-chart svg {
width: 100%;
height: 100%;
display: block;
}
/* ── Person cards (SVG) ── */
.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: 12px;
font-weight: 600;
fill: #212529;
dominant-baseline: auto;
}
.person-card .person-dates {
font-size: 10px;
fill: #6c757d;
dominant-baseline: auto;
}
.person-card .person-subtitle {
font-size: 9px;
fill: #868e96;
font-style: italic;
dominant-baseline: auto;
}
/* ── More ancestors/descendants indicators ── */
.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;
}
.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;
}
/* ── Connector lines ── */
.link {
fill: none;
stroke: #adb5bd;
stroke-width: 1.5;
}
.couple-link {
stroke: #868e96;
stroke-width: 2;
}
.ancestor-link {
stroke: #adb5bd;
}
.descendant-link {
stroke: #adb5bd;
}
/* ── Bio card tooltip ── */
.bio-card {
position: absolute;
z-index: 1000;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
padding: 12px;
min-width: 220px;
max-width: 300px;
font-size: 12px;
color: #212529;
pointer-events: auto;
}
.bio-header {
display: flex;
gap: 10px;
margin-bottom: 8px;
align-items: center;
}
.bio-photo {
width: 50px;
height: 50px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.bio-header-text {
flex: 1;
min-width: 0;
}
.bio-name {
font-weight: 600;
font-size: 14px;
line-height: 1.2;
}
.bio-age {
font-size: 11px;
color: #6c757d;
margin-top: 2px;
}
.bio-facts {
border-top: 1px solid #eee;
padding-top: 6px;
}
.bio-fact {
display: flex;
gap: 6px;
margin-bottom: 3px;
line-height: 1.4;
}
.bio-fact-label {
font-weight: 600;
color: #495057;
white-space: nowrap;
}
.bio-fact-label::after {
content: ":";
}
.bio-fact-value {
color: #6c757d;
}
.bio-link {
display: block;
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid #eee;
color: #4a90d9;
text-decoration: none;
font-size: 11px;
}
.bio-link:hover {
text-decoration: underline;
}
/* ── Zoom controls ── */
.zoom-controls {
position: absolute;
bottom: 12px;
right: 12px;
display: flex;
flex-direction: column;
gap: 4px;
z-index: 10;
}
.zoom-controls button {
width: 32px;
height: 32px;
border: 1px solid #dee2e6;
border-radius: 4px;
background: rgba(255, 255, 255, 0.9);
color: #495057;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
line-height: 1;
}
.zoom-controls button:hover {
background: #e9ecef;
}
/* ── Print styles ── */
@media print {
.full-diagram-container {
border: none;
height: auto !important;
overflow: visible;
}
.zoom-controls {
display: none;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Individual;
use Fisharebest\Webtrees\Tree;
/**
* @var Individual|null $individual
* @var Tree $tree
* @var int $ancestor_generations
* @var int $descendant_generations
* @var bool $show_siblings
*/
?>
<div class="row mb-3">
<label class="col-sm-3 col-form-label" for="xref">
<?= I18N::translate('Individual') ?>
</label>
<div class="col-sm-9">
<?= view('components/select-individual', [
'name' => 'xref',
'individual' => $individual,
'tree' => $tree,
]) ?>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label" for="ancestor_generations">
<?= I18N::translate('Generations of ancestors') ?>
</label>
<div class="col-sm-9">
<input
type="number"
class="form-control"
id="ancestor_generations"
name="ancestor_generations"
min="1"
max="10"
value="<?= $ancestor_generations ?>"
>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label" for="descendant_generations">
<?= I18N::translate('Generations of descendants') ?>
</label>
<div class="col-sm-9">
<input
type="number"
class="form-control"
id="descendant_generations"
name="descendant_generations"
min="1"
max="10"
value="<?= $descendant_generations ?>"
>
</div>
</div>
<div class="row mb-3">
<div class="col-sm-9 offset-sm-3">
<div class="form-check">
<input
type="checkbox"
class="form-check-input"
id="show_siblings"
name="show_siblings"
value="1"
<?= $show_siblings ? 'checked' : '' ?>
>
<label class="form-check-label" for="show_siblings">
<?= I18N::translate('Show siblings') ?>
</label>
</div>
</div>
</div>

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Individual;
use Fisharebest\Webtrees\Module\ModuleChartInterface;
use Fisharebest\Webtrees\Tree;
/**
* @var ModuleChartInterface $module
* @var Individual $individual
* @var Tree $tree
* @var string $tree_data
* @var string $javascript_url
* @var string $stylesheet_url
* @var int $block_id
* @var int $ancestor_generations
* @var int $descendant_generations
* @var bool $show_siblings
*/
$containerId = 'full-diagram-block-' . $block_id;
?>
<link rel="stylesheet" href="<?= e($stylesheet_url) ?>">
<div id="<?= $containerId ?>" class="full-diagram-container full-diagram-block wt-chart" data-tree-name="<?= e($tree->name()) ?>" style="height:450px;">
<div class="full-diagram-chart"></div>
</div>
<script>
(function() {
if (!window.fullDiagramI18n) {
window.fullDiagramI18n = <?= json_encode([
'Born' => I18N::translate('Born'),
'Baptism' => I18N::translate('Baptism'),
'Marriage' => I18N::translate('Marriage'),
'Died' => I18N::translate('Died'),
'Occupation' => I18N::translate('Occupation'),
'Residence' => I18N::translate('Residence'),
'View profile' => I18N::translate('View profile'),
'Died at age %s' => I18N::translate('Died at age %s', '__AGE__'),
'Deceased' => I18N::translate('Deceased'),
'Age ~%s' => I18N::translate('Age ~%s', '__AGE__'),
], JSON_THROW_ON_ERROR) ?>;
}
var data = <?= $tree_data ?>;
var baseUrl = <?= json_encode(route($module::ROUTE_NAME, [
'tree' => $tree->name(),
'xref' => '__XREF__',
'ancestor_generations' => $ancestor_generations,
'descendant_generations' => $descendant_generations,
'show_siblings' => $show_siblings ? '1' : '0',
]), JSON_THROW_ON_ERROR) ?>;
function initBlock() {
if (typeof window.FullDiagramChart === 'undefined') {
// JS not loaded yet, retry
setTimeout(initBlock, 100);
return;
}
var chart = new window.FullDiagramChart('#<?= $containerId ?>', data, baseUrl);
chart.render();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initBlock);
} else {
initBlock();
}
})();
</script>
<script src="<?= e($javascript_url) ?>"></script>

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Individual;
use Fisharebest\Webtrees\Module\ModuleChartInterface;
use Fisharebest\Webtrees\Tree;
/**
* @var ModuleChartInterface $module
* @var Individual $individual
* @var Tree $tree
* @var string $tree_data
* @var string $javascript_url
* @var string $stylesheet_url
* @var int $ancestor_generations
* @var int $descendant_generations
* @var bool $show_siblings
*/
?>
<link rel="stylesheet" href="<?= e($stylesheet_url) ?>">
<div id="full-diagram-container" class="full-diagram-container wt-chart" data-tree-name="<?= e($tree->name()) ?>">
<div class="full-diagram-chart"></div>
</div>
<script>
(function() {
var el = document.getElementById('full-diagram-container');
function resize() {
var rect = el.getBoundingClientRect();
el.style.height = Math.max(400, window.innerHeight - rect.top - 16) + 'px';
}
resize();
window.addEventListener('resize', resize);
})();
</script>
<script>
window.fullDiagramI18n = <?= json_encode([
'Born' => I18N::translate('Born'),
'Baptism' => I18N::translate('Baptism'),
'Marriage' => I18N::translate('Marriage'),
'Died' => I18N::translate('Died'),
'Occupation' => I18N::translate('Occupation'),
'Residence' => I18N::translate('Residence'),
'View profile' => I18N::translate('View profile'),
'Died at age %s' => I18N::translate('Died at age %s', '__AGE__'),
'Deceased' => I18N::translate('Deceased'),
'Age ~%s' => I18N::translate('Age ~%s', '__AGE__'),
], JSON_THROW_ON_ERROR) ?>;
window.fullDiagramData = <?= $tree_data ?>;
window.fullDiagramBaseUrl = <?= json_encode(route($module::ROUTE_NAME, [
'tree' => $tree->name(),
'xref' => '__XREF__',
'ancestor_generations' => $ancestor_generations,
'descendant_generations' => $descendant_generations,
'show_siblings' => $show_siblings ? '1' : '0',
]), JSON_THROW_ON_ERROR) ?>;
</script>
<script src="<?= e($javascript_url) ?>"></script>

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use Fisharebest\Webtrees\Http\RequestHandlers\IndividualPage;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Individual;
use Fisharebest\Webtrees\Module\ModuleChartInterface;
use Fisharebest\Webtrees\Tree;
use FullDiagram\Configuration;
/**
* @var string $title
* @var Individual $individual
* @var ModuleChartInterface $module
* @var Tree $tree
* @var Configuration $configuration
* @var string $tree_data
* @var string $javascript_url
* @var string $stylesheet_url
* @var int $ancestor_generations
* @var int $descendant_generations
* @var bool $show_siblings
*/
?>
<?= view('components/breadcrumbs', [
'links' => [
route(IndividualPage::class, ['tree' => $tree->name(), 'xref' => $individual->xref()]) => $individual->fullName(),
$title,
],
]) ?>
<h2 class="wt-page-title"><?= $title ?></h2>
<?= view($module->name() . '::modules/full-diagram/chart', [
'module' => $module,
'individual' => $individual,
'tree' => $tree,
'tree_data' => $tree_data,
'javascript_url' => $javascript_url,
'stylesheet_url' => $stylesheet_url,
'ancestor_generations' => $ancestor_generations,
'descendant_generations' => $descendant_generations,
'show_siblings' => $show_siblings,
]) ?>