No Description
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

danka-detail.ts 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790
  1. import { Component } from '@angular/core';
  2. import {
  3. ElementRef,
  4. ViewChild,
  5. AfterViewInit
  6. } from '@angular/core';
  7. import { OnInit } from '@angular/core';
  8. import { ActivatedRoute, RouterLink } from '@angular/router';
  9. import { DankaService } from '../../services/dankaService';
  10. import { FamilyService } from '../../services/family-service';
  11. import { KakochoService } from '../../services/kakocho-service';
  12. import { Danka } from '../../models/danka';
  13. import { Family } from '../../models/family';
  14. import { Kakocho } from '../../models/kakocho';
  15. import { MarriageRelation } from '../../models/marriage-relation';
  16. import { AppHeader } from '../../share/header/app-header';
  17. import { AppSideMenu } from '../../share/side-menu/app-side-menu';
  18. import { MarriageRelationService } from '../../services/marriage-relation-service';
  19. import { FormsModule } from '@angular/forms';
  20. import { EventStatus, EventTarget, EventType } from '../../models/event';
  21. import { EventService } from '../../services/event-service';
  22. import {
  23. FamilyTreeBuilderService,
  24. FamilyTreeNode
  25. } from '../../services/family-tree-builder';
  26. import {
  27. FamilyTreeLayoutService
  28. } from '../../services/family-tree-layout';
  29. import {
  30. LayoutNode
  31. } from '../../models/layoutnode';
  32. import svgPanZoom from 'svg-pan-zoom';
  33. import { FamilyUnitLayout } from '../../models/family-unit-layout';
  34. import { FamilyUnitLayoutService } from '../../services/family-unit-layout';
  35. interface NextMemorial {
  36. name: string;
  37. memorialType: string;
  38. targetYear: number;
  39. deathDate: string;
  40. }
  41. @Component({
  42. selector: 'app-danka-detail',
  43. imports: [AppHeader, AppSideMenu, RouterLink, FormsModule],
  44. templateUrl: './danka-detail.html',
  45. styleUrl: './danka-detail.scss',
  46. })
  47. export class DankaDetail implements OnInit, AfterViewInit {
  48. danka: Danka | undefined;
  49. families: Family[] = [];
  50. kakocholist: Kakocho[] = [];
  51. marriageRelations: MarriageRelation[] = [];
  52. nextMemorial: NextMemorial | undefined;
  53. currentYear = new Date().getFullYear();
  54. familySearchKeyword = '';
  55. eventSearchKeyword = '';
  56. eventStatuses: EventStatus[] = ['未案内', '案内済'];
  57. treeNodes: FamilyTreeNode[] = [];
  58. layoutNodes: LayoutNode[] = [];
  59. layoutNodeMap = new Map<string, LayoutNode>();
  60. unitLayouts: FamilyUnitLayout[] = [];
  61. unitLayoutMap = new Map<string, FamilyUnitLayout>();
  62. deathDateMap = new Map<string, Kakocho>();
  63. private kakochoByNameMap = new Map<string, Kakocho>();
  64. selectedTab: 'basic' | 'family' | 'kakocho' | 'event' | 'familyTree' = 'basic';
  65. selectedFamily: Family | undefined = undefined;
  66. @ViewChild('familyTreeSvg')
  67. familyTreeSvg?: ElementRef<SVGSVGElement>;
  68. private panZoomInstance: any;
  69. readonly PERSON_WIDTH = 90;
  70. readonly PERSON_HEIGHT = 140;
  71. readonly SPOUSE_GAP = 30;
  72. viewBox = '0 0 6000 6000';
  73. readonly DEATH_FONT_SIZE = 10;
  74. readonly DEATH_LINE_HEIGHT = 10; // 少し余白込み
  75. constructor(
  76. private dankaService: DankaService,
  77. private familyService: FamilyService,
  78. private kakochoService: KakochoService,
  79. private marriageRelationService: MarriageRelationService,
  80. private route: ActivatedRoute,
  81. private familyTreeBuilder: FamilyTreeBuilderService,
  82. private familyTreeLayout: FamilyTreeLayoutService,
  83. private familyUnitLayout: FamilyUnitLayoutService,
  84. private eventService: EventService,
  85. ) {
  86. const tab = this.route.snapshot.queryParams['tab'];
  87. if (tab === 'family') {
  88. this.selectedTab = 'family';
  89. } else if (tab === 'kakocho') {
  90. this.selectedTab = 'kakocho';
  91. } else if (tab === 'event') {
  92. this.selectedTab = 'event';
  93. } else if (tab === 'familyTree') {
  94. this.selectedTab = 'familyTree';
  95. }
  96. }
  97. ngOnInit(): void {
  98. this.init();
  99. }
  100. async init(): Promise<void> {
  101. const id = this.route.snapshot.params['id'];
  102. if (!id) return;
  103. this.danka = (await this.dankaService.getDankaById(id)) ?? undefined;
  104. if (!this.danka) return;
  105. this.marriageRelations = await this.marriageRelationService.getMarriageRelationsByDankaId(id);
  106. this.families = this.sortFamiliesByHouseholder(
  107. await this.familyService.getFamiliesByDankaId(id)
  108. );
  109. this.selectedFamily = this.families[0];
  110. this.kakocholist = await this.kakochoService.getKakochoByDankaId(id);
  111. this.nextMemorial = this.getNextMemorial();
  112. this.treeNodes =
  113. this.familyTreeBuilder.build(
  114. this.families,
  115. this.marriageRelations
  116. );
  117. const units =
  118. this.familyTreeBuilder.buildFamilyUnits(
  119. this.treeNodes
  120. );
  121. const unitTree =
  122. this.familyTreeBuilder.buildFamilyUnitTree(
  123. units
  124. );
  125. const unitRoots =
  126. this.familyTreeBuilder.getUnitRoots(
  127. unitTree
  128. );
  129. this.unitLayouts =
  130. this.familyUnitLayout.buildLayout(
  131. unitRoots
  132. );
  133. const roots =
  134. this.familyTreeBuilder.getRoots(
  135. this.treeNodes
  136. );
  137. this.layoutNodes =
  138. this.familyTreeLayout.buildLayout(
  139. roots
  140. );
  141. this.rebuildLayoutNodeMap();
  142. this.unitLayouts =
  143. this.familyUnitLayout.buildLayout(
  144. unitRoots
  145. );
  146. this.rebuildUnitLayoutMap();
  147. this.calculateViewBox();
  148. this.kakocholist.forEach(kakocho => {
  149. if (kakocho.familyId) {
  150. this.deathDateMap.set(
  151. kakocho.familyId,
  152. kakocho
  153. );
  154. }
  155. });
  156. this.kakocholist.forEach(k => {
  157. const key = this.normalizeName(k.name) + '_' + k.dankaId;
  158. this.kakochoByNameMap.set(key, k);
  159. });
  160. }
  161. ngAfterViewInit(): void {
  162. if (!this.familyTreeSvg) {
  163. return;
  164. }
  165. this.panZoomInstance = svgPanZoom(
  166. this.familyTreeSvg.nativeElement,
  167. {
  168. fit: true,
  169. center: true,
  170. zoomEnabled: true,
  171. panEnabled: true,
  172. minZoom: 0.1,
  173. maxZoom: 50
  174. }
  175. );
  176. }
  177. initializePanZoom(): void {
  178. if (!this.familyTreeSvg) {
  179. console.log('SVGなし');
  180. return;
  181. }
  182. console.log('SVGあり');
  183. if (!this.familyTreeSvg) {
  184. return;
  185. }
  186. if (this.panZoomInstance) {
  187. return;
  188. }
  189. this.panZoomInstance = svgPanZoom(
  190. this.familyTreeSvg.nativeElement,
  191. {
  192. zoomEnabled: true,
  193. panEnabled: true,
  194. fit: true,
  195. center: true,
  196. }
  197. );
  198. }
  199. openFamilyTreeTab(): void {
  200. this.selectedTab = 'familyTree';
  201. setTimeout(() => {
  202. this.initializePanZoom();
  203. });
  204. }
  205. getAge(birthDate: string): string {
  206. if (birthDate === '') {
  207. return '-';
  208. }
  209. const birth = new Date(birthDate);
  210. const today = new Date();
  211. const thisYearBirthday = new Date(today.getFullYear(), birth.getMonth(), birth.getDate());
  212. let age = today.getFullYear() - birth.getFullYear();
  213. if (thisYearBirthday > today) {
  214. age--;
  215. }
  216. return age.toString();
  217. }
  218. get filteredFamilies(): Family[] {
  219. const keyword = this.familySearchKeyword.trim();
  220. if (!keyword) {
  221. return this.families;
  222. }
  223. return this.families.filter((family) =>
  224. [
  225. family.name,
  226. family.furigana,
  227. family.birthDate,
  228. family.relationship,
  229. family.note,
  230. ].some((value) => value.includes(keyword)),
  231. );
  232. }
  233. getFamilyEventLabels(family: Family): string {
  234. const birthDate = this.parseDate(family.birthDate);
  235. if (!birthDate) {
  236. return '-';
  237. }
  238. const age = this.currentYear - birthDate.getFullYear();
  239. const eventTypes = this.getEventTypes(age);
  240. return eventTypes.length > 0 ? eventTypes.join('、') : '-';
  241. }
  242. get eventTargets(): EventTarget[] {
  243. if (!this.danka) {
  244. return [];
  245. }
  246. return this.families
  247. .flatMap((family) => {
  248. const birthDate = this.parseDate(family.birthDate);
  249. if (!birthDate) {
  250. return [];
  251. }
  252. const age = this.currentYear - birthDate.getFullYear();
  253. return this.getEventTypes(age).map((eventType) => {
  254. const id = `${family.id}-${eventType}`;
  255. const defaultStatus: EventStatus = Number(family.id) % 2 === 0 ? '案内済' : '未案内';
  256. return {
  257. id,
  258. dankaId: family.dankaId,
  259. name: family.name,
  260. furigana: family.furigana,
  261. householdName: this.danka?.householdName ?? '不明',
  262. relationship: family.relationship || '未登録',
  263. birthDate: family.birthDate,
  264. age,
  265. eventType,
  266. note: family.note,
  267. status: this.eventService.getEventStatus(id, defaultStatus),
  268. };
  269. });
  270. })
  271. .sort(
  272. (a, b) =>
  273. this.getEventSortOrder(a.eventType) - this.getEventSortOrder(b.eventType) ||
  274. a.age - b.age ||
  275. a.name.localeCompare(b.name, 'ja'),
  276. );
  277. }
  278. get filteredEventTargets(): EventTarget[] {
  279. const keyword = this.eventSearchKeyword.trim();
  280. if (!keyword) {
  281. return this.eventTargets;
  282. }
  283. return this.eventTargets.filter((target) =>
  284. [
  285. target.name,
  286. target.furigana,
  287. target.householdName,
  288. target.relationship,
  289. target.birthDate,
  290. target.age.toString(),
  291. target.eventType,
  292. target.note,
  293. target.status,
  294. ].some((value) => value.includes(keyword)),
  295. );
  296. }
  297. changeEventStatus(target: EventTarget, status: EventStatus): void {
  298. target.status = status;
  299. this.eventService.saveEventStatus(target.id, status);
  300. }
  301. getKaiki(deathDate: string): number {
  302. return this.currentYear - new Date(deathDate).getFullYear() + 1;
  303. }
  304. formatUpdatedAt(updatedAt: string): string {
  305. const date = this.parseDate(updatedAt);
  306. if (!date) {
  307. return '未登録';
  308. }
  309. return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
  310. }
  311. formatDeathDateWithYear(deathDate: string): string {
  312. const date = this.parseDate(deathDate);
  313. if (!date) {
  314. return '未登録';
  315. }
  316. return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
  317. }
  318. getMemorialType(deathDate: string): string {
  319. const deathYear = Number(deathDate.slice(0, 4));
  320. const yearDiff = this.currentYear - deathYear;
  321. switch (yearDiff) {
  322. case 1:
  323. return '一周忌';
  324. case 2:
  325. return '三回忌';
  326. case 6:
  327. return '七回忌';
  328. case 12:
  329. return '十三回忌';
  330. case 16:
  331. return '十七回忌';
  332. case 22:
  333. return '二十三回忌';
  334. default:
  335. return '';
  336. }
  337. }
  338. private getEventTypes(age: number): EventType[] {
  339. const eventTypes: EventType[] = [];
  340. if (age >= 3 && age <= 12) {
  341. eventTypes.push('稚児行列');
  342. }
  343. if ([3, 5, 7].includes(age)) {
  344. eventTypes.push('七五三');
  345. }
  346. if (age === 20) {
  347. eventTypes.push('成人式');
  348. }
  349. if (age === 88) {
  350. eventTypes.push('米寿');
  351. }
  352. return eventTypes;
  353. }
  354. private getEventSortOrder(eventType: EventType): number {
  355. return ['稚児行列', '七五三', '成人式', '米寿'].indexOf(eventType);
  356. }
  357. private getNextMemorial(): NextMemorial | undefined {
  358. const today = this.toDateOnly(new Date());
  359. return this.kakocholist
  360. .map((kakocho) => {
  361. const memorialType = this.getMemorialType(kakocho.deathDate);
  362. const deathDate = this.parseDate(kakocho.deathDate);
  363. if (!memorialType || !deathDate) {
  364. return undefined;
  365. }
  366. const memorialDate = new Date(
  367. this.currentYear,
  368. deathDate.getMonth(),
  369. deathDate.getDate(),
  370. );
  371. if (memorialDate < today) {
  372. return undefined;
  373. }
  374. return {
  375. memorialDate,
  376. memorial: {
  377. name: kakocho.name,
  378. memorialType,
  379. targetYear: this.currentYear,
  380. deathDate: kakocho.deathDate,
  381. },
  382. };
  383. })
  384. .filter(
  385. (item): item is { memorialDate: Date; memorial: NextMemorial } => item !== undefined,
  386. )
  387. .sort(
  388. (a, b) =>
  389. a.memorialDate.getTime() - b.memorialDate.getTime() ||
  390. a.memorial.name.localeCompare(b.memorial.name, 'ja'),
  391. )[0]?.memorial;
  392. }
  393. private toDateOnly(date: Date): Date {
  394. return new Date(date.getFullYear(), date.getMonth(), date.getDate());
  395. }
  396. private parseDate(value: string): Date | null {
  397. const [year, month, day] = value.split('-').map(Number);
  398. if (!year || !month || !day) {
  399. return null;
  400. }
  401. return new Date(year, month - 1, day);
  402. }
  403. selectFamily(family: Family): void {
  404. this.selectedFamily = family;
  405. }
  406. private sortFamiliesByHouseholder(families: Family[]): Family[] {
  407. if (!this.danka) {
  408. return families;
  409. }
  410. const householderName = this.normalizeName(this.danka.householder);
  411. return [...families].sort((a, b) => {
  412. const aIsHouseholder = this.normalizeName(a.name) === householderName;
  413. const bIsHouseholder = this.normalizeName(b.name) === householderName;
  414. if (aIsHouseholder === bIsHouseholder) {
  415. return 0;
  416. }
  417. return aIsHouseholder ? -1 : 1;
  418. });
  419. }
  420. private normalizeName(name: string): string {
  421. return name.replace(/\s/g, '');
  422. }
  423. getFamilyById(id: string): Family | undefined {
  424. if (!id) {
  425. return undefined;
  426. }
  427. return this.families.find((family) => family.id === id);
  428. }
  429. getFather(family: Family): Family | undefined {
  430. return this.getFamilyById(family.fatherId);
  431. }
  432. getMother(family: Family): Family | undefined {
  433. return this.getFamilyById(family.motherId);
  434. }
  435. getSpouse(family: Family): Family | undefined {
  436. return this.getFamilyById(family.spouseId);
  437. }
  438. getChildren(family: Family): Family[] {
  439. return this.families.filter(
  440. (child) => child.fatherId === family.id || child.motherId === family.id,
  441. );
  442. }
  443. getCurrentMarriage(family: Family): MarriageRelation | undefined {
  444. return this.marriageRelations.find(
  445. (relation) =>
  446. relation.status === 'current' &&
  447. (relation.person1Id === family.id || relation.person2Id === family.id),
  448. );
  449. }
  450. getPastMarriages(family: Family): MarriageRelation[] {
  451. return this.marriageRelations.filter(
  452. (relation) =>
  453. relation.status !== 'current' &&
  454. (relation.person1Id === family.id || relation.person2Id === family.id),
  455. );
  456. }
  457. getMarriagePartner(relation: MarriageRelation, family: Family): Family | undefined {
  458. const partnerId = relation.person1Id === family.id ? relation.person2Id : relation.person1Id;
  459. return this.getFamilyById(partnerId);
  460. }
  461. getMarriageStatusLabel(status: string): string {
  462. if (status === 'current') {
  463. return '現在の配偶者';
  464. }
  465. if (status === 'divorced') {
  466. return '離婚';
  467. }
  468. if (status === 'widowed') {
  469. return '死別';
  470. }
  471. return '不明';
  472. }
  473. getLayoutNode(
  474. familyId: string
  475. ): LayoutNode | undefined {
  476. return this.layoutNodeMap.get(
  477. familyId
  478. );
  479. }
  480. getCurrentMarriageLines() {
  481. return this.marriageRelations.filter(
  482. x => x.status === 'current'
  483. );
  484. }
  485. private rebuildLayoutNodeMap(): void {
  486. this.layoutNodeMap.clear();
  487. this.layoutNodes.forEach(node => {
  488. this.layoutNodeMap.set(
  489. node.familyId,
  490. node
  491. );
  492. });
  493. }
  494. private rebuildUnitLayoutMap(): void {
  495. this.unitLayoutMap.clear();
  496. this.unitLayouts.forEach(layout => {
  497. this.unitLayoutMap.set(
  498. layout.node.unit.id,
  499. layout
  500. );
  501. });
  502. }
  503. getUnitLayout(
  504. unitId: string
  505. ): FamilyUnitLayout | undefined {
  506. return this.unitLayoutMap.get(
  507. unitId
  508. );
  509. }
  510. getCenterX(layout: FamilyUnitLayout): number {
  511. return (
  512. layout.x +
  513. this.PERSON_WIDTH +
  514. this.SPOUSE_GAP / 2
  515. );
  516. }
  517. getTopCenterY(
  518. layout: FamilyUnitLayout
  519. ): number {
  520. return layout.y;
  521. }
  522. getBottomCenterY(
  523. layout: FamilyUnitLayout
  524. ): number {
  525. return (
  526. layout.y +
  527. this.PERSON_HEIGHT
  528. );
  529. }
  530. getMiddleY(
  531. parent: FamilyUnitLayout,
  532. child: FamilyUnitLayout
  533. ): number {
  534. return (
  535. this.getBottomCenterY(parent)
  536. +
  537. child.y
  538. ) / 2;
  539. }
  540. getHusbandX(
  541. layout: FamilyUnitLayout
  542. ): number {
  543. if (!layout.node.unit.wife) {
  544. return (
  545. layout.x +
  546. (this.PERSON_WIDTH + this.SPOUSE_GAP) / 2
  547. );
  548. }
  549. return layout.x;
  550. }
  551. getWifeX(
  552. layout: FamilyUnitLayout
  553. ): number {
  554. if (!layout.node.unit.husband) {
  555. return (
  556. layout.x +
  557. (this.PERSON_WIDTH + this.SPOUSE_GAP) / 2
  558. );
  559. }
  560. return (
  561. layout.x +
  562. this.PERSON_WIDTH +
  563. this.SPOUSE_GAP
  564. );
  565. }
  566. getHusbandTextX(
  567. layout: FamilyUnitLayout
  568. ): number {
  569. return (
  570. this.getHusbandX(layout)
  571. + this.PERSON_WIDTH / 2
  572. );
  573. }
  574. getWifeTextX(
  575. layout: FamilyUnitLayout
  576. ): number {
  577. return (
  578. this.getWifeX(layout)
  579. + this.PERSON_WIDTH / 2
  580. );
  581. }
  582. private calculateViewBox(): void {
  583. if (this.unitLayouts.length === 0) {
  584. return;
  585. }
  586. const minX = Math.min(
  587. ...this.unitLayouts.map(x => x.x)
  588. );
  589. const minY = Math.min(
  590. ...this.unitLayouts.map(x => x.y)
  591. );
  592. const maxX = Math.max(
  593. ...this.unitLayouts.map(
  594. x => x.x + this.PERSON_WIDTH * 2 + this.SPOUSE_GAP
  595. )
  596. );
  597. const maxY = Math.max(
  598. ...this.unitLayouts.map(
  599. x => x.y + this.PERSON_HEIGHT
  600. )
  601. );
  602. const padding = 100;
  603. this.viewBox =
  604. `${minX - padding}
  605. ${minY - padding}
  606. ${maxX - minX + padding * 2}
  607. ${maxY - minY + padding * 2}`;
  608. }
  609. getKakochoByFamilyId(
  610. familyId: string
  611. ): Kakocho | undefined {
  612. return this.deathDateMap.get(
  613. familyId
  614. );
  615. }
  616. getKakochoByFamily(family: Family): Kakocho | undefined {
  617. const key =
  618. this.normalizeName(family.name) + '_' + family.dankaId;
  619. return this.kakochoByNameMap.get(key);
  620. }
  621. getDeathWareki(family: Family): string {
  622. const kakocho = this.getKakochoByFamily(family);
  623. if (!kakocho?.deathDate) return '';
  624. const date = new Date(kakocho.deathDate);
  625. const wareki = new Intl.DateTimeFormat('ja-JP-u-ca-japanese', {
  626. era: 'long',
  627. year: 'numeric',
  628. }).format(date);
  629. return `${wareki}${date.getMonth() + 1}月${date.getDate()}日没`;
  630. }
  631. getAgeAtDeathText(family: Family): string {
  632. const kakocho = this.getKakochoByFamily(family);
  633. if (!kakocho?.ageAtDeath) return '';
  634. return `享年${kakocho.ageAtDeath}歳`;
  635. }
  636. getDeathDate(family: Family): string {
  637. const kakocho = this.getKakochoByFamily(family);
  638. return kakocho?.deathDate ?? '';
  639. }
  640. /**
  641. * 縦書きテキストの高さを概算する
  642. */
  643. getVerticalTextHeight(text: string | null | undefined): number {
  644. if (!text) return 0;
  645. // 縦書きは基本「1文字=1行」
  646. return text.length * this.DEATH_LINE_HEIGHT;
  647. }
  648. /**
  649. * テキストをカード下に揃えるためのY座標
  650. */
  651. getDeathTextY(layout: FamilyUnitLayout, text: string | null | undefined): number {
  652. const height = this.getVerticalTextHeight(text);
  653. return (
  654. layout.y +
  655. this.PERSON_HEIGHT - 5 - height
  656. );
  657. }
  658. }