Rework timeline: outside the card, thicker rail, day-first date

Addresses feedback that the previous timeline competed for
attention with the year and felt visually trapped inside the
event card.

- The card surface (cream background, hairline border, rounded
  corners) is now built per-row from the avatar + content TDs.
  The timeline TD sits on the page background to the right of
  the card with a 16px gutter between them.
- Rail bumped from 1px to 4px, in a warm grey #cdc7be that
  reads as a deliberate ribbon rather than a divider.
- Dots are 14px (up from 10), with a 4px page-coloured halo so
  they punch through the rail. Single colour (nord10 blue) for
  every event — no more per-event-type tinting.
- Each calendar day shows exactly one dot: rows are walked in
  upcoming-anniversary order and any row whose $fact->jd matches
  the previous row renders without a dot but keeps its date
  text, so two May-17 deaths share one marker on the rail.
- Date display split into "17. MAI" (semi-bold 13px tracking)
  and "1759" (light 11px, muted) on a second line, so the day
  of the year reads as the primary axis and the year as
  supporting context.
- Relationship label moved from inline "(your great-aunt)" to a
  separate italic muted line beneath the name, so long
  relationship strings don't crowd the event label.
This commit is contained in:
2026-05-15 13:21:35 +02:00
parent 105b09c4c5
commit 461c99fcd1
+192 -65
View File
@@ -44,6 +44,8 @@ $palette = [
'birth' => '#5E81AC', // nord10 — birth (blue) 'birth' => '#5E81AC', // nord10 — birth (blue)
'death' => '#4C566A', // nord3 — death (graphite) 'death' => '#4C566A', // nord3 — death (graphite)
'marr' => '#B48EAD', // nord15 — marriage (purple/pink) 'marr' => '#B48EAD', // nord15 — marriage (purple/pink)
'rail' => '#cdc7be', // muted warm grey for the timeline rail
'dot' => '#5E81AC', // single unified colour for every timeline dot
]; ];
$font_stack = "'Open Sans', Helvetica, Arial, 'Noto Sans', sans-serif"; $font_stack = "'Open Sans', Helvetica, Arial, 'Noto Sans', sans-serif";
@@ -81,46 +83,63 @@ $is_detailed = static function (Fact $fact) use ($detailed_xrefs): bool {
return false; return false;
}; };
$linked_name = static function (Individual $individual) use ($palette, $relationships): string { /**
* Just the linked name (no relationship), used both inline and inside
* the multi-line name+relationship block below.
*/
$linked_name = static function (Individual $individual) use ($palette): string {
$name = strip_tags($individual->fullName()); $name = strip_tags($individual->fullName());
$url = $individual->url(); $url = $individual->url();
$style = 'color:' . $palette['ink'] . ';text-decoration:none;' $style = 'color:' . $palette['ink'] . ';text-decoration:none;'
. 'border-bottom:1px solid ' . $palette['border'] . ';' . 'border-bottom:1px solid ' . $palette['border'] . ';'
. 'padding-bottom:1px;'; . 'padding-bottom:1px;';
$html = '<a href="' . e($url) . '" style="' . $style . '">' . e($name) . '</a>'; return '<a href="' . e($url) . '" style="' . $style . '">' . e($name) . '</a>';
};
if (isset($relationships[$individual->xref()])) { /**
$html .= ' <span style="color:' . $palette['ink3'] . ';font-style:italic;font-weight:400;font-size:13px;">(' * Inline list of names ("Hans Doe" or "Hans Doe & Lotte Doe") plus
. e(strip_tags($relationships[$individual->xref()])) * the combined relationship line on a second line beneath.
. ')</span>'; */
$record_label = static function (Fact $fact) use ($linked_name, $relationships, $palette): string {
$record = $fact->record();
$names = [];
$rels = [];
if ($record instanceof Individual) {
$names[] = $linked_name($record);
if (isset($relationships[$record->xref()])) {
$rels[] = strip_tags($relationships[$record->xref()]);
}
} elseif ($record instanceof Family) {
foreach ([$record->husband(), $record->wife()] as $spouse) {
if ($spouse instanceof Individual) {
$names[] = $linked_name($spouse);
if (isset($relationships[$spouse->xref()])) {
$rels[] = strip_tags($relationships[$spouse->xref()]);
}
}
}
} else {
return e($record->xref());
}
$html = implode(' &amp; ', $names);
if ($rels !== []) {
// Drop near-duplicate labels ("your father", "your father")
// so couples don't print the same relationship twice.
$rels = array_values(array_unique(array_map(static fn ($r) => e($r), $rels)));
$html .= '<div style="margin-top:2px;color:' . $palette['ink3']
. ';font-style:italic;font-weight:400;font-size:13px;">'
. implode(' &amp; ', $rels)
. '</div>';
} }
return $html; return $html;
}; };
$record_label = static function (Fact $fact) use ($linked_name): string {
$record = $fact->record();
if ($record instanceof Individual) {
return $linked_name($record);
}
if ($record instanceof Family) {
$parts = [];
foreach ([$record->husband(), $record->wife()] as $spouse) {
if ($spouse instanceof Individual) {
$parts[] = $linked_name($spouse);
}
}
return implode(' &amp; ', $parts);
}
return e($record->xref());
};
$event_date_display = static function (Fact $fact): string { $event_date_display = static function (Fact $fact): string {
$date = $fact->date(); $date = $fact->date();
@@ -131,6 +150,43 @@ $event_date_display = static function (Fact $fact): string {
return strip_tags($date->display()); return strip_tags($date->display());
}; };
/**
* Split a Fact's event date into (day+month, year) for the timeline
* rail where the day-of-the-anniversary is the meaningful sort key
* and the year is supporting information.
*
* @return array{day_month:string,year:string}
*/
$date_parts = static function (Fact $fact): array {
static $gregorian = null;
static $months_de = [
1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
static $months_en = [
1 => 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
$gregorian ??= new \Fisharebest\ExtCalendar\GregorianCalendar();
$date = $fact->date();
if (!$date->isOK()) {
return ['day_month' => '', 'year' => ''];
}
[$year, $month, $day] = $gregorian->jdToYmd($date->minimumJulianDay());
if (str_starts_with(I18N::languageTag(), 'de')) {
$day_month = $day . '. ' . mb_strtoupper($months_de[$month] ?? '');
} else {
$day_month = mb_strtoupper(($months_en[$month] ?? '') . ' ' . $day);
}
return ['day_month' => $day_month, 'year' => (string) $year];
};
$event_kind = static function (Fact $fact): string { $event_kind = static function (Fact $fact): string {
$parts = explode(':', $fact->tag()); $parts = explode(':', $fact->tag());
@@ -319,22 +375,35 @@ $section_kicker_style = 'margin:0 0 18px;'
. 'font-style:italic;font-weight:300;font-size:14px;' . 'font-style:italic;font-weight:300;font-size:14px;'
. 'color:' . $palette['ink3'] . ';'; . 'color:' . $palette['ink3'] . ';';
$divider_style = 'border-bottom:1px solid ' . $palette['border'] . ';'; // The card surface is built per-row out of avatar+content TDs (the
// outer table itself stays transparent). The timeline TD lives on the
// page background and carries a thick rail on its left edge.
$card_padding_y = 18;
$card_corner_radius = 8;
$avatar_cell_style = 'width:72px;vertical-align:middle;padding:18px 0 18px 18px;'; $avatar_cell_base = 'width:72px;vertical-align:middle;'
. 'padding:' . $card_padding_y . 'px 0 ' . $card_padding_y . 'px 18px;'
. 'background:' . $palette['surface'] . ';'
. 'border-left:1px solid ' . $palette['border'] . ';';
$content_cell_style = 'vertical-align:middle;' $content_cell_base = 'vertical-align:middle;'
. 'padding:18px 18px 18px 16px;' . 'padding:' . $card_padding_y . 'px 18px ' . $card_padding_y . 'px 16px;'
. 'background:' . $palette['surface'] . ';'
. 'border-right:1px solid ' . $palette['border'] . ';'
. 'font-family:' . $font_stack . ';font-size:15px;line-height:1.4;' . 'font-family:' . $font_stack . ';font-size:15px;line-height:1.4;'
. 'font-weight:300;color:' . $palette['ink'] . ';'; . 'font-weight:300;color:' . $palette['ink'] . ';';
$timeline_cell_style = 'width:170px;vertical-align:middle;' $timeline_cell_base = 'width:140px;vertical-align:middle;'
. 'padding:18px 18px 18px 26px;' . 'padding:' . $card_padding_y . 'px 14px ' . $card_padding_y . 'px 24px;'
. 'border-left:1px solid ' . $palette['border'] . ';' . 'border-left:4px solid ' . $palette['rail'] . ';'
. 'background:' . $palette['bg'] . ';'
. 'font-family:' . $font_stack . ';' . 'font-family:' . $font_stack . ';'
. 'font-weight:500;font-size:11px;letter-spacing:0.12em;text-transform:uppercase;'
. 'color:' . $palette['ink3'] . ';white-space:nowrap;'; . 'color:' . $palette['ink3'] . ';white-space:nowrap;';
// A 16px wide gutter cell sits between the card and the timeline so
// the rail visibly separates from the card edge.
$gutter_cell_style = 'width:16px;background:' . $palette['bg'] . ';';
// Renders one event row in the consistent 3-column layout shared by // Renders one event row in the consistent 3-column layout shared by
// every section (avatar | content | date on the timeline rail). // every section (avatar | content | date on the timeline rail).
$summary_kicker_style = 'margin:18px 0 8px;' $summary_kicker_style = 'margin:18px 0 8px;'
@@ -349,36 +418,85 @@ $summary_list_style = 'list-style:none;margin:0;padding:0;'
$summary_item_style = 'padding:3px 0;'; $summary_item_style = 'padding:3px 0;';
$event_row = static function (Fact $fact, string $body_html, string $dot_color) // Renders one event row in the 4-column layout
// [avatar] [content] [gutter] [timeline rail + date]
//
// The avatar+content TDs carry the card surface (background + side
// borders + corner radii on the first/last rows so the visual card
// wraps just those two cells). The timeline TD sits on the page
// background with a thick coloured rail on its left edge.
//
// $position is 'first', 'middle', or 'last' — used to add corner
// radii and outer borders only where they belong.
//
// $show_dot suppresses the dot on rows whose upcoming-day matches
// the previous row, so each calendar day has exactly one dot.
$event_row = static function (
Fact $fact,
string $body_html,
bool $is_first,
bool $is_last,
bool $show_dot,
)
use ( use (
$record_avatars, $record_avatars,
$event_date_display, $date_parts,
$avatar_cell_style, $avatar_cell_base,
$content_cell_style, $content_cell_base,
$timeline_cell_style, $timeline_cell_base,
$divider_style, $gutter_cell_style,
$card_corner_radius,
$palette,
): string { ): string {
return '<tr style="' . $divider_style . '">' $avatar_extra = '';
. '<td style="' . $avatar_cell_style . '">' . $record_avatars($fact) . '</td>' $content_extra = '';
. '<td style="' . $content_cell_style . '">' . $body_html . '</td>'
. '<td style="' . $timeline_cell_style . '">' if ($is_first) {
. '<span style="display:inline-block;width:10px;height:10px;background:' $avatar_extra .= 'border-top:1px solid ' . $palette['border'] . ';'
. $dot_color . ';border-radius:50%;' . 'border-top-left-radius:' . $card_corner_radius . 'px;';
. 'margin-left:-32px;margin-right:14px;vertical-align:middle;"></span>' $content_extra .= 'border-top:1px solid ' . $palette['border'] . ';'
. e($event_date_display($fact)) . 'border-top-right-radius:' . $card_corner_radius . 'px;';
. '</td>' }
if ($is_last) {
$avatar_extra .= 'border-bottom:1px solid ' . $palette['border'] . ';'
. 'border-bottom-left-radius:' . $card_corner_radius . 'px;';
$content_extra .= 'border-bottom:1px solid ' . $palette['border'] . ';'
. 'border-bottom-right-radius:' . $card_corner_radius . 'px;';
} else {
$avatar_extra .= 'border-bottom:1px solid ' . $palette['border'] . ';';
$content_extra .= 'border-bottom:1px solid ' . $palette['border'] . ';';
}
$parts = $date_parts($fact);
$dot_html = $show_dot
? '<span style="display:inline-block;width:14px;height:14px;background:'
. $palette['dot']
. ';border-radius:50%;margin-left:-31px;margin-right:14px;vertical-align:middle;'
. 'box-shadow:0 0 0 4px ' . $palette['bg'] . ';"></span>'
: '<span style="display:inline-block;width:14px;margin-left:-31px;margin-right:14px;vertical-align:middle;"></span>';
$date_html =
'<span style="display:inline-block;vertical-align:middle;">'
. '<div style="font-weight:600;font-size:13px;letter-spacing:0.12em;color:'
. $palette['ink'] . ';">' . e($parts['day_month']) . '</div>'
. '<div style="font-weight:300;font-size:11px;color:'
. $palette['mute'] . ';margin-top:1px;">' . e($parts['year']) . '</div>'
. '</span>';
return '<tr>'
. '<td style="' . $avatar_cell_base . $avatar_extra . '">' . $record_avatars($fact) . '</td>'
. '<td style="' . $content_cell_base . $content_extra . '">' . $body_html . '</td>'
. '<td style="' . $gutter_cell_style . '"></td>'
. '<td style="' . $timeline_cell_base . '">' . $dot_html . $date_html . '</td>'
. '</tr>'; . '</tr>';
}; };
// Wraps a list of <tr> rows in the "card" surface — rounded background // Outer section table is transparent. Each event row contributes its
// patch matching .card on the website. // own card-surfaced avatar+content TDs plus the timeline TD.
$card_open = '<table role="presentation" cellpadding="0" cellspacing="0" border="0" ' $card_open = '<table role="presentation" cellpadding="0" cellspacing="0" border="0" '
. 'style="width:100%;border-collapse:collapse;' . 'style="width:100%;border-collapse:separate;border-spacing:0;">';
. 'background:' . $palette['surface'] . ';'
. 'border:1px solid ' . $palette['border'] . ';'
. 'border-radius:8px;'
. 'box-shadow:0 1px 3px rgba(0,0,0,0.04);'
. 'overflow:hidden;">';
$card_close = '</table>'; $card_close = '</table>';
?><!doctype html> ?><!doctype html>
@@ -436,7 +554,8 @@ $card_close = '</table>';
</p> </p>
<?php if ($detailed !== []) : ?> <?php if ($detailed !== []) : ?>
<?= $card_open ?> <?= $card_open ?>
<?php foreach ($detailed as $fact) : ?> <?php $prev_jd = null; $total = count($detailed); ?>
<?php foreach ($detailed as $i => $fact) : ?>
<?php <?php
$age = $upcoming_age($fact); $age = $upcoming_age($fact);
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('BIRT') . '</span>' $body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('BIRT') . '</span>'
@@ -444,7 +563,9 @@ $card_close = '</table>';
. '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>' . '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>'
. ' <span style="color:' . $palette['ink2'] . ';font-weight:300;">— ' . e($birthday_label($age)) . '</span>' . ' <span style="color:' . $palette['ink2'] . ';font-weight:300;">— ' . e($birthday_label($age)) . '</span>'
. '</span>'; . '</span>';
echo $event_row($fact, $body, $palette['birth']); $show_dot = ($fact->jd ?? 0) !== $prev_jd;
$prev_jd = $fact->jd ?? 0;
echo $event_row($fact, $body, $i === 0, $i === $total - 1, $show_dot);
?> ?>
<?php endforeach ?> <?php endforeach ?>
<?= $card_close ?> <?= $card_close ?>
@@ -482,7 +603,8 @@ $card_close = '</table>';
</p> </p>
<?php if ($detailed !== []) : ?> <?php if ($detailed !== []) : ?>
<?= $card_open ?> <?= $card_open ?>
<?php foreach ($detailed as $fact) : ?> <?php $prev_jd = null; $total = count($detailed); ?>
<?php foreach ($detailed as $i => $fact) : ?>
<?php <?php
$age = $upcoming_age($fact); $age = $upcoming_age($fact);
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('MARR') . '</span>' $body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('MARR') . '</span>'
@@ -490,7 +612,9 @@ $card_close = '</table>';
. '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>' . '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>'
. ' <span style="color:' . $palette['ink2'] . ';font-weight:300;">— ' . e($anniversary_label($age)) . '</span>' . ' <span style="color:' . $palette['ink2'] . ';font-weight:300;">— ' . e($anniversary_label($age)) . '</span>'
. '</span>'; . '</span>';
echo $event_row($fact, $body, $palette['marr']); $show_dot = ($fact->jd ?? 0) !== $prev_jd;
$prev_jd = $fact->jd ?? 0;
echo $event_row($fact, $body, $i === 0, $i === $total - 1, $show_dot);
?> ?>
<?php endforeach ?> <?php endforeach ?>
<?= $card_close ?> <?= $card_close ?>
@@ -528,7 +652,8 @@ $card_close = '</table>';
</p> </p>
<?php if ($detailed !== []) : ?> <?php if ($detailed !== []) : ?>
<?= $card_open ?> <?= $card_open ?>
<?php foreach ($detailed as $fact) : ?> <?php $prev_jd = null; $total = count($detailed); ?>
<?php foreach ($detailed as $i => $fact) : ?>
<?php <?php
$kind = $event_kind($fact); $kind = $event_kind($fact);
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon($kind) . '</span>' $body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon($kind) . '</span>'
@@ -536,7 +661,9 @@ $card_close = '</table>';
. '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>' . '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>'
. ' <span style="color:' . $palette['ink2'] . ';font-weight:300;">— ' . e($fact->label()) . '</span>' . ' <span style="color:' . $palette['ink2'] . ';font-weight:300;">— ' . e($fact->label()) . '</span>'
. '</span>'; . '</span>';
echo $event_row($fact, $body, $event_color($kind)); $show_dot = ($fact->jd ?? 0) !== $prev_jd;
$prev_jd = $fact->jd ?? 0;
echo $event_row($fact, $body, $i === 0, $i === $total - 1, $show_dot);
?> ?>
<?php endforeach ?> <?php endforeach ?>
<?= $card_close ?> <?= $card_close ?>