Initial commit

This commit is contained in:
karniv00l 2021-03-22 22:29:03 +01:00
commit e2860fde14
No known key found for this signature in database
GPG Key ID: F40F61D5587F5673
70 changed files with 197334 additions and 0 deletions

10
.editorconfig Normal file
View File

@ -0,0 +1,10 @@
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = space
indent_size = 2

1
.env Normal file
View File

@ -0,0 +1 @@
BROWSER=none

1
.eslintignore Normal file
View File

@ -0,0 +1 @@
/src/**/*.js

49
.eslintrc.yml Normal file
View File

@ -0,0 +1,49 @@
---
settings:
import/core-modules:
- electron
import/resolver:
node:
extensions:
- ".js"
- ".jsx"
- ".ts"
- ".tsx"
extends:
- react-app
- airbnb
- plugin:jsx-a11y/recommended
- prettier
plugins:
- jsx-a11y
- prettier
rules:
semi:
- error
- always
comma-dangle:
- error
- always-multiline
react/jsx-filename-extension:
- 1
- extensions:
- ".jsx"
- ".tsx"
import/extensions: 0
react/react-in-jsx-scope: 0
react/jsx-props-no-spreading: 0
object-curly-spacing:
- error
- always
quotes:
- error
- single
no-console: 0
no-plusplus: 0
import/no-extraneous-dependencies: 0
no-undef: 1
react/require-default-props: 0
no-unused-vars: 0
'@typescript-eslint/no-unused-vars': 1
no-shadow: 0
'@typescript-eslint/no-shadow': 2

11
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "npm" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "daily"

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
# testing
/coverage
# production build
/build
/out
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.eslintcache
# custom ts builds
/src/**/*.js

10
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"editorconfig.editorconfig",
"dbaeumer.vscode-eslint",
"sonarsource.sonarlint-vscode",
"davidanson.vscode-markdownlint"
]
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Piotr Rogowski
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

20
README.md Normal file
View File

