diff --git a/package.json b/package.json index ec28dfef..534d58e9 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@types/react-color": "^2.14.0", "@types/react-dom": "^16.0.11", "@types/react-hot-loader": "^4.1.0", + "@types/react-outside-click-handler": "^1.2.0", "@types/react-router": "^4.4.3", "@types/react-router-dom": "^4.3.1", "@types/seedrandom": "^2.4.27", diff --git a/src/client/components/app/install.tsx b/src/client/components/app/install.tsx index d89224a0..3147b9e3 100644 --- a/src/client/components/app/install.tsx +++ b/src/client/components/app/install.tsx @@ -4,6 +4,7 @@ import { installChart } from '../chart/install' import { installDashboard } from '../dashboard/install' import { installLocalisation } from '../localisation/install' import { withRobotSelectorMenuBar } from '../menu_bar/view' +import { installScriptTuner } from '../script_tuner/install' import { installVision } from '../vision/install' import { installVisualMesh } from '../visual_mesh/install' @@ -23,6 +24,7 @@ export function installNav() { installDashboard({ nav, appModel, nusightNetwork, menu }) installLocalisation({ nav, appModel, nusightNetwork, menu }) + installScriptTuner({ nav, appModel, nusightNetwork, menu }) installChart({ nav, appModel, nusightNetwork, menu }) installVision({ nav, appModel, nusightNetwork, Menu: menu }) installVisualMesh({ nav, appModel, nusightNetwork, Menu: menu }) diff --git a/src/client/components/dropdown/style.css b/src/client/components/dropdown/style.css index 5922b40f..5096e2ef 100644 --- a/src/client/components/dropdown/style.css +++ b/src/client/components/dropdown/style.css @@ -4,7 +4,6 @@ .dropdownMenu { position: absolute; - top: 100%; z-index: 1; } @@ -15,3 +14,11 @@ .dropdownMenuRight { right: 0; } + +.dropdownMenuUp { + bottom: 100%; +} + +.dropdownMenuDown { + top: 100%; +} diff --git a/src/client/components/dropdown/view.tsx b/src/client/components/dropdown/view.tsx index 38278053..bc9d5f58 100644 --- a/src/client/components/dropdown/view.tsx +++ b/src/client/components/dropdown/view.tsx @@ -13,6 +13,7 @@ export interface DropdownProps { isOpen: boolean isFullwidth?: boolean dropdownPosition?: 'left' | 'right' + dropDirection?: 'up' | 'down' onRef?(dropdown: HTMLDivElement): void onToggleClick?(event: MouseEvent): void } @@ -20,7 +21,8 @@ export interface DropdownProps { export const Dropdown: StatelessComponent = (props: DropdownProps) => { const fullwidth = props.isFullwidth ? style.dropdownMenuFullwidth : '' const position = props.dropdownPosition === 'right' ? style.dropdownMenuRight : '' - const dropdownMenuClassName = classNames(style.dropdownMenu, fullwidth, position) + const direction = props.dropDirection === 'up' ? style.dropdownMenuUp : style.dropdownMenuDown + const dropdownMenuClassName = classNames(style.dropdownMenu, fullwidth, position, direction) return (
diff --git a/src/client/components/script_tuner/balance/style.css b/src/client/components/script_tuner/balance/style.css new file mode 100644 index 00000000..29327f38 --- /dev/null +++ b/src/client/components/script_tuner/balance/style.css @@ -0,0 +1,23 @@ +.balance { + display: flex; + flex-direction: column; + max-height: 100%; + overflow: hidden; +} + +.balanceHeader { + background-color: #777; + padding: 0 4px; + color: white; + font-size: 14px; + display: flex; + min-height: 24px; + align-items: center; +} + +.balanceBody { + padding: 0 4px; + flex-grow: 1; + overflow: hidden; + position: relative; +} diff --git a/src/client/components/script_tuner/balance/view.tsx b/src/client/components/script_tuner/balance/view.tsx new file mode 100644 index 00000000..5c59b828 --- /dev/null +++ b/src/client/components/script_tuner/balance/view.tsx @@ -0,0 +1,22 @@ +import * as classNames from 'classnames' +import { observer } from 'mobx-react' +import * as React from 'react' + +import * as style from './style.css' + +type BalanceProps = { + className?: string +} + +@observer +export class Balance extends React.Component { + render() { + return
+
+
Balance
+
+ +
+
+ } +} diff --git a/src/client/components/script_tuner/controller.ts b/src/client/components/script_tuner/controller.ts new file mode 100644 index 00000000..0854410d --- /dev/null +++ b/src/client/components/script_tuner/controller.ts @@ -0,0 +1,109 @@ +import { action, autorun, IReactionDisposer } from 'mobx' + +import { RobotModel } from '../robot/model' + +import { Script, ScriptTunerModel } from './model' +import { ScriptTunerNetwork } from './network' +import { createViewModel } from './utils' + +interface ScriptTunerOpts { + network: ScriptTunerNetwork + model: ScriptTunerModel +} + +export class ScriptTunerController { + network: ScriptTunerNetwork + model: ScriptTunerModel + stopPlaytimeAutorun?: IReactionDisposer + + constructor(opts: ScriptTunerOpts) { + this.network = opts.network + this.model = opts.model + + this.stopPlaytimeAutorun = autorun(() => { + if (this.model.playTime >= this.model.endTime) { + this.togglePlayback(false) + } + }) + + // TODO need to add an autorunner that if we are connected to the robot we need to send it update packets + } + + static of(opts: ScriptTunerOpts) { + return new ScriptTunerController(opts) + } + + @action + selectSourceRobot(robot: RobotModel) { + const isInitialSelect = this.model.sourceRobot === undefined + this.model.sourceRobot = robot + + if (isInitialSelect) { + this.network.requestScripts(robot) + } + + // Select the robot as target too + this.selectTargetRobot(robot) + } + + @action + selectTargetRobot(robot: RobotModel) { + this.model.targetRobot = robot + } + + @action + selectScript(script: Script) { + // Do nothing if the script is already selected + if (this.model.selectedScript && this.model.selectedScript.model === script) { + return + } + + // Prompt to confirm switch if the current script has unsaved changes + if (this.model.selectedScript && this.model.selectedScript.isDirty) { + const discardChanges = confirm(`${this.model.selectedScript.data.path} has unsaved changes. Discard?`) + + if (discardChanges) { + this.model.selectedScript.reset() + } else { + return + } + } + + // Reset the editor state + this.model.isPlaying = false + this.model.currentTime = 0 + this.model.previousTimelineLength = 0 + + // Select the script + this.model.selectedScript = createViewModel(script) + } + + @action + saveScript() { + if (this.model.selectedScript && this.model.targetRobot) { + this.network.saveScript(this.model.targetRobot) + } + } + + @action + setPlayTime(time: number) { + this.model.currentTime = Math.min(Math.max(time, this.model.startTime), this.model.endTime) + this.model.playStartedAt = Date.now() + } + + @action + togglePlayback(isPlaying: boolean = !this.model.isPlaying) { + if (isPlaying) { + // Reset to start if we want to play but are at the end of the time + if (this.model.playTime >= this.model.endTime) { + this.model.currentTime = 0 + } + + this.model.playStartedAt = Date.now() + } else { + this.model.currentTime = this.model.playTime + } + + this.model.isPlaying = isPlaying + } +} diff --git a/src/client/components/script_tuner/controls/style.css b/src/client/components/script_tuner/controls/style.css new file mode 100644 index 00000000..63f3b7ae --- /dev/null +++ b/src/client/components/script_tuner/controls/style.css @@ -0,0 +1,23 @@ +.controls { + display: flex; + flex-direction: column; + max-height: 100%; + overflow: hidden; +} + +.controlsHeader { + background-color: #777; + padding: 0 4px; + color: white; + font-size: 14px; + display: flex; + min-height: 24px; + align-items: center; +} + +.controlsBody { + padding: 0 4px; + flex-grow: 1; + overflow: hidden; + position: relative; +} diff --git a/src/client/components/script_tuner/controls/view.tsx b/src/client/components/script_tuner/controls/view.tsx new file mode 100644 index 00000000..3d83cc9d --- /dev/null +++ b/src/client/components/script_tuner/controls/view.tsx @@ -0,0 +1,22 @@ +import * as classNames from 'classnames' +import { observer } from 'mobx-react' +import * as React from 'react' + +import * as style from './style.css' + +type ControlsProps = { + className?: string +} + +@observer +export class Controls extends React.Component { + render() { + return
+
+
Controls
+
+ +
+
+ } +} diff --git a/src/client/components/script_tuner/editor/controller.ts b/src/client/components/script_tuner/editor/controller.ts new file mode 100644 index 00000000..db825a9b --- /dev/null +++ b/src/client/components/script_tuner/editor/controller.ts @@ -0,0 +1,67 @@ +import { action, computed, observable } from 'mobx' +import { createTransformer } from 'mobx-utils' + +import { ScriptTunerController } from '../controller' + +import { EditorViewModel } from './view_model' + +interface EditorControllerOpts { + viewModel: EditorViewModel, + controller: ScriptTunerController +} + +export class EditorController { + viewModel: EditorViewModel + controller: ScriptTunerController + + constructor(opts: EditorControllerOpts) { + this.viewModel = opts.viewModel + this.controller = opts.controller + } + + static of = createTransformer((opts: EditorControllerOpts) => { + return new EditorController(opts) + }) + + setPlayTime = (time: number) => { + this.controller.setPlayTime(time) + } + + play = () => { + if (this.viewModel.isPlaying) { + return + } + + this.controller.togglePlayback(true) + } + + pause = () => { + if (!this.viewModel.isPlaying) { + return + } + + this.controller.togglePlayback(false) + } + + togglePlayback = () => { + this.controller.togglePlayback() + } + + jumpToStart = () => { + this.controller.setPlayTime(this.viewModel.startTime) + } + + jumpToEnd = () => { + this.controller.setPlayTime(this.viewModel.endTime) + } + + @action + zoomIn = () => { + this.viewModel.scaleX = Math.min(this.viewModel.scaleX + 1, 10) + } + + @action + zoomOut = () => { + this.viewModel.scaleX = Math.max(this.viewModel.scaleX - 1, 1) + } +} diff --git a/src/client/components/script_tuner/editor/line/controller.ts b/src/client/components/script_tuner/editor/line/controller.ts new file mode 100644 index 00000000..9367bc39 --- /dev/null +++ b/src/client/components/script_tuner/editor/line/controller.ts @@ -0,0 +1,51 @@ +import * as bounds from 'binary-search-bounds' +import { action } from 'mobx' + +import { Frame, ScriptTunerModel, Servo } from '../../model' + +export class LineEditorController { + constructor(private model: Servo) { + this.model = model + } + + static of(model: Servo) { + return new LineEditorController(model) + } + + @action + addFrame = (data: { time: number, angle: number }) => { + const frame = { + time: data.time, + angle: data.angle, + pGain: 0, + iGain: 0, + dGain: 0, + torque: 0, + } + + const index = findNextIndexForTime(data.time, this.model.frames) + + if (index > 0) { + this.model.frames.splice(index, 0, frame) + } else { + this.model.frames.unshift(frame) + } + } + + @action + updateFrame = (frameIndex: number, data: { time: number, angle: number }) => { + this.model.frames[frameIndex].time = data.time + this.model.frames[frameIndex].angle = data.angle + } + + @action + removeFrame = (frameIndex: number) => { + this.model.frames.splice(frameIndex, 1) + } +} + +function findNextIndexForTime(time: number, frames: Frame[]) { + return bounds.gt(frames, frames[0], (frame: Frame) => { + return frame.time - time + }) +} diff --git a/src/client/components/script_tuner/editor/line/style.css b/src/client/components/script_tuner/editor/line/style.css new file mode 100644 index 00000000..b3c555b2 --- /dev/null +++ b/src/client/components/script_tuner/editor/line/style.css @@ -0,0 +1,15 @@ +.lineEditor { + line-height: 0; +} + +.lineEditorSvg { + font-family: monospace; + user-select: none; + border: 1px solid #888; + background-color: white; + cursor: crosshair; +} + +.lineEditorDraggable { + cursor: move; +} diff --git a/src/client/components/script_tuner/editor/line/view.tsx b/src/client/components/script_tuner/editor/line/view.tsx new file mode 100644 index 00000000..603e7b43 --- /dev/null +++ b/src/client/components/script_tuner/editor/line/view.tsx @@ -0,0 +1,222 @@ +import { observer } from 'mobx-react' +import * as React from 'react' +import { Component } from 'react' +import { ComponentType } from 'react' + +import { Servo } from '../../model' +import { EditorViewModel } from '../view_model' + +import { LineEditorController } from './controller' +import * as style from './style.css' +import { LineEditorViewModel } from './view_model' + +type LineEditorProps = { + className?: string, + servo: Servo, + controller: LineEditorController + editorViewModel: EditorViewModel +} + +@observer +export class LineEditor extends Component { + isDragging: boolean = false + svgRef: React.RefObject + selectedElement?: HTMLElement + viewModel?: LineEditorViewModel + + constructor(props: LineEditorProps) { + super(props) + this.svgRef = React.createRef() + } + + render() { + this.viewModel = LineEditorViewModel.of({ + servo: this.props.servo, + editorViewModel: this.props.editorViewModel, + }) + + return
+ + { this.viewModel.servoId } + + { /* Horizontal grid lines */ } + + + + + { + // Vertical grid lines + new Array(this.viewModel.width).fill(0).map((_, index) => { + const x = index * this.viewModel!.cellWidth + return + }) + } + + { + // The main plot line + this.viewModel.svgLineSegments.map((segment, index) => { + return + }) + } + + { + // Points on the main plot line + this.viewModel.svgPoints.map((point, index) => { + return + { point.label } + + }) + } + + { /* The current play position indicator */ } + + +
+ } + + private onDoubleClick = ({ nativeEvent: event }: React.MouseEvent) => { + const viewModel = this.viewModel + + if (!viewModel) { + return + } + + const svg = this.svgRef.current! + const reference = svg.createSVGPoint() + + reference.x = event.clientX + reference.y = event.clientY + + const { x, y } = reference.matrixTransform(svg.getScreenCTM()!.inverse()) + + this.props.controller.addFrame({ + time: viewModel.svgToTime(x), + angle: viewModel.svgToAngle(y), + }) + } + + private onRightClick = ({ nativeEvent: event }: React.MouseEvent) => { + event.preventDefault() + const pointIndex = Number((event.target as HTMLElement).dataset.index) + this.props.controller.removeFrame(pointIndex) + } + + private onMouseDown = ({ nativeEvent: event }: React.MouseEvent) => { + const leftMouseButton = 0 + if (event.button === leftMouseButton && (event.target as HTMLElement).dataset.draggable) { + this.selectedElement = (event.target as HTMLElement) + } + } + + private onMouseMove = ({ nativeEvent: event }: React.MouseEvent) => { + const viewModel = this.viewModel + + if (!this.selectedElement || !viewModel) { + return + } + + // Prevent things that happen on drag, like text selection + event.preventDefault() + + // Start the drag if not already dragging + if (!this.isDragging) { + this.startDrag() + } + + const index = Number(this.selectedElement.dataset.index!) + const mouse = this.getMousePositionInSvgSpace(event) + + const minFrameSeparation = 50 + + const previousFrameTime = index === 0 + ? 0 - minFrameSeparation + : viewModel.points[index - 1].time + + const nextFrameTime = index === viewModel.points.length - 1 + ? viewModel.svgToTime(viewModel.width) // this is the width of the timeline + : viewModel.points[index + 1].time + + // The drag needs to be constrained between the points's neighbours + const time = this.clampToRange( + viewModel.svgToTime(mouse.x), + previousFrameTime + minFrameSeparation, + nextFrameTime - minFrameSeparation, + ) + + const angle = viewModel.svgToAngle(mouse.y) + + this.props.controller.updateFrame(index, { time, angle }) + } + + private startDrag() { + this.isDragging = true + + document.addEventListener('mouseup', (event: MouseEvent) => { + this.endDrag() + }, { once: true }) + } + + private endDrag() { + this.isDragging = false + this.selectedElement = undefined + } + + private getMousePositionInSvgSpace(event: MouseEvent) { + const svg = this.svgRef.current! + const CTM = svg.getScreenCTM()! + + return { + x: (event.clientX - CTM.e) / CTM.a, + y: (event.clientY - CTM.f) / CTM.d, + } + } + + private clampToRange(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max) + } +} diff --git a/src/client/components/script_tuner/editor/line/view_model.ts b/src/client/components/script_tuner/editor/line/view_model.ts new file mode 100644 index 00000000..5533e2dd --- /dev/null +++ b/src/client/components/script_tuner/editor/line/view_model.ts @@ -0,0 +1,111 @@ +// Show an individual script line editor +// The view will be a simple line chart +// When hovering over a point in the chart it will show the valid positions based on RPM +// When scrolling the mouse wheel it will move up/down based limited by the surrounding points +// It will also show the current position of the robots limb + +import { computed } from 'mobx' +import { createTransformer } from 'mobx-utils' + +import { Servo } from '../../model' +import { EditorViewModel } from '../view_model' + +type LineEditorViewModelOptions = { + servo: Servo, + editorViewModel: EditorViewModel +} + +export class LineEditorViewModel { + private servo: Servo + private editorViewModel: EditorViewModel + + constructor(options: LineEditorViewModelOptions) { + this.servo = options.servo + this.editorViewModel = options.editorViewModel + } + + static of = createTransformer((options: LineEditorViewModelOptions): LineEditorViewModel => { + return new LineEditorViewModel(options) + }) + + @computed + get servoId() { + return this.servo.id + } + + @computed + get cellWidth() { + return this.editorViewModel.cellWidth * this.editorViewModel.scaleX + } + + @computed + get width() { + return Math.ceil(this.timeToSvg(this.editorViewModel.timelineLength)) + } + + @computed + get height() { + return this.editorViewModel.height + } + + @computed + get playPosition() { + return this.timeToSvg(this.editorViewModel.playTime) + } + + @computed + get points() { + return this.servo.frames + } + + @computed + get svgLineSegments() { + const segments = [] + + for (let i = 0; i < this.points.length - 1; i++) { + const point = this.points[i] + + segments.push({ + x1: this.timeToSvg(point.time), + x2: this.timeToSvg(this.points[i + 1].time), + y1: this.angleToSVG(point.angle), + y2: this.angleToSVG(this.points[i + 1].angle), + }) + } + + return segments + } + + @computed + get svgPoints() { + return this.points.map(point => { + return { + x: this.timeToSvg(point.time), + y: this.angleToSVG(point.angle), + label: `(${point.time}, ${point.angle.toFixed(2)})`, + } + }) + } + + timeToSvg(time: number) { + return (time / 1000) * this.cellWidth + } + + svgToTime(coordinate: number) { + return (coordinate * 1000) / this.cellWidth + } + + angleToSVG(angle: number) { + const translated = Math.PI - angle + return this.scale(translated, -Math.PI, Math.PI, -this.height / 2, this.height / 2) + } + + svgToAngle(coordinate: number) { + const unscaled = this.scale(coordinate, -this.height / 2, this.height / 2, -Math.PI, Math.PI) + return -(unscaled - Math.PI) + } + + private scale(num: number, inMin: number, inMax: number, outMin: number, outMax: number) { + return (num - inMin) * (outMax - outMin) / (inMax - inMin) + outMin + } +} diff --git a/src/client/components/script_tuner/editor/style.css b/src/client/components/script_tuner/editor/style.css new file mode 100644 index 00000000..83f489a7 --- /dev/null +++ b/src/client/components/script_tuner/editor/style.css @@ -0,0 +1,68 @@ +.editor { + background-color: #DDD; + display: flex; + flex-direction: column; + max-height: 100%; + overflow: hidden; +} + +.editorHeader { + background-color: #777; + padding: 0 4px; + color: white; + font-size: 14px; + display: flex; + min-height: 24px; + align-items: center; +} + +.editorControls { + margin-left: auto; + height: 18px; + display: flex; +} + +.editorControls button { + width: 18px; + height: 18px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + background-color: #444; + border: none; + margin-right: 4px; + outline: none; + color: white; + line-height: 1; +} + +.editorControls button:hover, +.editorControls button:focus { + background-color: #333; +} + +.editorControls button:active { + background-color: #222; +} + +.editorControls svg { + width: 12px; + height: 12px; + fill: white; +} + +.editorBody { + padding: 0 4px; + flex-grow: 1; + overflow: auto; +} + +.editorEmpty { + width: 100%; + height: 1000%; + display: flex; + align-items: center; + justify-content: center; + color: #000; +} diff --git a/src/client/components/script_tuner/editor/timeline/style.css b/src/client/components/script_tuner/editor/timeline/style.css new file mode 100644 index 00000000..17cb4b8e --- /dev/null +++ b/src/client/components/script_tuner/editor/timeline/style.css @@ -0,0 +1,30 @@ +.timeline { + box-sizing: border-box; + display: block; + flex-shrink: 0; + height: 25px; /* Timeline height + 1 for bottom border */ + padding: 0 5px; + line-height: 0; + background-color: #CCC; + border-bottom: 1px solid #999; + overflow-x: scroll; + overflow-y: hidden; + user-select: none; +} + +/* Hide the scrollbar, not using overflow:hidden because + * we need to programmatically scroll the timeline */ +.timeline::-webkit-scrollbar { + height: 0; + background: transparent; +} + +.timelineSvg { + box-sizing: border-box; + height: 24px; /* Timeline height */ +} + +.timelineText { + font-size: 12px; + fill: black; +} diff --git a/src/client/components/script_tuner/editor/timeline/view.tsx b/src/client/components/script_tuner/editor/timeline/view.tsx new file mode 100644 index 00000000..2ae0116f --- /dev/null +++ b/src/client/components/script_tuner/editor/timeline/view.tsx @@ -0,0 +1,156 @@ +import { observer } from 'mobx-react' +import * as React from 'react' + +import { EditorViewModel } from '../view_model' + +import * as style from './style.css' +import { TimelineViewModel } from './view_model' + +type TimelineProps = { + className?: string, + editorViewModel: EditorViewModel, + setPlayTime(time: number): void +} + +@observer +export class Timeline extends React.Component { + readyToDrag: boolean = false + isDragging: boolean = false + svgRef: React.RefObject + viewModel?: TimelineViewModel + + constructor(props: TimelineProps) { + super(props) + this.svgRef = React.createRef() + } + + render() { + const viewModel = TimelineViewModel.of(this.props.editorViewModel) + this.viewModel = viewModel + + return
+ + + + + { + viewModel.cells.map((_, i) => { + const isPrimaryCell = i % viewModel.scaleX === 0 + return + + { isPrimaryCell && + { i / viewModel.scaleX } + } + + }) + } + + + { /* Play head. 26 is the natural height of the play head symbol, in pixels. */ } + + + + + +
+ } + + private onClick = ({ nativeEvent: event }: React.MouseEvent) => { + const viewModel = this.viewModel + + if (!viewModel) { + return + } + + const svg = this.svgRef.current! + const reference = svg.createSVGPoint() + + reference.x = event.clientX + reference.y = event.clientY + + const { x } = reference.matrixTransform(svg.getScreenCTM()!.inverse()) + + this.props.setPlayTime(viewModel.svgToTime(x)) + } + + private onMouseDown = ({ nativeEvent: event }: React.MouseEvent) => { + const viewModel = this.viewModel + if (viewModel && (event.target as HTMLElement).dataset.draggable) { + this.readyToDrag = true + } + } + + private onMouseMove = ({ nativeEvent: event }: React.MouseEvent) => { + const viewModel = this.viewModel + + if (!viewModel || !this.readyToDrag) { + return + } + + // Prevent things that happen on drag, like text selection + event.preventDefault() + + // Start the drag if not already dragging + if (!this.isDragging) { + this.startDrag() + } + + const { x } = this.getMousePositionInSvgSpace(event) + + this.props.setPlayTime( + this.clampToRange(viewModel.svgToTime(x), 0, viewModel.timelineLength), + ) + } + + private startDrag() { + this.isDragging = true + + document.addEventListener('mouseup', (event: MouseEvent) => { + this.endDrag() + }, { once: true }) + } + + private endDrag() { + this.isDragging = false + this.readyToDrag = false + } + + private getMousePositionInSvgSpace(event: MouseEvent) { + const svg = this.svgRef.current! + const CTM = svg.getScreenCTM()! + + return { + x: (event.clientX - CTM.e) / CTM.a, + y: (event.clientY - CTM.f) / CTM.d, + } + } + + private clampToRange(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max) + } +} diff --git a/src/client/components/script_tuner/editor/timeline/view_model.ts b/src/client/components/script_tuner/editor/timeline/view_model.ts new file mode 100644 index 00000000..ba463d85 --- /dev/null +++ b/src/client/components/script_tuner/editor/timeline/view_model.ts @@ -0,0 +1,61 @@ +import { computed } from 'mobx' +import { createTransformer } from 'mobx-utils' + +import { EditorViewModel } from '../view_model' + +export class TimelineViewModel { + private editorViewModel: EditorViewModel + + constructor(editorViewModel: EditorViewModel) { + this.editorViewModel = editorViewModel + } + + static of = createTransformer((editorViewModel: EditorViewModel): TimelineViewModel => { + return new TimelineViewModel(editorViewModel) + }) + + @computed + get scaleX() { + return this.editorViewModel.scaleX + } + + @computed + get cellWidth() { + return this.editorViewModel.cellWidth + } + + @computed + get width() { + return this.timeToSvg(this.editorViewModel.timelineLength) + } + + @computed + get height() { + return 24 + } + + @computed + get timelineLength() { + return this.editorViewModel.timelineLength + } + + @computed + get cells() { + const timelineLengthSeconds = this.editorViewModel.timelineLength / 1000 + return new Array(Math.ceil(timelineLengthSeconds * this.scaleX)).fill(0) + } + + @computed + get playHeadPosition() { + const playHeadWidth = 23 // The rendered width of the playhead SVG element, from devtools + return this.timeToSvg(this.editorViewModel.playTime) - (playHeadWidth / 2) + } + + timeToSvg(time: number) { + return (time / 1000) * this.cellWidth * this.scaleX + } + + svgToTime(coordinate: number) { + return (coordinate * 1000) / this.cellWidth / this.scaleX + } +} diff --git a/src/client/components/script_tuner/editor/view.tsx b/src/client/components/script_tuner/editor/view.tsx new file mode 100644 index 00000000..ef55f823 --- /dev/null +++ b/src/client/components/script_tuner/editor/view.tsx @@ -0,0 +1,182 @@ +import * as classNames from 'classnames' +import { observer } from 'mobx-react' +import * as React from 'react' +import * as ReactDOM from 'react-dom' + +import { ScriptTunerController } from '../controller' +import { ScriptTunerModel } from '../model' + +import { EditorController } from './controller' +import { LineEditorController } from './line/controller' +import { LineEditor } from './line/view' +import * as style from './style.css' +import { Timeline } from './timeline/view' +import { EditorViewModel } from './view_model' + +type EditorProps = { + className?: string + controller: ScriptTunerController + model: ScriptTunerModel +} + +@observer +export class Editor extends React.Component { + props: EditorProps + timelineElement?: HTMLDivElement + bodyElement?: HTMLDivElement + controller?: EditorController + + constructor(props: EditorProps) { + super(props) + this.props = props + } + + componentDidMount() { + document.addEventListener('keydown', this.onDocumentKeydown) + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.onDocumentKeydown) + } + + onDocumentKeydown = (event: KeyboardEvent) => { + if (event.code === 'Space' && this.props.model.selectedScript) { + this.props.controller.togglePlayback() + } + } + + render() { + const { className, model } = this.props + const viewModel = EditorViewModel.of(model) + this.controller = EditorController.of({ + viewModel, + controller: this.props.controller, + }) + + return
+
+
+ { model.selectedScript + ? `Editor – ${model.selectedScript.data.path} ${model.selectedScript.isDirty ? ' * ' : ''}` + : 'Editor' + } +
+ { model.selectedScript &&
+ + + + + +
+ } +
+ + { model.selectedScript === undefined && +
Select a script to edit
+ } + + { model.selectedScript && + } + + { model.selectedScript &&
+ { + model.selectedScriptServos.map((servo, index) => { + const controller = LineEditorController.of(servo) + return + }) + } +
+ } +
+ } + + onTimelineRef = (timelineComponent: Timeline | null) => { + if (timelineComponent) { + const timelineElement = ReactDOM.findDOMNode(timelineComponent) as HTMLDivElement + + if (timelineElement) { + timelineElement.addEventListener('scroll', this.onBodyScroll, { passive: true }) + this.timelineElement = timelineElement + } + } else { + this.timelineElement = undefined + } + } + + onBodyRef = (bodyElement: HTMLDivElement | null) => { + if (bodyElement) { + bodyElement.addEventListener('scroll', this.onBodyScroll, { passive: true }) + this.bodyElement = bodyElement + } else { + this.bodyElement = undefined + } + } + + onTimelineScroll = (event: UIEvent) => { + if (this.bodyElement) { + this.bodyElement.scrollLeft = (event.currentTarget as HTMLDivElement).scrollLeft + } + } + + onBodyScroll = (event: UIEvent) => { + if (this.timelineElement) { + this.timelineElement.scrollLeft = (event.currentTarget as HTMLDivElement).scrollLeft + } + } + + private jumpToStart = () => { + if (this.controller) { + this.controller.jumpToStart() + } + } + + private togglePlayback = () => { + if (this.controller) { + this.controller.togglePlayback() + } + } + + private jumpToEnd = () => { + if (this.controller) { + this.controller.jumpToEnd() + } + } + + private zoomIn = () => { + if (this.controller) { + this.controller.zoomIn() + } + } + + private zoomOut = () => { + if (this.controller) { + this.controller.zoomOut() + } + } +} diff --git a/src/client/components/script_tuner/editor/view_model.ts b/src/client/components/script_tuner/editor/view_model.ts new file mode 100644 index 00000000..b680a4d0 --- /dev/null +++ b/src/client/components/script_tuner/editor/view_model.ts @@ -0,0 +1,44 @@ +// TODO map out each of the limbs into script line + +import { computed, observable } from 'mobx' +import { createTransformer } from 'mobx-utils' + +import { ScriptTunerModel } from '../model' + +export class EditorViewModel { + @observable cellWidth = 40 + @observable scaleX = 2 + @observable height = 200 + + constructor(private model: ScriptTunerModel) { + } + + static of = createTransformer((model: ScriptTunerModel): EditorViewModel => { + return new EditorViewModel(model) + }) + + @computed + get timelineLength() { + return this.model.timelineLength + } + + @computed + get isPlaying() { + return this.model.isPlaying + } + + @computed + get playTime() { + return this.model.playTime + } + + @computed + get startTime() { + return this.model.startTime + } + + @computed + get endTime() { + return this.model.endTime + } +} diff --git a/src/client/components/script_tuner/explorer/controller.ts b/src/client/components/script_tuner/explorer/controller.ts new file mode 100644 index 00000000..928adb85 --- /dev/null +++ b/src/client/components/script_tuner/explorer/controller.ts @@ -0,0 +1,38 @@ +import { action, computed, observable } from 'mobx' +import { createTransformer } from 'mobx-utils' + +import { RobotModel } from '../../robot/model' +import { ScriptTunerController } from '../controller' +import { Script } from '../model' + +interface ExplorerControllerOpts { + controller: ScriptTunerController +} + +export class ExplorerController { + controller: ScriptTunerController + + constructor(opts: ExplorerControllerOpts) { + this.controller = opts.controller + } + + static of = createTransformer((opts: ExplorerControllerOpts) => { + return new ExplorerController(opts) + }) + + selectSourceRobot = (robot: RobotModel) => { + this.controller.selectSourceRobot(robot) + } + + selectTargetRobot = (robot: RobotModel) => { + this.controller.selectTargetRobot(robot) + } + + selectScript = (script: Script) => { + this.controller.selectScript(script) + } + + saveScript = () => { + this.controller.saveScript() + } +} diff --git a/src/client/components/script_tuner/explorer/loading_icon/style.css b/src/client/components/script_tuner/explorer/loading_icon/style.css new file mode 100644 index 00000000..4c7efdd0 --- /dev/null +++ b/src/client/components/script_tuner/explorer/loading_icon/style.css @@ -0,0 +1,29 @@ +.loading { + position: relative; +} + +.loading svg { + animation: loading-rotate 0.7s linear infinite; + bottom: 0; + height: 100%; + left: 0; + margin: auto; + position: absolute; + right: 0; + top: 0; + transform-origin: center center; + width: 100%; +} + +.loading circle { + stroke-dasharray: 89,200; + stroke-dashoffset: -35px; + stroke-linecap: round; + stroke: #1197d3; +} + +@keyframes loading-rotate { + 100% { + transform: rotate(360deg); + } +} diff --git a/src/client/components/script_tuner/explorer/loading_icon/view.tsx b/src/client/components/script_tuner/explorer/loading_icon/view.tsx new file mode 100644 index 00000000..a4d12259 --- /dev/null +++ b/src/client/components/script_tuner/explorer/loading_icon/view.tsx @@ -0,0 +1,32 @@ +import * as classNames from 'classnames' +import { observer } from 'mobx-react' +import * as React from 'react' + +import * as style from './style.css' + +type LoadingIconProps = { + className?: string + size?: number +} + +@observer +export class LoadingIcon extends React.Component { + render() { + const { className, size = 32 } = this.props + return
+ + + +
+ } +} diff --git a/src/client/components/script_tuner/explorer/robot_selector/plug.svg b/src/client/components/script_tuner/explorer/robot_selector/plug.svg new file mode 100644 index 00000000..bd776331 --- /dev/null +++ b/src/client/components/script_tuner/explorer/robot_selector/plug.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/client/components/script_tuner/explorer/robot_selector/robot.svg b/src/client/components/script_tuner/explorer/robot_selector/robot.svg new file mode 100644 index 00000000..353fb737 --- /dev/null +++ b/src/client/components/script_tuner/explorer/robot_selector/robot.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/client/components/script_tuner/explorer/robot_selector/stories/robot_selector.stories.tsx b/src/client/components/script_tuner/explorer/robot_selector/stories/robot_selector.stories.tsx new file mode 100644 index 00000000..e0d9ad78 --- /dev/null +++ b/src/client/components/script_tuner/explorer/robot_selector/stories/robot_selector.stories.tsx @@ -0,0 +1,70 @@ +import { action } from '@storybook/addon-actions' +import { storiesOf } from '@storybook/react' +import * as React from 'react' + +import { RobotSelector } from '../view' + +const actions = { + onSelect: action('onSelect'), +} + +const container = { maxWidth: '320px', fontFamily: 'Arial, sans-serif' } + +storiesOf('components.robot_selector', module) + .add('renders empty', () => { + return
+ +
+ }) + .add('renders with robots', () => { + const robots = getRobots() + return
+ +
+ }) + .add('renders with selection', () => { + const robots = getRobots() + const selected = robots[0] + return
+ +
+ }) + +function getRobots() { + return [ + { + id: '1', + name: 'Virtual Robot 1', + connected: true, + enabled: true, + address: '', + port: 0, + }, + { + id: '2', + name: 'Virtual Robot 2', + connected: true, + enabled: true, + address: '', + port: 0, + }, + { + id: '3', + name: 'Virtual Robot 3', + connected: true, + enabled: true, + address: '', + port: 0, + }, + ] +} diff --git a/src/client/components/script_tuner/explorer/robot_selector/style.css b/src/client/components/script_tuner/explorer/robot_selector/style.css new file mode 100644 index 00000000..dad46384 --- /dev/null +++ b/src/client/components/script_tuner/explorer/robot_selector/style.css @@ -0,0 +1,30 @@ +.empty { + color: #888; + padding: 1em; + text-align: center; +} + +.emptyIcon { + background-color: #888; + border-radius: 100%; + box-shadow: + 0 0 8px 0 rgba(0, 0, 0, 0.2), + 1px 1px 4px 0 rgba(0,0,0,0.2); + fill: #fff; + height: 3em; + margin: 0 auto 1em auto; + opacity: 0.8; + padding: 1em; + width: 3em; +} + +.emptyTitle { + font-size: 1.54em; + padding: 0.25em 0; + white-space: nowrap; +} + +.emptyDescription { + font-size: 0.93em; + white-space: nowrap; +} diff --git a/src/client/components/script_tuner/explorer/robot_selector/view.tsx b/src/client/components/script_tuner/explorer/robot_selector/view.tsx new file mode 100644 index 00000000..69221b83 --- /dev/null +++ b/src/client/components/script_tuner/explorer/robot_selector/view.tsx @@ -0,0 +1,56 @@ +import * as classNames from 'classnames' +import { observer } from 'mobx-react' +import * as React from 'react' + +import { RobotModel } from '../../../robot/model' +import { Option, Select } from '../../../select/view' + +import PlugIcon from './plug.svg' +import RobotIcon from './robot.svg' +import * as style from './style.css' +import { RobotSelectorViewModel } from './view_model' + +export type RobotSelectorProps = { + className?: string + robots: RobotModel[] + selected?: RobotModel + dropDirection?: 'up' | 'down' + onSelect(robot: RobotModel): void +} + +@observer +export class RobotSelector extends React.Component { + render() { + const { className, robots, selected, dropDirection } = this.props + const viewModel = RobotSelectorViewModel.of({ + robots, + selected, + }) + + const empty = ( +
+
+
No connected robots
+ Run yarn start:sim to simulate robots +
+ ) + + return ( +
+