8 Commits

Author SHA1 Message Date
justin 7043532c3b Merge pull request 'Cleanup tool: mark deceased by a child's birth year' (#254) from cleanup-deceased-by-child into main
build-backend / build (push) Successful in 29s
build-frontend / build (push) Successful in 1m29s
2026-06-11 11:08:52 -04:00
justin 1340d1957f Cleanup tool: "mark deceased by a child's birth year" rule
Adds a preview/apply rule to the Cleanup tool for parents who have NO birth date
of their own (so the existing born-on-or-before rule can't reach them) but who
have a child born long ago — they're necessarily deceased. This is the gap that
left ~56 parents in the Paul tree as "unknown".

- cleanup_service.preview_deceased_by_child(year): parents of any child born
  on/before the cutoff, excluding already-deceased; returns child_birth_year.
- GET /trees/{id}/cleanup/deceased-by-child?born_on_or_before=1900. Apply reuses
  the existing POST .../cleanup/deceased (same audited mark-deceased path).
- Frontend: a new card in the Cleanup tool (year input → preview → select →
  apply), preview-first like the rest of the tool.

Test covers preview (finds the no-birthdate parent of a pre-cutoff child,
excludes modern-child parents), child_birth_year, apply, and re-preview drop.
Suite 106 passing.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 11:08:50 -04:00
justin e24a7cfcc9 Merge pull request 'Tree cards: living/unset-sex people render gray, not blue' (#253) from living-and-unset-cards-gray into main
build-frontend / build (push) Successful in 1m28s
2026-06-11 10:37:27 -04:00
justin 07944e329e Tree cards: render unset-sex / redacted "Living person" in gray, not blue
The chart mapped gender as `=== "female" ? "F" : "M"`, so anything non-female —
including null — became "M" (blue). On the public site, redacted living people
(whose gender the privacy engine nulls) all showed blue regardless of real sex,
and anywhere a person's sex was simply unset they also showed blue (misleading).

Map male→"M", female→"F", and everything else→null, which family-chart renders
as `card-genderless`. So living/redacted people render gray (and never imply a
sex), and unset-sex people render gray instead of defaulting to male/blue.
Applied to both the member tree (tree/page.tsx) and the public chart
(public-tree-chart.tsx), which share chart.css. Also bumped the genderless color
from the library's washed-out `lightgray` to a warm mid-gray that matches the
muted male/female tones and the brand palette.

Privacy note: `_redact` already nulls gender, so this is purely the client color
mapping — no sex leak, just a correct neutral rendering.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 10:37:25 -04:00
justin a33a88e558 Merge pull request 'docs: note the spouse-layout fix is upstreamed' (#252) from docs-upstream-spouse-fix into main 2026-06-11 09:33:21 -04:00
justin fe8349819f docs: note the spouse-layout fix is upstreamed (donatso/family-chart#105)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 09:33:19 -04:00
justin e745fb5d4d Merge pull request 'Move cardToMiddle fix into the family-chart patch (+ document patches)' (#251) from family-chart-patch-cardtomiddle into main
build-frontend / build (push) Successful in 1m27s
2026-06-11 09:21:32 -04:00
justin e0573e6be2 Move cardToMiddle vertical-centering fix into the family-chart patch
Fold the fly-to vertical-centering fix into our patch-package patch (alongside
the existing spouse-layout fix) instead of compensating in app code, and revert
the in-app workaround so the two don't double-correct.

- patches/family-chart+0.9.0.patch: cardToMiddle now scales datum.y by the zoom
  k in both dist builds (.js + .esm.js), matching datum.x. Verified the patch
  applies cleanly (patch-package --error-on-fail).
- tree/page.tsx: the cardToMiddle caller passes raw y again (the patched library
  does the scaling now); pre-scaling here too would double-correct. Behavior is
  identical to the previous in-app fix — both center the node exactly.
- CLAUDE.md: documents the two family-chart patches, how to regenerate them, and
  that both should be upstreamed. The cardToMiddle fix is submitted upstream
  (donatso/family-chart#103, issue #102); the spouse-layout fix is a TODO.

The frontend Dockerfile already COPYs patches/ before npm ci, so the fix is in
the production build.

Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-11 09:21:30 -04:00
12 changed files with 395 additions and 10 deletions
+17
View File
@@ -77,6 +77,23 @@ Don't get ahead of the phases. GEDCOM and the assistant's propose-diff foundatio
- **Privacy/assistant/hint code gets extra care** — these are the areas where bugs do real harm. Prefer a design note before a large change.
- **No secrets in the repo.** Config via env; provide `.env.example` with placeholders.
## Patched dependencies (family-chart)
The tree view uses **family-chart** (d3-based). Two adjustments live in the repo:
- **CSS is vendored** at `frontend/app/trees/[id]/tree/chart.css` — the package blocks its CSS subpath export, so we copy it in.
- **The library is patched** via `patch-package` (`frontend/patches/family-chart+0.9.0.patch`, applied by the `postinstall` hook; the backend/frontend Dockerfiles `COPY patches` before install). Both hunks touch `dist/family-chart.js` **and** `dist/family-chart.esm.js` (the app loads the `esm` build). Current fixes:
1. **Spouse-centering layout** (`setupSpouses` / `sortChildrenWithSpouses`) — center a person between two spouses with children under the correct pair.
2. **`cardToMiddle` vertical centering** — the lib scaled `datum.x` by the zoom factor `k` but not `datum.y`, so "fly to a node" drifted vertically at any zoom ≠ 1; we add the missing `* k`.
To change a patch: edit the file(s) under `node_modules/family-chart/dist/`, then `cd frontend && npx patch-package family-chart` to regenerate, and verify with `npx patch-package --error-on-fail`.
**Upstreamed.** Both are general library bugfixes, not app-specific, and are submitted upstream:
- `cardToMiddle` vertical centering — **donatso/family-chart#103** (issue **#102**).
- Multi-spouse centered layout — **donatso/family-chart#105** (issue **#104**).
If either is merged + released, bump `family-chart`, drop the corresponding patch hunk, **and** remove any in-app compensation (e.g. the `cardToMiddle` caller in `tree/page.tsx` passes raw `y` precisely because the patch fixes it — pre-scaling there too would double-correct). Until then, keep the patch.
## License & contribution terms
Provenance is **source-available** under **BUSL-1.1** (see [LICENSE](LICENSE)): free for personal/family/non-commercial use, no third-party commercial hosting, and each release converts to **AGPL-3.0** four years after it ships. The DCO sign-off keeps the licensing chain clean so the maintainer can manage that conversion and a possible future hosted offering. Don't add code under an incompatible license, and don't vendor dependencies whose licenses conflict with eventual AGPL distribution.
+19
View File
@@ -6,6 +6,7 @@ from app.api.deps import CurrentUser, SessionDep
from app.schemas.cleanup import (
CleanupResult,
DeceasedApply,
DeceasedByChildCandidate,
DeceasedCandidate,
GenderApply,
GenderProposal,
@@ -31,6 +32,24 @@ async def preview_deceased(
return [DeceasedCandidate(**r) for r in rows]
@router.get(
"/{tree_id}/cleanup/deceased-by-child", response_model=list[DeceasedByChildCandidate]
)
async def preview_deceased_by_child(
tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
born_on_or_before: int = 1900,
) -> list[DeceasedByChildCandidate]:
"""People with a child born on/before the cutoff — necessarily deceased even
when their own birth date is missing. Apply via POST .../cleanup/deceased."""
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rows = await cleanup_service.preview_deceased_by_child(
session, actor=current, tree=tree, year=born_on_or_before
)
return [DeceasedByChildCandidate(**r) for r in rows]
@router.post("/{tree_id}/cleanup/deceased", response_model=CleanupResult)
async def apply_deceased(
tree_id: uuid.UUID, data: DeceasedApply, session: SessionDep, current: CurrentUser
+6
View File
@@ -9,6 +9,12 @@ class DeceasedCandidate(BaseModel):
birth_year: int
class DeceasedByChildCandidate(BaseModel):
person_id: uuid.UUID
name: str
child_birth_year: int
class DeceasedApply(BaseModel):
person_ids: list[uuid.UUID]
+45
View File
@@ -133,6 +133,51 @@ async def apply_deceased(
return len(persons)
# ---- 1b. Mark deceased by a CHILD's birth year -------------------------------------
# For parents whose own birth date is missing (so the birth-year rule can't reach
# them) but who have a child born long ago — they're necessarily deceased. Applies
# through the same apply_deceased() path.
async def preview_deceased_by_child(
session: AsyncSession, *, actor: User, tree: Tree, year: int
) -> list[dict]:
await _require_editor(session, actor=actor, tree=tree)
names = await _primary_name_by_person(session, tree.id)
years = await _birth_year_by_person(session, tree.id)
rels = (
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id,
Relationship.deleted_at.is_(None),
Relationship.type == RelationshipType.parent_child,
)
)
).scalars().all()
# parent id -> earliest child birth year, among children born on/before `year`.
earliest_child: dict[uuid.UUID, int] = {}
for r in rels:
cy = years.get(r.person_to_id) # the child's birth year
if cy is None or cy > year:
continue
if r.person_from_id not in earliest_child or cy < earliest_child[r.person_from_id]:
earliest_child[r.person_from_id] = cy
persons = {p.id: p for p in await _persons(session, tree.id)}
out: list[dict] = []
for parent_id, cy in earliest_child.items():
p = persons.get(parent_id)
if p is None or p.is_living is False: # gone or already deceased
continue
out.append(
{
"person_id": str(parent_id),
"name": _display(names.get(parent_id)),
"child_birth_year": cy,
}
)
out.sort(key=lambda r: r["child_birth_year"])
return out
# ---- 2. Re-derive gender from a source GEDCOM (matches by name) ----------------------
async def preview_gender(
+47
View File
@@ -51,6 +51,53 @@ async def test_deceased_preview_and_apply(client):
assert old not in [r["person_id"] for r in prev2]
async def test_deceased_by_child_preview_and_apply(client):
h, tid = await _tree(client, "cl-decchild@example.com")
# Parent with NO birth date (the gap the birth-year rule can't reach).
parent = await _person(client, h, tid, "Gesche", "Frerking")
child = await _person(client, h, tid, "Kindt", "Frerking")
await _birth(client, h, tid, child, 1880) # child born before the cutoff
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "parent_child", "person_from_id": parent, "person_to_id": child},
headers=h,
)
# A parent of a modern child must NOT be flagged.
p_modern = await _person(client, h, tid, "Modern", "Parent")
c_modern = await _person(client, h, tid, "Kid", "Parent")
await _birth(client, h, tid, c_modern, 1990)
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "parent_child", "person_from_id": p_modern, "person_to_id": c_modern},
headers=h,
)
prev = (
await client.get(
f"/api/v1/trees/{tid}/cleanup/deceased-by-child?born_on_or_before=1900", headers=h
)
).json()
ids = [r["person_id"] for r in prev]
assert parent in ids and p_modern not in ids
assert next(r for r in prev if r["person_id"] == parent)["child_birth_year"] == 1880
# Apply through the shared deceased endpoint.
r = await client.post(
f"/api/v1/trees/{tid}/cleanup/deceased", json={"person_ids": [parent]}, headers=h
)
assert r.status_code == 200 and r.json()["updated"] == 1
assert (
await client.get(f"/api/v1/trees/{tid}/persons/{parent}", headers=h)
).json()["is_living"] is False
# Re-preview drops the now-deceased parent.
prev2 = (
await client.get(
f"/api/v1/trees/{tid}/cleanup/deceased-by-child?born_on_or_before=1900", headers=h
)
).json()
assert parent not in [r["person_id"] for r in prev2]
async def test_gender_from_spouse_preview_and_apply(client):
h, tid = await _tree(client, "cl-spouse@example.com")
husband = (
+77
View File
@@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Deceased = components["schemas"]["DeceasedCandidate"];
type DeceasedByChild = components["schemas"]["DeceasedByChildCandidate"];
type GenderProp = components["schemas"]["GenderProposal"];
type NameIssue = components["schemas"]["NameIssue"];
type Person = components["schemas"]["PersonRead"];
@@ -31,6 +32,12 @@ export default function CleanupPage() {
const [decSel, setDecSel] = useState<Set<string>>(new Set());
const [decMsg, setDecMsg] = useState<string | null>(null);
// 1b) Deceased by a child's birth year (for parents with no birth date)
const [childYear, setChildYear] = useState(1900);
const [decByChild, setDecByChild] = useState<DeceasedByChild[] | null>(null);
const [dbcSel, setDbcSel] = useState<Set<string>>(new Set());
const [dbcMsg, setDbcMsg] = useState<string | null>(null);
// 2) Gender from source GEDCOM
const [gender, setGender] = useState<GenderProp[] | null>(null);
const [genSel, setGenSel] = useState<Set<string>>(new Set());
@@ -63,6 +70,23 @@ export default function CleanupPage() {
setDeceased(null);
}
async function previewDeceasedByChild() {
setDbcMsg(null);
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/deceased-by-child", {
params: { path: { tree_id: treeId }, query: { born_on_or_before: childYear } },
});
setDecByChild(data ?? []);
setDbcSel(new Set((data ?? []).map((d) => d.person_id)));
}
async function applyDeceasedByChild() {
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/deceased", {
params: { path: { tree_id: treeId } },
body: { person_ids: [...dbcSel] },
});
setDbcMsg(`Marked ${data?.updated ?? 0} people deceased.`);
setDecByChild(null);
}
async function previewGender(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (genFile.current) genFile.current.value = "";
@@ -231,6 +255,59 @@ export default function CleanupPage() {
</CardContent>
</Card>
{/* 1b) Deceased by a child's birth year */}
<Card>
<CardHeader>
<CardTitle className="text-base">Mark deceased by a childs birth year</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[var(--muted)]">
Catches parents who have <strong>no birth date of their own</strong> (so the rule
above cant reach them) but who have a child born long ago theyre necessarily
deceased.
</p>
<div className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-[var(--muted)]">Has a child born on or before</span>
<Input
type="number"
className="w-28"
value={childYear}
onChange={(e) => setChildYear(Number(e.target.value))}
/>
</label>
<Button variant="outline" onClick={previewDeceasedByChild}>
Preview
</Button>
</div>
{dbcMsg && <p className="text-sm text-bronze">{dbcMsg}</p>}
{decByChild && (
<div className="space-y-2">
<p className="text-sm text-[var(--muted)]">
{decByChild.length} people with a child born {childYear} (not already marked
deceased).
</p>
<ul className="max-h-64 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
{decByChild.map((d) => (
<li key={d.person_id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
<input
type="checkbox"
checked={dbcSel.has(d.person_id)}
onChange={() => toggle(dbcSel, d.person_id, setDbcSel)}
/>
<span className="flex-1">{d.name}</span>
<span className="text-xs text-[var(--muted)]">child b. {d.child_birth_year}</span>
</li>
))}
</ul>
{decByChild.length > 0 && (
<Button onClick={applyDeceasedByChild}>Mark {dbcSel.size} deceased</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* 2) Gender from source */}
<Card>
<CardHeader>
+4 -1
View File
@@ -1,7 +1,10 @@
.f3 {
--female-color: rgb(196, 138, 146);
--male-color: rgb(120, 159, 172);
--genderless-color: lightgray;
/* Warm mid-gray for unset-sex / redacted "Living person" cards — matches the
muted male/female tone weight and the brand palette, instead of the library's
washed-out lightgray. */
--genderless-color: rgb(156, 150, 143);
--background-color: rgb(33, 33, 33);
--text-color: #fff;
+8 -6
View File
@@ -185,7 +185,9 @@ export default function TreePage() {
"first name": fn || "Unnamed",
"last name": ln,
birthday: years.get(pp.id) ?? "",
gender: pp.gender === "female" ? "F" : "M",
// male → blue, female → pink, unset → genderless (gray). Unset sex no
// longer defaults to male/blue (which was misleading).
gender: pp.gender === "male" ? "M" : pp.gender === "female" ? "F" : null,
},
rels: {
spouses: ok(partnersOf(pp.id), pp.id),
@@ -315,12 +317,12 @@ export default function TreePage() {
try {
const rect = svg.getBoundingClientRect();
const scale = handlers.getCurrentZoom ? handlers.getCurrentZoom(svg).k : 1;
// family-chart's cardToMiddle scales datum.x by the zoom but NOT
// datum.y (a library bug), so vertical centering is only correct at
// scale 1 and drifts by datum.y·(k1) otherwise — landing "below the
// tree". Pre-multiply y by the scale to cancel the missing ·k.
// cardToMiddle centers the datum at the current zoom. (Its vertical
// centering at non-1 zoom is fixed in our family-chart patch — see
// CLAUDE.md / upstream PR donatso/family-chart#103 — so we pass the
// raw y; do NOT pre-scale it here or it double-corrects.)
handlers.cardToMiddle({
datum: { x: xy.x, y: xy.y * scale },
datum: xy,
svg,
svg_dim: { width: rect.width, height: rect.height },
scale,
+4 -1
View File
@@ -120,7 +120,10 @@ export function PublicTreeChart({
"first name": fn || "Unnamed",
"last name": ln,
birthday: years.get(pp.id) ?? "",
gender: pp.gender === "female" ? "F" : "M",
// male → blue, female → pink, unset/redacted → genderless (gray).
// Redacted living people have null gender, so they render gray rather
// than defaulting to male/blue (and never imply a real person's sex).
gender: pp.gender === "male" ? "M" : pp.gender === "female" ? "F" : null,
},
rels: {
spouses: ok(partnersOf(pp.id), pp.id),
+66
View File
@@ -718,6 +718,27 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/cleanup/deceased-by-child": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Preview Deceased By Child
* @description People with a child born on/before the cutoff — necessarily deceased even
* when their own birth date is missing. Apply via POST .../cleanup/deceased.
*/
get: operations["preview_deceased_by_child_api_v1_trees__tree_id__cleanup_deceased_by_child_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/cleanup/gender/preview": {
parameters: {
query?: never;
@@ -1286,6 +1307,18 @@ export interface components {
/** Person Ids */
person_ids: string[];
};
/** DeceasedByChildCandidate */
DeceasedByChildCandidate: {
/**
* Person Id
* Format: uuid
*/
person_id: string;
/** Name */
name: string;
/** Child Birth Year */
child_birth_year: number;
};
/** DeceasedCandidate */
DeceasedCandidate: {
/**
@@ -4013,6 +4046,39 @@ export interface operations {
};
};
};
preview_deceased_by_child_api_v1_trees__tree_id__cleanup_deceased_by_child_get: {
parameters: {
query?: {
born_on_or_before?: number;
};
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["DeceasedByChildCandidate"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
preview_gender_api_v1_trees__tree_id__cleanup_gender_preview_post: {
parameters: {
query?: never;
+82
View File
@@ -2873,6 +2873,64 @@
}
}
},
"/api/v1/trees/{tree_id}/cleanup/deceased-by-child": {
"get": {
"tags": [
"cleanup"
],
"summary": "Preview Deceased By Child",
"description": "People with a child born on/before the cutoff \u2014 necessarily deceased even\nwhen their own birth date is missing. Apply via POST .../cleanup/deceased.",
"operationId": "preview_deceased_by_child_api_v1_trees__tree_id__cleanup_deceased_by_child_get",
"parameters": [
{
"name": "tree_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid",
"title": "Tree Id"
}
},
{
"name": "born_on_or_before",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 1900,
"title": "Born On Or Before"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DeceasedByChildCandidate"
},
"title": "Response Preview Deceased By Child Api V1 Trees Tree Id Cleanup Deceased By Child Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/cleanup/gender/preview": {
"post": {
"tags": [
@@ -4897,6 +4955,30 @@
],
"title": "DeceasedApply"
},
"DeceasedByChildCandidate": {
"properties": {
"person_id": {
"type": "string",
"format": "uuid",
"title": "Person Id"
},
"name": {
"type": "string",
"title": "Name"
},
"child_birth_year": {
"type": "integer",
"title": "Child Birth Year"
}
},
"type": "object",
"required": [
"person_id",
"name",
"child_birth_year"
],
"title": "DeceasedByChildCandidate"
},
"DeceasedCandidate": {
"properties": {
"person_id": {
+20 -2
View File
@@ -1,5 +1,5 @@
diff --git a/node_modules/family-chart/dist/family-chart.esm.js b/node_modules/family-chart/dist/family-chart.esm.js
index 3867be0..560c99e 100644
index 3867be0..656fafa 100644
--- a/node_modules/family-chart/dist/family-chart.esm.js
+++ b/node_modules/family-chart/dist/family-chart.esm.js
@@ -10,10 +10,10 @@ function sortChildrenWithSpouses(children, datum, data) {
@@ -61,8 +61,17 @@ index 3867be0..560c99e 100644
if (!d.spouses)
d.spouses = [];
d.spouses.push(spouse);
@@ -1073,7 +1091,7 @@ function calculateTreeFit(svg_dim, tree_dim) {
return { k, x, y };
}
function cardToMiddle({ datum, svg, svg_dim, scale, transition_time }) {
- const k = scale || 1, x = svg_dim.width / 2 - datum.x * k, y = svg_dim.height / 2 - datum.y, t = { k, x: x / k, y: y / k };
+ const k = scale || 1, x = svg_dim.width / 2 - datum.x * k, y = svg_dim.height / 2 - datum.y * k, t = { k, x: x / k, y: y / k };
positionTree({ t, svg, transition_time });
}
function manualZoom({ amount, svg, transition_time = 500 }) {
diff --git a/node_modules/family-chart/dist/family-chart.js b/node_modules/family-chart/dist/family-chart.js
index 1c750d4..47efcc2 100644
index 1c750d4..edeb804 100644
--- a/node_modules/family-chart/dist/family-chart.js
+++ b/node_modules/family-chart/dist/family-chart.js
@@ -33,10 +33,9 @@
@@ -116,3 +125,12 @@ index 1c750d4..47efcc2 100644
if (!d.spouses)
d.spouses = [];
d.spouses.push(spouse);
@@ -1096,7 +1106,7 @@
return { k, x, y };
}
function cardToMiddle({ datum, svg, svg_dim, scale, transition_time }) {
- const k = scale || 1, x = svg_dim.width / 2 - datum.x * k, y = svg_dim.height / 2 - datum.y, t = { k, x: x / k, y: y / k };
+ const k = scale || 1, x = svg_dim.width / 2 - datum.x * k, y = svg_dim.height / 2 - datum.y * k, t = { k, x: x / k, y: y / k };
positionTree({ t, svg, transition_time });
}
function manualZoom({ amount, svg, transition_time = 500 }) {