// @flow
import './GraphicalEditor.css';
import * as React from 'react';
import * as d3 from 'd3';
import {
	Button,
	ButtonGroup,
} from 'reactstrap';
import cn from 'classnames';
import Block from './ToolchainBlock.jsx';
import Connection, { connectionToId } from './ToolchainConnection.jsx';
import HelpModal from './GraphicalEditorHelpModal.jsx';
import { ContextMenuTrigger } from 'react-contextmenu';
import { changeObjFieldName, generateNewKey } from '@helpers';

import type {
	BlockType,
	NormalBlock,
	AnalyzerBlock,
	DatasetBlock,
	BlockSet,
	BlockCoords,
	ConnectionType,
	Group,
	LocationMapEntry,
	LocationMap ,
} from '@helpers/toolchainTypes';

type Props = {
	// the representation data of the toolchain (block locations & channel colors)
	repData: {
		blocks: {
			[string]: {
				col: number,
				row: number,
			}
		},
		channel_colors: {
			[string]: string
		},
	},
	// the blocks in the toolchain
	blocks: NormalBlock[],
	// the datasets in the toolchain
	datasets: DatasetBlock[],
	// the analyzers in the toolchain
	analyzers: AnalyzerBlock[],
	// the connections in the toolchain
	connections: ConnectionType[],
	// block groups (not a feature in beat web)
	groups: Group[],
	handleBlockClick?: (blockName: string, set: BlockSet) => any,
	handleBackgroundClick?: (e: any) => any,
	// updates a block location to col #x & row #y
	updateBlockLocations?: (bLocs: {blockName: string, x: number, y: number}[]) => any,
	createConnection?: (from: string, to: string, channel: string) => any,
	// extra svg to be drawn after the blocks & connections
	svgChildren?: React.Node,
	// extra children (e.g. context menus) that arent svg
	divChildren?: React.Node,
	// should the d3 behaviours be enabled?
	interactable?: boolean,
	// 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
	experimentData?: { [string]: string },
	// block names mapped to an array of error strings
	errorBlocks?: { [string]: string[] },
	// blocks to highlight strongly
	focusBlocks?: string[],
	toolbarContent?: React.Node,
};

type State = {
	// the scale transformation for the svg
	zoomLevel: number,
	// represents the most recent click at a specific point on the svg's grid background
	svgActionLocation: {
		x: number,
		y: number,
	},
	selectedBlocks: BlockType[],
	maximized: boolean,
	helpActive: boolean,
	rightClickBlock: ?string,
	rightClickConnection: ?ConnectionType,
};

// note: all measurements are in px by default for svg
// how tall  a line of text is in a block
export const blockTextHeight = 20;
export const svgDimensionLength = 6000;
export const svgWidth = svgDimensionLength;
export const svgHeight = svgDimensionLength;
export const startingZoomLevel = svgDimensionLength;
export const minZoomLevel = 5000;
export const svgWidthHalf = svgWidth / 2;
export const svgHeightHalf = svgHeight / 2;
export const blockWidth = 200;
// spacing between grid rows/cols
export const gridDistance = 20;
// how much the scaling changes at once
export const zoomAdjustmentAmount = 100;
// 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];
};
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];
};

export default class GraphicalEditor extends React.PureComponent<Props, State> {
	constructor(props: Props){
		super(props);
	}

	state = {
		zoomLevel: startingZoomLevel,
		svgActionLocation: {
			x: -1,
			y: -1,
		},
		selectedBlocks: [],
		maximized: false,
		helpActive: false,
		rightClickBlock: undefined,
		rightClickConnection: undefined,
	};

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

