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>
313 lines
9.1 KiB
TypeScript
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));
|
|
}
|
|
}
|