Files
dbx/apps/desktop/src/components/diagram/RelationshipEdge.vue
T

213 lines
7.5 KiB
Vue

<script setup lang="ts">
import { computed, inject, type Ref, type ComputedRef } from "vue";
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from "@vue-flow/core";
import type { DiagramRelationship } from "@/lib/diagram/erDiagram";
import type { InferredRelationship } from "@/types/diagram";
import { DIAGRAM_HOVERED_EDGE_KEY, DIAGRAM_EDGE_OBSTACLES_KEY, EDGE_ROUTE_OFFSET, EDGE_STROKE_IDLE, EDGE_STROKE_HOVER } from "@/lib/diagram/diagram-constants";
import type { RelationshipEdgeData } from "@/lib/diagram/vue-flow-adapter";
import { alignWaypointsToEndpoints, endpointRectsFromObstacles, pathSkimsEndpoints, pointAlongPolyline, pointsToSvgPath, polylineLength, routeOrthogonalAroundObstacles, type ObstacleRect, type Point } from "@/lib/diagram/edge-obstacle-router";
const HOVER_BLUE = "#2563eb";
/** Prefer live obstacle routing when it is at most this fraction of aligned ELK path length. */
const LIVE_ROUTE_LENGTH_RATIO = 0.85;
/** Cardinality badge positions along the edge (arc-length fraction). */
const SOURCE_CARDINALITY_T = 0.18;
const TARGET_CARDINALITY_T = 0.82;
const props = defineProps<EdgeProps<RelationshipEdgeData>>();
const hoveredEdgeId = inject<Ref<string | null> | null>(DIAGRAM_HOVERED_EDGE_KEY, null);
const obstacles = inject<ComputedRef<ObstacleRect[]> | Ref<ObstacleRect[]> | null>(DIAGRAM_EDGE_OBSTACLES_KEY, null);
const isHovered = computed(() => hoveredEdgeId?.value === props.id);
function isDiagramRelationship(rel: DiagramRelationship | InferredRelationship): rel is DiagramRelationship {
return "kind" in rel;
}
function snapRoutedToHandles(points: Point[]): Point[] {
if (points.length < 2) return points;
const snapped = points.map((p) => ({ ...p }));
snapped[0] = { x: props.sourceX, y: props.sourceY };
snapped[snapped.length - 1] = { x: props.targetX, y: props.targetY };
return snapped;
}
function buildRoutedPoints(): Point[] | null {
const obstacleList = obstacles?.value ?? [];
const endpointIds: [string, string] = [props.source, props.target];
const endpointRects = endpointRectsFromObstacles(obstacleList, endpointIds);
const live = routeOrthogonalAroundObstacles({
source: { x: props.sourceX, y: props.sourceY },
target: { x: props.targetX, y: props.targetY },
sourcePosition: props.sourcePosition,
targetPosition: props.targetPosition,
obstacles: obstacleList,
endpointIds,
offset: EDGE_ROUTE_OFFSET,
});
const stored = props.data?.waypoints;
let aligned: Point[] | null = null;
if (stored?.length) {
aligned = alignWaypointsToEndpoints(stored, props.sourceX, props.sourceY, props.targetX, props.targetY, {
obstacles: obstacleList,
endpointIds,
});
// Drop ELK paths that skim endpoint table borders (align also rejects when obstacles present)
if (aligned?.length && pathSkimsEndpoints(aligned, endpointRects)) {
aligned = null;
}
}
if (aligned?.length && live?.length) {
const alignedLen = polylineLength(aligned);
const liveLen = polylineLength(live);
if (alignedLen > 0 && liveLen <= alignedLen * LIVE_ROUTE_LENGTH_RATIO) {
return snapRoutedToHandles(live);
}
return snapRoutedToHandles(aligned);
}
if (aligned?.length) return snapRoutedToHandles(aligned);
if (live?.length) return snapRoutedToHandles(live);
return null;
}
const pathResult = computed(() => {
const routed = buildRoutedPoints();
if (routed?.length) {
return {
path: pointsToSvgPath(routed),
points: routed,
};
}
const [path] = getSmoothStepPath({
sourceX: props.sourceX,
sourceY: props.sourceY,
targetX: props.targetX,
targetY: props.targetY,
sourcePosition: props.sourcePosition,
targetPosition: props.targetPosition,
borderRadius: 0,
offset: EDGE_ROUTE_OFFSET,
});
// Smooth-step fallback: approximate with endpoint → mid → endpoint for badge placement
const mid = {
x: (props.sourceX + props.targetX) / 2,
y: (props.sourceY + props.targetY) / 2,
};
return {
path,
points: [{ x: props.sourceX, y: props.sourceY }, mid, { x: props.targetX, y: props.targetY }],
};
});
const path = computed(() => pathResult.value.path);
const sourceBadgePos = computed(() => pointAlongPolyline(pathResult.value.points, SOURCE_CARDINALITY_T));
const targetBadgePos = computed(() => pointAlongPolyline(pathResult.value.points, TARGET_CARDINALITY_T));
const idleStroke = computed(() => {
const rel = props.data?.relationship;
if (!rel) {
return "color-mix(in srgb, var(--muted-foreground) 45%, transparent)";
}
if (isDiagramRelationship(rel)) {
if (rel.kind === "foreign-key") {
return "color-mix(in srgb, var(--primary) 55%, transparent)";
}
if (rel.kind === "custom") {
return "color-mix(in srgb, var(--primary) 70%, transparent)";
}
}
return "color-mix(in srgb, var(--muted-foreground) 45%, transparent)";
});
const strokeColor = computed(() => (isHovered.value ? HOVER_BLUE : idleStroke.value));
const strokeWidth = computed(() => (isHovered.value ? EDGE_STROKE_HOVER : EDGE_STROKE_IDLE));
const strokeDasharray = computed(() => {
const rel = props.data?.relationship;
if (rel && isDiagramRelationship(rel) && (rel.kind === "foreign-key" || rel.kind === "custom")) {
return "none";
}
return "5,5";
});
const sourceCardinality = computed(() => {
const rel = props.data?.relationship;
if (rel && isDiagramRelationship(rel) && rel.sourceCardinality) return rel.sourceCardinality;
return "N";
});
const targetCardinality = computed(() => {
const rel = props.data?.relationship;
if (rel && isDiagramRelationship(rel) && rel.targetCardinality) return rel.targetCardinality;
return "1";
});
const badgeClass = computed(() => (isHovered.value ? "border-blue-500 text-blue-600" : "border-border/80 text-foreground"));
</script>
<template>
<BaseEdge
:id="id"
:path="path"
:interaction-width="28"
:style="{
stroke: strokeColor,
strokeWidth: strokeWidth,
strokeDasharray: strokeDasharray === 'none' ? undefined : strokeDasharray,
}"
/>
<EdgeLabelRenderer>
<div
class="nopan nodrag pointer-events-none absolute z-10 min-w-[1.1rem] rounded border bg-background/95 px-1 py-0.5 text-center font-mono text-[10px] font-semibold leading-none shadow-sm"
:class="badgeClass"
:style="{
transform: `translate(-50%, -50%) translate(${sourceBadgePos.x}px, ${sourceBadgePos.y}px)`,
}"
>
{{ sourceCardinality }}
</div>
<div
class="nopan nodrag pointer-events-none absolute z-10 min-w-[1.1rem] rounded border bg-background/95 px-1 py-0.5 text-center font-mono text-[10px] font-semibold leading-none shadow-sm"
:class="badgeClass"
:style="{
transform: `translate(-50%, -50%) translate(${targetBadgePos.x}px, ${targetBadgePos.y}px)`,
}"
>
{{ targetCardinality }}
</div>
</EdgeLabelRenderer>
</template>
<style>
.diagram-flow .vue-flow__edge.relationship-edge,
.diagram-flow .vue-flow__edge.relationship-edge.inactive {
pointer-events: stroke !important;
cursor: pointer;
}
.diagram-flow .vue-flow__edge.relationship-edge .vue-flow__edge-interaction {
stroke: #000 !important;
stroke-opacity: 0 !important;
pointer-events: stroke !important;
}
.diagram-flow .vue-flow__edge.relationship-edge:hover .vue-flow__edge-path,
.diagram-flow .vue-flow__edge.relationship-edge.updating .vue-flow__edge-path {
stroke: #2563eb !important;
stroke-width: 3.5px !important;
}
.diagram-flow .vue-flow__node-layer {
pointer-events: none !important;
}
.diagram-flow .vue-flow__node-layer .layer-drag-handle {
pointer-events: auto !important;
}
</style>