kuni 3週間前
コミット
d4e694adc7

+ 7
- 0
package-lock.json ファイルの表示

@@ -15,6 +15,7 @@
15 15
         "@angular/platform-browser": "^21.2.0",
16 16
         "@angular/router": "^21.2.0",
17 17
         "rxjs": "~7.8.0",
18
+        "svg-pan-zoom": "^3.6.2",
18 19
         "tslib": "^2.3.0"
19 20
       },
20 21
       "devDependencies": {
@@ -7835,6 +7836,12 @@
7835 7836
         "url": "https://github.com/chalk/strip-ansi?sponsor=1"
7836 7837
       }
7837 7838
     },
7839
+    "node_modules/svg-pan-zoom": {
7840
+      "version": "3.6.2",
7841
+      "resolved": "https://registry.npmjs.org/svg-pan-zoom/-/svg-pan-zoom-3.6.2.tgz",
7842
+      "integrity": "sha512-JwnvRWfVKw/Xzfe6jriFyfey/lWJLq4bUh2jwoR5ChWQuQoOH8FEh1l/bEp46iHHKHEJWIyFJETbazraxNWECg==",
7843
+      "license": "BSD-2-Clause"
7844
+    },
7838 7845
     "node_modules/symbol-tree": {
7839 7846
       "version": "3.2.4",
7840 7847
       "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",

+ 2
- 1
package.json ファイルの表示

@@ -18,6 +18,7 @@
18 18
     "@angular/platform-browser": "^21.2.0",
19 19
     "@angular/router": "^21.2.0",
20 20
     "rxjs": "~7.8.0",
21
+    "svg-pan-zoom": "^3.6.2",
21 22
     "tslib": "^2.3.0"
22 23
   },
23 24
   "devDependencies": {
@@ -29,4 +30,4 @@
29 30
     "typescript": "~5.9.2",
30 31
     "vitest": "^4.0.8"
31 32
   }
32
-}
33
+}

+ 13
- 0
src/app/models/family-unit-layout.ts ファイルの表示

@@ -0,0 +1,13 @@
1
+import { FamilyUnitNode } from './family-unit-node';
2
+
3
+export interface FamilyUnitLayout {
4
+
5
+    node: FamilyUnitNode;
6
+
7
+    x: number;
8
+    y: number;
9
+
10
+    width: number;
11
+    height: number;
12
+
13
+}

+ 11
- 0
src/app/models/family-unit-node.ts ファイルの表示

@@ -0,0 +1,11 @@
1
+import { FamilyUnit } from './family-unit';
2
+
3
+export interface FamilyUnitNode {
4
+
5
+  unit: FamilyUnit;
6
+
7
+  children: FamilyUnitNode[];
8
+
9
+  parents: FamilyUnitNode[];
10
+
11
+}

+ 13
- 0
src/app/models/family-unit.ts ファイルの表示

@@ -0,0 +1,13 @@
1
+import { Family } from './family';
2
+
3
+export interface FamilyUnit {
4
+
5
+  id: string;
6
+
7
+  husband?: Family;
8
+
9
+  wife?: Family;
10
+
11
+  children: Family[];
12
+
13
+}

+ 11
- 0
src/app/models/familytreenode.ts ファイルの表示

@@ -0,0 +1,11 @@
1
+import { Family } from "./family";
2
+
3
+export interface FamilyTreeNode {
4
+  family: Family;
5
+
6
+  spouses: FamilyTreeNode[];
7
+
8
+  children: FamilyTreeNode[];
9
+
10
+  parents: FamilyTreeNode[];
11
+}

+ 13
- 0
src/app/models/layout-unit.ts ファイルの表示

@@ -0,0 +1,13 @@
1
+import { FamilyUnit } from './family-unit';
2
+
3
+export interface LayoutUnit {
4
+
5
+  unit: FamilyUnit;
6
+
7
+  x: number;
8
+  y: number;
9
+
10
+  width: number;
11
+  height: number;
12
+
13
+}

+ 20
- 0
src/app/models/layoutnode.ts ファイルの表示

