diff --git a/.gitignore b/.gitignore index daf1ea3..7d44c3c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ __pycache__ .mypy_cache .nox .coverage + +# NPM build artifacts +app/dist/ diff --git a/app/.eslintrc.yml b/app/.eslintrc.yml new file mode 100644 index 0000000..f05f8d5 --- /dev/null +++ b/app/.eslintrc.yml @@ -0,0 +1,55 @@ +env: + browser: true + es6: true + node: true +extends: + - 'plugin:react/recommended' + - 'plugin:@typescript-eslint/recommended' + - airbnb + - 'plugin:prettier/recommended' +globals: + Atomics: readonly + SharedArrayBuffer: readonly +parser: '@typescript-eslint/parser' +parserOptions: + ecmaFeatures: + jsx: true + ecmaVersion: 2018 + sourceType: module +plugins: + - react + - '@typescript-eslint' +rules: + sort-imports: off + react/jsx-filename-extension: + - error + - extensions: + - ".jsx" + - ".tsx" + import/extensions: + - error + - ignorePackages + - tsx: never + ts: never + js: never + jsx: never + no-unused-vars: off + '@typescript-eslint/no-unused-vars': + - error + - argsIgnorePattern: "^_" + '@typescript-eslint/no-empty-interface': off + '@typescript-eslint/explicit-function-return-type': + - error + - allowExpressions: true + '@typescript-eslint/camelcase': off +settings: + import/resolver: + node: + paths: + - src + extensions: + - .js + - .jsx + - .ts + - .tsx + - .jpg diff --git a/app/.prettierrc b/app/.prettierrc new file mode 100644 index 0000000..edd0f6c --- /dev/null +++ b/app/.prettierrc @@ -0,0 +1,6 @@ +{ + "tabWidth": 2, + "useTabs": false, + "singleQuote": true, + "trailingComma": "all" +} diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..2775798 --- /dev/null +++ b/app/package.json @@ -0,0 +1,75 @@ +{ + "name": "ultratrace", + "version": "1.0.0", + "description": "A tool for manually annotating ultrasound tongue imaging data", + "main": "index.js", + "scripts": { + "build": "webpack --config ./webpack.config.js", + "start": "npm run build && electron ./dist/main.js", + "electron": "electron ./dist/main.js", + "watch": "webpack --watch" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/SwatPhonLab/UltraTrace.git" + }, + "author": "Jonathan Washington", + "license": "GPL-3.0", + "bugs": { + "url": "https://github.com/SwatPhonLab/UltraTrace/issues" + }, + "homepage": "https://github.com/SwatPhonLab/UltraTrace#readme", + "devDependencies": { + "@interactjs/types": "^1.8.0-rc.1", + "@types/classnames": "^2.2.9", + "@types/node": "^12.12.21", + "@types/ramda": "^0.26.38", + "@types/react": "^16.9.17", + "@types/react-calendar-timeline": "^0.26.0", + "@types/react-dom": "^16.9.4", + "@types/styled-components": "^4.4.1", + "@typescript-eslint/eslint-plugin": "^2.12.0", + "@typescript-eslint/parser": "^2.12.0", + "css-loader": "^3.4.0", + "eslint": "^6.8.0", + "eslint-config-airbnb": "^18.0.1", + "eslint-config-prettier": "^6.7.0", + "eslint-plugin-import": "^2.19.1", + "eslint-plugin-jsx-a11y": "^6.2.3", + "eslint-plugin-prettier": "^3.1.2", + "eslint-plugin-react": "^7.17.0", + "eslint-plugin-react-hooks": "^1.7.0", + "eslint-plugin-sort-imports-es6-autofix": "^0.5.0", + "file-loader": "^5.0.2", + "html-webpack-plugin": "^3.2.0", + "less": "^3.10.3", + "less-loader": "^5.0.0", + "prettier": "^1.19.1", + "source-map-loader": "^0.2.4", + "style-loader": "^1.1.1", + "ts-loader": "^6.2.1", + "typescript": "^3.7.4", + "url-loader": "^3.0.0", + "webpack": "^4.41.4", + "webpack-cli": "^3.3.10", + "webpack-dev-server": "^3.10.1" + }, + "dependencies": { + "classnames": "^2.2.6", + "electron": "^7.1.7", + "immutable": "^4.0.0-rc.12", + "interactjs": "^1.7.3", + "moment": "^2.24.0", + "ramda": "^0.26.1", + "react": "^16.12.0", + "react-calendar-timeline": "^0.26.7", + "react-dom": "^16.12.0", + "react-image-annotation": "^0.9.10", + "react-pinch-and-zoom": "^1.2.4", + "semantic-ui-less": "^2.4.1", + "semantic-ui-react": "^0.88.2", + "styled-components": "^4.4.1", + "tachyons": "^4.11.1", + "uuidv4": "^6.0.0" + } +} diff --git a/app/src/img/img_grid_square.jpg b/app/src/img/img_grid_square.jpg new file mode 100644 index 0000000..93892e0 Binary files /dev/null and b/app/src/img/img_grid_square.jpg differ diff --git a/app/src/main/main.ts b/app/src/main/main.ts new file mode 100644 index 0000000..df861a9 --- /dev/null +++ b/app/src/main/main.ts @@ -0,0 +1,17 @@ +import { BrowserWindow, app } from 'electron'; + +function createWindow(): void { + // Create the browser window. + const win = new BrowserWindow({ + width: 800, + height: 600, + webPreferences: { + nodeIntegration: true, + }, + }); + + // and load the index.html of the app. + win.loadFile('index.html'); +} + +app.on('ready', createWindow); diff --git a/app/src/renderer/components/App.tsx b/app/src/renderer/components/App.tsx new file mode 100644 index 0000000..82bf68a --- /dev/null +++ b/app/src/renderer/components/App.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { TraceItem } from './TraceSelectorRow'; +import Sidebar from './Sidebar'; +import Window from './Window'; + +type Props = {}; + +export default function App(_: Props): React.ReactElement { + const [currentTrace, setCurrentTrace] = React.useState( + null, + ); + return ( +
+ + +
+ ); +} diff --git a/app/src/renderer/components/PlaybackControls.tsx b/app/src/renderer/components/PlaybackControls.tsx new file mode 100644 index 0000000..e92ab11 --- /dev/null +++ b/app/src/renderer/components/PlaybackControls.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { Button, Icon } from 'semantic-ui-react'; + +interface Props {} + +export default function PlaybackControls(_: Props): React.ReactElement { + const [isPlaying, setIsPlaying] = React.useState(false); + + const toggleIsPlaying = React.useCallback(() => setIsPlaying(!isPlaying), [ + isPlaying + ]); + + return ( + + + + + + ); +} diff --git a/app/src/renderer/components/Point.tsx b/app/src/renderer/components/Point.tsx new file mode 100644 index 0000000..d54a057 --- /dev/null +++ b/app/src/renderer/components/Point.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { AnnotationData } from 'react-image-annotation'; +import { SemanticCOLORS } from 'semantic-ui-react'; + +interface Props { + annotation: AnnotationData<{}, HasXY, HasColor>; + active: boolean; +} + +interface HasColor { + color: SemanticCOLORS; +} + +interface HasXY { + x: number; + y: number; +} + +export default function Point({ + annotation, +}: Props): React.ReactElement | null { + const { geometry } = annotation; + if (geometry == null) { + return null; + } + + return ( +
+ ); +} diff --git a/app/src/renderer/components/SeekableSelector.tsx b/app/src/renderer/components/SeekableSelector.tsx new file mode 100644 index 0000000..29a22b1 --- /dev/null +++ b/app/src/renderer/components/SeekableSelector.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; + +import { Button, Icon, Input } from 'semantic-ui-react'; + +type Props = { + placeholder: string; +}; + +export default function SeekableSelector({ + placeholder, +}: Props): React.ReactElement { + return ( +
+ + + + + +
+ ); +} diff --git a/app/src/renderer/components/Sidebar.tsx b/app/src/renderer/components/Sidebar.tsx new file mode 100644 index 0000000..0d6750a --- /dev/null +++ b/app/src/renderer/components/Sidebar.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; + +import { OrderedMap } from 'immutable'; +import { CheckboxProps, Segment } from 'semantic-ui-react'; + +import TraceSelectorRow, { TraceItem } from './TraceSelectorRow'; + +import PlaybackControls from './PlaybackControls'; +import SeekableSelector from './SeekableSelector'; +import TraceSelector from './TraceSelector'; + +type Props = { + currentTrace: TraceItem | null; + setCurrentTrace: (item: TraceItem | null) => void; +}; + +/** + * The application sidebar. Provides an interface to select frames, modify + * traces, and control playback. + */ +export default function Sidebar({ + currentTrace, + setCurrentTrace, +}: Props): React.ReactElement { + const [traces, setTraces] = React.useState>( + OrderedMap(), + ); + const upsertTrace = React.useCallback( + (item: TraceItem) => setTraces(traces.set(item.id, item)), + [traces], + ); + const lookupTraceByKeyAndSetCurrent = React.useCallback( + (data: CheckboxProps) => { + const { value } = data; + if (value == null || typeof value === 'number') { + throw new Error(`Expected a string but got '${value}'`); + } + setCurrentTrace(traces.get(value, null)); + }, + [traces], + ); + + const traceElements = traces + .map((item: TraceItem) => ( + + )) + .valueSeq(); + return ( + +
+ +
+
+ +
+ + +
+ ); +} diff --git a/app/src/renderer/components/TraceSelector.tsx b/app/src/renderer/components/TraceSelector.tsx new file mode 100644 index 0000000..ecf6f12 --- /dev/null +++ b/app/src/renderer/components/TraceSelector.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; + +import { Button, Icon, Table } from 'semantic-ui-react'; +import { Seq } from 'immutable'; +import { uuid } from 'uuidv4'; +import TraceSelectorRow, { TraceItem } from './TraceSelectorRow'; + +type Props = { + traces: Seq.Indexed< + React.ReactElement< + React.ComponentProps, + typeof TraceSelectorRow + > + >; + onAddTrace: (item: TraceItem) => void; +}; + +/** + * The list of traces. Provides an interface to modify the trace list. + */ +export default function TraceSelector({ + traces, + onAddTrace, +}: Props): React.ReactElement { + const addTrace = React.useCallback(() => { + onAddTrace({ id: uuid(), name: '', color: 'blue' }); + }, [onAddTrace]); + return ( + + {traces} + + + + + + + + + + +
+ ); +} diff --git a/app/src/renderer/components/TraceSelectorRow.tsx b/app/src/renderer/components/TraceSelectorRow.tsx new file mode 100644 index 0000000..bfa1fe1 --- /dev/null +++ b/app/src/renderer/components/TraceSelectorRow.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; + +import { + Button, + CheckboxProps, + Input, + InputOnChangeData, + Radio, + SemanticCOLORS, + Table, +} from 'semantic-ui-react'; + +type Props = { + key: string; + item: TraceItem; + onEditItem: (nextItem: TraceItem) => void; + isSelected: boolean; + onSelect: (data: CheckboxProps) => void; +}; + +export interface TraceItem { + id: string; + name: string; + color: SemanticCOLORS; +} + +/** + * A single trace. Provides an interface to edit the trace's metadata. + */ +export default function TraceSelectorRow({ + item, + onEditItem, + isSelected, + onSelect, +}: Props): React.ReactElement { + // State of the name input while the user has it focused + const [inflightName, setInflightName] = React.useState(item.name); + + const commitNameChange = React.useCallback( + () => onEditItem({ ...item, name: inflightName }), + [item, inflightName], + ); + const updateInflightName = React.useCallback( + (_: React.ChangeEvent, data: InputOnChangeData) => + setInflightName(data.value), + [], + ); + + const onRadioChange = React.useCallback( + (_: React.FormEvent, data: CheckboxProps) => onSelect(data), + [], + ); + + return ( + + + + + + + + +