	// 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){
			this.initD3Drag();
			this.initD3Connections();
			this.initD3AreaSelect();
			this.initD3MouseMovement();
		}
	}

	// used for the scrollElement ref
	scrollElement = null;
	// used for the svgContextMenu ref
	svgContextMenu = null;
	// 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(() => {
				if(this.blockContextMenu)
					this.blockContextMenu.handleContextClick(e);
			}, 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(() => {
				if(this.connectionContextMenu)
					this.connectionContextMenu.handleContextClick(e);
			}, 0);
		}
	}

	scrollTo = (x: number, y: number) => {
		setTimeout(() => {
			if(this.scrollElement){
				this.scrollElement.scrollLeft = x;
				this.scrollElement.scrollTop = y;
			}
		}, 50);
	};

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

	// adjusts the zoomlevel
	setZoomLevel = (zoomLevel: number) => zoomLevel >= minZoomLevel && this.setState({zoomLevel: zoomLevel});
	// zoom out
	zoomOut = () => this.setZoomLevel(this.state.zoomLevel + zoomAdjustmentAmount);
	// zoom in
	zoomIn = () => this.setZoomLevel(this.state.zoomLevel - zoomAdjustmentAmount);
	// toggle maximized
	toggleMaximized = () => this.setState({ maximized: !this.state.maximized });
	// toggles the help modal
	toggleHelp = () => this.setState({ helpActive: !this.state.helpActive });

	// 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 }`;

	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;

	}

	// 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.
	getLocationMap = (): LocationMap => {
		const locMap = {};
		const hasExpData = this.props.experimentData !== undefined;
		const ioVerticalOffset = hasExpData ? blockTextHeight : blockTextHeight / 4;
		const notShownBlocks: string[] = hasExpData ? [] : this.props.groups.reduce((arr, g) => g.collapsed ? [...g.blocks, ...arr] : arr, []);

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

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

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

			locMap[name] = {
				block: locBlock,
			};

			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;
			}
		});

		const getBlocksBoundingBox = (names: string[], collapsed: boolean) => {
			const blockLocs = names.filter(n => locMap.hasOwnProperty(n)).map(n => locMap[n].block);
			let x = collapsed && !hasExpData ?
				blockLocs.map(l => l.x).reduce((n, x) => n + x, 0) / blockLocs.length :
				Math.min(...blockLocs.map(l => l.x))
			;
			let y = collapsed && !hasExpData ?
				blockLocs.map(l => l.y).reduce((n, y) => n + y, 0) / blockLocs.length :
				Math.min(...blockLocs.map(l => l.y))
			;
			if(collapsed && !hasExpData)
				[x, y] = clampCoordsToGrid(x, y);

			const width = collapsed && !hasExpData ?
				blockWidth :
				Math.max(...blockLocs.map(l => l.x + l.width - x)) + gridDistance * 2
			;
			const height = collapsed && !hasExpData ?
				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
			if(g.collapsed && !hasExpData){
				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;
						}
					}
				});
			}
		});

		return locMap;
	}

	// manually triggers the svg's context menu
	triggerSvgAction = (x: number, y: number, clickEvent: any) => {
		this.setState({
			svgActionLocation: {
				x,
				y,
			}
		});

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

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

	selectBlocks = (blocks: BlockType[]) => {
		this.setState({
			selectedBlocks: blocks,
		});
		this.initD3Drag();
	}

	// 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 {
				selectedBlocks: blockNames
				.map(name =>
					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();
	}

	// 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.
	initD3Drag = () => {
		setTimeout(() => {
			const updateBlockLocations = this.props.updateBlockLocations;
			if(!updateBlockLocations)
				return;
			const that = this;
			// currently debouncing isnt necessary perf-wise
			let startX = 0;
			let startY = 0;
			let dx = 0;
			let dy = 0;
			let mx = 0;
			let my = 0;
			// the event handler for all the drag events in between the start/end
			function dragged(d) {
				// keep track of the change to mouse location across drag events
				dx += d3.event.dx;
				dy += d3.event.dy;
				// 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;
				}
				if(Math.abs(dy) > gridDistance){
					my = Math.round(dy / gridDistance);
					dy = dy - my * gridDistance;
				}
				// if theres any grid movement to be made, make it
				if(mx != 0 || my != 0){
					const dRect = d3.select('.dragRect');
					dRect
					.attr('x', Number.parseInt(dRect.attr('x')) + mx * gridDistance)
					.attr('y', Number.parseInt(dRect.attr('y')) + my * gridDistance);
				}
				// reset the grid movement since we made it already
				mx = 0;
				my = 0;
			}

			// when the user starts dragging, create the placeholder block
			function startDrag(d) {
				const b = d3.select(this);
				const x = Number.parseFloat(b.attr('x'));
				const y = Number.parseFloat(b.attr('y'));
				const height = b.attr('height');
				const width = b.attr('width');
				startX = x / gridDistance;
				startY = y / gridDistance;

				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)
				;

				// reset vals for a new drag
				dx = 0;
				dy = 0;
				mx = 0;
				my = 0;
			}

			// when the user lets go, move the block to the location of the placeholder
			function endDrag(d) {
				const dRect = d3.select('.dragRect');
				const d3this = d3.select(this);
				const x = Number.parseInt(dRect.attr('x')) / gridDistance;
				const y = Number.parseInt(dRect.attr('y')) / gridDistance;
				const xShift = x - startX;
				const yShift = y - startY;
				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)];
					}

					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;

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

					updateBlockLocations(bLocs);
				}
				dRect.remove();
			}

			// all block elements should be draggable
			d3.selectAll('.fo,.areaSelect,.tcGroup').call(
				d3.drag()
				.on('start', startDrag)
				.on('end', endDrag)
				.on('drag', dragged)
			);
		}, 50);
	};

	// 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
	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;
			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
			function startDrag(d) {
				//console.log('startDrag');
				startX = d3.event.x;
				startY = d3.event.y;
				// find the block that the user started from
				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);
				// draw a line from the output rect to the mouse location
				d3.select('svg')
				.append('line')
				.classed('tmp', true)
				.attr('stroke', 'black')
				.attr('x1', startX)
				.attr('y1', startY)
				.attr('x2', startX)
				.attr('y2', startY);
			}

			const processEndBlock = (endInput: HTMLElement) => {
				if(!startBlock)
					return;
				//console.log(endInput);
				const endId = endInput.id.replace('-input-', '.');
				// create a new connection assuming theres a given func that does it
				if(createConnection)
					createConnection(startId, endId, startBlock.synchronized_channel || startBlock.name);

				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);
			}

			// update the temp line
			function dragged() {
				d3.select('.tmp')
				.attr('x2', d3.event.x)
				.attr('y2', d3.event.y);
			}

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

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

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

			d3.select('.puck')
			.remove();

			// draw the puck
			d3.select('svg')
			.insert('circle', '.backgroundLimit')
			.attr('class', 'puck background')
			.attr('cx', 0)
			.attr('cy', 0)
			.attr('r', 5);

			const svg = document.querySelector('svg');
			// when the mouse moves, recalc the puck's point on the grid
			// needs to scale to the zoomlevel
			d3.select('svg')
			.on('mousemove', () => {
				const [rawx, rawy] = d3.mouse(svg);
				const [x, y] = clampCoordsToGrid(rawx, rawy);
				cx = x;
				cy = y;
				d3.select('.puck')
				.attr('cx', cx)
				.attr('cy', cy)
				;
			})
			// if the click event's target is the svg background,
			// trigger the svg action at the puck's current point
			.on('contextmenu', () => {
				if(!Array.from(d3.event.target.classList).includes('background'))
					return;
				const [xData, yData] = convertWorldToDataCoords(cx, cy);
				this.triggerSvgAction(xData / gridDistance, yData / gridDistance, d3.event);
			})
			;
		}, 50);
	}

	// d3 behaviour for selecting all blocks inside a rect (via click-and-drag)
	initD3AreaSelect = () => {
		setTimeout(() => {
			const {findBlock, selectBlocks} = this;
			let startX, startY;
			let currX, currY;
			const getPolygonPoints = () => {
				return `${ startX },${ startY } ${ startX },${ currY } ${ currX },${ currY } ${ currX },${ startY }`;
			};
			function startDrag(d) {
				const [rawx, rawy] = d3.mouse(document.querySelector('svg'));
				const [x, y] = clampCoordsToGrid(rawx, rawy);
				startX = x;
				startY = y;
				currX = x;
				currY = y;
				d3.select('svg')
				.insert('polygon', '.backgroundLimit')
				.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');
				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);

				p.remove();
				startX = 0;
				startY = 0;
				currX = 0;
				currY = 0;
			}

			// update the temp rect
			function dragged(d) {
				const [rawx, rawy] = d3.mouse(document.querySelector('svg'));
				const [x, y] = clampCoordsToGrid(rawx, rawy);
				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);
	}

	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;
		const maxDimension = Math.max(Math.max(width, height) * zoomScaling, minZoomLevel);
		// zoom so the entire toolchain can fit in the window
		this.setZoomLevel(maxDimension);

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

	render = () => {
		const gridX = gridDistance;
		const gridY = gridDistance;
		const locMap = this.getLocationMap();
		// scales width/height to the zoomlevel so the background grid lines cover the entire svg
		const scaledSvgWidth = svgWidth * (this.state.zoomLevel / startingZoomLevel);
		const scaledSvgHeight = svgHeight * (this.state.zoomLevel / startingZoomLevel);

		const getBlocksBoundingBox = (names: string[]) => {
			const blockLocs = names.filter(n => locMap.hasOwnProperty(n)).map(n => locMap[n].block);
			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,
			};
		};

		const selectedBlockNames = this.state.selectedBlocks.map(b => b.name);
		const selectBoxCoords = getBlocksBoundingBox(selectedBlockNames);
		const hiddenBlocks = this.props.experimentData !== undefined ? [] : this.props.groups.reduce((bs, g) => g.collapsed ? [...g.blocks, ...bs] : bs, []);
		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));
		})
		;


		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;
		}

		return (
			<div className={cn('editorWrapper', {maximized: this.state.maximized})}>
				<div className='editorMenu'>
					<ButtonGroup>
						{ this.props.toolbarContent || null }
						<Button
							size='sm'
							onClick={this.zoomOut}
						>
							Zoom out
						</Button>
						<Button
							size='sm'
							onClick={this.zoomIn}
						>
							Zoom in
						</Button>
						<Button
							size='sm'
							onClick={() => this.fitToToolchain(locMap)}
						>
							Fit
						</Button>
						<Button
							size='sm'
							onClick={this.toggleMaximized}
						>
							{ this.state.maximized ? 'Pop In' : 'Pop Out' }
						</Button>
						{ !this.props.experimentData &&
								<Button
									color='primary'
									size='sm'
									onClick={this.toggleHelp}
								>
									See help
								</Button>
						}
					</ButtonGroup>
				</div>
				<div className='editorWrapper2'
					ref={e => this.scrollElement = e}
				>
					<svg
						onClick={e => {
							if((e.button === 0 || e.button === undefined) && this.props.handleBackgroundClick)
								this.props.handleBackgroundClick(e);
						}}
						className='background'
						width={svgWidth}
						height={svgHeight}
						viewBox={this.getSvgViewBox()}
						onWheel={e => {
							// zoom when scrolling only with the shift key held
							if(!e.shiftKey)
								return;

							e.preventDefault();
							if(e.deltaY < 0)
								this.zoomIn();
							else if(e.deltaY > 0)
								this.zoomOut();
						}}
					>
						{
							/* horizontal grid lines */
							[...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}
										stroke='#EEEEEE'
										strokeWidth={1}
									/>
								);
							})
						}

						{
							/* vertical grid lines */
							[...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}
										stroke='#EEEEEE'
										strokeWidth={1}
									/>
								);
							})
						}
						<line
							className='background'
							id='gridXCenter'
							x1={0}
							y1={svgHeightHalf - 2}
							x2={scaledSvgWidth}
							y2={svgHeightHalf - 2}
							stroke='gainsboro'
							strokeWidth={4}
						/>
						<line
							className='background'
							id='gridYCenter'
							x1={svgWidthHalf - 2}
							y1={0}
							x2={svgWidthHalf - 2}
							y2={scaledSvgHeight}
							stroke='gainsboro'
							strokeWidth={4}
						/>

						<g className='backgroundLimit'>
						</g>

						{
							this.props.groups.map(({name, blocks, collapsed}, i) => {
								const {x, y, width, height} = locMap[name].block;
								const nameClipId = `groupClip_${ name }`;
								return (
									<ContextMenuTrigger
										key={i}
										id='groupContextMenu'
										collect={props => ({ group: name, selectBlocks: this.selectBlocks })}
										renderTag='g'
									>
										<rect
											id={`group_${ name }`}
											className='tcGroup'
											x={x}
											y={y}
											width={width}
											height={height}
											fill='grey'
											opacity='0.2'
										>
											<title>Group { `"${ name }"` } contains { blocks.join(', ') }</title>
										</rect>
										<text
											x={x}
											y={y + 16}
											clipPath={`url(#${ nameClipId })`}
										>
											{ name }
										</text>
										<clipPath id={nameClipId}>
											<rect
												x={x}
												y={y}
												height={height}
												width={width}
											/>
										</clipPath>
									</ContextMenuTrigger>
								);
							})
						}

						{
							Number.isFinite(selectBoxCoords.x) &&
								<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>
						}
						<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>

						{
							/* connections between blocks */
							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}
									/>
								);
							})
						}

						{
							/* blocks */
							Object.entries(this.props.repData.blocks)
							.filter(([name, rep]) => !hiddenBlocks.includes(name))
							.map(([name, rep], i) => {
								// assume the block is in "blocks"
								let data: ?BlockType = blockData[name];
								// find the set the block belongs to manually
								let set: BlockSet = 'blocks';
								if(data === undefined){
									// if its not in there, try datasets
									data = datasetData[name];
									set = 'datasets';
									if(data === undefined){
										// the block isnt in datasets, is in analyzers
										data = analyzerData[name];
										set = 'analyzers';
									}
								}
								if(!data)
									return null;

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

								/*
								 * the block handleClick must be bound here, not in a different file!
								 * it needs to be bound afterish the d3 drag setup code runs
								 */
								return (
									<Block
										key={i}
										name={name}
										locMap={locMap[name]}
										data={data}
										set={set}
										highlighted={isSelected !== undefined || (this.props.focusBlocks !== undefined && this.props.focusBlocks.includes(name))}
										handleClick={() => this.props.handleBlockClick ? this.props.handleBlockClick(name, set) : undefined}
										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]);
										}}
										channelColor={this.props.repData.channel_colors[data.synchronized_channel || data.name]}
										opacity={isSelected ? 0.5 : 1}
										experimentString={this.props.experimentData ? this.props.experimentData[name] || 'Not Selected' : undefined}
										errors={this.props.errorBlocks ? this.props.errorBlocks[name] || [] : []}
										handleContextMenu={this.handleBlockContextMenu}
									/>
								);
							})
						}
						{ this.props.svgChildren }
					</svg>
					{/* 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.
					  */}
					<ContextMenuTrigger
						id='svgContextMenu'
						ref={e => this.svgContextMenu = e}
						collect={props => ({...this.state.svgActionLocation, selectBlocks: this.safeSelectBlocksByName})}
						attributes={{
							onContextMenu: (e) => {
								e.stopPropagation();
								e.preventDefault();
							}
						}}
					>
						<span></span>
					</ContextMenuTrigger>
					{ this.props.divChildren }
				</div>
				<HelpModal
					toggle={this.toggleHelp}
					active={this.state.helpActive}
				/>
			</div>
		);
	}
}