@@ -0,0 +1,20 @@
1
+import { Family } from "./family";
2
+
3
+export interface LayoutNode {
4
+
5
+  familyId: string;
6
+
7
+  family: Family;
8
+
9
+  spouse?: Family;
10
+
11
+  depth: number;
12
+
13
+  x: number;
14
+  y: number;
15
+
16
+  spouseX?: number;
17
+
18
+  width: number;
19
+  height: number;
20
+}

+ 501
- 530
src/app/pages/danka-detail/danka-detail.html
ファイル差分が大きすぎるため省略します
ファイルの表示


+ 30
- 0
src/app/pages/danka-detail/danka-detail.scss ファイルの表示

@@ -1091,3 +1091,33 @@
1091 1091
     bottom: -22px;
1092 1092
   }
1093 1093
 }
1094
+
1095
+.family-tree-svg-container {
1096
+  width: 100%;
1097
+  height: 700px;
1098
+
1099
+  overflow: auto;
1100
+
1101
+  border: 1px solid #ddd;
1102
+}
1103
+.family-tree-svg-container {
1104
+
1105
+  width: 100%;
1106
+
1107
+  height: 700px;
1108
+
1109
+  overflow: hidden;
1110
+
1111
+  border: 1px solid #ddd;
1112
+}
1113
+.family-node {
1114
+
1115
+  cursor: pointer;
1116
+
1117
+}
1118
+
1119
+.family-text {
1120
+
1121
+  cursor: pointer;
1122
+
1123
+}

+ 203
- 1
src/app/pages/danka-detail/danka-detail.ts ファイルの表示

@@ -1,4 +1,9 @@
1 1
 import { Component } from '@angular/core';
2
+import {
3
+  ElementRef,
4
+  ViewChild,
5
+  AfterViewInit
6
+} from '@angular/core';
2 7
 import { ActivatedRoute, RouterLink } from '@angular/router';
3 8
 import { DankaService } from '../../services/dankaService';
4 9
 import { FamilyService } from '../../services/family-service';
@@ -10,6 +15,20 @@ import { MarriageRelation } from '../../models/marriage-relation';
10 15
 import { AppHeader } from '../../share/header/app-header';
11 16
 import { AppSideMenu } from '../../share/side-menu/app-side-menu';
12 17
 import { MarriageRelationService } from '../../services/marriage-relation-service';
18
+import {
19
+  FamilyTreeBuilderService,
20
+  FamilyTreeNode
21
+} from '../../services/family-tree-builder';
22
+import {
23
+  FamilyTreeLayoutService
24
+} from '../../services/family-tree-layout';
25
+
26
+import {
27
+  LayoutNode
28
+} from '../../models/layoutnode';
29
+import svgPanZoom from 'svg-pan-zoom';
30
+import { FamilyUnitLayout } from '../../models/family-unit-layout';
31
+import { FamilyUnitLayoutService } from '../../services/family-unit-layout';
13 32
 
14 33
 interface NextMemorial {
15 34
   name: string;
@@ -24,23 +43,35 @@ interface NextMemorial {
24 43
   templateUrl: './danka-detail.html',
25 44
   styleUrl: './danka-detail.scss',
26 45
 })
