GraphicalEditor.jsx 33.2 KB
Newer Older
1
// @flow
2
import './GraphicalEditor.css';
3 4
import * as React from 'react';
import * as d3 from 'd3';
5 6 7 8 9
import {
	Button,
	ButtonGroup,
} from 'reactstrap';
import cn from 'classnames';
10
import Block from './ToolchainBlock.jsx';
11
import Connection, { connectionToId } from './ToolchainConnection.jsx';
12
import HelpModal from './GraphicalEditorHelpModal.jsx';
13
import { ContextMenuTrigger } from 'react-contextmenu';
14 15
import { changeObjFieldName, generateNewKey } from '@helpers';

16 17 18 19 20 21 22 23 24 25 26
import type {
	BlockType,
	NormalBlock,
	AnalyzerBlock,
	DatasetBlock,
	BlockSet,
	BlockCoords,
	ConnectionType,
	Group,
	LocationMapEntry,
	LocationMap ,
27
} from '@helpers/toolchainTypes';
28

29
type Props = {
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
30
	// the representation data of the toolchain (block locations & channel colors)
31
	repData: {
32 33 34 35 36 37 38 39 40
		blocks: {
			[string]: {
				col: number,
				row: number,
			}
		},
		channel_colors: {
			[string]: string
		},
41
	},
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
42
	// the blocks in the toolchain
43
	blocks: NormalBlock[],
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
44
	// the datasets in the toolchain
45
	datasets: DatasetBlock[],
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
46
	// the analyzers in the toolchain
47
	analyzers: AnalyzerBlock[],
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
48
	// the connections in the toolchain
49
	connections: ConnectionType[],
50 51
	// block groups (not a feature in beat web)
	groups: Group[],
52
	handleBlockClick?: (blockName: string, set: BlockSet) => any,
53
	handleBackgroundClick?: (e: any) => any,
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
54
	// updates a block location to col #x & row #y
55
	updateBlockLocations?: (bLocs: {blockName: string, x: number, y: number}[]) => any,
56
	createConnection?: (from: string, to: string, channel: string) => any,
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
57
	// extra svg to be drawn after the blocks & connections
58
	svgChildren?: React.Node,
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
59
	// extra children (e.g. context menus) that arent svg
60
	divChildren?: React.Node,
61
	// should the d3 behaviours be enabled?
62
	interactable?: boolean,
63 64
	// map of block names to strings of text to display on the bottom of a block
	// used for showing the selected dataset/algorithm in an exp
65 66 67
	experimentData?: { [string]: string },
	// block names mapped to an array of error strings
	errorBlocks?: { [string]: string[] },
68 69
	// blocks to highlight strongly
	focusBlocks?: string[],
70
	toolbarContent?: React.Node,
71 72 73
};

type State = {
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
74
	// the scale transformation for the svg
75
	zoomLevel: number,
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
76
	// represents the most recent click at a specific point on the svg's grid background
77 78 79 80
	svgActionLocation: {
		x: number,
		y: number,
	},
81
	selectedBlocks: BlockType[],
82 83
	maximized: boolean,
	helpActive: boolean,
84 85
	rightClickBlock: ?string,
	rightClickConnection: ?ConnectionType,
86 87
};

Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
88 89
// note: all measurements are in px by default for svg
// how tall  a line of text is in a block
90
export const blockTextHeight = 20;
91 92 93 94
export const svgDimensionLength = 6000;
export const svgWidth = svgDimensionLength;
export const svgHeight = svgDimensionLength;
export const startingZoomLevel = svgDimensionLength;
95
export const minZoomLevel = 5000;
96 97
export const svgWidthHalf = svgWidth / 2;
export const svgHeightHalf = svgHeight / 2;
98
export const blockWidth = 200;
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
99 100 101
// spacing between grid rows/cols
export const gridDistance = 20;
// how much the scaling changes at once
102
export const zoomAdjustmentAmount = 100;
103 104 105 106 107 108 109 110
// convert coords in the SVG element to coords to save in data
export const convertWorldToDataCoords = (x: number, y: number) => {
	return [x - svgWidthHalf, y - svgHeightHalf];
};
// convert coords in the data to the actual SVG locations
export const convertDataToWorldCoords = (x: number, y: number) => {
	return [x + svgWidthHalf, y + svgHeightHalf];
};
111 112 113 114 115 116
export const clampCoordsToGrid = (x: number, y: number): [number, number] => {
	const nx = Math.round(x / gridDistance) * gridDistance;
	const ny = Math.round(y / gridDistance) * gridDistance;
	return [nx, ny];
};

117
export default class GraphicalEditor extends React.PureComponent<Props, State> {
118 119 120 121 122
	constructor(props: Props){
		super(props);
	}

	state = {
123
		zoomLevel: startingZoomLevel,
124 125 126 127
		svgActionLocation: {
			x: -1,
			y: -1,
		},
128
		selectedBlocks: [],
129 130
		maximized: false,
		helpActive: false,
131 132
		rightClickBlock: undefined,
		rightClickConnection: undefined,
133 134 135 136
	};

