humainflow-web/src/app/shared/custom-connection/custom-connection.ts

289 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { Component, inject, Input } from '@angular/core';
import { ClassicPreset } from 'rete';
import {
DEFAULT_LOOP_MAX_ITERATIONS,
FLOW_DEPENDANT_PORT_KEY,
FLOW_DEPENDENCY_PORT_KEY,
MAX_LOOP_MAX_ITERATIONS
} from '@models/flow';
import type { LoopAwareConnection } from '@utilities/rete-editor';
import { GraphSelectionService } from '@services/graph-selection/graph-selection';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
@Component({
selector: 'app-custom-connection',
standalone: true,
template: `
<svg data-testid="connection" [attr.data-connection-id]="connectionId" [attr.data-loop-back]="isLoopBack || null">
<path
[attr.d]="drawnPath"
[attr.stroke]="strokeColor"
[attr.stroke-width]="strokeWidth"
[attr.stroke-dasharray]="strokeDasharray"
[attr.opacity]="opacity"
(pointerdown)="selectConnection($event)"
(click)="selectConnection($event)">
</path>
@if (isLoopBack) {
<foreignObject
class="loop-label-wrap"
[attr.x]="loopLabelX"
[attr.y]="loopLabelY"
width="190"
height="34">
<div xmlns="http://www.w3.org/1999/xhtml" class="loop-label" (pointerdown)="selectConnection($event)">
<span class="loop-glyph" aria-hidden="true">↻</span>
@if (isSelected && !isReadonly) {
<label class="loop-limit">
max
<input
type="number"
min="1"
[attr.max]="maxAllowed"
[value]="maxIterations"
title="How many times the flow may go round before the execution fails"
(pointerdown)="$event.stopPropagation()"
(keydown)="$event.stopPropagation()"
(change)="changeLimit($event)" />
</label>
} @else {
<span title="Goes round at most this many times; reaching the limit fails the execution">max {{ maxIterations }}</span>
}
</div>
</foreignObject>
}
@if (isSelected && !isReadonly) {
<foreignObject
class="connection-delete-wrap"
[attr.x]="deleteButtonX"
[attr.y]="deleteButtonY"
width="28"
height="28">
<button
xmlns="http://www.w3.org/1999/xhtml"
type="button"
class="connection-delete"
title="Delete connection"
(pointerdown)="deleteConnection($event)"
(click)="deleteConnection($event)">
<span>×</span>
</button>
</foreignObject>
}
</svg>
`,
styles: [`
:host svg {
overflow: visible !important;
position: absolute;
pointer-events: none;
width: 9999px;
height: 9999px;
}
:host path {
fill: none;
pointer-events: auto;
cursor: pointer;
stroke-linecap: round;
stroke-linejoin: round;
}
:host .connection-delete {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 0;
border-radius: 999px;
background: #fff;
color: #dc2626;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.18);
cursor: pointer;
font-size: 18px;
font-weight: 700;
line-height: 1;
padding: 0;
pointer-events: auto;
user-select: none;
-webkit-user-select: none;
}
:host .connection-delete-wrap {
overflow: visible;
pointer-events: auto;
}
:host .loop-label-wrap {
overflow: visible;
pointer-events: auto;
}
:host .loop-label {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border-radius: 999px;
background: #f5f3ff;
border: 1px solid #c4b5fd;
color: #5b21b6;
font: 600 12px/1.4 system-ui, sans-serif;
box-shadow: 0 4px 12px rgba(91, 33, 182, 0.14);
cursor: pointer;
white-space: nowrap;
}
:host .loop-glyph {
font-size: 15px;
line-height: 1;
}
:host .loop-limit {
display: inline-flex;
align-items: center;
gap: 4px;
}
:host .loop-limit input {
width: 52px;
padding: 1px 4px;
border: 1px solid #a78bfa;
border-radius: 6px;
font: inherit;
color: inherit;
background: #fff;
}
`]
})
export class CustomConnectionComponent {
private readonly graphSelection = inject(GraphSelectionService);
private readonly biasComparisonViewState = inject(BiasComparisonViewStateService);
@Input() data!: ClassicPreset.Connection<ClassicPreset.Node, ClassicPreset.Node>;
@Input() start!: { x: number; y: number };
@Input() end!: { x: number; y: number };
@Input() path = '';
get isDependencyConnection(): boolean {
return this.data?.sourceOutput === FLOW_DEPENDANT_PORT_KEY && this.data?.targetInput === FLOW_DEPENDENCY_PORT_KEY;
}
get connectionId(): string {
return String(this.data?.id ?? '');
}
private get loopData(): LoopAwareConnection | undefined {
return this.data as LoopAwareConnection | undefined;
}
/** Leads back round a loop, as the editor worked out from the graph. */
get isLoopBack(): boolean {
return this.loopData?.__loopBack === true;
}
get isReadonly(): boolean {
return this.loopData?.__readonly === true;
}
get maxIterations(): number {
return this.loopData?.loop?.maxIterations ?? DEFAULT_LOOP_MAX_ITERATIONS;
}
readonly maxAllowed = MAX_LOOP_MAX_ITERATIONS;
/**
* A connection leading back runs from a node's output to one drawn before it, so the usual curve
* would double back through both nodes. It goes round underneath them instead.
*/
get drawnPath(): string {
if (!this.isLoopBack || !this.start || !this.end) return this.path;
const drop = this.loopDrop;
const { x: sx, y: sy } = this.start;
const { x: ex, y: ey } = this.end;
const bottom = Math.max(sy, ey) + drop;
return `M ${sx} ${sy} C ${sx + 140} ${sy}, ${sx + 140} ${bottom}, ${sx} ${bottom}`
+ ` L ${ex} ${bottom} C ${ex - 140} ${bottom}, ${ex - 140} ${ey}, ${ex} ${ey}`;
}
private get loopDrop(): number {
return 150;
}
get loopLabelX(): number {
return ((this.start?.x ?? 0) + (this.end?.x ?? 0)) / 2 - 60;
}
get loopLabelY(): number {
return Math.max(this.start?.y ?? 0, this.end?.y ?? 0) + this.loopDrop - 17;
}
changeLimit(event: Event) {
const raw = (event.target as HTMLInputElement).value.trim();
const parsed = raw === '' ? null : Math.round(Number(raw));
const limit = parsed == null || Number.isNaN(parsed)
? null
: Math.min(MAX_LOOP_MAX_ITERATIONS, Math.max(1, parsed));
(event.target as HTMLInputElement).value = String(limit ?? DEFAULT_LOOP_MAX_ITERATIONS);
this.graphSelection.requestLoopLimit(this.connectionId, limit);
}
get isSelected(): boolean {
return this.graphSelection.selectedConnectionId() === this.connectionId;
}
get isBiasRoutingChange(): boolean {
return this.biasComparisonViewState.isBiasedRoutingConnection(
this.data?.source != null ? String(this.data.source) : null,
this.data?.sourceOutput != null ? String(this.data.sourceOutput) : null
);
}
get strokeColor(): string {
if (this.isSelected) return '#f97316';
if (this.isBiasRoutingChange) return '#b45309';
if (this.isLoopBack) return '#7c3aed';
return this.isDependencyConnection ? '#7c8ba1' : '#4682b4';
}
get strokeWidth(): number {
if (this.isSelected) return this.isDependencyConnection ? 3.25 : 6;
if (this.isBiasRoutingChange) return 6;
return this.isDependencyConnection ? 2.25 : 5;
}
get strokeDasharray(): string | null {
if (this.isLoopBack) return '12 7';
return this.isDependencyConnection ? '6 6' : null;
}
get opacity(): number {
return this.isDependencyConnection ? 0.95 : 1;
}
get deleteButtonX(): number {
return ((this.start?.x ?? 0) + (this.end?.x ?? 0)) / 2 - 12;
}
get deleteButtonY(): number {
return ((this.start?.y ?? 0) + (this.end?.y ?? 0)) / 2 - 12;
}
selectConnection(event: Event) {
event.preventDefault();
event.stopPropagation();
this.graphSelection.selectConnection(this.connectionId);
}
deleteConnection(event: Event) {
event.preventDefault();
event.stopPropagation();
this.graphSelection.selectConnection(this.connectionId);
this.graphSelection.requestDeleteSelectedConnection();
}
}