289 lines
8.5 KiB
TypeScript
289 lines
8.5 KiB
TypeScript
// 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();
|
||
}
|
||
}
|