	// initializes d3 behaviours (most SVG interactions)
	componentDidMount = () => {
137
		this.initD3Behaviours();
138 139
		this.fitToToolchain(this.getLocationMap());
		//this.scrollToMiddle();
140 141
	}

142 143 144 145 146 147 148 149 150 151 152
	// initalize all the d3 behaviours again if the tc changed,
	// so new blocks have the d3 stuff and old blocks are removed
	// (they overwrite older ones)
	componentDidUpdate = (prevProps: Props, prevState: State, snapshot: any) => {
		if(prevProps !== this.props){
			this.initD3Behaviours();
		}
	}

	initD3Behaviours = () => {
		if(this.props.interactable){
153 154
			this.initD3Drag();
			this.initD3Connections();
155
			this.initD3AreaSelect();
156
			this.initD3MouseMovement();
157
		}
158 159
	}

160 161
	// used for the scrollElement ref
	scrollElement = null;
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
162
	// used for the svgContextMenu ref
163
	svgContextMenu = null;
164 165 166 167 168 169 170 171 172 173
	// used for the blockContextMenu ref
	blockContextMenu = null;
	handleBlockContextMenu = (e: any, name: string, set: string) => {
		//console.log(`${ name } : ${ set }`);
		if(this.blockContextMenu){
			this.setState({ rightClickBlock: name });
			e.persist();
			e.preventDefault();
			e.stopPropagation();
			setTimeout(() => {
174 175
				if(this.blockContextMenu)
					this.blockContextMenu.handleContextClick(e);
176 177 178 179 180 181 182 183 184 185 186 187 188 189
			}, 0);
		}
	}

	// used for the connectionContextMenu ref
	connectionContextMenu = null;
	handleConnectionContextMenu = (e: any, connection: ConnectionType) => {
		//console.log(`${ name } : ${ set }`);
		if(this.connectionContextMenu){
			this.setState({ rightClickConnection: connection });
			e.persist();
			e.preventDefault();
			e.stopPropagation();
			setTimeout(() => {
190 191
				if(this.connectionContextMenu)
					this.connectionContextMenu.handleContextClick(e);
192 193 194
			}, 0);
		}
	}
195

196
	scrollTo = (x: number, y: number) => {
197 198
		setTimeout(() => {
			if(this.scrollElement){
199 200
				this.scrollElement.scrollLeft = x;
				this.scrollElement.scrollTop = y;
201 202 203 204
			}
		}, 50);
	};

205 206 207 208 209
	// scrolls to the middle of the page
	scrollToMiddle = () => {
		this.scrollTo(svgWidthHalf - 300, svgHeightHalf - 300);
	};

Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
210
	// adjusts the zoomlevel
211
	setZoomLevel = (zoomLevel: number) => zoomLevel >= minZoomLevel && this.setState({zoomLevel: zoomLevel});
212 213 214 215
	// zoom out
	zoomOut = () => this.setZoomLevel(this.state.zoomLevel + zoomAdjustmentAmount);
	// zoom in
	zoomIn = () => this.setZoomLevel(this.state.zoomLevel - zoomAdjustmentAmount);
216 217 218 219
	// toggle maximized
	toggleMaximized = () => this.setState({ maximized: !this.state.maximized });
	// toggles the help modal
	toggleHelp = () => this.setState({ helpActive: !this.state.helpActive });
220 221 222 223

