From c177873618d98dd318ee9789a50a81e1e54ebc9a Mon Sep 17 00:00:00 2001 From: Andrey Bakhvalov Date: Sun, 25 Oct 2026 19:50:24 +0100 Subject: [PATCH] fix(chart): fit long life dates into the person box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dates like «около 1707 — между 1757 и 1758» ran out of the box even as years only. They now break after the dash onto a second line under a one-line name; under a two-line name the birth stays with an ellipsis. The card and the accessible name keep the full dates. Co-Authored-By: Claude Opus 5.5 --- src/chart/renderer.ts | 25 +++++++++++++++++++------ src/chart/text.test.ts | 22 +++++++++++++++++++++- src/chart/text.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/chart/renderer.ts b/src/chart/renderer.ts index e3d17a0..d005e9f 100644 --- a/src/chart/renderer.ts +++ b/src/chart/renderer.ts @@ -17,7 +17,7 @@ import type {ChartSettings, IndiView, LoadedTree} from '../gedcom/types'; import {displayName, initials, lifeSpan} from '../ui/format'; import {familyBox, marriageOrder} from './family'; import {cropBox, photoOf, type Photo} from './photo'; -import {wrapName, type Measure} from './text'; +import {wrapLife, wrapName, type Measure} from './text'; import { birthYear, decadeFill, @@ -44,6 +44,7 @@ const NAME_TOP = PAD + PHOTO + 10 + 14; // baseline of the first name line const NAME_LINE = 18.5; /** Life dates are set at 13px regular; `measure` is the 15px medium name font. */ const LIFE_SCALE = 0.85; +const LIFE_LINE = 16; /** Numbers the portraits' clip paths: each needs an id of its own. */ let clipCount = 0; @@ -293,11 +294,23 @@ export class GedviewRenderer extends CompositeRenderer implements Renderer { if (tags.length) g.attr('aria-label', [g.attr('aria-label'), ...tags].join(', ')); if (life) { - g.append('text') - .attr('class', 'gv-life') - .attr('x', PAD) - .attr('y', NAME_TOP + (lines.length - 1) * NAME_LINE + 3 + 16) - .text(` ${life}`); + // Dates still too wide take a second line under a one-line name. + const lifeLines = wrapLife( + life, + maxText / LIFE_SCALE, + this.ctx.measure, + lines.length === 1 ? 2 : 1, + ); + lifeLines.forEach((line, i) => { + g.append('text') + .attr('class', 'gv-life') + .attr('x', PAD) + .attr( + 'y', + NAME_TOP + (lines.length - 1) * NAME_LINE + 19 + i * LIFE_LINE, + ) + .text(` ${line}`); + }); } // Last in the box, so the text reads in the order of the accessible // name; drawn in the portrait row so the card keeps its height. diff --git a/src/chart/text.test.ts b/src/chart/text.test.ts index dcdc2c8..0548e88 100644 --- a/src/chart/text.test.ts +++ b/src/chart/text.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {wrapName} from './text'; +import {wrapLife, wrapName} from './text'; // Every character is 10 px wide. const measure = (s: string) => s.length * 10; @@ -34,3 +34,23 @@ describe('wrapName', () => { expect(wrapName('', 100, measure)).toEqual(['']); }); }); + +describe('wrapLife', () => { + // As joinRange writes it: a no-break space before the dash. + const life = 'около 1707\u00a0— между 1757 и 1758'; + + it('keeps dates that fit on one line', () => { + expect(wrapLife('1858—1921', 200, measure, 2)).toEqual(['1858—1921']); + }); + + it('breaks long dates after the dash', () => { + expect(wrapLife(life, 180, measure, 2)).toEqual([ + 'около 1707\u00a0—', + 'между 1757 и 1758', + ]); + }); + + it('keeps the birth and an ellipsis when one line is left', () => { + expect(wrapLife(life, 180, measure, 1)).toEqual(['около 1707\u00a0— …']); + }); +}); diff --git a/src/chart/text.ts b/src/chart/text.ts index 376ed3f..2b503f9 100644 --- a/src/chart/text.ts +++ b/src/chart/text.ts @@ -82,3 +82,31 @@ export function wrapName( } return lines.length ? lines : ['']; } + +/** The dash between birth and death when either side has spaces: a + * no-break space before it, a space after (see joinRange). */ +const DASH = /\u00a0(\S+) /; + +/** + * Life dates in at most `maxLines` lines of at most `max` pixels. Too long + * for a line, they break after the dash, birth above and death below; with + * one line only, the birth stays and an ellipsis stands for the rest. + */ +export function wrapLife( + life: string, + max: number, + measure: Measure, + maxLines: number, +): string[] { + if (measure(life) <= max) return [life]; + const m = DASH.exec(life); + if (m) { + const birth = `${life.slice(0, m.index)}\u00a0${m[1]}`; + const death = life.slice(m.index + m[0].length); + if (maxLines > 1 && measure(birth) <= max && measure(death) <= max) + return [birth, death]; + if (maxLines === 1 && measure(`${birth} ${ELLIPSIS}`) <= max) + return [`${birth} ${ELLIPSIS}`]; + } + return wrapName(life, max, measure, maxLines); +}