Skip to content

Commit d111ba1

Browse files
herzadinataBOCOVO
authored andcommitted
feat(dblm-ext): arrow button on path to target table
1 parent 5e700f9 commit d111ba1

File tree

2 files changed

+641
-10
lines changed

2 files changed

+641
-10
lines changed

packages/json-table-schema-visualizer/src/components/RelationConnection/ConnectionPath.tsx

Lines changed: 246 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,62 @@
1-
import { Path } from "react-konva";
2-
import { useState } from "react";
1+
import { Path, Group, Circle, RegularPolygon } from "react-konva";
2+
import { useState, useRef } from "react";
33

4+
import eventEmitter from "@/events-emitter";
5+
import { tableCoordsStore } from "@/stores/tableCoords";
6+
import {
7+
DIAGRAM_PADDING,
8+
COLUMN_HEIGHT,
9+
TABLE_HEADER_HEIGHT,
10+
} from "@/constants/sizing";
411
import { useThemeColors } from "@/hooks/theme";
512
import { useTablesInfo } from "@/hooks/table";
613
import { useTableColor } from "@/hooks/tableColor";
14+
import { useTableWidthStoredValue } from "@/hooks/tableWidthStore";
15+
import {
16+
findNearestPointOnPath,
17+
getButtonLocalAndStage,
18+
decideTargetAndAngleFromPath,
19+
computeTooCloseStage,
20+
setArrowZIndexRelativeToTables,
21+
} from "@/utils/connectionPathUtils";
722