	// generates the string representing the viewbox of the svg. The first two numbers represent the offset, the last two the scaling.
	getSvgViewBox = () => `0 0 ${ this.state.zoomLevel } ${ this.state.zoomLevel }`;

224 225 226 227 228 229 230 231 232 233 234 235
	findBlock = (name: string): BlockType => {
		const b = this.props.blocks.find(b => b.name === name) ||
			this.props.datasets.find(b => b.name === name) ||
			this.props.analyzers.find(b => b.name === name)
		;
		if(!b)
			throw new Error(`invalid block name: ${ name }`);

		return b;

	}

236 237
	// generates a map of the location (and sizes) of all blocks, inputs, & outputs on the graphical editor.
	// necessary to draw lines between connected outputs & inputs.
238
	getLocationMap = (): LocationMap => {
239
		const locMap = {};
240 241
		const hasExpData = this.props.experimentData !== undefined;
		const ioVerticalOffset = hasExpData ? blockTextHeight : blockTextHeight / 4;
242
		const notShownBlocks: string[] = hasExpData ? [] : this.props.groups.reduce((arr, g) => g.collapsed ? [...g.blocks, ...arr] : arr, []);
243

244 245
		((Object.entries(this.props.repData.blocks): any): [string, any][])
		.forEach(([name, rep], i) => {
246
			const data: BlockType = this.findBlock(name);
247

248
			const [x, y] = convertDataToWorldCoords(rep.col * gridDistance, rep.row * gridDistance);
249 250
			const inputs = data.inputs || [];
			const outputs = data.outputs || [];
251
			const height = ((Math.max(inputs.length, outputs.length, 1) + 1) + 1) * blockTextHeight;
252 253 254
			const width = blockWidth;

			const locBlock = { x, y, height, width };
255 256

			locMap[name] = {
257
				block: locBlock,
258
			};
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284

			if(data.inputs){
				const locInputs = {};
				inputs.forEach((inp, i) => {
					locInputs[inp] = {
						x: x - 8,
						y: (y + blockTextHeight) + (i * blockTextHeight) + (blockTextHeight * 0.3) + ioVerticalOffset,
						width: 8,
						height: blockTextHeight * 0.75,
					};
				});
				locMap[name].inputs = locInputs;
			}

			if(data.outputs){
				const locOutputs = {};
				outputs.forEach((out, i) => {
					locOutputs[out] = {
						x: x + blockWidth,
						y: (y + blockTextHeight) + (i * blockTextHeight) + (blockTextHeight * 0.3) + ioVerticalOffset,
						width: 8,
						height: blockTextHeight * 0.75,
					};
				});
				locMap[name].outputs = locOutputs;
			}
285 286
		});

287 288
		const getBlocksBoundingBox = (names: string[], collapsed: boolean) => {
			const blockLocs = names.filter(n => locMap.hasOwnProperty(n)).map(n => locMap[n].block);
289
			let x = collapsed && !hasExpData ?
290 291 292
				blockLocs.map(l => l.x).reduce((n, x) => n + x, 0) / blockLocs.length :
				Math.min(...blockLocs.map(l => l.x))
			;
293
			let y = collapsed && !hasExpData ?
294 295 296
				blockLocs.map(l => l.y).reduce((n, y) => n + y, 0) / blockLocs.length :
				Math.min(...blockLocs.map(l => l.y))
			;
297
			if(collapsed && !hasExpData)
298
				[x, y] = clampCoordsToGrid(x, y);
299

300
			const width = collapsed && !hasExpData ?
301 302 303
				blockWidth :
				Math.max(...blockLocs.map(l => l.x + l.width - x)) + gridDistance * 2
			;
304
			const height = collapsed && !hasExpData ?
305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325
				gridDistance * 3 :
				Math.max(...blockLocs.map(l => l.y + l.height - y)) + gridDistance * 2
			;

			return {
				x: x - gridDistance,
				y: y - gridDistance,
				width,
				height,
			};
		};

		this.props.groups.forEach(g => {
			// get the x,y,width,height for the group box
			const bbox = getBlocksBoundingBox(g.blocks, g.collapsed);
			locMap[g.name] = {
				block: bbox,
			};
			// if the group is collapsed,
			// patch the locMap so that connections to/from blocks in the group
			// instead are to/from the group block
326
			if(g.collapsed && !hasExpData){
327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346
				g.blocks.forEach(b => {
					if(locMap[b].inputs){
						for(const inp in locMap[b].inputs){
							const o = locMap[b].inputs[inp];
							o.x = bbox.x - 1;
							o.y = bbox.y + bbox.height / 2;
						}
					}

					if(locMap[b].outputs){
						for(const out in locMap[b].outputs){
							const o = locMap[b].outputs[out];
							o.x = bbox.x + bbox.width - 2;
							o.y = bbox.y + bbox.height / 2;
						}
					}
				});
			}
		});

347
		return locMap;
348 349
	}

Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
350
	// manually triggers the svg's context menu
351 352 353 354 355 356 357 358 359 360 361 362
	triggerSvgAction = (x: number, y: number, clickEvent: any) => {
		this.setState({
			svgActionLocation: {
				x,
				y,
			}
		});

		if(this.svgContextMenu)
			this.svgContextMenu.handleContextClick(clickEvent);
	}

363 364 365 366 367 368
	scaleD3EventLocation = (d3X: number, d3Y: number): [number, number] => {
		const x = d3X * (this.state.zoomLevel / svgWidth);
		const y = d3Y * (this.state.zoomLevel / svgHeight);
		return [x, y];
	};

369 370 371 372 373 374 375
	selectBlocks = (blocks: BlockType[]) => {
		this.setState({
			selectedBlocks: blocks,
		});
		this.initD3Drag();
	}

376 377 378 379 380
	// synchronizes setState call via passing a func instead of an object
	// uses names instead of block objects so it can find them in the latest props
	safeSelectBlocksByName = (blockNames: string[]) => {
		this.setState((prevState, props) => {
			return {
381 382
				selectedBlocks: blockNames
				.map(name =>
383 384 385 386 387 388 389 390 391
					props.blocks.find(b => b.name === name) ||
					props.datasets.find(b => b.name === name) ||
					props.analyzers.find(b => b.name === name)
				).filter(b => b)
			};
		});
		this.initD3Drag();
	}

Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
392 393 394 395
	// d3 hooks for dragging blocks around
	// to get around perf problems with updating the entire react state every drag event,
	// a placeholder (that's outside the react framework!) is drawn via d3.
	// only at the end of the drag is the react state updated.
396 397
	initD3Drag = () => {
		setTimeout(() => {
398 399
			const updateBlockLocations = this.props.updateBlockLocations;
			if(!updateBlockLocations)
400
				return;
401
			const that = this;
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
402
			// currently debouncing isnt necessary perf-wise
403 404
			let startX = 0;
			let startY = 0;
405 406 407 408
			let dx = 0;
			let dy = 0;
			let mx = 0;
			let my = 0;
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
409
			// the event handler for all the drag events in between the start/end
410
			function dragged(d) {
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
411
				// keep track of the change to mouse location across drag events
412 413
				dx += d3.event.dx;
				dy += d3.event.dy;
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
414 415 416 417 418
				// if the user has dragged enough to move at least one grid space,
				// calculate the amount of grid spaces to move
				if(Math.abs(dx) > gridDistance){
					mx = Math.round(dx / gridDistance);
					dx = dx - mx * gridDistance;
419
				}
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
420 421 422
				if(Math.abs(dy) > gridDistance){
					my = Math.round(dy / gridDistance);
					dy = dy - my * gridDistance;
423
				}
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
424
				// if theres any grid movement to be made, make it
425 426 427
				if(mx != 0 || my != 0){
					const dRect = d3.select('.dragRect');
					dRect
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
428 429
					.attr('x', Number.parseInt(dRect.attr('x')) + mx * gridDistance)
					.attr('y', Number.parseInt(dRect.attr('y')) + my * gridDistance);
430
				}
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
431
				// reset the grid movement since we made it already
432 433 434 435
				mx = 0;
				my = 0;
			}

Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
436
			// when the user starts dragging, create the placeholder block
437 438
			function startDrag(d) {
				const b = d3.select(this);
439 440
				const x = Number.parseFloat(b.attr('x'));
				const y = Number.parseFloat(b.attr('y'));
441 442
				const height = b.attr('height');
				const width = b.attr('width');
443 444
				startX = x / gridDistance;
				startY = y / gridDistance;
445 446 447 448 449 450 451 452 453 454 455 456 457 458 459

				d3.select('svg')
				.insert('rect', '.tcBlock')
				.attr('class', 'dragRect')
				.attr('fill', 'grey')
				.attr('stroke', 'black')
				.attr('stroke-width', '2')
				.attr('stroke-dasharray', '10, 10')
				.attr('opacity', '0.4')
				.attr('x', x)
				.attr('y', y)
				.attr('height', height)
				.attr('width', width)
				;

Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
460
				// reset vals for a new drag
461 462 463 464 465 466
				dx = 0;
				dy = 0;
				mx = 0;
				my = 0;
			}

Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
467
			// when the user lets go, move the block to the location of the placeholder
468 469
			function endDrag(d) {
				const dRect = d3.select('.dragRect');
470
				const d3this = d3.select(this);
471 472
				const x = Number.parseInt(dRect.attr('x')) / gridDistance;
				const y = Number.parseInt(dRect.attr('y')) / gridDistance;
473 474
				const xShift = x - startX;
				const yShift = y - startY;
475 476 477 478 479 480 481 482 483 484 485 486 487
				if(xShift !== 0 || yShift !== 0){
					let bNames = [];
					if(d3this.classed('areaSelect')){
						bNames = that.state.selectedBlocks.map(b => b.name);
					} else if(d3this.classed('tcGroup')){
						const gId = d3this.attr('id').slice(6);
						const g = that.props.groups.find(g => g.name === gId);
						if(!g)
							return;
						bNames = g.blocks;
					} else {
						bNames = [d3this.attr('id').slice(6)];
					}
488

489 490 491 492 493
					const bLocs = bNames.map(b => {
						const x = that.props.repData.blocks[b].col;
						const y = that.props.repData.blocks[b].row;
						const bx = x + xShift;
						const by = y + yShift;
494

495 496
						return {blockName: b, x: bx, y: by};
					});
497

498 499
					updateBlockLocations(bLocs);
				}
500 501 502
				dRect.remove();
			}

Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
503
			// all block elements should be draggable
504
			d3.selectAll('.fo,.areaSelect,.tcGroup').call(
505 506 507 508 509 510 511 512
				d3.drag()
				.on('start', startDrag)
				.on('end', endDrag)
				.on('drag', dragged)
			);
		}, 50);
	};

513 514 515
	// d3 behaviour for connecting an output and input and creating a connection
	// allows users to drag from output to input,
	// or click an output and click an input
516 517 518 519 520 521 522 523
	initD3Connections = () => {
		// creating connections
		setTimeout(() => {
			let startId;
			const createConnection = this.props.createConnection;
			const channelColors = this.props.repData.channel_colors;
			const {datasets, blocks} = this.props;
			let startBlock;
524 525 526 527 528 529 530
			let startX = 0;
			let startY = 0;
			// the user has started dragging from an output block,
			// or it might be starting a click event!
			// to see if the user is clicking or dragging,
			// check in endDrag() if the distance travelled is small
			// enough to be considered a click or not
531
			function startDrag(d) {
532
				//console.log('startDrag');
533 534
				startX = d3.event.x;
				startY = d3.event.y;
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
535
				// find the block that the user started from
536 537 538 539 540 541
				const rawId = d3.select(this).attr('id');
				const bName = rawId.split('-output-')[0];
				startId = rawId.replace('-output-', '.');
				startBlock = datasets.find(d => d.name === bName);
				if(!startBlock)
					startBlock = blocks.find(d => d.name === bName);
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
542
				// draw a line from the output rect to the mouse location
543 544 545 546
				d3.select('svg')
				.append('line')
				.classed('tmp', true)
				.attr('stroke', 'black')
547 548 549 550
				.attr('x1', startX)
				.attr('y1', startY)
				.attr('x2', startX)
				.attr('y2', startY);
551 552
			}

553
			const processEndBlock = (endInput: HTMLElement) => {
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
554 555
				if(!startBlock)
					return;
556
				//console.log(endInput);
557
				const endId = endInput.id.replace('-input-', '.');
558
				// create a new connection assuming theres a given func that does it
559 560
				if(createConnection)
					createConnection(startId, endId, startBlock.synchronized_channel || startBlock.name);
561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597

				startBlock = undefined;
			};

			// create a connection if the user ended the drag on an input block,
			// else just remove the temp svg line
			function endDrag(d) {
				//console.log('endDrag');
				const {x, y} = d3.event;
				if(Math.abs(startX - x) < 10 && Math.abs(startY - y) < 10) {
					// click event not drag
				} else {
					// probably not a click event
					d3.select('.tmp').remove();
					// with the location of the end event,
					// compare the location to the location & dims of all input blocks
					// in the svg.
					const endInput = Array.from(document.querySelectorAll('.iBlock'))
					.find(n => {
						const nx = n.x.baseVal.value;
						const ny = n.y.baseVal.value;
						const width = n.width.baseVal.value;
						const height = n.height.baseVal.value;
						return nx <= x && x <= nx + width && ny <= y && y <= ny + height;
					});
					if(endInput === undefined){
						startBlock = undefined;
					} else {
						processEndBlock(endInput);
					}
				}
			}

			// create a connection if the user clicked an input block,
			// else just reset the startBlock
			function endClick() {
				processEndBlock(this);
598 599
			}

Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
600
			// update the temp line
601 602 603 604 605 606
			function dragged() {
				d3.select('.tmp')
				.attr('x2', d3.event.x)
				.attr('y2', d3.event.y);
			}

607 608
			d3.selectAll('.oBlock')
			.call(
609 610 611 612 613
				d3.drag()
				.on('start', startDrag)
				.on('end', endDrag)
				.on('drag', dragged)
			);
614 615 616

			d3.selectAll('.iBlock')
			.on('click', endClick);
617 618 619
		}, 50);
	}

Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
620
	// show a little black dot signifying the closest grid point to the current mouse location.
621
	// right-clicking the svg background will use this location for its context menu's actions (e.g. adding/pasting blocks).
622 623 624 625 626 627
	initD3MouseMovement = () => {
		// mouse movement
		setTimeout(() => {
			let cx = 0;
			let cy = 0;

628 629 630
			d3.select('.puck')
			.remove();

Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
631
			// draw the puck
632
			d3.select('svg')
633
			.insert('circle', '.backgroundLimit')
634
			.attr('class', 'puck background')
635 636 637 638
			.attr('cx', 0)
			.attr('cy', 0)
			.attr('r', 5);

639
			const svg = document.querySelector('svg');
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
640 641
			// when the mouse moves, recalc the puck's point on the grid
			// needs to scale to the zoomlevel
642 643
			d3.select('svg')
			.on('mousemove', () => {
644
				const [rawx, rawy] = d3.mouse(svg);
645
				const [x, y] = clampCoordsToGrid(rawx, rawy);
646 647
				cx = x;
				cy = y;
648
				d3.select('.puck')
649 650 651 652
				.attr('cx', cx)
				.attr('cy', cy)
				;
			})
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
653 654
			// if the click event's target is the svg background,
			// trigger the svg action at the puck's current point
655
			.on('contextmenu', () => {
656 657
				if(!Array.from(d3.event.target.classList).includes('background'))
					return;
658 659
				const [xData, yData] = convertWorldToDataCoords(cx, cy);
				this.triggerSvgAction(xData / gridDistance, yData / gridDistance, d3.event);
660 661 662 663 664
			})
			;
		}, 50);
	}

665 666 667
	// d3 behaviour for selecting all blocks inside a rect (via click-and-drag)
	initD3AreaSelect = () => {
		setTimeout(() => {
668
			const {findBlock, selectBlocks} = this;
669 670 671 672 673 674
			let startX, startY;
			let currX, currY;
			const getPolygonPoints = () => {
				return `${ startX },${ startY } ${ startX },${ currY } ${ currX },${ currY } ${ currX },${ startY }`;
			};
			function startDrag(d) {
675
				const [rawx, rawy] = d3.mouse(document.querySelector('svg'));
676
				const [x, y] = clampCoordsToGrid(rawx, rawy);
677 678 679 680 681
				startX = x;
				startY = y;
				currX = x;
				currY = y;
				d3.select('svg')
682
				.insert('polygon', '.backgroundLimit')
683 684 685 686 687 688 689 690 691 692
				.attr('class', 'tmp')
				.attr('fill', 'grey')
				.attr('stroke', 'black')
				.attr('opacity', '0.3')
				.attr('points', getPolygonPoints())
				;
			}

			function endDrag(d) {
				const p = d3.select('polygon.tmp');
693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715
				const lesserX = Math.min(startX, currX);
				const lesserY = Math.min(startY, currY);
				const greaterX = Math.max(startX, currX);
				const greaterY = Math.max(startY, currY);
				const isInArea = (svgEl) => {
					const ex = svgEl.x.baseVal.value;
					const ey = svgEl.y.baseVal.value;
					const ew = svgEl.width.baseVal.value;
					const eh = svgEl.height.baseVal.value;
					if(ex >= greaterX || ex + ew <= lesserX)
						return false;
					if(ey >= greaterY || ey + eh <= lesserY)
						return false;
					return true;
				};
				const blocksInArea = Array.from(document.querySelectorAll('.fo'))
				.filter(el => isInArea(el))
				.map(el => el.id.slice(6))
				.map(name => findBlock(name))
				;

				selectBlocks(blocksInArea);

716
				p.remove();
717 718 719 720
				startX = 0;
				startY = 0;
				currX = 0;
				currY = 0;
721 722 723 724
			}

			// update the temp rect
			function dragged(d) {
725
				const [rawx, rawy] = d3.mouse(document.querySelector('svg'));
726
				const [x, y] = clampCoordsToGrid(rawx, rawy);
727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742
				currX = x;
				currY = y;
				d3.select('polygon.tmp')
				.attr('points', getPolygonPoints())
				;
			}

			d3.selectAll('.background').call(
				d3.drag()
				.on('start', startDrag)
				.on('end', endDrag)
				.on('drag', dragged)
			);
		}, 50);
	}

743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766
	fitToToolchain = (locMap: LocationMap) => {
		if(!this.scrollElement)
			return;

		// need to check the editorWrapper size dynamically
		// because it changes when the user maximizes/minimizes the editor
		const editorWrapper = document.querySelector('.editorWrapper');
		if(!editorWrapper)
			return;
		const wrapperWidth = editorWrapper.offsetWidth;
		const wrapperHeight = editorWrapper.offsetHeight;

		// find the min/max coords (boundaries) for the entire toolchain
		const ls = ((Object.values(locMap): any): LocationMapEntry[]);
		const minX = Math.min(...ls.map(l => l.block.x));
		const minY = Math.min(...ls.map(l => l.block.y));
		const maxX = Math.max(...ls.map(l => l.block.x + l.block.width));
		const maxY = Math.max(...ls.map(l => l.block.y + l.block.height));

		// calculate needed zoom level via the largest dimension
		const width = (maxX - minX) * svgWidth / wrapperWidth;
		const height = (maxY - minY) * svgWidth / wrapperHeight;
		// the scrollbars cut into the space so pad it a bit
		const zoomScaling =  1.05;
767
		const maxDimension = Math.max(Math.max(width, height) * zoomScaling, minZoomLevel);
768 769 770 771 772
		// zoom so the entire toolchain can fit in the window
		this.setZoomLevel(maxDimension);

		// scroll to view the entire toolchain
		const scaleFactor = startingZoomLevel / maxDimension;
773 774
		const centerX = Number.isFinite(minX) ? minX : svgWidthHalf - wrapperWidth / 4;
		const centerY = Number.isFinite(minY) ? minY : svgHeightHalf - wrapperHeight / 4;
775 776
		this.scrollTo(centerX * scaleFactor, centerY * scaleFactor);
	}
777 778