@ -0,0 +1,20 @@
# SpeedyTuner Cloud
Share your Speeduino tune and logs.
## Development
Recommended dev environment:
- [Node LTS](https://nodejs.org/) 14.x.x
```bash
# install packages
npm install
# run development server
npm start
# open in browser
open http://localhost:3000
```

140
craco-less.js Normal file
View File

@ -0,0 +1,140 @@
const path = require('path');
const { getLoader, loaderByName, throwUnexpectedConfigError } = require('@craco/craco');
const overrideWebpackConfig = ({ context, webpackConfig, pluginOptions }) => {
const throwError = (message, githubIssueQuery) =>
throwUnexpectedConfigError({
packageName: 'craco-less',
githubRepo: 'DocSpring/craco-less',
message,
githubIssueQuery,
});
const lessExtension = /\.less$/;
const options = pluginOptions || {};
const pathSep = path.sep;
const oneOfRule = webpackConfig.module.rules.find(rule => rule.oneOf);
if (!oneOfRule) {
throwError(
'Can\'t find a \'oneOf\' rule under module.rules in the ' +
`${context.env} webpack config!`,
'webpack+rules+oneOf',
);
}
const sassRule = oneOfRule.oneOf.find(
rule => rule.test && rule.test.toString().includes('scss|sass'),
);
if (!sassRule) {
throwError(
'Can\'t find the webpack rule to match scss/sass files in the ' +
`${context.env} webpack config!`,
'webpack+rules+scss+sass',
);
}
let lessRule = {
exclude: /\.module\.(less)$/,
test: lessExtension,
use: [],
};
const loaders = sassRule.use;
loaders.forEach(ruleOrLoader => {
let rule;
if (typeof ruleOrLoader === 'string') {
rule = {
loader: ruleOrLoader,
options: {},
};
} else {
rule = ruleOrLoader;
}
if (
(context.env === 'development' || context.env === 'test') &&
rule.loader.includes(`${pathSep}style-loader${pathSep}`)
) {
lessRule.use.push({
loader: rule.loader,
options: {
...rule.options,
...(options.styleLoaderOptions || {}),
},
});
} else if (rule.loader.includes(`${pathSep}css-loader${pathSep}`)) {
lessRule.use.push({
loader: rule.loader,
options: {
...rule.options,
...(options.cssLoaderOptions || {}),
},
});
} else if (rule.loader.includes(`${pathSep}postcss-loader${pathSep}`)) {
lessRule.use.push({
loader: rule.loader,
options: {
...rule.options,
...(options.postcssLoaderOptions || {}),
},
});
} else if (rule.loader.includes(`${pathSep}resolve-url-loader${pathSep}`)) {
lessRule.use.push({
loader: rule.loader,
options: {
...rule.options,
...(options.resolveUrlLoaderOptions || {}),
},
});
} else if (
context.env === 'production' &&
rule.loader.includes(`${pathSep}mini-css-extract-plugin${pathSep}`)
) {
lessRule.use.push({
loader: rule.loader,
options: {
...rule.options,
...(options.miniCssExtractPluginOptions || {}),
},
});
} else if (rule.loader.includes(`${pathSep}sass-loader${pathSep}`)) {
const defaultLessLoaderOptions =
context.env === 'production' ? { sourceMap: true } : {};
lessRule.use.push({
loader: require.resolve('less-loader'),
options: {
...defaultLessLoaderOptions,
...options.lessLoaderOptions,
},
});
} else {
throwError(
`Found an unhandled loader in the ${context.env} webpack config: ${rule.loader}`,
'webpack+unknown+rule',
);
}
});
if (options.modifyLessRule) {
lessRule = options.modifyLessRule(lessRule, context);
}
oneOfRule.oneOf.push(lessRule);
const { isFound, match: fileLoaderMatch } = getLoader(
webpackConfig,
loaderByName('file-loader'),
);
if (!isFound) {
throwError(
`Can't find file-loader in the ${context.env} webpack config!`,
'webpack+file-loader',
);
}
fileLoaderMatch.loader.exclude.push(lessExtension);
return webpackConfig;
};
module.exports = {
overrideWebpackConfig,
};

16
craco.config.js Normal file
View File

@ -0,0 +1,16 @@
const CracoLessPlugin = require('./craco-less');
module.exports = {
plugins: [
{
plugin: CracoLessPlugin,
options: {
lessLoaderOptions: {
lessOptions: {
javascriptEnabled: true,
},
},
},
},
],
};

7
forge.config.js Normal file
View File

@ -0,0 +1,7 @@
module.exports = {
makers: [
{
name: '@electron-forge/maker-dmg'
},
],
};

46850
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

112
package.json Normal file
View File

@ -0,0 +1,112 @@
{
"name": "speedy-tuner",
"description": "Speeduino Tuning Software",
"version": "0.1.0",
"private": true,
"license": "MIT",
"homepage": "./",
"main": "src/electron.js",
"scripts": {
"start": "craco start",
"dev": "concurrently \"npm start\" \"wait-on http://localhost:3000 && electron .\"",
"web:build": "craco build",
"electron:compile": "tsc -p tsconfig.custom.json",
"electron:start": "npm run electron:compile && electron-forge start",
"electron:package": "npm run web:build && npm run electron:compile && electron-forge package",
"electron:make": "npm run web:build && npm run electron:compile && electron-forge make",
"ini:compile": "tsc -p tsconfig.custom.json",
"ini:start": "npm run ini:compile && node src/parser/ini.js",
"ini:watch": "npx onchange src/parser/ini.ts -- npm run ini:start",
"test": "craco test",
"lint": "eslint src/",
"lint-fix": "eslint --fix src/",
"eject": "react-scripts eject"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"dependencies": {
"@reduxjs/toolkit": "^1.5.0",
"antd": "^4.14.0",
"electron-squirrel-startup": "^1.0.0",
"js-yaml": "^4.0.0 ",
"mlg-converter": "^0.1.5",
"parsimmon": "^1.16.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-perfect-scrollbar": "^1.5.8",
"react-redux": "^7.2.2",
"react-router-dom": "^5.2.0",
"react-scripts": "^4.0.3",
"react-table-drag-select": "^0.3.1",
"recharts": "^2.0.8"
},
"devDependencies": {
"@craco/craco": "^6.1.1",
"@electron-forge/cli": "^6.0.0-beta.54",
"@electron-forge/maker-deb": "^6.0.0-beta.54",
"@electron-forge/maker-dmg": "^6.0.0-beta.54",
"@electron-forge/maker-rpm": "^6.0.0-beta.54",
"@electron-forge/maker-squirrel": "^6.0.0-beta.54",
"@electron-forge/maker-zip": "^6.0.0-beta.54",
"@types/js-yaml": "^4.0.0",
"@types/node": "^14.14.35",
"@types/parsimmon": "^1.10.6",
"@types/react": "^17.0.3",
"@types/react-dom": "^17.0.2",
"@types/react-redux": "^7.1.16",
"@types/react-router-dom": "^5.1.7",
"concurrently": "^6.0.0",
"electron": "^12.0.1",
"eslint": "^7.22.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-config-airbnb-base": "^14.2.1",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-prettier": "^3.3.1",
"less-loader": "^6.1.0",
"prettier": "^2.2.1",
"typescript": "^4.1.5",
"wait-on": "^5.3.0"
},
"config": {
"forge": {
"packagerConfig": {
"name": "SpeedyTuner",
"icon": "./public/icons/icon"
},
"makers": [
{
"name": "@electron-forge/maker-squirrel",
"config": {
"name": "SpeedyTuner"
}
},
{
"name": "@electron-forge/maker-zip",
"platforms": [
"darwin"
]
},
{
"name": "@electron-forge/maker-deb",
"config": {}
},
{
"name": "@electron-forge/maker-rpm",
"config": {}
}
]
}
}
}

BIN
public/icons/icon.icns Normal file

Binary file not shown.

BIN
public/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

BIN
public/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

17
public/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/icons/icon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#222629" />
<meta name="description" content="SpeedyTuner" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/icons/icon.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>SpeedyTuner</title>
</head>
<body style="background-color: #222629;">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

20
public/manifest.json Normal file
View File

@ -0,0 +1,20 @@
{
"short_name": "SpeedyTuner",
"name": "Speeduino Tuning Software",
"icons": [
{
"src": "/icons/icon.ico",
"type": "image/x-icon",
"sizes": "256x256"
},
{
"src": "/icons/icon.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#222629",
"background_color": "#222629"
}

3
public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow: /

5027
public/tunes/202012.ini Normal file

File diff suppressed because it is too large Load Diff

1
public/tunes/202012.json Normal file

File diff suppressed because one or more lines are too long

2150
public/tunes/202012.msq Normal file

File diff suppressed because it is too large Load Diff

65453
public/tunes/202012.yml Normal file

File diff suppressed because it is too large Load Diff

5117
public/tunes/202103.ini Normal file

File diff suppressed because it is too large Load Diff

1
public/tunes/202103.json Normal file

File diff suppressed because one or more lines are too long

2370
public/tunes/202103.msq Normal file

File diff suppressed because it is too large Load Diff

65849
public/tunes/202103.yml Normal file

File diff suppressed because it is too large Load Diff

BIN
public/tunes/bin.mlg Normal file

Binary file not shown.

View File

@ -0,0 +1 @@
declare module 'react-table-drag-select';

100
src/App.less Normal file
View File

@ -0,0 +1,100 @@
@import '~antd/dist/antd.less';
// @import '~antd/dist/antd.compact.less';
// @import './themes/light.less';
@import './themes/dark.less';
@import './themes/common.less';
@import './themes/ant.less';
body {
overflow: hidden;
}
.border-right {
border-right-width: 1px;
border-right-color: @border-color-split;
border-right-style: solid;
}
.row {
margin: 20px;
}
.electron-draggable {
-webkit-app-region: drag;
}
.electron-not-draggable {
-webkit-app-region: no-drag;
}
.app-top-bar {
.electron-draggable;
// border-bottom-width: 1px;
// border-bottom-color: @border-color-split;
// border-bottom-style: solid;
box-shadow: 0px 0px 20px 0px #0000001c, 5px 5px 15px 5px rgb(0 0 0 e('/') 4%);
z-index: @bars-z-index;
}
.app-sidebar {
height: calc(100vh - @layout-header-height - @layout-footer-height);
position: fixed;
left: 0;
user-select: none;
// .border-right;
}
.app-status-bar {
position: fixed;
bottom: 0;
width: 100%;
height: @layout-footer-height;
font-size: @font-size-sm;
color: @text-color;
z-index: @bars-z-index;
// border-top-width: 1px;
// border-top-color: @border-color-split;
// border-top-style: solid;
}
.app-content {
height: calc(100vh - @layout-header-height - @layout-footer-height);
}
.table {
margin: 20px;
table {
width: 100%;
}
td {
text-align: center;
border: 1px solid @main-light;
height: 50px;
user-select: none;
// transition: all 0.1s;
&.value {
color: rgba(0, 0, 0, 1);
width: 5%;
font-weight: 600;
}
&.title-curve {
width: 15%;
}
&.title-map {
width: 5%;
}
&.cell-selected {
box-shadow: inset 0 0 25px 2px @info-color;
}
&.cell-being-selected {
box-shadow: inset 0 0 25px 2px @warning-color;
}
}
}

108
src/App.tsx Normal file
View File

@ -0,0 +1,108 @@
import { useEffect, useMemo } from 'react';
import {
useLocation,
Switch,
Route,
matchPath,
Redirect,
generatePath,
} from 'react-router-dom';
import { Layout, Result } from 'antd';
import { connect } from 'react-redux';
import PerfectScrollbar from 'react-perfect-scrollbar';
import Dialog from './components/Dialog';
import { loadAll } from './utils/api';
import SideBar, { DialogMatchedPathType } from './components/SideBar';
import { AppState, UIState } from './types/state';
import BurnButton from './components/BurnButton';
import TopBar from './components/TopBar';
import StatusBar from './components/StatusBar';
import { isDesktop } from './utils/env';
import 'react-perfect-scrollbar/dist/css/styles.css';
import './App.less';
import { Routes } from './routes';
import { Config as ConfigType } from './types/config';
import { storageGet } from './utils/storage';
const { Content } = Layout;
const mapStateToProps = (state: AppState) => ({
ui: state.ui,
status: state.status,
config: state.config,
});
const App = ({ ui, config }: { ui: UIState, config: ConfigType }) => {
const margin = ui.sidebarCollapsed ? 80 : 250;
const { pathname } = useLocation();
const dialogMatchedPath: DialogMatchedPathType = useMemo(() => matchPath(pathname, {
path: Routes.DIALOG,
exact: true,
}) || { url: '', params: { category: '', dialog: '' } }, [pathname]);
const lastDialogPath = storageGet('lastDialog');
const firstDialogPath = useMemo(() => {
if (!config.menus) {
return null;
}
const firstCategory = Object.keys(config.menus)[0];
const firstDialog = Object.keys(config.menus[firstCategory].subMenus)[0];
return generatePath(Routes.DIALOG, { category: firstCategory, dialog: firstDialog });
}, [config.menus]);
console.log({
firstDialogPath,
});
useEffect(() => {
loadAll();
// window.addEventListener('beforeunload', beforeUnload);
// return () => {
// window.removeEventListener('beforeunload', beforeUnload);
// };
}, []);
return (
<>
<Layout>
<TopBar />
<Switch>
<Route path={Routes.ROOT} exact>
<Redirect to="/tune" />
</Route>
<Route path={Routes.TUNE}>
<Route path={Routes.TUNE} exact>
{firstDialogPath && <Redirect to={lastDialogPath || firstDialogPath} />}
</Route>
<Layout style={{ marginLeft: margin }}>
<SideBar matchedPath={dialogMatchedPath} />
<Layout className="app-content">
<Content>
<PerfectScrollbar options={{ suppressScrollX: true }}>
<Dialog
name={dialogMatchedPath.params.dialog}
url={dialogMatchedPath.url}
burnButton={isDesktop && <BurnButton />}
/>
</PerfectScrollbar>
</Content>
</Layout>
</Layout>
</Route>
<Route>
<Result
status="warning"
title="There is nothing here"
style={{ marginTop: 50 }}
/>
</Route>
</Switch>
</Layout>
<StatusBar />
</>
);
};
export default connect(mapStateToProps)(App);

View File

@ -0,0 +1,23 @@
import { Button, Grid } from 'antd';
import { FireOutlined } from '@ant-design/icons';
const { useBreakpoint } = Grid;
const BurnButton = () => {
const { md } = useBreakpoint();
return (
<Button
type="primary"
size="large"
danger
htmlType="submit"
icon={<FireOutlined />}
style={{ position: 'fixed', right: 35, bottom: 45 }}
>
{md && 'Burn'}
</Button>
);
};
export default BurnButton;

421
src/components/Dialog.tsx Normal file
View File

@ -0,0 +1,421 @@
import { useEffect } from 'react';
import { connect } from 'react-redux';
import {
Form,
Skeleton,
Divider,
Col,
Row,
Popover,
Space,
Result,
} from 'antd';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { AppState } from '../types/state';
import SmartSelect from './Dialog/SmartSelect';
import SmartNumber from './Dialog/SmartNumber';
import TextField from './Dialog/TextField';
import Curve from './Dialog/Curve';
import {
Dialogs as DialogsType,
Dialog as DialogType,
Config as ConfigType,
Field as FieldType,
Curve as CurveType,
Table as TableType,
ScalarConstant as ScalarConstantType,
ConstantTypes,
} from '../types/config';
import {
Tune as TuneType,
} from '../types/tune';
import { findOnPage } from '../utils/config/find';
import { parseXy, parseZ } from '../utils/tune/table';
import Map from './Dialog/Map';
import { evaluateExpression, isExpression } from '../utils/tune/expression';
import { storageSet } from '../utils/storage';
interface DialogsAndCurves {
[name: string]: DialogType | CurveType | TableType,
}
interface RenderedPanel {
type: string,
name: string,
title: string;
labels: string[];
xAxis: number[];
yAxis: number[];
xBins: string[];
yBins: string[];
size: number[];
gauge?: string;
fields: FieldType[],
map: string;
page: number;
help?: string;
xyLabels: string[];
zBins: string[];
gridHeight: number;
gridOrient: number[];
upDownLabel: string[];
}
enum PanelTypes {
FIELDS = 'fields',
CURVE = 'curve',
TABLE = 'table',
}
const mapStateToProps = (state: AppState) => ({
config: state.config,
tune: state.tune,
});
const containerStyle = {
padding: 20,
};
const skeleton = (<div style={containerStyle}>
<Skeleton /><Skeleton />
</div>);
// TODO: refactor this
const Dialog = ({
config,
tune,
url,
name,
burnButton,
}: {
config: ConfigType,
tune: TuneType,
name: string,
url: string,
burnButton: any
}) => {
const isDataReady = Object.keys(tune.constants).length && Object.keys(config.constants).length;
useEffect(() => {
storageSet('lastDialog', url);
}, [url]);
const renderHelp = (link?: string) => (link &&
<Popover
content={
<a
href={`${link}`}
target="__blank"
rel="noopener noreferrer"
>
{link}
</a>
}
placement="right"
>
<QuestionCircleOutlined
style={{ position: 'sticky', top: 15, zIndex: 1 }}
/>
</Popover>
);
const renderCurve = (curve: CurveType) => {
const x = tune.constants[curve.xBins[0]];
const y = tune.constants[curve.yBins[0]];
const xConstant = findOnPage(config, curve.xBins[0]) as ScalarConstantType;
const yConstant = findOnPage(config, curve.yBins[0]) as ScalarConstantType;
return (
<Curve
name={curve.yBins[0]}
key={curve.yBins[0]}
disabled={false} // TODO: evaluate condition
help={config.help[curve.yBins[0]]}
xLabel={curve.labels[0]}
yLabel={curve.labels[1]}
xUnits={xConstant.units}
yUnits={yConstant.units}
xMin={xConstant.min as number}
xMax={xConstant.max as number}
yMin={yConstant.min as number}
yMax={yConstant.max as number}
xData={parseXy(x.value as string)}
yData={parseXy(y.value as string)}
/>
);
};
const renderTable = (table: TableType | RenderedPanel) => {
const x = tune.constants[table.xBins[0]];
const y = tune.constants[table.yBins[0]];
const z = tune.constants[table.zBins[0]];
const zConstant = findOnPage(config, table.zBins[0]) as ScalarConstantType;
let max = zConstant.max as number;
if (isExpression(zConstant.max)) {
max = evaluateExpression(zConstant.max as string, tune.constants, config);
}
let min = zConstant.min as number;
if (isExpression(zConstant.min)) {
min = evaluateExpression(zConstant.min as string, tune.constants, config);
}
return <div>
{renderHelp(table.help)}
<Map
name={table.map}
key={table.map}
xData={parseXy(x.value as string)}
yData={parseXy(y.value as string).reverse()}
zData={parseZ(z.value as string)}
disabled={false}
zMin={min}
zMax={max}
digits={zConstant.digits as number}
xUnits={x.units as string}
yUnits={y.units as string}
/>
</div>;
};
if (!isDataReady) {
return skeleton;
}
const dialogConfig = config.dialogs[name];
const curveConfig = config.curves[name];
const tableConfig = config.tables[name];
// standalone dialog / page
if (!dialogConfig) {
if (curveConfig) {
return (
<div style={containerStyle}>
<Divider>{curveConfig.title}</Divider>
{renderCurve(curveConfig)}
</div>
);
}
if (tableConfig) {
return (
<div style={containerStyle}>
{renderHelp(tableConfig.help)}
<Divider>{tableConfig.title}</Divider>
{renderTable(tableConfig)}
</div>
);
}
return (
<Result
status="warning"
title="Dialog not found"
style={{ marginTop: 50 }}
/>
);
}
const calculateSpan = (type: PanelTypes, dialogsCount: number) => {
let xxl = 24;
const xl = 24;
if (dialogsCount > 1 && type === PanelTypes.FIELDS) {
xxl = 12;
}
return {
span: 24,
xxl,
xl,
};
};
const resolvedDialogs: DialogsAndCurves = {};
const resolveDialogs = (source: DialogsType, dialogName: string) => {
if (!source[dialogName]) {
return;
}
// resolve root dialog
resolvedDialogs[dialogName] = source[dialogName];
Object.keys(source[dialogName].panels).forEach((panelName: string) => {
const currentDialog = source[panelName];
if (!currentDialog) {
// resolve 2D map / curve panel
if (config.curves[panelName]) {
resolvedDialogs[panelName] = {
...config.curves[panelName],
};
return;
}
// resolve 3D map / table panel
if (config.tables[panelName]) {
resolvedDialogs[panelName] = {
...config.tables[panelName],
};
return;
}
console.info('Unable to resolve panel:', panelName);
return;
}
if (currentDialog.fields.length > 0) {
// resolve in root scope
resolvedDialogs[panelName] = config.dialogs[panelName];
}
// recursion
resolveDialogs(config.dialogs, panelName);
});
};
// TODO: refactor this
resolveDialogs(config.dialogs, name);
// remove dummy dialogs and flatten to array
const panels = Object.keys(resolvedDialogs).map((dialogName: string): RenderedPanel => {
const currentDialog: DialogType | CurveType | TableType = resolvedDialogs[dialogName];
let type = PanelTypes.CURVE;
let fields: FieldType[] = [];
if ('fields' in currentDialog) {
type = PanelTypes.FIELDS;
fields = (currentDialog as DialogType)
.fields
.filter((field) => field.title !== '' );
} else if ('zBins' in currentDialog) {
type = PanelTypes.TABLE;
}
return {
type,
name: dialogName,
title: currentDialog.title,
fields,
labels: (currentDialog as CurveType).labels,
xAxis: (currentDialog as CurveType).xAxis,
yAxis: (currentDialog as CurveType).yAxis,
xBins: (currentDialog as CurveType).xBins,
yBins: (currentDialog as CurveType).yBins,
size: (currentDialog as CurveType).size,
gauge: (currentDialog as CurveType).gauge,
map: (currentDialog as TableType).map,
page: (currentDialog as TableType).page,
help: (currentDialog as TableType).help,
xyLabels: (currentDialog as TableType).xyLabels,
zBins: (currentDialog as TableType).zBins,
gridHeight: (currentDialog as TableType).gridHeight,
gridOrient: (currentDialog as TableType).gridOrient,
upDownLabel: (currentDialog as TableType).upDownLabel,
};
});
const panelsComponents = () => panels.map((panel: RenderedPanel) => {
if (panel.type === PanelTypes.FIELDS && panel.fields.length === 0) {
return null;
}
return (
<Col key={panel.name} {...calculateSpan(panel.type as PanelTypes, panels.length)}>
<Divider>{panel.title}</Divider>
{(panel.fields).map((field: FieldType) => {
const constant = findOnPage(config, field.name);
const tuneField = tune.constants[field.name];
const help = config.help[field.name];
let input;
let enabled = true;
if (field.condition) {
enabled = evaluateExpression(field.condition, tune.constants, config);
}
if (field.name === '_fieldText_' && enabled) {
return <TextField key={`${panel.name}-${field.title}`} title={field.title} />;
}
if (!tuneField) {
// TODO: handle this?
// name: "rpmwarn", title: "Warning",
return null;
}
switch (constant.type) {
// case ConstantTypes.ARRAY: // TODO: arrays
case ConstantTypes.BITS:
input = <SmartSelect
defaultValue={`${tuneField.value}`}
values={constant.values as string[]}
disabled={!enabled}
/>;
break;
case ConstantTypes.SCALAR:
input = <SmartNumber
defaultValue={Number(tuneField.value)}
digits={(constant as ScalarConstantType).digits}
min={((constant as ScalarConstantType).min as number) || 0}
max={(constant as ScalarConstantType).max as number}
disabled={!enabled}
units={(constant as ScalarConstantType).units}
/>;
break;
default:
break;
}
return (
<Form.Item
key={field.name}
label={
<Space>
{field.title}
{help && (<Popover content={
help.split('\\n').map((line) => <div key={line}>{line}</div>)
}>
<QuestionCircleOutlined />
</Popover>)}
</Space>
}
>
{input}
</Form.Item>
);
})}
{panel.type === PanelTypes.CURVE && renderCurve(panel)}
{panel.type === PanelTypes.TABLE && renderTable(panel)}
</Col>
);
});
return (
<div style={containerStyle}>
{renderHelp(dialogConfig?.help)}
<Form
labelCol={{ span: 10 }}
wrapperCol={{ span: 10 }}
>
<Row gutter={20}>
{isDataReady && panelsComponents()}
</Row>
<Form.Item>
{burnButton}
</Form.Item>
</Form>
</div>
);
};
export default connect(mapStateToProps)(Dialog);

View File

@ -0,0 +1,128 @@
import { Typography } from 'antd';
import { useState } from 'react';
import {
CartesianGrid,
Line,
LineChart,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Label,
} from 'recharts';
import Table from './Table';
const Curve = ({
name,
xLabel,
yLabel,
xData,
yData,
disabled,
help,
xMin,
xMax,
yMin,
yMax,
xUnits = '',
yUnits = '',
}: {
name: string,
xLabel: string,
yLabel: string,
xData: number[],
yData: number[],
disabled: boolean,
help: string,
xMin: number,
xMax: number,
yMin: number,
yMax: number,
xUnits?: string,
yUnits?: string,
}) => {
const mapData = (rawData: number[][]) => rawData[1].map((val, i) => ({
x: val,
y: rawData[0][i],
}));
const [data, setData] = useState(mapData([yData, xData]));
const margin = 15;
const mainColor = '#ccc';
const tooltipBg = '#2E3338';
const animationDuration = 500;
return (
<>
<Typography.Paragraph>
<Typography.Text type="secondary">{help}</Typography.Text>
</Typography.Paragraph>
<ResponsiveContainer height={450}>
<LineChart
data={data}
margin={{
top: margin,
right: margin,
left: margin,
bottom: margin + 5,
}}
>
<CartesianGrid
strokeDasharray="4 4"
strokeOpacity={0.1}
/>
<XAxis dataKey="x">
<Label
value={`${xLabel} (${xUnits})`}
position="bottom"
style={{ fill: mainColor }}
/>
</XAxis>
<YAxis domain={['auto', 'auto']}>
<Label
value={`${yLabel} (${yUnits})`}
position="left"
angle={-90}
style={{ fill: mainColor }}
/>
</YAxis>
<Tooltip
labelFormatter={(value) => `${xLabel} : ${value} ${xUnits}`}
formatter={(value: number) => [`${value} ${yUnits}`, yLabel]}
contentStyle={{
backgroundColor: tooltipBg,
border: 0,
boxShadow: '0 3px 6px -4px rgb(0 0 0 / 12%), 0 6px 16px 0 rgb(0 0 0 / 8%), 0 9px 28px 8px rgb(0 0 0 / 5%)',
borderRadius: 5,
}}
animationDuration={animationDuration}
/>
<Line
strokeWidth={3}
type="linear"
dataKey="y"
stroke="#1e88ea"
animationDuration={animationDuration}
/>
</LineChart>
</ResponsiveContainer>
<Table
name={name}
key={name}
xLabel={xLabel}
yLabel={yLabel}
xData={xData}
yData={yData}
disabled={disabled}
xMin={xMin}
xMax={xMax}
yMin={yMin}
yMax={yMax}
xUnits={xUnits}
yUnits={yUnits}
onChange={(newData: number[][]) => setData(mapData(newData))}
/>
</>
);
};
export default Curve;

View File

@ -0,0 +1,276 @@
/* eslint-disable react/no-array-index-key */
import {
useEffect,
useRef,
useState,
} from 'react';
import {
Button,
InputNumber,
Modal,
Popover,
Space,
} from 'antd';
import {
PlusCircleOutlined,
MinusCircleOutlined,
EditOutlined,
} from '@ant-design/icons';
import TableDragSelect from 'react-table-drag-select';
import {
isDecrement,
isEscape,
isIncrement,
isReplace,
} from '../../utils/keyboard/shortcuts';
type CellsType = boolean[][];
type DataType = number[][];
type OnChangeType = (data: DataType) => void;
enum Operations {
INC,
DEC,
REPLACE,
}
type HslType = [number, number, number];
const Map = ({
name,
xData,
yData,
zData,
disabled,
onChange,
zMin,
zMax,
digits,
xUnits,
yUnits,
}: {
name: string,
xData: number[],
yData: number[],
zData: number[][],
disabled: boolean,
onChange?: OnChangeType,
zMin: number,
zMax: number,
digits: number,
xUnits: string,
yUnits: string,
}) => {
const titleProps = { disabled: true };
const [isModalVisible, setIsModalVisible] = useState(false);
const [modalValue, setModalValue] = useState<number | undefined>();
const [data, _setData] = useState<DataType>(zData);
const generateCells = () => Array.from(
Array(yData.length + 1).fill(false),
() => new Array(xData.length + 1).fill(false),
);
const [cells, _setCells] = useState<CellsType>(generateCells());
const cellsRef = useRef(cells);
const dataRef = useRef(data);
const modalInputRef = useRef<HTMLInputElement | null>(null);
const setCells = (currentCells: CellsType) => {
cellsRef.current = currentCells;
_setCells(currentCells);
};
const setData = (currentData: DataType) => {
dataRef.current = currentData;
_setData(currentData);
if (onChange) {
onChange(currentData);
}
};
const modifyData = (operation: Operations, currentCells: CellsType, currentData: DataType, value = 0): DataType => {
const newData = [...currentData.map((row) => [...row])];
currentCells.forEach((_, rowIndex) => {
currentCells[rowIndex].forEach((selected, valueIndex) => {
if (!selected) {
return;
}
const current = newData[rowIndex][valueIndex - 1];
switch (operation) {
case Operations.INC:
if (current < zMax) {
newData[rowIndex][valueIndex - 1] = Number((newData[rowIndex][valueIndex - 1] + 10**-digits).toFixed(digits));
}
break;
case Operations.DEC:
if (current > zMin) {
newData[rowIndex][valueIndex - 1] = Number((newData[rowIndex][valueIndex - 1] - 10**-digits).toFixed(digits));
}
break;
case Operations.REPLACE:
if (value > zMax) {
newData[rowIndex][valueIndex - 1] = zMax;
break;
}
if (value < zMin) {
newData[rowIndex][valueIndex - 1] = zMin;
break;
}
newData[rowIndex][valueIndex - 1] = value;
break;
default:
break;
}
});
});
return [...newData];
};
const oneModalOk = () => {
setData(modifyData(Operations.REPLACE, cellsRef.current, dataRef.current, modalValue));
setIsModalVisible(false);
setModalValue(undefined);
};
const onModalCancel = () => {
setIsModalVisible(false);
setModalValue(undefined);
};
const resetCells = () => setCells(generateCells());
const increment = () => setData(modifyData(Operations.INC, cellsRef.current, dataRef.current));
const decrement = () => setData(modifyData(Operations.DEC, cellsRef.current, dataRef.current));
const replace = () => {
// don't show modal when no cell is selected
if (cellsRef.current.flat().find((val) => val === true)) {
setModalValue(undefined);
setIsModalVisible(true);
setInterval(() => modalInputRef.current?.focus(), 1);
}
};
useEffect(() => {
const keyboardListener = (e: KeyboardEvent) => {
if (isIncrement(e)) {
increment();
}
if (isDecrement(e)) {
decrement();
}
if (isReplace(e)) {
e.preventDefault();
replace();
}
if (isEscape(e)) {
resetCells();
}
};
document.addEventListener('keydown', keyboardListener);
return () => {
document.removeEventListener('keydown', keyboardListener);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const colorHsl = (min: number, max: number, value: number): HslType => {
const saturation = 60;
const lightness = 40;
const coldDeg = 220;
const hotDeg = 0;
const remap = (x: number, inMin: number, inMax: number, outMin: number, outMax: number) => (x - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
let hue = remap(value, min, max, coldDeg, hotDeg);
// fallback to cold temp
if (Number.isNaN(hue)) {
hue = coldDeg;
}
return [hue, saturation, lightness];
};
const min = Math.min(...data.map((row) => Math.min(...row)));
const max = Math.max(...data.map((row) => Math.max(...row)));
const renderRow = (rowIndex: number, input: number[]) => input
.map((value, index) => {
const [hue, sat, light] = colorHsl(min, max, value);
const yValue = yData[rowIndex];
const result = [];
if (index === 0) {
result.push((
<td {...titleProps} className="title-map" key={`y-${yValue}`}>
{`${yValue}`}
</td>
));
}
result.push((
<td
className="value"
key={`${rowIndex}-${index}-${value}-${hue}${sat}${light}`}
style={{ backgroundColor: `hsl(${hue}, ${sat}%, ${light}%)` }}
>
{`${value}`}
</td>
));
return result;
});
return (
<>
<div className="table">
<Popover
visible={cells.flat().find((val) => val === true) === true}
content={
<Space>
<Button onClick={decrement} icon={<MinusCircleOutlined />} />
<Button onClick={increment} icon={<PlusCircleOutlined />} />
<Button onClick={replace} icon={<EditOutlined />} />
</Space>
}
>
<TableDragSelect
key={name}
value={cells}
onChange={setCells}
>
{data.map((row, i) => (
<tr key={`row-${i}`}>
{renderRow(i, row)}
</tr>
))}
<tr>
<td {...titleProps} className="title-map">
{yUnits} / {xUnits}
</td>
{xData.map((xValue) => (
<td {...titleProps} key={`x-${xValue}`}>
{`${xValue}`}
</td>
))}
</tr>
</TableDragSelect>
</Popover>
</div>
<Modal
title="Set cell values"
visible={isModalVisible}
onOk={oneModalOk}
onCancel={onModalCancel}
centered
forceRender
>
<InputNumber
ref={modalInputRef}
value={modalValue}
onChange={(val) => setModalValue(Number(val))}
onPressEnter={oneModalOk}
style={{ width: '20%' }}
/>
</Modal>
</>
);
};
export default Map;

View File

@ -0,0 +1,65 @@
import {
InputNumber,
Slider,
} from 'antd';
const SmartNumber = ({
defaultValue,
min,
max,
units,
digits,
disabled,
}: {
defaultValue: number,
min: number,
max: number,
units: string | undefined,
digits: number,
disabled: boolean,
}) => {
const isSlider = (u: string) => ['%', 'C']
.includes(`${u}`.toUpperCase());
const sliderMarks: { [value: number]: string } = {};
sliderMarks[min] = `${min}${units}`;
if (min <= 0) {
sliderMarks[0] = `0${units}`;
}
if (max) {
sliderMarks[max] = `${max}${units}`;
}
if (isSlider(units || '')) {
return (
<Slider
defaultValue={defaultValue}
min={min}
max={max}
step={10**-digits}
disabled={disabled}
marks={sliderMarks}
tipFormatter={(val) => `${val}${units}`}
// tooltipVisible
// tooltipPlacement="bottom"
/>
);
}
return (
<InputNumber
defaultValue={defaultValue}
precision={digits}
min={min}
max={max}
step={10**-digits}
disabled={disabled}
style={{ minWidth: 150 }}
formatter={(val) => units ? `${val} ${units}` : `${val}`}
parser={(val) => Number(`${val}`.replace(/[^\d.]/g, ''))}
/>
);
};
export default SmartNumber;

View File

@ -0,0 +1,68 @@
import {
Radio,
Select,
Switch,
} from 'antd';
import {
CheckOutlined,
CloseOutlined,
} from '@ant-design/icons';
import { Switches } from '../../types/config';
const SmartSelect = ({
values,
defaultValue,
disabled,
}: {
values: string[],
defaultValue: string,
disabled: boolean,
}) => {
if (values.length === 2
&& (
(values.includes(Switches.YES) && values.includes(Switches.NO)) ||
(values.includes(Switches.ON) && values.includes(Switches.OFF))
)
) {
return <Switch
defaultChecked={[Switches.ON, Switches.YES].includes(defaultValue as Switches)}
checkedChildren={<CheckOutlined />}
unCheckedChildren={<CloseOutlined />}
/>;
}
if (values.length < 3) {
return (
<Radio.Group
defaultValue={values.indexOf(defaultValue)}
optionType="button"
buttonStyle="solid"
disabled={disabled}
>
{values.map((val: string, index) =>
<Radio key={val} value={index}>{val}</Radio>,
)}
</Radio.Group>
);
}
return (
<Select
defaultValue={values.indexOf(defaultValue)}
showSearch
optionFilterProp="label"
disabled={disabled}
style={{ maxWidth: 250 }}
>
{/* we need to preserve indexes here, skip INVALID option */}
{values.map((val: string, index) =>
val === 'INVALID'
? null
: <Select.Option key={val} value={index} label={val}>{val}</Select.Option>,
)}
</Select>
);
};
export default SmartSelect;

View File

@ -0,0 +1,277 @@
/* eslint-disable react/no-array-index-key */
import {
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import {
Button,
InputNumber,
Modal,
Popover,
Space,
} from 'antd';
import {
PlusCircleOutlined,
MinusCircleOutlined,
EditOutlined,
} from '@ant-design/icons';
import TableDragSelect from 'react-table-drag-select';
import {
isDecrement,
isEscape,
isIncrement,
isReplace,
} from '../../utils/keyboard/shortcuts';
type AxisType = 'x' | 'y';
type CellsType = boolean[][];
type DataType = number[][];
type OnChangeType = (data: DataType) => void;
enum Operations {
INC,
DEC,
REPLACE,
}
type HslType = [number, number, number];
const Table = ({
name,
xLabel,
yLabel,
xData,
yData,
disabled,
onChange,
xMin,
xMax,
yMin,
yMax,
xUnits,
yUnits,
}: {
name: string,
xLabel: string,
yLabel: string,
xData: number[],
yData: number[],
disabled: boolean,
onChange?: OnChangeType,
xMin: number,
xMax: number,
yMin: number,
yMax: number,
xUnits: string,
yUnits: string,
}) => {
const titleProps = { disabled: true };
const [isModalVisible, setIsModalVisible] = useState(false);
const [modalValue, setModalValue] = useState<number | undefined>();
const [data, _setData] = useState<DataType>([yData, xData]);
// data starts from `1` index, 0 is title / name
const rowsCount = data[1].length + 1;
const generateCells = () => [
Array(rowsCount).fill(false),
Array(rowsCount).fill(false),
];
const [cells, _setCells] = useState<CellsType>(generateCells());
const cellsRef = useRef(cells);
const dataRef = useRef(data);
const modalInputRef = useRef<HTMLInputElement | null>(null);
const setCells = (currentCells: CellsType) => {
cellsRef.current = currentCells;
_setCells(currentCells);
};
const setData = (currentData: DataType) => {
dataRef.current = currentData;
_setData(currentData);
if (onChange) {
onChange(currentData);
}
};
const modifyData = useCallback((operation: Operations, currentCells: CellsType, currentData: DataType, value = 0): DataType => {
const newData = [...currentData.map((row) => [...row])];
// rowIndex: [0 => Y, 1 => X]
const isY = (row: number) => row === 0;
const isX = (row: number) => row === 1;
const isNotGreater = (row: number, val: number) => (isY(row) && val < yMax) || (isX(row) && val < xMax);
const isNotLess = (row: number, val: number) => (isY(row) && val > yMin) || (isX(row) && val > xMin);
currentCells.forEach((_, rowIndex) => {
currentCells[rowIndex].forEach((selected, valueIndex) => {
if (!selected) {
return;
}
const current = newData[rowIndex][valueIndex - 1];
switch (operation) {
case Operations.INC:
if (isNotGreater(rowIndex, current)) {
newData[rowIndex][valueIndex - 1] += 1;
}
break;
case Operations.DEC:
if (isNotLess(rowIndex, current)) {
newData[rowIndex][valueIndex - 1] -= 1;
}
break;
case Operations.REPLACE:
if (isX(rowIndex) && value > xMax) {
newData[rowIndex][valueIndex - 1] = xMax;
break;
}
if (isX(rowIndex) && value < xMin) {
newData[rowIndex][valueIndex - 1] = xMin;
break;
}
if (isY(rowIndex) && value < yMin) {
newData[rowIndex][valueIndex - 1] = yMin;
break;
}
if (isY(rowIndex) && value > yMax) {
newData[rowIndex][valueIndex - 1] = yMax;
break;
}
newData[rowIndex][valueIndex - 1] = value;
break;
default:
break;
}
});
});
return [...newData];
}, [xMax, xMin, yMax, yMin]);
const oneModalOk = () => {
setData(modifyData(Operations.REPLACE, cellsRef.current, dataRef.current, modalValue));
setIsModalVisible(false);
setModalValue(undefined);
};
const onModalCancel = () => {
setIsModalVisible(false);
setModalValue(undefined);
};
const resetCells = () => setCells(generateCells());
const increment = () => setData(modifyData(Operations.INC, cellsRef.current, dataRef.current));
const decrement = () => setData(modifyData(Operations.DEC, cellsRef.current, dataRef.current));
const replace = () => {
// don't show modal when no cell is selected
if (cellsRef.current.flat().find((val) => val === true)) {
setModalValue(undefined);
setIsModalVisible(true);
setInterval(() => modalInputRef.current?.focus(), 1);
}
};
useEffect(() => {
const keyboardListener = (e: KeyboardEvent) => {
if (isIncrement(e)) {
increment();
}
if (isDecrement(e)) {
decrement();
}
if (isReplace(e)) {
e.preventDefault();
replace();
}
if (isEscape(e)) {
resetCells();
}
};
document.addEventListener('keydown', keyboardListener);
return () => {
document.removeEventListener('keydown', keyboardListener);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const colorHsl = (min: number, max: number, value: number): HslType => {
const saturation = 60;
const lightness = 40;
const coldDeg = 220;
const hotDeg = 0;
const remap = (x: number, inMin: number, inMax: number, outMin: number, outMax: number) => (x - inMin) * (outMax - outMin) / (inMax - inMin) + outMin;
let hue = remap(value, min, max, coldDeg, hotDeg);
// fallback to cold temp
if (Number.isNaN(hue)) {
hue = coldDeg;
}
return [hue, saturation, lightness];
};
const renderRow = (axis: AxisType, input: number[]) => input
.map((value, index) => {
const [hue, sat, light] = colorHsl(Math.min(...input), Math.max(...input), value);
return (
<td
className="value"
key={`${axis}-${index}-${value}-${hue}${sat}${light}`}
style={{ backgroundColor: `hsl(${hue}, ${sat}%, ${light}%)` }}
>
{`${value}`}
</td>
);
});
return (
<>
<div className="table table-2d">
<Popover
visible={cells.flat().find((val) => val === true) === true}
content={
<Space>
<Button onClick={decrement} icon={<MinusCircleOutlined />} />
<Button onClick={increment} icon={<PlusCircleOutlined />} />
<Button onClick={replace} icon={<EditOutlined />} />
</Space>
}
>
<TableDragSelect
key={name}
value={cells}
onChange={setCells}
>
<tr>
<td {...titleProps} className="title-curve" key={yLabel}>{`${yLabel} (${yUnits})`}</td>
{renderRow('y', data[0])}
</tr>
<tr>
<td {...titleProps} className="title-curve" key={xLabel}>{`${xLabel} (${xUnits})`}</td>
{renderRow('x', data[1])}
</tr>
</TableDragSelect>
</Popover>
</div>
<Modal
title="Set cell values"
visible={isModalVisible}
onOk={oneModalOk}
onCancel={onModalCancel}
centered
forceRender
>
<InputNumber
ref={modalInputRef}
value={modalValue}
onChange={(val) => setModalValue(Number(val))}
onPressEnter={oneModalOk}
style={{ width: '20%' }}
/>
</Modal>
</>
);
};
export default Table;

View File

@ -0,0 +1,27 @@
import {
Typography,
Alert,
} from 'antd';
const TextField = ({ title }: { title: string }) => {
const types: { [char: string]: 'info' | 'warning' } = {
'#': 'info',
'!': 'warning',
};
const type = types[title.charAt(0)];
return (
<Typography.Paragraph
style={{ display: 'flex', justifyContent: 'center' }}
>
{type ? <Alert
message={type ? title.substring(1) : title}
type={type}
showIcon
style={{ width: '100%', maxWidth: 700 }}
/> : <Typography.Text type="secondary">{title}</Typography.Text>}
</Typography.Paragraph>
);
};
export default TextField;

152
src/components/SideBar.tsx Normal file
View File

@ -0,0 +1,152 @@
import { Layout, Menu, Skeleton } from 'antd';
import { connect } from 'react-redux';
import {
generatePath,
Link,
} from 'react-router-dom';
import PerfectScrollbar from 'react-perfect-scrollbar';
import { useCallback } from 'react';
import store from '../store';
import {
AppState,
UIState,
} from '../types/state';
import {
Config as ConfigType,
Menus as MenusType,
} from '../types/config';
import {
Tune as TuneType,
} from '../types/tune';
import Icon from './SideBar/Icon';
import { evaluateExpression } from '../utils/tune/expression';
import { Routes } from '../routes';
const { Sider } = Layout;
const { SubMenu } = Menu;
export interface DialogMatchedPathType {
url: string;
params: {
category: string;
dialog: string;
};
}
const mapStateToProps = (state: AppState) => ({
config: state.config,
tune: state.tune,
ui: state.ui,
});
const SKIP_MENUS = [
'help',
'hardwareTesting',
'3dTuningMaps',
'dataLogging',
'tools',
];
const SKIP_SUB_MENUS = [
'settings/gaugeLimits',
'settings/io_summary',
'tuning/std_realtime',
];
const SideBar = ({
config,
tune,
ui,
matchedPath,
}: {
config: ConfigType,
tune: TuneType,
ui: UIState,
matchedPath: DialogMatchedPathType,
}) => {
const sidebarWidth = 250;
const siderProps = {
width: sidebarWidth,
collapsible: true,
breakpoint: 'xl',
collapsed: ui.sidebarCollapsed,
onCollapse: (collapsed: boolean) => store.dispatch({ type: 'ui/sidebarCollapsed', payload: collapsed }),
} as any;
const checkCondition = useCallback((condition: string) => evaluateExpression(condition, tune.constants, config), [tune.constants, config]);
const buildLink = useCallback((main: string, sub: string) => generatePath(Routes.DIALOG, {
category: main,
dialog: sub,
}), []);
const menusList = useCallback((menus: MenusType) => (
Object.keys(menus).map((menuName: string) => {
if (SKIP_MENUS.includes(menuName)) {
return null;
}
return (
<SubMenu
key={`/${menuName}`}
icon={<Icon name={menuName} />}
title={menus[menuName].title}
>
{Object.keys(menus[menuName].subMenus).map((subMenuName: string) => {
if (subMenuName === 'std_separator') {
return <Menu.Divider key={buildLink(menuName, subMenuName)} />;
}
if (SKIP_SUB_MENUS.includes(`${menuName}/${subMenuName}`)) {
return null;
}
const subMenu = menus[menuName].subMenus[subMenuName];
let enabled = true;
// TODO: optimize this!
if (subMenu.condition) {
enabled = checkCondition(subMenu.condition);
}
return (<Menu.Item
key={buildLink(menuName, subMenuName)}
icon={<Icon name={subMenuName} />}
disabled={!enabled}
>
<Link to={buildLink(menuName, subMenuName)}>
{subMenu.title}
</Link>
</Menu.Item>);
})}
</SubMenu>
);
})
), [buildLink, checkCondition]);
if (!config || !config.constants) {
return (
<Sider {...siderProps} className="app-sidebar" >
<div style={{ paddingLeft: 10 }}>
<Skeleton /><Skeleton /><Skeleton /><Skeleton /><Skeleton />
</div>
</Sider>
);
}
return (
<Sider {...siderProps} className="app-sidebar">
<PerfectScrollbar options={{ suppressScrollX: true }}>
<Menu
defaultSelectedKeys={[matchedPath.url]}
defaultOpenKeys={[`/${matchedPath.params.category}`]}
mode="inline"
style={{ height: '100%' }}
key={matchedPath.url}
>
{Object.keys(tune.constants).length && menusList(config.menus)}
</Menu>
</PerfectScrollbar>
</Sider>
);
};
export default connect(mapStateToProps)(SideBar);

View File

@ -0,0 +1,127 @@
import {
ApartmentOutlined,
ApiOutlined,
CarOutlined,
ControlOutlined,
DashboardOutlined,
DotChartOutlined,
ExperimentOutlined,
FieldTimeOutlined,
FireOutlined,
FundOutlined,
FundProjectionScreenOutlined,
PoweroffOutlined,
QuestionCircleOutlined,
RocketOutlined,
SafetyOutlined,
SettingOutlined,
TableOutlined,
ThunderboltOutlined,
ToolOutlined,
UnorderedListOutlined,
UpCircleOutlined,
} from '@ant-design/icons';
const Icon = ({ name }: { name: string }) => {
switch (name) {
// main menu
case 'settings':
return <ControlOutlined />;
case 'tuning':
return <CarOutlined />;
case 'spark':
return <FireOutlined />;
case 'startupIdle':
return <PoweroffOutlined />;
case 'accessories':
return <ApiOutlined />;
case 'tools':
return <ToolOutlined />;
case '3dTuningMaps':
return <DotChartOutlined />;
case 'hardwareTesting':
return <ExperimentOutlined />;
case 'help':
return <QuestionCircleOutlined />;
// common, 2D table
case 'injChars':
case 'airdensity_curve':
case 'baroFuel_curve':
case 'dwellCompensation':
case 'iatRetard':
case 'clt_advance_curve':
case 'rotary_ignition':
case 'accelEnrichments':
case 'flexFueling':
case 'dwell_correction_curve':
case 'iat_retard_curve':
case 'crankPW':
case 'primePW':
case 'warmup':
case 'ASE':
case 'iacClosedLoop_curve':
case 'iacPwm_curve':
case 'iacPwmCrank_curve':
case 'iacStep_curve':
case 'iacStepCrank_curve':
case 'idleAdvanceSettings':
return <FundOutlined />;
// common 3D table / map
case 'sparkTbl':
case 'veTableDialog':
case 'afrTable1Tbl':
case 'fuelTable2Dialog':
case 'sparkTable2Dialog':
case 'inj_trimad':
case 'stagingTableDialog':
case 'stagedInjection':
case 'fuelTemp_curve':
case 'boostLoad':
return <TableOutlined />;
// rest
case 'triggerSettings':
return <SettingOutlined />;
case 'reset_control':
return <PoweroffOutlined />;
case 'engine_constants':
return <ControlOutlined />;
case 'io_summary':
return <UnorderedListOutlined />;
case 'prgm_out_config':
return <ApartmentOutlined />;
case 'std_realtime':
return <FundProjectionScreenOutlined />;
case 'sparkSettings':
return <FireOutlined />;
case 'dwellSettings':
return <FieldTimeOutlined />;
case 'RevLimiterS':
return <SafetyOutlined />;
case 'idleUpSettings':
return <UpCircleOutlined />;
case 'LaunchControl':
return <ThunderboltOutlined />;
case 'NitrousControl':
return <RocketOutlined />;
case 'vssSettings':
return <SettingOutlined />;
case 'Auxin_config':
return <ApiOutlined />;
case 'tacho':
case 'pressureSensors':
return <DashboardOutlined />;
// default / not found
default:
// return null;
// return <BarsOutlined />;
// return <SettingOutlined />;
// return <MenuOutlined />;
return <ControlOutlined />;
}
};
export default Icon;

View File

@ -0,0 +1,39 @@
import { Layout, Space, Row, Col } from 'antd';
import { CarOutlined, InfoCircleOutlined } from '@ant-design/icons';
import { connect } from 'react-redux';
import { AppState, ConfigState, StatusState } from '../types/state';
const { Footer } = Layout;
const mapStateToProps = (state: AppState) => ({
status: state.status,
config: state.config,
});
const firmware = (signature: string) => (
<Space>
<InfoCircleOutlined />{signature}
</Space>
);
const StatusBar = ({ status, config }: { status: StatusState, config: ConfigState }) => (
<Footer className="app-status-bar">
<Row>
<Col span={8}>
<Space>
<CarOutlined />
default
</Space>
</Col>
<Col span={8} style={{ textAlign: 'center' }}>
{config.megaTune && firmware(config.megaTune.signature)}
</Col>
<Col span={8} style={{ textAlign: 'right' }}>
{status.text}
</Col>
</Row>
</Footer>
);
export default connect(mapStateToProps)(StatusBar);

187
src/components/TopBar.tsx Normal file
View File

@ -0,0 +1,187 @@
import {
matchPath,
useLocation,
useHistory,
} from 'react-router';
import {
Layout,
Space,
Button,
Input,
Row,
Col,
Tooltip,
Grid,
Menu,
Dropdown,
Typography,
Radio,
} from 'antd';
import {
UserOutlined,
ShareAltOutlined,
CloudUploadOutlined,
CloudDownloadOutlined,
SettingOutlined,
LoginOutlined,
LineChartOutlined,
SlidersOutlined,
GithubOutlined,
FileExcelOutlined,
FileTextOutlined,
FileZipOutlined,
SaveOutlined,
DesktopOutlined,
GlobalOutlined,
LinkOutlined,
DownOutlined,
SearchOutlined,
} from '@ant-design/icons';
import {
useEffect,
useMemo,
useRef,
} from 'react';
import store from '../store';
import { isMac } from '../utils/env';
import {
isCommand,
isToggleSidebar,
} from '../utils/keyboard/shortcuts';
import { Routes } from '../routes';
const { Header } = Layout;
const { useBreakpoint } = Grid;
const { SubMenu } = Menu;
const TopBar = () => {
const { sm } = useBreakpoint();
const { pathname } = useLocation();
const history = useHistory();
const matchedTabPath = useMemo(() => matchPath(pathname, { path: Routes.TAB }), [pathname]);
const userMenu = (
<Menu>
<Menu.Item disabled icon={<LoginOutlined />}>
Login / Sign-up
</Menu.Item>
<Menu.Item icon={<GithubOutlined />}>
<a href="https://github.com/speedy-tuner/speedy-tuner-cloud" target="__blank" rel="noopener noreferrer">
GitHub
</a>
</Menu.Item>
<Menu.Divider />
<Menu.Item disabled icon={<SettingOutlined />}>
Preferences
</Menu.Item>
</Menu>
);
const shareMenu = (
<Menu>
<Menu.Item disabled icon={<CloudUploadOutlined />}>
Upload
</Menu.Item>
<SubMenu title="Download" icon={<CloudDownloadOutlined />}>
<SubMenu title="Tune" icon={<SlidersOutlined />}>
<Menu.Item icon={<SaveOutlined />}>
<a href="/tunes/202103.msq" target="__blank" rel="noopener noreferrer">
Download
</a>
</Menu.Item>
<Menu.Item disabled icon={<DesktopOutlined />}>
Open in app
</Menu.Item>
</SubMenu>
<SubMenu title="Logs" icon={<LineChartOutlined />}>
<Menu.Item disabled icon={<FileZipOutlined />}>
MLG
</Menu.Item>
<Menu.Item disabled icon={<FileTextOutlined />}>
MSL
</Menu.Item>
<Menu.Item disabled icon={<FileExcelOutlined />}>
CSV
</Menu.Item>
</SubMenu>
</SubMenu>
<Menu.Item disabled icon={<LinkOutlined />}>
Create link
</Menu.Item>
<Menu.Item disabled icon={<GlobalOutlined />}>
Publish to Hub
</Menu.Item>
</Menu>
);
const searchInput = useRef<Input | null>(null);
const handleGlobalKeyboard = (e: KeyboardEvent) => {
if (isCommand(e)) {
if (searchInput) {
e.preventDefault();
searchInput.current!.focus();
}
}
if (isToggleSidebar(e)) {
e.preventDefault();
store.dispatch({ type: 'ui/toggleSidebar' });
}
};
useEffect(() => {
document.addEventListener('keydown', handleGlobalKeyboard);
return () => document.removeEventListener('keydown', handleGlobalKeyboard);
});
return (
<Header className="app-top-bar">
<Row>
<Col span={0} md={8} sm={0} />
<Col span={12} md={8} sm={16} style={{ textAlign: 'center' }}>
<Radio.Group
key={pathname}
defaultValue={matchedTabPath?.url}
optionType="button"
buttonStyle="solid"
onChange={(e) => history.push(e.target.value)}
>
<Radio.Button value={Routes.TUNE}>Tune</Radio.Button>
<Radio.Button value={Routes.LOG}>Log</Radio.Button>
<Radio.Button value={Routes.DIAGNOSE}>Diagnose</Radio.Button>
</Radio.Group>
</Col>
<Col span={12} md={8} sm={8} style={{ textAlign: 'right' }}>
<Space className="electron-not-draggable">
<Tooltip title={
<>
<Typography.Text keyboard>{isMac ? '⌘' : 'CTRL'}</Typography.Text>
<Typography.Text keyboard>P</Typography.Text>
</>
}>
{sm && <Button icon={<SearchOutlined />} />}
</Tooltip>
<Dropdown
overlay={shareMenu}
placement="bottomCenter"
>
<Button icon={<ShareAltOutlined />}>
<DownOutlined />
</Button>
</Dropdown>
<Dropdown
overlay={userMenu}
placement="bottomCenter"
>
<Button icon={<UserOutlined />}>
<DownOutlined />
</Button>
</Dropdown>
</Space>
</Col>
</Row>
</Header>
);
};
export default TopBar;

17
src/data/constants.ts Normal file
View File

@ -0,0 +1,17 @@
import {
ConstantTypes,
ScalarConstant,
} from '../types/config';
// eslint-disable-next-line import/prefer-default-export
export const divider: ScalarConstant = {
type: ConstantTypes.SCALAR,
size: 'U08',
offset: 25,
units: '',
scale: 1,
transform: 0,
min: 1,
max: 8,
digits: 0,
};

16
src/data/help.ts Normal file
View File

@ -0,0 +1,16 @@
import {
Help as HelpType,
} from '../types/config';
export const help: HelpType = {
reqFuel: 'The base reference pulse width required to achieve stoichiometric at 100% VE and a manifold absolute pressure (MAP) of 100kPa using current settings.',
algorithm: 'Fueling calculation algorithm',
alternate: 'Whether or not the injectors should be fired at the same time.\nThis setting is ignored when Sequential is selected below, however it will still affect req_fuel value.',
twoStroke: 'Four-stroke (most engines) / Two-stroke',
nCylinders: 'Cylinder count',
injType: 'Port Injection (one injector for each cylinder) or Throttle Body (injectors shared by cylinders)',
nInjectors: 'Number of primary injectors',
engineType: 'Engines with an equal number of degrees between all firings (This is most engines) should select Even fire.\nSome 2 and 6 cylinder engines are Odd fire however',
};
export default help;

View File

@ -0,0 +1,58 @@
import {
Dialogs as DialogsType,
} from '../types/config';
const standardDialogs: DialogsType = {
std_injection: {
title: 'Engine Constants',
help: 'https://wiki.speeduino.com/en/configuration/Engine_Constants',
layout: '',
panels: {
engine_constants_southwest: {
layout: '',
fields: [], // overridden by ini file
condition: '',
},
},
fields: [
{
name: 'reqFuel',
title: 'Required Fuel',
},
{
name: 'algorithm',
title: 'Control Algorithm',
},
{
name: 'divider',
title: 'Squirts Per Engine Cycle',
},
{
name: 'alternate',
title: 'Injector Staging',
},
{
name: 'twoStroke',
title: 'Engine Stroke',
},
{
name: 'nCylinders',
title: 'Number of Cylinders',
},
{
name: 'injType',
title: 'Injector Port Type',
},
{
name: 'nInjectors',
title: 'Number of Injectors',
},
{
name: 'engineType',
title: 'Engine Type',
},
],
},
};
export default standardDialogs;

62
src/electron.ts Normal file
View File

@ -0,0 +1,62 @@
import { app, BrowserWindow } from 'electron';
import path from 'path';
const isDev = !app.isPackaged;
let main: BrowserWindow;
// let splash: BrowserWindow | null;
// const createSplash = () => {
// splash = new BrowserWindow({
// width: 200,
// height: 400,
// frame: false,
// transparent: true,
// resizable: false,
// });
// splash.loadURL(`file://${path.join(__dirname, '../public/splash.html')}`);
// splash.on('closed', () => {
// splash = null;
// });
// splash.webContents.on('did-finish-load', () => {
// splash.show();
// });
// };
function createMain() {
main = new BrowserWindow({
title: 'SpeedyTuner',
width: 1400,
height: 1000,
show: false,
titleBarStyle: 'hiddenInset',
backgroundColor: '#222629',
});
main.setMenuBarVisibility(false);
const startURL = isDev
? 'http://localhost:3000'
: `file://${path.join(__dirname, '../build/index.html')}`;
main.loadURL(startURL);
main.on('closed', () => {
main = null as any;
});
main.once('ready-to-show', () => main.show());
// main.webContents.on('did-finish-load', () => {
// if (splash) {
// splash.close();
// }
// main.show();
// });
}
app.on('ready', () => {
// createSplash();
createMain();
});

14
src/index.tsx Normal file
View File

@ -0,0 +1,14 @@
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { HashRouter } from 'react-router-dom';
import App from './App';
import store from './store';
ReactDOM.render(
<HashRouter>
<Provider store={store}>
<App />
</Provider>
</HashRouter>,
document.getElementById('root'),
);

1124
src/parser/ini.ts Normal file

File diff suppressed because it is too large Load Diff

1
src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

9
src/routes.ts Normal file
View File

@ -0,0 +1,9 @@
// eslint-disable-next-line import/prefer-default-export
export enum Routes {
ROOT = '/',
TAB = '/:tab',
TUNE = '/tune',
DIALOG = '/tune/:category/:dialog',
LOG = '/log',
DIAGNOSE = '/diagnose',
}

57
src/store.ts Normal file
View File

@ -0,0 +1,57 @@
/* eslint-disable no-param-reassign */
import { configureStore, createAction, createReducer } from '@reduxjs/toolkit';
import * as Types from './types/state';
// tune and config
const updateTune = createAction<Types.UpdateTunePayload>('tune/update');
const loadTune = createAction<Types.TuneState>('tune/load');
const loadConfig = createAction<Types.ConfigState>('config/load');
// status bar
const setStatus = createAction<string>('status');
// ui
const setSidebarCollapsed = createAction<boolean>('ui/sidebarCollapsed');
const toggleSidebar = createAction('ui/toggleSidebar');
const initialState: Types.AppState = {
tune: {
constants: {},
},
config: {} as any,
ui: {
sidebarCollapsed: false,
},
status: {
text: null,
},
};
const rootReducer = createReducer(initialState, (builder) => {
builder
.addCase(loadConfig, (state: Types.AppState, action) => {
state.config = action.payload;
})
.addCase(loadTune, (state: Types.AppState, action) => {
state.tune = action.payload;
})
.addCase(updateTune, (state: Types.AppState, action) => {
state.tune.constants[action.payload.name].value = action.payload.value;
})
.addCase(setSidebarCollapsed, (state: Types.AppState, action) => {
state.ui.sidebarCollapsed = action.payload;
})
.addCase(toggleSidebar, (state: Types.AppState) => {
state.ui.sidebarCollapsed = !state.ui.sidebarCollapsed;
})
.addCase(setStatus, (state: Types.AppState, action) => {
state.status.text = action.payload;
});
});
const store = configureStore({
reducer: rootReducer,
});
export default store;

16
src/themes/ant.less Normal file
View File

@ -0,0 +1,16 @@
.ant-menu {
border-color: transparent;
}
.ant-layout-sider-trigger {
bottom: @layout-footer-height;
// .border-right;
}
.ant-empty {
opacity: 0.6;
}
.ant-popover-content {
box-shadow: @box-shadow;
}

14
src/themes/common.less Normal file
View File

@ -0,0 +1,14 @@
@primary-color: #126ec3;
@text-light: #fff;
@border-radius-base: 5px;
@layout-header-padding: 0 15px;
@layout-header-height: 45px;
@layout-footer-padding: 2px 10px;
@layout-footer-height: 24px;
@bars-z-index: 5;
@zindex-modal: 1080;
@box-shadow: 0 3px 6px -4px rgb(0 0 0 e('/') 12%), 0 6px 16px 0 rgb(0 0 0 e('/') 8%), 0 9px 28px 8px rgb(0 0 0 e('/') 5%);

102
src/themes/dark.less Normal file
View File

@ -0,0 +1,102 @@
// darker
@main: #222629;
@main-dark: #191C1E;
@main-light: #2E3338;
@text: #ddd;
// lighter
// @main: #272c30;
// @main-dark: #212527;
// @main-light: #394046;
@text-dark: #222;
@text-disabled: #999;
// First
// @primary-color: #26547C;
@info: #FFD166;
@success: #06D6A0;
@error: #EF476F;
@warn: #E55934;
// Second
// @primary-color: #004E64;
// @info: #F2C14E;
// @success: #499F68;
// @error: #DA2C38;
// @warn: #ED5A31;
// new ant
@primary-1: @primary-color;
@processing-color: @info;
@info-color: @info;
@success-color: @success;
@error-color: @error;
@highlight-color: @error;
@warning-color: @warn;
@text-color: @text;
@text-color-dark: @text-dark;
@disabled-color: @text-disabled;
@text-color-secondary: @text-disabled;
@heading-color: @text-light;
@heading-color-dark: @text-dark;
@link-color: lighten(@primary-color, 10%);
@body-background: @main;
@component-background: @main-light;
@item-active-bg: @primary-color;
@item-hover-bg: @primary-color;
@menu-highlight-color: @text;
@table-row-hover-bg: darken(@primary-color, 10%);
@descriptions-bg: @main-light;
@menu-bg: @main;
@border-color-base: @main-dark;
@border-color-split: lighten(@main-light, 2%);
@background-color-light: @main-light;
@background-color-base: @main-light;
@checkbox-check-color: @main-light;
@layout-body-background: @main;
// Alert
@alert-success-border-color: @success;
@alert-success-bg-color: darken(@success, 20%);
@alert-success-icon-color: @success;
@alert-info-border-color: @info;
@alert-info-bg-color: darken(@info, 30%);
@alert-info-icon-color: @info;
@alert-warning-border-color: @warn;
@alert-warning-bg-color: darken(@warn, 20%);
@alert-warning-icon-color: @warn;
@alert-error-border-color: @error;
@alert-error-bg-color: darken(@error, 20%);
@alert-error-icon-color: @error;
// Divider
@divider-color: @border-color-split;
// Header
@layout-header-background: @main;
// Side
@layout-sider-background: @main;
@layout-trigger-background: @main;
@layout-trigger-color: @text-color;
// Tooltip
@tooltip-bg: rgb(46, 51, 56, 0.8);
@zindex-tooltip: 1;
// Modal
@modal-header-bg: @main;
@modal-content-bg: @main;
// Radio buttons
@radio-solid-checked-color: @white;

12
src/themes/light.less Normal file
View File

@ -0,0 +1,12 @@
@main: @white;
@text: @white;
@main-light: #181818;
@body-background: @main;
@menu-bg: @main;
@layout-body-background: @main;
@layout-header-background: @main;
@layout-sider-background: @main;
@layout-trigger-background: @main;
@layout-trigger-color: @text-color;

200
src/types/config.ts Normal file
View File

@ -0,0 +1,200 @@
export enum Switches {
YES = 'Yes',
NO = 'No',
ON = 'On',
OFF = 'Off',
}
export interface Field {
title: string;
name: string;
condition?: string;
}
export interface Panel {
layout: string;
panels?: {
[name: string]: Panel;
};
fields?: Field[];
condition?: string;
}
export interface Dialog {
title: string;
layout: string;
help?: string;
panels: {
[name: string]: Panel;
};
fields: Field[];
// TODO:
// settingSelector
// commandButton
}
export interface Dialogs {
[name: string]: Dialog;
}
export interface SubMenu {
title: string;
page: number;
condition: string;
}
export interface Menu {
title: string;
subMenus: {
[name: string]: SubMenu;
};
}
export interface Menus {
[name: string]: Menu;
}
export interface ArrayShape {
columns: number;
rows: number;
}
export enum ConstantTypes {
SCALAR = 'scalar',
BITS = 'bits',
ARRAY = 'array',
STRING = 'string',
}
export type ConstantSize = 'U08' | 'S08' | 'U16' | 'S16' | 'U32' | 'S32' | 'S64' | 'F32' | 'ASCII';
export interface ScalarConstant {
type: ConstantTypes.SCALAR;
size: ConstantSize;
offset?: number;
units: string;
scale: number | string;
transform: number | string;
min: number | string;
max: number | string;
digits: number;
}
export interface BitsConstant {
type: ConstantTypes.BITS;
size: ConstantSize;
offset?: number;
address: number[];
values: string[];
}
export interface ArrayConstant {
type: ConstantTypes.ARRAY;
size: ConstantSize;
offset?: number;
shape: ArrayShape;
units: string;
scale: number | string;
transform: number | string;
min: number | string;
max: number | string;
digits: number;
}
export interface StringConstant {
type: ConstantTypes.SCALAR;
size: ConstantSize;
length: number;
}
export interface OutputChannel {
type: ConstantTypes.SCALAR | ConstantTypes.BITS;
size: ConstantSize;
offset: number;
units: string;
scale: number | string;
transform: number | string;
}
export interface SimpleConstant {
value: string;
}
export type Constant = ScalarConstant | BitsConstant | ArrayConstant | StringConstant;
export interface Constants {
[name: string]: Constant;
}
export interface Page {
number: number;
size: number;
data: Constants;
}
export interface Help {
[key: string]: string;
}
export interface Curve {
title: string;
labels: string[];
xAxis: number[];
yAxis: number[];
xBins: string[];
yBins: string[];
size: number[];
gauge?: string;
}
export interface Table {
map: string;
title: string;
page: number;
help?: string;
xBins: string[];
yBins: string[];
xyLabels: string[];
zBins: string[];
gridHeight: number;
gridOrient: number[];
upDownLabel: string[];
}
export interface OutputChannels {
[name: string]: OutputChannel | SimpleConstant;
}
export interface Config {
[key: string]: any;
megaTune: {
[key: string]: any;
signature: string;
MTversion: number;
queryCommand: string;
versionInfo: string;
};
tunerStudio: {
[key: string]: any;
iniSpecVersion: number;
};
pcVariables: Constants;
constants: {
pages: Page[];
};
defines: {
[name: string]: string[];
};
menus: Menus;
help: Help;
dialogs: {
[name: string]: Dialog;
};
curves: {
[name: string]: Curve;
};
tables: {
[name: string]: Table;
};
outputChannels: OutputChannels;
}

26
src/types/state.ts Normal file
View File

@ -0,0 +1,26 @@
import { Config } from './config';
import { Tune } from './tune';
export interface ConfigState extends Config {}
export interface TuneState extends Tune {}
export interface UIState {
sidebarCollapsed: boolean;
}
export interface StatusState {
text: string | null;
}
export interface AppState {
tune: TuneState;
config: ConfigState;
ui: UIState;
status: StatusState;
}
export interface UpdateTunePayload {
name: string;
value: string | number;
}

12
src/types/tune.ts Normal file
View File

@ -0,0 +1,12 @@
export interface Constant {
units?: string;
value: string | number;
}
export interface Constants {
[name: string]: Constant;
}
export interface Tune {
constants: Constants;
}

82
src/utils/api.ts Normal file
View File

@ -0,0 +1,82 @@
import store from '../store';
import {
Config as ConfigType,
} from '../types/config';
import stdDialogs from '../data/standardDialogs';
import help from '../data/help';
import { divider } from '../data/constants';
export const loadAll = () => {
const started = new Date();
// const version = 202012;
const version = 202103;
fetch(`./tunes/${version}.json`)
.then((response) => response.json())
.then((json: ConfigType) => {
fetch(`./tunes/${version}.msq`)
.then((response) => response.text())
.then((tune) => {
const xml = (new DOMParser()).parseFromString(tune, 'text/xml');
const xmlPages = xml.getElementsByTagName('page');
const constants: any = {};
Object.keys(xmlPages).forEach((key: any) => {
const page = xmlPages[key];
const pageElements = page.children;
Object.keys(pageElements).forEach((item: any) => {
const element = pageElements[item];
if (element.tagName === 'constant') {
const attributes: any = {};
Object.keys(element.attributes).forEach((attr: any) => {
attributes[element.attributes[attr].name] = element.attributes[attr].value;
});
const val = element.textContent?.replace(/"/g, '').toString();
constants[attributes.name] = {
value: Number.isNaN(Number(val)) ? val : Number(val),
// digits: Number.isNaN(Number(attributes.digits)) ? attributes.digits : Number(attributes.digits),
// cols: Number.isNaN(Number(attributes.cols)) ? attributes.cols : Number(attributes.cols),
// rows: Number.isNaN(Number(attributes.rows)) ? attributes.rows : Number(attributes.rows),
units: attributes.units ?? null,
};
}
});
});
if (!Object.keys(constants).length) {
console.error('Invalid tune');
}
const config = json;
// override / merge standard dialogs, constants and help
config.dialogs = {
...config.dialogs,
...stdDialogs,
};
config.help = {
...config.help,
...help,
};
config.constants.pages[0].data.divider = divider;
const loadingTimeInfo = `Tune loaded in ${(new Date().getTime() - started.getTime())}ms`;
console.log(loadingTimeInfo);
store.dispatch({ type: 'config/load', payload: config });
store.dispatch({ type: 'tune/load', payload: { constants } });
store.dispatch({
type: 'status',
payload: loadingTimeInfo,
});
});
});
};
export const test = () => 'test';

16
src/utils/config/find.ts Normal file
View File

@ -0,0 +1,16 @@
import {
Config as ConfigType,
Page as PageType,
Constant,
} from '../../types/config';
export const findOnPage = (config: ConfigType, fieldName: string): Constant => {
const foundPage = config
.constants
.pages
.find((page: PageType) => fieldName in page.data) || { data: {} } as PageType;
return foundPage.data[fieldName];
};
export const todo = '';

4
src/utils/env.ts Normal file
View File

@ -0,0 +1,4 @@
export const isWeb = process.env.APP_PLATFORM === 'web';
export const isDesktop = process.env.APP_PLATFORM === 'desktop';
export const isMac = `${window.navigator.platform}`.includes('Mac');
export const platform = `${window.navigator.platform}`;

View File

@ -0,0 +1,28 @@
import React from 'react';
type KeyEvent = KeyboardEvent | React.KeyboardEvent<HTMLInputElement>;
enum Keys {
INCREMENT = '.',
DECREMENT = ',',
COMMAND = 'p',
SIDEBAR = '\\',
ESCAPE = 'Escape',
REPLACE = '=',
}
const digits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
export const isCommand = (e: KeyEvent) => (e.metaKey || e.ctrlKey) && e.key === Keys.COMMAND;
export const isToggleSidebar = (e: KeyEvent) => (e.metaKey || e.ctrlKey) && e.key === Keys.SIDEBAR;
export const isIncrement = (e: KeyEvent) => e.key === Keys.INCREMENT;
export const isDecrement = (e: KeyEvent) => e.key === Keys.DECREMENT;
export const isReplace = (e: KeyEvent) => e.key === Keys.REPLACE;
export const isEscape = (e: KeyEvent) => e.key === Keys.ESCAPE;
export const useDigits = (e: KeyEvent): [boolean, number] => [digits.includes(Number(e.key)), Number(e.key)];

3
src/utils/storage.ts Normal file
View File

@ -0,0 +1,3 @@
export const storageGet = (key: string) => window.localStorage.getItem(key);
export const storageSet = (key: string, value: string) => window.localStorage.setItem(key, value);

8
src/utils/string.ts Normal file
View File

@ -0,0 +1,8 @@
export const camelToSnakeCase = (str: string) => str
.replace(/[A-Z]/g, (letter: string) => `_${letter.toLowerCase()}`);
export const camelToUrlCase = (str: string) => str
.replace(/[A-Z]/g, (letter: string) => `-${letter.toLowerCase()}`);
export const snakeToCamelCase = (str: string) => str
.replace(/([-_]\w)/g, (g) => g[1].toUpperCase());

View File

@ -0,0 +1,113 @@
import {
Constants as TuneConstantsType,
} from '../../types/tune';
import {
Config as ConfigType,
OutputChannels as OutputChannelsType,
Page as ConfigPageType,
SimpleConstant as SimpleConstantType,
} from '../../types/config';
export const isExpression = (val: any) => `${val}`.startsWith('{') && `${val}`.endsWith('}');
export const stripExpression = (val: string) => val.slice(1).slice(0, -1).trim();
export const isNumber = (val: any) => !Number.isNaN(Number(val));
// export const isNumber
// ochGetCommand
export const prepareConstDeclarations = (tuneConstants: TuneConstantsType, configPages: ConfigPageType[]) => (
Object.keys(tuneConstants).map((constName: string) => {
let val = tuneConstants[constName].value;
// TODO: skip 2D and 3D maps for now
if (typeof val === 'string' && val.includes('\n')) {
return null;
}
// TODO: check whether we can limit this to a single page
const constant = configPages
.find((page: ConfigPageType) => constName in page.data)
?.data[constName];
// we need array index instead of a display value
if (constant?.type === 'bits') {
val = (constant.values as string[]).indexOf(`${val}`);
}
// escape string values
if (typeof val === 'string') {
val = `'${val}'`;
}
return `const ${constName} = ${val};`;
})
);
const prepareChannelsDeclarations = (configOutputChannels: OutputChannelsType) => (
Object.keys(configOutputChannels).map((channelName: string) => {
const current = configOutputChannels[channelName] as SimpleConstantType;
if (!current.value) {
return null;
}
let val = current.value;
if (isExpression(val)) {
val = stripExpression(val);
} else if (!isNumber(val)) {
val = `"${val}"`;
}
return `const ${channelName} = ${val};`;
}).filter((val) => val !== null)
);
export const evaluateExpression = (expression: string, tuneConstants: TuneConstantsType, config: ConfigType) => {
const constDeclarations = prepareConstDeclarations(tuneConstants, config.constants.pages);
const channelsDeclarations = prepareChannelsDeclarations(config.outputChannels);
try {
// TODO: strip eval from `command` etc
// https://www.electronjs.org/docs/tutorial/security
// eslint-disable-next-line no-eval
return eval(`
'use strict';
const arrayValue = (number, layout) => number;
const array = {
boardFuelOutputs: 4,
boardIgnOutputs: 4,
};
const coolantRaw = 21;
const iatRaw = 21;
const fuelTempRaw = 21;
const timeNow = new Date().getTime();
const secl = 0;
const tps = 0;
const rpm = 0;
const nSquirts = 1;
const boostCutFuel = 0;
const boostCutSpark = 0;
const afr = 14.7;
const afrTarget = 14.7;
const map = 0;
const loopsPerSecond = 0;
const batCorrection = 0;
const ASECurr = 0;
const baro = 0;
const vss = 0;
const CLIdleTarget = 0;
${constDeclarations.join('')}
${channelsDeclarations.join('')}
${stripExpression(expression)};
`);
} catch (error) {
console.info('Condition evaluation failed with:', error.message);
}
return undefined;
};

12
src/utils/tune/table.ts Normal file
View File

@ -0,0 +1,12 @@
export const parseXy = (value: string) => value
.trim()
.split('\n')
.map((val) => val.trim())
.filter((val) => val !== '')
.map(Number);
export const parseZ = (value: string) => value
.trim()
.split('\n')
.map((val) => val.trim().split(' ').map(Number))
.reverse();

17
tsconfig.custom.json Normal file
View File

@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"target": "ES2019",
"lib": [
"ES2019"
],
"compilerOptions": {
"module": "commonjs",
"noEmit": false
},
"files": [
"src/electron.ts"
],
"include": [
"src/parser"
]
}

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES6",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"removeComments": true,
"jsx": "react-jsx"
},
"include": [
"src"
],
}