27
-export class DankaDetail {
46
+export class DankaDetail implements AfterViewInit {
28 47
   danka: Danka | undefined;
29 48
   families: Family[] = [];
30 49
   kakocholist: Kakocho[] = [];
31 50
   marriageRelations: MarriageRelation[] = [];
32 51
   nextMemorial: NextMemorial | undefined;
33 52
   currentYear = new Date().getFullYear();
53
+  treeNodes: FamilyTreeNode[] = [];
54
+  layoutNodes: LayoutNode[] = [];
55
+  layoutNodeMap = new Map<string, LayoutNode>();
56
+  unitLayouts: FamilyUnitLayout[] = [];
57
+  unitLayoutMap = new Map<string, FamilyUnitLayout>();
34 58
 
35 59
   selectedTab: 'basic' | 'family' | 'kakocho' | 'familyTree' = 'basic';
36 60
   selectedFamily: Family | undefined = undefined;
37 61
 
62
+  @ViewChild('familyTreeSvg')
63
+  familyTreeSvg?: ElementRef<SVGSVGElement>;
64
+  private panZoomInstance: any;
65
+
38 66
   constructor(
39 67
     private dankaService: DankaService,
40 68
     private familyService: FamilyService,
41 69
     private kakochoService: KakochoService,
42 70
     private marriageRelationService: MarriageRelationService,
43 71
     private route: ActivatedRoute,
72
+    private familyTreeBuilder: FamilyTreeBuilderService,
73
+    private familyTreeLayout: FamilyTreeLayoutService,
74
+    private familyUnitLayout: FamilyUnitLayoutService,
44 75
   ) {
45 76
     const tab = this.route.snapshot.queryParams['tab'];
46 77
     if (tab === 'family') {
@@ -59,7 +90,120 @@ export class DankaDetail {
59 90
       this.selectedFamily = this.families[0];
60 91
       this.kakocholist = this.kakochoService.getKakochoByDankaId(id);
61 92
       this.nextMemorial = this.getNextMemorial();
93
+
94
+      this.treeNodes =
95
+        this.familyTreeBuilder.build(
96
+          this.families,
97
+          this.marriageRelations
98
+        );
99
+
100
+      const units =
101
+        this.familyTreeBuilder.buildFamilyUnits(
102
+          this.treeNodes
103
+        );
104
+
105
+      const unitTree =
106
+        this.familyTreeBuilder.buildFamilyUnitTree(
107
+          units
108
+        );
109
+
110
+      const unitRoots =
111
+        this.familyTreeBuilder.getUnitRoots(
112
+          unitTree
113
+        );
114
+
115
+      this.unitLayouts =
116
+        this.familyUnitLayout.buildLayout(
117
+          unitRoots
118
+        );
119
+
120
+      const roots =
121
+        this.familyTreeBuilder.getRoots(
122
+          this.treeNodes
123
+        );
124
+
125
+      this.layoutNodes =
126
+        this.familyTreeLayout.buildLayout(
127
+          roots
128
+        );
129
+
130
+      this.rebuildLayoutNodeMap();
131
+
132
+      this.unitLayouts =
133
+        this.familyUnitLayout.buildLayout(
134
+          unitRoots
135
+        );
136
+
137
+      this.rebuildUnitLayoutMap();
138
+
139
+    }
140
+  }
141
+  ngAfterViewInit(): void {
142
+    if (!this.familyTreeSvg) {
143
+      return;
144
+    }
145
+
146
+    svgPanZoom(
147
+      this.familyTreeSvg.nativeElement,
148
+      {
149
+        zoomEnabled: true,
150
+
151
+        controlIconsEnabled: true,
152
+
153
+        fit: true,
154
+
155
+        center: true,
156
+
157
+        minZoom: 0.2,
158
+
159
+        maxZoom: 20,
160
+
161
+        mouseWheelZoomEnabled: true,
162
+
163
+        dblClickZoomEnabled: true,
164
+
165
+        panEnabled: true
166
+      }
167
+    );
168
+  }
169
+
170
+  initializePanZoom(): void {
171
+
172
+    if (!this.familyTreeSvg) {
173
+      console.log('SVGなし');
174
+      return;
175
+    }
176
+
177
+    console.log('SVGあり');
178
+
179
+
180
+    if (!this.familyTreeSvg) {
181
+      return;
182
+    }
183
+
184
+    if (this.panZoomInstance) {
185
+      return;
62 186
     }
187
+
188
+    this.panZoomInstance = svgPanZoom(
189
+      this.familyTreeSvg.nativeElement,
190
+      {
191
+        zoomEnabled: true,
192
+        panEnabled: true,
193
+        fit: true,
194
+        center: true,
195
+      }
196
+    );
197
+
198
+  }
199
+  openFamilyTreeTab(): void {
200
+
201
+    this.selectedTab = 'familyTree';
202
+
203
+    setTimeout(() => {
204
+      this.initializePanZoom();
205
+    });
206
+
63 207
   }
64 208
 
65 209
   getAge(birthDate: string): string {
@@ -264,4 +408,62 @@ export class DankaDetail {
264 408
     }
265 409
     return '不明';
266 410
   }
411
+
412
+  getLayoutNode(
413
+    familyId: string
414
+  ): LayoutNode | undefined {
415
+
416
+    return this.layoutNodeMap.get(
417
+      familyId
418
+    );
419
+
420
+  }
421
+  getCurrentMarriageLines() {
422
+
423
+    return this.marriageRelations.filter(
424
+      x => x.status === 'current'
425
+    );
426
+
427
+  }
428
+
429
+  private rebuildLayoutNodeMap(): void {
430
+
431
+    this.layoutNodeMap.clear();
432
+
433
+    this.layoutNodes.forEach(node => {
434
+
435
+      this.layoutNodeMap.set(
436
+        node.familyId,
437
+        node
438
+      );
439
+
440
+    });
441
+
442
+  }
443
+
444
+  private rebuildUnitLayoutMap(): void {
445
+
446
+    this.unitLayoutMap.clear();
447
+
448
+    this.unitLayouts.forEach(layout => {
449
+
450
+      this.unitLayoutMap.set(
451
+        layout.node.unit.id,
452
+        layout
453
+      );
454
+
455
+    });
456
+
457
+  }
458
+
459
+  getUnitLayout(
460
+    unitId: string
461
+  ): FamilyUnitLayout | undefined {
462
+
463
+    return this.unitLayoutMap.get(
464
+      unitId
465
+    );
466
+
467
+  }
468
+
267 469
 }

+ 16
- 0
src/app/services/family-tree-builder.spec.ts ファイルの表示

@@ -0,0 +1,16 @@
1
+import { TestBed } from '@angular/core/testing';
2
+
3
+import { FamilyTreeBuilder } from './family-tree-builder';
4
+
5
+describe('FamilyTreeBuilder', () => {
6
+  let service: FamilyTreeBuilder;
7
+
8
+  beforeEach(() => {
9
+    TestBed.configureTestingModule({});
10
+    service = TestBed.inject(FamilyTreeBuilder);
11
+  });
12
+
13
+  it('should be created', () => {
14
+    expect(service).toBeTruthy();
15
+  });
16
+});

+ 421
- 0
src/app/services/family-tree-builder.ts ファイルの表示

@@ -0,0 +1,421 @@
1
+import { Injectable } from '@angular/core';
2
+
3
+import { Family } from '../models/family';
4
+import { MarriageRelation } from '../models/marriage-relation';
5
+import { FamilyUnit } from '../models/family-unit';
6
+import { FamilyUnitNode } from '../models/family-unit-node';
7
+
8
+export interface FamilyTreeNode {
9
+  family: Family;
10
+
11
+  parents: FamilyTreeNode[];
12
+
13
+  children: FamilyTreeNode[];
14
+
15
+  spouses: FamilyTreeNode[];
16
+}
17
+
18
+@Injectable({
19
+  providedIn: 'root',
20
+})
21
+export class FamilyTreeBuilderService {
22
+
23
+  build(
24
+    families: Family[],
25
+    marriages: MarriageRelation[],
26
+  ): FamilyTreeNode[] {
27
+
28
+    const nodeMap = new Map<string, FamilyTreeNode>();
29
+
30
+    //
31
+    // ノード生成
32
+    //
33
+    families.forEach((family) => {
34
+
35
+      nodeMap.set(family.id, {
36
+        family,
37
+        parents: [],
38
+        children: [],
39
+        spouses: [],
40
+      });
41
+
42
+    });
43
+
44
+    //
45
+    // 親子関係生成
46
+    //
47
+    families.forEach((family) => {
48
+
49
+      const childNode = nodeMap.get(family.id);
50
+
51
+      if (!childNode) {
52
+        return;
53
+      }
54
+
55
+      //
56
+      // 父
57
+      //
58
+      if (family.fatherId) {
59
+
60
+        const fatherNode =
61
+          nodeMap.get(family.fatherId);
62
+
63
+        if (fatherNode) {
64
+
65
+          fatherNode.children.push(childNode);
66
+
67
+          childNode.parents.push(fatherNode);
68
+        }
69
+      }
70
+
71
+      //
72
+      // 母
73
+      //
74
+      if (family.motherId) {
75
+
76
+        const motherNode =
77
+          nodeMap.get(family.motherId);
78
+
79
+        if (motherNode) {
80
+
81
+          motherNode.children.push(childNode);
82
+
83
+          childNode.parents.push(motherNode);
84
+        }
85
+      }
86
+
87
+    });
88
+
89
+    //
90
+    // 配偶者関係
91
+    //
92
+    marriages
93
+      .filter(
94
+        (m) =>
95
+          m.status === 'current',
96
+      )
97
+      .forEach((marriage) => {
98
+
99
+        const person1 =
100
+          nodeMap.get(
101
+            marriage.person1Id,
102
+          );
103
+
104
+        const person2 =
105
+          nodeMap.get(
106
+            marriage.person2Id,
107
+          );
108
+
109
+        if (!person1 || !person2) {
110
+          return;
111
+        }
112
+
113
+        const id1 =
114
+          Number(person1.family.id);
115
+
116
+        const id2 =
117
+          Number(person2.family.id);
118
+
119
+        const owner =
120
+          id1 < id2
121
+            ? person1
122
+            : person2;
123
+
124
+        const spouse =
125
+          id1 < id2
126
+            ? person2
127
+            : person1;
128
+
129
+        if (
130
+          !owner.spouses.some(
131
+            (s) =>
132
+              s.family.id ===
133
+              spouse.family.id,
134
+          )
135
+        ) {
136
+          owner.spouses.push(
137
+            spouse,
138
+          );
139
+        }
140
+      });
141
+
142
+    //
143
+    // spouseId フォールバック
144
+    //
145
+    families.forEach((family) => {
146
+
147
+      if (!family.spouseId) {
148
+        return;
149
+      }
150
+
151
+      const person =
152
+        nodeMap.get(family.id);
153
+
154
+      const spouse =
155
+        nodeMap.get(family.spouseId);
156
+
157
+      if (!person || !spouse) {
158
+        return;
159
+      }
160
+
161
+      if (
162
+        !person.spouses.some(
163
+          (s) =>
164
+            s.family.id ===
165
+            spouse.family.id
166
+        )
167
+      ) {
168
+
169
+        person.spouses.push(
170
+          spouse
171
+        );
172
+      }
173
+
174
+    });
175
+
176
+    return [...nodeMap.values()];
177
+  }
178
+
179
+  /**
180
+   * 家系図の起点になる人物
181
+   * (親が登録されていない人物)
182
+   */
183
+  getRoots(
184
+    nodes: FamilyTreeNode[]
185
+  ): FamilyTreeNode[] {
186
+
187
+    return nodes.filter(node => {
188
+
189
+      if (
190
+        node.parents.length > 0
191
+      ) {
192
+        return false;
193
+      }
194
+
195
+      // 配偶者がいて
196
+      if (
197
+        node.spouses.length > 0
198
+      ) {
199
+
200
+        const spouseId =
201
+          node.spouses[0].family.id;
202
+
203
+        // IDが若い方だけRoot
204
+        return (
205
+          node.family.id <
206
+          spouseId
207
+        );
208
+      }
209
+
210
+      return true;
211
+
212
+    });
213
+
214
+  }
215
+
216
+  /**
217
+   * ID検索
218
+   */
219
+  getNodeById(
220
+    nodes: FamilyTreeNode[],
221
+    id: string,
222
+  ): FamilyTreeNode | undefined {
223
+
224
+    return nodes.find(
225
+      (node) =>
226
+        node.family.id === id,
227
+    );
228
+  }
229
+
230
+  buildFamilyUnits(
231
+    nodes: FamilyTreeNode[]
232
+  ): FamilyUnit[] {
233
+
234
+    const units: FamilyUnit[] = [];
235
+
236
+    const processed =
237
+      new Set<string>();
238
+
239
+    for (const node of nodes) {
240
+
241
+      //
242
+      // 夫婦あり
243
+      //
244
+      if (node.spouses.length > 0) {
245
+
246
+        const spouse =
247
+          node.spouses[0];
248
+
249
+        const key =
250
+          [
251
+            node.family.id,
252
+            spouse.family.id
253
+          ]
254
+            .sort()
255
+            .join('-');
256
+
257
+        if (
258
+          processed.has(key)
259
+        ) {
260
+          continue;
261
+        }
262
+
263
+        processed.add(key);
264
+
265
+        //
266
+        // この夫婦の子供を取得
267
+        //
268
+        const children =
269
+          nodes
270
+            .filter(child => {
271
+
272
+              const fatherId =
273
+                child.family.fatherId;
274
+
275
+              const motherId =
276
+                child.family.motherId;
277
+
278
+              return (
279
+                (
280
+                  fatherId === node.family.id &&
281
+                  motherId === spouse.family.id
282
+                )
283
+                ||
284
+                (
285
+                  fatherId === spouse.family.id &&
286
+                  motherId === node.family.id
287
+                )
288
+              );
289
+
290
+            })
291
+            .map(child => child.family);
292
+
293
+        units.push({
294
+
295
+          id: key,
296
+
297
+          husband:
298
+            node.family.gender === 'male'
299
+              ? node.family
300
+              : spouse.family,
301
+
302
+          wife:
303
+            node.family.gender === 'female'
304
+              ? node.family
305
+              : spouse.family,
306
+
307
+          children
308
+
309
+        });
310
+
311
+      } else {
312
+
313
+        //
314
+        // 独身者
315
+        //
316
+        units.push({
317
+
318
+          id:
319
+            node.family.id,
320
+
321
+          husband:
322
+            node.family.gender === 'male'
323
+              ? node.family
324
+              : undefined,
325
+
326
+          wife:
327
+            node.family.gender === 'female'
328
+              ? node.family
329
+              : undefined,
330
+
331
+          children: []
332
+
333
+        });
334
+
335
+      }
336
+
337
+    }
338
+
339
+    return units;
340
+  }
341
+
342
+  buildFamilyUnitTree(
343
+    units: FamilyUnit[]
344
+  ): FamilyUnitNode[] {
345
+
346
+    const nodeMap =
347
+      new Map<string, FamilyUnitNode>();
348
+
349
+    //
350
+    // ノード生成
351
+    //
352
+    units.forEach(unit => {
353
+
354
+      nodeMap.set(unit.id, {
355
+
356
+        unit,
357
+
358
+        children: [],
359
+
360
+        parents: []
361
+
362
+      });
363
+
364
+    });
365
+
366
+    //
367
+    // 親子リンク
368
+    //
369
+    units.forEach(parentUnit => {
370
+
371
+      parentUnit.children.forEach(child => {
372
+
373
+        const childUnit =
374
+          units.find(u => {
375
+
376
+            return (
377
+              u.husband?.id === child.id ||
378
+              u.wife?.id === child.id
379
+            );
380
+
381
+          });
382
+
383
+        if (!childUnit) {
384
+          return;
385
+        }
386
+
387
+        const parentNode =
388
+          nodeMap.get(parentUnit.id)!;
389
+
390
+        const childNode =
391
+          nodeMap.get(childUnit.id)!;
392
+
393
+        parentNode.children.push(
394
+          childNode
395
+        );
396
+
397
+        childNode.parents.push(
398
+          parentNode
399
+        );
400
+
401
+      });
402
+
403
+    });
404
+
405
+    return [
406
+      ...nodeMap.values()
407
+    ];
408
+  }
409
+
410
+  getUnitRoots(
411
+    nodes: FamilyUnitNode[]
412
+  ): FamilyUnitNode[] {
413
+
414
+    return nodes.filter(
415
+      x =>
416
+        x.parents.length === 0
417
+    );
418
+
419
+  }
420
+
421
+}

+ 16
- 0
src/app/services/family-tree-layout.spec.ts ファイルの表示

@@ -0,0 +1,16 @@
1
+import { TestBed } from '@angular/core/testing';
2
+
3
+import { FamilyTreeLayout } from './family-tree-layout';
4
+
5
+describe('FamilyTreeLayout', () => {
6
+  let service: FamilyTreeLayout;
7
+
8
+  beforeEach(() => {
9
+    TestBed.configureTestingModule({});
10
+    service = TestBed.inject(FamilyTreeLayout);
11
+  });
12
+
13
+  it('should be created', () => {
14
+    expect(service).toBeTruthy();
15
+  });
16
+});

+ 164
- 0
src/app/services/family-tree-layout.ts ファイルの表示

@@ -0,0 +1,164 @@
1
+import { Injectable } from '@angular/core';
2
+
3
+import {
4
+  FamilyTreeNode
5
+} from './family-tree-builder';
6
+
7
+import { LayoutNode } from '../models/layoutnode';
8
+import { Family } from '../models/family';
9
+
10
+@Injectable({
11
+  providedIn: 'root'
12
+})
13
+export class FamilyTreeLayoutService {
14
+
15
+  private readonly NODE_WIDTH = 140;
16
+  private readonly NODE_HEIGHT = 70;
17
+
18
+  private readonly HORIZONTAL_GAP = 200;
19
+  private readonly VERTICAL_GAP = 200;
20
+
21
+  private readonly SPOUSE_GAP = 180;
22
+
23
+  private processedCouples = new Set<string>();
24
+  private currentX = 0;
25
+
26
+
27
+  buildLayout(
28
+    roots: FamilyTreeNode[]
29
+  ): LayoutNode[] {
30
+
31
+    this.currentX = 0;
32
+
33
+    this.processedCouples.clear();
34
+
35
+    const result: LayoutNode[] = [];
36
+
37
+    const visited = new Set<string>();
38
+
39
+    roots.forEach(root => {
40
+
41
+      this.layoutNode(
42
+        root,
43
+        0,
44
+        result,
45
+        visited
46
+      );
47
+
48
+      this.currentX +=
49
+        this.HORIZONTAL_GAP;
50
+    });
51
+
52
+    return result;
53
+  }
54
+
55
+  private layoutNode(
56
+    node: FamilyTreeNode,
57
+    depth: number,
58
+    result: LayoutNode[],
59
+    visited: Set<string>
60
+  ): number {
61
+
62
+    if (visited.has(node.family.id)) {
63
+
64
+      const existing = result.find(
65
+        x => x.familyId === node.family.id
66
+      );
67
+
68
+      return existing?.x ?? 0;
69
+    }
70
+
71
+    visited.add(node.family.id);
72
+
73
+    const children = node.children;
74
+
75
+    let x: number;
76
+
77
+    if (children.length === 0) {
78
+
79
+      x = this.currentX;
80
+
81
+      this.currentX +=
82
+        this.HORIZONTAL_GAP;
83
+
84
+    } else {
85
+
86
+      const childXs =
87
+        children.map(child =>
88
+          this.layoutNode(
89
+            child,
90
+            depth + 1,
91
+            result,
92
+            visited
93
+          )
94
+        );
95
+
96
+      x =
97
+        (Math.min(...childXs) +
98
+          Math.max(...childXs))
99
+        / 2;
100
+    }
101
+
102
+    const y =
103
+      depth *
104
+      this.VERTICAL_GAP;
105
+
106
+    let spouseX: number | undefined;
107
+    let spouse: Family | undefined;
108
+
109
+    if (node.spouses.length > 0) {
110
+
111
+      const spouseNode =
112
+        node.spouses[0];
113
+
114
+      const coupleKey =
115
+        [
116
+          node.family.id,
117
+          spouseNode.family.id
118
+        ]
119
+          .sort()
120
+          .join('-');
121
+
122
+      if (
123
+        !this.processedCouples.has(
124
+          coupleKey
125
+        )
126
+      ) {
127
+
128
+        this.processedCouples.add(
129
+          coupleKey
130
+        );
131
+
132
+        spouse =
133
+          spouseNode.family;
134
+
135
+        spouseX =
136
+          x + this.SPOUSE_GAP;
137
+      }
138
+    }
139
+
140
+    result.push({
141
+
142
+      familyId: node.family.id,
143
+
144
+      family: node.family,
145
+
146
+      spouse,
147
+
148
+      depth,
149
+
150
+      x,
151
+      y,
152
+
153
+      spouseX,
154
+
155
+      width: this.NODE_WIDTH,
156
+
157
+      height: this.NODE_HEIGHT
158
+
159
+    });
160
+
161
+    return x;
162
+  }
163
+
164
+}

+ 16
- 0
src/app/services/family-unit-layout.spec.ts ファイルの表示

@@ -0,0 +1,16 @@
1
+import { TestBed } from '@angular/core/testing';
2
+
3
+import { FamilyUnitLayout } from './family-unit-layout';
4
+
5
+describe('FamilyUnitLayout', () => {
6
+  let service: FamilyUnitLayout;
7
+
8
+  beforeEach(() => {
9
+    TestBed.configureTestingModule({});
10
+    service = TestBed.inject(FamilyUnitLayout);
11
+  });
12
+
13
+  it('should be created', () => {
14
+    expect(service).toBeTruthy();
15
+  });
16
+});

+ 138
- 0
src/app/services/family-unit-layout.ts ファイルの表示

@@ -0,0 +1,138 @@
1
+import { Injectable } from '@angular/core';
2
+
3
+import { FamilyUnitNode }
4
+  from '../models/family-unit-node';
5
+
6
+import { FamilyUnitLayout }
7
+  from '../models/family-unit-layout';
8
+
9
+@Injectable({
10
+  providedIn: 'root'
11
+})
12
+export class FamilyUnitLayoutService {
13
+
14
+  private readonly UNIT_WIDTH = 320;
15
+
16
+  private readonly UNIT_HEIGHT = 80;
17
+
18
+  private readonly HORIZONTAL_GAP = 100;
19
+
20
+  private readonly VERTICAL_GAP = 180;
21
+
22
+  private currentX = 0;
23
+
24
+  buildLayout(
25
+    roots: FamilyUnitNode[]
26
+  ): FamilyUnitLayout[] {
27
+
28
+    this.currentX = 0;
29
+
30
+    const result:
31
+      FamilyUnitLayout[] = [];
32
+
33
+    const visited =
34
+      new Set<string>();
35
+
36
+    roots.forEach(root => {
37
+
38
+      this.layoutNode(
39
+        root,
40
+        0,
41
+        result,
42
+        visited
43
+      );
44
+
45
+    });
46
+
47
+    return result;
48
+
49
+  }
50
+
51
+  private layoutNode(
52
+    node: FamilyUnitNode,
53
+    depth: number,
54
+    result: FamilyUnitLayout[],
55
+    visited: Set<string>
56
+  ): number {
57
+
58
+    if (
59
+      visited.has(
60
+        node.unit.id
61
+      )
62
+    ) {
63
+
64
+      const existing =
65
+        result.find(
66
+          x =>
67
+            x.node.unit.id ===
68
+            node.unit.id
69
+        );
70
+
71
+      return existing?.x ?? 0;
72
+
73
+    }
74
+
75
+    visited.add(
76
+      node.unit.id
77
+    );
78
+
79
+    let x: number;
80
+
81
+    if (
82
+      node.children.length === 0
83
+    ) {
84
+
85
+      x = this.currentX;
86
+
87
+      this.currentX +=
88
+        this.UNIT_WIDTH +
89
+        this.HORIZONTAL_GAP;
90
+
91
+    }
92
+    else {
93
+
94
+      const childXs =
95
+        node.children.map(
96
+          child =>
97
+            this.layoutNode(
98
+              child,
99
+              depth + 1,
100
+              result,
101
+              visited
102
+            )
103
+        );
104
+
105
+      x =
106
+        (
107
+          Math.min(...childXs)
108
+          +
109
+          Math.max(...childXs)
110
+        ) / 2;
111
+
112
+    }
113
+
114
+    const y =
115
+      depth *
116
+      this.VERTICAL_GAP;
117
+
118
+    result.push({
119
+
120
+      node,
121
+
122
+      x,
123
+
124
+      y,
125
+
126
+      width:
127
+        this.UNIT_WIDTH,
128
+
129
+      height:
130
+        this.UNIT_HEIGHT
131
+
132
+    });
133
+
134
+    return x;
135
+
136
+  }
137
+
138
+}

読み込み中…
キャンセル
保存