	render = () => {
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
779 780
		const gridX = gridDistance;
		const gridY = gridDistance;
781
		const locMap = this.getLocationMap();
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
782
		// scales width/height to the zoomlevel so the background grid lines cover the entire svg
783 784
		const scaledSvgWidth = svgWidth * (this.state.zoomLevel / startingZoomLevel);
		const scaledSvgHeight = svgHeight * (this.state.zoomLevel / startingZoomLevel);
785 786

		const getBlocksBoundingBox = (names: string[]) => {
787
			const blockLocs = names.filter(n => locMap.hasOwnProperty(n)).map(n => locMap[n].block);
788 789 790 791 792 793 794 795 796 797 798
			const xMin = Math.min(...blockLocs.map(l => l.x));
			const yMin = Math.min(...blockLocs.map(l => l.y));
			const width = Math.max(...blockLocs.map(l => l.x + l.width - xMin));
			const height = Math.max(...blockLocs.map(l => l.y + l.height - yMin));

			return {
				x: xMin - gridDistance,
				y: yMin - gridDistance,
				width: width + gridDistance * 2,
				height: height + gridDistance * 2,
			};
799
		};
800

801 802
		const selectedBlockNames = this.state.selectedBlocks.map(b => b.name);
		const selectBoxCoords = getBlocksBoundingBox(selectedBlockNames);
803
		const hiddenBlocks = this.props.experimentData !== undefined ? [] : this.props.groups.reduce((bs, g) => g.collapsed ? [...g.blocks, ...bs] : bs, []);
804 805 806 807 808 809 810 811 812 813
		const shownConnections = (
			selectedBlockNames.length === 0 ? this.props.connections : this.props.connections
			.filter(conn => selectedBlockNames.find(n => conn.from.startsWith(`${ n }.`) || conn.to.startsWith(`${ n }.`)))
		).filter(({ from, to }) => {
			const fb = from.split('.')[0];
			const tb = to.split('.')[0];
			return !this.props.groups.find(g => g.collapsed && g.blocks.includes(fb) && g.blocks.includes(tb));
		})
		;

814 815 816 817 818 819 820 821 822 823 824 825 826

		const datasetData = {};
		for(const b of this.props.datasets){
			datasetData[b.name] = b;
		}
		const blockData = {};
		for(const b of this.props.blocks){
			blockData[b.name] = b;
		}
		const analyzerData = {};
		for(const b of this.props.analyzers){
			analyzerData[b.name] = b;
		}
827

828
		return (
829 830 831
			<div className={cn('editorWrapper', {maximized: this.state.maximized})}>
				<div className='editorMenu'>
					<ButtonGroup>
832
						{ this.props.toolbarContent || null }
833 834 835 836 837 838 839 840 841 842 843 844
						<Button
							size='sm'
							onClick={this.zoomOut}
						>
							Zoom out
						</Button>
						<Button
							size='sm'
							onClick={this.zoomIn}
						>
							Zoom in
						</Button>
845 846 847 848 849 850
						<Button
							size='sm'
							onClick={() => this.fitToToolchain(locMap)}
						>
							Fit
						</Button>
851 852 853 854
						<Button
							size='sm'
							onClick={this.toggleMaximized}
						>
855
							{ this.state.maximized ? 'Pop In' : 'Pop Out' }
856
						</Button>
857 858 859 860 861 862 863 864 865
						{ !this.props.experimentData &&
								<Button
									color='primary'
									size='sm'
									onClick={this.toggleHelp}
								>
									See help
								</Button>
						}
866 867
					</ButtonGroup>
				</div>
868 869 870
				<div className='editorWrapper2'
					ref={e => this.scrollElement = e}
				>
871
					<svg
872
						onClick={e => {
873
							if((e.button === 0 || e.button === undefined) && this.props.handleBackgroundClick)
874 875
								this.props.handleBackgroundClick(e);
						}}
876
						className='background'
877 878 879 880
						width={svgWidth}
						height={svgHeight}
						viewBox={this.getSvgViewBox()}
						onWheel={e => {
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
881
							// zoom when scrolling only with the shift key held
882 883 884 885 886 887 888 889 890 891 892
							if(!e.shiftKey)
								return;

							e.preventDefault();
							if(e.deltaY < 0)
								this.zoomIn();
							else if(e.deltaY > 0)
								this.zoomOut();
						}}
					>
						{
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
893
							/* horizontal grid lines */
894 895 896 897 898 899 900 901 902
							[...Array(Math.round(scaledSvgHeight / gridY))].map((u, i) => {
								return (
									<line
										className='background'
										key={`gridY${ i }`}
										x1={0}
										y1={i * gridY}
										x2={scaledSvgWidth}
										y2={i * gridY}
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
903
										stroke='#EEEEEE'
904 905 906 907 908 909 910
										strokeWidth={1}
									/>
								);
							})
						}

						{
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
911
							/* vertical grid lines */
912 913 914 915 916 917 918 919 920
							[...Array(Math.round(scaledSvgWidth / gridX))].map((u, i) => {
								return (
									<line
										className='background'
										key={`gridX${ i }`}
										x1={i * gridX}
										y1={0}
										x2={i * gridX}
										y2={scaledSvgHeight}
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
921
										stroke='#EEEEEE'
922 923 924 925 926
										strokeWidth={1}
									/>
								);
							})
						}
927 928 929 930 931 932 933
						<line
							className='background'
							id='gridXCenter'
							x1={0}
							y1={svgHeightHalf - 2}
							x2={scaledSvgWidth}
							y2={svgHeightHalf - 2}
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
934
							stroke='gainsboro'
935 936 937 938 939 940 941 942 943
							strokeWidth={4}
						/>
						<line
							className='background'
							id='gridYCenter'
							x1={svgWidthHalf - 2}
							y1={0}
							x2={svgWidthHalf - 2}
							y2={scaledSvgHeight}
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
944
							stroke='gainsboro'
945 946
							strokeWidth={4}
						/>
947

948 949 950 951
						<g className='backgroundLimit'>
						</g>

						{
952 953
							this.props.groups.map(({name, blocks, collapsed}, i) => {
								const {x, y, width, height} = locMap[name].block;
954 955 956 957 958
								const nameClipId = `groupClip_${ name }`;
								return (
									<ContextMenuTrigger
										key={i}
										id='groupContextMenu'
959
										collect={props => ({ group: name, selectBlocks: this.selectBlocks })}
960 961 962 963 964 965 966 967 968 969 970
										renderTag='g'
									>
										<rect
											id={`group_${ name }`}
											className='tcGroup'
											x={x}
											y={y}
											width={width}
											height={height}
											fill='grey'
											opacity='0.2'
971
										>
972
											<title>Group { `"${ name }"` } contains { blocks.join(', ') }</title>
973
										</rect>
974 975 976 977 978 979 980 981 982 983 984 985 986 987 988
										<text
											x={x}
											y={y + 16}
											clipPath={`url(#${ nameClipId })`}
										>
											{ name }
										</text>
										<clipPath id={nameClipId}>
											<rect
												x={x}
												y={y}
												height={height}
												width={width}
											/>
										</clipPath>
989 990 991 992 993
									</ContextMenuTrigger>
								);
							})
						}

994 995
						{
							Number.isFinite(selectBoxCoords.x) &&
996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010
								<ContextMenuTrigger
									id='areaSelectContextMenu'
									renderTag='g'
									collect={props => ({ selection: this.state.selectedBlocks })}
								>
									<rect
										className='areaSelect'
										x={selectBoxCoords.x}
										y={selectBoxCoords.y}
										width={selectBoxCoords.width}
										height={selectBoxCoords.height}
										fill='grey'
										opacity='0.5'
									/>
								</ContextMenuTrigger>
1011
						}
1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027
						<ContextMenuTrigger
							id='connectionContextMenu'
							renderTag='g'
							ref={e => this.connectionContextMenu = e}
							collect={props => ({ connection: this.state.rightClickConnection })}
						>
							<g></g>
						</ContextMenuTrigger>
						<ContextMenuTrigger
							id='blockContextMenu'
							renderTag='g'
							ref={e => this.blockContextMenu = e}
							collect={props => ({ name: this.state.rightClickBlock })}
						>
							<g></g>
						</ContextMenuTrigger>
1028

1029
						{
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
1030
							/* connections between blocks */
1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049
							shownConnections.map((conn, i) => {
								const fromInfo = conn.from.split('.');
								const toInfo = conn.to.split('.');

								const fromLoc = locMap[fromInfo[0]].outputs[fromInfo[1]];
								const toLoc = locMap[toInfo[0]].inputs[toInfo[1]];
								return (
									<Connection
										key={i}
										connection={conn}
										locMap={locMap}
										fromLocMap={fromLoc}
										toLocMap={toLoc}
										channelColor={this.props.repData.channel_colors[conn.channel]}
										errors={this.props.errorBlocks ? this.props.errorBlocks[connectionToId(conn)] || [] : []}
										handleContextMenu={this.handleConnectionContextMenu}
									/>
								);
							})
1050 1051 1052
						}

						{
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
1053
							/* blocks */
1054 1055 1056
							Object.entries(this.props.repData.blocks)
							.filter(([name, rep]) => !hiddenBlocks.includes(name))
							.map(([name, rep], i) => {
1057
								// assume the block is in "blocks"
1058
								let data: ?BlockType = blockData[name];
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
1059
								// find the set the block belongs to manually
1060 1061 1062
								let set: BlockSet = 'blocks';
								if(data === undefined){
									// if its not in there, try datasets
1063
									data = datasetData[name];
1064 1065 1066
									set = 'datasets';
									if(data === undefined){
										// the block isnt in datasets, is in analyzers
1067
										data = analyzerData[name];
1068 1069 1070
										set = 'analyzers';
									}
								}
1071
								if(!data)
1072
									return null;
1073

1074 1075
								const isSelected = this.state.selectedBlocks.find(b => b.name === name);

1076 1077 1078 1079
								/*
								 * the block handleClick must be bound here, not in a different file!
								 * it needs to be bound afterish the d3 drag setup code runs
								 */
1080 1081 1082 1083 1084 1085 1086
								return (
									<Block
										key={i}
										name={name}
										locMap={locMap[name]}
										data={data}
										set={set}
1087
										highlighted={isSelected !== undefined || (this.props.focusBlocks !== undefined && this.props.focusBlocks.includes(name))}
1088
										handleClick={() => this.props.handleBlockClick ? this.props.handleBlockClick(name, set) : undefined}
1089 1090 1091 1092 1093 1094 1095 1096 1097
										handleShiftClick={() => {
											const sels = this.state.selectedBlocks;
											if(!this.props.interactable)
												return;
											if(sels.find(b => b.name === name))
												this.selectBlocks(sels.filter(b => b.name !== name));
											else
												this.selectBlocks([...sels, data]);
										}}
1098
										channelColor={this.props.repData.channel_colors[data.synchronized_channel || data.name]}
1099
										opacity={isSelected ? 0.5 : 1}
1100
										experimentString={this.props.experimentData ? this.props.experimentData[name] || 'Not Selected' : undefined}
1101
										errors={this.props.errorBlocks ? this.props.errorBlocks[name] || [] : []}
1102
										handleContextMenu={this.handleBlockContextMenu}
1103 1104 1105 1106
									/>
								);
							})
						}
1107
						{ this.props.svgChildren }
1108
					</svg>
Jaden DIEFENBAUGH's avatar
Jaden DIEFENBAUGH committed
1109 1110 1111
					{/* the trigger to the menu that pops up on left-clicking the background.
					  * there needs to be a <ContextMenu> element with the same id somewhere in the divChildren to show anything.
					  */}
1112 1113 1114
					<ContextMenuTrigger
						id='svgContextMenu'
						ref={e => this.svgContextMenu = e}
1115
						collect={props => ({...this.state.svgActionLocation, selectBlocks: this.safeSelectBlocksByName})}
1116 1117 1118 1119 1120 1121 1122 1123 1124 1125
						attributes={{
							onContextMenu: (e) => {
								e.stopPropagation();
								e.preventDefault();
							}
						}}
					>
						<span></span>
					</ContextMenuTrigger>
					{ this.props.divChildren }
1126
				</div>
1127 1128 1129 1130
				<HelpModal
					toggle={this.toggleHelp}
					active={this.state.helpActive}
				/>
1131 1132 1133 1134
			</div>
		);
	}
}