arrow connection detection working
This commit is contained in:
parent
6e75866d4e
commit
2adf902a7f
3 changed files with 137 additions and 11 deletions
|
|
@ -99,4 +99,9 @@ text.selected, text.selected:hover {
|
||||||
text:hover {
|
text:hover {
|
||||||
fill: rgba(0, 200, 0, 1);
|
fill: rgba(0, 200, 0, 1);
|
||||||
/* cursor: grab; */
|
/* cursor: grab; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
stroke: green;
|
||||||
|
stroke-width: 4px;
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Dispatch, MouseEventHandler, SetStateAction, useEffect, useRef, useState } from "react";
|
import { Dispatch, MouseEventHandler, SetStateAction, useEffect, useRef, useState } from "react";
|
||||||
import { Line2D, Rect2D, Vec2D, addV2D, area, isEntirelyWithin, normalizeRect, subtractV2D, transformLine, transformRect } from "./geometry";
|
import { Line2D, Rect2D, Vec2D, addV2D, area, euclideanDistance, getBottomSide, getLeftSide, getRightSide, getTopSide, intersectLines, isEntirelyWithin, isWithin, lineBBox, normalizeRect, subtractV2D, transformLine, transformRect } from "./geometry";
|
||||||
|
|
||||||
import "./VisualEditor.css";
|
import "./VisualEditor.css";
|
||||||
import { getBBoxInSvgCoords } from "./svg_helper";
|
import { getBBoxInSvgCoords } from "./svg_helper";
|
||||||
|
|
@ -79,6 +79,34 @@ type HistoryState = {
|
||||||
future: VisualEditorState[],
|
future: VisualEditorState[],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const threshold = 20;
|
||||||
|
|
||||||
|
const sides: [RountanglePart, (r:Rect2D)=>Line2D][] = [
|
||||||
|
["left", getLeftSide],
|
||||||
|
["top", getTopSide],
|
||||||
|
["right", getRightSide],
|
||||||
|
["bottom", getBottomSide],
|
||||||
|
];
|
||||||
|
|
||||||
|
function findNearestRountangleSide(arrow: Line2D, arrowPart: "start"|"end", candidates: Rountangle[]): RountangleSelectable | undefined {
|
||||||
|
let best = Infinity;
|
||||||
|
let bestSide: undefined | RountangleSelectable;
|
||||||
|
for (const rountangle of candidates) {
|
||||||
|
for (const [side, getSide] of sides) {
|
||||||
|
const asLine = getSide(rountangle);
|
||||||
|
const intersection = intersectLines(arrow, asLine);
|
||||||
|
if (intersection !== null) {
|
||||||
|
const bbox = lineBBox(asLine, threshold);
|
||||||
|
const dist = euclideanDistance(arrow[arrowPart], intersection);
|
||||||
|
if (isWithin(arrow[arrowPart], bbox) && dist<best) {
|
||||||
|
best = dist;
|
||||||
|
bestSide = {uid: rountangle.uid, parts: [side]};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestSide;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function VisualEditor() {
|
export function VisualEditor() {
|
||||||
|
|
@ -396,8 +424,8 @@ export function VisualEditor() {
|
||||||
setMode("text");
|
setMode("text");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (e.ctrlKey) {
|
||||||
if (e.key === "z") {
|
if (e.key === "z") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
undo();
|
undo();
|
||||||
}
|
}
|
||||||
|
|
@ -405,8 +433,6 @@ export function VisualEditor() {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
redo();
|
redo();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.ctrlKey) {
|
|
||||||
if (e.key === "a") {
|
if (e.key === "a") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragging(null);
|
setDragging(null);
|
||||||
|
|
@ -432,6 +458,23 @@ export function VisualEditor() {
|
||||||
};
|
};
|
||||||
}, [selectingState, dragging]);
|
}, [selectingState, dragging]);
|
||||||
|
|
||||||
|
let sidesToHighlight: {[key: string]: RountanglePart[]} = {};
|
||||||
|
for (const selected of selection) {
|
||||||
|
for (const arrow of state.arrows) {
|
||||||
|
if (arrow.uid === selected.uid) {
|
||||||
|
const rSideStart = findNearestRountangleSide(arrow, "start", state.rountangles);
|
||||||
|
if (rSideStart) {
|
||||||
|
sidesToHighlight[rSideStart.uid] = [...(sidesToHighlight[rSideStart.uid] || []), rSideStart.parts[0]];
|
||||||
|
}
|
||||||
|
const rSideEnd = findNearestRountangleSide(arrow, "end", state.rountangles);
|
||||||
|
if (rSideEnd) {
|
||||||
|
sidesToHighlight[rSideEnd.uid] = [...(sidesToHighlight[rSideEnd.uid] || []), rSideEnd.parts[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return <svg width="4000px" height="4000px"
|
return <svg width="4000px" height="4000px"
|
||||||
className="svgCanvas"
|
className="svgCanvas"
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
|
|
@ -455,6 +498,7 @@ export function VisualEditor() {
|
||||||
key={rountangle.uid}
|
key={rountangle.uid}
|
||||||
rountangle={rountangle}
|
rountangle={rountangle}
|
||||||
selected={selection.find(r => r.uid === rountangle.uid)?.parts || []}
|
selected={selection.find(r => r.uid === rountangle.uid)?.parts || []}
|
||||||
|
highlight={sidesToHighlight[rountangle.uid] || []}
|
||||||
/>)}
|
/>)}
|
||||||
|
|
||||||
{state.arrows.map(arrow => <ArrowSVG
|
{state.arrows.map(arrow => <ArrowSVG
|
||||||
|
|
@ -537,7 +581,7 @@ function rountangleMinSize(size: Vec2D): Vec2D {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]}) {
|
export function RountangleSVG(props: {rountangle: Rountangle, selected: string[], highlight: RountanglePart[]}) {
|
||||||
const {topLeft, size, uid} = props.rountangle;
|
const {topLeft, size, uid} = props.rountangle;
|
||||||
// always draw a rountangle with a minimum size
|
// always draw a rountangle with a minimum size
|
||||||
// during resizing, rountangle can be smaller than this size and even have a negative size, but we don't show it
|
// during resizing, rountangle can be smaller than this size and even have a negative size, but we don't show it
|
||||||
|
|
@ -557,7 +601,9 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
|
||||||
/>
|
/>
|
||||||
<line
|
<line
|
||||||
className={"lineHelper"
|
className={"lineHelper"
|
||||||
+(props.selected.includes("top")?" selected":"")}
|
+(props.selected.includes("top")?" selected":"")
|
||||||
|
+(props.highlight.includes("top")?" highlight":"")
|
||||||
|
}
|
||||||
x1={0}
|
x1={0}
|
||||||
y1={0}
|
y1={0}
|
||||||
x2={minSize.x}
|
x2={minSize.x}
|
||||||
|
|
@ -567,7 +613,9 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
|
||||||
/>
|
/>
|
||||||
<line
|
<line
|
||||||
className={"lineHelper"
|
className={"lineHelper"
|
||||||
+(props.selected.includes("right")?" selected":"")}
|
+(props.selected.includes("right")?" selected":"")
|
||||||
|
+(props.highlight.includes("right")?" highlight":"")
|
||||||
|
}
|
||||||
x1={minSize.x}
|
x1={minSize.x}
|
||||||
y1={0}
|
y1={0}
|
||||||
x2={minSize.x}
|
x2={minSize.x}
|
||||||
|
|
@ -577,7 +625,9 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
|
||||||
/>
|
/>
|
||||||
<line
|
<line
|
||||||
className={"lineHelper"
|
className={"lineHelper"
|
||||||
+(props.selected.includes("bottom")?" selected":"")}
|
+(props.selected.includes("bottom")?" selected":"")
|
||||||
|
+(props.highlight.includes("bottom")?" highlight":"")
|
||||||
|
}
|
||||||
x1={0}
|
x1={0}
|
||||||
y1={minSize.y}
|
y1={minSize.y}
|
||||||
x2={minSize.x}
|
x2={minSize.x}
|
||||||
|
|
@ -587,7 +637,9 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
|
||||||
/>
|
/>
|
||||||
<line
|
<line
|
||||||
className={"lineHelper"
|
className={"lineHelper"
|
||||||
+(props.selected.includes("left")?" selected":"")}
|
+(props.selected.includes("left")?" selected":"")
|
||||||
|
+(props.highlight.includes("left")?" highlight":"")
|
||||||
|
}
|
||||||
x1={0}
|
x1={0}
|
||||||
y1={0}
|
y1={0}
|
||||||
x2={0}
|
x2={0}
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,19 @@ export function area(rect: Rect2D) {
|
||||||
return rect.size.x * rect.size.y;
|
return rect.size.x * rect.size.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function lineBBox(line: Line2D, margin=0): Rect2D {
|
||||||
|
return {
|
||||||
|
topLeft: {
|
||||||
|
x: line.start.x - margin,
|
||||||
|
y: line.start.y - margin,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
x: line.end.x - line.start.x + margin*2,
|
||||||
|
y: line.end.y - line.start.y + margin*2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function transformRect(rect: Rect2D, parts: string[], delta: Vec2D): Rect2D {
|
export function transformRect(rect: Rect2D, parts: string[], delta: Vec2D): Rect2D {
|
||||||
return {
|
return {
|
||||||
topLeft: {
|
topLeft: {
|
||||||
|
|
@ -90,4 +103,60 @@ export function transformLine(line: Line2D, parts: string[], delta: Vec2D): Line
|
||||||
start: parts.includes("start") ? addV2D(line.start, {x: delta.x, y: delta.y}) : line.start,
|
start: parts.includes("start") ? addV2D(line.start, {x: delta.x, y: delta.y}) : line.start,
|
||||||
end: parts.includes("end") ? addV2D(line.end, {x: delta.x, y: delta.y}) : line.end,
|
end: parts.includes("end") ? addV2D(line.end, {x: delta.x, y: delta.y}) : line.end,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// intersection point of two lines
|
||||||
|
// note: point may not be part of the lines
|
||||||
|
// author: ChatGPT
|
||||||
|
export function intersectLines(a: Line2D, b: Line2D): Vec2D | null {
|
||||||
|
const { start: A1, end: A2 } = a;
|
||||||
|
const { start: B1, end: B2 } = b;
|
||||||
|
|
||||||
|
const den =
|
||||||
|
(A1.x - A2.x) * (B1.y - B2.y) - (A1.y - A2.y) * (B1.x - B2.x);
|
||||||
|
|
||||||
|
if (den === 0) return null; // parallel or coincident
|
||||||
|
|
||||||
|
const x =
|
||||||
|
((A1.x * A2.y - A1.y * A2.x) * (B1.x - B2.x) -
|
||||||
|
(A1.x - A2.x) * (B1.x * B2.y - B1.y * B2.x)) /
|
||||||
|
den;
|
||||||
|
|
||||||
|
const y =
|
||||||
|
((A1.x * A2.y - A1.y * A2.x) * (B1.y - B2.y) -
|
||||||
|
(A1.y - A2.y) * (B1.x * B2.y - B1.y * B2.x)) /
|
||||||
|
den;
|
||||||
|
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function euclideanDistance(a: Vec2D, b: Vec2D): number {
|
||||||
|
const diffX = a.x - b.x;
|
||||||
|
const diffY = a.y - b.y;
|
||||||
|
return Math.sqrt(diffX*diffX + diffY*diffY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLeftSide(rect: Rect2D): Line2D {
|
||||||
|
return {
|
||||||
|
start: rect.topLeft,
|
||||||
|
end: {x: rect.topLeft.x, y: rect.topLeft.y + rect.size.y},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function getTopSide(rect: Rect2D): Line2D {
|
||||||
|
return {
|
||||||
|
start: rect.topLeft,
|
||||||
|
end: { x: rect.topLeft.x + rect.size.x, y: rect.topLeft.y },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function getRightSide(rect: Rect2D): Line2D {
|
||||||
|
return {
|
||||||
|
start: { x: rect.topLeft.x + rect.size.x, y: rect.topLeft.y },
|
||||||
|
end: { x: rect.topLeft.x + rect.size.x, y: rect.topLeft.y + rect.size.y },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function getBottomSide(rect: Rect2D): Line2D {
|
||||||
|
return {
|
||||||
|
start: { x: rect.topLeft.x, y: rect.topLeft.y + rect.size.y },
|
||||||
|
end: { x: rect.topLeft.x + rect.size.x, y: rect.topLeft.y + rect.size.y },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue