diff --git a/src/canvas/canvasReducer.ts b/src/canvas/canvasReducer.ts index 76d630f..3a34e73 100644 --- a/src/canvas/canvasReducer.ts +++ b/src/canvas/canvasReducer.ts @@ -1,12 +1,9 @@ import { PayloadAction, createSlice, Middleware } from "@reduxjs/toolkit"; import * as ViewField from "./view_field"; -import { - getSelectedAtomId, getViewField, getAtoms, getDraftConnection -} from "./selectors"; +import { getSelectedAtomId, getDraftConnection } from "./selectors"; import { addAtom, moveAtom, connectAtoms } from "../store/defaultReducer"; import { createAtom } from "../store/atom"; import { Point, Line } from "./geometry"; -import { buildNodeGeometry, within, withinDragArea } from "./node_geometry"; import { nearestGridPoint } from "./grid"; interface WindowDimensions { @@ -36,6 +33,7 @@ export interface CanvasState { selectedAtomId: string; draggedAtomId: string; clickOffset: Offset; + selectedEdgeId: string; mode: Mode; draftConnection: { show: boolean; @@ -51,6 +49,7 @@ const initialState: CanvasState = { draggedAtomId: null, selectedAtomId: null, clickOffset: { deltaX: 0, deltaY: 0 }, + selectedEdgeId: null, mode: "idle", draftConnection: { show: false, @@ -122,10 +121,12 @@ const canvasSlice = createSlice({ }, select(state, action: PayloadAction) { state.selectedAtomId = action.payload; + state.selectedEdgeId = null; state.mode = "ready"; }, unselect(state, action: PayloadAction) { state.selectedAtomId = null; + state.selectedEdgeId = null; state.mode = "idle"; }, typed(state, action: PayloadAction) { @@ -133,6 +134,9 @@ const canvasSlice = createSlice({ state.mode = "enter"; } }, + selectEdge(state, action: PayloadAction) { + state.selectedEdgeId = action.payload; + }, }, extraReducers: { "delete-atom": (state) => { @@ -141,61 +145,6 @@ const canvasSlice = createSlice({ } }); -const convertToGlobalCoordsMiddleware: Middleware = ({ getState }) => { - const types = [ - canvasSlice.actions.doubleClicked.type, - canvasSlice.actions.clicked.type, - canvasSlice.actions.mousePressed.type, - canvasSlice.actions.clicked.type, - canvasSlice.actions.mouseDragged.type, - ]; - - return next => action => { - if (types.includes(action.type)) { - action.payload.mouse = ViewField.toGlobalCoordinates( - getViewField(getState()), - action.payload.mouse, - ); - } - - next(action); - } -}; - -const detectMouseLocationMiddleware: Middleware = ({getState}) => { - const types = [ - canvasSlice.actions.doubleClicked.type, - canvasSlice.actions.clicked.type, - canvasSlice.actions.mousePressed.type, - canvasSlice.actions.clicked.type, - canvasSlice.actions.mouseDragged.type, - ]; - - return next => action => { - if (types.includes(action.type)) { - const atoms = getAtoms(getState()); - const mouse = action.payload.mouse; - const withinAtom = (candidate) => ( - within(mouse, buildNodeGeometry(candidate)) - ); - const atom = Object.values(atoms).find(withinAtom); - if (atom) { - action.payload.atomId = atom.id; - action.payload.dragArea = withinDragArea(mouse, buildNodeGeometry(atom)); - action.payload.clickOffset = { - deltaX: atom.x - mouse.x, - deltaY: atom.y - mouse.y, - }; - } else { - action.payload.atomId = null; - action.payload.dragArea = false; - } - } - - next(action); - } -}; - const doubleClickedMiddleware: Middleware = ({ getState, dispatch }) => { const switchToEditMode = () => dispatch(canvasSlice.actions.changeMode("edit")); @@ -288,8 +237,6 @@ const connectAtomsMiddleware: Middleware = ({ getState, dispatch }) => { }; export const middlewares = [ - convertToGlobalCoordsMiddleware, - detectMouseLocationMiddleware, doubleClickedMiddleware, mouseClickedMiddleware, moveDragAtomMiddleware, diff --git a/src/canvas/edge.ts b/src/canvas/edge.ts new file mode 100644 index 0000000..201413a --- /dev/null +++ b/src/canvas/edge.ts @@ -0,0 +1,8 @@ +interface Edge { + sourceId: string; + targetId: string; +} + +export default { + id: (edge: Edge): string => `${edge.sourceId}-->${edge.targetId}`, +}; diff --git a/src/canvas/edge_geometry.ts b/src/canvas/edge_geometry.ts new file mode 100644 index 0000000..ecb5979 --- /dev/null +++ b/src/canvas/edge_geometry.ts @@ -0,0 +1,48 @@ +import * as p5 from "p5"; +import { Line, Point, distance } from "./geometry"; + +interface EdgeGeometry { + line: Line; + draw(s: p5, selected: boolean): void; + isWithin(mouse: Point): boolean; +} + +const buildEdgeGeometry = (line: Line): EdgeGeometry => { + const strokeWeight = 1.5; + const strokeColor = 150; + const strokeSelectedColor = "#79B8FF"; + + return { + line, + + draw(s, selected) { + s.push(); + s.strokeWeight(strokeWeight); + selected ? s.stroke(s.color(strokeSelectedColor)) : s.stroke(strokeColor); + s.line(this.line.x1, this.line.y1, this.line.x2, this.line.y2); + s.pop(); + }, + + isWithin(mouse) { + const start = { x: this.line.x1, y: this.line.y1 }; + const end = { x: this.line.x2, y: this.line.y2 }; + + // Triangle sides + const a = distance(start, mouse); + const b = distance(mouse, end); + const c = distance(start, end); + + // Perimeter and semi-perimeter + const p = a + b + c; + const s = p / 2; + + const area = Math.sqrt(s * (s - a) * (s - b) * (s - c)); + + const height = 2 * area / c; + + return height < 5; + }, + }; +}; + +export { buildEdgeGeometry }; diff --git a/src/canvas/geometry.ts b/src/canvas/geometry.ts index efe218f..50f3635 100644 --- a/src/canvas/geometry.ts +++ b/src/canvas/geometry.ts @@ -42,4 +42,19 @@ export function midPoint(line: Line): Point { const y3 = midRatio * y2 + (1 - midRatio) * y1; return { x: x3, y: y3 }; -} \ No newline at end of file +} + +/** + * Checks if a given point `p` is inside of a rectangle `r`. + */ +export const within = (r: Rect, p: Point): boolean => { + const left = r.x; + const right = r.x + r.width; + const top = r.y; + const bottom = r.y + r.height; + + return ( + p.x > left && p.x < right && + p.y < bottom && p.y > top + ); +}; diff --git a/src/canvas/node_geometry.ts b/src/canvas/node_geometry.ts index 81e674e..aaa4dc0 100644 --- a/src/canvas/node_geometry.ts +++ b/src/canvas/node_geometry.ts @@ -1,12 +1,15 @@ import * as p5 from "p5"; -import { Point, Rect } from "./geometry"; +import { Point, Rect, within } from "./geometry"; -interface NodeGeometry { +export interface NodeGeometry { center: Point; border: Rect; text: Point; - drag: Point[]; + dragPoints: Point[]; value: string; + draw(s: p5, selected: boolean): void; + isWithin(mouse: Point): boolean; + isWithinDragArea(mouse: Point): boolean; } const buildNodeGeometry = (atom: any): NodeGeometry => { @@ -28,7 +31,7 @@ const buildNodeGeometry = (atom: any): NodeGeometry => { center, border: { x: atom.x - 20, y: atom.y - 14, width, height }, text: { x: atom.x + 2, y: atom.y - 7.5 }, - drag: [ + dragPoints: [ { x: atom.x - 12, y: atom.y - 7 }, { x: atom.x - 7, y: atom.y - 7 }, { x: atom.x - 12, y: atom.y }, @@ -37,71 +40,52 @@ const buildNodeGeometry = (atom: any): NodeGeometry => { { x: atom.x - 7, y: atom.y + 7 }, ], value: atom.value, - }; -}; - -const drawNode = (s: p5, geometry: NodeGeometry, selected: boolean) => { - const fillColor = "#ffffff"; - const unselectedColor = "#999999"; - const selectedColor = "#79B8FF"; - const strokeWeight = 1.5; - const borderRadius = 6; - - const strokeColor = selected ? selectedColor : unselectedColor; - - s.push(); - s.fill(s.color(fillColor)); - s.strokeWeight(strokeWeight); - s.stroke(s.color(strokeColor)); - - const border = geometry.border; - s.rect(border.x, border.y, border.width, border.height, borderRadius); - s.pop(); - - if (selected) { - s.push(); - s.fill(s.color(fillColor)); - s.stroke(s.color(strokeColor)); - geometry.drag.forEach((p) => s.circle(p.x, p.y, 2)); - s.pop(); - } - s.push(); - s.fill(50); - s.strokeWeight(0); - s.textAlign(s.LEFT, s.TOP); - s.textFont("monospace", 14); - s.textLeading(17); - s.text(geometry.value, geometry.text.x, geometry.text.y); - s.pop(); -}; - -const within = (mouse: Point, geometry: NodeGeometry): boolean => { - const leftBoundary = geometry.border.x; - const rightBoundary = geometry.border.x + geometry.border.width; - const topBoundary = geometry.border.y; - const bottomBoundary = geometry.border.y + geometry.border.height; - - return ( - mouse.x > leftBoundary && - mouse.x < rightBoundary && - mouse.y < bottomBoundary && - mouse.y > topBoundary - ); -}; - -const withinDragArea = (mouse: Point, geometry: NodeGeometry): boolean => { - const leftBoundary = geometry.border.x; - const rightBoundary = geometry.border.x + 20; - const topBoundary = geometry.border.y; - const bottomBoundary = geometry.border.y + 20; - - return ( - mouse.x > leftBoundary && - mouse.x < rightBoundary && - mouse.y < bottomBoundary && - mouse.y > topBoundary - ); + draw(s, selected) { + const fillColor = "#ffffff"; + const unselectedColor = "#999999"; + const selectedColor = "#79B8FF"; + const strokeWeight = 1.5; + const borderRadius = 6; + + const strokeColor = selected ? selectedColor : unselectedColor; + + s.push(); + s.fill(s.color(fillColor)); + s.strokeWeight(strokeWeight); + s.stroke(s.color(strokeColor)); + + const border = this.border; + s.rect(border.x, border.y, border.width, border.height, borderRadius); + s.pop(); + + if (selected) { + s.push(); + s.fill(s.color(fillColor)); + s.stroke(s.color(strokeColor)); + this.dragPoints.forEach((p) => s.circle(p.x, p.y, 2)); + s.pop(); + } + + s.push(); + s.fill(50); + s.strokeWeight(0); + s.textAlign(s.LEFT, s.TOP); + s.textFont("monospace", 14); + s.textLeading(17); + s.text(this.value, this.text.x, this.text.y); + s.pop(); + }, + + isWithin(mouse) { + return within(this.border, mouse); + }, + + isWithinDragArea(mouse) { + const dragArea: Rect = { x: this.border.x, y: this.border.y, width: 20, height: 20 }; + return within(dragArea, mouse); + }, + }; }; -export { buildNodeGeometry, drawNode, within, withinDragArea }; +export { buildNodeGeometry }; diff --git a/src/canvas/selectors.ts b/src/canvas/selectors.ts index 81fdba8..91b7fa5 100644 --- a/src/canvas/selectors.ts +++ b/src/canvas/selectors.ts @@ -1,5 +1,6 @@ import { createSelector } from "@reduxjs/toolkit"; import { ApplicationState } from "../store"; +import Edge from "./edge"; const getEdges = (state: ApplicationState) => state.default.edges; @@ -9,6 +10,8 @@ export const getMode = (state: ApplicationState) => state.canvas.mode; export const getSelectedAtomId = (state: ApplicationState) => state.canvas.selectedAtomId; +export const getSelectedEdgeId = (state: ApplicationState) => state.canvas.selectedEdgeId; + export const getViewField = (state: ApplicationState) => state.canvas.viewField; export const getTranslateValue = (state: ApplicationState) => state.canvas.translate; @@ -24,17 +27,26 @@ export const getDraftConnection = (state: ApplicationState) => { export const getDrawableAtoms = createSelector([getAtoms, getSelectedAtomId], (atoms, selectedAtomId) => { return Object.values(atoms).map(({ id, x, y, value }) => { - const selected = (id == selectedAtomId); - return { x, y, value, selected }; + const selected = (id === selectedAtomId); + return { id, x, y, value, selected }; }); }); export const getDrawableEdges = - createSelector([getAtoms, getEdges], (atoms, edges) => { + createSelector([getAtoms, getEdges, getSelectedEdgeId], (atoms, edges, selectedEdgeId) => { return edges.map(({ sourceId, targetId }) => { const source = atoms[sourceId]; const target = atoms[targetId]; - return { x1: source.x, y1: source.y, x2: target.x, y2: target.y }; + const edgeId = Edge.id({ sourceId, targetId }); + return { + x1: source.x, + y1: source.y, + x2: target.x, + y2: target.y, + sourceId, + targetId, + selected: (edgeId === selectedEdgeId), + }; }) }); diff --git a/src/canvas/sketch.ts b/src/canvas/sketch.ts index 215ee11..08b5382 100644 --- a/src/canvas/sketch.ts +++ b/src/canvas/sketch.ts @@ -2,12 +2,14 @@ import * as p5 from "p5"; import { Store } from "redux"; import { Point } from "./geometry"; -import { buildNodeGeometry, drawNode } from "./node_geometry"; +import { buildNodeGeometry, NodeGeometry } from "./node_geometry"; +import { buildEdgeGeometry } from "./edge_geometry"; import * as Legend from "./legend"; import { gridPoints, gridTiles } from "./grid"; import * as ViewField from "./view_field"; import * as ValueInput from "./value_input"; import { ApplicationState } from "../store"; +import Edge from "./edge"; import { actions, Click } from "./canvasReducer"; import { @@ -43,30 +45,24 @@ export default function Sketch(store: Store) { const backgroundColor = p.color("#FDFDFD"); let bg: p5.Graphics = null; - const createClickPayload = (): Click => { - const mouse = { x: p.mouseX, y: p.mouseY }; - const atomId = null; - const dragArea = false; - return { mouse, atomId, dragArea }; - } - - function drawEdges() { - p.push(); + const createClickPayload = (x: number, y: number): Click => { + const clickedOn = (mouse) => (candidate) => buildNodeGeometry(candidate).isWithin(mouse); - p.strokeWeight(1.5); - p.stroke(150); + const mouse = ViewField.toGlobalCoordinates(viewField, { x, y }); + const atom = Object.values(atoms).find(clickedOn(mouse)); + const atomId = atom?.id; + const dragArea = atom ? buildNodeGeometry(atom).isWithinDragArea(mouse) : false; + const clickOffset = atom ? { deltaX: atom.x - mouse.x, deltaY: atom.y - mouse.y } : null; - edges.forEach(e => p.line(e.x1, e.y1, e.x2, e.y2)); + return { mouse, atomId, dragArea, clickOffset }; + }; - p.pop(); + function drawEdges() { + edges.forEach((edge) => buildEdgeGeometry(edge).draw(p, edge.selected)); } function drawAtoms() { - p.push(); - atoms.forEach(atom => { - drawNode(p, buildNodeGeometry(atom), atom.selected); - }); - p.pop(); + atoms.forEach((node) => buildNodeGeometry(node).draw(p, node.selected)); } function drawBackground(s: p5, bg: p5.Graphics, color: p5.Color) { @@ -152,15 +148,28 @@ export default function Sketch(store: Store) { const tagName = (event.srcElement as HTMLElement).tagName; if (tagName !== "CANVAS" && tagName !== "INPUT") return; - store.dispatch(actions.mousePressed(createClickPayload())); + const payload = createClickPayload(p.mouseX, p.mouseY); + store.dispatch(actions.mousePressed(payload)); } p.mouseClicked = () => { - store.dispatch(actions.clicked(createClickPayload())); + const payload = createClickPayload(p.mouseX, p.mouseY); + store.dispatch(actions.clicked(payload)); + + if (payload.atomId) return; + + // Check for click on edge + edges.forEach((edge) => { + if (buildEdgeGeometry(edge).isWithin(payload.mouse)) { + const edgeId = Edge.id(edge); + store.dispatch(actions.selectEdge(edgeId)); + } + }); } p.doubleClicked = () => { - store.dispatch(actions.doubleClicked(createClickPayload())); + const payload = createClickPayload(p.mouseX, p.mouseY); + store.dispatch(actions.doubleClicked(payload)); } p.mouseWheel = (event: WheelEvent) => { @@ -170,7 +179,8 @@ export default function Sketch(store: Store) { } p.mouseDragged = () => { - store.dispatch(actions.mouseDragged(createClickPayload())); + const payload = createClickPayload(p.mouseX, p.mouseY); + store.dispatch(actions.mouseDragged(payload)); } p.keyTyped = () => {