Skip to content

Commit 681af7b

Browse files
committed
feat: fan chart full-circle expansion with auto-scaling text
- Progressive arc sweep from 180° (semi-circle) to 360° (full circle) as generations increase - Curved textPath for gen 1-3, radial text for gen 4+ (first name only) - Auto-scale font to fit full name without truncation, CSS scale for sub-pixel rendering - Root person name word-wraps into multiple lines inside center circle - Support up to 10 generations with text at all depths (visible on zoom) - Fix text orientation: letter tops always face outward from center
1 parent 8fff4f2 commit 681af7b

4 files changed

Lines changed: 308 additions & 104 deletions

File tree

.changelog/NEXT.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Added
44

5+
- Fan chart: progressive expansion from semi-circle to full circle as generations increase (paternal left, maternal right)
6+
- Fan chart: curved text along arcs (gen 1-3) and radial text (gen 4+) with auto-scaling to fit names without truncation
7+
- Fan chart: root person name auto-wraps into multiple lines to fit center circle
8+
- Fan chart: support up to 10 generations (was 6) with text at all depths visible on zoom
59
- Production serving via PM2: `npm start` builds all packages then starts PM2
610
- Express serves built client UI from `client/dist` with SPA catch-all routing
711
- `scripts/dev-start.js` for clean PM2-based dev startup (replaces concurrently)

client/src/components/ancestry-tree/AncestryTreeView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export function AncestryTreeView() {
8888
// Load more generations for views that benefit from deeper data
8989
const generations = viewMode === 'columns' ? 10 :
9090
viewMode === 'horizontal' ? 5 :
91-
viewMode === 'fan' ? 6 : 8;
91+
viewMode === 'fan' ? 10 : 8;
9292

9393
api.getAncestryTree(dbId, rootId, generations)
9494
.then(data => setTreeData(data))

client/src/components/ancestry-tree/utils/arcGenerator.ts

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -209,14 +209,32 @@ export function calculateFontSize(
209209
outerRadius: number,
210210
arcAngle: number
211211
): number {
212-
const arcLength = ((outerRadius + innerRadius) / 2) * arcAngle;
212+
const avgRadius = (outerRadius + innerRadius) / 2;
213+
const arcLength = avgRadius * arcAngle;
213214
const arcHeight = outerRadius - innerRadius;
214215

215-
// Use the smaller dimension to constrain font size
216-
const constraint = Math.min(arcLength / 10, arcHeight / 2);
216+
// Height constrained by radial band, must fit ≥2 chars along arc
217+
const heightConstraint = arcHeight * 0.55;
218+
const lengthConstraint = arcLength / 2;
219+
const constraint = Math.min(heightConstraint, lengthConstraint);
220+
221+
// No minimum — CSS scale handles sub-pixel rendering for zoom
222+
return Math.min(14, constraint);
223+
}
217224

218-
// Clamp between reasonable bounds
219-
return Math.max(8, Math.min(14, constraint));
225+
/**
226+
* Calculate the font size needed to fit a name without truncation.
227+
* For radial text: name must fit in the radial height.
228+
* For arc text: name must fit along the arc length.
229+
*/
230+
export function fitFontSizeToName(
231+
name: string,
232+
availableSpace: number,
233+
maxFontSize: number
234+
): number {
235+
// Estimate: each char is ~0.55em wide
236+
const neededSize = availableSpace / (name.length * 0.55);
237+
return Math.min(maxFontSize, neededSize);
220238
}
221239

222240
/**
@@ -256,6 +274,57 @@ export function truncateNameForArc(
256274
return name.slice(0, maxLength - 1) + '\u2026';
257275
}
258276

277+
/**
278+
* Check if an arc is in the bottom half of the circle (needs text flip).
279+
* In SVG coords (y-down): angles 0°-180° are the bottom half.
280+
*/
281+
export function isArcInBottomHalf(startAngle: number, endAngle: number): boolean {
282+
const midAngleDeg = (((startAngle + endAngle) / 2) * 180) / Math.PI;
283+
const normalized = ((midAngleDeg % 360) + 360) % 360;
284+
return normalized > 0 && normalized < 180;
285+
}
286+
287+
/**
288+
* Generate a curved text path along an arc segment.
289+
* For bottom-half arcs (flipped), reverses path direction for readability.
290+
* Caller should offset the radius for flipped arcs to keep text outward-facing.
291+
*/
292+
export function generateTextArcPath(
293+
cx: number,
294+
cy: number,
295+
radius: number,
296+
startAngle: number,
297+
endAngle: number,
298+
flipped: boolean
299+
): string {
300+
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
301+
302+
if (flipped) {
303+
// Reverse direction so text reads correctly in the bottom half
304+
const start = polarToCartesian(cx, cy, radius, endAngle);
305+
const end = polarToCartesian(cx, cy, radius, startAngle);
306+
return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArc} 0 ${end.x} ${end.y}`;
307+
}
308+
309+
const start = polarToCartesian(cx, cy, radius, startAngle);
310+
const end = polarToCartesian(cx, cy, radius, endAngle);
311+
return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArc} 1 ${end.x} ${end.y}`;
312+
}
313+
314+
/**
315+
* Get rotation angle for radial text (reading from center outward).
316+
* Flips text on the left half so it's always readable.
317+
* Input: angle in degrees (0=right, 90=down, 180=left, 270=up).
318+
*/
319+
export function getRadialTextRotation(angleDeg: number): number {
320+
const a = ((angleDeg % 360) + 360) % 360;
321+
// Left half: flip so text reads inward (visually right-side-up)
322+
if (a > 90 && a < 270) {
323+
return a + 180;
324+
}
325+
return a;
326+
}
327+
259328
/**
260329
* Generate a root circle path for the center of the fan chart
261330
*/

0 commit comments

Comments
 (0)