823
interface ConnectionPathProps {
924
path: string;
1025
sourceTableName: string;
1126
targetTableName: string;
1227
relationOwner: string;
1328
}
29+
30+
const ARROW_ANGLE_OFFSET = 90;
31+
const MIN_LINE_OFFSET = 40; // Distance from each endpoint - hides circle if within this distance
32+
const END_LINE_TOLERATE = 5; // At the end of the target line, it needs hardcoded additional distance
33+
const ARROW_BUTTON_DISAPPEARANCE_DELAY = 50;
34+
1435
const ConnectionPath = ({
1536
path,
1637
sourceTableName,
1738
targetTableName,
1839
relationOwner,
1940
}: ConnectionPathProps) => {
2041
const themeColors = useThemeColors();
21-
const { hoveredTableName } = useTablesInfo();
42+
const tablesInfo = useTablesInfo();
43+
const { hoveredTableName, setHoveredTableName } = tablesInfo;
2244
const sourceTableColors = useTableColor(relationOwner);
45+
const srcWidth = useTableWidthStoredValue(sourceTableName);
46+
const tgtWidth = useTableWidthStoredValue(targetTableName);
2347
const [isHovered, setIsHovered] = useState(false);
48+
const [btnVisible, setBtnVisible] = useState(false);
49+
const [btnPos, setBtnPos] = useState({ x: 0, y: 0 });
50+
const [btnAngle, setBtnAngle] = useState(0);
51+
const [btnTarget, setBtnTarget] = useState<string | null>(null);
52+
const [btnHovering, setBtnHovering] = useState(false);
53+
const hideTimeoutRef = useRef<number | null>(null);
54+
const btnStagePosRef = useRef<{ x: number; y: number } | null>(null);
55+
56+
// Keep a ref to the arrow group so we can bring it to top of the layer when visible
57+
const arrowGroupRef = useRef<any>(null);
58+
59+
const colsIndexes = tablesInfo.colsIndexes ?? {};
2460

2561
const highlight =
2662
hoveredTableName === sourceTableName ||
@@ -33,20 +69,220 @@ const ConnectionPath = ({
3369

3470
const handleOnHover = () => {
3571
setIsHovered(true);
72+
setBtnVisible(true);
3673
};
3774

3875
const handleOnBlur = () => {
3976
setIsHovered(false);
77+
if (hideTimeoutRef.current != null) {
78+
window.clearTimeout(hideTimeoutRef.current);
79+
}
80+
hideTimeoutRef.current = window.setTimeout(() => {
81+
if (!btnHovering) setBtnVisible(false);
82+
hideTimeoutRef.current = null;
83+
}, ARROW_BUTTON_DISAPPEARANCE_DELAY);
84+
};
85+
86+
const handleOnMove = (e: any) => {
87+
const stage = e.target.getStage();
88+
if (stage == null) return;
89+
const pointer = stage.getPointerPosition();
90+
if (pointer == null) return;
91+
92+
const px = pointer.x as number;
93+
const py = pointer.y as number;
94+
95+
const node = e.target;
96+
const srcCoords = tableCoordsStore.getCoords(sourceTableName);
97+
const tgtCoords = tableCoordsStore.getCoords(targetTableName);
98+
let localX = px;
99+
let localY = py;
100+
101+
// Local computed results
102+
let computedArrow: string | null = null;
103+
let computedAngle: number | null = null;
104+
105+
try {
106+
const { best, bestL, totalLength } = findNearestPointOnPath(node, px, py);
107+
if (best != null && totalLength > 0) {
108+
const {
109+
localX: lX,
110+
localY: lY,
111+
btnStagePos,
112+
} = getButtonLocalAndStage(node, best, DIAGRAM_PADDING);
113+
localX = lX;
114+
localY = lY;
115+
if (btnStagePos != null) btnStagePosRef.current = btnStagePos;
116+
117+
// Decide arrow target and angle based on path geometry + stage centers
118+
try {
119+
const decision = decideTargetAndAngleFromPath(
120+
node,
121+
bestL,
122+
totalLength,
123+
btnStagePosRef.current,
124+
sourceTableName,
125+
targetTableName,
126+
colsIndexes,
127+
srcWidth,
128+
tgtWidth,
129+
srcCoords,
130+
tgtCoords,
131+
);
132+
if (decision.computedArrow != null)
133+
computedArrow = decision.computedArrow;
134+
if (decision.computedAngle != null)
135+
computedAngle = decision.computedAngle; // degrees without ARROW_ANGLE_OFFSET
136+
} catch (ex) {
137+
// Ignore and fallback below
138+
}
139+
}
140+
} catch (err) {
141+
// Fallback to stage coords + padding
142+
localX = px + DIAGRAM_PADDING;
143+
localY = py + DIAGRAM_PADDING;
144+
btnStagePosRef.current = { x: px, y: py };
145+
}
146+
147+
// Decide visibility: hide when too close to path endpoints (prevents overlap with tables and relation markers).
148+
// Use a robust stage-space distance check between the current button
149+
// position and the path start/end. This is simpler and more reliable than
150+
// depending on arc-length methods which can be flaky on transformed paths.
151+
const tooClose = computeTooCloseStage(
152+
node,
153+
btnStagePosRef.current ?? { x: px, y: py },
154+
MIN_LINE_OFFSET,
155+
END_LINE_TOLERATE,
156+
);
157+
158+
setBtnPos({ x: localX, y: localY });
159+
if (computedArrow != null) setBtnTarget(computedArrow);
160+
161+
// Ensure arrow is above relation lines but below table nodes. We look for
162+
// the first child whose name starts with `table-` and set the arrow's
163+
// z-index to just before that index. If no table child is found, move the
164+
// arrow to the top of the parent's children so it's above lines.
165+
if (!tooClose && arrowGroupRef.current != null) {
166+
setArrowZIndexRelativeToTables(arrowGroupRef.current);
167+
}
168+
if (computedAngle != null) setBtnAngle(computedAngle + ARROW_ANGLE_OFFSET);
169+
170+
setBtnVisible(!tooClose);
171+
};
172+
173+
const handleBtnLeave = () => {
174+
document.body.style.cursor = "default";
175+
setBtnHovering(false);
176+
setIsHovered(false); // unhover both line and circle
177+
if (hideTimeoutRef.current != null) {
178+
window.clearTimeout(hideTimeoutRef.current);
179+
}
180+
hideTimeoutRef.current = window.setTimeout(() => {
181+
setBtnVisible(false);
182+
hideTimeoutRef.current = null;
183+
}, 80);
184+
};
185+
186+
const handleBtnEnter = () => {
187+
document.body.style.cursor = "pointer";
188+
setBtnHovering(true);
189+
setIsHovered(true); // hover both line and circle
190+
if (hideTimeoutRef.current != null) {
191+
window.clearTimeout(hideTimeoutRef.current);
192+
hideTimeoutRef.current = null;
193+
}
194+
};
195+
196+
const handleBtnClick = () => {
197+
// Recompute the intended target using the stored stage-position of the button
198+
const srcCoords = tableCoordsStore.getCoords(sourceTableName);
199+
const tgtCoords = tableCoordsStore.getCoords(targetTableName);
200+
// Use the button position in the same coordinate-space as table coords (btnPos)
201+
const btnLocal = btnPos;
202+
let targetToUse: string | null = btnTarget;
203+
if (btnLocal != null) {
204+
// Derive visible fields count from colsIndexes in tablesInfo
205+
const colsIndexes = tablesInfo.colsIndexes ?? {};
206+
const countKeysWithPrefix = (prefix: string) =>
207+
Object.keys(colsIndexes).filter((k) => k.startsWith(`${prefix}.`))
208+
.length;
209+
210+
const srcFields = countKeysWithPrefix(sourceTableName);
211+
const tgtFields = countKeysWithPrefix(targetTableName);
212+
213+
const srcHeight = TABLE_HEADER_HEIGHT + COLUMN_HEIGHT * srcFields;
214+
const tgtHeight = TABLE_HEADER_HEIGHT + COLUMN_HEIGHT * tgtFields;
215+
216+
const srcCenter = {
217+
x: (srcCoords.x ?? 0) + srcWidth / 2,
218+
y: (srcCoords.y ?? 0) + srcHeight / 2,
219+
};
220+
const tgtCenter = {
221+
x: (tgtCoords.x ?? 0) + tgtWidth / 2,
222+
y: (tgtCoords.y ?? 0) + tgtHeight / 2,
223+
};
224+
225+
const ds = Math.hypot(
226+
(btnLocal.x ?? 0) - srcCenter.x,
227+
(btnLocal.y ?? 0) - srcCenter.y,
228+
);
229+
const dt = Math.hypot(
230+
(btnLocal.x ?? 0) - tgtCenter.x,
231+
(btnLocal.y ?? 0) - tgtCenter.y,
232+
);
233+
234+
// Farther table wins
235+
targetToUse = ds < dt ? targetTableName : sourceTableName;
236+
}
237+
238+
if (targetToUse == null || targetToUse.length === 0) return;
239+
try {
240+
setHoveredTableName(targetToUse);
241+
} catch (e) {
242+
// Ignore
243+
}
244+
eventEmitter.emit("table:center", { tableName: targetToUse });
245+
eventEmitter.emit(`highlight:table:${targetToUse}`);
40246
};
41247

42248
return (
43-
<Path
44-
data={path}
45-
onMouseEnter={handleOnHover}
46-
onMouseLeave={handleOnBlur}
47-
strokeWidth={2}
48-
stroke={strokeColor}
49-
/>
249+
<>
250+
<Path
251+
data={path}
252+
onMouseEnter={handleOnHover}
253+
onMouseLeave={handleOnBlur}
254+
onMouseMove={handleOnMove}
255+
hitStrokeWidth={12}
256+
strokeWidth={2}
257+
stroke={strokeColor}
258+
/>
259+
260+
{btnVisible && (
261+
<Group x={btnPos.x} y={btnPos.y} listening={true} ref={arrowGroupRef}>
262+
<Circle
263+
x={0}
264+
y={0}
265+
radius={14}
266+
fill={strokeColor}
267+
cursor="pointer"
268+
onMouseEnter={handleBtnEnter}
269+
onMouseLeave={handleBtnLeave}
270+
onClick={handleBtnClick}
271+
listening={true}
272+
/>
273+
{/* Triangle arrow as vector, rotated to face target */}
274+
<RegularPolygon
275+
x={0}
276+
y={0}
277+
sides={3}
278+
radius={6}
279+
fill="#fff"
280+
rotation={btnAngle}
281+
listening={false}
282+
/>
283+
</Group>
284+
)}
285+
</>
50286
);
51287
};
52288

0 commit comments

Comments
 (0)