Files
Portfolio/src/app/shared/systems-map/systems-map.ts
Antonio Ledebuhr bb9912daef Keep the default-open terminal inside the viewport and the map labels inside their shapes.
The dock prompt and placeholder now meet contrast on the raised surface, and service nodes use the same rect geometry as the cases.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 00:43:56 +02:00

313 lines
9.1 KiB
TypeScript

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<ElementRef<HTMLElement>>('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<SystemsMapNodeId | null>(null);
protected readonly openNodeId = signal<SystemsMapNodeId | null>(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<HTMLElement>(
'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<SystemsMapNodeId> {
return new Set(connectedNodeIds(id));
}
}