├── .coderabbit.yaml ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── config.yml │ └── feature-request.yml ├── no-response.yml ├── pull_request_template.md ├── release.yml └── workflows │ ├── deploy-preview.yml │ └── stale.yaml ├── .gitignore ├── .idea ├── blackbox-log-viewer.iml ├── modules.xml └── vcs.xml ├── .jshintrc ├── .lintstagedrc ├── .nvmrc ├── .prettierignore ├── .sonarcloud.properties ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── dmg-background.psd ├── index.html ├── package.json ├── public ├── css │ └── jquery.nouislider.min.css ├── images │ ├── bf_icon.icns │ ├── bf_icon.ico │ ├── bf_icon_128.png │ ├── cf_logo_white.svg │ ├── dmg-background.png │ ├── dmg-background@2x.png │ ├── glyphs │ │ └── scrollwheel.svg │ ├── inav_logo_white.png │ ├── light-wide-2.svg │ ├── logo-background.png │ ├── logo.png │ ├── markers │ │ ├── craft.png │ │ └── home.png │ ├── motor_order │ │ ├── airplane.svg │ │ ├── atail_quad.svg │ │ ├── bicopter.svg │ │ ├── custom.svg │ │ ├── flying_wing.svg │ │ ├── hex_p.svg │ │ ├── hex_x.svg │ │ ├── octo_flat_p.svg │ │ ├── octo_flat_x.svg │ │ ├── octo_x8.svg │ │ ├── quad_p.svg │ │ ├── quad_x.svg │ │ ├── tri.svg │ │ ├── vtail_quad.svg │ │ ├── y4.svg │ │ └── y6.svg │ ├── pwa │ │ ├── bf_icon_128.png │ │ ├── bf_icon_192.png │ │ └── bf_icon_256.png │ └── stick_modes │ │ ├── Mode_1.png │ │ ├── Mode_2.png │ │ ├── Mode_3.png │ │ └── Mode_4.png └── js │ ├── FileSaver.js │ ├── complex.js │ ├── jquery-1.11.3.min.js │ ├── jquery-ui-1.11.4.min.js │ ├── jquery.ba-throttle-debounce.js │ ├── lodash.min.js │ ├── modernizr-2.6.2-respond-1.1.0.min.js │ ├── real.js │ ├── semver.js │ ├── three.js │ ├── three.min.js │ ├── webm-writer │ ├── ArrayBufferDataStream.js │ ├── BlobBuffer.js │ ├── Readme.md │ └── WebMWriter.js │ └── webworkers │ ├── csv-export-worker.js │ ├── gpx-export-worker.js │ └── spectrum-export-worker.js ├── src ├── cache.js ├── configuration.js ├── craft_2d.js ├── craft_3d.js ├── css │ ├── header_dialog.css │ ├── keys_dialog.css │ ├── main.css │ ├── menu.css │ └── user_settings_dialog.css ├── csv-exporter.js ├── datastream.js ├── decoders.js ├── expo.js ├── flightlog.js ├── flightlog_fielddefs.js ├── flightlog_fields_presenter.js ├── flightlog_index.js ├── flightlog_parser.js ├── flightlog_video_renderer.js ├── gps_transform.js ├── gpx-exporter.js ├── graph_config.js ├── graph_config_dialog.js ├── graph_legend.js ├── graph_map.js ├── graph_minmax_setting_menu.js ├── graph_spectrum.js ├── graph_spectrum_calc.js ├── graph_spectrum_plot.js ├── grapher.js ├── header_dialog.js ├── imu.js ├── jquery.js ├── keys_dialog.js ├── laptimer.js ├── main.js ├── pref_storage.js ├── screenshot.js ├── seekbar.js ├── simple-stats.js ├── spectrum-exporter.js ├── sticks.js ├── tools.js ├── user_settings_dialog.js ├── vendor.js ├── vendor │ └── jquery.nouislider.all.min.js ├── video_export_dialog.js ├── workspace_menu.js ├── workspace_selection.js ├── ws_ctzsnooze.json └── ws_supafly.json ├── test ├── configs │ └── opt │ │ └── etc │ │ └── dummy.cfg ├── index.html └── index.js ├── vite.config.js └── yarn.lock /.coderabbit.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json 2 | language: "en-US" 3 | early_access: false 4 | reviews: 5 | profile: "chill" 6 | request_changes_workflow: true 7 | high_level_summary: true 8 | poem: false 9 | review_status: true 10 | collapse_walkthrough: false 11 | disable_placeholder_comment_notifications: true 12 | auto_review: 13 | enabled: true 14 | drafts: false 15 | chat: 16 | auto_reply: true 17 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.json 2 | *.html 3 | *.less 4 | *.css 5 | package.json 6 | docusaurus.config.js 7 | public/ 8 | src/vendor/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | ecmaVersion: 2020, 4 | sourceType: 'module', 5 | }, 6 | extends: ['eslint:recommended', 'prettier'], 7 | root: true, 8 | env: { 9 | node: true, 10 | jquery: true, 11 | es2017: true, 12 | browser: true, 13 | webextensions: true, 14 | }, 15 | rules: { 16 | // TODO: currently a lot of these issues are marked as 17 | // warnings because they are in the codebase already 18 | // and I don't want to fix them all at once. 19 | // Eventually, they should be fixed and the rules 20 | // should be set to 'error' (default in preset). 21 | 'no-var': 'warn', 22 | 'no-unused-vars': 'warn', 23 | 'no-undef': 'warn', 24 | 'no-redeclare': 'warn', 25 | 'no-prototype-builtins': 'warn', 26 | 'no-empty': 'warn', 27 | 'no-inner-declarations': 'warn', 28 | 'no-fallthrough': 'warn', 29 | 'no-useless-escape': 'warn', 30 | 'no-constant-condition': 'warn', 31 | 'no-unreachable': 'warn', 32 | 'no-duplicate-case': 'warn', 33 | 'no-dupe-keys': 'warn', 34 | 'no-irregular-whitespace': 'warn', 35 | 'no-case-declarations': 'warn', 36 | 'prefer-template': 'warn', 37 | 'comma-dangle': ['warn', 'always-multiline'], 38 | semi: ['error', 'always'], 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | LICENSE text eol=lf 2 | /screenshots export-ignore 3 | *.md text eol=lf 4 | *.c text eol=lf 5 | *.cc text eol=lf 6 | *.h text eol=lf 7 | *.cc text eol=lf 8 | *.S text eol=lf 9 | *.s text eol=lf 10 | *.hex -crlf -diff 11 | *.elf -crlf -diff 12 | *.ld text eol=lf 13 | Makefile text eol=lf 14 | *.mk text eol=lf 15 | *.nomk text eol=lf 16 | *.pl text eol=lf 17 | *.js text eol=lf 18 | *.json text eol=lf 19 | *.html text eol=lf 20 | *.css text eol=lf 21 | *.svg text eol=lf 22 | *.png -crlf -diff 23 | *.yml text eol=lf 24 | *.xml text eol=lf 25 | *.mcm text eol=lf 26 | *.nsi text eol=lf 27 | *.nsh text eol=lf 28 | *.lua text eol=lf 29 | *.txt text eol=lf 30 | *.sh text eol=lf 31 | *.config text eol=lf 32 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://paypal.me/betaflight 2 | patreon: betaflight 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a report to help us fix bugs in Betaflight Blackbox Explorer. 3 | labels: ["Template: Bug"] 4 | body: 5 | 6 | - type: markdown 7 | attributes: 8 | value: | 9 | # Please fill all the fields with the required information 10 | 11 | - type: textarea 12 | attributes: 13 | label: Describe the bug 14 | description: A clear and concise description of what the bug is. 15 | validations: 16 | required: true 17 | 18 | - type: textarea 19 | attributes: 20 | label: To Reproduce 21 | description: Steps to reproduce the behavior. 22 | validations: 23 | required: true 24 | 25 | - type: textarea 26 | attributes: 27 | label: Expected behavior 28 | description: A clear and concise description of what you expected to happen. 29 | validations: 30 | required: true 31 | 32 | - type: markdown 33 | attributes: 34 | value: | 35 | # Setup 36 | 37 | - type: input 38 | attributes: 39 | label: Betaflight Blackbox Explorer version 40 | description: Specify the version of the Betaflight Blackbox Explorer (as displayed in the app). 41 | validations: 42 | required: true 43 | 44 | - type: markdown 45 | attributes: 46 | value: | 47 | # Other information 48 | 49 | - type: textarea 50 | attributes: 51 | label: Add any other context about the problem that you think might be relevant here 52 | description: If this bug is related to a blackbox log please post it here too. 53 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Configuration and other support questions 4 | url: https://github.com/betaflight/betaflight#support-and-developers-channel 5 | about: Official Slack chat channel and Facebook group about Betaflight 6 | - name: Hardware Issues 7 | url: https://github.com/betaflight/betaflight#hardware-issues 8 | about: What to do in the case of hardware issues 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature idea for Betaflight Blackbox Explorer. 3 | labels: ["Template: Feature Request"] 4 | body: 5 | 6 | - type: markdown 7 | attributes: 8 | value: | 9 | **Please note that feature requests are not 'fire and forget'.** 10 | It is a lot more likely that the feature you would like to have will be implemented if you keep watching your feature request, and provide more details to developers looking into implementing your feature, and help them with testing. 11 | 12 | - type: textarea 13 | attributes: 14 | label: Is your feature request related to a problem? Please describe 15 | description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]. 16 | validations: 17 | required: true 18 | 19 | - type: textarea 20 | attributes: 21 | label: Describe the solution you'd like 22 | description: A clear and concise description of what you want to happen. 23 | validations: 24 | required: true 25 | 26 | - type: textarea 27 | attributes: 28 | label: Describe alternatives you've considered 29 | description: A clear and concise description of any alternative solutions or features you've considered. 30 | validations: 31 | required: true 32 | 33 | - type: textarea 34 | attributes: 35 | label: Other information 36 | description: Add any other context or screenshots about the feature request that you think might be relevant here. 37 | -------------------------------------------------------------------------------- /.github/no-response.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-no-response - https://github.com/probot/no-response 2 | 3 | # Number of days of inactivity before an Issue is closed for lack of response 4 | daysUntilClose: 1 5 | # Label requiring a response 6 | responseRequiredLabel: Missing Information 7 | # Comment to post when closing an Issue for lack of response. Set to `false` to disable 8 | closeComment: > 9 | This issue has been automatically closed because the information we asked 10 | to be provided when opening it was not supplied by the original author. 11 | With only the information that is currently in the issue, we don't have 12 | enough information to take action. 13 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Important considerations when opening a pull request: 2 | 3 | 1. Make sure you do not make the changes you want to open a pull request for on the `master` branch of your fork, or open the pull request from the `master` branch of your fork. Some of our integrations will fail if you do this, resulting in your pull request not being accepted. If this is your first pull request, it is probably a good idea to first read up on how opening pull requests work (https://opensource.com/article/19/7/create-pull-request-github is a good introduction); 4 | 5 | 2. Pull requests will only be accepted if they are opened against the `master` branch of our repository. Pull requests opened against other branches without prior consent from the maintainers will be closed; 6 | 7 | 3. Please follow the coding style guidelines: https://github.com/betaflight/betaflight/blob/master/docs/development/CodingStyle.md 8 | 9 | 4. Keep your pull requests as small and concise as possible. One pull request should only ever add / update one feature. If the change that you are proposing has a wider scope, consider splitting it over multiple pull requests. In particular, pull requests that combine changes to features and one or more new targets are not acceptable. 10 | 11 | 5. Ideally, a pull request should contain only one commit, with a descriptive message. If your changes use more than one commit, rebase / squash them into one commit before submitting a pull request. If you need to amend your pull request, make sure that the additional commit has a descriptive message, or - even better - use `git commit --amend` to amend your original commit. 12 | 13 | 6. All pull requests are reviewed. Be ready to receive constructive criticism, and to learn and improve your coding style. Also, be ready to clarify anything that isn't already sufficiently explained in the code and text of the pull request, and to defend your ideas. 14 | 15 | 7. We use continuous integration (CI) with [Travis](https://travis-ci.com/betaflight) to build all targets and run the test suite for every pull request. Pull requests that fail any of the builds or fail tests will most likely not be reviewed before they are fixed to build successfully and pass the tests. In order to get a quick idea if there are things that need fixing **before** opening a pull request or pushing an update into an existing pull request, run `make pre-push` to run a representative subset of the CI build. _Note: This is not an exhaustive test (which will take hours to run on any desktop type system), so even if this passes the CI build might still fail._ 16 | 17 | 8. If your pull request is a fix for one or more issues that are open in GitHub, add a comment to your pull request, and add the issue numbers of the issues that are fixed in the form `Fixes #`. This will cause the issues to be closed when the pull request is merged; 18 | 19 | 9. Remove this Text :). 20 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - "cleanup" 5 | - "RN: IGNORE" 6 | categories: 7 | - title: Features 8 | labels: 9 | - "RN: FEATURE" 10 | - "RN: MAJOR FEATURE" 11 | - "RN: MINOR FEATURE" 12 | - title: Improvements 13 | labels: 14 | - "RN: IMPROVEMENT" 15 | - "RN: UI" 16 | - "RN: REFACTOR" 17 | - "RN: FONT" 18 | - title: Fixes 19 | labels: 20 | - "RN: BUGFIX" 21 | - title: Translation 22 | labels: 23 | - "RN: TRANSLATION" 24 | - title: Known Issues 25 | labels: 26 | - BUG 27 | -------------------------------------------------------------------------------- /.github/workflows/deploy-preview.yml: -------------------------------------------------------------------------------- 1 | name: 'Preview Deployment' 2 | on: 3 | pull_request_target: 4 | types: [opened, synchronize, reopened] 5 | branches: 6 | - master 7 | 8 | jobs: 9 | # Job 1: Build the code (no secrets here) 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | with: 16 | ref: ${{ github.event.pull_request.head.sha }} 17 | persist-credentials: false # Don't persist GitHub token 18 | 19 | - name: Cache node_modules 20 | uses: actions/cache@v4 21 | with: 22 | path: node_modules/ 23 | key: node_modules-${{ runner.os }}-${{ hashFiles('yarn.lock') }} 24 | 25 | - name: Install node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version-file: '.nvmrc' 29 | 30 | - run: npm install yarn -g 31 | - run: yarn install 32 | - run: yarn build 33 | 34 | - name: Upload build artifact 35 | uses: actions/upload-artifact@v4 36 | with: 37 | name: dist-files 38 | path: dist 39 | 40 | # Job 2: Deploy with secrets (no PR code checkout) 41 | deploy: 42 | needs: build # Wait for build job to complete 43 | permissions: 44 | actions: read 45 | contents: read 46 | deployments: write 47 | issues: write 48 | pull-requests: write 49 | runs-on: ubuntu-latest 50 | timeout-minutes: 5 51 | steps: 52 | - name: Download build artifact 53 | uses: actions/download-artifact@v4 54 | with: 55 | name: dist-files 56 | path: dist 57 | 58 | - name: Set short git commit SHA 59 | id: vars 60 | run: | 61 | calculatedSha=$(echo ${{ github.event.pull_request.head.sha }} | head -c 8) 62 | echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV 63 | 64 | - name: Deploy to Cloudflare 65 | id: deploy 66 | uses: cloudflare/wrangler-action@v3 67 | with: 68 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 69 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 70 | command: pages deploy dist --project-name=${{ vars.CLOUDFLARE_PROJECT_NAME }} --branch=${{ env.COMMIT_SHORT_SHA }} --commit-dirty=true 71 | 72 | - name: Add deployment comment 73 | uses: thollander/actions-comment-pull-request@v3 74 | with: 75 | message: | 76 | Preview URL: ${{ steps.deploy.outputs.pages-deployment-alias-url }} 77 | reactions: eyes, rocket 78 | comment-tag: 'Preview URL' 79 | mode: recreate 80 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues' 2 | 3 | on: 4 | schedule: 5 | - cron: "30 4 * * *" 6 | 7 | jobs: 8 | stale: 9 | name: 'Check and close stale issues' 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/stale@v8 13 | with: 14 | repo-token: ${{ secrets.GITHUB_TOKEN }} 15 | operations-per-run: 30 16 | days-before-stale: 30 17 | days-before-close: 7 18 | stale-issue-message: > 19 | This issue has been automatically marked as stale because it 20 | has not had recent activity. It will be closed if no further activity occurs 21 | within a week. 22 | close-issue-message: 'Issue closed automatically as inactive.' 23 | exempt-issue-labels: 'BUG,Feature Request,Pinned' 24 | stale-issue-label: 'Inactive' 25 | stale-pr-message: > 26 | This pull request has been automatically marked as stale because it 27 | has not had recent activity. It will be closed if no further activity occurs 28 | within a week. 29 | close-pr-message: 'Pull request closed automatically as inactive.' 30 | exempt-pr-labels: 'Pinned' 31 | stale-pr-label: 'Inactive' 32 | exempt-all-milestones: true 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.settings/ 2 | /.project 3 | .DS_Store 4 | .idea/workspace.xml 5 | .idea/tasks.xml 6 | /.idea/ 7 | node_modules/ 8 | npm-debug.log 9 | cache/ 10 | apps/ 11 | dist/ 12 | debug/ 13 | release/ 14 | 15 | # artefacts for Visual Studio Code 16 | /.vscode/ 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* -------------------------------------------------------------------------------- /.idea/blackbox-log-viewer.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | // Environment settings 3 | "browser": true, 4 | "typed": true, 5 | "devel": true, 6 | "globalstrict": true, 7 | 8 | "globals": { 9 | "$" : false, 10 | "jQuery" : false, 11 | "THREE" : false, 12 | "Modernizr" : false 13 | } 14 | } -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "**/*.{tsx,js}": [ 3 | "prettier --write --ignore-unknown", 4 | "eslint --fix --ext .tsx,.js" 5 | ], 6 | "**/*.{html,md,mdx,less,css,json}": ["prettier --write --ignore-unknown"] 7 | } -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.10.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # node.js npm related 2 | 3 | node_modules/ 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Configurator Build process 9 | cache/ 10 | apps/ 11 | dist/ 12 | public/ 13 | src/vendor/ 14 | dist_cordova/ 15 | debug/ 16 | release/ 17 | testresults/ 18 | .eslintcache 19 | cordova/bundle.keystore 20 | 21 | # OSX 22 | .DS_store 23 | 24 | # artefacts for Visual Studio Code 25 | /.vscode/ 26 | 27 | # NetBeans 28 | nbproject/ 29 | 30 | # IntelliJ 31 | .idea 32 | 33 | # Eclipse 34 | .project 35 | .settings/ 36 | test-results-junit/ -------------------------------------------------------------------------------- /.sonarcloud.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/.sonarcloud.properties -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at conduct-violations@betaflight.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Issues and Support. 2 | 3 | Please remember the issue tracker on github is _not_ for user support. Please also do not email developers directly for support. Instead please use IRC or the forums first, then if the problem is confirmed create an issue that details how to repeat the problem so it can be investigated. 4 | 5 | Issues created without steps to repeat are likely to be closed. E-mail requests for support will go un-answered; All support needs to be public so that other people can read the problems and solutions. 6 | 7 | Remember that issues that are due to misconfiguration, wiring or failure to read documentation just takes time away from the developers and can often be solved without developer interaction by other users. 8 | 9 | Please search for existing issues *before* creating new ones. 10 | 11 | # Developers 12 | 13 | Please refer to the development section in the [this folder](https://github.com/betaflight/betaflight/tree/master/docs/development). 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Betaflight Blackbox Explorer 2 | 3 | [![Latest version](https://img.shields.io/github/v/release/betaflight/blackbox-log-viewer)](https://github.com/betaflight/blackbox-log-viewer/releases) 4 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=betaflight_blackbox-log-viewer&metric=alert_status)](https://sonarcloud.io/dashboard?id=betaflight_blackbox-log-viewer) 5 | [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) 6 | 7 | ![Main explorer interface](screenshots/main-interface.jpg) 8 | 9 | This tool allows you to open logs recorded by Betaflight's Blackbox feature in 10 | your web browser. You can seek through the log to examine graphed values at each 11 | timestep. If you have a flight video, you can load that in as well and it'll be 12 | played behind the log. You can export the graphs as a WebM video to share with 13 | others. 14 | 15 | ## Installation 16 | 17 | Current blackbox explorer version is built as 18 | [PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/What_is_a_progressive_web_app). 19 | Meaning it can work in both online and offline modes as regular desktop app 20 | would. 21 | 22 | ### Latest stable version 23 | 24 | For the latest stable released version visit https://blackbox.betaflight.com/ 25 | 26 | ### Unstable testing versions 27 | 28 | The latest master build is always available at https://master.dev.blackbox.betaflight.com/ 29 | 30 | **Be aware that these versions are intended for testing / feedback only, and may be buggy or broken. Caution is advised when using these versions.** 31 | 32 | ### Install the app to be used in offline mode 33 | 34 | Follow the procedure to install PWA on your platform. For example on MacOS chrome: 35 | ![Url bar PWA install](screenshots/url-bar.webp) 36 | ![PWA install dialog](screenshots/pwa-install-dialog.webp) 37 | 38 | ## Usage 39 | 40 | Click the "Open log file/video" button at the top right and select your log file 41 | and your flight video (if you recorded one). 42 | 43 | You can scroll through the log by clicking or dragging on the seek bar that 44 | appears underneath the main graph. The current time is represented by the 45 | vertical red bar in the center of the graph. You can also click and drag left 46 | and right on the graph area to scrub backwards and forwards. 47 | 48 | ### Syncing your log to your flight video 49 | 50 | The blackbox plays a short beep on the buzzer when arming, and this corresponds 51 | with the start of the logged data. You can sync your log against your flight 52 | video by pressing the "start log here" button when you hear the beep in the 53 | video. You can tune the alignment of the log manually by pressing the nudge left 54 | and nudge right buttons in the log sync section, or by editing the value in the 55 | "log sync" box. Positive values move the log toward the end of the video, 56 | negative values move it towards the beginning. 57 | 58 | ### Customizing the graph display 59 | 60 | Click the "Graph Setup" button on the right side of the display in order to 61 | choose which fields should be plotted on the graph. You may, for example, want 62 | to remove the default gyro plot and add separate gyro plots for each rotation 63 | axis. Or you may want to plot vbat against throttle to examine your battery's 64 | performance. 65 | 66 | ## Developing 67 | 68 | ### Node setup 69 | 70 | We are using [nvm](https://github.com/nvm-sh/nvm) to manage the correct node 71 | vesion, follow the install instructions there. After which from blackbox directory 72 | just run: 73 | 74 | ```bash 75 | nvm use 76 | ``` 77 | 78 | ### Yarn 79 | 80 | For dependency management we are using [yarn](https://yarnpkg.com/), follow the 81 | instructions there to install it. 82 | 83 | ### Development mode 84 | 85 | We are using [vite](https://vitejs.dev/) for development setup. It provides 86 | bundling and various optimisations like hot module reloading. 87 | 88 | With `node` and `yarn` setup, to start developing run: 89 | 90 | ```bash 91 | yarn start 92 | ``` 93 | 94 | This will start development server and the Blackbox will be available on http://localhost:5173/. 95 | 96 | ### Installing development build locally 97 | 98 | If you want to have latest and greatest version installed on your machine from 99 | the tip of the repository: 100 | 101 | First need to build the application: 102 | ```bash 103 | yarn build 104 | ``` 105 | Then start the application in `preview` mode 106 | ```bash 107 | yarn preview 108 | ``` 109 | Visit http://localhost:4173/ and follow the steps from [Install the app to be used in offline mode](#install-the-app-to-be-used-in-offline-mode) 110 | 111 | ## Common problems 112 | 113 | ### Flight video won't load, or jumpy flight video upon export 114 | 115 | Some flight video formats aren't supported by Chrome, so the viewer can't open 116 | them. You can fix this by re-encoding your video using the free tool 117 | [Handbrake][]. Open your original video using Handbrake. In the output settings, 118 | choose MP4 as the format, and H.264 as the video codec. 119 | 120 | Because of [Google Bug #66631][], Chrome is unable to accurately seek within 121 | H.264 videos that use B-frames. This is mostly fine when viewing the flight 122 | video inside Blackbox Explorer. However, if you use the "export video" feature, 123 | this bug will cause the flight video in the background of the exported video to 124 | occasionally jump backwards in time for a couple of frames, causing a very 125 | glitchy appearance. 126 | 127 | To fix that issue, you need to tell Handbrake to render every frame as an 128 | intraframe, which will avoid any problematic B-frames. Do that by adding 129 | "keyint=1" into the Additional Options box: 130 | 131 | ![Handbrake settings](screenshots/handbrake.png) 132 | 133 | Hit start to begin re-encoding your video. Once it finishes, you should be able 134 | to load the new video into the Blackbox Explorer. 135 | 136 | [Handbrake]: https://handbrake.fr/ 137 | [Google Bug #66631]: http://code.google.com/p/chromium/issues/detail?id=66631 138 | 139 | ## License 140 | 141 | This project is licensed under GPLv3. 142 | -------------------------------------------------------------------------------- /dmg-background.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/dmg-background.psd -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "betaflight-blackbox-explorer", 3 | "productName": "Blackbox Explorer", 4 | "displayName": "Betaflight - Blackbox Explorer", 5 | "description": "Crossplatform blackbox analitics tool for Betaflight flight control system.", 6 | "version": "4.0.0", 7 | "main": "index.html", 8 | "default_locale": "en", 9 | "scripts": { 10 | "start": "vite", 11 | "build": "vite build", 12 | "preview": "vite preview", 13 | "lint": "eslint src", 14 | "lint:fix": "eslint src --fix", 15 | "format": "prettier --write src" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "github.com/betaflight/blackbox-log-viewer" 20 | }, 21 | "author": "The Betaflight open source project.", 22 | "license": "GPL-3.0", 23 | "dependencies": { 24 | "Leaflet.MultiOptionsPolyline": "hgoebl/Leaflet.MultiOptionsPolyline", 25 | "bootstrap": "~3.4.1", 26 | "eslint": "^8.24.0", 27 | "eslint-config-prettier": "^8.5.0", 28 | "html2canvas": "^1.0.0-rc.5", 29 | "jquery": "^3.7.1", 30 | "jquery-ui": "^1.13.2", 31 | "leaflet": "^1.9.3", 32 | "leaflet-marker-rotation": "^0.4.0", 33 | "lodash": "^4.17.21", 34 | "prettier": "^2.8.1", 35 | "throttle-debounce": "^5.0.0", 36 | "vite": "^5.4.19", 37 | "vite-plugin-pwa": "^0.19.7" 38 | }, 39 | "devDependencies": { 40 | "inflection": "1.12.0", 41 | "yarn": "^1.22.0" 42 | }, 43 | "resolutions": { 44 | "**/**/lodash.template": "^4.5.0" 45 | }, 46 | "engines": { 47 | "node": "20.x" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /public/css/jquery.nouislider.min.css: -------------------------------------------------------------------------------- 1 | /*! noUiSlider - 7.0.9 - 2014-10-08 16:49:45 */ 2 | 3 | 4 | .noUi-target,.noUi-target *{-webkit-touch-callout:none;-webkit-user-select:none;-ms-touch-action:none;-ms-user-select:none;-moz-user-select:none;-moz-box-sizing:border-box;box-sizing:border-box}.noUi-target{position:relative}.noUi-base{width:100%;height:100%;position:relative}.noUi-origin{position:absolute;right:0;top:0;left:0;bottom:0}.noUi-handle{position:relative;z-index:1}.noUi-stacking .noUi-handle{z-index:10}.noUi-state-tap .noUi-origin{-webkit-transition:left .3s,top .3s;transition:left .3s,top .3s}.noUi-state-drag *{cursor:inherit!important}.noUi-base{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.noUi-horizontal{height:18px}.noUi-horizontal .noUi-handle{width:34px;height:28px;left:-17px;top:-6px}.noUi-vertical{width:18px}.noUi-vertical .noUi-handle{width:28px;height:34px;left:-6px;top:-17px}.noUi-background{background:#FAFAFA;box-shadow:inset 0 1px 1px #f0f0f0}.noUi-connect{background:#3FB8AF;box-shadow:inset 0 0 3px rgba(51,51,51,.45);-webkit-transition:background 450ms;transition:background 450ms}.noUi-origin{border-radius:2px}.noUi-target{border-radius:4px;border:1px solid #D3D3D3;box-shadow:inset 0 1px 1px #F0F0F0,0 3px 6px -5px #BBB}.noUi-target.noUi-connect{box-shadow:inset 0 0 3px rgba(51,51,51,.45),0 3px 6px -5px #BBB}.noUi-dragable{cursor:w-resize}.noUi-vertical .noUi-dragable{cursor:n-resize}.noUi-handle{border:1px solid #D9D9D9;border-radius:3px;background:#FFF;cursor:default;box-shadow:inset 0 0 1px #FFF,inset 0 1px 7px #EBEBEB,0 3px 6px -3px #BBB}.noUi-active{box-shadow:inset 0 0 1px #FFF,inset 0 1px 7px #DDD,0 3px 6px -3px #BBB}.noUi-handle:after,.noUi-handle:before{content:"";display:block;position:absolute;height:14px;width:1px;background:#E8E7E6;left:14px;top:6px}.noUi-handle:after{left:17px}.noUi-vertical .noUi-handle:after,.noUi-vertical .noUi-handle:before{width:14px;height:1px;left:6px;top:14px}.noUi-vertical .noUi-handle:after{top:17px}[disabled] .noUi-connect,[disabled].noUi-connect{background:#B8B8B8}[disabled] .noUi-handle{cursor:not-allowed} 5 | -------------------------------------------------------------------------------- /public/images/bf_icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/public/images/bf_icon.icns -------------------------------------------------------------------------------- /public/images/bf_icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/public/images/bf_icon.ico -------------------------------------------------------------------------------- /public/images/bf_icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/public/images/bf_icon_128.png -------------------------------------------------------------------------------- /public/images/cf_logo_white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 15 | 18 | 21 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /public/images/dmg-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/public/images/dmg-background.png -------------------------------------------------------------------------------- /public/images/dmg-background@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/public/images/dmg-background@2x.png -------------------------------------------------------------------------------- /public/images/glyphs/scrollwheel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 27 | 32 | 33 | 40 | 45 | 46 | 53 | 58 | 59 | 66 | 72 | 73 | 80 | 86 | 87 | 94 | 100 | 101 | 108 | 114 | 115 | 122 | 128 | 129 | 130 | 154 | 156 | 157 | 159 | image/svg+xml 160 | 162 | 163 | 164 | 165 | 166 | 171 | 180 | 186 | 195 | 200 | 205 | 206 | 207 | -------------------------------------------------------------------------------- /public/images/inav_logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/public/images/inav_logo_white.png -------------------------------------------------------------------------------- /public/images/light-wide-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 19 | 20 | 21 | 23 | 24 | 25 | 26 | 29 | 30 | 31 | 32 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /public/images/logo-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/public/images/logo-background.png -------------------------------------------------------------------------------- /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/public/images/logo.png -------------------------------------------------------------------------------- /public/images/markers/craft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/public/images/markers/craft.png -------------------------------------------------------------------------------- /public/images/markers/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/public/images/markers/home.png -------------------------------------------------------------------------------- /public/images/motor_order/atail_quad.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /public/images/motor_order/bicopter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /public/images/motor_order/custom.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 31 | 33 | 35 | 38 | 40 | 42 | 46 | 49 | 51 | 53 | 56 | 58 | 59 | -------------------------------------------------------------------------------- /public/images/motor_order/flying_wing.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /public/images/motor_order/quad_p.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /public/images/motor_order/quad_x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /public/images/motor_order/tri.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /public/images/motor_order/vtail_quad.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /public/images/motor_order/y4.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /public/images/pwa/bf_icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/public/images/pwa/bf_icon_128.png -------------------------------------------------------------------------------- /public/images/pwa/bf_icon_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/public/images/pwa/bf_icon_192.png -------------------------------------------------------------------------------- /public/images/pwa/bf_icon_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/public/images/pwa/bf_icon_256.png -------------------------------------------------------------------------------- /public/images/stick_modes/Mode_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/public/images/stick_modes/Mode_1.png -------------------------------------------------------------------------------- /public/images/stick_modes/Mode_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/public/images/stick_modes/Mode_2.png -------------------------------------------------------------------------------- /public/images/stick_modes/Mode_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/public/images/stick_modes/Mode_3.png -------------------------------------------------------------------------------- /public/images/stick_modes/Mode_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/betaflight/blackbox-log-viewer/900c0dfa09eae17e31fac4340ad9f4d4c14dd10d/public/images/stick_modes/Mode_4.png -------------------------------------------------------------------------------- /public/js/FileSaver.js: -------------------------------------------------------------------------------- 1 | /* FileSaver.js 2 | * A saveAs() FileSaver implementation. 3 | * 2013-01-23 4 | * 5 | * By Eli Grey, http://eligrey.com 6 | * License: X11/MIT 7 | * See LICENSE.md 8 | */ 9 | 10 | /*global self */ 11 | /*jslint bitwise: true, regexp: true, confusion: true, es5: true, vars: true, white: true, 12 | plusplus: true */ 13 | 14 | /*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/FileSaver.js */ 15 | 16 | var saveAs = saveAs 17 | || (navigator.msSaveOrOpenBlob && navigator.msSaveOrOpenBlob.bind(navigator)) 18 | || (function(view) { 19 | "use strict"; 20 | var 21 | doc = view.document 22 | // only get URL when necessary in case BlobBuilder.js hasn't overridden it yet 23 | , get_URL = function() { 24 | return view.URL || view.webkitURL || view; 25 | } 26 | , URL = view.URL || view.webkitURL || view 27 | , save_link = doc.createElementNS("http://www.w3.org/1999/xhtml", "a") 28 | , can_use_save_link = !view.externalHost && "download" in save_link 29 | , click = function(node) { 30 | var event = doc.createEvent("MouseEvents"); 31 | event.initMouseEvent( 32 | "click", true, false, view, 0, 0, 0, 0, 0 33 | , false, false, false, false, 0, null 34 | ); 35 | node.dispatchEvent(event); 36 | } 37 | , webkit_req_fs = view.webkitRequestFileSystem 38 | , req_fs = view.requestFileSystem || webkit_req_fs || view.mozRequestFileSystem 39 | , throw_outside = function (ex) { 40 | (view.setImmediate || view.setTimeout)(function() { 41 | throw ex; 42 | }, 0); 43 | } 44 | , force_saveable_type = "application/octet-stream" 45 | , fs_min_size = 0 46 | , deletion_queue = [] 47 | , process_deletion_queue = function() { 48 | var i = deletion_queue.length; 49 | while (i--) { 50 | var file = deletion_queue[i]; 51 | if (typeof file === "string") { // file is an object URL 52 | URL.revokeObjectURL(file); 53 | } else { // file is a File 54 | file.remove(); 55 | } 56 | } 57 | deletion_queue.length = 0; // clear queue 58 | } 59 | , dispatch = function(filesaver, event_types, event) { 60 | event_types = [].concat(event_types); 61 | var i = event_types.length; 62 | while (i--) { 63 | var listener = filesaver["on" + event_types[i]]; 64 | if (typeof listener === "function") { 65 | try { 66 | listener.call(filesaver, event || filesaver); 67 | } catch (ex) { 68 | throw_outside(ex); 69 | } 70 | } 71 | } 72 | } 73 | , FileSaver = function(blob, name) { 74 | // First try a.download, then web filesystem, then object URLs 75 | var 76 | filesaver = this 77 | , type = blob.type 78 | , blob_changed = false 79 | , object_url 80 | , target_view 81 | , get_object_url = function() { 82 | var object_url = get_URL().createObjectURL(blob); 83 | deletion_queue.push(object_url); 84 | return object_url; 85 | } 86 | , dispatch_all = function() { 87 | dispatch(filesaver, "writestart progress write writeend".split(" ")); 88 | } 89 | // on any filesys errors revert to saving with object URLs 90 | , fs_error = function() { 91 | // don't create more object URLs than needed 92 | if (blob_changed || !object_url) { 93 | object_url = get_object_url(blob); 94 | } 95 | if (target_view) { 96 | target_view.location.href = object_url; 97 | } else { 98 | window.open(object_url, "_blank"); 99 | } 100 | filesaver.readyState = filesaver.DONE; 101 | dispatch_all(); 102 | } 103 | , abortable = function(func) { 104 | return function() { 105 | if (filesaver.readyState !== filesaver.DONE) { 106 | return func.apply(this, arguments); 107 | } 108 | }; 109 | } 110 | , create_if_not_found = {create: true, exclusive: false} 111 | , slice 112 | ; 113 | filesaver.readyState = filesaver.INIT; 114 | if (!name) { 115 | name = "download"; 116 | } 117 | if (can_use_save_link) { 118 | object_url = get_object_url(blob); 119 | save_link.href = object_url; 120 | save_link.download = name; 121 | click(save_link); 122 | filesaver.readyState = filesaver.DONE; 123 | dispatch_all(); 124 | return; 125 | } 126 | // Object and web filesystem URLs have a problem saving in Google Chrome when 127 | // viewed in a tab, so I force save with application/octet-stream 128 | // http://code.google.com/p/chromium/issues/detail?id=91158 129 | if (view.chrome && type && type !== force_saveable_type) { 130 | slice = blob.slice || blob.webkitSlice; 131 | blob = slice.call(blob, 0, blob.size, force_saveable_type); 132 | blob_changed = true; 133 | } 134 | // Since I can't be sure that the guessed media type will trigger a download 135 | // in WebKit, I append .download to the filename. 136 | // https://bugs.webkit.org/show_bug.cgi?id=65440 137 | if (webkit_req_fs && name !== "download") { 138 | name += ".download"; 139 | } 140 | if (type === force_saveable_type || webkit_req_fs) { 141 | target_view = view; 142 | } 143 | if (!req_fs) { 144 | fs_error(); 145 | return; 146 | } 147 | fs_min_size += blob.size; 148 | req_fs(view.TEMPORARY, fs_min_size, abortable(function(fs) { 149 | fs.root.getDirectory("saved", create_if_not_found, abortable(function(dir) { 150 | var save = function() { 151 | dir.getFile(name, create_if_not_found, abortable(function(file) { 152 | file.createWriter(abortable(function(writer) { 153 | writer.onwriteend = function(event) { 154 | target_view.location.href = file.toURL(); 155 | deletion_queue.push(file); 156 | filesaver.readyState = filesaver.DONE; 157 | dispatch(filesaver, "writeend", event); 158 | }; 159 | writer.onerror = function() { 160 | var error = writer.error; 161 | if (error.code !== error.ABORT_ERR) { 162 | fs_error(); 163 | } 164 | }; 165 | "writestart progress write abort".split(" ").forEach(function(event) { 166 | writer["on" + event] = filesaver["on" + event]; 167 | }); 168 | writer.write(blob); 169 | filesaver.abort = function() { 170 | writer.abort(); 171 | filesaver.readyState = filesaver.DONE; 172 | }; 173 | filesaver.readyState = filesaver.WRITING; 174 | }), fs_error); 175 | }), fs_error); 176 | }; 177 | dir.getFile(name, {create: false}, abortable(function(file) { 178 | // delete file if it already exists 179 | file.remove(); 180 | save(); 181 | }), abortable(function(ex) { 182 | if (ex.code === ex.NOT_FOUND_ERR) { 183 | save(); 184 | } else { 185 | fs_error(); 186 | } 187 | })); 188 | }), fs_error); 189 | }), fs_error); 190 | } 191 | , FS_proto = FileSaver.prototype 192 | , saveAs = function(blob, name) { 193 | return new FileSaver(blob, name); 194 | } 195 | ; 196 | FS_proto.abort = function() { 197 | var filesaver = this; 198 | filesaver.readyState = filesaver.DONE; 199 | dispatch(filesaver, "abort"); 200 | }; 201 | FS_proto.readyState = FS_proto.INIT = 0; 202 | FS_proto.WRITING = 1; 203 | FS_proto.DONE = 2; 204 | 205 | FS_proto.error = 206 | FS_proto.onwritestart = 207 | FS_proto.onprogress = 208 | FS_proto.onwrite = 209 | FS_proto.onabort = 210 | FS_proto.onerror = 211 | FS_proto.onwriteend = 212 | null; 213 | 214 | view.addEventListener("unload", process_deletion_queue, false); 215 | return saveAs; 216 | }(self)); 217 | -------------------------------------------------------------------------------- /public/js/webm-writer/ArrayBufferDataStream.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A tool for presenting an ArrayBuffer as a stream for writing some simple data types. 3 | * 4 | * By Nicholas Sherlock 5 | * 6 | * Released under the WTFPLv2 https://en.wikipedia.org/wiki/WTFPL 7 | */ 8 | 9 | "use strict"; 10 | 11 | (function(){ 12 | /* 13 | * Create an ArrayBuffer of the given length and present it as a writable stream with methods 14 | * for writing data in different formats. 15 | */ 16 | let ArrayBufferDataStream = function(length) { 17 | this.data = new Uint8Array(length); 18 | this.pos = 0; 19 | }; 20 | 21 | ArrayBufferDataStream.prototype.seek = function(toOffset) { 22 | this.pos = toOffset; 23 | }; 24 | 25 | ArrayBufferDataStream.prototype.writeBytes = function(arr) { 26 | for (let i = 0; i < arr.length; i++) { 27 | this.data[this.pos++] = arr[i]; 28 | } 29 | }; 30 | 31 | ArrayBufferDataStream.prototype.writeByte = function(b) { 32 | this.data[this.pos++] = b; 33 | }; 34 | 35 | //Synonym: 36 | ArrayBufferDataStream.prototype.writeU8 = ArrayBufferDataStream.prototype.writeByte; 37 | 38 | ArrayBufferDataStream.prototype.writeU16BE = function(u) { 39 | this.data[this.pos++] = u >> 8; 40 | this.data[this.pos++] = u; 41 | }; 42 | 43 | ArrayBufferDataStream.prototype.writeDoubleBE = function(d) { 44 | let 45 | bytes = new Uint8Array(new Float64Array([d]).buffer); 46 | 47 | for (let i = bytes.length - 1; i >= 0; i--) { 48 | this.writeByte(bytes[i]); 49 | } 50 | }; 51 | 52 | ArrayBufferDataStream.prototype.writeFloatBE = function(d) { 53 | let 54 | bytes = new Uint8Array(new Float32Array([d]).buffer); 55 | 56 | for (let i = bytes.length - 1; i >= 0; i--) { 57 | this.writeByte(bytes[i]); 58 | } 59 | }; 60 | 61 | /** 62 | * Write an ASCII string to the stream 63 | */ 64 | ArrayBufferDataStream.prototype.writeString = function(s) { 65 | for (let i = 0; i < s.length; i++) { 66 | this.data[this.pos++] = s.charCodeAt(i); 67 | } 68 | }; 69 | 70 | /** 71 | * Write the given 32-bit integer to the stream as an EBML variable-length integer using the given byte width 72 | * (use measureEBMLVarInt). 73 | * 74 | * No error checking is performed to ensure that the supplied width is correct for the integer. 75 | * 76 | * @param i Integer to be written 77 | * @param width Number of bytes to write to the stream 78 | */ 79 | ArrayBufferDataStream.prototype.writeEBMLVarIntWidth = function(i, width) { 80 | switch (width) { 81 | case 1: 82 | this.writeU8((1 << 7) | i); 83 | break; 84 | case 2: 85 | this.writeU8((1 << 6) | (i >> 8)); 86 | this.writeU8(i); 87 | break; 88 | case 3: 89 | this.writeU8((1 << 5) | (i >> 16)); 90 | this.writeU8(i >> 8); 91 | this.writeU8(i); 92 | break; 93 | case 4: 94 | this.writeU8((1 << 4) | (i >> 24)); 95 | this.writeU8(i >> 16); 96 | this.writeU8(i >> 8); 97 | this.writeU8(i); 98 | break; 99 | case 5: 100 | /* 101 | * JavaScript converts its doubles to 32-bit integers for bitwise operations, so we need to do a 102 | * division by 2^32 instead of a right-shift of 32 to retain those top 3 bits 103 | */ 104 | this.writeU8((1 << 3) | ((i / 4294967296) & 0x7)); 105 | this.writeU8(i >> 24); 106 | this.writeU8(i >> 16); 107 | this.writeU8(i >> 8); 108 | this.writeU8(i); 109 | break; 110 | default: 111 | throw new Error("Bad EBML VINT size " + width); 112 | } 113 | }; 114 | 115 | /** 116 | * Return the number of bytes needed to encode the given integer as an EBML VINT. 117 | */ 118 | ArrayBufferDataStream.prototype.measureEBMLVarInt = function(val) { 119 | if (val < (1 << 7) - 1) { 120 | /* Top bit is set, leaving 7 bits to hold the integer, but we can't store 127 because 121 | * "all bits set to one" is a reserved value. Same thing for the other cases below: 122 | */ 123 | return 1; 124 | } else if (val < (1 << 14) - 1) { 125 | return 2; 126 | } else if (val < (1 << 21) - 1) { 127 | return 3; 128 | } else if (val < (1 << 28) - 1) { 129 | return 4; 130 | } else if (val < 34359738367) { // 2 ^ 35 - 1 (can address 32GB) 131 | return 5; 132 | } else { 133 | throw new Error("EBML VINT size not supported " + val); 134 | } 135 | }; 136 | 137 | ArrayBufferDataStream.prototype.writeEBMLVarInt = function(i) { 138 | this.writeEBMLVarIntWidth(i, this.measureEBMLVarInt(i)); 139 | }; 140 | 141 | /** 142 | * Write the given unsigned 32-bit integer to the stream in big-endian order using the given byte width. 143 | * No error checking is performed to ensure that the supplied width is correct for the integer. 144 | * 145 | * Omit the width parameter to have it determined automatically for you. 146 | * 147 | * @param u Unsigned integer to be written 148 | * @param width Number of bytes to write to the stream 149 | */ 150 | ArrayBufferDataStream.prototype.writeUnsignedIntBE = function(u, width) { 151 | if (width === undefined) { 152 | width = this.measureUnsignedInt(u); 153 | } 154 | 155 | // Each case falls through: 156 | switch (width) { 157 | case 5: 158 | this.writeU8(Math.floor(u / 4294967296)); // Need to use division to access >32 bits of floating point var 159 | case 4: 160 | this.writeU8(u >> 24); 161 | case 3: 162 | this.writeU8(u >> 16); 163 | case 2: 164 | this.writeU8(u >> 8); 165 | case 1: 166 | this.writeU8(u); 167 | break; 168 | default: 169 | throw new Error("Bad UINT size " + width); 170 | } 171 | }; 172 | 173 | /** 174 | * Return the number of bytes needed to hold the non-zero bits of the given unsigned integer. 175 | */ 176 | ArrayBufferDataStream.prototype.measureUnsignedInt = function(val) { 177 | // Force to 32-bit unsigned integer 178 | if (val < (1 << 8)) { 179 | return 1; 180 | } else if (val < (1 << 16)) { 181 | return 2; 182 | } else if (val < (1 << 24)) { 183 | return 3; 184 | } else if (val < 4294967296) { 185 | return 4; 186 | } else { 187 | return 5; 188 | } 189 | }; 190 | 191 | /** 192 | * Return a view on the portion of the buffer from the beginning to the current seek position as a Uint8Array. 193 | */ 194 | ArrayBufferDataStream.prototype.getAsDataArray = function() { 195 | if (this.pos < this.data.byteLength) { 196 | return this.data.subarray(0, this.pos); 197 | } else if (this.pos == this.data.byteLength) { 198 | return this.data; 199 | } else { 200 | throw new Error("ArrayBufferDataStream's pos lies beyond end of buffer"); 201 | } 202 | }; 203 | 204 | if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { 205 | module.exports = ArrayBufferDataStream; 206 | } else { 207 | window.ArrayBufferDataStream = ArrayBufferDataStream; 208 | } 209 | }()); -------------------------------------------------------------------------------- /public/js/webm-writer/BlobBuffer.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * Allows a series of Blob-convertible objects (ArrayBuffer, Blob, String, etc) to be added to a buffer. Seeking and 5 | * overwriting of blobs is allowed. 6 | * 7 | * You can supply a FileWriter, in which case the BlobBuffer is just used as temporary storage before it writes it 8 | * through to the disk. 9 | * 10 | * By Nicholas Sherlock 11 | * 12 | * Released under the WTFPLv2 https://en.wikipedia.org/wiki/WTFPL 13 | */ 14 | (function() { 15 | let BlobBuffer = function(fs) { 16 | return function(destination) { 17 | let 18 | buffer = [], 19 | writePromise = Promise.resolve(), 20 | fileWriter = null, 21 | fd = null; 22 | 23 | /* PATCHED: Modified over the original library version. Is no compatible since some update of the Node.js app 24 | if (destination && destination.constructor.name === "FileWriter") { 25 | */ 26 | if (destination) { 27 | fileWriter = destination; 28 | } else if (fs && destination) { 29 | fd = destination; 30 | } 31 | 32 | // Current seek offset 33 | this.pos = 0; 34 | 35 | // One more than the index of the highest byte ever written 36 | this.length = 0; 37 | 38 | // Returns a promise that converts the blob to an ArrayBuffer 39 | function readBlobAsBuffer(blob) { 40 | return new Promise(function (resolve, reject) { 41 | let 42 | reader = new FileReader(); 43 | 44 | reader.addEventListener("loadend", function () { 45 | resolve(reader.result); 46 | }); 47 | 48 | reader.readAsArrayBuffer(blob); 49 | }); 50 | } 51 | 52 | function convertToUint8Array(thing) { 53 | return new Promise(function (resolve, reject) { 54 | if (thing instanceof Uint8Array) { 55 | resolve(thing); 56 | } else if (thing instanceof ArrayBuffer || ArrayBuffer.isView(thing)) { 57 | resolve(new Uint8Array(thing)); 58 | } else if (thing instanceof Blob) { 59 | resolve(readBlobAsBuffer(thing).then(function (buffer) { 60 | return new Uint8Array(buffer); 61 | })); 62 | } else { 63 | //Assume that Blob will know how to read this thing 64 | resolve(readBlobAsBuffer(new Blob([thing])).then(function (buffer) { 65 | return new Uint8Array(buffer); 66 | })); 67 | } 68 | }); 69 | } 70 | 71 | function measureData(data) { 72 | let 73 | result = data.byteLength || data.length || data.size; 74 | 75 | if (!Number.isInteger(result)) { 76 | throw new Error("Failed to determine size of element"); 77 | } 78 | 79 | return result; 80 | } 81 | 82 | /** 83 | * Seek to the given absolute offset. 84 | * 85 | * You may not seek beyond the end of the file (this would create a hole and/or allow blocks to be written in non- 86 | * sequential order, which isn't currently supported by the memory buffer backend). 87 | */ 88 | this.seek = function (offset) { 89 | if (offset < 0) { 90 | throw new Error("Offset may not be negative"); 91 | } 92 | 93 | if (isNaN(offset)) { 94 | throw new Error("Offset may not be NaN"); 95 | } 96 | 97 | if (offset > this.length) { 98 | throw new Error("Seeking beyond the end of file is not allowed"); 99 | } 100 | 101 | this.pos = offset; 102 | }; 103 | 104 | /** 105 | * Write the Blob-convertible data to the buffer at the current seek position. 106 | * 107 | * Note: If overwriting existing data, the write must not cross preexisting block boundaries (written data must 108 | * be fully contained by the extent of a previous write). 109 | */ 110 | this.write = function (data) { 111 | let 112 | newEntry = { 113 | offset: this.pos, 114 | data: data, 115 | length: measureData(data) 116 | }, 117 | isAppend = newEntry.offset >= this.length; 118 | 119 | this.pos += newEntry.length; 120 | this.length = Math.max(this.length, this.pos); 121 | 122 | // After previous writes complete, perform our write 123 | writePromise = writePromise.then(function () { 124 | if (fd) { 125 | return new Promise(function(resolve, reject) { 126 | convertToUint8Array(newEntry.data).then(function(dataArray) { 127 | let 128 | totalWritten = 0, 129 | buffer = Buffer.from(dataArray.buffer), 130 | 131 | handleWriteComplete = function(err, written, buffer) { 132 | totalWritten += written; 133 | 134 | if (totalWritten >= buffer.length) { 135 | resolve(); 136 | } else { 137 | // We still have more to write... 138 | fs.write(fd, buffer, totalWritten, buffer.length - totalWritten, newEntry.offset + totalWritten, handleWriteComplete); 139 | } 140 | }; 141 | 142 | fs.write(fd, buffer, 0, buffer.length, newEntry.offset, handleWriteComplete); 143 | }); 144 | }); 145 | } else if (fileWriter) { 146 | return new Promise(function (resolve, reject) { 147 | fileWriter.onwriteend = resolve; 148 | 149 | fileWriter.seek(newEntry.offset); 150 | fileWriter.write(new Blob([newEntry.data])); 151 | }); 152 | } else if (!isAppend) { 153 | // We might be modifying a write that was already buffered in memory. 154 | 155 | // Slow linear search to find a block we might be overwriting 156 | for (let i = 0; i < buffer.length; i++) { 157 | let 158 | entry = buffer[i]; 159 | 160 | // If our new entry overlaps the old one in any way... 161 | if (!(newEntry.offset + newEntry.length <= entry.offset || newEntry.offset >= entry.offset + entry.length)) { 162 | if (newEntry.offset < entry.offset || newEntry.offset + newEntry.length > entry.offset + entry.length) { 163 | throw new Error("Overwrite crosses blob boundaries"); 164 | } 165 | 166 | if (newEntry.offset == entry.offset && newEntry.length == entry.length) { 167 | // We overwrote the entire block 168 | entry.data = newEntry.data; 169 | 170 | // We're done 171 | return; 172 | } else { 173 | return convertToUint8Array(entry.data) 174 | .then(function (entryArray) { 175 | entry.data = entryArray; 176 | 177 | return convertToUint8Array(newEntry.data); 178 | }).then(function (newEntryArray) { 179 | newEntry.data = newEntryArray; 180 | 181 | entry.data.set(newEntry.data, newEntry.offset - entry.offset); 182 | }); 183 | } 184 | } 185 | } 186 | // Else fall through to do a simple append, as we didn't overwrite any pre-existing blocks 187 | } 188 | 189 | buffer.push(newEntry); 190 | }); 191 | }; 192 | 193 | /** 194 | * Finish all writes to the buffer, returning a promise that signals when that is complete. 195 | * 196 | * If a FileWriter was not provided, the promise is resolved with a Blob that represents the completed BlobBuffer 197 | * contents. You can optionally pass in a mimeType to be used for this blob. 198 | * 199 | * If a FileWriter was provided, the promise is resolved with null as the first argument. 200 | */ 201 | this.complete = function (mimeType) { 202 | if (fd || fileWriter) { 203 | writePromise = writePromise.then(function () { 204 | return null; 205 | }); 206 | } else { 207 | // After writes complete we need to merge the buffer to give to the caller 208 | writePromise = writePromise.then(function () { 209 | let 210 | result = []; 211 | 212 | for (let i = 0; i < buffer.length; i++) { 213 | result.push(buffer[i].data); 214 | } 215 | 216 | return new Blob(result, {type: mimeType}); 217 | }); 218 | } 219 | 220 | return writePromise; 221 | }; 222 | }; 223 | }; 224 | 225 | if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { 226 | module.exports = BlobBuffer(require('fs')); 227 | } else { 228 | window.BlobBuffer = BlobBuffer(null); 229 | } 230 | })(); 231 | -------------------------------------------------------------------------------- /public/js/webm-writer/Readme.md: -------------------------------------------------------------------------------- 1 | # WebM Writer for Electron 2 | 3 | This is a WebM video encoder based on the ideas from [Whammy][]. It allows you to turn a series of 4 | Canvas frames into a WebM video. 5 | 6 | This implementation allows you to create very large video files (exceeding the size of available memory), because it 7 | can stream chunks immediately to a file on disk while the video is being constructed, 8 | instead of needing to buffer the entire video in memory before saving can begin. Video sizes in excess of 4GB can be 9 | written. The implementation currently tops out at 32GB, but this could be extended. 10 | 11 | When not streaming to disk, it can instead buffer the video in memory as a series of Blobs which are eventually 12 | returned to the calling code as one composite Blob. This Blob can be displayed in a <video> element, transmitted 13 | to a server, or used for some other purpose. Note that Chrome has a [Blob size limit][] of 500MB. 14 | 15 | Because this library relies on Chrome's WebP encoder to do the hard work for it, it can only run in a Chrome environment 16 | (e.g. Chrome, Chromium, Electron), it can't run on vanilla Node. 17 | 18 | [Whammy]: https://github.com/antimatter15/whammy 19 | [Blob size limit]: https://github.com/eligrey/FileSaver.js/ 20 | 21 | ## Usage 22 | 23 | Add webm-writer to your project: 24 | 25 | ``` 26 | npm install --save webm-writer 27 | ``` 28 | 29 | Require and construct the writer, passing in any options you want to customize: 30 | 31 | ```js 32 | var 33 | WebMWriter = require('webm-writer'), 34 | 35 | videoWriter = new WebMWriter({ 36 | quality: 0.95, // WebM image quality from 0.0 (worst) to 0.99999 (best), 1.00 (VP8L lossless) is not supported 37 | fileWriter: null, // FileWriter in order to stream to a file instead of buffering to memory (optional) 38 | fd: null, // Node.js file handle to write to instead of buffering to memory (optional) 39 | 40 | // You must supply one of: 41 | frameDuration: null, // Duration of frames in milliseconds 42 | frameRate: null, // Number of frames per second 43 | 44 | transparent: false, // True if an alpha channel should be included in the video 45 | alphaQuality: undefined, // Allows you to set the quality level of the alpha channel separately. 46 | // If not specified this defaults to the same value as `quality`. 47 | }); 48 | ``` 49 | 50 | Add as many Canvas frames as you like to build your video: 51 | 52 | ```js 53 | videoWriter.addFrame(canvas); 54 | ``` 55 | 56 | When you're done, you must call `complete()` to finish writing the video: 57 | 58 | ```js 59 | videoWriter.complete(); 60 | ``` 61 | 62 | `complete()` returns a Promise which resolves when writing is completed. 63 | 64 | If you didn't supply a `fd` in the options, the Promise will resolve to Blob which represents the video. You 65 | could display this blob in an HTML5 <video> tag: 66 | 67 | ```js 68 | videoWriter.complete().then(function(webMBlob) { 69 | $("video").attr("src", URL.createObjectURL(webMBlob)); 70 | }); 71 | ``` 72 | 73 | There's an example which saves the video to an open file descriptor instead of to a Blob on this page: 74 | 75 | https://github.com/thenickdude/webm-writer-js/tree/master/test/electron 76 | 77 | ## Transparent WebM support 78 | 79 | Transparent WebM files are supported, check out the example in https://github.com/thenickdude/webm-writer-js/tree/master/test/transparent. However, because I'm re-using Chrome's 80 | WebP encoder to create the alpha channel, and the alpha channel is taken from the Y channel of a YUV-encoded WebP frame, 81 | and Y values are clamped by Chrome to be in the range 22-240 instead of the full 0-255 range, the encoded video can 82 | neither be fully opaque or fully transparent :(. 83 | 84 | Sorry, I wasn't able to find a workaround to get that to work. 85 | -------------------------------------------------------------------------------- /public/js/webworkers/csv-export-worker.js: -------------------------------------------------------------------------------- 1 | importScripts("/js/lodash.min.js"); 2 | 3 | onmessage = function(event) { 4 | 5 | /** 6 | * Converts `null` and other empty non-numeric values to empty string. 7 | * 8 | * @param {object} value is not a number 9 | * @returns {string} 10 | */ 11 | function normalizeEmpty(value) { 12 | return !!value ? value : ""; 13 | } 14 | 15 | /** 16 | * @param {array} columns 17 | * @returns {string} 18 | */ 19 | function joinColumns(columns) { 20 | return _(columns) 21 | .map(value => 22 | _.isNumber(value) 23 | ? value 24 | : stringDelim + normalizeEmpty(value) + stringDelim) 25 | .join(opts.columnDelimiter); 26 | } 27 | 28 | /** 29 | * Converts `null` entries in columns and other empty non-numeric values to NaN value string. 30 | * 31 | * @param {array} columns 32 | * @returns {string} 33 | */ 34 | function joinColumnValues(columns) { 35 | return _(columns) 36 | .map(value => 37 | (_.isNumber(value) || _.value) 38 | ? value 39 | : "NaN") 40 | .join(opts.columnDelimiter); 41 | } 42 | 43 | let opts = event.data.opts, 44 | stringDelim = opts.quoteStrings 45 | ? opts.stringDelimiter 46 | : "", 47 | mainFields = _([joinColumns(event.data.fieldNames)]) 48 | .concat(_(event.data.frames) 49 | .flatten() 50 | .map(row => joinColumnValues(row)) 51 | .value()) 52 | .join("\n"), 53 | headers = _(event.data.sysConfig) 54 | .map((value, key) => joinColumns([key, value])) 55 | .join("\n"), 56 | result = headers + "\n" + mainFields; 57 | 58 | postMessage(result); 59 | 60 | }; 61 | -------------------------------------------------------------------------------- /public/js/webworkers/gpx-export-worker.js: -------------------------------------------------------------------------------- 1 | importScripts("/js/lodash.min.js"); 2 | 3 | onmessage = function (event) { 4 | const header = ` 5 | 8 | 9 | 10 | Betaflight Blackbox Explorer 11 | 12 | 13 | `; 14 | 15 | const footer = ``; 16 | 17 | const timeIndex = event.data.fieldNames.indexOf("time"); 18 | const latIndex = event.data.fieldNames.indexOf("GPS_coord[0]"); 19 | const lngIndex = event.data.fieldNames.indexOf("GPS_coord[1]"); 20 | const altitudeIndex = event.data.fieldNames.indexOf("GPS_altitude"); 21 | 22 | let trkpts = ""; 23 | for (const chunk of event.data.frames) { 24 | for (const frame of chunk) { 25 | if (!frame[latIndex] || !frame[lngIndex]) { 26 | continue; 27 | } 28 | const timeMillis = Math.floor(frame[timeIndex] / 1000); 29 | const lat = frame[latIndex] / 10000000; 30 | const lng = frame[lngIndex] / 10000000; 31 | const altitude = frame[altitudeIndex] / 10; 32 | 33 | let date = new Date(event.data.sysConfig["Log start datetime"]); 34 | date.setTime(date.getTime() + timeMillis); 35 | 36 | let trkpt = ``; 37 | trkpt += `${altitude}`; 38 | trkpt += ``; 39 | trkpt += `\n`; 40 | 41 | trkpts += trkpt; 42 | } 43 | } 44 | 45 | let trk = ` 46 | 47 | ${trkpts} 48 | 49 | `; 50 | 51 | postMessage(header + "\n" + trk + "\n" + footer); 52 | }; 53 | -------------------------------------------------------------------------------- /public/js/webworkers/spectrum-export-worker.js: -------------------------------------------------------------------------------- 1 | onmessage = function(event) { 2 | const columnDelimiter = event.data.opts.columnDelimiter; 3 | const fftOutput = event.data.fftOutput; 4 | const spectrumDataLength = fftOutput.length / 2; 5 | const frequencyStep = 0.5 * event.data.blackBoxRate / spectrumDataLength; 6 | 7 | let outText = "freq" + columnDelimiter + "value" + "\n"; 8 | for (let index = 0; index < spectrumDataLength; index += 10) { 9 | const frequency = frequencyStep * index; 10 | outText += frequency.toString() + columnDelimiter + fftOutput[index].toString() + "\n"; 11 | } 12 | 13 | postMessage(outText); 14 | }; 15 | -------------------------------------------------------------------------------- /src/cache.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A FIFO cache to hold key-pair mappings. Its capacity will be at least the initialCapacity 3 | * supplied on creation, which you can increase by increasing the "capacity" property. 4 | * 5 | * One extra element beyond the set capacity will be stored which can be fetched by calling "recycle()". 6 | * This allows the oldest value to be removed in order to be reused, instead of leaving it to be collected 7 | * by the garbage collector. 8 | * 9 | * Element age is determined by the time it was added or last get()'d from the cache. 10 | */ 11 | export function FIFOCache(initialCapacity) { 12 | //Private: 13 | let queue = [], 14 | items = {}; 15 | 16 | function removeFromQueue(key) { 17 | for (let i = 0; i < queue.length; i++) { 18 | if (queue[i] == key) { 19 | //Assume there's only one copy to remove: 20 | for (let j = i; j < queue.length - 1; j++) { 21 | queue[j] = queue[j + 1]; 22 | } 23 | 24 | queue.length--; 25 | break; 26 | } 27 | } 28 | } 29 | 30 | //Public: 31 | this.capacity = initialCapacity; 32 | 33 | /** 34 | * Remove and return the oldest value from the cache to be reused, or null if the cache wasn't full. 35 | */ 36 | this.recycle = function () { 37 | if (queue.length > this.capacity) { 38 | let key = queue.shift(), 39 | result = items[key]; 40 | 41 | delete items[key]; 42 | 43 | return result; 44 | } 45 | 46 | return null; 47 | }; 48 | 49 | /** 50 | * Add a mapping for the given key to the cache. If an existing value with that key was 51 | * present, it will be overwritten. 52 | */ 53 | this.add = function (key, value) { 54 | // Was this already cached? Bump it back up to the end of the queue 55 | if (items[key] !== undefined) removeFromQueue(key); 56 | 57 | queue.push(key); 58 | 59 | items[key] = value; 60 | 61 | while (queue.length > this.capacity + 1) { 62 | delete items[queue.shift()]; 63 | } 64 | }; 65 | 66 | /** 67 | * Return the value in the cache that corresponds to the given key, or undefined if it has 68 | * expired or had never been stored. 69 | */ 70 | this.get = function (key) { 71 | let item = items[key]; 72 | 73 | if (item) { 74 | removeFromQueue(key); 75 | queue.push(key); 76 | } 77 | 78 | return item; 79 | }; 80 | 81 | /** 82 | * Erase the entire content of the cache 83 | */ 84 | this.clear = function () { 85 | queue = []; 86 | items = {}; 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /src/configuration.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration 3 | * 4 | * Handle loading and display of configuration file 5 | * 6 | */ 7 | 8 | export function Configuration(file, configurationDefaults, showConfigFile) { 9 | // Private Variables 10 | let that = this; // generic pointer back to this function 11 | let fileData; // configuration file information 12 | let fileLinesArray; // Store the contents of the file globally 13 | 14 | function renderFileContentList(configurationList, filter) { 15 | let li; 16 | 17 | // Clear the contents of the list 18 | $("li", configurationList).remove(); 19 | 20 | for (let i = 0; i < fileLinesArray.length; i++) { 21 | if (!filter || filter.length < 1) { 22 | //Everything 23 | // li = $('
  • ' + fileLinesArray[i] + '
  • '); 24 | 25 | li = $( 26 | `
  • ${fileLinesArray[i].length == 0 ? " " : fileLinesArray[i]}
  • ` 31 | ); // Removed default syntax highlighting 32 | configurationList.append(li); 33 | } else { 34 | try { 35 | let regFilter = new RegExp(`(.*)(${filter})(.*)`, "i"); 36 | let highLight = fileLinesArray[i].match(regFilter); 37 | if (highLight != null) { 38 | // don't include blank lines 39 | li = $( 40 | `
  • ${highLight[1]}${highLight[2]}${highLight[3]}
  • ` 41 | ); // Removed default syntax highlighting 42 | configurationList.append(li); 43 | } 44 | } catch (e) { 45 | continue; 46 | } 47 | } 48 | } 49 | } 50 | 51 | function renderFileContents(filter) { 52 | let configurationElem = ".configuration-file", // point to the actual element in index.html 53 | configurationDiv = $( 54 | `
    ` + 55 | `
    ` + 56 | `

    ${file.name}` + 57 | `

    ` + 58 | `` + 59 | `
    ` + 60 | `
      ` + 61 | `
      ` 62 | ), 63 | configurationTitle = $("h3", configurationDiv), 64 | li; 65 | 66 | // now replace the element in the index.html with the loaded file information 67 | $(configurationElem).replaceWith(configurationDiv); 68 | 69 | let configurationList = $(".configuration-list"); 70 | renderFileContentList(configurationList, null); 71 | 72 | //configurationTitle.text(file.name); 73 | $("#status-bar .configuration-file-name").text(file.name); 74 | 75 | // now replace the element in the index.html with the loaded file information 76 | $(configurationElem).replaceWith(configurationDiv); 77 | 78 | // Add close icon 79 | $(".configuration-close").click(function () { 80 | if (showConfigFile) showConfigFile(false); // hide the config file 81 | }); 82 | } 83 | 84 | function loadFile(file) { 85 | let reader = new FileReader(); 86 | fileData = file; // Store the data locally; 87 | 88 | reader.onload = function (e) { 89 | let data = e.target.result; // all the data 90 | 91 | fileLinesArray = data.split("\n"); // separated into lines 92 | 93 | renderFileContents(); 94 | 95 | // Add user configurable file filter 96 | $(".configuration-filter").keyup(function () { 97 | let newFilter = $(".configuration-filter").val(); 98 | 99 | let configurationList = $(".configuration-list"); 100 | renderFileContentList(configurationList, newFilter); 101 | }); 102 | }; 103 | 104 | reader.readAsText(file); 105 | } 106 | 107 | // Public variables and functions 108 | this.getFile = function () { 109 | return fileData; 110 | }; 111 | 112 | loadFile(file); // configuration file loaded 113 | 114 | // Add filter 115 | } 116 | 117 | export function ConfigurationDefaults(prefs) { 118 | // Special configuration file that handles default values only 119 | 120 | // Private Variables 121 | let that = this; // generic pointer back to this function 122 | let fileData; // configuration file information 123 | let fileLinesArray = null; // Store the contents of the file globally 124 | 125 | function loadFileFromCache() { 126 | // Get the file from the cache if it exists 127 | prefs.get("configurationDefaults", function (item) { 128 | if (item) { 129 | fileLinesArray = item; 130 | } else { 131 | fileLinesArray = null; 132 | } 133 | }); 134 | } 135 | 136 | this.loadFile = function (file) { 137 | let reader = new FileReader(); 138 | fileData = file; // Store the data locally; 139 | 140 | reader.onload = function (e) { 141 | let data = e.target.result; // all the data 142 | fileLinesArray = data.split("\n"); // separated into lines 143 | 144 | prefs.set("configurationDefaults", fileLinesArray); // and store it to the cache 145 | }; 146 | 147 | reader.readAsText(file); 148 | }; 149 | 150 | // Public variables and functions 151 | this.getFile = function () { 152 | return fileData; 153 | }; 154 | 155 | this.getLines = function () { 156 | return fileLinesArray; 157 | }; 158 | 159 | this.hasDefaults = function () { 160 | return fileLinesArray != null; // is there a default file array 161 | }; 162 | 163 | this.isDefault = function (line) { 164 | // Returns the default line equivalent 165 | 166 | if (!fileLinesArray) return true; // by default, lines are the same if there is no default file loaded 167 | 168 | for (let i = 0; i < fileLinesArray.length; i++) { 169 | if (line != fileLinesArray[i]) continue; // not the same line, keep looking 170 | return true; // line is same as default 171 | } 172 | return false; // line not the same as default or not found 173 | }; 174 | 175 | loadFileFromCache(); // configuration file loaded 176 | } 177 | -------------------------------------------------------------------------------- /src/craft_2d.js: -------------------------------------------------------------------------------- 1 | export function Craft2D(flightLog, canvas, propColors) { 2 | let ARM_THICKNESS_MULTIPLIER = 0.18, 3 | ARM_EXTEND_BEYOND_MOTOR_MULTIPLIER = 1.1, 4 | CENTRAL_HUB_SIZE_MULTIPLIER = 0.3, 5 | MOTOR_LABEL_SPACING = 10; 6 | 7 | let canvasContext = canvas.getContext("2d"); 8 | 9 | let craftParameters = {}; 10 | 11 | let customMix; 12 | 13 | if (userSettings != null) { 14 | customMix = userSettings.customMix; 15 | } else { 16 | customMix = null; 17 | } 18 | 19 | let numMotors; 20 | if (!customMix) { 21 | numMotors = propColors.length; 22 | } else { 23 | numMotors = customMix.motorOrder.length; 24 | } 25 | 26 | let shadeColors = [], 27 | craftColor = "rgb(76,76,76)", 28 | armLength, 29 | bladeRadius; 30 | 31 | let motorOrder, yawOffset; 32 | 33 | // Motor numbering in counter-clockwise order starting from the 3 o'clock position 34 | if (!customMix) { 35 | switch (numMotors) { 36 | case 3: 37 | motorOrder = [0, 1, 2]; // Put motor 1 at the right 38 | yawOffset = -Math.PI / 2; 39 | break; 40 | case 4: 41 | motorOrder = [1, 3, 2, 0]; // Numbering for quad-plus 42 | yawOffset = Math.PI / 4; // Change from "plus" orientation to "X" 43 | break; 44 | case 6: 45 | motorOrder = [4, 1, 3, 5, 2, 0]; 46 | yawOffset = 0; 47 | break; 48 | case 8: 49 | motorOrder = [5, 1, 4, 0, 7, 3, 6, 2]; 50 | yawOffset = Math.PI / 8; // Put two motors at the front 51 | break; 52 | default: 53 | motorOrder = new Array(numMotors); 54 | for (var i = 0; i < numMotors; i++) { 55 | motorOrder[i] = i; 56 | } 57 | yawOffset = 0; 58 | } 59 | } else { 60 | motorOrder = customMix.motorOrder; 61 | yawOffset = customMix.yawOffset; 62 | } 63 | 64 | function makeColorHalfStrength(color) { 65 | color = parseInt(color.substring(1), 16); 66 | 67 | return `rgba(${(color >> 16) & 0xff},${(color >> 8) & 0xff},${ 68 | color & 0xff 69 | },0.5)`; 70 | } 71 | 72 | /** 73 | * Examine the log metadata to determine the layout of motors for the 2D craft model. Returns the craft parameters 74 | * object. 75 | */ 76 | function decide2DCraftParameters() { 77 | switch (numMotors) { 78 | case 2: 79 | craftParameters.motors = [ 80 | { 81 | x: -1, 82 | y: 0, 83 | direction: -1, 84 | color: propColors[motorOrder[0]], 85 | }, 86 | { 87 | x: 1, 88 | y: 0, 89 | direction: -1, 90 | color: propColors[motorOrder[1]], 91 | }, 92 | ]; 93 | break; 94 | case 3: 95 | craftParameters.motors = [ 96 | { 97 | x: 1, 98 | y: 0, 99 | direction: -1, 100 | color: propColors[motorOrder[0]], 101 | }, 102 | { 103 | x: -0.71, 104 | y: -0.71, 105 | direction: -1, 106 | color: propColors[motorOrder[1]], 107 | }, 108 | { 109 | x: -0.71, 110 | y: +0.71, 111 | direction: -1, 112 | color: propColors[motorOrder[2]], 113 | }, 114 | ]; 115 | break; 116 | case 4: // Classic '+' quad, yawOffset rotates it into an X 117 | craftParameters.motors = [ 118 | { 119 | x: 1 /*0.71,*/, 120 | y: 0 /*-0.71,*/, 121 | direction: -1, 122 | color: propColors[motorOrder[1]], 123 | }, 124 | { 125 | x: 0 /*-0.71,*/, 126 | y: -1 /*-0.71,*/, 127 | direction: 1, 128 | color: propColors[motorOrder[3]], 129 | }, 130 | { 131 | x: -1 /*-0.71,*/, 132 | y: 0 /*0.71,*/, 133 | direction: -1, 134 | color: propColors[motorOrder[2]], 135 | }, 136 | { 137 | x: 0 /*0.71,*/, 138 | y: 1 /*0.71,*/, 139 | direction: 1, 140 | color: propColors[motorOrder[0]], 141 | }, 142 | ]; 143 | break; 144 | default: 145 | craftParameters.motors = []; 146 | 147 | for (let i = 0; i < numMotors; i++) { 148 | craftParameters.motors.push({ 149 | x: Math.cos((i / numMotors) * Math.PI * 2), 150 | y: Math.sin((i / numMotors) * Math.PI * 2), 151 | direction: Math.pow(-1, i), 152 | color: propColors[i], 153 | }); 154 | } 155 | break; 156 | } 157 | 158 | return craftParameters; 159 | } 160 | 161 | this.render = function (frame, frameFieldIndexes) { 162 | let motorIndex, 163 | sysConfig = flightLog.getSysConfig(); 164 | 165 | canvasContext.save(); 166 | 167 | canvasContext.clearRect(0, 0, canvas.width, canvas.height); // clear the craft 168 | canvasContext.translate(canvas.width * 0.5, canvas.height * 0.5); 169 | canvasContext.rotate(-yawOffset); 170 | canvasContext.scale(0.5, 0.5); // scale to fit 171 | 172 | //Draw arms 173 | canvasContext.lineWidth = armLength * ARM_THICKNESS_MULTIPLIER; 174 | 175 | canvasContext.lineCap = "round"; 176 | canvasContext.strokeStyle = craftColor; 177 | 178 | canvasContext.beginPath(); 179 | 180 | for (i = 0; i < numMotors; i++) { 181 | canvasContext.moveTo(0, 0); 182 | 183 | canvasContext.lineTo( 184 | armLength * 185 | ARM_EXTEND_BEYOND_MOTOR_MULTIPLIER * 186 | craftParameters.motors[i].x, 187 | armLength * 188 | ARM_EXTEND_BEYOND_MOTOR_MULTIPLIER * 189 | craftParameters.motors[i].y 190 | ); 191 | } 192 | 193 | canvasContext.stroke(); 194 | 195 | //Draw the central hub 196 | canvasContext.beginPath(); 197 | 198 | canvasContext.moveTo(0, 0); 199 | canvasContext.arc( 200 | 0, 201 | 0, 202 | armLength * CENTRAL_HUB_SIZE_MULTIPLIER, 203 | 0, 204 | 2 * Math.PI 205 | ); 206 | 207 | canvasContext.fillStyle = craftColor; 208 | canvasContext.fill(); 209 | 210 | for (i = 0; i < numMotors; i++) { 211 | let motorValue = frame[frameFieldIndexes[`motor[${motorOrder[i]}]`]]; 212 | 213 | canvasContext.save(); 214 | { 215 | //Move to the motor center 216 | canvasContext.translate( 217 | armLength * craftParameters.motors[i].x, 218 | armLength * craftParameters.motors[i].y 219 | ); 220 | 221 | canvasContext.fillStyle = shadeColors[motorOrder[i]]; 222 | 223 | canvasContext.beginPath(); 224 | 225 | canvasContext.moveTo(0, 0); 226 | canvasContext.arc(0, 0, bladeRadius, 0, Math.PI * 2, false); 227 | 228 | canvasContext.fill(); 229 | 230 | canvasContext.fillStyle = propColors[motorOrder[i]]; 231 | 232 | canvasContext.beginPath(); 233 | 234 | canvasContext.moveTo(0, 0); 235 | canvasContext.arc( 236 | 0, 237 | 0, 238 | bladeRadius, 239 | -Math.PI / 2, 240 | -Math.PI / 2 + 241 | (Math.PI * 2 * Math.max(motorValue - sysConfig.motorOutput[0], 0)) / 242 | (sysConfig.motorOutput[1] - sysConfig.motorOutput[0]), 243 | false 244 | ); 245 | 246 | canvasContext.fill(); 247 | 248 | /* Disable motor value markers 249 | var 250 | motorLabel = "" + motorValue; 251 | 252 | if (craftParameters.motors[motorIndex].x > 0) { 253 | canvasContext.textAlign = 'left'; 254 | canvasContext.fillText(motorLabel, bladeRadius + MOTOR_LABEL_SPACING, 0); 255 | } else { 256 | canvasContext.textAlign = 'right'; 257 | canvasContext.fillText(motorLabel, -(bladeRadius + MOTOR_LABEL_SPACING), 0); 258 | } 259 | */ 260 | } 261 | canvasContext.restore(); 262 | } 263 | canvasContext.restore(); 264 | }; 265 | 266 | for (var i = 0; i < propColors.length; i++) { 267 | shadeColors.push(makeColorHalfStrength(propColors[i])); 268 | } 269 | 270 | decide2DCraftParameters(); 271 | 272 | this.resize = function (width, height) { 273 | if (canvas.width != width || canvas.height != height) { 274 | canvas.width = width; 275 | canvas.height = height; 276 | } 277 | 278 | armLength = 0.5 * height; 279 | 280 | if (numMotors >= 6) { 281 | bladeRadius = armLength * 0.4; 282 | } else { 283 | bladeRadius = armLength * 0.6; 284 | } 285 | }; 286 | 287 | // Assume we're to fill the entire canvas until we're told otherwise by .resize() 288 | this.resize(canvas.width, canvas.height); 289 | } 290 | -------------------------------------------------------------------------------- /src/css/keys_dialog.css: -------------------------------------------------------------------------------- 1 | .keys-dialog .parameter th { 2 | background-color: #828885; 3 | padding: 4px; 4 | border-left: 0 solid #ccc; 5 | border-bottom: 1px solid #ccc; 6 | font-weight: bold; 7 | color: white; 8 | text-align: left; 9 | } 10 | 11 | .keys-dialog .modal-dialog { 12 | width: 90%; 13 | min-width: 800px; 14 | } 15 | 16 | .keys-dialog .gui_box { 17 | margin-bottom: 0; 18 | } 19 | 20 | .keys-dialog .spacer_box { 21 | padding-bottom: 10px; 22 | float: left; 23 | width: calc(100% - 5px); 24 | } 25 | 26 | .keys-dialog .show { 27 | width: 110px; 28 | float: right; 29 | margin-right: 3px; 30 | } 31 | 32 | .keys-dialog .show a { 33 | margin-left: 10px; 34 | width: calc(100% - 10px); 35 | } 36 | 37 | .keys-dialog .helpicon { 38 | margin-top: -1px; 39 | } 40 | 41 | .keys-dialog li { 42 | display: flex; 43 | position: static; 44 | margin-bottom: 0.05em; 45 | height: 39px; 46 | } 47 | 48 | .keys-dialog ul { 49 | list-style-type: none; 50 | padding: initial; 51 | float: left; 52 | width: calc(100% - 4px); 53 | } 54 | 55 | .keys-dialog .keys { 56 | text-align: left; 57 | } 58 | 59 | .keys-dialog .key { 60 | margin: 3px; 61 | background-color: beige; 62 | display: inline-block; 63 | border-radius: 8px; 64 | border-style: groove; 65 | padding: 5px 10px; 66 | vertical-align: middle; 67 | } 68 | 69 | .keys-dialog .normal { 70 | margin: 3px; 71 | display: inline-block; 72 | border-radius: 8px; 73 | border-style: none; 74 | padding: 8px 0; 75 | vertical-align: middle; 76 | } 77 | 78 | .keys-dialog div.description { 79 | flex: 1; 80 | padding-left: 10px; 81 | color: #7d7d7d; 82 | text-align: justify; 83 | position: relative; 84 | align-self: center; 85 | font-size: 11px; 86 | } 87 | 88 | .keys-dialog .gui_box_titlebar .helpicon { 89 | margin-top: 5px; 90 | margin-right: 5px; 91 | } 92 | 93 | .keys-dialog .noline td { 94 | border: 0 solid #ccc; 95 | } 96 | 97 | .keys-dialog .gui_box span { 98 | font-style: normal; 99 | font-family: "open_sansregular", Arial, sans-serif; 100 | line-height: 19px; 101 | color: #7d7d7d; 102 | font-size: 11px; 103 | text-align: left; 104 | min-width: 165px; 105 | vertical-align: middle; 106 | } 107 | 108 | .noline { 109 | border: 0; 110 | } 111 | 112 | .keys-dialog .right { 113 | float: right; 114 | } 115 | 116 | .keys-dialog .leftzero { 117 | padding-left: 0; 118 | } 119 | 120 | .keys-dialog .borderleft { 121 | border-top-left-radius: 3px; 122 | border-top-right-radius: 3px; 123 | } 124 | 125 | .keys-dialog .textleft { 126 | width: 25%; 127 | float: left; 128 | text-align: left; 129 | } 130 | 131 | .keys-dialog .topspacer { 132 | margin-top: 15px; 133 | } 134 | -------------------------------------------------------------------------------- /src/css/menu.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bgColor: #0fddaf; 3 | --txtColor: #ffffff; 4 | --borColor: rgba(0, 0, 0, 0); 5 | --sizeVar: 8px; 6 | --textPrimary: #4b4760; 7 | --textSecondary: #7f7989; 8 | --borderColor: #cccccc; 9 | } 10 | 11 | .flexDiv { 12 | display: flex; 13 | flex-direction: row; 14 | align-items: flex-start; 15 | width: fit-content; 16 | } 17 | 18 | .selectWrapper { 19 | width: 100%; 20 | position: relative; 21 | padding-top: calc(var(--sizeVar) / 2); 22 | } 23 | 24 | .dropdown-content { 25 | display: none; 26 | border: 1px solid var(--borderColor); 27 | box-sizing: border-box; 28 | border-radius: calc(var(--sizeVar) / 2); 29 | position: absolute; 30 | width: auto; 31 | left: 0; 32 | right: 0; 33 | overflow: visible; 34 | background: #ffffff; 35 | z-index: 300; 36 | min-width: fit-content; 37 | } 38 | .dropdown-content div { 39 | color: var(--textPrimary); 40 | padding: 2px; 41 | cursor: pointer; 42 | white-space: nowrap; 43 | width: auto; 44 | } 45 | 46 | .show { 47 | display: block; 48 | } 49 | 50 | .dropdown-content div:hover { 51 | background-color: #f6f6f6; 52 | } 53 | 54 | .dropdown-content div:active { 55 | font-style: italic; 56 | } 57 | 58 | .bottomBorder { 59 | border-bottom: 1px solid var(--borderColor); 60 | } 61 | .topBorder { 62 | border-top: 1px solid var(--borderColor); 63 | } 64 | .iconDiv { 65 | display: flex; 66 | align-items: center; 67 | justify-content: space-between; 68 | } 69 | .noSpace { 70 | justify-content: flex-start; 71 | gap: 6px; 72 | } 73 | .titleDiv { 74 | pointer-events: none; 75 | font-weight: 600; 76 | } 77 | .justHover i { 78 | opacity: 0; 79 | } 80 | .justHover:hover i { 81 | opacity: 1; 82 | } 83 | .dropdown-content .placeholder { 84 | color: var(--textSecondary); 85 | font-style: italic; 86 | } 87 | .dropdown-content .narrow { 88 | padding-top: 10px; 89 | padding-bottom: 10px; 90 | } 91 | .dropdown-content i { 92 | color: var(--textSecondary); 93 | } 94 | .menu-button { 95 | background-color: #f6f6f6; 96 | font-weight: 700; 97 | border: 1px solid black; 98 | border-radius: 3px; 99 | } 100 | .minmax-control { 101 | min-width: 60px; 102 | } 103 | -------------------------------------------------------------------------------- /src/css/user_settings_dialog.css: -------------------------------------------------------------------------------- 1 | .user-settings-dialog .modal-dialog { 2 | width: 75%; 3 | min-width: 954px; 4 | } 5 | 6 | .user-settings-dialog input[type="number"] { 7 | margin-right: 2px; 8 | min-width: 48px; 9 | } 10 | 11 | .user-settings-dialog .custom-mixes-group img { 12 | width: 125px; 13 | max-height: 125px; 14 | } 15 | 16 | .user-settings-dialog .stick-mode-group img { 17 | width: 200px; 18 | max-height: 125px; 19 | } 20 | 21 | .user-settings-dialog .watermark-logo-image { 22 | background-image: url("/images/logo-background.png"); 23 | height: 100px; 24 | width: 100px; 25 | background-repeat: no-repeat; 26 | background-size: 100px 100px; 27 | } 28 | 29 | .user-settings-dialog .watermark-group img { 30 | width: 100px; 31 | height: 100px; 32 | /* background-color:aquamarine; */ 33 | padding: 2px; 34 | border: 1px solid #021a40; 35 | opacity: 1; 36 | } 37 | 38 | .user-settings-dialog .watermark-group input[type="file"] { 39 | color: #7d7d7d; 40 | margin: 2px; 41 | } 42 | 43 | .user-settings-dialog table { 44 | width: calc(100% - 2px); 45 | } 46 | 47 | .user-settings-dialog select { 48 | width: 100px; 49 | margin-left: 10px; 50 | margin-bottom: 5px; 51 | } 52 | 53 | .user-settings-dialog label.option { 54 | width: 100%; 55 | margin-bottom: 5px; 56 | position: relative; 57 | } 58 | 59 | .user-settings-dialog span { 60 | /* display: block; */ 61 | margin-left: 10px; 62 | position: absolute; 63 | left: 125px; 64 | } 65 | 66 | .user-settings-dialog label + input[type="radio"] { 67 | margin-left: 6px; 68 | margin-right: 10px; 69 | margin-bottom: 6px; 70 | } 71 | 72 | .user-settings-dialog input[type="radio"] { 73 | margin-left: 85px; 74 | margin-bottom: 5px; 75 | margin-right: 10px; 76 | } 77 | 78 | .user-settings-dialog label { 79 | width: 75px; 80 | margin-bottom: 0px; 81 | padding-top: 0px; 82 | } 83 | 84 | .user-settings-dialog table { 85 | margin-top: 10px; 86 | } 87 | 88 | .user-settings-dialog td.position { 89 | text-align-last: right; 90 | } 91 | 92 | .user-settings-dialog td.position label { 93 | width: 41px; 94 | font-style: oblique; 95 | margin-right: 6px; 96 | padding-top: 0px; 97 | } 98 | 99 | .user-settings-dialog input[type="number"] + span { 100 | position: static; 101 | display: inline; 102 | } 103 | 104 | .user-settings-dialog p, 105 | .user-settings-dialog span { 106 | font-style: normal; 107 | font-family: "open_sansregular", Arial; 108 | line-height: 19px; 109 | color: #7d7d7d; 110 | font-size: 11px; 111 | display: inline; 112 | margin-left: 2px; 113 | } 114 | 115 | /* Normal Track Override positioning*/ 116 | .user-settings-dialog input[type="checkbox"].ios-switch + div { 117 | position: absolute; 118 | left: 50px; 119 | } 120 | -------------------------------------------------------------------------------- /src/csv-exporter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {object} ExportOptions 3 | * @property {string} columnDelimiter 4 | * @property {string} stringDelimiter 5 | * @property {boolean} quoteStrings 6 | */ 7 | 8 | /** 9 | * @constructor 10 | * @param {FlightLog} flightLog 11 | * @param {ExportOptions} [opts={}] 12 | */ 13 | export function CsvExporter(flightLog, opts = {}) { 14 | var opts = _.merge( 15 | { 16 | columnDelimiter: ",", 17 | stringDelimiter: '"', 18 | quoteStrings: true, 19 | }, 20 | opts 21 | ); 22 | 23 | /** 24 | * @param {function} success is a callback triggered when export is done 25 | */ 26 | function dump(success) { 27 | let frames = _( 28 | flightLog.getChunksInTimeRange( 29 | flightLog.getMinTime(), 30 | flightLog.getMaxTime() 31 | ) 32 | ) 33 | .map((chunk) => chunk.frames) 34 | .value(), 35 | worker = new Worker("/js/webworkers/csv-export-worker.js"); 36 | 37 | worker.onmessage = (event) => { 38 | success(event.data); 39 | worker.terminate(); 40 | }; 41 | worker.postMessage({ 42 | sysConfig: flightLog.getSysConfig(), 43 | fieldNames: flightLog.getMainFieldNames(), 44 | frames: frames, 45 | opts: opts, 46 | }); 47 | } 48 | 49 | // exposed functions 50 | return { 51 | dump: dump, 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/datastream.js: -------------------------------------------------------------------------------- 1 | import { signExtend16Bit, signExtend8Bit } from "./tools"; 2 | 3 | let EOF = -1; 4 | 5 | /* 6 | * Take an array of unsigned byte data and present it as a stream with various methods 7 | * for reading data in different formats. 8 | */ 9 | export const ArrayDataStream = function (data, start, end) { 10 | this.data = data; 11 | this.eof = false; 12 | this.start = start === undefined ? 0 : start; 13 | this.end = end === undefined ? data.length : end; 14 | this.pos = this.start; 15 | }; 16 | 17 | /** 18 | * Read a single byte from the string and turn it into a JavaScript string (assuming ASCII). 19 | * 20 | * @returns String containing one character, or EOF if the end of file was reached (eof flag 21 | * is set). 22 | */ 23 | ArrayDataStream.prototype.readChar = function () { 24 | if (this.pos < this.end) return String.fromCharCode(this.data[this.pos++]); 25 | 26 | this.eof = true; 27 | return EOF; 28 | }; 29 | 30 | /** 31 | * Read one unsigned byte from the stream 32 | * 33 | * @returns Unsigned byte, or EOF if the end of file was reached (eof flag is set). 34 | */ 35 | ArrayDataStream.prototype.readByte = function () { 36 | if (this.pos < this.end) return this.data[this.pos++]; 37 | 38 | this.eof = true; 39 | return EOF; 40 | }; 41 | 42 | //Synonym: 43 | ArrayDataStream.prototype.readU8 = ArrayDataStream.prototype.readByte; 44 | 45 | ArrayDataStream.prototype.readS8 = function () { 46 | return signExtend8Bit(this.readByte()); 47 | }; 48 | 49 | ArrayDataStream.prototype.unreadChar = function (c) { 50 | this.pos--; 51 | }; 52 | 53 | ArrayDataStream.prototype.peekChar = function () { 54 | if (this.pos < this.end) return String.fromCharCode(this.data[this.pos]); 55 | 56 | this.eof = true; 57 | return EOF; 58 | }; 59 | 60 | /** 61 | * Read a (maximally 32-bit) unsigned integer from the stream which was encoded in Variable Byte format. 62 | * 63 | * @returns the unsigned integer, or 0 if a valid integer could not be read (EOF was reached or integer format 64 | * was invalid). 65 | */ 66 | ArrayDataStream.prototype.readUnsignedVB = function () { 67 | let i, 68 | b, 69 | shift = 0, 70 | result = 0; 71 | 72 | // 5 bytes is enough to encode 32-bit unsigned quantities 73 | for (i = 0; i < 5; i++) { 74 | b = this.readByte(); 75 | 76 | if (b == EOF) return 0; 77 | 78 | result = result | ((b & ~0x80) << shift); 79 | 80 | // Final byte? 81 | if (b < 128) { 82 | /* 83 | * Force the 32-bit integer to be reinterpreted as unsigned by doing an unsigned right shift, so that 84 | * the top bit being set doesn't cause it to interpreted as a negative number. 85 | */ 86 | return result >>> 0; 87 | } 88 | 89 | shift += 7; 90 | } 91 | 92 | // This VB-encoded int is too long! 93 | return 0; 94 | }; 95 | 96 | ArrayDataStream.prototype.readSignedVB = function () { 97 | let unsigned = this.readUnsignedVB(); 98 | 99 | // Apply ZigZag decoding to recover the signed value 100 | return (unsigned >>> 1) ^ -(unsigned & 1); 101 | }; 102 | 103 | ArrayDataStream.prototype.readString = function (length) { 104 | let chars = new Array(length), 105 | i; 106 | 107 | for (i = 0; i < length; i++) { 108 | chars[i] = this.readChar(); 109 | } 110 | 111 | return chars.join(""); 112 | }; 113 | 114 | ArrayDataStream.prototype.readS16 = function () { 115 | let b1 = this.readByte(), 116 | b2 = this.readByte(); 117 | 118 | return signExtend16Bit(b1 | (b2 << 8)); 119 | }; 120 | 121 | ArrayDataStream.prototype.readU16 = function () { 122 | let b1 = this.readByte(), 123 | b2 = this.readByte(); 124 | 125 | return b1 | (b2 << 8); 126 | }; 127 | 128 | ArrayDataStream.prototype.readU32 = function () { 129 | let b1 = this.readByte(), 130 | b2 = this.readByte(), 131 | b3 = this.readByte(), 132 | b4 = this.readByte(); 133 | return b1 | (b2 << 8) | (b3 << 16) | (b4 << 24); 134 | }; 135 | 136 | /** 137 | * Search for the string 'needle' beginning from the current stream position up 138 | * to the end position. Return the offset of the first occurrence found. 139 | * 140 | * @param needle 141 | * String to search for 142 | * @returns Position of the start of needle in the stream, or -1 if it wasn't 143 | * found 144 | */ 145 | ArrayDataStream.prototype.nextOffsetOf = function (needle) { 146 | let i, j; 147 | 148 | for (i = this.pos; i <= this.end - needle.length; i++) { 149 | if (this.data[i] == needle[0]) { 150 | for (j = 1; j < needle.length && this.data[i + j] == needle[j]; j++); 151 | 152 | if (j == needle.length) return i; 153 | } 154 | } 155 | 156 | return -1; 157 | }; 158 | 159 | ArrayDataStream.prototype.EOF = EOF; 160 | -------------------------------------------------------------------------------- /src/expo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a lookup-table based expo curve, which takes values that range between -inputrange and +inputRange, and 3 | * scales them to -outputRange to +outputRange with the given power curve (curve <1.0 exaggerates values near the origin, 4 | * curve = 1.0 is a straight line mapping). 5 | */ 6 | export function ExpoCurve(offset, power, inputRange, outputRange, steps) { 7 | let curve, inputScale, rawInputScale; 8 | 9 | function lookupStraightLine(input) { 10 | return (input + offset) * inputScale; 11 | } 12 | 13 | this.lookupRaw = function (input) { 14 | return (input + offset) * rawInputScale; 15 | }; 16 | 17 | this.getCurve = function () { 18 | return { 19 | offset: offset, 20 | power: power, 21 | inputRange: inputRange, 22 | outputRange: outputRange, 23 | steps: steps, 24 | }; 25 | }; 26 | 27 | /** 28 | * An approximation of lookupMathPow by precomputing several expo curve points and interpolating between those 29 | * points using straight line interpolation. 30 | * 31 | * The error will be largest in the area of the curve where the slope changes the fastest with respect to input 32 | * (e.g. the approximation will be too straight near the origin when power < 1.0, but a good fit far from the origin) 33 | */ 34 | function lookupInterpolatedCurve(input) { 35 | let valueInCurve, prevStepIndex; 36 | 37 | input += offset; 38 | 39 | valueInCurve = Math.abs(input * inputScale); 40 | prevStepIndex = Math.floor(valueInCurve); 41 | 42 | /* If the input value lies beyond the stated input range, use the final 43 | * two points of the curve to extrapolate out (the "curve" out there is a straight line, though) 44 | */ 45 | if (prevStepIndex > steps - 2) { 46 | prevStepIndex = steps - 2; 47 | } 48 | 49 | //Straight-line interpolation between the two curve points 50 | let proportion = valueInCurve - prevStepIndex, 51 | result = 52 | curve[prevStepIndex] + 53 | (curve[prevStepIndex + 1] - curve[prevStepIndex]) * proportion; 54 | 55 | if (input < 0) return -result; 56 | return result; 57 | } 58 | 59 | function lookupMathPow(input) { 60 | input += offset; 61 | 62 | let result = Math.pow(Math.abs(input) / inputRange, power) * outputRange; 63 | 64 | if (input < 0) return -result; 65 | return result; 66 | } 67 | 68 | rawInputScale = outputRange / inputRange; 69 | 70 | // If steps argument isn't supplied, use a reasonable default 71 | if (steps === undefined) { 72 | steps = 12; 73 | } 74 | 75 | if (steps <= 2 || power == 1.0) { 76 | //Curve is actually a straight line 77 | inputScale = outputRange / inputRange; 78 | 79 | this.lookup = lookupStraightLine; 80 | } else { 81 | let stepSize = 1.0 / (steps - 1), 82 | i; 83 | 84 | curve = new Array(steps); 85 | 86 | inputScale = (steps - 1) / inputRange; 87 | 88 | for (i = 0; i < steps; i++) { 89 | curve[i] = Math.pow(i * stepSize, power) * outputRange; 90 | } 91 | 92 | this.lookup = lookupInterpolatedCurve; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/gps_transform.js: -------------------------------------------------------------------------------- 1 | export function GPS_transform(Lat0, Lon0, H0, Heading) { 2 | 3 | function deg2rad(deg) { 4 | return deg * Math.PI / 180.0; 5 | } 6 | 7 | Lat0 = deg2rad(Lat0); 8 | Lon0 = deg2rad(Lon0); 9 | const Semimajor = 6378137.0, 10 | Flat = 1.0 / 298.257223563, 11 | Ecc_2 = Flat * (2 - Flat), 12 | SinB = Math.sin(Lat0), 13 | CosB = Math.cos(Lat0), 14 | SinL = Math.sin(Lon0), 15 | CosL = Math.cos(Lon0), 16 | N = Semimajor / Math.sqrt(1.0 - Ecc_2 * SinB * SinB), 17 | 18 | a11 = -SinB * CosL, 19 | a12 = -SinB * SinL, 20 | a13 = CosB, 21 | a21 = -SinL, 22 | a22 = CosL, 23 | a23 = 0, 24 | a31 = CosL * CosB, 25 | a32 = CosB * SinL, 26 | a33 = SinB, 27 | 28 | X0 = (N + H0) * CosB * CosL, 29 | Y0 = (N + H0) * CosB * SinL, 30 | Z0 = (N + H0 - Ecc_2 * N) * SinB, 31 | c11 = Math.cos( deg2rad(Heading) ), 32 | c12 = Math.sin( deg2rad(Heading) ), 33 | c21 = -c12, 34 | c22 = c11; 35 | 36 | this.WGS_ECEF = function (Lat, Lon, H) { 37 | Lat = deg2rad(Lat); 38 | Lon = deg2rad(Lon); 39 | const 40 | SinB = Math.sin(Lat), 41 | CosB = Math.cos(Lat), 42 | SinL = Math.sin(Lon), 43 | CosL = Math.cos(Lon), 44 | N = Semimajor / Math.sqrt(1 - Ecc_2 * SinB * SinB); 45 | 46 | return { 47 | x: (N + H) * CosB * CosL, 48 | y: (N + H) * CosB * SinL, 49 | z: (N + H - Ecc_2 * N) * SinB, 50 | }; 51 | }; 52 | 53 | this.ECEF_BS = function (pos) { 54 | const PosX1= a11 * (pos.x - X0) + a12 * (pos.y - Y0) + a13 * (pos.z - Z0); 55 | const PosZ1= a21 * (pos.x - X0) + a22 * (pos.y - Y0) + a23 * (pos.z - Z0); 56 | 57 | return { 58 | x: c11 * PosX1 + c12 * PosZ1, 59 | y: a31 * (pos.x - X0) + a32 * (pos.y - Y0) + a33 * (pos.z - Z0), 60 | z: c21 * PosX1 + c22 * PosZ1, 61 | }; 62 | }; 63 | 64 | this.WGS_BS = function (Lat, Lon, H) { 65 | return this.ECEF_BS(this.WGS_ECEF(Lat, Lon, H)); 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /src/gpx-exporter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @constructor 3 | * @param {FlightLog} flightLog 4 | */ 5 | export function GpxExporter(flightLog) { 6 | /** 7 | * @param {function} success is a callback triggered when export is done 8 | */ 9 | function dump(success) { 10 | let frames = _( 11 | flightLog.getChunksInTimeRange( 12 | flightLog.getMinTime(), 13 | flightLog.getMaxTime() 14 | ) 15 | ) 16 | .map((chunk) => chunk.frames) 17 | .value(), 18 | worker = new Worker("/js/webworkers/gpx-export-worker.js"); 19 | 20 | worker.onmessage = (event) => { 21 | success(event.data); 22 | worker.terminate(); 23 | }; 24 | worker.postMessage({ 25 | sysConfig: flightLog.getSysConfig(), 26 | fieldNames: flightLog.getMainFieldNames(), 27 | frames: frames, 28 | }); 29 | } 30 | 31 | // exposed functions 32 | return { 33 | dump: dump, 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/imu.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This IMU code is used for attitude estimation, and is directly derived from Baseflight's imu.c. 3 | */ 4 | 5 | export function IMU(copyFrom) { 6 | // Constants: 7 | let RAD = Math.PI / 180.0, 8 | ROLL = 0, 9 | PITCH = 1, 10 | YAW = 2, 11 | THROTTLE = 3, 12 | X = 0, 13 | Y = 1, 14 | Z = 2, 15 | //Settings that would normally be set by the user in MW config: 16 | gyro_cmpf_factor = 600, 17 | gyro_cmpfm_factor = 250, 18 | accz_lpf_cutoff = 5.0, 19 | magneticDeclination = 0, // user to set to local declination in degrees * 10 20 | //Calculate RC time constant used in the accZ lpf: 21 | fc_acc = 0.5 / (Math.PI * accz_lpf_cutoff), 22 | INV_GYR_CMPF_FACTOR = 1.0 / (gyro_cmpf_factor + 1.0), 23 | INV_GYR_CMPFM_FACTOR = 1.0 / (gyro_cmpfm_factor + 1.0); 24 | 25 | // ************************************************** 26 | // Simplified IMU based on "Complementary Filter" 27 | // Inspired by http://starlino.com/imu_guide.html 28 | // 29 | // adapted by ziss_dm : http://www.multiwii.com/forum/viewtopic.php?f=8&t=198 30 | // 31 | // The following ideas was used in this project: 32 | // 1) Rotation matrix: http://en.wikipedia.org/wiki/Rotation_matrix 33 | // 34 | // Currently Magnetometer uses separate CF which is used only 35 | // for heading approximation. 36 | // 37 | // ************************************************** 38 | 39 | function normalizeVector(src, dest) { 40 | let length = Math.sqrt(src.X * src.X + src.Y * src.Y + src.Z * src.Z); 41 | 42 | if (length !== 0) { 43 | dest.X = src.X / length; 44 | dest.Y = src.Y / length; 45 | dest.Z = src.Z / length; 46 | } 47 | } 48 | 49 | function rotateVector(v, delta) { 50 | // This does a "proper" matrix rotation using gyro deltas without small-angle approximation 51 | let v_tmp = { X: v.X, Y: v.Y, Z: v.Z }, 52 | mat = [ 53 | [0, 0, 0], 54 | [0, 0, 0], 55 | [0, 0, 0], 56 | ], 57 | cosx, 58 | sinx, 59 | cosy, 60 | siny, 61 | cosz, 62 | sinz, 63 | coszcosx, 64 | sinzcosx, 65 | coszsinx, 66 | sinzsinx; 67 | 68 | cosx = Math.cos(delta[ROLL]); 69 | sinx = Math.sin(delta[ROLL]); 70 | cosy = Math.cos(delta[PITCH]); 71 | siny = Math.sin(delta[PITCH]); 72 | cosz = Math.cos(delta[YAW]); 73 | sinz = Math.sin(delta[YAW]); 74 | 75 | coszcosx = cosz * cosx; 76 | sinzcosx = sinz * cosx; 77 | coszsinx = sinx * cosz; 78 | sinzsinx = sinx * sinz; 79 | 80 | mat[0][0] = cosz * cosy; 81 | mat[0][1] = -cosy * sinz; 82 | mat[0][2] = siny; 83 | mat[1][0] = sinzcosx + coszsinx * siny; 84 | mat[1][1] = coszcosx - sinzsinx * siny; 85 | mat[1][2] = -sinx * cosy; 86 | mat[2][0] = sinzsinx - coszcosx * siny; 87 | mat[2][1] = coszsinx + sinzcosx * siny; 88 | mat[2][2] = cosy * cosx; 89 | 90 | v.X = v_tmp.X * mat[0][0] + v_tmp.Y * mat[1][0] + v_tmp.Z * mat[2][0]; 91 | v.Y = v_tmp.X * mat[0][1] + v_tmp.Y * mat[1][1] + v_tmp.Z * mat[2][1]; 92 | v.Z = v_tmp.X * mat[0][2] + v_tmp.Y * mat[1][2] + v_tmp.Z * mat[2][2]; 93 | } 94 | 95 | // Rotate the accel values into the earth frame and subtract acceleration due to gravity from the result 96 | function calculateAccelerationInEarthFrame(accSmooth, attitude, acc_1G) { 97 | let rpy = [-attitude.roll, -attitude.pitch, -attitude.heading], 98 | result = { 99 | X: accSmooth[0], 100 | Y: accSmooth[1], 101 | Z: accSmooth[2], 102 | }; 103 | 104 | rotateVector(result, rpy); 105 | 106 | result.Z -= acc_1G; 107 | 108 | return result; 109 | } 110 | 111 | // Use the craft's estimated roll/pitch to compensate for the roll/pitch of the magnetometer reading 112 | function calculateHeading(vec, roll, pitch) { 113 | let cosineRoll = Math.cos(roll), 114 | sineRoll = Math.sin(roll), 115 | cosinePitch = Math.cos(pitch), 116 | sinePitch = Math.sin(pitch), 117 | headingX = 118 | vec.X * cosinePitch + 119 | vec.Y * sineRoll * sinePitch + 120 | vec.Z * sinePitch * cosineRoll, 121 | headingY = vec.Y * cosineRoll - vec.Z * sineRoll, 122 | heading = 123 | Math.atan2(headingY, headingX) + (magneticDeclination / 10.0) * RAD; // RAD = pi/180 124 | 125 | heading += 2 * Math.PI; // positive all the time, we want zero to return pi 126 | if (heading > 2 * Math.PI) { 127 | heading -= 2 * Math.PI; 128 | } 129 | 130 | return heading; 131 | } 132 | 133 | /** 134 | * Using the given raw data, update the IMU state and return the new estimate for the attitude. 135 | */ 136 | this.updateEstimatedAttitude = function ( 137 | gyroADC, 138 | accSmooth, 139 | currentTime, 140 | acc_1G, 141 | gyroScale, 142 | magADC 143 | ) { 144 | let accMag = 0, 145 | deltaTime, 146 | scale, 147 | deltaGyroAngle = [0, 0, 0]; 148 | 149 | if (this.previousTime === false) { 150 | deltaTime = 1; 151 | } else { 152 | deltaTime = currentTime - this.previousTime; 153 | } 154 | 155 | scale = deltaTime * gyroScale; 156 | this.previousTime = currentTime; 157 | 158 | // Initialization 159 | for (let axis = 0; axis < 3; axis++) { 160 | deltaGyroAngle[axis] = gyroADC[axis] * scale; 161 | 162 | accMag += accSmooth[axis] * accSmooth[axis]; 163 | } 164 | accMag = (accMag * 100) / (acc_1G * acc_1G); 165 | 166 | rotateVector(this.estimateGyro, deltaGyroAngle); 167 | 168 | // Apply complimentary filter (Gyro drift correction) 169 | // If accel magnitude >1.15G or <0.85G and ACC vector outside of the limit range => we neutralize the effect of accelerometers in the angle estimation. 170 | // To do that, we just skip filter, as EstV already rotated by Gyro 171 | if (72 < accMag && accMag < 133) { 172 | this.estimateGyro.X = 173 | (this.estimateGyro.X * gyro_cmpf_factor + accSmooth[0]) * 174 | INV_GYR_CMPF_FACTOR; 175 | this.estimateGyro.Y = 176 | (this.estimateGyro.Y * gyro_cmpf_factor + accSmooth[1]) * 177 | INV_GYR_CMPF_FACTOR; 178 | this.estimateGyro.Z = 179 | (this.estimateGyro.Z * gyro_cmpf_factor + accSmooth[2]) * 180 | INV_GYR_CMPF_FACTOR; 181 | } 182 | 183 | let attitude = { 184 | roll: Math.atan2(this.estimateGyro.Y, this.estimateGyro.Z), 185 | pitch: Math.atan2( 186 | -this.estimateGyro.X, 187 | Math.sqrt( 188 | this.estimateGyro.Y * this.estimateGyro.Y + 189 | this.estimateGyro.Z * this.estimateGyro.Z 190 | ) 191 | ), 192 | }; 193 | 194 | if (false && magADC) { 195 | //TODO temporarily disabled 196 | rotateVector(this.estimateMag, deltaGyroAngle); 197 | 198 | this.estimateMag.X = 199 | (this.estimateMag.X * gyro_cmpfm_factor + magADC[0]) * 200 | INV_GYR_CMPFM_FACTOR; 201 | this.estimateMag.Y = 202 | (this.estimateMag.Y * gyro_cmpfm_factor + magADC[1]) * 203 | INV_GYR_CMPFM_FACTOR; 204 | this.estimateMag.Z = 205 | (this.estimateMag.Z * gyro_cmpfm_factor + magADC[2]) * 206 | INV_GYR_CMPFM_FACTOR; 207 | 208 | attitude.heading = calculateHeading( 209 | this.estimateMag, 210 | attitude.roll, 211 | attitude.pitch 212 | ); 213 | } else { 214 | rotateVector(this.EstN, deltaGyroAngle); 215 | normalizeVector(this.EstN, this.EstN); 216 | attitude.heading = calculateHeading( 217 | this.EstN, 218 | attitude.roll, 219 | attitude.pitch 220 | ); 221 | } 222 | 223 | return attitude; 224 | }; 225 | 226 | if (copyFrom) { 227 | this.copyStateFrom(copyFrom); 228 | } else { 229 | this.reset(); 230 | } 231 | } 232 | 233 | IMU.prototype.reset = function () { 234 | this.estimateGyro = { X: 0, Y: 0, Z: 0 }; 235 | this.EstN = { X: 1, Y: 0, Z: 0 }; 236 | this.estimateMag = { X: 0, Y: 0, Z: 0 }; 237 | 238 | this.previousTime = false; 239 | }; 240 | 241 | IMU.prototype.copyStateFrom = function (that) { 242 | this.estimateGyro = { 243 | X: that.estimateGyro.X, 244 | Y: that.estimateGyro.Y, 245 | Z: that.estimateGyro.Z, 246 | }; 247 | 248 | this.estimateMag = { 249 | X: that.estimateMag.X, 250 | Y: that.estimateMag.Y, 251 | Z: that.estimateMag.Z, 252 | }; 253 | 254 | this.EstN = { 255 | X: that.EstN.X, 256 | Y: that.EstN.Y, 257 | Z: that.EstN.Z, 258 | }; 259 | 260 | this.previousTime = that.previousTime; 261 | }; 262 | -------------------------------------------------------------------------------- /src/jquery.js: -------------------------------------------------------------------------------- 1 | import $ from "jquery"; 2 | 3 | /** 4 | * jQuery has plugins which load in all sort of different ways, 5 | * not necessary as modules. This binds jquery package to global 6 | * scope and is loaded in first, so that when plugins are loaded 7 | * all of them have access to the same instance. 8 | */ 9 | if (typeof globalThis !== "undefined") { 10 | // eslint-disable-next-line no-undef 11 | globalThis.jQuery = $; 12 | // eslint-disable-next-line no-undef 13 | globalThis.$ = $; 14 | } 15 | 16 | if (typeof window !== "undefined") { 17 | window.jQuery = $; 18 | window.$ = $; 19 | } 20 | 21 | if (typeof global !== "undefined") { 22 | global.$ = $; 23 | global.jQuery = $; 24 | } 25 | 26 | export default $; 27 | -------------------------------------------------------------------------------- /src/keys_dialog.js: -------------------------------------------------------------------------------- 1 | export function KeysDialog(dialog) { 2 | // Private Variables 3 | let that = this; // generic pointer back to this function 4 | 5 | // Public variables 6 | 7 | this.show = function (sysConfig) { 8 | dialog.modal("show"); 9 | }; 10 | 11 | // Buttons 12 | } 13 | -------------------------------------------------------------------------------- /src/laptimer.js: -------------------------------------------------------------------------------- 1 | import { formatTime, roundRect } from "./tools"; 2 | 3 | export function LapTimer() { 4 | let lapTime = { 5 | current: null, 6 | last: null, 7 | best: null, 8 | laps: [], 9 | }; 10 | 11 | this.currentLapTime = function () { 12 | return lapTime.current || "00:00.000"; 13 | }; 14 | 15 | this.lastLapTime = function () { 16 | return lapTime.last || "00:00.000"; 17 | }; 18 | 19 | this.bestLapTime = function () { 20 | return lapTime.best || "00:00.000"; 21 | }; 22 | 23 | this.laps = function () { 24 | return lapTime.laps; 25 | }; 26 | 27 | this.drawCanvas = function (canvas, options) { 28 | // Draw the LapTimes using a canvas 29 | let ctx = canvas.getContext("2d"); 30 | 31 | let lineHeight = 14, //px 32 | DEFAULT_FONT_FACE = "8pt Verdana, Arial, sans-serif", 33 | fgColor = "rgba(191,191,191,1.0)", // Text and highlights color 34 | bgColor = `rgba(76,76,76,${ 35 | parseInt(options.laptimer.transparency) / 100.0 36 | })`, // background color 37 | left = (canvas.width * parseInt(options.laptimer.left)) / 100.0, 38 | top = (canvas.height * parseInt(options.laptimer.top)) / 100.0, 39 | margin = 4, // pixels 40 | rows = 5 + (lapTime.laps.length > 0 ? 1 + lapTime.laps.length : 0); 41 | 42 | ctx.save(); // Store the current canvas configuration 43 | 44 | let firstColumnWidth = ctx.measureText("Current").width, 45 | secondColumn = ctx.measureText("XX:XX.XXX").width, 46 | width = margin + firstColumnWidth + margin + secondColumn + margin; // get the size of the box 47 | 48 | // move to the top left of the Lap Timer 49 | ctx.translate(left, top); 50 | 51 | ctx.lineWidth = 1; 52 | 53 | ctx.fillStyle = bgColor; 54 | ctx.strokeStyle = fgColor; 55 | 56 | //Fill in background 57 | roundRect(ctx, 0, 0, width, lineHeight * (rows - 0.5), 7, true, true); // draw the bounding box with border 58 | 59 | // Add Title, and current values 60 | let currentRow = 1; 61 | ctx.textAlign = "left"; 62 | ctx.fillStyle = fgColor; 63 | 64 | // Title 65 | ctx.font = `italic ${DEFAULT_FONT_FACE}`; 66 | ctx.fillText("Lap Timer", margin, lineHeight * currentRow); 67 | // Underline 68 | ctx.beginPath(); 69 | ctx.strokeStyle = fgColor; 70 | ctx.moveTo(margin, lineHeight * currentRow + 2 /*px*/); 71 | ctx.lineTo(width - margin, lineHeight * currentRow + 2 /*px*/); 72 | ctx.stroke(); 73 | 74 | currentRow++; 75 | 76 | // Summary 77 | ctx.font = DEFAULT_FONT_FACE; 78 | ctx.fillText("Current", margin, lineHeight * currentRow); 79 | ctx.fillText( 80 | formatTime(lapTime.current, true), 81 | margin + firstColumnWidth + margin, 82 | lineHeight * currentRow++ 83 | ); 84 | ctx.fillText("Last", margin, lineHeight * currentRow); 85 | ctx.fillText( 86 | formatTime(lapTime.last, true), 87 | margin + firstColumnWidth + margin, 88 | lineHeight * currentRow++ 89 | ); 90 | ctx.fillText("Best", margin, lineHeight * currentRow); 91 | ctx.fillText( 92 | formatTime(lapTime.best, true), 93 | margin + firstColumnWidth + margin, 94 | lineHeight * currentRow++ 95 | ); 96 | 97 | // Laps 98 | if (lapTime.laps.length > 0) { 99 | // Title 100 | ctx.font = `italic ${DEFAULT_FONT_FACE}`; 101 | ctx.fillText("Laps", margin, lineHeight * currentRow); 102 | // Underline 103 | ctx.beginPath(); 104 | ctx.strokeStyle = fgColor; 105 | ctx.moveTo(margin, lineHeight * currentRow + 2 /*px*/); 106 | ctx.lineTo(width - margin, lineHeight * currentRow + 2 /*px*/); 107 | ctx.stroke(); 108 | currentRow++; 109 | 110 | // Each Lap 111 | ctx.font = DEFAULT_FONT_FACE; 112 | for (let i = 0; i < lapTime.laps.length; i++) { 113 | ctx.fillText(`Lap ${i + 1}`, margin, lineHeight * currentRow); 114 | ctx.fillText( 115 | formatTime(lapTime.laps[i], true), 116 | margin + firstColumnWidth + margin, 117 | lineHeight * currentRow++ 118 | ); 119 | } 120 | } 121 | 122 | ctx.restore(); 123 | }; 124 | 125 | this.refresh = function (currentTime, maxTime, bookmarkTimes) { 126 | // Update the lapTimeTable with the current information 127 | 128 | if (currentTime != null && bookmarkTimes != null) 129 | if (bookmarkTimes.length > 0) { 130 | let bookmarkTimesSorted = bookmarkTimes.slice(0); 131 | bookmarkTimesSorted.push(maxTime); // add end time 132 | bookmarkTimesSorted.sort((a, b) => a - b); // sort on value (rather than default alphabetically) 133 | 134 | lapTime.laps = []; // Clear the array 135 | 136 | for (var i = 0; i < bookmarkTimesSorted.length - 1; i++) { 137 | if (i > 0 && currentTime >= bookmarkTimesSorted[0]) { 138 | // Calculate all the laps so far 139 | lapTime.laps.push( 140 | (bookmarkTimesSorted[i] - bookmarkTimesSorted[i - 1]) / 1000 141 | ); 142 | } 143 | if ( 144 | currentTime < bookmarkTimesSorted[i + 1] && 145 | currentTime >= bookmarkTimesSorted[i] 146 | ) { 147 | // We have found the current lap 148 | lapTime.current = (currentTime - bookmarkTimesSorted[i]) / 1000; 149 | if (i > 0) { 150 | lapTime.last = 151 | (bookmarkTimesSorted[i] - bookmarkTimesSorted[i - 1]) / 1000; 152 | } else { 153 | lapTime.last = 0; // we are in the first lap, there is no last or best value 154 | lapTime.best = 0; 155 | } 156 | 157 | break; 158 | } else { 159 | // We are before the first bookmark (i.e. the start of the race) 160 | lapTime.current = 0; 161 | lapTime.last = 0; 162 | } 163 | } 164 | 165 | if (lapTime.laps.length > 0 && currentTime > bookmarkTimesSorted[0]) { 166 | lapTime.best = maxTime; 167 | for (var i = 0; i < lapTime.laps.length; i++) { 168 | if (lapTime.laps[i] < lapTime.best) { 169 | lapTime.best = lapTime.laps[i]; 170 | } 171 | } 172 | } 173 | } 174 | }; 175 | 176 | // Initialisation Code 177 | 178 | // None 179 | } 180 | -------------------------------------------------------------------------------- /src/pref_storage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A local key/value store for JSON-encodable values. Supports localStorage and chrome.storage.local backends. 3 | * 4 | * Supply keyPrefix if you want it automatically prepended to key names. 5 | */ 6 | export function PrefStorage(keyPrefix) { 7 | let LOCALSTORAGE = 0, 8 | CHROME_STORAGE_LOCAL = 1, 9 | mode; 10 | 11 | /** 12 | * Fetch the value with the given name, calling the onGet handler (possibly asynchronously) with the retrieved 13 | * value, or null if the value didn't exist. 14 | */ 15 | this.get = function (name, onGet) { 16 | name = keyPrefix + name; 17 | 18 | switch (mode) { 19 | case LOCALSTORAGE: 20 | var parsed = null; 21 | 22 | try { 23 | parsed = JSON.parse(window.localStorage[name]); 24 | } catch (e) {} 25 | 26 | onGet(parsed); 27 | break; 28 | case CHROME_STORAGE_LOCAL: 29 | chrome.storage.local.get(name, function (data) { 30 | onGet(data[name]); 31 | }); 32 | break; 33 | } 34 | }; 35 | 36 | /** 37 | * Set the given JSON-encodable value into storage using the given name. 38 | */ 39 | this.set = function (name, value) { 40 | name = keyPrefix + name; 41 | 42 | switch (mode) { 43 | case LOCALSTORAGE: 44 | window.localStorage[name] = JSON.stringify(value); 45 | break; 46 | case CHROME_STORAGE_LOCAL: 47 | var data = {}; 48 | 49 | data[name] = value; 50 | 51 | chrome.storage.local.set(data); 52 | break; 53 | } 54 | }; 55 | 56 | if (window.chrome && window.chrome.storage && window.chrome.storage.local) { 57 | mode = CHROME_STORAGE_LOCAL; 58 | } else { 59 | mode = LOCALSTORAGE; 60 | } 61 | 62 | keyPrefix = keyPrefix || ""; 63 | } 64 | -------------------------------------------------------------------------------- /src/screenshot.js: -------------------------------------------------------------------------------- 1 | import html2canvas from "html2canvas"; 2 | 3 | export function makeScreenshot() { 4 | let el = document.querySelector("#screenshot-frame"), 5 | now = new Date(), 6 | timestamp = `${now.getFullYear()}${`00${now.getMonth() + 1}`.slice( 7 | -2 8 | )}${`00${now.getDate()}`.slice(-2)}-${`00${now.getHours()}`.slice( 9 | -2 10 | )}${`00${now.getMinutes()}`.slice(-2)}${`00${now.getSeconds()}`.slice(-2)}`, 11 | defaultFilename = `${$(".log-filename") 12 | .text() 13 | .replace(".", "_")}-${timestamp}.png`; 14 | html2canvas(el).then((canvas) => { 15 | window.canv = canvas; 16 | let anchor = document.createElement("a"); 17 | anchor.download = defaultFilename; 18 | anchor.href = canvas.toDataURL(); 19 | anchor.click(); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/simple-stats.js: -------------------------------------------------------------------------------- 1 | export function SimpleStats(flightLog) { 2 | const frames = _( 3 | flightLog.getChunksInTimeRange( 4 | flightLog.getMinTime(), 5 | flightLog.getMaxTime() 6 | ) 7 | ) 8 | .map((chunk) => chunk.frames) 9 | .flatten() 10 | .value(), 11 | fields = _.map(flightLog.getMainFieldNames(), (f) => { 12 | // fix typo. potential bug in either FW or BBE 13 | if (f === "BaroAlt") { 14 | return "baroAlt"; 15 | } else { 16 | return f; 17 | } 18 | }); 19 | 20 | const getMinMaxMean = (fieldName) => { 21 | const index = _.findIndex(fields, (f) => f === fieldName); 22 | if (index === -1 || !frames.length || !(index in frames[0]) || !frames[index][index]) { 23 | return undefined; 24 | } 25 | const result = _.mapValues({ 26 | min: _.minBy(frames, (f) => f[index])[index], 27 | max: _.maxBy(frames, (f) => f[index])[index], 28 | mean: _.meanBy(frames, (f) => f[index]), 29 | }); 30 | result["name"] = fieldName; 31 | return result; 32 | }; 33 | 34 | const template = { 35 | roll: () => getMinMaxMean("rcCommand[0]"), 36 | pitch: () => getMinMaxMean("rcCommand[1]"), 37 | yaw: () => getMinMaxMean("rcCommand[2]"), 38 | throttle: () => getMinMaxMean("rcCommand[3]"), 39 | vbat: () => getMinMaxMean("vbatLatest"), 40 | amps: () => getMinMaxMean("amperageLatest"), 41 | rssi: () => getMinMaxMean("rssi"), 42 | alt_baro: () => getMinMaxMean("baroAlt"), 43 | alt_gps: () => getMinMaxMean("GPS_altitude"), 44 | }; 45 | 46 | function calculate() { 47 | return _.mapValues(template, (f) => f.call()); 48 | } 49 | 50 | return { 51 | calculate: calculate, 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/spectrum-exporter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {object} ExportOptions 3 | * @property {string} columnDelimiter 4 | * @property {string} stringDelimiter 5 | * @property {boolean} quoteStrings 6 | */ 7 | 8 | /** 9 | * @constructor 10 | * @param {object} fftOutput 11 | * @param {ExportOptions} [opts={}] 12 | */ 13 | export function SpectrumExporter(fftData, opts = {}) { 14 | opts = _.merge( 15 | { 16 | columnDelimiter: ",", 17 | quoteStrings: true, 18 | }, 19 | opts, 20 | ); 21 | 22 | /** 23 | * @param {function} success is a callback triggered when export is done 24 | */ 25 | function dump(success) { 26 | const worker = new Worker("/js/webworkers/spectrum-export-worker.js"); 27 | 28 | worker.onmessage = (event) => { 29 | success(event.data); 30 | worker.terminate(); 31 | }; 32 | 33 | worker.postMessage({fftOutput: fftData.fftOutput, 34 | blackBoxRate: fftData.blackBoxRate, 35 | opts: opts}); 36 | } 37 | 38 | // exposed functions 39 | return { 40 | dump: dump, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/vendor.js: -------------------------------------------------------------------------------- 1 | import "leaflet"; 2 | import "leaflet-marker-rotation"; 3 | import "Leaflet.MultiOptionsPolyline"; 4 | import "jquery-ui/dist/jquery-ui"; 5 | import "./vendor/jquery.nouislider.all.min.js"; 6 | import "bootstrap"; 7 | import _ from "lodash"; 8 | 9 | globalThis._ = _; 10 | -------------------------------------------------------------------------------- /src/video_export_dialog.js: -------------------------------------------------------------------------------- 1 | import { FlightLogVideoRenderer } from "./flightlog_video_renderer.js"; 2 | 3 | export function VideoExportDialog(dialog, onSave) { 4 | let DIALOG_MODE_SETTINGS = 0, 5 | DIALOG_MODE_IN_PROGRESS = 1, 6 | DIALOG_MODE_COMPLETE = 2, 7 | currentGraphConfig, 8 | flightLogDataArray, 9 | dialogMode, 10 | videoRenderer = false, 11 | videoDuration = $(".video-duration", dialog), 12 | progressBar = $("progress", dialog), 13 | progressRenderedFrames = $(".video-export-rendered-frames", dialog), 14 | progressRemaining = $(".video-export-remaining", dialog), 15 | progressSize = $(".video-export-size", dialog), 16 | fileSizeWarning = $(".video-export-size + .alert", dialog), 17 | renderStartTime, 18 | lastEstimatedTimeMsec, 19 | that = this; 20 | 21 | function leftPad(value, pad, width) { 22 | // Coorce value to string 23 | value = `${value}`; 24 | 25 | while (value.length < width) { 26 | value = pad + value; 27 | } 28 | 29 | return value; 30 | } 31 | 32 | function formatTime(secs) { 33 | var mins = Math.floor(secs / 60), 34 | secs = secs % 60, 35 | hours = Math.floor(mins / 60); 36 | 37 | mins = mins % 60; 38 | 39 | if (hours) { 40 | return `${hours}:${leftPad(mins, "0", 2)}:${leftPad(secs, "0", 2)}`; 41 | } else { 42 | return `${mins}:${leftPad(secs, "0", 2)}`; 43 | } 44 | } 45 | 46 | function formatFilesize(bytes) { 47 | let megs = Math.round(bytes / (1024 * 1024)); 48 | 49 | return `${megs}MB`; 50 | } 51 | 52 | function setDialogMode(mode) { 53 | dialogMode = mode; 54 | 55 | let settingClasses = [ 56 | "video-export-mode-settings", 57 | "video-export-mode-progress", 58 | "video-export-mode-complete", 59 | ]; 60 | 61 | dialog.removeClass(settingClasses.join(" ")).addClass(settingClasses[mode]); 62 | 63 | $(".video-export-dialog-start").toggle(mode == DIALOG_MODE_SETTINGS); 64 | $(".video-export-dialog-cancel").toggle(mode != DIALOG_MODE_COMPLETE); 65 | $(".video-export-dialog-close").toggle(mode == DIALOG_MODE_COMPLETE); 66 | 67 | let title = "Export video"; 68 | 69 | switch (mode) { 70 | case DIALOG_MODE_IN_PROGRESS: 71 | title = "Rendering video..."; 72 | break; 73 | case DIALOG_MODE_COMPLETE: 74 | title = "Video rendering complete!"; 75 | break; 76 | } 77 | 78 | $(".modal-title", dialog).text(title); 79 | } 80 | 81 | function populateConfig(videoConfig) { 82 | if (videoConfig.frameRate) { 83 | $(".video-frame-rate").val(videoConfig.frameRate); 84 | } 85 | if (videoConfig.videoDim !== undefined) { 86 | // Look for a value in the UI which closely matches the stored one (allows for floating point inaccuracy) 87 | $(".video-dim option").each(function () { 88 | let thisVal = parseFloat($(this).attr("value")); 89 | 90 | if (Math.abs(videoConfig.videoDim - thisVal) < 0.05) { 91 | $(".video-dim").val($(this).attr("value")); 92 | } 93 | }); 94 | } 95 | if (videoConfig.width) { 96 | $(".video-resolution").val(`${videoConfig.width}x${videoConfig.height}`); 97 | } 98 | } 99 | 100 | function convertUIToVideoConfig() { 101 | let videoConfig = { 102 | frameRate: parseFloat($(".video-frame-rate", dialog).val()), 103 | videoDim: parseFloat($(".video-dim", dialog).val()), 104 | }, 105 | resolution; 106 | 107 | resolution = $(".video-resolution", dialog).val(); 108 | 109 | videoConfig.width = parseInt(resolution.split("x")[0], 10); 110 | videoConfig.height = parseInt(resolution.split("x")[1], 10); 111 | 112 | return videoConfig; 113 | } 114 | 115 | this.show = function (flightLog, logParameters, videoConfig) { 116 | setDialogMode(DIALOG_MODE_SETTINGS); 117 | 118 | if (!("inTime" in logParameters) || logParameters.inTime === false) { 119 | logParameters.inTime = flightLog.getMinTime(); 120 | } 121 | 122 | if (!("outTime" in logParameters) || logParameters.outTime === false) { 123 | logParameters.outTime = flightLog.getMaxTime(); 124 | } 125 | 126 | videoDuration.text( 127 | formatTime( 128 | Math.round((logParameters.outTime - logParameters.inTime) / 1000000) 129 | ) 130 | ); 131 | 132 | $(".jumpy-video-note").toggle(!!logParameters.flightVideo); 133 | 134 | dialog.modal("show"); 135 | 136 | this.flightLog = flightLog; 137 | this.logParameters = logParameters; 138 | 139 | populateConfig(videoConfig); 140 | }; 141 | 142 | $(".video-export-dialog-start").click(function (e) { 143 | let lastWrittenBytes = 0, 144 | videoConfig = convertUIToVideoConfig(); 145 | 146 | // Send our video config to our host to be saved for next time: 147 | onSave(videoConfig); 148 | 149 | videoRenderer = new FlightLogVideoRenderer( 150 | that.flightLog, 151 | that.logParameters, 152 | videoConfig, 153 | { 154 | onProgress: function (frameIndex, frameCount) { 155 | progressBar.prop("max", frameCount - 1); 156 | progressBar.prop("value", frameIndex); 157 | 158 | progressRenderedFrames.text( 159 | `${frameIndex + 1} / ${frameCount} (${( 160 | ((frameIndex + 1) / frameCount) * 161 | 100 162 | ).toFixed(1)}%)` 163 | ); 164 | 165 | if (frameIndex > 0) { 166 | let elapsedTimeMsec = Date.now() - renderStartTime, 167 | estimatedTimeMsec = (elapsedTimeMsec * frameCount) / frameIndex; 168 | 169 | if (lastEstimatedTimeMsec === false) { 170 | lastEstimatedTimeMsec = estimatedTimeMsec; 171 | } else { 172 | lastEstimatedTimeMsec = 173 | lastEstimatedTimeMsec * 0.0 + estimatedTimeMsec * 1.0; 174 | } 175 | 176 | let estimatedRemaining = Math.max( 177 | Math.round((lastEstimatedTimeMsec - elapsedTimeMsec) / 1000), 178 | 0 179 | ); 180 | 181 | progressRemaining.text(formatTime(estimatedRemaining)); 182 | 183 | let writtenBytes = videoRenderer.getWrittenSize(), 184 | estimatedBytes = Math.round( 185 | (frameCount / frameIndex) * writtenBytes 186 | ); 187 | 188 | /* 189 | * Only update the filesize estimate when a block is written (avoids the estimated filesize slowly 190 | * decreasing between blocks) 191 | */ 192 | if (writtenBytes != lastWrittenBytes) { 193 | lastWrittenBytes = writtenBytes; 194 | 195 | if (writtenBytes > 1000000) { 196 | // Wait for the first significant chunk to be written (don't use the tiny header as a size estimate) 197 | progressSize.text( 198 | `${formatFilesize(writtenBytes)} / ${formatFilesize( 199 | estimatedBytes 200 | )}` 201 | ); 202 | 203 | fileSizeWarning.toggle( 204 | !videoRenderer.willWriteDirectToDisk() && 205 | estimatedBytes >= 475 * 1024 * 1024 206 | ); 207 | } 208 | } 209 | } 210 | }, 211 | onComplete: function (success, frameCount) { 212 | if (success) { 213 | $(".video-export-result").text( 214 | `Rendered ${frameCount} frames in ${formatTime( 215 | Math.round((Date.now() - renderStartTime) / 1000) 216 | )}` 217 | ); 218 | setDialogMode(DIALOG_MODE_COMPLETE); 219 | } else { 220 | dialog.modal("hide"); 221 | } 222 | // Free up any memory still held by the video renderer 223 | if (videoRenderer) { 224 | videoRenderer = false; 225 | } 226 | }, 227 | } 228 | ); 229 | 230 | progressBar.prop("value", 0); 231 | progressRenderedFrames.text(""); 232 | progressRemaining.text(""); 233 | progressSize.text("Calculating..."); 234 | fileSizeWarning.hide(); 235 | 236 | setDialogMode(DIALOG_MODE_IN_PROGRESS); 237 | 238 | renderStartTime = Date.now(); 239 | lastEstimatedTimeMsec = false; 240 | videoRenderer.start(); 241 | 242 | e.preventDefault(); 243 | }); 244 | 245 | $(".video-export-dialog-cancel").click(function (e) { 246 | if (videoRenderer) { 247 | videoRenderer.cancel(); 248 | } 249 | }); 250 | 251 | dialog.modal({ 252 | show: false, 253 | backdrop: "static", // Don't allow a click on the backdrop to close the dialog 254 | }); 255 | } 256 | -------------------------------------------------------------------------------- /src/workspace_menu.js: -------------------------------------------------------------------------------- 1 | import ctzsnoozeWorkspace from "./ws_ctzsnooze.json"; 2 | import supaflyWorkspace from "./ws_supafly.json"; 3 | export function WorkspaceMenu(menuElem, onSwitchWorkspace) { 4 | const workspace_menu = menuElem; 5 | 6 | function hideMenu() { 7 | workspace_menu.removeClass("show"); 8 | workspace_menu.empty(); 9 | } 10 | 11 | function showMenu() { 12 | workspace_menu.addClass("show"); 13 | } 14 | 15 | this.show = function () { 16 | let elem = $('
      SELECT WORKSPACE:
      '); 17 | workspace_menu.append(elem); 18 | elem = $("
      Ctzsnooze
      "); 19 | elem.click(1, ApplyWorkspace); 20 | workspace_menu.append(elem); 21 | elem = $("
      SupaflyFPV
      "); 22 | elem.click(2, ApplyWorkspace); 23 | workspace_menu.append(elem); 24 | elem = $(''); 25 | elem.click(ApplyWorkspace); 26 | workspace_menu.append(elem); 27 | showMenu(); 28 | }; 29 | 30 | function ApplyWorkspace(e) { 31 | switch (e.data) { 32 | case 1: 33 | onSwitchWorkspace(ctzsnoozeWorkspace, 1); 34 | break; 35 | case 2: 36 | onSwitchWorkspace(supaflyWorkspace, 1); 37 | break; 38 | } 39 | hideMenu(); 40 | } 41 | 42 | $(document).keydown(function (e) { 43 | if (e.which === 27 && workspace_menu.length > 0) { 44 | e.preventDefault(); 45 | hideMenu(); 46 | } 47 | }); 48 | 49 | this.getDefaultWorkspace = function () { 50 | return ctzsnoozeWorkspace; 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/workspace_selection.js: -------------------------------------------------------------------------------- 1 | const UNTITLED = "Untitled"; 2 | 3 | export function WorkspaceSelection( 4 | targetElem, 5 | workspaces, 6 | onSelectionChange, 7 | onSaveWorkspace 8 | ) { 9 | var numberSpan = null, 10 | titleSpan = null, 11 | buttonElem = null, 12 | menuElem = null, 13 | editButton = null, 14 | workspaces = [], 15 | activeId = 1; 16 | 17 | function buildUI() { 18 | buttonElem = $( 19 | '' 20 | ); 21 | numberSpan = $(''); 22 | titleSpan = $(''); 23 | let caretElem = $(''); 24 | 25 | editButton = $( 26 | '' 27 | ); 28 | editButton.click(editTitle); 29 | editButton.tooltip({ trigger: "hover", placement: "auto bottom" }); 30 | 31 | menuElem = $( 32 | '' 33 | ); 34 | 35 | targetElem.empty(); 36 | targetElem.addClass("dropdown"); 37 | targetElem.append(buttonElem); 38 | targetElem.append(menuElem); 39 | buttonElem.append(numberSpan); 40 | buttonElem.append(titleSpan); 41 | buttonElem.append(editButton); 42 | buttonElem.append(caretElem); 43 | 44 | buttonElem.dropdown(); // initialise dropdown 45 | } 46 | 47 | function editTitle(e) { 48 | buttonElem.dropdown("toggle"); // Hack to undrop 49 | editButton.hide(); 50 | let inputElem = $(''); 51 | inputElem.click((e) => e.stopPropagation()); // Stop click from closing 52 | titleSpan.replaceWith(inputElem); 53 | inputElem.val(workspaces[activeId]?.title); 54 | inputElem.focus(); 55 | inputElem.on("focusout", () => { 56 | inputElem.replaceWith(titleSpan); 57 | editButton.show(); 58 | onSaveWorkspace(activeId, inputElem.val()); 59 | }); 60 | 61 | e.preventDefault(); 62 | } 63 | 64 | function update() { 65 | menuElem.empty(); 66 | // Sort for non-programmers with 1-9 and then 0 last. 67 | for (let index = 1; index < 11; index++) { 68 | let id = index % 10; 69 | let element = workspaces[id % 10]; 70 | 71 | let item = $("
    • "); 72 | let link = $(''); 73 | 74 | if (!element) { 75 | // item.addClass("disabled"); 76 | } 77 | 78 | let number = $('').text(id); 79 | let title = $(''); 80 | 81 | if (!element) { 82 | title.text(""); 83 | title.addClass("faded"); 84 | } else { 85 | title.text(element.title); 86 | } 87 | 88 | link.click((e) => { 89 | if (element) { 90 | buttonElem.dropdown("toggle"); 91 | onSelectionChange(workspaces, id); 92 | e.preventDefault(); 93 | } 94 | }); 95 | 96 | let actionButtons = $(''); 97 | 98 | let saveButton = $( 99 | '' 100 | ); 101 | saveButton.click((e) => { 102 | if (!element) { 103 | onSaveWorkspace(id, UNTITLED); 104 | } else { 105 | onSaveWorkspace(id, element.title); 106 | } 107 | e.preventDefault(); 108 | }); 109 | 110 | saveButton.tooltip({ trigger: "hover", placement: "auto bottom" }); 111 | 112 | item.append(link); 113 | link.append(number); 114 | link.append(title); 115 | link.append(actionButtons); 116 | actionButtons.append(saveButton); 117 | item.toggleClass("active", id == activeId); 118 | menuElem.append(item); 119 | } 120 | 121 | if (workspaces[activeId]) { 122 | numberSpan.text(activeId); 123 | titleSpan.text(workspaces[activeId].title); 124 | } else { 125 | titleSpan.text(""); 126 | } 127 | } 128 | 129 | this.setWorkspaces = function (newWorkspaces) { 130 | workspaces = newWorkspaces; 131 | update(); 132 | }; 133 | 134 | this.setActiveWorkspace = function (newId) { 135 | activeId = newId; 136 | update(); 137 | }; 138 | 139 | buildUI(); 140 | } 141 | -------------------------------------------------------------------------------- /test/configs/opt/etc/dummy.cfg: -------------------------------------------------------------------------------- 1 | 2 | # Only for test purpose! 3 | # You configuration starts here: 4 | option1 = true; 5 | option2 = false; 6 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Blackbox viewer tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function assert(condition) { 4 | if (!condition) { 5 | throw "Assert failed"; 6 | } 7 | } 8 | 9 | function testExpoCurve() { 10 | var 11 | curve = new ExpoCurve(0, 0.700, 750, 1.0, 10); 12 | 13 | assert(curve.lookup(0) == 0.0); 14 | assert(curve.lookup(-750) == -1.0); 15 | assert(curve.lookup(750) == 1.0); 16 | } 17 | 18 | function testExpoStraightLine() { 19 | var 20 | curve = new ExpoCurve(0, 1.0, 500, 1.0, 1); 21 | 22 | assert(curve.lookup(0) == 0.0); 23 | assert(curve.lookup(-500) == -1.0); 24 | assert(curve.lookup(500) == 1.0); 25 | assert(curve.lookup(-250) == -0.5); 26 | assert(curve.lookup(250) == 0.5); 27 | } 28 | 29 | function benchExpoCurve() { 30 | var 31 | trial, i, 32 | curve = new ExpoCurve(0, 0.700, 750, 1.0, 10), 33 | acc = 0, 34 | endTime, results = ""; 35 | 36 | for (trial = 0; trial < 10; trial++) { 37 | var 38 | start = Date.now(), 39 | end; 40 | 41 | for (i = 0; i < 10000000; i++) { 42 | acc += curve.lookup(Math.random() * 750); 43 | } 44 | 45 | end = Date.now(); 46 | 47 | results += (end - start) + "\n"; 48 | } 49 | 50 | alert("Expo curve bench\n" + results); 51 | } 52 | 53 | try { 54 | testExpoCurve(); 55 | testExpoStraightLine(); 56 | 57 | //benchExpoCurve(); 58 | 59 | alert("All tests pass"); 60 | } catch (e) { 61 | alert(e); 62 | } -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { VitePWA } from 'vite-plugin-pwa'; 2 | import pkg from './package.json'; 3 | 4 | /** @type {import('vite').UserConfig} */ 5 | export default { 6 | build: { 7 | sourcemap: true, 8 | }, 9 | plugins: [ 10 | VitePWA({ 11 | registerType: 'autoUpdate', 12 | workbox: { 13 | globPatterns: ['**/*.{js,css,html,ico,png,svg,json,mcm,woff2}'], 14 | // 5MB 15 | maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, 16 | }, 17 | includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'], 18 | manifest: { 19 | name: pkg.displayName, 20 | short_name: pkg.productName, 21 | description: pkg.description, 22 | theme_color: '#ffffff', 23 | icons: [ 24 | { 25 | src: '/images/pwa/bf_icon_128.png', 26 | sizes: '128x128', 27 | type: 'image/png', 28 | }, 29 | { 30 | src: '/images/pwa/bf_icon_192.png', 31 | sizes: '192x192', 32 | type: 'image/png', 33 | }, 34 | { 35 | src: '/images/pwa/bf_icon_256.png', 36 | sizes: '256x256', 37 | type: 'image/png', 38 | }, 39 | ], 40 | file_handlers: [{ 41 | action: "/", 42 | accept: { 43 | "application/octet-stream": [".bbl", ".bfl"], 44 | }, 45 | }], 46 | }, 47 | }), 48 | ], 49 | define: { 50 | '__APP_VERSION__': JSON.stringify(pkg.version), 51 | }, 52 | } --------------------------------------------------------------------------------