family-tree-svg 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,188 @@
1
+ # family-tree-svg
2
+
3
+ Pure organic SVG family tree visualization from JSON data — with leaves, branches, trunk, customizable colors, pan/zoom, and export.
4
+
5
+ ## Features
6
+
7
+ - ðŸŒģ **Organic Tree Structure** — Calligraphic trunk, smooth curving branches, and leaf-shaped nodes
8
+ - ðŸŽĻ **Fully Customizable Colors** — Set custom hex colors for leaves, branches, and trunk
9
+ - ðŸ–ąïļ **Pan & Zoom** — Smooth drag-to-pan, mouse wheel zoom, and mobile touch support
10
+ - ➕➖ **Collapse/Expand** — Interactive branch collapse/expand buttons
11
+ - ðŸ‘Ŧ **Partner Support** — Spouses shown as attached companion leaves
12
+ - ⚡ **Auto-Collapse** — Large trees (500+ members) automatically collapse deeper branches for clean rendering
13
+ - ðŸ“Ī **SVG Export** — Download your tree as a clean vector SVG
14
+ - ðŸŠķ **Zero UI Clutter** — Pure tree component without unnecessary toolbars or widgets
15
+
16
+ ---
17
+
18
+ ## Installation
19
+
20
+ ### Via Script Tag (Direct / CDN)
21
+
22
+ ```html
23
+ <script src="path/to/family-tree-svg/src/family-tree-svg.js"></script>
24
+ ```
25
+
26
+ ### Via npm
27
+
28
+ ```bash
29
+ npm install family-tree-svg
30
+ ```
31
+
32
+ ```javascript
33
+ // CommonJS
34
+ const FamilyTreeSVG = require('family-tree-svg');
35
+
36
+ // ES Module
37
+ import FamilyTreeSVG from 'family-tree-svg';
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Quick Start
43
+
44
+ ```html
45
+ <!DOCTYPE html>
46
+ <html>
47
+ <head>
48
+ <style>
49
+ body { margin: 0; }
50
+ #tree-container { width: 100vw; height: 100vh; }
51
+ </style>
52
+ </head>
53
+ <body>
54
+ <div id="tree-container"></div>
55
+
56
+ <script src="src/family-tree-svg.js"></script>
57
+ <script>
58
+ const familyData = {
59
+ full_name: "Ahmed Khan",
60
+ family_name: "Khan",
61
+ partners: [{ full_name: "Fatima Ahmed", family_name: "Khan" }],
62
+ children: [
63
+ {
64
+ full_name: "Yusuf Ahmed",
65
+ family_name: "Khan",
66
+ partners: [{ full_name: "Ayesha Yusuf", family_name: "Khan" }],
67
+ children: [
68
+ { full_name: "Hamza Yusuf", family_name: "Khan", partners: [], children: [] }
69
+ ]
70
+ }
71
+ ]
72
+ };
73
+
74
+ // Initialize the family tree with custom colors
75
+ const tree = FamilyTreeSVG.create('#tree-container', familyData, {
76
+ leafColor: '#17361a', // Leaf color (e.g. green)
77
+ branchColor: '#3a1f13', // Branch color (e.g. brown)
78
+ trunkColor: '#2d1607' // Trunk color (e.g. dark brown)
79
+ });
80
+ </script>
81
+ </body>
82
+ </html>
83
+ ```
84
+
85
+ ---
86
+
87
+ ## JSON Data Format
88
+
89
+ Each person node represents a family member and can have nested children:
90
+
91
+ ```json
92
+ {
93
+ "full_name": "Person Name",
94
+ "family_name": "Surname",
95
+ "partners": [
96
+ { "full_name": "Spouse Name", "family_name": "Surname" }
97
+ ],
98
+ "children": [
99
+ {
100
+ "full_name": "Child Name",
101
+ "family_name": "Surname",
102
+ "partners": [],
103
+ "children": []
104
+ }
105
+ ]
106
+ }
107
+ ```
108
+
109
+ ### Properties:
110
+ - `full_name` (*string*, required): Name displayed on the leaf.
111
+ - `family_name` (*string*, optional): Surname.
112
+ - `partners` (*array*, optional): List of partners / spouses.
113
+ - `children` (*array*, optional): Array of child node objects (recursive).
114
+
115
+ You can also pass an array of root nodes for multiple trees: `[ { ... }, { ... } ]`.
116
+
117
+ ---
118
+
119
+ ## API Reference
120
+
121
+ ### `FamilyTreeSVG.create(container, data, options)`
122
+
123
+ Creates and returns a new `FamilyTreeSVG` instance.
124
+
125
+ | Parameter | Type | Description |
126
+ |---|---|---|
127
+ | `container` | `string \| HTMLElement` | CSS selector (e.g. `'#tree'`) or DOM element |
128
+ | `data` | `Object \| Array` | Root person object or array of roots |
129
+ | `options` | `Object` | Custom options (see below) |
130
+
131
+ #### Options
132
+
133
+ | Option | Type | Default | Description |
134
+ |---|---|---|---|
135
+ | `leafColor` | `string` | `'#17361a'` | Hex color for the leaves |
136
+ | `branchColor` | `string` | `'#3a1f13'` | Hex color for branches |
137
+ | `trunkColor` | `string` | `'#2d1607'` | Hex color for trunk |
138
+
139
+ ---
140
+
141
+ ### Instance Methods
142
+
143
+ #### `tree.fitView()`
144
+ Centers and fits the entire tree inside the container viewport.
145
+
146
+ #### `tree.setColors(leafColor, branchColor, trunkColor)`
147
+ Updates the color palette and re-renders the tree dynamically.
148
+
149
+ ```javascript
150
+ // Switch to autumn colors
151
+ tree.setColors('#dfa467', '#4d2b1a', '#3d1d11');
152
+
153
+ // Switch to cherry blossom colors
154
+ tree.setColors('#a35c6a', '#5e3b25', '#4a2c16');
155
+ ```
156
+
157
+ #### `tree.loadData(newData)`
158
+ Loads a new JSON dataset, recalculates layout, and fits view.
159
+
160
+ ```javascript
161
+ tree.loadData(newFamilyJson);
162
+ ```
163
+
164
+ #### `tree.exportSVG()`
165
+ Downloads the current tree as a standalone `.svg` vector file.
166
+
167
+ #### `tree.destroy()`
168
+ Cleans up event listeners and empties the container DOM.
169
+
170
+ ---
171
+
172
+ ## Color Recipes
173
+
174
+ Here are some suggested color combinations:
175
+
176
+ | Style | Leaf | Branch | Trunk |
177
+ |---|---|---|---|
178
+ | **Emerald Forest** | `#17361a` | `#3a1f13` | `#2d1607` |
179
+ | **Golden Autumn** | `#dfa467` | `#4d2b1a` | `#3d1d11` |
180
+ | **Cherry Blossom** | `#a35c6a` | `#5e3b25` | `#4a2c16` |
181
+ | **Winter Pine** | `#2a6b5c` | `#2d3e50` | `#1f2937` |
182
+ | **Sunset Clay** | `#d67b45` | `#4d2b1a` | `#2d1607` |
183
+
184
+ ---
185
+
186
+ ## License
187
+
188
+ MIT
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "family-tree-svg",
3
+ "version": "1.0.0",
4
+ "description": "Pure organic SVG family tree visualization from JSON data — with leaves, branches, trunk, customizable colors, pan/zoom, and export.",
5
+ "main": "src/family-tree-svg.js",
6
+ "files": [
7
+ "src/",
8
+ "README.md"
9
+ ],
10
+ "scripts": {
11
+ "demo": "npx -y serve demo"
12
+ },
13
+ "keywords": [
14
+ "family-tree",
15
+ "svg",
16
+ "tree",
17
+ "visualization",
18
+ "genealogy",
19
+ "organic",
20
+ "interactive"
21
+ ],
22
+ "author": "ammarcoderkube",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/ammarcoderkube/family-tree-package.git"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/ammarcoderkube/family-tree-package/issues"
29
+ },
30
+ "homepage": "https://github.com/ammarcoderkube/family-tree-package#readme",
31
+ "license": "MIT"
32
+ }
@@ -0,0 +1,824 @@
1
+ /**
2
+ * FamilyTreeSVG — Pure organic SVG family tree from JSON data
3
+ *
4
+ * Usage:
5
+ * FamilyTreeSVG.create('#container', familyData, {
6
+ * leafColor: '#17361a',
7
+ * branchColor: '#3a1f13',
8
+ * trunkColor: '#2d1607'
9
+ * });
10
+ */
11
+ (function (root, factory) {
12
+ if (typeof define === 'function' && define.amd) {
13
+ define([], factory);
14
+ } else if (typeof module === 'object' && module.exports) {
15
+ module.exports = factory();
16
+ } else {
17
+ root.FamilyTreeSVG = factory();
18
+ }
19
+ }(typeof self !== 'undefined' ? self : this, function () {
20
+ 'use strict';
21
+
22
+ // ═══════════════════════════════════════════════
23
+ // MINIMAL CSS (just for pan/zoom cursor)
24
+ // ═══════════════════════════════════════════════
25
+ var CSS_INJECTED = false;
26
+ function injectCSS() {
27
+ if (CSS_INJECTED) return;
28
+ var style = document.createElement('style');
29
+ style.textContent = `
30
+ .fts-root {
31
+ position: relative;
32
+ width: 100%;
33
+ height: 100%;
34
+ overflow: hidden;
35
+ background: #ffffff;
36
+ user-select: none;
37
+ }
38
+ .fts-root * { margin: 0; padding: 0; box-sizing: border-box; }
39
+ .fts-stage {
40
+ width: 100%;
41
+ height: 100%;
42
+ overflow: hidden;
43
+ position: relative;
44
+ cursor: grab;
45
+ }
46
+ .fts-stage.dragging { cursor: grabbing; }
47
+ `;
48
+ document.head.appendChild(style);
49
+ CSS_INJECTED = true;
50
+ }
51
+
52
+ // ═══════════════════════════════════════════════
53
+ // COLOR HELPERS
54
+ // ═══════════════════════════════════════════════
55
+ function h2r(hex) {
56
+ var h = hex.replace('#', '');
57
+ if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
58
+ return [parseInt(h.substr(0, 2), 16), parseInt(h.substr(2, 2), 16), parseInt(h.substr(4, 2), 16)];
59
+ }
60
+ function mix(a, b, t) {
61
+ var ca = h2r(a), cb = h2r(b);
62
+ var r = Math.round(ca[0] * (1 - t) + cb[0] * t);
63
+ var g = Math.round(ca[1] * (1 - t) + cb[1] * t);
64
+ var bl = Math.round(ca[2] * (1 - t) + cb[2] * t);
65
+ return '#' + (r < 16 ? '0' : '') + r.toString(16) + (g < 16 ? '0' : '') + g.toString(16) + (bl < 16 ? '0' : '') + bl.toString(16);
66
+ }
67
+ function lighter(c, t) { return mix(c, '#ffffff', t); }
68
+ function darker(c, t) { return mix(c, '#000000', t); }
69
+ function getContrastYIQ(hexcolor) {
70
+ var c = hexcolor.replace('#', '');
71
+ if (c.length === 3) c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
72
+ var r = parseInt(c.substr(0, 2), 16);
73
+ var g = parseInt(c.substr(2, 2), 16);
74
+ var b = parseInt(c.substr(4, 2), 16);
75
+ var yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
76
+ return (yiq >= 128) ? '#3a1f13' : '#fffdf5';
77
+ }
78
+
79
+ // ═══════════════════════════════════════════════
80
+ // DOM / SVG HELPERS
81
+ // ═══════════════════════════════════════════════
82
+ function mkSVG(tag, attrs, parent) {
83
+ var e = document.createElementNS('http://www.w3.org/2000/svg', tag);
84
+ for (var k in attrs) e.setAttribute(k, attrs[k]);
85
+ if (parent) parent.appendChild(e);
86
+ return e;
87
+ }
88
+ function rng(id, salt) {
89
+ var s = id + '~' + salt, h = 0;
90
+ for (var i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
91
+ return ((Math.sin(h + 1) * 43758.5453123) % 1 + 1) % 1;
92
+ }
93
+ function leafD(ox, oy, w, h) {
94
+ var hw = w / 2;
95
+ return 'M ' + ox + ',' + (oy - h * .50) +
96
+ ' C ' + (ox + hw * .12) + ',' + (oy - h * .40) + ' ' + (ox + hw) + ',' + (oy - h * .12) + ' ' + (ox + hw * .93) + ',' + (oy + h * .12) +
97
+ ' C ' + (ox + hw * .78) + ',' + (oy + h * .38) + ' ' + (ox + hw * .36) + ',' + (oy + h * .50) + ' ' + ox + ',' + (oy + h * .50) +
98
+ ' C ' + (ox - hw * .36) + ',' + (oy + h * .50) + ' ' + (ox - hw * .78) + ',' + (oy + h * .38) + ' ' + (ox - hw * .93) + ',' + (oy + h * .12) +
99
+ ' C ' + (ox - hw) + ',' + (oy - h * .12) + ' ' + (ox - hw * .12) + ',' + (oy - h * .40) + ' ' + ox + ',' + (oy - h * .50) + ' Z';
100
+ }
101
+
102
+ // ═══════════════════════════════════════════════
103
+ // MAIN CLASS
104
+ // ═══════════════════════════════════════════════
105
+ function FamilyTreeSVG(containerEl, data, options) {
106
+ if (typeof containerEl === 'string') containerEl = document.querySelector(containerEl);
107
+ if (!containerEl) throw new Error('FamilyTreeSVG: container element not found');
108
+ injectCSS();
109
+
110
+ var opts = options || {};
111
+ this._container = containerEl;
112
+ this._leafColor = opts.leafColor || '#17361a';
113
+ this._branchColor = opts.branchColor || '#3a1f13';
114
+ this._trunkColor = opts.trunkColor || '#2d1607';
115
+
116
+ this._LC = [];
117
+ this._BC = [];
118
+ this._BW = [38, 26, 17, 10.5, 6.5, 4.0];
119
+ this._TRUNK_H = 480;
120
+ this._BR_LEN = [0, 280, 210, 165, 130, 100];
121
+ this._SPREAD = [0, 220, 170, 135, 108, 85];
122
+ this._LW = 78;
123
+ this._LH = 96;
124
+ this._nodeMap = {};
125
+ this._collapsed = {};
126
+ this._panX = 0;
127
+ this._panY = 0;
128
+ this._zoom = 1;
129
+ this._SVG_W = 0;
130
+ this._SVG_H = 0;
131
+ this._ROOT = null;
132
+ this._isDragging = false;
133
+ this._lastMX = 0;
134
+ this._lastMY = 0;
135
+
136
+ this._buildDOM();
137
+ this._bindEvents();
138
+
139
+ if (data) {
140
+ var d = Array.isArray(data) ? data[0] : data;
141
+ this._preprocess(d, new Set());
142
+ this._ROOT = d;
143
+ this._collapsed = {};
144
+ this._autoCollapse(d);
145
+ this.render();
146
+ var self = this;
147
+ setTimeout(function () { self.fitView(); }, 90);
148
+ }
149
+ }
150
+
151
+ // ═══════════════════════════════════════════════
152
+ // DOM — just the stage + SVG, nothing else
153
+ // ═══════════════════════════════════════════════
154
+ FamilyTreeSVG.prototype._buildDOM = function () {
155
+ var c = this._container;
156
+ c.innerHTML = '';
157
+ c.classList.add('fts-root');
158
+
159
+ var stage = document.createElement('div');
160
+ stage.className = 'fts-stage';
161
+
162
+ var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
163
+ svg.style.cssText = 'position:absolute;top:0;left:0;overflow:visible;';
164
+ var defs = mkSVG('defs', {}, svg);
165
+ var filter = mkSVG('filter', { id: 'fts-ds', x: '-30%', y: '-30%', width: '160%', height: '160%' }, defs);
166
+ mkSVG('feDropShadow', { dx: '2', dy: '4', stdDeviation: '6', 'flood-color': 'rgba(0,0,0,0.35)' }, filter);
167
+ var vp = mkSVG('g', {}, svg);
168
+ var gBg = mkSVG('g', {}, vp);
169
+ mkSVG('g', {}, vp); // roots placeholder
170
+ var gTrunk = mkSVG('g', {}, vp);
171
+ var gBr = mkSVG('g', {}, vp);
172
+ var gDeco = mkSVG('g', {}, vp);
173
+ var gLv = mkSVG('g', {}, vp);
174
+ stage.appendChild(svg);
175
+ c.appendChild(stage);
176
+
177
+ this._els = {
178
+ stage: stage,
179
+ svg: svg,
180
+ vp: vp,
181
+ gBg: gBg,
182
+ gTrunk: gTrunk,
183
+ gBr: gBr,
184
+ gDeco: gDeco,
185
+ gLv: gLv
186
+ };
187
+ };
188
+
189
+ // ═══════════════════════════════════════════════
190
+ // EVENTS — pan, zoom, touch only
191
+ // ═══════════════════════════════════════════════
192
+ FamilyTreeSVG.prototype._bindEvents = function () {
193
+ var self = this;
194
+ var stage = this._els.stage;
195
+
196
+ // Mouse pan
197
+ stage.onmousedown = function (ev) {
198
+ self._isDragging = true; self._lastMX = ev.clientX; self._lastMY = ev.clientY;
199
+ stage.classList.add('dragging');
200
+ };
201
+ this._onMouseMove = function (ev) {
202
+ if (!self._isDragging) return;
203
+ self._panX += ev.clientX - self._lastMX;
204
+ self._panY += ev.clientY - self._lastMY;
205
+ self._lastMX = ev.clientX; self._lastMY = ev.clientY;
206
+ self._applyTransform(false);
207
+ };
208
+ this._onMouseUp = function () {
209
+ self._isDragging = false; stage.classList.remove('dragging');
210
+ };
211
+ window.addEventListener('mousemove', this._onMouseMove);
212
+ window.addEventListener('mouseup', this._onMouseUp);
213
+
214
+ // Touch pan
215
+ stage.addEventListener('touchstart', function (ev) {
216
+ if (ev.touches.length === 1) {
217
+ self._isDragging = true;
218
+ self._lastMX = ev.touches[0].clientX;
219
+ self._lastMY = ev.touches[0].clientY;
220
+ stage.classList.add('dragging');
221
+ }
222
+ }, { passive: true });
223
+ this._onTouchMove = function (ev) {
224
+ if (!self._isDragging || ev.touches.length !== 1) return;
225
+ self._panX += ev.touches[0].clientX - self._lastMX;
226
+ self._panY += ev.touches[0].clientY - self._lastMY;
227
+ self._lastMX = ev.touches[0].clientX;
228
+ self._lastMY = ev.touches[0].clientY;
229
+ self._applyTransform(false);
230
+ };
231
+ this._onTouchEnd = function () {
232
+ self._isDragging = false; stage.classList.remove('dragging');
233
+ };
234
+ window.addEventListener('touchmove', this._onTouchMove, { passive: true });
235
+ window.addEventListener('touchend', this._onTouchEnd);
236
+
237
+ // Wheel zoom
238
+ stage.onwheel = function (ev) {
239
+ ev.preventDefault();
240
+ var r = stage.getBoundingClientRect();
241
+ var mx = ev.clientX - r.left, my = ev.clientY - r.top;
242
+ var f = ev.deltaY < 0 ? 1.12 : 0.89;
243
+ var nz = Math.min(5, Math.max(0.05, self._zoom * f));
244
+ self._panX = mx - (mx - self._panX) * (nz / self._zoom);
245
+ self._panY = my - (my - self._panY) * (nz / self._zoom);
246
+ self._zoom = nz;
247
+ self._applyTransform(false);
248
+ };
249
+ };
250
+
251
+ FamilyTreeSVG.prototype._applyTransform = function (smooth) {
252
+ var vp = this._els.vp;
253
+ vp.style.transition = smooth ? 'transform .32s' : 'none';
254
+ vp.setAttribute('transform', 'translate(' + this._panX + ',' + this._panY + ') scale(' + this._zoom + ')');
255
+ };
256
+
257
+ // ═══════════════════════════════════════════════
258
+ // DATA PROCESSING
259
+ // ═══════════════════════════════════════════════
260
+ FamilyTreeSVG.prototype._preprocess = function (node, visited) {
261
+ if (!node.id) node.id = 'n_' + Math.random().toString(36).substr(2, 7);
262
+ var key = node.full_name || node.name;
263
+ if (!key) key = node.id;
264
+ if (visited.has(key)) return;
265
+ visited.add(key);
266
+ if (!node.name && node.full_name) node.name = node.full_name;
267
+ if (!node.children) node.children = [];
268
+ if (node.partners && Array.isArray(node.partners)) {
269
+ var self = this;
270
+ node.partners.forEach(function (p) {
271
+ if (!node.children.find(function (c) { return (c.full_name || c.name) === (p.full_name || p.name); })) {
272
+ var pNode = JSON.parse(JSON.stringify(p));
273
+ pNode._isPartner = true;
274
+ node.children.unshift(pNode);
275
+ }
276
+ });
277
+ }
278
+ var self2 = this;
279
+ node.children.forEach(function (c) { self2._preprocess(c, visited); });
280
+ };
281
+
282
+ FamilyTreeSVG.prototype._countMembers = function (node) {
283
+ var count = 1;
284
+ if (node.partners && Array.isArray(node.partners)) count += node.partners.length;
285
+ if (node.children && Array.isArray(node.children)) {
286
+ var self = this;
287
+ node.children.forEach(function (c) { count += self._countMembers(c); });
288
+ }
289
+ return count;
290
+ };
291
+
292
+ FamilyTreeSVG.prototype._autoCollapse = function (root) {
293
+ this._collapsed = {};
294
+ var count = this._countMembers(root);
295
+ if (count > 500) {
296
+ var self = this;
297
+ (function tc(node, depth) {
298
+ if (depth >= 2 && node.children && node.children.length > 0) self._collapsed[node.id] = true;
299
+ if (node.children) node.children.forEach(function (c) { tc(c, depth + (c._isPartner ? 0 : 1)); });
300
+ })(root, 0);
301
+ }
302
+ };
303
+
304
+ // ═══════════════════════════════════════════════
305
+ // LAYOUT
306
+ // ═══════════════════════════════════════════════
307
+ FamilyTreeSVG.prototype._pass1 = function (node, gen) {
308
+ node._gen = gen;
309
+ this._nodeMap[node.id] = node;
310
+ var kids = this._collapsed[node.id] ? [] : (node.children || []);
311
+ if (!kids.length) { node._sw = 1; return; }
312
+ var self = this;
313
+ kids.forEach(function (k) { self._pass1(k, gen + 1); });
314
+ var s = 0; kids.forEach(function (k) { s += k._sw; });
315
+ node._sw = s;
316
+ };
317
+
318
+ FamilyTreeSVG.prototype._pass2 = function (node, startX, startY, endX, endY, angle, depth) {
319
+ node._sx = startX; node._sy = startY; node._x = endX; node._y = endY;
320
+ node._angle = angle; node._depth = depth;
321
+ var kids = this._collapsed[node.id] ? [] : (node.children || []);
322
+ if (!kids.length) return;
323
+ var depth2 = Math.min(depth + 1, this._SPREAD.length - 1);
324
+ var branchLen = this._BR_LEN[depth2] || 70;
325
+ var totalSW = 0; kids.forEach(function (k) { totalSW += k._sw; });
326
+ var totalSpread = this._SPREAD[depth2];
327
+ var minSp = [0, 140, 100, 82, 65, 52];
328
+ if (kids.length > 0) totalSpread = Math.max(totalSpread, minSp[depth2] || 52);
329
+ if (kids.length < 3) totalSpread = totalSpread * 1.35;
330
+ totalSpread = Math.min(240, totalSpread * Math.max(1, Math.pow(totalSW, 0.28) * 0.72));
331
+ if (depth === 0) totalSpread = Math.min(200, totalSpread);
332
+ var centerAngle = depth === 0 ? angle : angle * 0.62;
333
+ var startAngle = centerAngle - totalSpread / 2;
334
+ var offset = 0;
335
+ var self = this;
336
+ var ROOT = this._ROOT;
337
+ kids.forEach(function (k) {
338
+ var frac = k._sw / totalSW;
339
+ var kidAngle = startAngle + (offset + frac * 0.5) * totalSpread;
340
+ offset += frac;
341
+ if (k._isPartner) kidAngle = angle - 22;
342
+ else kidAngle += (rng(k.id, 'j') - .5) * 8;
343
+ if (!k._isPartner) kidAngle = Math.max(-82, Math.min(82, kidAngle));
344
+ var swMul = Math.max(1, Math.pow(k._sw, 0.28) * 0.72);
345
+ var lenVar = branchLen * (k._isPartner ? 0.32 : (0.88 + rng(k.id, 'l') * 0.26)) * swMul;
346
+ var rad = (kidAngle - 90) * Math.PI / 180;
347
+ var kex = endX + Math.cos(rad) * lenVar;
348
+ var key = endY + Math.sin(rad) * lenVar;
349
+ var limitY = (ROOT && ROOT._y !== undefined ? ROOT._y : endY) + 20;
350
+ if (key > limitY) key = limitY;
351
+ self._pass2(k, endX, endY, kex, key, kidAngle, depth + 1);
352
+ });
353
+ };
354
+
355
+ FamilyTreeSVG.prototype._resolveOverlaps = function () {
356
+ var nodes = Object.values(this._nodeMap);
357
+ var N = nodes.length;
358
+ var R = Math.max(520, Math.min(1200, Math.sqrt(N) * 135));
359
+ var rx = R * 1.6, ry = R * 0.85;
360
+ var ccx = this._ROOT._x, ccy = this._ROOT._y - ry * 0.52;
361
+ var LW = this._LW, ROOT = this._ROOT;
362
+ for (var it = 0; it < 80; it++) {
363
+ var moved = false;
364
+ for (var j = 0; j < nodes.length; j++) {
365
+ for (var k = j + 1; k < nodes.length; k++) {
366
+ var n1 = nodes[j], n2 = nodes[k];
367
+ var dx = n1._x - n2._x, dy = n1._y - n2._y;
368
+ var d2 = dx * dx + dy * dy;
369
+ var sc1 = n1._gen === 0 ? 1.5 : n1._gen === 1 ? 1.18 : n1._gen === 2 ? 1.02 : 0.90;
370
+ var sc2 = n2._gen === 0 ? 1.5 : n2._gen === 1 ? 1.18 : n2._gen === 2 ? 1.02 : 0.90;
371
+ if (n1._isPartner) sc1 *= 0.85;
372
+ if (n2._isPartner) sc2 *= 0.85;
373
+ var r1 = LW * sc1 * 0.65, r2 = LW * sc2 * 0.65;
374
+ var isSib = (n1._sx === n2._sx && n1._sy === n2._sy);
375
+ var pad = isSib ? 65 : 35;
376
+ var minD = r1 + r2 + pad;
377
+ if (n1.children && n1.children.some(function (c) { return c.id === n2.id && n2._isPartner; })) minD = (r1 + r2) * 0.7;
378
+ if (n2.children && n2.children.some(function (c) { return c.id === n1.id && n1._isPartner; })) minD = (r1 + r2) * 0.7;
379
+ if (d2 < minD * minD) {
380
+ var dist = Math.sqrt(d2) || 0.1;
381
+ var force = (minD - dist) / dist * 0.35;
382
+ var ox = dx * force, oy = dy * force;
383
+ var m1 = n1._gen === 0 ? 0.01 : 1, m2 = n2._gen === 0 ? 0.01 : 1;
384
+ n1._x += ox * m1; n1._y += oy * m1;
385
+ n2._x -= ox * m2; n2._y -= oy * m2;
386
+ moved = true;
387
+ }
388
+ }
389
+ }
390
+ nodes.forEach(function (n) {
391
+ if (n.id !== ROOT.id) {
392
+ var dx = n._x - ccx, dy = n._y - ccy;
393
+ var ed = (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry);
394
+ if (ed > 1.0) { var s = 1.0 / Math.sqrt(ed); n._x = ccx + dx * s; n._y = ccy + dy * s; moved = true; }
395
+ }
396
+ });
397
+ nodes.forEach(function (n) {
398
+ if (n.id !== ROOT.id) { var lim = ROOT._y + 20; if (n._y > lim) { n._y = lim; moved = true; } }
399
+ });
400
+ if (!moved) break;
401
+ }
402
+ };
403
+
404
+ // ═══════════════════════════════════════════════
405
+ // DRAWING
406
+ // ═══════════════════════════════════════════════
407
+ FamilyTreeSVG.prototype._updateColors = function () {
408
+ this._LC = [];
409
+ for (var i = 0; i < 6; i++) {
410
+ var c = lighter(this._leafColor, i * 0.09);
411
+ var tc = getContrastYIQ(c);
412
+ var bc = tc === '#3a1f13' ? darker(this._leafColor, 0.2) : '#d6a661';
413
+ this._LC.push({ f: c, s: bc, t: tc });
414
+ }
415
+ this._BC = [];
416
+ for (var i = 0; i < 6; i++) this._BC.push(lighter(this._branchColor, i * 0.09));
417
+ };
418
+
419
+ FamilyTreeSVG.prototype._drawBg = function (rx, groundY) {
420
+ this._els.gBg.innerHTML = '';
421
+ var groundRx = Math.max(200, Math.min(550, 150 + this._countMembers(this._ROOT) * 4));
422
+ mkSVG('ellipse', { cx: rx, cy: groundY + 14, rx: groundRx, ry: 65, fill: '#d6a661', opacity: '0.4' }, this._els.gBg);
423
+ };
424
+
425
+ FamilyTreeSVG.prototype._drawTrunk = function (rx, groundY, topY) {
426
+ var gTrunk = this._els.gTrunk;
427
+ gTrunk.innerHTML = '';
428
+ var mc = this._countMembers(this._ROOT);
429
+ var ts = Math.max(0.45, Math.min(1.0, mc / 60));
430
+ var bw = Math.round(40 + 50 * ts), tw = Math.round(16 + 18 * ts);
431
+ var h = groundY - topY;
432
+ var ptsL = [], ptsR = [], steps = 40;
433
+ var leanX = (rng('trunk', 'lean') - 0.5) * 60;
434
+ var tc = this._trunkColor;
435
+
436
+ for (var i = 0; i <= steps; i++) {
437
+ var t = i / steps;
438
+ var co = Math.sin(t * Math.PI) * leanX * 0.4 + Math.sin(t * 2 * Math.PI) * 15;
439
+ var cw = tw + (bw - tw) * Math.pow(1 - t, 2.2);
440
+ var sw = Math.sin(t * 8 * Math.PI) * 1.5;
441
+ ptsL.push({ x: rx - cw + co + sw, y: groundY - t * h });
442
+ ptsR.push({ x: rx + cw + co + sw, y: groundY - t * h });
443
+ }
444
+ var dL = 'M' + ptsL[0].x + ',' + ptsL[0].y; ptsL.forEach(function (p, i) { if (i > 0) dL += ' L' + p.x + ',' + p.y; });
445
+ var dR = ' L' + ptsR[ptsR.length - 1].x + ',' + ptsR[ptsR.length - 1].y;
446
+ for (var i = ptsR.length - 2; i >= 0; i--) dR += ' L' + ptsR[i].x + ',' + ptsR[i].y;
447
+ var d = dL + dR + ' Z';
448
+ mkSVG('path', { d: d, fill: tc, stroke: 'none' }, gTrunk);
449
+
450
+ // Mid-tone
451
+ var midL = [], midR = [];
452
+ for (var i = 0; i <= steps; i++) {
453
+ var t = i / steps;
454
+ var cw = tw + (bw - tw) * Math.pow(1 - t, 2.2);
455
+ var co = Math.sin(t * Math.PI) * leanX * 0.4 + Math.sin(t * 2 * Math.PI) * 15;
456
+ var sw = Math.sin(t * 8 * Math.PI) * 1.5;
457
+ midL.push({ x: rx - cw * 0.72 + co + sw, y: groundY - t * h });
458
+ midR.push({ x: rx + cw * 0.78 + co + sw, y: groundY - t * h });
459
+ }
460
+ var dML = 'M' + midL[0].x + ',' + midL[0].y; midL.forEach(function (p, i) { if (i > 0) dML += ' L' + p.x + ',' + p.y; });
461
+ var dMR = ' L' + midR[midR.length - 1].x + ',' + midR[midR.length - 1].y;
462
+ for (var i = midR.length - 2; i >= 0; i--) dMR += ' L' + midR[i].x + ',' + midR[i].y;
463
+ mkSVG('path', { d: dML + dMR + ' Z', fill: lighter(tc, 0.12), stroke: 'none' }, gTrunk);
464
+
465
+ // Highlight
466
+ var hiL = [], hiR = [];
467
+ for (var i = 0; i <= steps; i++) {
468
+ var t = i / steps;
469
+ var cw = tw + (bw - tw) * Math.pow(1 - t, 2.2);
470
+ var co = Math.sin(t * Math.PI) * leanX * 0.4 + Math.sin(t * 2 * Math.PI) * 15;
471
+ var sw = Math.sin(t * 8 * Math.PI) * 1.5;
472
+ hiL.push({ x: rx - cw * 0.25 + co + sw, y: groundY - t * h });
473
+ hiR.push({ x: rx + cw * 0.45 + co + sw, y: groundY - t * h });
474
+ }
475
+ var dHL = 'M' + hiL[0].x + ',' + hiL[0].y; hiL.forEach(function (p, i) { if (i > 0) dHL += ' L' + p.x + ',' + p.y; });
476
+ var dHR = ' L' + hiR[hiR.length - 1].x + ',' + hiR[hiR.length - 1].y;
477
+ for (var i = hiR.length - 2; i >= 0; i--) dHR += ' L' + hiR[i].x + ',' + hiR[i].y;
478
+ mkSVG('path', { d: dHL + dHR + ' Z', fill: lighter(tc, 0.28), stroke: 'none', opacity: '0.65' }, gTrunk);
479
+
480
+ var outCol = getContrastYIQ(tc) === '#3a1f13' ? 'rgba(0,0,0,0.15)' : 'rgba(214,166,97,0.5)';
481
+ mkSVG('path', { d: dR, fill: 'none', stroke: outCol, 'stroke-width': '4.5', 'stroke-linecap': 'round' }, gTrunk);
482
+ mkSVG('path', { d: d, fill: 'none', stroke: darker(tc, 0.2), 'stroke-width': '3.5' }, gTrunk);
483
+ };
484
+
485
+ FamilyTreeSVG.prototype._drawSeg = function (x1, y1, x2, y2, depth, nid, isP) {
486
+ var g = Math.min(depth, this._BC.length - 1);
487
+ var bw = Math.max(2, this._BW[g]);
488
+ var dx = x2 - x1, dy = y2 - y1;
489
+ var len = Math.sqrt(dx * dx + dy * dy) || 1;
490
+ var px = -dy / len, py = dx / len;
491
+ var sf = isP ? 0.05 : (0.16 + rng(nid, 'sw') * 0.12);
492
+ var sweep = len * sf;
493
+ var bs = dx < 0 ? 1 : -1;
494
+ var side = bs * (0.45 + rng(nid, 'sd') * 0.55);
495
+ var d = 'M' + x1 + ',' + y1;
496
+ var ROOT = this._ROOT;
497
+ for (var i = 1; i <= 8; i++) {
498
+ var t = i / 8;
499
+ var lx = x1 + dx * t, ly = y1 + dy * t;
500
+ var bow = Math.sin(t * Math.PI) * sweep * side;
501
+ var wig = i < 8 ? (rng(nid, 'wg_' + i) - 0.5) * (len * 0.06) : 0;
502
+ var cx = lx + px * (bow + wig), cy = ly + py * (bow + wig);
503
+ if (cy > ROOT._y + 30) cy = ROOT._y + 30;
504
+ d += ' L' + cx + ',' + cy;
505
+ }
506
+ mkSVG('path', {
507
+ d: d, fill: 'none',
508
+ stroke: isP ? 'rgba(214,166,97,0.5)' : this._BC[g],
509
+ 'stroke-width': isP ? bw * 0.6 : bw,
510
+ 'stroke-linecap': 'round', 'stroke-linejoin': 'round'
511
+ }, this._els.gBr);
512
+ };
513
+
514
+ FamilyTreeSVG.prototype._drawAllBranches = function (node) {
515
+ var self = this;
516
+ var kids = this._collapsed[node.id] ? [] : (node.children || []);
517
+ kids.forEach(function (c) {
518
+ self._drawSeg(node._x, node._y, c._x, c._y, c._depth, c.id, c._isPartner);
519
+ self._drawAllBranches(c);
520
+ });
521
+ };
522
+
523
+ FamilyTreeSVG.prototype._splitName = function (n) {
524
+ if (n.length <= 11) return [n];
525
+ var w = n.split(' ');
526
+ return w.length === 1 ? [n.substr(0, 9) + 'â€Ķ'] : [w.slice(0, Math.ceil(w.length / 2)).join(' '), w.slice(Math.ceil(w.length / 2)).join(' ')];
527
+ };
528
+
529
+ FamilyTreeSVG.prototype._drawLeaf = function (node) {
530
+ var self = this;
531
+ var gi = Math.min(node._gen, this._LC.length - 1), col = this._LC[gi];
532
+ if (node._isPartner) {
533
+ var spBg = darker(this._leafColor, 0.15);
534
+ var spTxt = getContrastYIQ(spBg);
535
+ col = { f: spBg, s: spTxt === '#3a1f13' ? darker(this._leafColor, 0.35) : '#d6a661', t: spTxt };
536
+ }
537
+ var sc = node._gen === 0 ? 1.5 : node._gen === 1 ? 1.18 : node._gen === 2 ? 1.02 : 0.90;
538
+ if (node._isPartner) sc *= 0.85;
539
+ var w = this._LW * sc, h = this._LH * sc, cx = node._x, cy = node._y, ang = node._angle || 0;
540
+ var g = mkSVG('g', { 'data-id': node.id, cursor: 'pointer' }, this._els.gLv);
541
+ var gid = 'lg_' + node.id;
542
+ var defs = this._els.svg.querySelector('defs');
543
+ if (!defs.querySelector('#' + gid)) {
544
+ var lg = mkSVG('linearGradient', { id: gid, x1: '25%', y1: '0%', x2: '78%', y2: '100%' }, defs);
545
+ mkSVG('stop', { offset: '0%', 'stop-color': lighter(col.f, 0.2) }, lg);
546
+ mkSVG('stop', { offset: '50%', 'stop-color': col.f }, lg);
547
+ mkSVG('stop', { offset: '100%', 'stop-color': darker(col.f, 0.1) }, lg);
548
+ }
549
+ mkSVG('path', { d: leafD(0, 0, w, h), fill: 'rgba(0,0,0,0.25)', filter: 'url(#fts-ds)', transform: 'translate(' + (cx + 3) + ',' + (cy + 5) + ') rotate(' + ang + ')' }, g);
550
+ mkSVG('path', { d: leafD(0, 0, w, h), fill: 'url(#' + gid + ')', stroke: col.s, 'stroke-width': node._isPartner ? '1.5' : '2.5', transform: 'translate(' + cx + ',' + cy + ') rotate(' + ang + ')' }, g);
551
+ var lines = this._splitName(node.name || node.full_name || 'No Name');
552
+ var fs = (node._gen === 0 ? 13 : 11) * sc, lineH = fs + 2.5;
553
+ var tg = mkSVG('g', { transform: 'translate(' + cx + ',' + cy + ') rotate(' + ang + ')', 'pointer-events': 'none' }, g);
554
+ var totalTH = lines.length * lineH;
555
+ lines.forEach(function (ln, li) {
556
+ var txt = mkSVG('text', {
557
+ x: 0, y: -totalTH / 2 + li * lineH + lineH * .5,
558
+ 'text-anchor': 'middle', 'dominant-baseline': 'middle',
559
+ 'font-family': 'Georgia,serif', 'font-size': fs, 'font-weight': '700', fill: col.t
560
+ }, tg);
561
+ txt.textContent = ln;
562
+ });
563
+ var kids = node.children || [];
564
+ if (kids.length) {
565
+ var rad = (ang - 90) * Math.PI / 180;
566
+ var tipX = cx + Math.cos(rad) * (-h * .52), tipY = cy + Math.sin(rad) * (-h * .52);
567
+ var tg2 = mkSVG('g', { cursor: 'pointer', class: 'collapse-btn' }, g);
568
+ mkSVG('circle', { cx: tipX, cy: tipY, r: 10, fill: '#f9f3e0', stroke: col.s, 'stroke-width': '1.8' }, tg2);
569
+ var bt = mkSVG('text', { x: tipX, y: tipY, 'text-anchor': 'middle', 'dominant-baseline': 'middle', 'font-size': '12', 'font-weight': '700', fill: '#3a1f13' }, tg2);
570
+ bt.textContent = this._collapsed[node.id] ? '+' : '−';
571
+ tg2.onclick = function (e) { e.stopPropagation(); self._toggleCollapse(node.id); };
572
+ }
573
+ if (!this._collapsed[node.id]) kids.forEach(function (c) { self._drawLeaf(c); });
574
+ };
575
+
576
+ FamilyTreeSVG.prototype._drawDecLeaf = function (cx, cy, w, h, ang, gen) {
577
+ var gi = Math.min(gen, this._LC.length - 1);
578
+ var col = this._LC[gi];
579
+ var g = mkSVG('g', { opacity: '0.72' }, this._els.gDeco);
580
+ var gid = 'dec_lg_' + Math.floor(cx) + '_' + Math.floor(cy) + '_' + Math.floor(w);
581
+ var defs = this._els.svg.querySelector('defs');
582
+ if (!defs.querySelector('#' + gid)) {
583
+ var lg = mkSVG('linearGradient', { id: gid, x1: '25%', y1: '0%', x2: '78%', y2: '100%' }, defs);
584
+ mkSVG('stop', { offset: '0%', 'stop-color': lighter(col.f, 0.2) }, lg);
585
+ mkSVG('stop', { offset: '50%', 'stop-color': col.f }, lg);
586
+ mkSVG('stop', { offset: '100%', 'stop-color': darker(col.f, 0.1) }, lg);
587
+ }
588
+ mkSVG('path', { d: leafD(0, 0, w, h), fill: 'rgba(0,0,0,0.15)', filter: 'url(#fts-ds)', transform: 'translate(' + (cx + 2) + ',' + (cy + 3) + ') rotate(' + ang + ')' }, g);
589
+ mkSVG('path', { d: leafD(0, 0, w, h), fill: 'url(#' + gid + ')', stroke: col.s, 'stroke-width': '1.3', transform: 'translate(' + cx + ',' + cy + ') rotate(' + ang + ')' }, g);
590
+ };
591
+
592
+ FamilyTreeSVG.prototype._drawDecorations = function () {
593
+ var gDeco = this._els.gDeco;
594
+ var gBr = this._els.gBr;
595
+ gDeco.innerHTML = '';
596
+ var ROOT = this._ROOT;
597
+ var collapsed = this._collapsed;
598
+ var self = this;
599
+ var branches = [];
600
+ (function collect(node) {
601
+ var kids = collapsed[node.id] ? [] : (node.children || []);
602
+ kids.forEach(function (c) {
603
+ branches.push({ x1: node._x, y1: node._y, x2: c._x, y2: c._y, depth: c._depth, id: c.id, isP: c._isPartner });
604
+ collect(c);
605
+ });
606
+ })(ROOT);
607
+
608
+ // Trunk decorations
609
+ var groundY = ROOT._y + self._TRUNK_H;
610
+ var trunkH = groundY - (ROOT._y + self._LH * 0.45);
611
+ for (var i = 0; i < 15; i++) {
612
+ var t = rng('trunk_dec', i);
613
+ var x = ROOT._x + (rng('trunk_dec_x', i) - 0.5) * 60;
614
+ var y = groundY - t * trunkH;
615
+ var size = 10 + rng('trunk_dec_s', i) * 12;
616
+ var ang = rng('trunk_dec_a', i) * 360;
617
+ var col = ['#7d5c48', '#a37f6a', '#5c3d2e'][Math.floor(rng('trunk_dec_c', i) * 3)];
618
+ var gl = mkSVG('g', { transform: 'translate(' + x + ',' + y + ') rotate(' + ang + ')' }, gDeco);
619
+ mkSVG('path', { d: leafD(0, 0, size, size * 1.3), fill: col, opacity: '0.4' }, gl);
620
+ }
621
+
622
+ // Twigs + sub-branches
623
+ branches.forEach(function (b) {
624
+ var num = Math.floor(rng(b.id, 'd_n') * 3) + 3;
625
+ if (b.depth <= 2) num += 2;
626
+ var dx = b.x2 - b.x1, dy = b.y2 - b.y1;
627
+ var len = Math.sqrt(dx * dx + dy * dy) || 1;
628
+ var px = -dy / len, py = dx / len;
629
+ var sf = b.isP ? 0.04 : (0.10 + rng(b.id, 'sw') * 0.10);
630
+ var sweep = len * sf;
631
+ var bs = dx < 0 ? 1 : -1;
632
+ var side = bs * (0.55 + rng(b.id, 'sd') * 0.35);
633
+ for (var i = 0; i < num; i++) {
634
+ var t = 0.2 + (i / num) * 0.65;
635
+ var disp = Math.sin(t * Math.PI) * sweep * side;
636
+ var lx = b.x1 + dx * t, ly = b.y1 + dy * t;
637
+ var cx = lx + px * disp, cy = ly + py * disp;
638
+ var dir = (i % 2 === 0 ? 1 : -1);
639
+ var tl = 28 + rng(b.id, 'twig_l' + i) * 30;
640
+ var ta = (rng(b.id, 'twig_a' + i) - 0.5) * 0.6;
641
+ var tx = cx + (px * dir + ta) * tl, ty = cy + (py * dir - Math.abs(ta) * 0.3) * tl;
642
+ if (ty > ROOT._y + 30) ty = ROOT._y + 30;
643
+ mkSVG('line', { x1: cx, y1: cy, x2: tx, y2: ty, stroke: '#3a1f13', 'stroke-width': '1.5', 'stroke-linecap': 'round' }, gBr);
644
+ var scf = b.depth <= 2 ? 1.3 : 1.0;
645
+ var ls = (22 + rng(b.id, 'd_s' + i) * 10) * scf;
646
+ var la = Math.atan2(ty - cy, tx - cx) * 180 / Math.PI + 90;
647
+ self._drawDecLeaf(tx, ty, ls * 0.9, ls * 1.1, la, b.depth);
648
+ if (rng(b.id, 'sub' + i) > 0.45) {
649
+ var sl = 18 + rng(b.id, 'sub_l' + i) * 20;
650
+ var sd = rng(b.id, 'sub_d' + i) > 0.5 ? 1 : -1;
651
+ var sx = tx + (px * sd * 0.7 + (rng(b.id, 'sub_a' + i) - 0.5) * 0.5) * sl;
652
+ var sy = ty + (py * sd * 0.7 - 0.3) * sl;
653
+ if (sy > ROOT._y + 30) sy = ROOT._y + 30;
654
+ mkSVG('line', { x1: tx, y1: ty, x2: sx, y2: sy, stroke: '#4d2b1a', 'stroke-width': '1', 'stroke-linecap': 'round' }, gBr);
655
+ var ss = 16 + rng(b.id, 'sub_s' + i) * 8;
656
+ var sa = Math.atan2(sy - ty, sx - tx) * 180 / Math.PI + 90;
657
+ self._drawDecLeaf(sx, sy, ss * 0.85, ss * 1.05, sa, Math.min(b.depth + 1, 5));
658
+ }
659
+ }
660
+ });
661
+
662
+ // Leaf clusters
663
+ var nm = this._nodeMap;
664
+ Object.values(nm).forEach(function (n) {
665
+ var kids = n.children || [];
666
+ var nc = kids.length === 0 ? 4 : 2;
667
+ var sc = n._gen === 0 ? 1.0 : n._gen === 1 ? 0.85 : n._gen === 2 ? 0.7 : 0.6;
668
+ if (n._isPartner) sc *= 0.8;
669
+ for (var i = 0; i < nc; i++) {
670
+ var ao = -60 + i * (120 / nc) + (rng(n.id, 'cl_a' + i) - 0.5) * 25;
671
+ var rad = (n._angle + ao - 90) * Math.PI / 180;
672
+ var dist = (22 + rng(n.id, 'cl_d' + i) * 14) * sc;
673
+ var lx = n._x + Math.cos(rad) * dist, ly = n._y + Math.sin(rad) * dist;
674
+ if (ly > ROOT._y + 30) ly = ROOT._y + 30;
675
+ var lw = (26 + rng(n.id, 'cl_w' + i) * 10) * sc;
676
+ var lh = (32 + rng(n.id, 'cl_h' + i) * 12) * sc;
677
+ var la = n._angle + ao + (rng(n.id, 'cl_r' + i) - 0.5) * 30;
678
+ self._drawDecLeaf(lx, ly, lw, lh, la, n._gen);
679
+ }
680
+ });
681
+
682
+ // Filler leaves
683
+ var allN = Object.values(nm);
684
+ if (allN.length > 1) {
685
+ var nf = Math.max(20, Math.floor(allN.length * 1.5));
686
+ for (var f = 0; f < nf; f++) {
687
+ var si = Math.floor(rng('fill', f) * allN.length);
688
+ var src = allN[si];
689
+ var fx = src._x + (rng('fill_x', f) - 0.5) * 120;
690
+ var fy = src._y + (rng('fill_y', f) - 0.5) * 80;
691
+ if (fy > ROOT._y + 30) fy = ROOT._y + 30;
692
+ var fs = 14 + rng('fill_s', f) * 14;
693
+ var fa = rng('fill_a', f) * 360;
694
+ self._drawDecLeaf(fx, fy, fs, fs * 1.2, fa, Math.min(src._gen + 1, 5));
695
+ }
696
+ }
697
+ };
698
+
699
+ // ═══════════════════════════════════════════════
700
+ // PUBLIC API
701
+ // ═══════════════════════════════════════════════
702
+ FamilyTreeSVG.prototype._toggleCollapse = function (id) {
703
+ this._collapsed[id] = !this._collapsed[id];
704
+ this.render();
705
+ };
706
+
707
+ FamilyTreeSVG.prototype.render = function () {
708
+ if (!this._ROOT) return;
709
+ this._updateColors();
710
+ this._nodeMap = {};
711
+ this._els.gBg.innerHTML = '';
712
+ this._els.gTrunk.innerHTML = '';
713
+ this._els.gBr.innerHTML = '';
714
+ this._els.gLv.innerHTML = '';
715
+ var defs = this._els.svg.querySelector('defs');
716
+ defs.querySelectorAll('linearGradient').forEach(function (el) { defs.removeChild(el); });
717
+
718
+ this._pass1(this._ROOT, 0);
719
+ var mc = this._countMembers(this._ROOT);
720
+ this._TRUNK_H = Math.max(200, Math.min(520, 140 + mc * 3.5));
721
+ var CX = 700, CY = 1100;
722
+ this._pass2(this._ROOT, CX, CY, CX, CY - this._TRUNK_H, 0, 0);
723
+ this._resolveOverlaps();
724
+
725
+ var x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
726
+ var nm = this._nodeMap, LW = this._LW, LH = this._LH;
727
+ Object.keys(nm).forEach(function (id) {
728
+ var n = nm[id];
729
+ x0 = Math.min(x0, n._x - LW); y0 = Math.min(y0, n._y - LH);
730
+ x1 = Math.max(x1, n._x + LW); y1 = Math.max(y1, n._y + LH);
731
+ });
732
+ var PAD = 140, ox = -x0 + PAD, oy = -y0 + PAD;
733
+ Object.keys(nm).forEach(function (id) {
734
+ var n = nm[id]; n._x += ox; n._y += oy; n._sx += ox; n._sy += oy;
735
+ });
736
+ this._SVG_W = (x1 - x0) + PAD * 2;
737
+ this._SVG_H = (y1 - y0) + PAD * 2 + 150;
738
+ this._els.svg.setAttribute('width', this._SVG_W);
739
+ this._els.svg.setAttribute('height', this._SVG_H);
740
+ this._els.svg.style.width = this._SVG_W + 'px';
741
+ this._els.svg.style.height = this._SVG_H + 'px';
742
+
743
+ var groundY = this._ROOT._y + this._TRUNK_H;
744
+ this._drawBg(this._ROOT._x, groundY + 50);
745
+ this._drawTrunk(this._ROOT._x, groundY + 25, this._ROOT._y + LH * .45);
746
+ this._drawAllBranches(this._ROOT);
747
+ this._drawDecorations();
748
+ this._drawLeaf(this._ROOT);
749
+ };
750
+
751
+ FamilyTreeSVG.prototype.fitView = function () {
752
+ var sw = this._els.stage.clientWidth;
753
+ var sh = this._els.stage.clientHeight;
754
+ var fz = Math.min(sw / this._SVG_W, sh / this._SVG_H) * .82;
755
+ this._zoom = fz;
756
+ this._panX = (sw - this._SVG_W * this._zoom) / 2;
757
+ this._panY = (sh - this._SVG_H * this._zoom) / 2;
758
+ this._applyTransform(true);
759
+ };
760
+
761
+ FamilyTreeSVG.prototype.setColors = function (leafColor, branchColor, trunkColor) {
762
+ if (leafColor) this._leafColor = leafColor;
763
+ if (branchColor) this._branchColor = branchColor;
764
+ if (trunkColor) this._trunkColor = trunkColor;
765
+ this.render();
766
+ };
767
+
768
+ FamilyTreeSVG.prototype.loadData = function (data) {
769
+ var d = Array.isArray(data) ? data[0] : data;
770
+ this._preprocess(d, new Set());
771
+ this._ROOT = d;
772
+ this._collapsed = {};
773
+ this._autoCollapse(d);
774
+ this.render();
775
+ var self = this;
776
+ setTimeout(function () { self.fitView(); }, 90);
777
+ };
778
+
779
+ FamilyTreeSVG.prototype.exportSVG = function () {
780
+ var clone = this._els.svg.cloneNode(true);
781
+ clone.removeAttribute('style');
782
+ var vpClone = clone.children[0]?.children[1] ? clone.children[0] : clone.querySelector('g');
783
+ // Remove bg
784
+ if (vpClone && vpClone.children[0]) vpClone.children[0].remove();
785
+ clone.querySelectorAll('.collapse-btn').forEach(function (el) { el.remove(); });
786
+ if (vpClone) vpClone.removeAttribute('transform');
787
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
788
+ clone.style.position = 'absolute'; clone.style.top = '-9999px'; clone.style.left = '-9999px'; clone.style.visibility = 'hidden';
789
+ document.body.appendChild(clone);
790
+ var bbox = { x: 0, y: 0, width: this._SVG_W, height: this._SVG_H };
791
+ try { bbox = vpClone.getBBox(); } catch (e) {}
792
+ document.body.removeChild(clone);
793
+ var p = 40;
794
+ var vx = bbox.x - p, vy = bbox.y - p, vw = bbox.width + p * 2, vh = bbox.height + p * 2;
795
+ if (isNaN(vx) || vw <= 0) { vx = 0; vy = 0; vw = this._SVG_W; vh = this._SVG_H; }
796
+ clone.setAttribute('viewBox', vx + ' ' + vy + ' ' + vw + ' ' + vh);
797
+ clone.setAttribute('width', vw); clone.setAttribute('height', vh);
798
+ clone.style.position = ''; clone.style.top = ''; clone.style.left = ''; clone.style.visibility = '';
799
+ var s = new XMLSerializer().serializeToString(clone);
800
+ if (!s.startsWith('<?xml')) s = '<?xml version="1.0" standalone="no"?>\r\n' + s;
801
+ var blob = new Blob([s], { type: 'image/svg+xml;charset=utf-8' });
802
+ var url = URL.createObjectURL(blob);
803
+ var a = document.createElement('a');
804
+ a.download = ((this._ROOT.family_name || this._ROOT.full_name || 'family') + '_tree.svg').toLowerCase().replace(/\s+/g, '_');
805
+ a.href = url; document.body.appendChild(a); a.click(); document.body.removeChild(a);
806
+ URL.revokeObjectURL(url);
807
+ };
808
+
809
+ FamilyTreeSVG.prototype.destroy = function () {
810
+ window.removeEventListener('mousemove', this._onMouseMove);
811
+ window.removeEventListener('mouseup', this._onMouseUp);
812
+ window.removeEventListener('touchmove', this._onTouchMove);
813
+ window.removeEventListener('touchend', this._onTouchEnd);
814
+ this._container.innerHTML = '';
815
+ this._container.classList.remove('fts-root');
816
+ };
817
+
818
+ // Static factory
819
+ FamilyTreeSVG.create = function (container, data, options) {
820
+ return new FamilyTreeSVG(container, data, options);
821
+ };
822
+
823
+ return FamilyTreeSVG;
824
+ }));