import { DOCUMENT } from '@angular/common'; import { afterNextRender, ChangeDetectionStrategy, Component, computed, ElementRef, inject, Injector, input, signal, viewChild, } from '@angular/core'; import { RouterLink } from '@angular/router'; import { SIGNATURE_COPY } from '../../core/content/signature-copy'; import { type AppLocale } from '../../core/i18n/locale'; import { LocaleService } from '../../core/i18n/locale.service'; import { NavigationService } from '../../core/navigation/navigation.service'; import { isBrowserPlatform } from '../../core/platform/browser'; import { connectedNodeIds, nodeHref, SYSTEMS_MAP_EDGES, SYSTEMS_MAP_NODE_KINDS, SYSTEMS_MAP_NODES, type SystemsMapNode, type SystemsMapNodeId, } from './systems-map.model'; let systemsMapInstanceId = 0; export interface SystemsMapNodeView { readonly id: SystemsMapNodeId; readonly kind: SystemsMapNode['kind']; readonly routeId: SystemsMapNode['routeId']; readonly fragment: string; readonly x: number; readonly y: number; readonly label: string; readonly labelLines: readonly string[]; readonly summary: string; readonly gist: string; readonly href: string; readonly link: unknown[]; readonly relationship: string; readonly accessibleName: string; readonly linkLabel: string; readonly shapeWidth: number; readonly shapeHeight: number; readonly textStartDy: number; } export interface SystemsMapEdgeView { readonly from: SystemsMapNodeId; readonly to: SystemsMapNodeId; readonly x1: number; readonly y1: number; readonly x2: number; readonly y2: number; } @Component({ selector: 'app-systems-map', changeDetection: ChangeDetectionStrategy.OnPush, imports: [RouterLink], templateUrl: './systems-map.html', styleUrl: './systems-map.scss', }) export class SystemsMap { readonly heading = input(''); readonly intro = input(''); readonly headingLevel = input<2 | 3>(2); private readonly document = inject(DOCUMENT); private readonly injector = inject(Injector); private readonly navigation = inject(NavigationService); private readonly localeService = inject(LocaleService); private readonly isBrowser = isBrowserPlatform(); private readonly instanceId = systemsMapInstanceId++; private readonly dialogRef = viewChild>('gistDialog'); private opener: HTMLElement | null = null; protected readonly headingId = `systems-map-heading-${this.instanceId}`; protected readonly svgTitleId = `systems-map-svg-title-${this.instanceId}`; protected readonly svgDescId = `systems-map-svg-desc-${this.instanceId}`; protected readonly listHeadingId = `systems-map-list-${this.instanceId}`; protected readonly legendHeadingId = `systems-map-legend-${this.instanceId}`; protected readonly dialogTitleId = `systems-map-dialog-title-${this.instanceId}`; protected readonly dialogDescId = `systems-map-dialog-desc-${this.instanceId}`; protected readonly activeNodeId = signal(null); protected readonly openNodeId = signal(null); protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].systemsMap); protected readonly headingText = computed(() => { const override = this.heading().trim(); return override.length > 0 ? override : this.copy().heading; }); protected readonly introText = computed(() => { const override = this.intro().trim(); return override.length > 0 ? override : this.copy().intro; }); protected readonly nodes = computed(() => { const locale = this.localeService.locale(); return SYSTEMS_MAP_NODES.map((node) => this.toNodeView(node, locale)); }); protected readonly edges = computed((): readonly SystemsMapEdgeView[] => { const byId = new Map(SYSTEMS_MAP_NODES.map((node) => [node.id, node])); return SYSTEMS_MAP_EDGES.map((edge) => { const from = byId.get(edge.from); const to = byId.get(edge.to); return { from: edge.from, to: edge.to, x1: from?.x ?? 0, y1: from?.y ?? 0, x2: to?.x ?? 0, y2: to?.y ?? 0, }; }); }); protected readonly legendItems = computed(() => { const legend = this.copy().legend; return SYSTEMS_MAP_NODE_KINDS.map((kind) => ({ kind, label: legend[kind], })); }); protected readonly openNode = computed(() => { const id = this.openNodeId(); return id ? (this.nodes().find((node) => node.id === id) ?? null) : null; }); protected readonly dialogTitle = computed(() => { const node = this.openNode(); return node ? this.copy().dialogTitlePattern.replace('{node}', node.label) : ''; }); protected onNodeActivate(event: MouseEvent, node: SystemsMapNodeView): void { if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) { return; } event.preventDefault(); this.openDialog(node.id, event.currentTarget as HTMLElement); } protected onNodeKeydown(event: KeyboardEvent, node: SystemsMapNodeView): void { if (event.key !== 'Enter' && event.key !== ' ') { return; } event.preventDefault(); this.openDialog(node.id, event.currentTarget as HTMLElement); } protected onNodeEnter(id: SystemsMapNodeId): void { this.activeNodeId.set(id); } protected onNodeLeave(id: SystemsMapNodeId): void { if (this.activeNodeId() === id) { this.activeNodeId.set(null); } } protected closeDialog(): void { this.openNodeId.set(null); const opener = this.opener; this.opener = null; if (!this.isBrowser || !(opener instanceof HTMLElement)) { return; } afterNextRender( () => { if (opener.isConnected) { opener.focus(); } }, { injector: this.injector }, ); } protected onDialogKeydown(event: KeyboardEvent): void { if (event.key === 'Escape') { event.preventDefault(); this.closeDialog(); return; } if (event.key !== 'Tab') { return; } const dialog = this.dialogRef()?.nativeElement; if (!dialog) { return; } const focusable = this.focusableIn(dialog); if (focusable.length === 0) { event.preventDefault(); return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; const active = this.document.activeElement; if (event.shiftKey && active === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && active === last) { event.preventDefault(); first.focus(); } } protected isConnectedNode(id: SystemsMapNodeId): boolean { const active = this.activeNodeId(); return !!active && (active === id || this.neighborSet(active).has(id)); } protected isDimmedNode(id: SystemsMapNodeId): boolean { const active = this.activeNodeId(); return !!active && active !== id && !this.neighborSet(active).has(id); } protected isConnectedEdge(edge: SystemsMapEdgeView): boolean { const active = this.activeNodeId(); return !!active && (edge.from === active || edge.to === active); } protected isDimmedEdge(edge: SystemsMapEdgeView): boolean { const active = this.activeNodeId(); return !!active && edge.from !== active && edge.to !== active; } private openDialog(id: SystemsMapNodeId, opener: HTMLElement): void { this.opener = opener; this.openNodeId.set(id); afterNextRender( () => { const dialog = this.dialogRef()?.nativeElement; const target = this.focusableIn(dialog)[0] ?? dialog; target?.focus(); }, { injector: this.injector }, ); } private focusableIn(root: HTMLElement | undefined): HTMLElement[] { if (!root) { return []; } return Array.from( root.querySelectorAll( 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])', ), ).filter((element) => !element.hasAttribute('hidden')); } private toNodeView(node: SystemsMapNode, locale: AppLocale): SystemsMapNodeView { const copy = this.copy(); const label = node.label[locale]; const labelLines = node.labelLines[locale]; const summary = node.summary[locale]; const gist = node.gist[locale]; const relationship = node.relationships[locale]; const multiline = labelLines.length > 1; const longest = labelLines.reduce((max, line) => Math.max(max, line.length), 0); const linkPattern = node.kind === 'project' ? copy.caseLinkPattern : copy.sectionLinkPattern; return { id: node.id, kind: node.kind, routeId: node.routeId, fragment: node.fragment, x: node.x, y: node.y, label, labelLines, summary, gist, shapeWidth: Math.max(160, longest * 9 + 32), shapeHeight: multiline ? 52 : 40, textStartDy: multiline ? -6 : 4, href: nodeHref(node, locale), link: this.navigation.link(node.routeId), relationship, accessibleName: `${label}. ${summary} ${relationship}`, linkLabel: linkPattern.replace('{node}', label), }; } private neighborSet(id: SystemsMapNodeId): ReadonlySet { return new Set(connectedNodeIds(id)); } }