浏览代码

Merge branch 'master' into develop

poohr 3 周前
父节点
当前提交
6e6dcb77e5

+ 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
+}

+ 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
+});

+ 147
- 0
src/app/services/family-unit-layout.ts 查看文件

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

正在加载...
取消
保存