import { Component } from '@angular/core'; import { ElementRef, ViewChild, AfterViewInit } from '@angular/core'; import { OnInit } from '@angular/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { DankaService } from '../../services/dankaService'; import { FamilyService } from '../../services/family-service'; import { KakochoService } from '../../services/kakocho-service'; import { Danka } from '../../models/danka'; import { Family } from '../../models/family'; import { Kakocho } from '../../models/kakocho'; import { MarriageRelation } from '../../models/marriage-relation'; import { AppHeader } from '../../share/header/app-header'; import { AppSideMenu } from '../../share/side-menu/app-side-menu'; import { MarriageRelationService } from '../../services/marriage-relation-service'; import { FormsModule } from '@angular/forms'; import { EventStatus, EventTarget, EventType } from '../../models/event'; import { EventService } from '../../services/event-service'; import { FamilyTreeBuilderService, FamilyTreeNode } from '../../services/family-tree-builder'; import { FamilyTreeLayoutService } from '../../services/family-tree-layout'; import { LayoutNode } from '../../models/layoutnode'; import svgPanZoom from 'svg-pan-zoom'; import { FamilyUnitLayout } from '../../models/family-unit-layout'; import { FamilyUnitLayoutService } from '../../services/family-unit-layout'; interface NextMemorial { name: string; memorialType: string; targetYear: number; deathDate: string; } @Component({ selector: 'app-danka-detail', imports: [AppHeader, AppSideMenu, RouterLink, FormsModule], templateUrl: './danka-detail.html', styleUrl: './danka-detail.scss', }) export class DankaDetail implements OnInit, AfterViewInit { danka: Danka | undefined; families: Family[] = []; kakocholist: Kakocho[] = []; marriageRelations: MarriageRelation[] = []; nextMemorial: NextMemorial | undefined; currentYear = new Date().getFullYear(); familySearchKeyword = ''; eventSearchKeyword = ''; eventStatuses: EventStatus[] = ['未案内', '案内済']; treeNodes: FamilyTreeNode[] = []; layoutNodes: LayoutNode[] = []; layoutNodeMap = new Map(); unitLayouts: FamilyUnitLayout[] = []; unitLayoutMap = new Map(); deathDateMap = new Map(); private kakochoByNameMap = new Map(); selectedTab: 'basic' | 'family' | 'kakocho' | 'event' | 'familyTree' = 'basic'; selectedFamily: Family | undefined = undefined; @ViewChild('familyTreeSvg') familyTreeSvg?: ElementRef; private panZoomInstance: any; readonly PERSON_WIDTH = 90; readonly PERSON_HEIGHT = 140; readonly SPOUSE_GAP = 30; viewBox = '0 0 6000 6000'; readonly DEATH_FONT_SIZE = 10; readonly DEATH_LINE_HEIGHT = 10; // 少し余白込み constructor( private dankaService: DankaService, private familyService: FamilyService, private kakochoService: KakochoService, private marriageRelationService: MarriageRelationService, private route: ActivatedRoute, private familyTreeBuilder: FamilyTreeBuilderService, private familyTreeLayout: FamilyTreeLayoutService, private familyUnitLayout: FamilyUnitLayoutService, private eventService: EventService, ) { const tab = this.route.snapshot.queryParams['tab']; if (tab === 'family') { this.selectedTab = 'family'; } else if (tab === 'kakocho') { this.selectedTab = 'kakocho'; } else if (tab === 'event') { this.selectedTab = 'event'; } else if (tab === 'familyTree') { this.selectedTab = 'familyTree'; } } ngOnInit(): void { this.init(); } async init(): Promise { const id = this.route.snapshot.params['id']; if (!id) return; this.danka = (await this.dankaService.getDankaById(id)) ?? undefined; if (!this.danka) return; this.marriageRelations = await this.marriageRelationService.getMarriageRelationsByDankaId(id); this.families = this.sortFamiliesByHouseholder( await this.familyService.getFamiliesByDankaId(id) ); this.selectedFamily = this.families[0]; this.kakocholist = await this.kakochoService.getKakochoByDankaId(id); this.nextMemorial = this.getNextMemorial(); this.treeNodes = this.familyTreeBuilder.build( this.families, this.marriageRelations ); const units = this.familyTreeBuilder.buildFamilyUnits( this.treeNodes ); const unitTree = this.familyTreeBuilder.buildFamilyUnitTree( units ); const unitRoots = this.familyTreeBuilder.getUnitRoots( unitTree ); this.unitLayouts = this.familyUnitLayout.buildLayout( unitRoots ); const roots = this.familyTreeBuilder.getRoots( this.treeNodes ); this.layoutNodes = this.familyTreeLayout.buildLayout( roots ); this.rebuildLayoutNodeMap(); this.unitLayouts = this.familyUnitLayout.buildLayout( unitRoots ); this.rebuildUnitLayoutMap(); this.calculateViewBox(); this.kakocholist.forEach(kakocho => { if (kakocho.familyId) { this.deathDateMap.set( kakocho.familyId, kakocho ); } }); this.kakocholist.forEach(k => { const key = this.normalizeName(k.name) + '_' + k.dankaId; this.kakochoByNameMap.set(key, k); }); } ngAfterViewInit(): void { if (!this.familyTreeSvg) { return; } this.panZoomInstance = svgPanZoom( this.familyTreeSvg.nativeElement, { fit: true, center: true, zoomEnabled: true, panEnabled: true, minZoom: 0.1, maxZoom: 50 } ); } initializePanZoom(): void { if (!this.familyTreeSvg) { console.log('SVGなし'); return; } console.log('SVGあり'); if (!this.familyTreeSvg) { return; } if (this.panZoomInstance) { return; } this.panZoomInstance = svgPanZoom( this.familyTreeSvg.nativeElement, { zoomEnabled: true, panEnabled: true, fit: true, center: true, } ); } openFamilyTreeTab(): void { this.selectedTab = 'familyTree'; setTimeout(() => { this.initializePanZoom(); }); } getAge(birthDate: string): string { if (birthDate === '') { return '-'; } const birth = new Date(birthDate); const today = new Date(); const thisYearBirthday = new Date(today.getFullYear(), birth.getMonth(), birth.getDate()); let age = today.getFullYear() - birth.getFullYear(); if (thisYearBirthday > today) { age--; } return age.toString(); } get filteredFamilies(): Family[] { const keyword = this.familySearchKeyword.trim(); if (!keyword) { return this.families; } return this.families.filter((family) => [ family.name, family.furigana, family.birthDate, family.relationship, family.note, ].some((value) => value.includes(keyword)), ); } getFamilyEventLabels(family: Family): string { const birthDate = this.parseDate(family.birthDate); if (!birthDate) { return '-'; } const age = this.currentYear - birthDate.getFullYear(); const eventTypes = this.getEventTypes(age); return eventTypes.length > 0 ? eventTypes.join('、') : '-'; } get eventTargets(): EventTarget[] { if (!this.danka) { return []; } return this.families .flatMap((family) => { const birthDate = this.parseDate(family.birthDate); if (!birthDate) { return []; } const age = this.currentYear - birthDate.getFullYear(); return this.getEventTypes(age).map((eventType) => { const id = `${family.id}-${eventType}`; const defaultStatus: EventStatus = Number(family.id) % 2 === 0 ? '案内済' : '未案内'; return { id, dankaId: family.dankaId, name: family.name, furigana: family.furigana, householdName: this.danka?.householdName ?? '不明', relationship: family.relationship || '未登録', birthDate: family.birthDate, age, eventType, note: family.note, status: this.eventService.getEventStatus(id, defaultStatus), }; }); }) .sort( (a, b) => this.getEventSortOrder(a.eventType) - this.getEventSortOrder(b.eventType) || a.age - b.age || a.name.localeCompare(b.name, 'ja'), ); } get filteredEventTargets(): EventTarget[] { const keyword = this.eventSearchKeyword.trim(); if (!keyword) { return this.eventTargets; } return this.eventTargets.filter((target) => [ target.name, target.furigana, target.householdName, target.relationship, target.birthDate, target.age.toString(), target.eventType, target.note, target.status, ].some((value) => value.includes(keyword)), ); } changeEventStatus(target: EventTarget, status: EventStatus): void { target.status = status; this.eventService.saveEventStatus(target.id, status); } getKaiki(deathDate: string): number { return this.currentYear - new Date(deathDate).getFullYear() + 1; } formatUpdatedAt(updatedAt: string): string { const date = this.parseDate(updatedAt); if (!date) { return '未登録'; } return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`; } formatDeathDateWithYear(deathDate: string): string { const date = this.parseDate(deathDate); if (!date) { return '未登録'; } return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`; } getMemorialType(deathDate: string): string { const deathYear = Number(deathDate.slice(0, 4)); const yearDiff = this.currentYear - deathYear; switch (yearDiff) { case 1: return '一周忌'; case 2: return '三回忌'; case 6: return '七回忌'; case 12: return '十三回忌'; case 16: return '十七回忌'; case 22: return '二十三回忌'; default: return ''; } } private getEventTypes(age: number): EventType[] { const eventTypes: EventType[] = []; if (age >= 3 && age <= 12) { eventTypes.push('稚児行列'); } if ([3, 5, 7].includes(age)) { eventTypes.push('七五三'); } if (age === 20) { eventTypes.push('成人式'); } if (age === 88) { eventTypes.push('米寿'); } return eventTypes; } private getEventSortOrder(eventType: EventType): number { return ['稚児行列', '七五三', '成人式', '米寿'].indexOf(eventType); } private getNextMemorial(): NextMemorial | undefined { const today = this.toDateOnly(new Date()); return this.kakocholist .map((kakocho) => { const memorialType = this.getMemorialType(kakocho.deathDate); const deathDate = this.parseDate(kakocho.deathDate); if (!memorialType || !deathDate) { return undefined; } const memorialDate = new Date( this.currentYear, deathDate.getMonth(), deathDate.getDate(), ); if (memorialDate < today) { return undefined; } return { memorialDate, memorial: { name: kakocho.name, memorialType, targetYear: this.currentYear, deathDate: kakocho.deathDate, }, }; }) .filter( (item): item is { memorialDate: Date; memorial: NextMemorial } => item !== undefined, ) .sort( (a, b) => a.memorialDate.getTime() - b.memorialDate.getTime() || a.memorial.name.localeCompare(b.memorial.name, 'ja'), )[0]?.memorial; } private toDateOnly(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } private parseDate(value: string): Date | null { const [year, month, day] = value.split('-').map(Number); if (!year || !month || !day) { return null; } return new Date(year, month - 1, day); } selectFamily(family: Family): void { this.selectedFamily = family; } private sortFamiliesByHouseholder(families: Family[]): Family[] { if (!this.danka) { return families; } const householderName = this.normalizeName(this.danka.householder); return [...families].sort((a, b) => { const aIsHouseholder = this.normalizeName(a.name) === householderName; const bIsHouseholder = this.normalizeName(b.name) === householderName; if (aIsHouseholder === bIsHouseholder) { return 0; } return aIsHouseholder ? -1 : 1; }); } private normalizeName(name: string): string { return name.replace(/\s/g, ''); } getFamilyById(id: string): Family | undefined { if (!id) { return undefined; } return this.families.find((family) => family.id === id); } getFather(family: Family): Family | undefined { return this.getFamilyById(family.fatherId); } getMother(family: Family): Family | undefined { return this.getFamilyById(family.motherId); } getSpouse(family: Family): Family | undefined { return this.getFamilyById(family.spouseId); } getChildren(family: Family): Family[] { return this.families.filter( (child) => child.fatherId === family.id || child.motherId === family.id, ); } getCurrentMarriage(family: Family): MarriageRelation | undefined { return this.marriageRelations.find( (relation) => relation.status === 'current' && (relation.person1Id === family.id || relation.person2Id === family.id), ); } getPastMarriages(family: Family): MarriageRelation[] { return this.marriageRelations.filter( (relation) => relation.status !== 'current' && (relation.person1Id === family.id || relation.person2Id === family.id), ); } getMarriagePartner(relation: MarriageRelation, family: Family): Family | undefined { const partnerId = relation.person1Id === family.id ? relation.person2Id : relation.person1Id; return this.getFamilyById(partnerId); } getMarriageStatusLabel(status: string): string { if (status === 'current') { return '現在の配偶者'; } if (status === 'divorced') { return '離婚'; } if (status === 'widowed') { return '死別'; } return '不明'; } getLayoutNode( familyId: string ): LayoutNode | undefined { return this.layoutNodeMap.get( familyId ); } getCurrentMarriageLines() { return this.marriageRelations.filter( x => x.status === 'current' ); } private rebuildLayoutNodeMap(): void { this.layoutNodeMap.clear(); this.layoutNodes.forEach(node => { this.layoutNodeMap.set( node.familyId, node ); }); } private rebuildUnitLayoutMap(): void { this.unitLayoutMap.clear(); this.unitLayouts.forEach(layout => { this.unitLayoutMap.set( layout.node.unit.id, layout ); }); } getUnitLayout( unitId: string ): FamilyUnitLayout | undefined { return this.unitLayoutMap.get( unitId ); } getCenterX(layout: FamilyUnitLayout): number { return ( layout.x + this.PERSON_WIDTH + this.SPOUSE_GAP / 2 ); } getTopCenterY( layout: FamilyUnitLayout ): number { return layout.y; } getBottomCenterY( layout: FamilyUnitLayout ): number { return ( layout.y + this.PERSON_HEIGHT ); } getMiddleY( parent: FamilyUnitLayout, child: FamilyUnitLayout ): number { return ( this.getBottomCenterY(parent) + child.y ) / 2; } getHusbandX( layout: FamilyUnitLayout ): number { if (!layout.node.unit.wife) { return ( layout.x + (this.PERSON_WIDTH + this.SPOUSE_GAP) / 2 ); } return layout.x; } getWifeX( layout: FamilyUnitLayout ): number { if (!layout.node.unit.husband) { return ( layout.x + (this.PERSON_WIDTH + this.SPOUSE_GAP) / 2 ); } return ( layout.x + this.PERSON_WIDTH + this.SPOUSE_GAP ); } getHusbandTextX( layout: FamilyUnitLayout ): number { return ( this.getHusbandX(layout) + this.PERSON_WIDTH / 2 ); } getWifeTextX( layout: FamilyUnitLayout ): number { return ( this.getWifeX(layout) + this.PERSON_WIDTH / 2 ); } private calculateViewBox(): void { if (this.unitLayouts.length === 0) { return; } const minX = Math.min( ...this.unitLayouts.map(x => x.x) ); const minY = Math.min( ...this.unitLayouts.map(x => x.y) ); const maxX = Math.max( ...this.unitLayouts.map( x => x.x + this.PERSON_WIDTH * 2 + this.SPOUSE_GAP ) ); const maxY = Math.max( ...this.unitLayouts.map( x => x.y + this.PERSON_HEIGHT ) ); const padding = 100; this.viewBox = `${minX - padding} ${minY - padding} ${maxX - minX + padding * 2} ${maxY - minY + padding * 2}`; } getKakochoByFamilyId( familyId: string ): Kakocho | undefined { return this.deathDateMap.get( familyId ); } getKakochoByFamily(family: Family): Kakocho | undefined { const key = this.normalizeName(family.name) + '_' + family.dankaId; return this.kakochoByNameMap.get(key); } getDeathWareki(family: Family): string { const kakocho = this.getKakochoByFamily(family); if (!kakocho?.deathDate) return ''; const date = new Date(kakocho.deathDate); const wareki = new Intl.DateTimeFormat('ja-JP-u-ca-japanese', { era: 'long', year: 'numeric', }).format(date); return `${wareki}${date.getMonth() + 1}月${date.getDate()}日没`; } getAgeAtDeathText(family: Family): string { const kakocho = this.getKakochoByFamily(family); if (!kakocho?.ageAtDeath) return ''; return `享年${kakocho.ageAtDeath}歳`; } getDeathDate(family: Family): string { const kakocho = this.getKakochoByFamily(family); return kakocho?.deathDate ?? ''; } /** * 縦書きテキストの高さを概算する */ getVerticalTextHeight(text: string | null | undefined): number { if (!text) return 0; // 縦書きは基本「1文字=1行」 return text.length * this.DEATH_LINE_HEIGHT; } /** * テキストをカード下に揃えるためのY座標 */ getDeathTextY(layout: FamilyUnitLayout, text: string | null | undefined): number { const height = this.getVerticalTextHeight(text); return ( layout.y + this.PERSON_HEIGHT - 5 - height ); } }