visual feedback on labels belonging to arrows and vice-versa
This commit is contained in:
parent
db246ccefe
commit
58a75ddd8b
5 changed files with 103 additions and 14 deletions
|
|
@ -2,8 +2,23 @@
|
||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
}
|
}
|
||||||
|
|
||||||
text {
|
text, text.highlight {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
/* text-shadow: 2px 0 #fff, -2px 0 #fff, 0 2px #fff, 0 -2px #fff, 1px 1px #fff, -1px -1px #fff, 1px -1px #fff, -1px 1px #fff; */
|
||||||
|
/* -webkit-text-stroke: 4px white; */
|
||||||
|
paint-order: stroke;
|
||||||
|
stroke: white;
|
||||||
|
stroke-width: 4px;
|
||||||
|
stroke-linecap: butt;
|
||||||
|
stroke-linejoin: miter;
|
||||||
|
stroke-opacity: 1;
|
||||||
|
fill-opacity:1;
|
||||||
|
/* font-weight: 800; */
|
||||||
|
}
|
||||||
|
|
||||||
|
text.highlight {
|
||||||
|
fill: green;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selecting {
|
.selecting {
|
||||||
|
|
@ -70,6 +85,10 @@ text {
|
||||||
stroke: black;
|
stroke: black;
|
||||||
stroke-width: 2px;
|
stroke-width: 2px;
|
||||||
}
|
}
|
||||||
|
.arrow.selected {
|
||||||
|
stroke: blue;
|
||||||
|
stroke-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
/* .arrow.selected {
|
/* .arrow.selected {
|
||||||
stroke: blue;
|
stroke: blue;
|
||||||
|
|
@ -100,7 +119,7 @@ text.selected, text.selected:hover {
|
||||||
/* font-weight: bold; */
|
/* font-weight: bold; */
|
||||||
}
|
}
|
||||||
text:hover {
|
text:hover {
|
||||||
fill: rgba(0, 200, 0, 1);
|
fill: darkcyan;
|
||||||
/* cursor: grab; */
|
/* cursor: grab; */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Line2D, Rect2D, Vec2D, addV2D, area, getBottomSide, getLeftSide, getRig
|
||||||
import "./VisualEditor.css";
|
import "./VisualEditor.css";
|
||||||
|
|
||||||
import { getBBoxInSvgCoords } from "./svg_helper";
|
import { getBBoxInSvgCoords } from "./svg_helper";
|
||||||
import { VisualEditorState, Rountangle, emptyState, Arrow, ArrowPart, RountanglePart, findNearestRountangleSide } from "./editor_types";
|
import { VisualEditorState, Rountangle, emptyState, Arrow, ArrowPart, RountanglePart, findNearestRountangleSide, findNearestArrow, Text } from "./editor_types";
|
||||||
import { parseStatechart } from "./parser";
|
import { parseStatechart } from "./parser";
|
||||||
import { CORNER_HELPER_OFFSET, CORNER_HELPER_RADIUS, MIN_ROUNTANGLE_SIZE, ROUNTANGLE_RADIUS } from "./parameters";
|
import { CORNER_HELPER_OFFSET, CORNER_HELPER_RADIUS, MIN_ROUNTANGLE_SIZE, ROUNTANGLE_RADIUS } from "./parameters";
|
||||||
|
|
||||||
|
|
@ -124,7 +124,7 @@ export function VisualEditor() {
|
||||||
// 2) performance: only save when the user does nothing
|
// 2) performance: only save when the user does nothing
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
window.localStorage.setItem("state", JSON.stringify(state));
|
window.localStorage.setItem("state", JSON.stringify(state));
|
||||||
console.log('saved to localStorage');
|
// console.log('saved to localStorage');
|
||||||
|
|
||||||
const [statechart, errors] = parseStatechart(state);
|
const [statechart, errors] = parseStatechart(state);
|
||||||
console.log('statechart: ', statechart, 'errors:', errors);
|
console.log('statechart: ', statechart, 'errors:', errors);
|
||||||
|
|
@ -396,7 +396,11 @@ export function VisualEditor() {
|
||||||
};
|
};
|
||||||
}, [selectingState, dragging]);
|
}, [selectingState, dragging]);
|
||||||
|
|
||||||
|
// whenever an arrow is selected, highlight the rountangle sides it connects to
|
||||||
|
// just for visual feedback
|
||||||
let sidesToHighlight: {[key: string]: RountanglePart[]} = {};
|
let sidesToHighlight: {[key: string]: RountanglePart[]} = {};
|
||||||
|
let arrowsToHighlight: {[key: string]: Arrow} = {};
|
||||||
|
let textsToHighlight: {[key: string]: Text} = {};
|
||||||
for (const selected of selection) {
|
for (const selected of selection) {
|
||||||
for (const arrow of state.arrows) {
|
for (const arrow of state.arrows) {
|
||||||
if (arrow.uid === selected.uid) {
|
if (arrow.uid === selected.uid) {
|
||||||
|
|
@ -408,7 +412,20 @@ export function VisualEditor() {
|
||||||
if (rSideEnd) {
|
if (rSideEnd) {
|
||||||
sidesToHighlight[rSideEnd.uid] = [...(sidesToHighlight[rSideEnd.uid] || []), rSideEnd.part];
|
sidesToHighlight[rSideEnd.uid] = [...(sidesToHighlight[rSideEnd.uid] || []), rSideEnd.part];
|
||||||
}
|
}
|
||||||
|
for (const text of state.texts) {
|
||||||
|
const belongsToArrow = findNearestArrow(text.topLeft, state.arrows);
|
||||||
|
if (belongsToArrow === arrow) {
|
||||||
|
textsToHighlight[text.uid] = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const text of state.texts) {
|
||||||
|
if (text.uid === selected.uid) {
|
||||||
|
const belongsToArrow = findNearestArrow(text.topLeft, state.arrows);
|
||||||
|
if (belongsToArrow) {
|
||||||
|
arrowsToHighlight[belongsToArrow.uid] = belongsToArrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -449,16 +466,19 @@ export function VisualEditor() {
|
||||||
arrow={arrow}
|
arrow={arrow}
|
||||||
selected={selection.find(a => a.uid === arrow.uid)?.parts || []}
|
selected={selection.find(a => a.uid === arrow.uid)?.parts || []}
|
||||||
errors={errors.filter(([uid,msg])=>uid===arrow.uid).map(err=>err[1])}
|
errors={errors.filter(([uid,msg])=>uid===arrow.uid).map(err=>err[1])}
|
||||||
|
highlight={arrowsToHighlight.hasOwnProperty(arrow.uid)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state.texts.map(txt => <text
|
{state.texts.map(txt => <text
|
||||||
key={txt.uid}
|
key={txt.uid}
|
||||||
className={selection.find(s => s.uid === txt.uid)?.parts?.length ? "selected":""}
|
className={
|
||||||
|
(selection.find(s => s.uid === txt.uid)?.parts?.length ? "selected":"")
|
||||||
|
+(textsToHighlight.hasOwnProperty(txt.uid)?" highlight":"")
|
||||||
|
}
|
||||||
x={txt.topLeft.x}
|
x={txt.topLeft.x}
|
||||||
width={200}
|
|
||||||
height={40}
|
|
||||||
y={txt.topLeft.y}
|
y={txt.topLeft.y}
|
||||||
|
textAnchor="middle"
|
||||||
data-uid={txt.uid}
|
data-uid={txt.uid}
|
||||||
data-parts="text"
|
data-parts="text"
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
|
|
@ -543,7 +563,8 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
|
||||||
data-parts="left top right bottom"
|
data-parts="left top right bottom"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(props.errors.length>0) && <text className="error" x={10} y={40} data-uid={uid} data-parts="left top right bottom">{props.errors.join(' ')}</text>}
|
{(props.errors.length>0) &&
|
||||||
|
<text className="error" x={10} y={40} data-uid={uid} data-parts="left top right bottom">{props.errors.join(' ')}</text>}
|
||||||
|
|
||||||
|
|
||||||
<line
|
<line
|
||||||
|
|
@ -631,11 +652,15 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
|
||||||
</g>;
|
</g>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArrowSVG(props: {arrow: Arrow, selected: string[], errors: string[]}) {
|
export function ArrowSVG(props: {arrow: Arrow, selected: string[], errors: string[], highlight: boolean}) {
|
||||||
const {start, end, uid} = props.arrow;
|
const {start, end, uid} = props.arrow;
|
||||||
return <g>
|
return <g>
|
||||||
<line
|
<line
|
||||||
className={"arrow"+(props.errors.length>0?" error":"")}
|
className={"arrow"
|
||||||
|
+(props.selected.length===2?" selected":"")
|
||||||
|
+(props.errors.length>0?" error":"")
|
||||||
|
+(props.highlight?" highlight":"")
|
||||||
|
}
|
||||||
markerEnd='url(#arrowEnd)'
|
markerEnd='url(#arrowEnd)'
|
||||||
x1={start.x}
|
x1={start.x}
|
||||||
y1={start.y}
|
y1={start.y}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Rect2D, Vec2D, Line2D, euclideanDistance, intersectLines, isWithin, lineBBox } from "./geometry";
|
import { Rect2D, Vec2D, Line2D, euclideanDistance, intersectLines, isWithin, lineBBox } from "./geometry";
|
||||||
import { ARROW_SNAP_THRESHOLD } from "./parameters";
|
import { ARROW_SNAP_THRESHOLD, TEXT_SNAP_THRESHOLD } from "./parameters";
|
||||||
import { sides } from "./VisualEditor";
|
import { sides } from "./VisualEditor";
|
||||||
|
|
||||||
export type Rountangle = {
|
export type Rountangle = {
|
||||||
|
|
@ -7,7 +7,7 @@ export type Rountangle = {
|
||||||
kind: "and" | "or";
|
kind: "and" | "or";
|
||||||
} & Rect2D;
|
} & Rect2D;
|
||||||
|
|
||||||
type Text = {
|
export type Text = {
|
||||||
uid: string;
|
uid: string;
|
||||||
topLeft: Vec2D;
|
topLeft: Vec2D;
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -44,6 +44,7 @@ export const onOffStateMachine = {
|
||||||
nextID: 3,
|
nextID: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// used to find which rountangle an arrow connects to (src/tgt)
|
||||||
export function findNearestRountangleSide(arrow: Line2D, arrowPart: "start" | "end", candidates: Rountangle[]): {uid: string, part: RountanglePart} | undefined {
|
export function findNearestRountangleSide(arrow: Line2D, arrowPart: "start" | "end", candidates: Rountangle[]): {uid: string, part: RountanglePart} | undefined {
|
||||||
let best = Infinity;
|
let best = Infinity;
|
||||||
let bestSide: undefined | {uid: string, part: RountanglePart};
|
let bestSide: undefined | {uid: string, part: RountanglePart};
|
||||||
|
|
@ -63,3 +64,45 @@ export function findNearestRountangleSide(arrow: Line2D, arrowPart: "start" | "e
|
||||||
}
|
}
|
||||||
return bestSide;
|
return bestSide;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function point2LineDistance(point: Vec2D, {start, end}: Line2D): number {
|
||||||
|
const A = point.x - start.x;
|
||||||
|
const B = point.y - start.y;
|
||||||
|
const C = end.x - start.x;
|
||||||
|
const D = end.y - start.y;
|
||||||
|
|
||||||
|
const dot = A * C + B * D;
|
||||||
|
const lenSq = C * C + D * D;
|
||||||
|
let t = lenSq ? dot / lenSq : -1;
|
||||||
|
|
||||||
|
if (t < 0) t = 0;
|
||||||
|
else if (t > 1) t = 1;
|
||||||
|
|
||||||
|
const closestX = start.x + t * C;
|
||||||
|
const closestY = start.y + t * D;
|
||||||
|
|
||||||
|
const dx = point.x - closestX;
|
||||||
|
const dy = point.y - closestY;
|
||||||
|
|
||||||
|
const distance = Math.hypot(dx, dy);
|
||||||
|
|
||||||
|
return distance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// used to find which arrow a text label belongs to (if any)
|
||||||
|
// author: ChatGPT
|
||||||
|
export function findNearestArrow(point: Vec2D, candidates: Arrow[]): Arrow | undefined {
|
||||||
|
let best;
|
||||||
|
let bestDistance = Infinity
|
||||||
|
|
||||||
|
for (const arrow of candidates) {
|
||||||
|
const distance = point2LineDistance(point, arrow);
|
||||||
|
|
||||||
|
if (distance < TEXT_SNAP_THRESHOLD && distance < bestDistance) {
|
||||||
|
bestDistance = distance;
|
||||||
|
best = arrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,8 @@ export function intersectLines(a: Line2D, b: Line2D): Vec2D | null {
|
||||||
export function euclideanDistance(a: Vec2D, b: Vec2D): number {
|
export function euclideanDistance(a: Vec2D, b: Vec2D): number {
|
||||||
const diffX = a.x - b.x;
|
const diffX = a.x - b.x;
|
||||||
const diffY = a.y - b.y;
|
const diffY = a.y - b.y;
|
||||||
return Math.sqrt(diffX*diffX + diffY*diffY);
|
return Math.hypot(diffX, diffY);
|
||||||
|
// return Math.sqrt(diffX*diffX + diffY*diffY);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLeftSide(rect: Rect2D): Line2D {
|
export function getLeftSide(rect: Rect2D): Line2D {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
|
|
||||||
export const ARROW_SNAP_THRESHOLD = 20;
|
export const ARROW_SNAP_THRESHOLD = 20;
|
||||||
|
export const TEXT_SNAP_THRESHOLD = 20;
|
||||||
|
|
||||||
export const ROUNTANGLE_RADIUS = 20;
|
export const ROUNTANGLE_RADIUS = 20;
|
||||||
export const MIN_ROUNTANGLE_SIZE = { x: ROUNTANGLE_RADIUS*2, y: ROUNTANGLE_RADIUS*2 };
|
export const MIN_ROUNTANGLE_SIZE = { x: ROUNTANGLE_RADIUS*2, y: ROUNTANGLE_RADIUS*2 };
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue