visual feedback on labels belonging to arrows and vice-versa

This commit is contained in:
Joeri Exelmans 2025-10-06 11:45:42 +02:00
parent db246ccefe
commit 58a75ddd8b
5 changed files with 103 additions and 14 deletions

View file

@ -2,8 +2,23 @@
cursor: crosshair;
}
text {
text, text.highlight {
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 {
@ -70,6 +85,10 @@ text {
stroke: black;
stroke-width: 2px;
}
.arrow.selected {
stroke: blue;
stroke-width: 3px;
}
/* .arrow.selected {
stroke: blue;
@ -100,7 +119,7 @@ text.selected, text.selected:hover {
/* font-weight: bold; */
}
text:hover {
fill: rgba(0, 200, 0, 1);
fill: darkcyan;
/* cursor: grab; */
}

View file

@ -4,7 +4,7 @@ import { Line2D, Rect2D, Vec2D, addV2D, area, getBottomSide, getLeftSide, getRig
import "./VisualEditor.css";
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 { 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
const timeout = setTimeout(() => {
window.localStorage.setItem("state", JSON.stringify(state));
console.log('saved to localStorage');
// console.log('saved to localStorage');
const [statechart, errors] = parseStatechart(state);
console.log('statechart: ', statechart, 'errors:', errors);
@ -396,7 +396,11 @@ export function VisualEditor() {
};
}, [selectingState, dragging]);
// whenever an arrow is selected, highlight the rountangle sides it connects to
// just for visual feedback
let sidesToHighlight: {[key: string]: RountanglePart[]} = {};
let arrowsToHighlight: {[key: string]: Arrow} = {};
let textsToHighlight: {[key: string]: Text} = {};
for (const selected of selection) {
for (const arrow of state.arrows) {
if (arrow.uid === selected.uid) {
@ -408,7 +412,20 @@ export function VisualEditor() {
if (rSideEnd) {
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}
selected={selection.find(a => a.uid === arrow.uid)?.parts || []}
errors={errors.filter(([uid,msg])=>uid===arrow.uid).map(err=>err[1])}
highlight={arrowsToHighlight.hasOwnProperty(arrow.uid)}
/>
)}
{state.texts.map(txt => <text
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}
width={200}
height={40}
y={txt.topLeft.y}
textAnchor="middle"
data-uid={txt.uid}
data-parts="text"
onDoubleClick={() => {
@ -543,7 +563,8 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
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
@ -631,11 +652,15 @@ export function RountangleSVG(props: {rountangle: Rountangle, selected: string[]
</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;
return <g>
<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)'
x1={start.x}
y1={start.y}

View file

@ -1,5 +1,5 @@
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";
export type Rountangle = {
@ -7,7 +7,7 @@ export type Rountangle = {
kind: "and" | "or";
} & Rect2D;
type Text = {
export type Text = {
uid: string;
topLeft: Vec2D;
text: string;
@ -44,6 +44,7 @@ export const onOffStateMachine = {
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 {
let best = Infinity;
let bestSide: undefined | {uid: string, part: RountanglePart};
@ -63,3 +64,45 @@ export function findNearestRountangleSide(arrow: Line2D, arrowPart: "start" | "e
}
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;
}

View file

@ -133,7 +133,8 @@ export function intersectLines(a: Line2D, b: Line2D): Vec2D | null {
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);
return Math.hypot(diffX, diffY);
// return Math.sqrt(diffX*diffX + diffY*diffY);
}
export function getLeftSide(rect: Rect2D): Line2D {

View file

@ -1,5 +1,6 @@
export const ARROW_SNAP_THRESHOLD = 20;
export const TEXT_SNAP_THRESHOLD = 20;
export const ROUNTANGLE_RADIUS = 20;
export const MIN_ROUNTANGLE_SIZE = { x: ROUNTANGLE_RADIUS*2, y: ROUNTANGLE_RADIUS*2 };