diff --git a/package.json b/package.json index e1720385..37a294b5 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "dependencies": { "mi": "^1.0.0", "minami": "^1.2.3", + "react": "^18.2.0", "taffydb": "^2.7.3" }, "jest": { diff --git a/react-maidr/.gitignore b/react-maidr/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/react-maidr/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/react-maidr/index.html b/react-maidr/index.html new file mode 100644 index 00000000..27a75875 --- /dev/null +++ b/react-maidr/index.html @@ -0,0 +1,1187 @@ + + + + Tutorial 1: Bar Chart + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + 5000 + + + + + + + + + 10000 + + + + + + + + + 15000 + + + + + + + + + 20000 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fair + + + + + + + + + Good + + + + + + + + + Very Good + + + + + + + + + Premium + + + + + + + + + Ideal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cut + + + + + + + + + + + + + + + + + + + + + Count + + + + + + + + + + + + + + + + + + + + + + + + + + + The Number of Diamonds by Cut. + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + diff --git a/react-maidr/package-lock.json b/react-maidr/package-lock.json new file mode 100644 index 00000000..b7310d00 --- /dev/null +++ b/react-maidr/package-lock.json @@ -0,0 +1,1348 @@ +{ + "name": "react-maidr", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "react-maidr", + "version": "0.0.0", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.0.27", + "@types/react-dom": "^18.0.10", + "@vitejs/plugin-react": "^3.1.0", + "vite": "^4.1.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", + "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", + "integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.24.0", + "@babel/parser": "^7.24.0", + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.0", + "@babel/types": "^7.24.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", + "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.23.6", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz", + "integrity": "sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.0.tgz", + "integrity": "sha512-ulDZdc0Aj5uLc5nETsa7EPx2L7rM0YJM8r7ck7U73AXi7qOV44IHHRAYZHY6iU1rr3C5N4NtTmMRUJP6kwCWeA==", + "dev": true, + "dependencies": { + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.0", + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.0.tgz", + "integrity": "sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.23.3.tgz", + "integrity": "sha512-qXRvbeKDSfwnlJnanVRp0SfuWE5DQhwQr5xtLBzp56Wabyo+4CMosF6Kfp+eOD/4FYpql64XVJ2W0pVLlJZxOQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.23.3.tgz", + "integrity": "sha512-91RS0MDnAWDNvGC6Wio5XYkyWI39FMFO+JK9+4AlgaTH+yWwVTsw7/sn6LK0lH7c5F+TFkpv/3LfCJ1Ydwof/g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", + "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/parser": "^7.24.0", + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.0.tgz", + "integrity": "sha512-HfuJlI8qq3dEDmNU5ChzzpZRWq+oxCZQyMzIMEqLho+AQnhMnKQUzH6ydo3RBl/YjPCuk68Y6s0Gx0AeyULiWw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.6", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.24.0", + "@babel/types": "^7.24.0", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.11", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", + "dev": true + }, + "node_modules/@types/react": { + "version": "18.2.61", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.61.tgz", + "integrity": "sha512-NURTN0qNnJa7O/k4XUkEW2yfygA+NxS0V5h1+kp9jPwhzZy95q3ADoGMP0+JypMhrZBTTgjKAUlTctde1zzeQA==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.2.19", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.19.tgz", + "integrity": "sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", + "dev": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-3.1.0.tgz", + "integrity": "sha512-AfgcRL8ZBhAlc3BFdigClmTUMISmmzHn7sB2h9U1odvc5U/MjWXsAaz18b/WoppUTDBzxOJwo2VdClfUcItu9g==", + "dev": true, + "dependencies": { + "@babel/core": "^7.20.12", + "@babel/plugin-transform-react-jsx-self": "^7.18.6", + "@babel/plugin-transform-react-jsx-source": "^7.19.6", + "magic-string": "^0.27.0", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.1.0-beta.0" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/browserslist": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", + "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001587", + "electron-to-chromium": "^1.4.668", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001591", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001591.tgz", + "integrity": "sha512-PCzRMei/vXjJyL5mJtzNiUCKP59dm8Apqc3PH8gJkMnMXZGox93RbE76jHsmLwmIo6/3nsYIpJtx0O7u5PqFuQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.4.690", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.690.tgz", + "integrity": "sha512-+2OAGjUx68xElQhydpcbqH50hE8Vs2K6TkAeLhICYfndb67CVH0UsZaijmRUE3rHlIxU1u0jxwhgVe6fK3YANA==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", + "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.13" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/postcss": { + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-refresh": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", + "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "3.29.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", + "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.2.tgz", + "integrity": "sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==", + "dev": true, + "dependencies": { + "esbuild": "^0.18.10", + "postcss": "^8.4.27", + "rollup": "^3.27.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } +} diff --git a/react-maidr/package.json b/react-maidr/package.json new file mode 100644 index 00000000..6ebaee62 --- /dev/null +++ b/react-maidr/package.json @@ -0,0 +1,21 @@ +{ + "name": "react-maidr", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.0.27", + "@types/react-dom": "^18.0.10", + "@vitejs/plugin-react": "^3.1.0", + "vite": "^4.1.0" + } +} \ No newline at end of file diff --git a/react-maidr/public/vite.svg b/react-maidr/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/react-maidr/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/react-maidr/src/App.css b/react-maidr/src/App.css new file mode 100644 index 00000000..2826a83d --- /dev/null +++ b/react-maidr/src/App.css @@ -0,0 +1,43 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/react-maidr/src/App.jsx b/react-maidr/src/App.jsx new file mode 100644 index 00000000..9ad5221f --- /dev/null +++ b/react-maidr/src/App.jsx @@ -0,0 +1,1134 @@ +import { useState } from 'react' +import reactLogo from './assets/react.svg' +import './App.css' + +function App() { + const [count, setCount] = useState(0) + + return ( +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + 5000 + + + + + + + + + 10000 + + + + + + + + + 15000 + + + + + + + + + 20000 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fair + + + + + + + + + Good + + + + + + + + + Very Good + + + + + + + + + Premium + + + + + + + + + Ideal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cut + + + + + + + + + + + + + + + + + + + + + Count + + + + + + + + + + + + + + + + + + + + + + + + + + + The Number of Diamonds by Cut. + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+ ) +} + +export default App diff --git a/react-maidr/src/assets/react.svg b/react-maidr/src/assets/react.svg new file mode 100644 index 00000000..6c87de9b --- /dev/null +++ b/react-maidr/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/react-maidr/src/index.css b/react-maidr/src/index.css new file mode 100644 index 00000000..ec95bc40 --- /dev/null +++ b/react-maidr/src/index.css @@ -0,0 +1,336 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light; + color: rgba(0, 0, 0, 0.87); + background-color: #ffffff; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + /* -webkit-text-size-adjust: 100%; */ +} + +*, +::after, +::before { + box-sizing: border-box; +} + +.sr-only { + clip: rect(1px, 1px, 1px, 1px); + clip-path: inset(50%); + height: 1px; + width: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; +} + +.sr-only-focusable { + position: static; + width: auto; + height: auto; + overflow: visible; + clip: auto; + white-space: normal; + -webkit-clip-path: none; + clip-path: none; +} + +.invis_button { + background-color: transparent; + border: none; + padding: 0; + margin: 0; + cursor: pointer; +} + +#skip a { + position: absolute; + left: -10000px; + top: auto; + width: 1px; + height: 1px; + overflow: hidden; +} + +#skip a:focus { + position: static; + width: auto; + height: auto; +} + +.hidden { + display: none; + opacity: 0; +} + +.braille-input { + font-size: 200%; + border: none; + resize: none; + padding: 5px; + /* pointer-events: none; */ +} + +/* Menu stuff */ +.modal { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1072; + overflow-x: hidden; + overflow-y: auto; + + opacity: 1; + transition: opacity 0.15s linear; +} + +.modal-dialog { + position: relative; + width: auto; + margin: 0.5rem; +} + +@media (min-width: 576px) { + .modal-dialog { + max-width: 500px; + margin: 1.75rem auto; + } +} + +@media (min-width: 900px) { + .modal-dialog { + max-width: 800px; + margin: 1.75rem auto; + } +} + +.close:not(:disabled):not(.disabled) { + cursor: pointer; +} + +.modal-header .close { + padding: 1rem; + margin: -1rem -1rem -1rem auto; + font-size: 2rem; +} + +button.close { + padding: 0; + background-color: transparent; + border: 0; + -webkit-appearance: none; +} + +.close { + float: right; + font-size: 1.5rem; + font-weight: 700; + line-height: 1; + color: #000; + text-shadow: 0 1px 0 #fff; + opacity: 0.5; +} + +button, +select { + text-transform: none; +} + +.modal-title { + margin-bottom: 1rem; + line-height: 1.5; + text-align: center; +} + +h5.modal-title { + font-size: 1.25rem; + border-bottom: 1px solid #dee2e6; +} + +h4.modal-title { + font-size: 1.5rem; +} + +.modal-content { + position: relative; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + width: 100%; + pointer-events: auto; + background-color: #fff; + background-clip: padding-box; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 0.3rem; + outline: 0; +} + +.modal-header { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-align: start; + -ms-flex-align: start; + align-items: flex-start; + -webkit-box-pack: justify; + -ms-flex-pack: justify; + justify-content: space-between; + padding: 1rem; + border-bottom: 1px solid #e9ecef; + border-top-left-radius: 0.3rem; + border-top-right-radius: 0.3rem; +} + +.modal-body { + position: relative; + -webkit-box-flex: 1; + -ms-flex: 1 1 auto; + flex: 1 1 auto; + padding: 1rem; +} + +.modal-footer { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: end; + -ms-flex-pack: end; + justify-content: flex-end; + padding: 1rem; + border-top: 1px solid #e9ecef; +} + +/* custom modal stuff */ +.modal button { + display: inline-block; + text-align: center; + vertical-align: middle; + font-size: 1rem; + line-height: 1.5; + padding: 0.375rem 0.75rem; + margin: 0.125rem; + font-weight: bold; +} + +.modal-backdrop { + z-index: 1071; + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: #000; + opacity: 0.5; +} + +/* table stuff */ +table { + width: 100%; + max-width: 100%; + margin-bottom: 1rem; + background-color: transparent; + border-collapse: collapse; +} + +table thead th { + vertical-align: bottom; + border-bottom: 2px solid #dee2e6; +} + +table td, +table th { + padding: 0.75rem; + vertical-align: top; +} + +/* form fields */ +label { + margin: 0 1.5rem; + vertical-align: middle; +} +.form-group { + margin-bottom: 1rem; +} +.form-group label { + display: inline-block; + margin-bottom: 0.5rem; +} +.form-control { + display: block; + width: 100%; + padding: 0.375rem 0.75rem; + font-size: 1rem; + line-height: 1.5; + color: #000; + background-color: #fff; + background-clip: padding-box; + border: 1px solid #ced4da; + border-radius: 0.25rem; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} +button, +input, +optgroup, +select, +textarea { + margin: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +/* LLM stuff */ +#chatLLM_chat_history { + height: 100%; + min-height: 400px; + max-height: 450px; + overflow-y: auto; + padding: 0.5rem; + background-color: #fff; + border: 1px solid #dee2e6; + border-radius: 0.25rem; + display: flex; + flex-direction: column; + align-items: column; + justify-content: center; +} +.chatLLM_message_self, +.chatLLM_message_other { + padding: 0.5rem 1.5rem; + border-radius: 1.5rem; + margin: 0.5rem; + max-width: 70%; + word-wrap: break-word; +} +.chatLLM_message_self { + align-self: flex-end; +} +.chatLLM_message_other { + align-self: flex-start; +} + +.LLM_suggestions > p > button { + font-weight: normal; + border-radius: 0.5rem; + border: none; +} + +.LLM_suggestions > p { + display: inline-block; +} diff --git a/react-maidr/src/js/__tests__/audio.test.js b/react-maidr/src/js/__tests__/audio.test.js new file mode 100644 index 00000000..0a8e9672 --- /dev/null +++ b/react-maidr/src/js/__tests__/audio.test.js @@ -0,0 +1,49 @@ +// Generated by CodiumAI + +describe('Audio', () => { + // The constructor initializes the AudioContext and compressor. + it('should initialize the AudioContext and compressor when instantiated', () => { + const audio = new Audio(); + expect(audio.audioContext).toBeDefined(); + expect(audio.compressor).toBeDefined(); + }); + + // The playTone() method plays a tone based on the chart type. + it('should play a tone based on the chart type', () => { + // Mock necessary constants and plot data + const audio = new Audio(); + audio.playTone(); + // Add assertions for expected behavior + }); + + // The playOscillator() method plays an oscillator with the given frequency, duration, panning, volume, and wave type. + it('should play an oscillator with the given parameters', () => { + // Mock necessary constants and plot data + const audio = new Audio(); + audio.playOscillator(440, 1, 0, 1, 'sine'); + // Add assertions for expected behavior + }); + + // The compressorSetup() method sets up the compressor with specific values. + it('should set up the compressor with specific values', () => { + const audio = new Audio(); + const compressor = audio.compressorSetup(); + // Add assertions for expected compressor values + }); + + // Playing a tone with a frequency of 0 should play a null tone. + it('should play a null tone when the frequency is 0', () => { + // Mock necessary constants and plot data + const audio = new Audio(); + audio.playTone(); + // Add assertions for expected behavior + }); + + // Playing a smooth oscillator with an empty frequency array should not play any tones. + it('should not play any tones when the frequency array is empty', () => { + // Mock necessary constants and plot data + const audio = new Audio(); + audio.playSmooth([], 1, [0], 1, 'sine'); + // Add assertions for expected behavior + }); +}); diff --git a/react-maidr/src/js/__tests__/constants.test.js b/react-maidr/src/js/__tests__/constants.test.js new file mode 100644 index 00000000..c2a0a049 --- /dev/null +++ b/react-maidr/src/js/__tests__/constants.test.js @@ -0,0 +1,622 @@ +// Generated by CodiumAI + +describe('Constants', () => { + // Constants instance should have default values for all properties + it('should have default values for all properties', () => { + const constants = new Constants(); + expect(constants.chart_container_id).toBe('chart-container'); + expect(constants.main_container_id).toBe('maidr-container'); + expect(constants.braille_container_id).toBe('braille-div'); + expect(constants.braille_input_id).toBe('braille-input'); + expect(constants.info_id).toBe('info'); + expect(constants.announcement_container_id).toBe('announcements'); + expect(constants.end_chime_id).toBe('end_chime'); + expect(constants.container_id).toBe('container'); + expect(constants.project_id).toBe('maidr'); + expect(constants.review_id_container).toBe('review_container'); + expect(constants.review_id).toBe('review'); + expect(constants.reviewSaveSpot).toBeUndefined(); + expect(constants.reviewSaveBrailleMode).toBeUndefined(); + expect(constants.chartId).toBe(''); + expect(constants.events).toEqual([]); + expect(constants.postLoadEvents).toEqual([]); + expect(constants.textMode).toBe('verbose'); + expect(constants.brailleMode).toBe('off'); + expect(constants.sonifMode).toBe('on'); + expect(constants.reviewMode).toBe('off'); + expect(constants.minX).toBe(0); + expect(constants.maxX).toBe(0); + expect(constants.minY).toBe(0); + expect(constants.maxY).toBe(0); + expect(constants.plotId).toBe(''); + expect(constants.chartType).toBe(''); + expect(constants.navigation).toBe(1); + expect(constants.MAX_FREQUENCY).toBe(1000); + expect(constants.MIN_FREQUENCY).toBe(200); + expect(constants.NULL_FREQUENCY).toBe(100); + expect(constants.MAX_SPEED).toBe(500); + expect(constants.MIN_SPEED).toBe(50); + expect(constants.DEFAULT_SPEED).toBe(250); + expect(constants.INTERVAL).toBe(20); + expect(constants.AUTOPLAY_DURATION).toBe(5000); + expect(constants.vol).toBe(0.5); + expect(constants.MAX_VOL).toBe(30); + expect(constants.autoPlayRate).toBe(250); + expect(constants.colorSelected).toBe('#03C809'); + expect(constants.brailleDisplayLength).toBe(32); + expect(constants.showRect).toBe(1); + expect(constants.hasRect).toBe(1); + expect(constants.hasSmooth).toBe(1); + expect(constants.duration).toBe(0.3); + expect(constants.outlierDuration).toBe(0.06); + expect(constants.autoPlayOutlierRate).toBe(50); + expect(constants.autoPlayPointsRate).toBe(30); + expect(constants.colorUnselected).toBe('#595959'); + expect(constants.isTracking).toBe(1); + expect(constants.visualBraille).toBe(false); + expect(constants.globalMinMax).toBe(true); + expect(constants.showDisplay).toBe(1); + expect(constants.showDisplayInBraille).toBe(1); + expect(constants.showDisplayInAutoplay).toBe(0); + expect(constants.outlierInterval).toBeNull(); + expect(constants.isMac).toBe(false); + expect(constants.control).toBe('Ctrl'); + expect(constants.alt).toBe('Alt'); + expect(constants.home).toBe('Home'); + expect(constants.end).toBe('End'); + expect(constants.keypressInterval).toBe(2000); + expect(constants.tabMovement).toBeNull(); + expect(constants.debugLevel).toBe(3); + expect(constants.canPlayEndChime).toBe(false); + expect(constants.manualData).toBe(true); + }); + + // Constants instance should be able to modify its properties + it('should be able to modify its properties', () => { + const constants = new Constants(); + constants.chart_container_id = 'new-chart-container'; + constants.main_container_id = 'new-maidr-container'; + constants.braille_container_id = 'new-braille-div'; + constants.braille_input_id = 'new-braille-input'; + constants.info_id = 'new-info'; + constants.announcement_container_id = 'new-announcements'; + constants.end_chime_id = 'new-end_chime'; + constants.container_id = 'new-container'; + constants.project_id = 'new-maidr'; + constants.review_id_container = 'new-review_container'; + constants.review_id = 'new-review'; + constants.reviewSaveSpot = 'new-reviewSaveSpot'; + constants.reviewSaveBrailleMode = 'new-reviewSaveBrailleMode'; + constants.chartId = 'new-chartId'; + constants.events = ['event1', 'event2']; + constants.postLoadEvents = ['postLoadEvent1', 'postLoadEvent2']; + constants.textMode = 'new-verbose'; + constants.brailleMode = 'new-off'; + constants.sonifMode = 'new-on'; + constants.reviewMode = 'new-off'; + constants.minX = 10; + constants.maxX = 20; + constants.minY = 30; + constants.maxY = 40; + constants.plotId = 'new-plotId'; + constants.chartType = 'new-chartType'; + constants.navigation = 0; + constants.MAX_FREQUENCY = 2000; + constants.MIN_FREQUENCY = 400; + constants.NULL_FREQUENCY = 200; + constants.MAX_SPEED = 1000; + constants.MIN_SPEED = 100; + constants.DEFAULT_SPEED = 500; + constants.INTERVAL = 40; + constants.AUTOPLAY_DURATION = 10000; + constants.vol = 0.8; + constants.MAX_VOL = 40; + constants.autoPlayRate = 500; + constants.colorSelected = '#C80309'; + constants.brailleDisplayLength = 64; + constants.showRect = 0; + constants.hasRect = 0; + constants.hasSmooth = 0; + constants.duration = 0.6; + constants.outlierDuration = 0.12; + constants.autoPlayOutlierRate = 100; + constants.autoPlayPointsRate = 60; + constants.colorUnselected = '#959595'; + constants.isTracking = 0; + constants.visualBraille = true; + constants.globalMinMax = false; + constants.showDisplay = 0; + constants.showDisplayInBraille = 0; + constants.showDisplayInAutoplay = 1; + constants.outlierInterval = 'new-outlierInterval'; + constants.isMac = true; + constants.control = 'Cmd'; + constants.alt = 'option'; + constants.home = 'fn + Left arrow'; + constants.end = 'fn + Right arrow'; + constants.keypressInterval = 4000; + constants.tabMovement = 'new-tabMovement'; + constants.debugLevel = 2; + constants.canPlayEndChime = true; + constants.manualData = false; + + expect(constants.chart_container_id).toBe('new-chart-container'); + expect(constants.main_container_id).toBe('new-maidr-container'); + expect(constants.braille_container_id).toBe('new-braille-div'); + expect(constants.braille_input_id).toBe('new-braille-input'); + expect(constants.info_id).toBe('new-info'); + expect(constants.announcement_container_id).toBe('new-announcements'); + expect(constants.end_chime_id).toBe('new-end_chime'); + expect(constants.container_id).toBe('new-container'); + expect(constants.project_id).toBe('new-maidr'); + expect(constants.review_id_container).toBe('new-review_container'); + expect(constants.review_id).toBe('new-review'); + expect(constants.reviewSaveSpot).toBe('new-reviewSaveSpot'); + expect(constants.reviewSaveBrailleMode).toBe('new-reviewSaveBrailleMode'); + expect(constants.chartId).toBe('new-chartId'); + expect(constants.events).toEqual(['event1', 'event2']); + expect(constants.postLoadEvents).toEqual([ + 'postLoadEvent1', + 'postLoadEvent2', + ]); + expect(constants.textMode).toBe('new-verbose'); + expect(constants.brailleMode).toBe('new-off'); + expect(constants.sonifMode).toBe('new-on'); + expect(constants.reviewMode).toBe('new-off'); + expect(constants.minX).toBe(10); + expect(constants.maxX).toBe(20); + expect(constants.minY).toBe(30); + expect(constants.maxY).toBe(40); + expect(constants.plotId).toBe('new-plotId'); + expect(constants.chartType).toBe('new-chartType'); + expect(constants.navigation).toBe(0); + expect(constants.MAX_FREQUENCY).toBe(2000); + expect(constants.MIN_FREQUENCY).toBe(400); + expect(constants.NULL_FREQUENCY).toBe(200); + expect(constants.MAX_SPEED).toBe(1000); + expect(constants.MIN_SPEED).toBe(100); + expect(constants.DEFAULT_SPEED).toBe(500); + expect(constants.INTERVAL).toBe(40); + expect(constants.AUTOPLAY_DURATION).toBe(10000); + expect(constants.vol).toBe(0.8); + expect(constants.MAX_VOL).toBe(40); + expect(constants.autoPlayRate).toBe(500); + expect(constants.colorSelected).toBe('#C80309'); + expect(constants.brailleDisplayLength).toBe(64); + expect(constants.showRect).toBe(0); + expect(constants.hasRect).toBe(0); + expect(constants.hasSmooth).toBe(0); + expect(constants.duration).toBe(0.6); + expect(constants.outlierDuration).toBe(0.12); + expect(constants.autoPlayOutlierRate).toBe(100); + expect(constants.autoPlayPointsRate).toBe(60); + expect(constants.colorUnselected).toBe('#959595'); + expect(constants.isTracking).toBe(0); + expect(constants.visualBraille).toBe(true); + expect(constants.globalMinMax).toBe(false); + expect(constants.showDisplay).toBe(0); + expect(constants.showDisplayInBraille).toBe(0); + expect(constants.showDisplayInAutoplay).toBe(1); + expect(constants.outlierInterval).toBe('new-outlierInterval'); + expect(constants.isMac).toBe(true); + expect(constants.control).toBe('Cmd'); + expect(constants.alt).toBe('option'); + expect(constants.home).toBe('fn + Left arrow'); + expect(constants.end).toBe('fn + Right arrow'); + expect(constants.keypressInterval).toBe(4000); + expect(constants.tabMovement).toBe('new-tabMovement'); + expect(constants.debugLevel).toBe(2); + expect(constants.canPlayEndChime).toBe(true); + expect(constants.manualData).toBe(false); + }); +}); +// Generated by CodiumAI + +describe('Resources', () => { + // Resources object can be instantiated successfully + it('should instantiate Resources object successfully', () => { + const resources = new Resources(); + expect(resources).toBeInstanceOf(Resources); + }); + + // GetString method returns the correct string for a given id + it('should return the correct string for a given id', () => { + const resources = new Resources(); + const string = resources.GetString('upper_outlier'); + expect(string).toBe('Upper Outlier'); + }); + + // Language and knowledgeLevel properties can be set and retrieved successfully + it('should set and retrieve Language and knowledgeLevel properties successfully', () => { + const resources = new Resources(); + resources.language = 'fr'; + resources.knowledgeLevel = 'intermediate'; + expect(resources.language).toBe('fr'); + expect(resources.knowledgeLevel).toBe('intermediate'); + }); + + // GetString method throws an error if id is not found in strings object + it('should throw an error if id is not found in strings object', () => { + const resources = new Resources(); + expect(() => { + resources.GetString('invalid_id'); + }).toThrow(); + }); + + // Language property throws an error if set to an invalid value + it('should throw an error if Language property is set to an invalid value', () => { + const resources = new Resources(); + expect(() => { + resources.language = 'invalid_language'; + }).toThrow(); + }); + + // KnowledgeLevel property throws an error if set to an invalid value + it('should throw an error if KnowledgeLevel property is set to an invalid value', () => { + const resources = new Resources(); + expect(() => { + resources.knowledgeLevel = 'invalid_level'; + }).toThrow(); + }); +}); +// Generated by CodiumAI + +describe('Menu', () => { + // Menu is created successfully + it('should create menu successfully', () => { + const menu = new Menu(); + expect(menu).toBeDefined(); + }); + + // Menu is destroyed successfully + it('should destroy menu successfully', () => { + const menu = new Menu(); + menu.Destroy(); + expect(document.getElementById('menu')).toBeNull(); + expect(document.getElementById('menu_modal_backdrop')).toBeNull(); + }); + + // Menu is toggled on successfully + it('should toggle menu on successfully', () => { + const menu = new Menu(); + menu.Toggle(true); + expect(document.getElementById('menu').classList.contains('hidden')).toBe( + false + ); + expect( + document + .getElementById('menu_modal_backdrop') + .classList.contains('hidden') + ).toBe(false); + }); +}); + +// Generated by CodiumAI + +describe('Description', () => { + // CreateComponent method creates a modal with description summary + it('should create a modal with description summary when called', () => { + const description = new Description(); + description.CreateComponent(); + const modal = document.getElementById('description'); + expect(modal).not.toBeNull(); + }); + + // Toggle method opens and closes the modal + it('should open and close the modal when called with onoff parameter', () => { + const description = new Description(); + description.CreateComponent(); + description.Toggle(true); + const modal = document.getElementById('description'); + expect(modal.classList.contains('hidden')).toBe(false); + description.Toggle(false); + expect(modal.classList.contains('hidden')).toBe(true); + }); + + // PopulateData method populates the modal with chart info and data + it('should populate the modal with chart info and data when called', () => { + const description = new Description(); + description.CreateComponent(); + description.PopulateData(); + const content = document.getElementById('desc_content'); + const table = document.getElementById('desc_table'); + expect(content.innerHTML).not.toBe(''); + expect(table.innerHTML).not.toBe(''); + }); + + // CreateComponent method does not create a modal if called in development mode + it('should not create a modal if called in development mode', () => { + const description = new Description(); + description.CreateComponent(); + const modal = document.getElementById('description'); + expect(modal).toBeNull(); + }); + + // Destroy method removes the modal and backdrop elements + it('should remove the modal and backdrop elements when called', () => { + const description = new Description(); + description.CreateComponent(); + description.Destroy(); + const modal = document.getElementById('description'); + const backdrop = document.getElementById('desc_modal_backdrop'); + expect(modal).toBeNull(); + expect(backdrop).toBeNull(); + }); + + // Toggle method handles case when onoff parameter is undefined + it('should handle case when onoff parameter is undefined', () => { + const description = new Description(); + description.CreateComponent(); + description.Toggle(); + const modal = document.getElementById('description'); + expect(modal.classList.contains('hidden')).toBe(false); + description.Toggle(); + expect(modal.classList.contains('hidden')).toBe(true); + }); +}); + +// Generated by CodiumAI + +describe('Position', () => { + // Creating a new Position object with valid x and y values should set the x and y attributes accordingly. + it('should set x and y attributes when creating a new Position object with valid x and y values', () => { + const position = new Position(1, 2); + expect(position.x).toBe(1); + expect(position.y).toBe(2); + }); + + // Creating a new Position object with valid x and y values and an optional z value should set the x, y, and z attributes accordingly. + it('should set x, y, and z attributes when creating a new Position object with valid x, y, and z values', () => { + const position = new Position(1, 2, 3); + expect(position.x).toBe(1); + expect(position.y).toBe(2); + expect(position.z).toBe(3); + }); + + // The x, y, and z attributes of a Position object should be retrievable and modifiable using dot notation. + it('should be able to retrieve and modify x, y, and z attributes using dot notation', () => { + const position = new Position(1, 2, 3); + position.x = 4; + position.y = 5; + position.z = 6; + expect(position.x).toBe(4); + expect(position.y).toBe(5); + expect(position.z).toBe(6); + }); + + // Creating a new Position object with a non-numeric x value should raise a TypeError. + it('should raise a TypeError when creating a new Position object with a non-numeric x value', () => { + expect(() => new Position('a', 2)).toThrow(TypeError); + }); +}); + +// Generated by CodiumAI + +describe('Helper', () => { + // containsObject returns true when object is in array + it('should return true when object is in array', () => { + const obj = { name: 'John' }; + const arr = [obj, { name: 'Jane' }, { name: 'Bob' }]; + + expect(Helper.containsObject(obj, arr)).toBe(true); + }); + + // containsObject returns false when object is not in array + it('should return false when object is not in array', () => { + const obj = { name: 'John' }; + const arr = [{ name: 'Jane' }, { name: 'Bob' }]; + + expect(Helper.containsObject(obj, arr)).toBe(false); + }); + + // containsObject returns false when array is empty + it('should return false when array is empty', () => { + const obj = { name: 'John' }; + const arr = []; + + expect(Helper.containsObject(obj, arr)).toBe(false); + }); + + // containsObject returns false when object is null + it('should return false when object is null', () => { + const obj = null; + const arr = [{ name: 'Jane' }, { name: 'Bob' }]; + + expect(Helper.containsObject(obj, arr)).toBe(false); + }); + + // containsObject returns false when array is null + it('should return false when array is null', () => { + const obj = { name: 'John' }; + const arr = null; + + expect(Helper.containsObject(obj, arr)).toBe(false); + }); + + // containsObject returns true when object is equal to an object in array + it('should return true when object is equal to an object in array', () => { + const obj = { name: 'John' }; + const arr = [{ name: 'Jane' }, obj, { name: 'Bob' }]; + + expect(Helper.containsObject(obj, arr)).toBe(true); + }); +}); + +// Generated by CodiumAI + +describe('Tracker', () => { + // Tracker is initialized with default data if no previous data is found + it('should initialize Tracker with default data when no previous data is found', () => { + const tracker = new Tracker(); + const data = tracker.GetTrackerData(); + + expect(data).toBeDefined(); + expect(data.userAgent).toBeDefined(); + expect(data.vendor).toBeDefined(); + expect(data.language).toBeDefined(); + expect(data.platform).toBeDefined(); + expect(data.events).toBeDefined(); + }); + + // Tracker can save data to local storage + it('should save data to local storage', () => { + const tracker = new Tracker(); + const data = { + userAgent: 'test', + vendor: 'test', + language: 'test', + platform: 'test', + events: [], + }; + + tracker.SaveTrackerData(data); + const savedData = JSON.parse(localStorage.getItem(constants.project_id)); + + expect(savedData).toEqual(data); + }); + + // Tracker can retrieve data from local storage + it('should retrieve data from local storage', () => { + const tracker = new Tracker(); + const data = { + userAgent: 'test', + vendor: 'test', + language: 'test', + platform: 'test', + events: [], + }; + + localStorage.setItem(constants.project_id, JSON.stringify(data)); + const retrievedData = tracker.GetTrackerData(); + + expect(retrievedData).toEqual(data); + }); + + // Tracker can handle cases where previous data is found in local storage + it('should handle cases where previous data is found in local storage', () => { + const tracker = new Tracker(); + const prevData = { + userAgent: 'test', + vendor: 'test', + language: 'test', + platform: 'test', + events: [], + }; + const newData = { + userAgent: 'new', + vendor: 'new', + language: 'new', + platform: 'new', + events: [], + }; + + localStorage.setItem(constants.project_id, JSON.stringify(prevData)); + tracker.DataSetup(); + const retrievedData = tracker.GetTrackerData(); + + expect(retrievedData).toEqual(prevData); + + tracker.SaveTrackerData(newData); + const savedData = JSON.parse(localStorage.getItem(constants.project_id)); + + expect(savedData).toEqual(newData); + }); +}); + +// Generated by CodiumAI + +describe('Review', () => { + // Toggling review mode on sets focus to review element and announces "Review on" + it('should set focus to review element and announce "Review on" when toggling review mode on', () => { + const review = new Review(); + document.activeElement = null; + display.announceText = jest.fn(); + + review.ToggleReviewMode(true); + + expect(document.activeElement).toBe(constants.review); + expect(display.announceText).toHaveBeenCalledWith('Review on'); + }); + + // Toggling review mode off hides review container and announces "Review off" + it('should hide review container and announce "Review off" when toggling review mode off', () => { + const review = new Review(); + constants.review_container.classList.remove = jest.fn(); + display.announceText = jest.fn(); + + review.ToggleReviewMode(false); + + expect(constants.review_container.classList.add).toHaveBeenCalledWith( + 'hidden' + ); + expect(display.announceText).toHaveBeenCalledWith('Review off'); + }); + + // Toggling review mode off with braille mode on turns braille mode back on + it('should turn braille mode back on when toggling review mode off with braille mode on', () => { + const review = new Review(); + constants.brailleMode = 'on'; + constants.reviewSaveBrailleMode = 'on'; + display.toggleBrailleMode = jest.fn(); + + review.ToggleReviewMode(false); + + expect(display.toggleBrailleMode).toHaveBeenCalledWith('on'); + }); + + // Toggling review mode on with no active element sets saved spot to null + it('should set saved spot to null when toggling review mode on with no active element', () => { + const review = new Review(); + document.activeElement = null; + + review.ToggleReviewMode(true); + + expect(constants.reviewSaveSpot).toBeNull(); + }); +}); + +// Generated by CodiumAI + +describe('LogError', () => { + // LogAbsentElement method logs a message with the absent element and a default message. + it('should log a message with the absent element and a default message', () => { + const logError = new LogError(); + const consoleSpy = jest.spyOn(console, 'log'); + + logError.LogAbsentElement('element'); + + expect(consoleSpy).toHaveBeenCalledWith( + 'element not found. Visual highlighting is turned off.' + ); + + consoleSpy.mockRestore(); + }); + + // LogCriticalElement method logs a message with the critical element and a specific message. + it('should log a message with the critical element and a specific message', () => { + const logError = new LogError(); + const consoleSpy = jest.spyOn(console, 'log'); + + logError.LogCriticalElement('element'); + + expect(consoleSpy).toHaveBeenCalledWith( + 'element is critical. MAIDR unable to run' + ); + + consoleSpy.mockRestore(); + }); + + // LogDifferentLengths method logs a message with two elements that do not have the same length and a default message. + it('should log a message with two elements that do not have the same length and a default message', () => { + const logError = new LogError(); + const consoleSpy = jest.spyOn(console, 'log'); + + logError.LogDifferentLengths('element1', 'element2'); + + expect(consoleSpy).toHaveBeenCalledWith( + 'element1 and element2 do not have the same length. Visual highlighting is turned off.' + ); + + consoleSpy.mockRestore(); + }); +}); diff --git a/react-maidr/src/js/audio.js b/react-maidr/src/js/audio.js new file mode 100644 index 00000000..53b1850b --- /dev/null +++ b/react-maidr/src/js/audio.js @@ -0,0 +1,587 @@ +/** + * Audio class + * Sets up audio stuff (compressor, gain), + * sets up an oscillator that has good falloff (no clipping sounds) and can be instanced to be played anytime and can handle overlaps, + * sets up an actual playTone function that plays tones based on current chart position + * + * @class + */ +class Audio { + constructor() { + this.AudioContext = window['AudioContext'] || window['webkitAudioContext']; + this.audioContext = new AudioContext(); + this.compressor = this.compressorSetup(this.audioContext); + } + + /** + * Sets up a dynamics compressor for better audio quality. + * @returns {DynamicsCompressorNode} The created compressor. + */ + compressorSetup() { + let compressor = this.audioContext.createDynamicsCompressor(); // create compressor for better audio quality + + compressor.threshold.value = -50; + compressor.knee.value = 40; + compressor.ratio.value = 12; + compressor.attack.value = 0; + compressor.release.value = 0.25; + let gainMaster = this.audioContext.createGain(); // create master gain + gainMaster.gain.value = constants.vol; + compressor.connect(gainMaster); + gainMaster.connect(this.audioContext.destination); + + return compressor; + } + + /** + * Initilizes a tone play based on the current chart type and position. + * Triggers playOscillator() with the correct parameters. + */ + playTone(params = null) { + let currentDuration = constants.duration; + let volume = constants.vol; + if (params != null) { + if (params.volScale != null) { + volume = params.volScale * constants.vol; + } + } + + let rawPanning = 0; + let rawFreq = 0; + let frequency = 0; + let panning = 0; + + let waveType = 'sine'; + + // freq goes between min / max as rawFreq goes between min(0) / max + if (constants.chartType == 'bar') { + rawFreq = plot.plotData[position.x]; + rawPanning = position.x; + frequency = this.SlideBetween( + rawFreq, + constants.minY, + constants.maxY, + constants.MIN_FREQUENCY, + constants.MAX_FREQUENCY + ); + panning = this.SlideBetween( + rawPanning, + constants.minX, + constants.maxX, + -1, + 1 + ); + } else if (constants.chartType == 'box') { + let plotPos = + constants.plotOrientation == 'vert' ? position.x : position.y; + let sectionKey = plot.GetSectionKey( + constants.plotOrientation == 'vert' ? position.y : position.x + ); + if (Array.isArray(plot.plotData[plotPos][sectionKey])) { + // outliers are stored in values with a seperate itterator + rawFreq = plot.plotData[plotPos][sectionKey][position.z]; + } else { + // normal points + rawFreq = plot.plotData[plotPos][sectionKey]; + } + if (plot.plotData[plotPos][sectionKey] != null) { + if (constants.plotOrientation == 'vert') { + frequency = this.SlideBetween( + rawFreq, + constants.minY, + constants.maxY, + constants.MIN_FREQUENCY, + constants.MAX_FREQUENCY + ); + panning = this.SlideBetween( + rawFreq, + constants.minY, + constants.maxY, + -1, + 1 + ); + } else { + frequency = this.SlideBetween( + rawFreq, + constants.minX, + constants.maxX, + constants.MIN_FREQUENCY, + constants.MAX_FREQUENCY + ); + panning = this.SlideBetween( + rawFreq, + constants.minX, + constants.maxX, + -1, + 1 + ); + } + } else { + frequency = constants.MIN_FREQUENCY; + panning = 0; + } + } else if (constants.chartType == 'heat') { + rawFreq = plot.data[position.y][position.x]; + rawPanning = position.x; + frequency = this.SlideBetween( + rawFreq, + constants.minY, + constants.maxY, + constants.MIN_FREQUENCY, + constants.MAX_FREQUENCY + ); + panning = this.SlideBetween( + rawPanning, + constants.minX, + constants.maxX, + -1, + 1 + ); + } else if ( + constants.chartType == 'point' || + constants.chartType == 'smooth' + ) { + // are we using global min / max, or just this layer? + constants.globalMinMax = true; + let chartMin = constants.minY; + let chartMax = constants.maxY; + if (constants.chartType == 'smooth') { + chartMin = plot.curveMinY; + chartMax = plot.curveMaxY; + } + if (constants.globalMinMax) { + chartMin = Math.min(constants.minY, plot.curveMinY); + chartMax = Math.max(constants.maxY, plot.curveMaxY); + } + if (constants.chartType == 'point') { + // point layer + // more than one point with same x-value + rawFreq = plot.y[position.x][position.z]; + if (plot.max_count == 1) { + volume = constants.vol; + } else { + volume = this.SlideBetween( + plot.points_count[position.x][position.z], + 1, + plot.max_count, + constants.vol, + constants.MAX_VOL + ); + } + + rawPanning = position.x; + frequency = this.SlideBetween( + rawFreq, + chartMin, + chartMax, + constants.MIN_FREQUENCY, + constants.MAX_FREQUENCY + ); + panning = this.SlideBetween(rawPanning, chartMin, chartMax, -1, 1); + } else if (constants.chartType == 'smooth') { + // best fit smooth layer + + rawFreq = plot.curvePoints[positionL1.x]; + rawPanning = positionL1.x; + frequency = this.SlideBetween( + rawFreq, + chartMin, + chartMax, + constants.MIN_FREQUENCY, + constants.MAX_FREQUENCY + ); + panning = this.SlideBetween(rawPanning, chartMin, chartMax, -1, 1); + } + } else if (constants.chartType == 'hist') { + rawFreq = plot.plotData[position.x].y; + rawPanning = plot.plotData[position.x].x; + frequency = this.SlideBetween( + rawFreq, + constants.minY, + constants.maxY, + constants.MIN_FREQUENCY, + constants.MAX_FREQUENCY + ); + panning = this.SlideBetween( + rawPanning, + constants.minX, + constants.maxX, + -1, + 1 + ); + } else if (constants.chartType == 'line') { + rawFreq = plot.pointValuesY[position.x]; + rawPanning = position.x; + frequency = this.SlideBetween( + rawFreq, + constants.minY, + constants.maxY, + constants.MIN_FREQUENCY, + constants.MAX_FREQUENCY + ); + panning = this.SlideBetween( + rawPanning, + constants.minX, + constants.maxX, + -1, + 1 + ); + } else if ( + constants.chartType == 'stacked_bar' || + constants.chartType == 'stacked_normalized_bar' || + constants.chartType == 'dodged_bar' + ) { + rawFreq = plot.plotData[position.x][position.y]; + if (rawFreq == 0) { + this.PlayNull(); + return; + } else if (Array.isArray(rawFreq)) { + rawFreq = rawFreq[position.z]; + } + rawPanning = position.x; + frequency = this.SlideBetween( + rawFreq, + constants.minY, + constants.maxY, + constants.MIN_FREQUENCY, + constants.MAX_FREQUENCY + ); + panning = this.SlideBetween( + rawPanning, + constants.minX, + constants.maxX, + -1, + 1 + ); + let waveTypeArr = ['triangle', 'square', 'sawtooth', 'sine']; + waveType = waveTypeArr[position.y]; + } + + if (constants.debugLevel > 5) { + console.log('will play tone at freq', frequency); + if (constants.chartType == 'box') { + console.log( + 'based on', + constants.minY, + '<', + rawFreq, + '<', + constants.maxY, + ' | freq min', + constants.MIN_FREQUENCY, + 'max', + constants.MAX_FREQUENCY + ); + } else { + console.log( + 'based on', + constants.minX, + '<', + rawFreq, + '<', + constants.maxX, + ' | freq min', + constants.MIN_FREQUENCY, + 'max', + constants.MAX_FREQUENCY + ); + } + } + + if (constants.chartType == 'box') { + // different types of sounds for different regions. + // outlier = short tone + // whisker = normal tone + // range = chord + let sectionKey = plot.GetSectionKey( + constants.plotOrientation == 'vert' ? position.y : position.x + ); + if (sectionKey == 'lower_outlier' || sectionKey == 'upper_outlier') { + currentDuration = constants.outlierDuration; + } else if ( + sectionKey == 'q1' || + sectionKey == 'q2' || + sectionKey == 'q3' + ) { + //currentDuration = constants.duration * 2; + } else { + //currentDuration = constants.duration * 2; + } + } + + // create tones + this.playOscillator(frequency, currentDuration, panning, volume, waveType); + if (constants.chartType == 'box') { + let sectionKey = plot.GetSectionKey( + constants.plotOrientation == 'vert' ? position.y : position.x + ); + if (sectionKey == 'q1' || sectionKey == 'q2' || sectionKey == 'q3') { + // also play an octive below at lower vol + let freq2 = frequency / 2; + this.playOscillator( + freq2, + currentDuration, + panning, + constants.vol / 4, + 'triangle' + ); + } + } else if (constants.chartType == 'heat') { + // Added heatmap tone feature + if (rawFreq == 0) { + this.PlayNull(); + } + } + } + + /** + * Plays an oscillator with the given frequency, duration, panning, volume, and wave type. + * Typically used by playTone(), which does all the heavy lifting. + * @param {number} frequency - The frequency of the oscillator. + * @param {number} currentDuration - The duration of the oscillator in seconds. + * @param {number} panning - The panning value of the oscillator. + * @param {number} [currentVol=1] - The volume of the oscillator. + * @param {string} [wave='sine'] - The wave type of the oscillator. + */ + playOscillator( + frequency, + currentDuration, + panning, + currentVol = 1, + wave = 'sine' + ) { + const t = this.audioContext.currentTime; + const oscillator = this.audioContext.createOscillator(); + oscillator.type = wave; + oscillator.frequency.value = parseFloat(frequency); + oscillator.start(); + + // create gain for this event + const gainThis = this.audioContext.createGain(); + gainThis.gain.setValueCurveAtTime( + [ + 0.5 * currentVol, + 1 * currentVol, + 0.5 * currentVol, + 0.5 * currentVol, + 0.5 * currentVol, + 0.1 * currentVol, + 1e-4 * currentVol, + ], + t, + currentDuration + ); // this is what makes the tones fade out properly and not clip + + let MAX_DISTANCE = 10000; + let posZ = 1; + const panner = new PannerNode(this.audioContext, { + panningModel: 'HRTF', + distanceModel: 'linear', + positionX: position.x, + positionY: position.y, + positionZ: posZ, + plotOrientationX: 0.0, + plotOrientationY: 0.0, + plotOrientationZ: -1.0, + refDistance: 1, + maxDistance: MAX_DISTANCE, + rolloffFactor: 10, + coneInnerAngle: 40, + coneOuterAngle: 50, + coneOuterGain: 0.4, + }); + + // create panning + const stereoPanner = this.audioContext.createStereoPanner(); + stereoPanner.pan.value = panning; + oscillator.connect(gainThis); + gainThis.connect(stereoPanner); + stereoPanner.connect(panner); + panner.connect(this.compressor); + + // create panner node + + // play sound for duration + setTimeout(() => { + panner.disconnect(); + gainThis.disconnect(); + oscillator.stop(); + oscillator.disconnect(); + }, currentDuration * 1e3 * 2); + } + + /** + * Plays a smooth sound with the given frequency array, duration, panning array, volume, and wave type. + * The idea here is you give it an array of frequencies, and it plays them smoothly in order, like listening to a whole line chart + * @param {number[]} freqArr - The array of frequencies to play. + * @param {number} currentDuration - The duration of the sound in seconds. + * @param {number[]} panningArr - The array of panning values. + * @param {number} currentVol - The volume of the sound. + * @param {string} wave - The type of wave to use for the oscillator. + */ + playSmooth( + freqArr = [600, 500, 400, 300], + currentDuration = 2, + panningArr = [-1, 0, 1], + currentVol = 1, + wave = 'sine' + ) { + // todo: make smooth duration dependant on how much line there is to do. Like, at max it should be max duration, but if we only have like a tiny bit to play we should just play for a tiny bit + + let gainArr = new Array(freqArr.length * 3).fill(0.5 * currentVol); + gainArr.push(1e-4 * currentVol); + + const t = this.audioContext.currentTime; + const smoothOscillator = this.audioContext.createOscillator(); + smoothOscillator.type = wave; + smoothOscillator.frequency.setValueCurveAtTime(freqArr, t, currentDuration); + smoothOscillator.start(); + constants.isSmoothAutoplay = true; + + // create gain for this event + this.smoothGain = this.audioContext.createGain(); + this.smoothGain.gain.setValueCurveAtTime(gainArr, t, currentDuration); // this is what makes the tones fade out properly and not clip + + let MAX_DISTANCE = 10000; + let posZ = 1; + const panner = new PannerNode(this.audioContext, { + panningModel: 'HRTF', + distanceModel: 'linear', + positionX: position.x, + positionY: position.y, + positionZ: posZ, + plotOrientationX: 0.0, + plotOrientationY: 0.0, + plotOrientationZ: -1.0, + refDistance: 1, + maxDistance: MAX_DISTANCE, + rolloffFactor: 10, + coneInnerAngle: 40, + coneOuterAngle: 50, + coneOuterGain: 0.4, + }); + + // create panning + const stereoPanner = this.audioContext.createStereoPanner(); + stereoPanner.pan.setValueCurveAtTime(panningArr, t, currentDuration); + smoothOscillator.connect(this.smoothGain); + this.smoothGain.connect(stereoPanner); + stereoPanner.connect(panner); + panner.connect(this.compressor); + + // play sound for duration + constants.smoothId = setTimeout(() => { + panner.disconnect(); + this.smoothGain.disconnect(); + smoothOscillator.stop(); + smoothOscillator.disconnect(); + constants.isSmoothAutoplay = false; + }, currentDuration * 1e3 * 2); + } + + /** + * Initializes play of a custom null frequency sound. + * Calls the usual playOscillator() to do so. + */ + PlayNull() { + let frequency = constants.NULL_FREQUENCY; + let duration = constants.duration; + let panning = 0; + let vol = constants.vol; + let wave = 'triangle'; + + this.playOscillator(frequency, duration, panning, vol, wave); + + setTimeout( + function (audioThis) { + audioThis.playOscillator( + (frequency * 23) / 24, + duration, + panning, + vol, + wave + ); + }, + Math.round((duration / 5) * 1000), + this + ); + } + + /** + * Plays a pleasant end chime. + * @function + * @memberof audio + * @returns {void} + */ + playEnd() { + // play a pleasent end chime. We'll use terminal chime from VSCode + if (constants.canPlayEndChime) { + let chimeClone = constants.endChime.cloneNode(true); // we clone so that we can trigger a tone while one is already playing + /* + * the following (panning) only works if we're on a server + let panning = 0; + try { + if ( constants.chartType == 'bar' ) { + panning = this.SlideBetween(position.x, 0, plot.bars.length-1, -1, 1); + } else if ( constants.chartType == 'box' ) { + panning = this.SlideBetween(position.x, 0, plot.plotData[position.y].length-1, -1, 1); + } else if ( constants.chartType == 'heat' ) { + panning = this.SlideBetween(position.x, 0, plot.num_cols-1, -1, 1); + } else if ( constants.chartType == 'point' ) { + panning = this.SlideBetween(position.x, 0, plot.x.length-1, -1, 1); + } + } catch { + } + + const track = this.audioContext.createMediaElementSource(chimeClone); + const stereoNode = new StereoPannerNode(this.audioContext, {pan:panning} ); + track.connect(stereoNode).connect(this.audioContext.destination); + */ + chimeClone.play(); + chimeClone = null; + } + } + + /** + * Stops the smooth gain and cancels any scheduled values. + * @function + * @memberof Audio + * @instance + * @returns {void} + */ + KillSmooth() { + if (constants.smoothId) { + this.smoothGain.gain.cancelScheduledValues(0); + this.smoothGain.gain.exponentialRampToValueAtTime( + 0.0001, + this.audioContext.currentTime + 0.03 + ); + + clearTimeout(constants.smoothId); + + constants.isSmoothAutoplay = false; + } + } + + /** + * Goes between min and max proportional to how val goes between a and b. + * @param {number} val - The value to slide between a and b. + * @param {number} a - The start value of the slide. + * @param {number} b - The end value of the slide. + * @param {number} min - The minimum value of the slide. + * @param {number} max - The maximum value of the slide. + * @returns {number} The new value between min and max. + */ + SlideBetween(val, a, b, min, max) { + val = Number(val); + a = Number(a); + b = Number(b); + min = Number(min); + max = Number(max); + let newVal = ((val - a) / (b - a)) * (max - min) + min; + if (a == 0 && b == 0) { + newVal = 0; + } + return newVal; + } +} diff --git a/react-maidr/src/js/barplot.js b/react-maidr/src/js/barplot.js new file mode 100644 index 00000000..2141886e --- /dev/null +++ b/react-maidr/src/js/barplot.js @@ -0,0 +1,321 @@ +/** + * Represents a bar chart. + * @class + */ +class BarChart { + /** + * Creates a new instance of Barplot. + * @constructor + */ + constructor() { + // initialize variables xlevel, data, and elements + let xlevel = null; + if ('axes' in singleMaidr) { + if (singleMaidr.axes.x) { + if (singleMaidr.axes.x.level) { + xlevel = singleMaidr.axes.x.level; + } + } + // todo: handle y for vertical bar charts + } + let data = null; + if ('data' in singleMaidr) { + data = singleMaidr.data; + } + let elements = null; + if ('selector' in singleMaidr) { + elements = document.querySelectorAll(singleMaidr.selector); + } else if ('elements' in singleMaidr) { + elements = singleMaidr.elements; + } + + if (xlevel && data && elements) { + if (elements.length != data.length) { + // I didn't throw an error but give a warning + constants.hasRect = 0; + logError.LogDifferentLengths('elements', 'data'); + } else if (xlevel.length != elements.length) { + constants.hasRect = 0; + logError.LogDifferentLengths('x level', 'elements'); + } else if (data.length != xlevel.length) { + constants.hasRect = 0; + logError.LogDifferentLengths('x level', 'data'); + } else { + this.bars = elements; + constants.hasRect = 1; + } + } else if (data && elements) { + if (data.length != elements.length) { + constants.hasRect = 0; + logError.LogDifferentLengths('data', 'elements'); + } else { + this.bars = elements; + constants.hasRect = 1; + } + } else if (xlevel && data) { + if (xlevel.length != data.length) { + constants.hasRect = 0; + logError.LogDifferentLengths('x level', 'data'); + } + logError.LogAbsentElement('elements'); + } else if (data) { + logError.LogAbsentElement('x level'); + logError.LogAbsentElement('elements'); + } + + // column labels, both legend and tick + this.columnLabels = []; + let legendX = ''; + let legendY = ''; + if ('labels' in singleMaidr) { + if ('x' in singleMaidr.labels) { + legendX = singleMaidr.labels.x; + } + if ('y' in singleMaidr.labels) { + legendY = singleMaidr.labels.y; + } + } + if ('axes' in singleMaidr) { + // legend labels + if (singleMaidr.axes.x) { + if (singleMaidr.axes.x.label) { + if (legendX == '') { + legendX = singleMaidr.axes.x.label; + } + } + } + if (singleMaidr.axes.y) { + if (singleMaidr.axes.y.label) { + if (legendY == '') { + legendY = singleMaidr.axes.y.label; + } + } + } + + // tick labels + if (singleMaidr.axes.x) { + if (singleMaidr.axes.x.level) { + this.columnLabels = singleMaidr.axes.x.level; + } + } + if (singleMaidr.axes.y) { + if (singleMaidr.axes.y.level) { + this.columnLabels = singleMaidr.axes.y.level; + } + } + } + + this.plotLegend = { + x: legendX, + y: legendY, + }; + + // title + this.title = ''; + if ('labels' in singleMaidr) { + if ('title' in singleMaidr.labels) { + this.title = singleMaidr.labels.title; + } + } + if (this.title == '') { + if ('title' in singleMaidr) { + this.title = singleMaidr.title; + } + } + + // subtitle + if ('labels' in singleMaidr) { + if ('subtitle' in singleMaidr.labels) { + this.subtitle = singleMaidr.labels.subtitle; + } + } + // caption + if ('labels' in singleMaidr) { + if ('caption' in singleMaidr.labels) { + this.caption = singleMaidr.labels.caption; + } + } + + if (Array.isArray(singleMaidr)) { + this.plotData = singleMaidr; + } else if ('data' in singleMaidr) { + this.plotData = singleMaidr.data; + } + + // set the max and min values for the plot + this.SetMaxMin(); + + this.autoplay = null; + } + + /** + * Sets the maximum and minimum values for the plot data and calculates other constants. + */ + SetMaxMin() { + for (let i = 0; i < this.plotData.length; i++) { + if (i == 0) { + constants.maxY = this.plotData[i]; + constants.minY = this.plotData[i]; + } else { + if (this.plotData[i] > constants.maxY) { + constants.maxY = this.plotData[i]; + } + if (this.plotData[i] < constants.minY) { + constants.minY = this.plotData[i]; + } + } + } + constants.maxX = this.columnLabels.length; + constants.autoPlayRate = Math.min( + Math.ceil(constants.AUTOPLAY_DURATION / (constants.maxX + 1)), + constants.MAX_SPEED + ); + constants.DEFAULT_SPEED = constants.autoPlayRate; + if (constants.autoPlayRate < constants.MIN_SPEED) { + constants.MIN_SPEED = constants.autoPlayRate; + } + } + + /** + * Plays a tone using the audio player. + */ + PlayTones() { + audio.playTone(); + } + + /** + * Returns the legend object for the barplot based on manual data. + * @returns {Object} The legend object with x and y coordinates. + */ + GetLegendFromManualData() { + let legend = {}; + + legend.x = barplotLegend.x; + legend.y = barplotLegend.y; + + return legend; + } + + /** + * Returns an array of heights for each bar in the plot. + * @returns {Array} An array of heights for each bar in the plot. + */ + GetData() { + // set height for each bar + + let plotData = []; + + if (this.bars) { + for (let i = 0; i < this.bars.length; i++) { + plotData.push(this.bars[i].getAttribute('height')); + } + } + + return plotData; + } + + /** + * Returns an array of column names from the chart. + * @returns {Array} An array of column names. + */ + GetColumns() { + // get column names + // the pattern seems to be a with dy="10", but check this for future output (todo) + + let columnLabels = []; + let els = constants.chart.querySelectorAll('tspan[dy="10"]'); // todo, generalize this selector + for (var i = 0; i < els.length; i++) { + columnLabels.push(els[i].innerHTML); + } + + return columnLabels; + } + + /** + * Returns an object containing the x and y coordinates of the legend. + * @returns {{x: string, y: string}} An object with x and y properties representing the coordinates of the legend. + */ + GetLegend() { + let legend = {}; + let els = constants.chart.querySelectorAll('tspan[dy="12"]'); // todo, generalize this selector + legend.x = els[1].innerHTML; + legend.y = els[0].innerHTML; + + return legend; + } + + /** + * Parses the innerHTML of elements. + * @param {Array} els - The array of elements to parse. + * @returns {Array} - The parsed innerHTML of the elements. + */ + ParseInnerHTML(els) { + // parse innerHTML of elements + let parsed = []; + for (var i = 0; i < els.length; i++) { + parsed.push(els[i].innerHTML); + } + return parsed; + } + + /** + * Selects the active element and changes its color. + */ + Select() { + this.UnSelectPrevious(); + if (this.bars) { + this.activeElement = this.bars[position.x]; + if (this.activeElement) { + // Case where fill is a direct attribute + if (this.activeElement.hasAttribute('fill')) { + this.activeElementColor = this.activeElement.getAttribute('fill'); + // Get new color to highlight and replace fill value + this.activeElement.setAttribute( + 'fill', + constants.GetBetterColor(this.activeElementColor) + ); + // Case where fill is within the style attribute + } else if ( + this.activeElement.hasAttribute('style') && + this.activeElement.getAttribute('style').indexOf('fill') !== -1 + ) { + let styleString = this.activeElement.getAttribute('style'); + // Extract all style attributes and values + let styleArray = constants.GetStyleArrayFromString(styleString); + this.activeElementColor = styleArray[styleArray.indexOf('fill') + 1]; + // Get new color to highlight and replace fill value in style array + styleArray[styleArray.indexOf('fill') + 1] = constants.GetBetterColor( + this.activeElementColor + ); + // Recreate style string and set style attribute + styleString = constants.GetStyleStringFromArray(styleArray); + this.activeElement.setAttribute('style', styleString); + } + } + } + } + + /** + * Unselects the previously selected element by setting its fill attribute to the original color. + */ + UnSelectPrevious() { + if (this.activeElement) { + // set fill attribute to the original color + if (this.activeElement.hasAttribute('fill')) { + this.activeElement.setAttribute('fill', this.activeElementColor); + this.activeElement = null; + } else if ( + this.activeElement.hasAttribute('style') && + this.activeElement.getAttribute('style').indexOf('fill') !== -1 + ) { + let styleString = this.activeElement.getAttribute('style'); + let styleArray = constants.GetStyleArrayFromString(styleString); + styleArray[styleArray.indexOf('fill') + 1] = this.activeElementColor; + // Recreate style string and set style attribute + styleString = constants.GetStyleStringFromArray(styleArray); + this.activeElement.setAttribute('style', styleString); + this.activeElement = null; + } + } + } +} diff --git a/react-maidr/src/js/boxplot.js b/react-maidr/src/js/boxplot.js new file mode 100644 index 00000000..6dfcc385 --- /dev/null +++ b/react-maidr/src/js/boxplot.js @@ -0,0 +1,745 @@ +/** + * A class representing a box plot. + * @class + */ +class BoxPlot { + /** + * Creates a new instance of BoxPlot. + * @constructor + */ + constructor() { + // the default sections for all boxplots + this.sections = [ + 'lower_outlier', + 'min', + 'q1', + 'q2', + 'q3', + 'max', + 'upper_outlier', + ]; + + // set orientation + constants.plotOrientation = 'horz'; + if ('axes' in singleMaidr) { + if ('x' in singleMaidr.axes) { + if ('level' in singleMaidr.axes.x) { + constants.plotOrientation = 'vert'; + } + } + } + + // title + this.title = ''; + if ('labels' in singleMaidr) { + if ('title' in singleMaidr.labels) { + this.title = singleMaidr.labels.title; + } + } + if (this.title == '') { + if ('title' in singleMaidr) { + this.title = singleMaidr.title; + } + } + // subtitle + this.subtitle = ''; + if ('labels' in singleMaidr) { + if ('subtitle' in singleMaidr.labels) { + this.subtitle = singleMaidr.labels.subtitle; + } + } + // caption + this.caption = ''; + if ('labels' in singleMaidr) { + if ('caption' in singleMaidr.labels) { + this.caption = singleMaidr.labels.caption; + } + } + + // axes labels + if ('labels' in singleMaidr) { + if (!this.x_group_label) { + if ('x' in singleMaidr.labels) { + this.x_group_label = singleMaidr.labels.x; + } + } + if (!this.y_group_label) { + if ('y' in singleMaidr.labels) { + this.y_group_label = singleMaidr.labels.y; + } + } + } + if ('axes' in singleMaidr) { + if ('x' in singleMaidr.axes) { + if ('label' in singleMaidr.axes.x) { + if (!this.x_group_label) { + this.x_group_label = singleMaidr.axes.x.label; + } + } + if ('level' in singleMaidr.axes.x) { + this.x_labels = singleMaidr.axes.x.level; + } else { + this.x_labels = []; + } + } + if ('y' in singleMaidr.axes) { + if ('label' in singleMaidr.axes.y) { + if (!this.y_group_label) { + this.y_group_label = singleMaidr.axes.y.label; + } + } + if ('level' in singleMaidr.axes.y) { + this.y_labels = singleMaidr.axes.y.level; + } else { + this.y_labels = []; + } + } + } + + // main data + this.plotData = singleMaidr.data; + + // bounds data + if ('selector' in singleMaidr) { + let elements = document.querySelector(singleMaidr.selector); + this.plotBounds = this.GetPlotBounds(elements); + constants.hasRect = true; + } else if ('elements' in singleMaidr) { + this.plotBounds = this.GetPlotBounds(singleMaidr.elements); + constants.hasRect = true; + } else { + constants.hasRect = false; + } + + this.CleanData(); + } + + /** + * Cleans up data and extra variables like min/max stuff. + */ + CleanData() { + let min, max; + for (let i = 0; i < this.plotData.length; i++) { + if (this.plotData[i].lower_outlier) { + let outlierMin = Math.min(...this.plotData[i].lower_outlier); + let outlierMax = Math.max(...this.plotData[i].lower_outlier); + + if (min == undefined || outlierMin < min) min = outlierMin; + if (max == undefined || outlierMax > max) max = outlierMax; + } + if (this.plotData[i].min) { + if (min == undefined || this.plotData[i].min < min) + min = this.plotData[i].min; + if (max == undefined || this.plotData[i].max > max) + max = this.plotData[i].max; + } + if (this.plotData[i].q1) { + if (min == undefined || this.plotData[i].q1 < min) + min = this.plotData[i].q1; + if (max == undefined || this.plotData[i].q1 > max) + max = this.plotData[i].q1; + } + if (this.plotData[i].q2) { + if (min == undefined || this.plotData[i].q2 < min) + min = this.plotData[i].q2; + if (max == undefined || this.plotData[i].q2 > max) + max = this.plotData[i].q2; + } + if (this.plotData[i].q3) { + if (min == undefined || this.plotData[i].q3 < min) + min = this.plotData[i].q3; + if (max == undefined || this.plotData[i].q3 > max) + max = this.plotData[i].q3; + } + if (this.plotData[i].max) { + if (min == undefined || this.plotData[i].max < min) + min = this.plotData[i].max; + if (max == undefined || this.plotData[i].max > max) + max = this.plotData[i].max; + } + if (this.plotData[i].upper_outlier) { + let outlierMin = Math.min(...this.plotData[i].upper_outlier); + let outlierMax = Math.max(...this.plotData[i].upper_outlier); + + if (min == undefined || outlierMin < min) min = outlierMin; + if (max == undefined || outlierMax > max) max = outlierMax; + } + } + + if (constants.plotOrientation == 'vert') { + constants.minY = min; + constants.maxY = max; + constants.minX = 0; + constants.maxX = this.plotData.length - 1; + } else { + constants.minX = min; + constants.maxX = max; + constants.minY = 0; + constants.maxY = this.plotData.length - 1; + } + constants.autoPlayRate = Math.min( + Math.ceil(constants.AUTOPLAY_DURATION / this.plotData.length), + constants.MAX_SPEED + ); + constants.DEFAULT_SPEED = constants.autoPlayRate; + if (constants.autoPlayRate < constants.MIN_SPEED) { + constants.MIN_SPEED = constants.autoPlayRate; + } + } + + /** + * Calculates the bounding boxes for all elements in the parent element, including outliers, whiskers, and range. + * @returns {Array} An array of bounding boxes for all elements. + */ + GetPlotBounds(elements) { + // we fetch the elements in our parent, + // and similar to old GetData we run through and get bounding boxes (or blanks) for everything, + // and store in an identical structure + + let plotBounds = []; + let allWeNeed = this.GetAllSegmentTypes(); + let re = /(?:\d+(?:\.\d*)?|\.\d+)/g; + + // get initial set of elements, a parent element for all outliers, whiskers, and range + let initialElemSet = []; + let plots = elements.children; + for (let i = 0; i < plots.length; i++) { + // each plot + let plotSet = {}; + let sections = plots[i].children; + for (let j = 0; j < sections.length; j++) { + let elemType = this.GetBoxplotSegmentType( + sections[j].getAttribute('id') + ); + plotSet[elemType] = sections[j]; + } + initialElemSet.push(plotSet); + } + + // we build our structure based on the full set we need, and have blanks as placeholders + // many of these overlap or are missing, so now we go through and make the actual array structure we need + // like, all outliers are in 1 set, so we have to split those out and then get the bounding boxes + for (let i = 0; i < initialElemSet.length; i++) { + let plotBound = []; + + // we always have a range, and need those bounds to set others, so we'll do this first + let rangeBounds = initialElemSet[i].range.getBoundingClientRect(); + + // we get the midpoint from actual point values in the chart GRID.segments + let midPoints = initialElemSet[i].range + .querySelector('polyline[id^="GRID"]') + .getAttribute('points') + .match(re); + let rangePoints = initialElemSet[i].range + .querySelector('polygon[id^="geom_polygon"]') + .getAttribute('points') + .match(re); + // get midpoint as percentage from bottom to mid to apply to bounding boxes + // vert: top(rangePoints[1]) | mid(midPoints[1]) | bottom(rangePoints[3]) + // horz: top(rangePoints[0]) | mid(midPoints[0]) | bottom(rangePoints[2]) + let midPercent = 0; + if (constants.plotOrientation == 'vert') { + midPercent = + (midPoints[1] - rangePoints[3]) / (rangePoints[1] - rangePoints[3]); + } else { + midPercent = + (midPoints[0] - rangePoints[2]) / (rangePoints[0] - rangePoints[2]); + } + let midSize = 0; + if (constants.plotOrientation == 'vert') { + midSize = rangeBounds.height * midPercent; + } else { + midSize = rangeBounds.width * midPercent; + } + + // set bounding box values + // we critically need x / left, y / top, width, height. We can ignore the rest or let it be wrong + + // 25% + plotBound[2] = this.convertBoundingClientRectToObj(rangeBounds); + plotBound[2].label = allWeNeed[2]; + plotBound[2].type = 'range'; + if (constants.plotOrientation == 'vert') { + plotBound[2].height = midSize; + plotBound[2].top = plotBound[2].bottom - midSize; + plotBound[2].y = plotBound[2].top; + } else { + plotBound[2].width = midSize; + } + // 50% + plotBound[3] = this.convertBoundingClientRectToObj(rangeBounds); + plotBound[3].label = allWeNeed[3]; + plotBound[3].type = 'range'; + if (constants.plotOrientation == 'vert') { + plotBound[3].height = 0; + plotBound[3].top = rangeBounds.bottom - midSize; + plotBound[3].y = plotBound[3].top; + plotBound[3].bottom = plotBound[3].top; + } else { + plotBound[3].width = 0; + plotBound[3].left = rangeBounds.left + midSize; + } + // 75% + plotBound[4] = this.convertBoundingClientRectToObj(rangeBounds); + plotBound[4].label = allWeNeed[4]; + plotBound[4].type = 'range'; + if (constants.plotOrientation == 'vert') { + plotBound[4].height = rangeBounds.height - midSize; + plotBound[4].bottom = plotBound[3].top; + } else { + plotBound[4].width = rangeBounds.width - midSize; + plotBound[4].left = plotBound[3].left; + } + + // now the tricky ones, outliers and whiskers, if we have them + if (Object.hasOwn(initialElemSet[i], 'whisker')) { + // ok great we have a whisker. It could be just above or below or span across the range (in which case we need to split it up). Let's check + let whiskerBounds = initialElemSet[i].whisker.getBoundingClientRect(); + let hasBelow = false; + let hasAbove = false; + if (constants.plotOrientation == 'vert') { + if (whiskerBounds.bottom > rangeBounds.bottom) hasBelow = true; + if (whiskerBounds.top < rangeBounds.top) hasAbove = true; + } else { + if (whiskerBounds.left < rangeBounds.left) hasBelow = true; + if (whiskerBounds.right > rangeBounds.right) hasAbove = true; + } + + // lower whisker + if (hasBelow) { + plotBound[1] = this.convertBoundingClientRectToObj(whiskerBounds); + plotBound[1].label = allWeNeed[1]; + plotBound[1].type = 'whisker'; + if (constants.plotOrientation == 'vert') { + plotBound[1].top = plotBound[2].bottom; + plotBound[1].y = plotBound[1].top; + plotBound[1].height = plotBound[1].bottom - plotBound[1].top; + } else { + plotBound[1].width = plotBound[2].left - plotBound[1].left; + } + } else { + plotBound[1] = {}; + plotBound[1].label = allWeNeed[1]; + plotBound[1].type = 'blank'; + } + // upper whisker + if (hasAbove) { + plotBound[5] = this.convertBoundingClientRectToObj(whiskerBounds); + plotBound[5].label = allWeNeed[5]; + plotBound[5].type = 'whisker'; + if (constants.plotOrientation == 'vert') { + plotBound[5].bottom = plotBound[4].top; + plotBound[5].height = plotBound[5].bottom - plotBound[5].top; + } else { + plotBound[5].left = plotBound[4].right; + plotBound[5].x = plotBound[4].right; + plotBound[5].width = plotBound[5].right - plotBound[5].left; + } + } else { + plotBound[5] = {}; + plotBound[5].label = allWeNeed[5]; + plotBound[5].type = 'blank'; + } + } + if (Object.hasOwn(initialElemSet[i], 'outlier')) { + // we have one or more outliers. + // Where do they appear? above or below the range? both? + // we want to split them up and put 1 bounding box around each above and below + + let outlierElems = initialElemSet[i].outlier.children; + let outlierUpperBounds = null; + let outlierLowerBounds = null; + for (let j = 0; j < outlierElems.length; j++) { + // add this outlier's bounds, or expand if more than one + let newOutlierBounds = outlierElems[j].getBoundingClientRect(); + + if (constants.plotOrientation == 'vert') { + if (newOutlierBounds.y < rangeBounds.y) { + // higher, remember y=0 is at the bottom of the page + if (!outlierUpperBounds) { + outlierUpperBounds = + this.convertBoundingClientRectToObj(newOutlierBounds); + } else { + if (newOutlierBounds.y < outlierUpperBounds.y) + outlierUpperBounds.y = newOutlierBounds.y; + if (newOutlierBounds.top < outlierUpperBounds.top) + outlierUpperBounds.top = newOutlierBounds.top; + if (newOutlierBounds.bottom > outlierUpperBounds.bottom) + outlierUpperBounds.bottom = newOutlierBounds.bottom; + } + } else { + if (!outlierLowerBounds) { + outlierLowerBounds = + this.convertBoundingClientRectToObj(newOutlierBounds); + } else { + if (newOutlierBounds.y < outlierLowerBounds.y) + outlierLowerBounds.y = newOutlierBounds.y; + if (newOutlierBounds.top < outlierLowerBounds.top) + outlierLowerBounds.top = newOutlierBounds.top; + if (newOutlierBounds.bottom > outlierLowerBounds.bottom) + outlierLowerBounds.bottom = newOutlierBounds.bottom; + } + } + } else { + if (newOutlierBounds.x > rangeBounds.x) { + // higher, remember x=0 is at the left of the page + if (!outlierUpperBounds) { + outlierUpperBounds = + this.convertBoundingClientRectToObj(newOutlierBounds); + } else { + if (newOutlierBounds.x < outlierUpperBounds.x) + outlierUpperBounds.x = newOutlierBounds.x; + if (newOutlierBounds.left < outlierUpperBounds.left) + outlierUpperBounds.left = newOutlierBounds.left; + if (newOutlierBounds.right > outlierUpperBounds.right) + outlierUpperBounds.right = newOutlierBounds.right; + } + } else { + if (!outlierLowerBounds) { + outlierLowerBounds = + this.convertBoundingClientRectToObj(newOutlierBounds); + } else { + if (newOutlierBounds.x < outlierLowerBounds.x) + outlierLowerBounds.x = newOutlierBounds.x; + if (newOutlierBounds.left < outlierLowerBounds.left) + outlierLowerBounds.left = newOutlierBounds.left; + if (newOutlierBounds.right > outlierLowerBounds.right) + outlierLowerBounds.right = newOutlierBounds.right; + } + } + } + } + + // now we add plotBound outlier stuff + if (outlierLowerBounds) { + outlierLowerBounds.height = + outlierLowerBounds.bottom - outlierLowerBounds.top; + outlierLowerBounds.width = + outlierLowerBounds.right - outlierLowerBounds.left; + + plotBound[0] = + this.convertBoundingClientRectToObj(outlierLowerBounds); + plotBound[0].label = allWeNeed[0]; + plotBound[0].type = 'outlier'; + } else { + plotBound[0] = {}; + plotBound[0].label = allWeNeed[0]; + plotBound[0].type = 'blank'; + } + if (outlierUpperBounds) { + outlierUpperBounds.height = + outlierUpperBounds.bottom - outlierUpperBounds.top; + outlierUpperBounds.width = + outlierUpperBounds.right - outlierUpperBounds.left; + + plotBound[6] = + this.convertBoundingClientRectToObj(outlierUpperBounds); + plotBound[6].label = allWeNeed[6]; + plotBound[6].type = 'outlier'; + } else { + plotBound[6] = {}; + plotBound[6].label = allWeNeed[6]; + plotBound[6].type = 'blank'; + } + } else { + // add all blanks + plotBound[0] = {}; + plotBound[0].label = allWeNeed[0]; + plotBound[0].type = 'blank'; + plotBound[6] = {}; + plotBound[6].label = allWeNeed[6]; + plotBound[6].type = 'blank'; + } + + plotBounds.push(plotBound); + } + + if (constants.debugLevel > 5) { + console.log('plotBounds', plotBounds); + } + + return plotBounds; + } + + /** + * Returns an array of all segment types needed for a box plot. + * @returns {string[]} Array of segment types. + */ + GetAllSegmentTypes() { + let allWeNeed = []; + for (let i = 0; i < this.sections.length; i++) { + allWeNeed.push(resources.GetString(this.sections[i])); + } + + return allWeNeed; + } + + /** + * Returns the type of boxplot segment based on the section id. + * @param {string} sectionId - The section id to determine the segment type. + * @returns {string} - The type of boxplot segment ('range', 'whisker', or 'outlier'). + */ + GetBoxplotSegmentType(sectionId) { + // Helper function for main GetData: + // Fetch type, which comes from section id: + // geom_polygon = range + // GRID = whisker + // points = outlier + + let segmentType = 'outlier'; // default? todo: should probably default null, and then throw error instead of return if not set after ifs + if (sectionId.includes('geom_crossbar')) { + segmentType = 'range'; + } else if (sectionId.includes('GRID')) { + segmentType = 'whisker'; + } else if (sectionId.includes('points')) { + segmentType = 'outlier'; + } + + return segmentType; + } + + /** + * Helper function for main GetData: Fetch x and y point data from chart + * @param {Object} segment - The segment object to get points from + * @param {string} segmentType - The type of segment ('range', 'outlier', or 'whisker') + * @returns {Array} - An array of x and y point data from the chart + */ + GetBoxplotSegmentPoints(segment, segmentType) { + // Helper function for main GetData: + // Fetch x and y point data from chart + + let re = /(?:\d+(?:\.\d*)?|\.\d+)/g; + let pointString = ''; + let points = []; + if (segmentType == 'range') { + // ranges go a level deeper + let matches = segment.children[0].getAttribute('points').match(re); + points.push(matches[0], matches[1]); + // the middle bar has 2 points but we just need one, check if they're the same + if (matches[0] != matches[2]) { + points.push(matches[2], matches[3]); + } + } else if (segmentType == 'outlier') { + // outliers use x attr directly, but have multiple children + points.push(segment.getAttribute('x'), segment.getAttribute('y')); + } else { + // whisker. Get first and third number from points attr + // but sometimes it's null, giving the same for both, and don't add if that's true + let matches = segment.getAttribute('points').match(re); + if (constants.plotOrientation == 'vert') { + if (matches[1] != matches[3]) { + points.push(matches[0], matches[1], matches[2], matches[3]); + } + } else { + if (matches[0] != matches[2]) { + points.push(matches[0], matches[1], matches[2], matches[3]); + } + } + } + + return points; + } + /** + * Returns an array of all the segment types needed for a box plot. + * @returns {string[]} Array of segment types. + */ + GetAllSegmentTypes() { + let allWeNeed = [ + resources.GetString('lower_outlier'), + resources.GetString('min'), + resources.GetString('25'), + resources.GetString('50'), + resources.GetString('75'), + resources.GetString('max'), + resources.GetString('upper_outlier'), + ]; + + return allWeNeed; + } + + /** + * Converts a DOMRect object to a plain object with properties for top, right, bottom, left, width, height, x, and y. + * @param {DOMRect} rect - The DOMRect object to convert. + * @returns {Object} An object with properties for top, right, bottom, left, width, height, x, and y. + */ + convertBoundingClientRectToObj(rect) { + return { + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height, + x: rect.x, + y: rect.y, + }; + } + + /** + * Plays tones based on the plot data and position. + */ + PlayTones() { + // init + let plotPos = null; + let sectionKey = null; + if (constants.outlierInterval) clearInterval(constants.outlierInterval); + if (constants.plotOrientation == 'vert') { + plotPos = position.x; + sectionKey = this.GetSectionKey(position.y); + } else { + plotPos = position.y; + sectionKey = this.GetSectionKey(position.x); + } + + // chose tone to play + if (plot.plotData[plotPos][sectionKey] == null) { + audio.PlayNull(); + } else if (sectionKey != 'lower_outlier' && sectionKey != 'upper_outlier') { + // normal tone + audio.playTone(); + } else if (plot.plotData[plotPos][sectionKey].length == 0) { + audio.PlayNull(); + } else { + // outlier(s): we play a run of tones + position.z = 0; + constants.outlierInterval = setInterval(function () { + // play this tone + audio.playTone(); + + // and then set up for the next one + position.z += 1; + + // and kill if we're done + if (plot.plotData[plotPos][sectionKey] == null) { + clearInterval(constants.outlierInterval); + position.z = -1; + } else if (position.z + 1 > plot.plotData[plotPos][sectionKey].length) { + clearInterval(constants.outlierInterval); + position.z = -1; + } + }, constants.autoPlayOutlierRate); + } + } + + /** + * Returns the section key at the specified position. + * @param {number} sectionPos - The position of the section. + * @returns {string} The section key. + */ + GetSectionKey(sectionPos) { + return this.sections[sectionPos]; + } +} + +// BoxplotRect class +// Initializes and updates the visual outline around sections of the chart +/** + * Represents a rectangular box in a box plot chart. + * @class + */ +class BoxplotRect { + /** + * The padding between rectangles in pixels. + * @type {number} + */ + rectPadding = 15; // px + /** + * The stroke width of the rectangle in the box plot. + * @type {number} + */ + rectStrokeWidth = 4; // px + + /** + * Creates a new BoxPlot object. + * @constructor + */ + constructor() { + this.x1 = 0; + this.width = 0; + this.y1 = 0; + this.height = 0; + this.chartOffsetLeft = constants.chart.getBoundingClientRect().left; + this.chartOffsetTop = constants.chart.getBoundingClientRect().top; + } + + /** + * Updates the bounding box values from the object and gets bounds of visual outline to be drawn. + */ + UpdateRect() { + if (document.getElementById('highlight_rect')) + document.getElementById('highlight_rect').remove(); // destroy to be recreated + + let plotPos = position.x; + let sectionPos = position.y; + let sectionKey = plot.GetSectionKey(position.y); + if (constants.plotOrientation == 'vert') { + } else { + plotPos = position.y; + sectionPos = position.x; + sectionKey = plot.GetSectionKey(position.x); + } + + if ( + (constants.plotOrientation == 'vert' && position.y > -1) || + (constants.plotOrientation == 'horz' && position.x > -1) + ) { + // initial value could be -1, which throws errors, so ignore that + + let bounds = plot.plotBounds[plotPos][sectionPos]; + + if (bounds.type != 'blank') { + //let chartBounds = constants.chart.getBoundingClientRect(); + + this.x1 = bounds.left - this.rectPadding - this.chartOffsetLeft; + this.width = bounds.width + this.rectPadding * 2; + this.y1 = bounds.top - this.rectPadding - this.chartOffsetTop; + this.height = bounds.height + this.rectPadding * 2; + + if (constants.debugLevel > 5) { + console.log( + 'Point', + sectionKey, + 'bottom:', + bounds.bottom, + 'top:', + bounds.top + ); + console.log( + 'x1:', + this.x1, + 'y1:', + this.y1, + 'width:', + this.width, + 'height:', + this.height + ); + } + + this.CreateRectDisplay(); + } + } + } + + /** + * Creates a visual outline using the given bounding points. + * @function + * @memberof module:boxplot.js + * @returns {void} + */ + CreateRectDisplay() { + // CreateRectDisplay takes bounding points and creates the visual outline + + const svgns = 'http://www.w3.org/2000/svg'; + let rect = document.createElementNS(svgns, 'rect'); + rect.setAttribute('id', 'highlight_rect'); + rect.setAttribute('x', this.x1); + rect.setAttribute('y', this.y1); // y coord is inverse from plot data + rect.setAttribute('width', this.width); + rect.setAttribute('height', this.height); + rect.setAttribute('stroke', constants.colorSelected); + rect.setAttribute('stroke-width', this.rectStrokeWidth); + rect.setAttribute('fill', 'none'); + constants.chart.appendChild(rect); + } +} diff --git a/react-maidr/src/js/constants.js b/react-maidr/src/js/constants.js new file mode 100644 index 00000000..2667b20b --- /dev/null +++ b/react-maidr/src/js/constants.js @@ -0,0 +1,2257 @@ +/** + * A class representing system vars, user config vars, and helper functions used throughout the application. + * + * @class + */ +class Constants { + chart_container_id = 'chart-container'; + main_container_id = 'maidr-container'; + //chart_container_class = 'chart-container'; // remove later + braille_container_id = 'braille-div'; + braille_input_id = 'braille-input'; + info_id = 'info'; + announcement_container_id = 'announcements'; + end_chime_id = 'end_chime'; + container_id = 'container'; + project_id = 'maidr'; + review_id_container = 'review_container'; + review_id = 'review'; + reviewSaveSpot; + reviewSaveBrailleMode; + chartId = ''; + events = []; + postLoadEvents = []; + + constructor() {} + + // BTS modes initial values + textMode = 'verbose'; // off / terse / verbose + brailleMode = 'off'; // on / off + sonifMode = 'on'; // sep / same / off + reviewMode = 'off'; // on / off + + // basic chart properties + minX = 0; + maxX = 0; + minY = 0; + maxY = 0; + plotId = ''; // update with id in chart specific js + chartType = ''; // set as 'box' or whatever later in chart specific js file + navigation = 1; // 0 = row navigation (up/down), 1 = col navigation (left/right) + + // basic audio properties + MAX_FREQUENCY = 1000; + MIN_FREQUENCY = 200; + NULL_FREQUENCY = 100; + combinedVolMin = 0.25; // volume for min amplitude combined tones + combinedVolMax = 1.25; // volume for max amplitude combined tones + + // autoplay speed + MAX_SPEED = 500; + MIN_SPEED = 50; // 50; + DEFAULT_SPEED = 250; + INTERVAL = 20; + AUTOPLAY_DURATION = 5000; // 5s + + // user settings + vol = 0.5; + MAX_VOL = 30; + // autoPlayRate = this.DEFAULT_SPEED; // ms per tone + autoPlayRate = this.DEFAULT_SPEED; // ms per tone + colorSelected = '#03C809'; + brailleDisplayLength = 32; // num characters in user's braille display. 40 is common length for desktop / mobile applications + + // advanced user settings + showRect = 1; // true / false + hasRect = 1; // true / false + hasSmooth = 1; // true / false (for smooth line points) + duration = 0.3; + outlierDuration = 0.06; + autoPlayOutlierRate = 50; // ms per tone + autoPlayPointsRate = 50; // time between tones in a run + colorUnselected = '#595959'; // deprecated, todo: find all instances replace with storing old color method + isTracking = 1; // 0 / 1, is tracking on or off + visualBraille = false; // do we want to represent braille based on what's visually there or actually there. Like if we have 2 outliers with the same position, do we show 1 (visualBraille true) or 2 (false) + globalMinMax = true; + ariaMode = 'assertive'; // assertive (default) / polite + + // LLM settings + openAIAuthKey = null; // OpenAI authentication key, set in menu + geminiAuthKey = null; // Gemini authentication key, set in menu + LLMmaxResponseTokens = 1000; // max tokens to send to LLM, 20 for testing, 1000 ish for real + playLLMWaitingSound = true; + LLMDetail = 'high'; // low (default for testing, like 100 tokens) / high (default for real, like 1000 tokens) + LLMModel = 'openai'; // openai (default) / gemini + LLMSystemMessage = + 'You are a helpful assistant describing the chart to a blind person. '; + skillLevel = 'basic'; // basic / intermediate / expert + skillLevelOther = ''; // custom skill level + + // user controls (not exposed to menu, with shortcuts usually) + showDisplay = 1; // true / false + showDisplayInBraille = 1; // true / false + showDisplayInAutoplay = 0; // true / false + outlierInterval = null; + + // platform controls + isMac = navigator.userAgent.toLowerCase().includes('mac'); // true if macOS + control = this.isMac ? 'Cmd' : 'Ctrl'; + alt = this.isMac ? 'option' : 'Alt'; + home = this.isMac ? 'fn + Left arrow' : 'Home'; + end = this.isMac ? 'fn + Right arrow' : 'End'; + + // internal controls + keypressInterval = 2000; // ms or 2s + tabMovement = null; + + // debug stuff + debugLevel = 3; // 0 = no console output, 1 = some console, 2 = more console, etc + canPlayEndChime = false; // + manualData = true; // pull from manual data like chart2music (true), or do the old method where we pull from the chart (false) + + KillAutoplay() { + if (this.autoplayId) { + clearInterval(this.autoplayId); + this.autoplayId = null; + } + } + + KillSepPlay() { + if (this.sepPlayId) { + clearInterval(this.sepPlayId); + this.sepPlayId = null; + } + } + + SpeedUp() { + if (constants.autoPlayRate - this.INTERVAL > this.MIN_SPEED) { + constants.autoPlayRate -= this.INTERVAL; + } + } + + SpeedDown() { + if (constants.autoPlayRate + this.INTERVAL <= this.MAX_SPEED) { + constants.autoPlayRate += this.INTERVAL; + } + } + + SpeedReset() { + constants.autoPlayRate = constants.DEFAULT_SPEED; + } + + /** + * Function to convert hexadecimal color to string formatted rgb() functional notation. + * @param hexColorString - hexadecimal color (e.g., "#595959"). + * @returns {string} - rgb() functional notation string (e.g., "rgb(100,100,100)"). + * @constructor + */ + ConvertHexToRGBString(hexColorString) { + return ( + 'rgb(' + + parseInt(hexColorString.slice(1, 3), 16) + + ',' + + parseInt(hexColorString.slice(3, 5), 16) + + ',' + + parseInt(hexColorString.slice(5, 7), 16) + + ')' + ); + } + + /** + * Function to convert an rgb() functional notation string to hexadecimal color. + * @param rgbColorString - color in rgb() functional notation (e.g., "rgb(100,100,100)"). + * @returns {string} - hexadecimal color (e.g., "#595959"). + * @constructor + */ + ConvertRGBStringToHex(rgbColorString) { + let rgb = rgbColorString.replace(/[^\d,]/g, '').split(','); + return ( + '#' + + rgb[0].toString(16).padStart(2, '0') + + rgb[1].toString(16).padStart(2, '0') + + rgb[2].toString(16).padStart(2, '0') + ); + } + + ColorInvert(color) { + // invert an rgb color + let rgb = color.replace(/[^\d,]/g, '').split(','); + let r = 255 - rgb[0]; + let g = 255 - rgb[1]; + let b = 255 - rgb[2]; + return 'rgb(' + r + ',' + g + ',' + b + ')'; + } + GetBetterColor(oldColor) { + // get a highly contrasting color against the current + // method: choose an inverted color, but if it's just a shade of gray, default to this.colorSelected + // Convert hex color to RGB color string if needed + if (oldColor.indexOf('#') !== -1) { + oldColor = this.ConvertHexToRGBString(oldColor); + } + let newColor = this.ColorInvert(oldColor); + let rgb = newColor.replace(/[^\d,]/g, '').split(','); + if ( + rgb[1] < rgb[0] + 10 && + rgb[1] > rgb[0] - 10 && + rgb[2] < rgb[0] + 10 && + rgb[2] > rgb[0] - 10 && + (rgb[0] > 86 || rgb[0] < 169) + ) { + // too gray and too close to center gray, use default + newColor = this.colorSelected; + } + + return newColor; + } + + /** + * Function to parse a string containing CSS styles and return an array of strings containing CSS style attributes and values. + * @param styleString - a string containing CSS styles in inline format. + * @returns {string[]} - an array of strings containing CSS style attributes and values. + * @constructor + */ + GetStyleArrayFromString(styleString) { + // Get an array of CSS style attributes and values from a style string + return styleString.replaceAll(' ', '').split(/[:;]/); + } + + /** + * Function to parse an array of strings containing CSS style attributes and values and return a string containing CSS styles. + * @param styleArray - an array of strings containing CSS style attributes and values. + * @returns {string} - a string containing the CSS styles. + * @constructor + */ + GetStyleStringFromArray(styleArray) { + // Get CSS style string from an array of style attributes and values + let styleString = ''; + for (let i = 0; i < styleArray.length; i++) { + if (i % 2 === 0) { + if (i !== styleArray.length - 1) { + styleString += styleArray[i] + ': '; + } else { + styleString += styleArray[i]; + } + } else { + styleString += styleArray[i] + '; '; + } + } + return styleString; + } +} + +/** + * Resources class contains properties and methods related to language, knowledge level, and strings. + */ +class Resources { + constructor() {} + + language = 'en'; // 2 char lang code + knowledgeLevel = 'basic'; // basic, intermediate, expert + + // these strings run on getters, which pull in language, knowledgeLevel, chart, and actual requested string + strings = { + en: { + basic: { + upper_outlier: 'Upper Outlier', + lower_outlier: 'Lower Outlier', + min: 'Minimum', + max: 'Maximum', + 25: '25%', + 50: '50%', + 75: '75%', + q1: '25%', + q2: '50%', + q3: '75%', + son_on: 'Sonification on', + son_off: 'Sonification off', + son_des: 'Sonification descrete', + son_comp: 'Sonification compare', + son_ch: 'Sonification chord', + son_sep: 'Sonification separate', + son_same: 'Sonification combined', + empty: 'Empty', + openai: 'OpenAI Vision', + gemini: 'Gemini Pro Vision', + multi: 'Multiple AI', + }, + }, + }; + + /** + * Returns a string based on the provided ID, language, and knowledge level. + * @param {string} id - The ID of the string to retrieve. + * @returns {string} The string corresponding to the provided ID, language, and knowledge level. + */ + GetString(id) { + return this.strings[this.language][this.knowledgeLevel][id]; + } +} + +/** + * Represents a menu object with various settings and keyboard shortcuts. + */ +class Menu { + whereWasMyFocus = null; + + constructor() { + this.CreateMenu(); + this.LoadDataFromLocalStorage(); + } + + menuHtml = ` + + + + `; + + /** + * Creates a menu element and sets up event listeners for opening and closing the menu, + * and saving and loading data from local storage. + */ + CreateMenu() { + // menu element creation + document + .querySelector('body') + .insertAdjacentHTML('beforeend', this.menuHtml); + + // menu close events + let allClose = document.querySelectorAll('#close_menu, #menu .close'); + for (let i = 0; i < allClose.length; i++) { + constants.events.push([ + allClose[i], + 'click', + function (e) { + menu.Toggle(false); + }, + ]); + } + constants.events.push([ + document.getElementById('save_and_close_menu'), + 'click', + function (e) { + menu.SaveData(); + menu.Toggle(false); + }, + ]); + constants.events.push([ + document.getElementById('menu'), + 'keyup', + function (e) { + if (e.key == 'Esc') { + // esc + menu.Toggle(false); + } + }, + ]); + + // Menu open events + constants.events.push([ + document, + 'keyup', + function (e) { + // don't fire on input elements + if ( + e.target.tagName.toLowerCase() == 'input' || + e.target.tagName.toLowerCase() == 'textarea' + ) { + return; + } else if (e.key == 'h') { + menu.Toggle(true); + } + }, + ]); + + // toggle auth key fields + constants.events.push([ + document.getElementById('LLM_model'), + 'change', + function (e) { + if (e.target.value == 'openai') { + document + .getElementById('openai_auth_key_container') + .classList.remove('hidden'); + document + .getElementById('gemini_auth_key_container') + .classList.add('hidden'); + document + .getElementById('openai_multi_container') + .classList.add('hidden'); + document + .getElementById('gemini_multi_container') + .classList.add('hidden'); + document.getElementById('openai_multi').checked = true; + document.getElementById('gemini_multi').checked = false; + } else if (e.target.value == 'gemini') { + document + .getElementById('openai_auth_key_container') + .classList.add('hidden'); + document + .getElementById('gemini_auth_key_container') + .classList.remove('hidden'); + document + .getElementById('openai_multi_container') + .classList.add('hidden'); + document + .getElementById('gemini_multi_container') + .classList.add('hidden'); + document.getElementById('openai_multi').checked = false; + document.getElementById('gemini_multi').checked = true; + } else if (e.target.value == 'multi') { + document + .getElementById('openai_auth_key_container') + .classList.remove('hidden'); + document + .getElementById('gemini_auth_key_container') + .classList.remove('hidden'); + document + .getElementById('openai_multi_container') + .classList.remove('hidden'); + document + .getElementById('gemini_multi_container') + .classList.remove('hidden'); + document.getElementById('openai_multi').checked = true; + document.getElementById('gemini_multi').checked = true; + } + }, + ]); + + // Skill level other events + constants.events.push([ + document.getElementById('skill_level'), + 'change', + function (e) { + if (e.target.value == 'other') { + document + .getElementById('skill_level_other_container') + .classList.remove('hidden'); + } else { + document + .getElementById('skill_level_other_container') + .classList.add('hidden'); + } + }, + ]); + } + + /** + * Destroys the menu element and its backdrop. + * @function + * @name Destroy + * @memberof module:constants + * @returns {void} + */ + Destroy() { + // menu element destruction + let menu = document.getElementById('menu'); + if (menu) { + menu.remove(); + } + let backdrop = document.getElementById('menu_modal_backdrop'); + if (backdrop) { + backdrop.remove(); + } + } + + /** + * Toggles the menu on and off. + * @param {boolean} [onoff=false] - Whether to turn the menu on or off. Defaults to false (close). + */ + Toggle(onoff = false) { + if (typeof onoff == 'undefined') { + if (document.getElementById('menu').classList.contains('hidden')) { + onoff = true; + } else { + onoff = false; + } + } + // don't open if we have another modal open already + if (onoff && document.getElementById('chatLLM')) { + if (!document.getElementById('chatLLM').classList.contains('hidden')) { + return; + } + } + if (onoff) { + // open + this.whereWasMyFocus = document.activeElement; + this.PopulateData(); + constants.tabMovement = 0; + document.getElementById('menu').classList.remove('hidden'); + document.getElementById('menu_modal_backdrop').classList.remove('hidden'); + document.querySelector('#menu .close').focus(); + } else { + // close + document.getElementById('menu').classList.add('hidden'); + document.getElementById('menu_modal_backdrop').classList.add('hidden'); + this.whereWasMyFocus.focus(); + this.whereWasMyFocus = null; + } + } + + /** + * Populates the form fields in the help menu with the values from the constants object. + */ + PopulateData() { + document.getElementById('vol').value = constants.vol; + document.getElementById('autoplay_rate').value = constants.autoPlayRate; + document.getElementById('braille_display_length').value = + constants.brailleDisplayLength; + document.getElementById('color_selected').value = constants.colorSelected; + document.getElementById('min_freq').value = constants.MIN_FREQUENCY; + document.getElementById('max_freq').value = constants.MAX_FREQUENCY; + document.getElementById('keypress_interval').value = + constants.keypressInterval; + if (typeof constants.openAIAuthKey == 'string') { + document.getElementById('openai_auth_key').value = + constants.openAIAuthKey; + } + if (typeof constants.geminiAuthKey == 'string') { + document.getElementById('gemini_auth_key').value = + constants.geminiAuthKey; + } + document.getElementById('skill_level').value = constants.skillLevel; + if (constants.skillLevelOther) { + document.getElementById('skill_level_other').value = + constants.skillLevelOther; + } + document.getElementById('LLM_model').value = constants.LLMModel; + + // aria mode + if (constants.ariaMode == 'assertive') { + document.getElementById('aria_mode_assertive').checked = true; + document.getElementById('aria_mode_polite').checked = false; + } else { + document.getElementById('aria_mode_polite').checked = true; + document.getElementById('aria_mode_assertive').checked = false; + } + // hide either openai or gemini auth key field + if (constants.LLMModel == 'openai') { + document + .getElementById('openai_auth_key_container') + .classList.remove('hidden'); + document + .getElementById('gemini_auth_key_container') + .classList.add('hidden'); + } else if (constants.LLMModel == 'gemini') { + document + .getElementById('openai_auth_key_container') + .classList.add('hidden'); + document + .getElementById('gemini_auth_key_container') + .classList.remove('hidden'); + } else if (constants.LLMModel == 'multi') { + // multi LLM mode + document + .getElementById('openai_auth_key_container') + .classList.remove('hidden'); + document + .getElementById('gemini_auth_key_container') + .classList.remove('hidden'); + document + .getElementById('openai_multi_container') + .classList.remove('hidden'); + document + .getElementById('gemini_multi_container') + .classList.remove('hidden'); + document.getElementById('openai_multi').checked = false; + if (constants.LLMOpenAiMulti) { + document.getElementById('openai_multi').checked = true; + } + document.getElementById('gemini_multi').checked = false; + if (constants.LLMGeminiMulti) { + document.getElementById('gemini_multi').checked = true; + } + } + // skill level other + if (constants.skillLevel == 'other') { + document + .getElementById('skill_level_other_container') + .classList.remove('hidden'); + } + // LLM preferences + if (constants.LLMPreferences) { + document.getElementById('LLM_preferences').value = + constants.LLMPreferences; + } + } + + /** + * Saves the data from the HTML elements into the constants object. + */ + SaveData() { + this.HandleLLMChanges(); + + constants.vol = document.getElementById('vol').value; + constants.autoPlayRate = document.getElementById('autoplay_rate').value; + constants.brailleDisplayLength = document.getElementById( + 'braille_display_length' + ).value; + constants.colorSelected = document.getElementById('color_selected').value; + constants.MIN_FREQUENCY = document.getElementById('min_freq').value; + constants.MAX_FREQUENCY = document.getElementById('max_freq').value; + constants.keypressInterval = + document.getElementById('keypress_interval').value; + + constants.openAIAuthKey = document.getElementById('openai_auth_key').value; + constants.geminiAuthKey = document.getElementById('gemini_auth_key').value; + constants.skillLevel = document.getElementById('skill_level').value; + constants.skillLevelOther = + document.getElementById('skill_level_other').value; + constants.LLMModel = document.getElementById('LLM_model').value; + constants.LLMPreferences = document.getElementById('LLM_preferences').value; + + constants.LLMOpenAiMulti = document.getElementById('openai_multi').checked; + constants.LLMGeminiMulti = document.getElementById('gemini_multi').checked; + + // aria + if (document.getElementById('aria_mode_assertive').checked) { + constants.ariaMode = 'assertive'; + } else if (document.getElementById('aria_mode_polite').checked) { + constants.ariaMode = 'polite'; + } + + this.SaveDataToLocalStorage(); + this.UpdateHtml(); + } + + /** + * Updates various html elements and attributes. + * Typically used to do things like update the aria-live attributes + * + * @function + * @memberof constants + * @returns {void} + */ + UpdateHtml() { + // set aria attributes + constants.infoDiv.setAttribute('aria-live', constants.ariaMode); + document + .getElementById(constants.announcement_container_id) + .setAttribute('aria-live', constants.ariaMode); + } + + /** + * Handles changes to the LLM model and multi-modal settings. + * We reset if we change the LLM model, multi settings, or skill level. + */ + HandleLLMChanges() { + let shouldReset = false; + if ( + !shouldReset && + constants.skillLevel != document.getElementById('skill_level').value + ) { + shouldReset = true; + } + if ( + !shouldReset && + constants.LLMModel != document.getElementById('LLM_model').value + ) { + shouldReset = true; + } + if ( + !shouldReset && + (constants.LLMOpenAiMulti != + document.getElementById('openai_multi').checked || + constants.LLMGeminiMulti != + document.getElementById('gemini_multi').checked) + ) { + shouldReset = true; + } + + if (shouldReset) { + if (chatLLM) { + chatLLM.ResetChatHistory(); + } + } + } + + /** + * Saves all data in Menu to local storage. + * @function + * @memberof constants + * @returns {void} + */ + SaveDataToLocalStorage() { + let data = {}; + data.vol = constants.vol; + data.autoPlayRate = constants.autoPlayRate; + data.brailleDisplayLength = constants.brailleDisplayLength; + data.colorSelected = constants.colorSelected; + data.MIN_FREQUENCY = constants.MIN_FREQUENCY; + data.MAX_FREQUENCY = constants.MAX_FREQUENCY; + data.keypressInterval = constants.keypressInterval; + data.ariaMode = constants.ariaMode; + data.openAIAuthKey = constants.openAIAuthKey; + data.geminiAuthKey = constants.geminiAuthKey; + data.skillLevel = constants.skillLevel; + data.skillLevelOther = constants.skillLevelOther; + data.LLMModel = constants.LLMModel; + data.LLMPreferences = constants.LLMPreferences; + data.LLMOpenAiMulti = constants.LLMOpenAiMulti; + data.LLMGeminiMulti = constants.LLMGeminiMulti; + localStorage.setItem('settings_data', JSON.stringify(data)); + } + /** + * Loads data from local storage and updates the constants object with the retrieved values, to be loaded into the menu + */ + LoadDataFromLocalStorage() { + let data = JSON.parse(localStorage.getItem('settings_data')); + if (data) { + constants.vol = data.vol; + constants.autoPlayRate = data.autoPlayRate; + constants.brailleDisplayLength = data.brailleDisplayLength; + constants.colorSelected = data.colorSelected; + constants.MIN_FREQUENCY = data.MIN_FREQUENCY; + constants.MAX_FREQUENCY = data.MAX_FREQUENCY; + constants.keypressInterval = data.keypressInterval; + constants.ariaMode = data.ariaMode; + constants.openAIAuthKey = data.openAIAuthKey; + constants.geminiAuthKey = data.geminiAuthKey; + constants.skillLevel = data.skillLevel; + constants.skillLevelOther = data.skillLevelOther; + constants.LLMModel = data.LLMModel ? data.LLMModel : constants.LLMModel; + constants.LLMPreferences = data.LLMPreferences; + constants.LLMOpenAiMulti = data.LLMOpenAiMulti; + constants.LLMGeminiMulti = data.LLMGeminiMulti; + } + this.PopulateData(); + this.UpdateHtml(); + } +} + +/** + * Creates an html modal with a basic text input, + * and hooks to send info to an LLM + * @class + */ +class ChatLLM { + constructor() { + this.firstTime = true; + this.firstMulti = true; + this.CreateComponent(); + this.SetEvents(); + } + + /** + * Creates a modal component containing basic text input + * Sets events to toggle on and off chat window + */ + CreateComponent() { + let html = ` + + + `; + document.querySelector('body').insertAdjacentHTML('beforeend', html); + } + + /** + * Sets events for the chatLLM modal + */ + SetEvents() { + // chatLLM close events + let allClose = document.querySelectorAll('#close_chatLLM, #chatLLM .close'); + for (let i = 0; i < allClose.length; i++) { + constants.events.push([ + allClose[i], + 'click', + function (e) { + chatLLM.Toggle(false); + }, + ]); + } + constants.events.push([ + document.getElementById('chatLLM'), + 'keyup', + function (e) { + if (e.key == 'Esc') { + // esc + chatLLM.Toggle(false); + } + }, + ]); + + // ChatLLM open/close toggle + constants.events.push([ + document, + 'keyup', + function (e) { + if (e.key == '?' && (e.ctrlKey || e.metaKey)) { + chatLLM.Toggle(); + } + }, + ]); + + // ChatLLM request events + constants.events.push([ + document.getElementById('chatLLM_submit'), + 'click', + function (e) { + let text = document.getElementById('chatLLM_input').value; + chatLLM.DisplayChatMessage('User', text); + chatLLM.Submit(text); + }, + ]); + constants.events.push([ + document.getElementById('chatLLM_input'), + 'keyup', + function (e) { + if (e.key == 'Enter' && !e.shiftKey) { + let text = document.getElementById('chatLLM_input').value; + chatLLM.DisplayChatMessage('User', text); + chatLLM.Submit(text); + } + }, + ]); + + // ChatLLM suggestion events + // the more button + constants.events.push([ + document.getElementById('more_suggestions'), + 'click', + function (e) { + document + .getElementById('more_suggestions_container') + .classList.toggle('hidden'); + // focus on button right after the more button + document + .querySelector('#more_suggestions_container > p > button') + .focus(); + document.getElementById('more_suggestions').remove(); + }, + ]); + // actual suggestions: + let suggestions = document.querySelectorAll( + '#chatLLM .LLM_suggestions button:not(#more_suggestions)' + ); + for (let i = 0; i < suggestions.length; i++) { + constants.events.push([ + suggestions[i], + 'click', + function (e) { + let text = e.target.innerHTML; + chatLLM.DisplayChatMessage('User', text); + chatLLM.Submit(text); + }, + ]); + } + + // Delete OpenAI and Gemini keys + constants.events.push([ + document.getElementById('delete_openai_key'), + 'click', + function (e) { + document.getElementById('openai_auth_key').value = ''; + }, + ]); + constants.events.push([ + document.getElementById('delete_gemini_key'), + 'click', + function (e) { + document.getElementById('gemini_auth_key').value = ''; + }, + ]); + + // Reset chatLLM + constants.events.push([ + document.getElementById('reset_chatLLM'), + 'click', + function (e) { + chatLLM.Toggle(false); + chatLLM.ResetChatHistory(); + }, + ]); + } + + /** + * Submits text to the LLM with a REST call, returns the response to the user. + * Depends on the one or more LLMs being selected in the menu. + * @function + * @name Submit + * @memberof module:constants + * @text {string} - The text to send to the LLM. + * @img {string} - The image to send to the LLM in base64 string format. Defaults to null (no image). + * @returns {void} + */ + async Submit(text, firsttime = false) { + // start waiting sound + if (constants.playLLMWaitingSound) { + chatLLM.WaitingSound(true); + } + + let img = null; + this.firstMulti = true; + + if (constants.LLMOpenAiMulti || constants.LLMModel == 'openai') { + if (firsttime) { + img = await this.ConvertSVGtoJPG(singleMaidr.id, 'openai'); + } + chatLLM.OpenAIPrompt(text, img); + } + if (constants.LLMGeminiMulti || constants.LLMModel == 'gemini') { + if (firsttime) { + img = await this.ConvertSVGtoJPG(singleMaidr.id, 'gemini'); + } + chatLLM.GeminiPrompt(text, img); + } + } + + /* + * Sets a waiting sound to play while waiting for the LLM to respond. + * @function + * @name SetWaitingSound + * @memberof module:constants + * @onoff {boolean} - Whether to turn the waiting sound on or off. Defaults to true (on). + * @returns {void} + */ + WaitingSound(onoff = true) { + // clear old intervals and timeouts + if (constants.waitingInterval) { + // destroy old waiting sound + clearInterval(constants.waitingInterval); + constants.waitingSound = null; + } + if (constants.waitingSoundOverride) { + clearTimeout(constants.waitingSoundOverride); + constants.waitingSoundOverride = null; + } + + // assuming we're turning it on, start playing a new waiting sound + if (onoff) { + // create new waiting sound + let delay = 1000; + let freq = 440; // a440 babee + constants.waitingInterval = setInterval(function () { + if (audio) { + audio.playOscillator(freq, 0.2, 0); + } + }, delay); + + // clear automatically after 30 sec, assuming no response + constants.waitingSoundOverride = setTimeout(function () { + chatLLM.WaitingSound(false); + }, 30000); + } + } + + /** + * Processes the response from the LLM and displays it to the user. + * @function + * @returns {void} + */ + ProcessLLMResponse(data, model) { + chatLLM.WaitingSound(false); + console.log('LLM response: ', data); + let text = ''; + let LLMName = resources.GetString(model); + + if (model == 'openai') { + text = data.choices[0].message.content; + let i = this.requestJson.messages.length; + this.requestJson.messages[i] = {}; + this.requestJson.messages[i].role = 'assistant'; + this.requestJson.messages[i].content = text; + + if (data.error) { + chatLLM.DisplayChatMessage(LLMName, 'Error processing request.', true); + } else { + chatLLM.DisplayChatMessage(LLMName, text); + } + } else if (model == 'gemini') { + if (data.text()) { + text = data.text(); + chatLLM.DisplayChatMessage(LLMName, text); + } else { + if (!data.error) { + data.error = 'Error processing request.'; + } + } + if (data.error) { + chatLLM.DisplayChatMessage(LLMName, 'Error processing request.', true); + } else { + // todo: display actual response + } + } + } + + /** + * Fakes an LLM response for testing purposes. Returns a JSON object formatted like the LLM response. + * @function + * @returns {json} + */ + fakeLLMResponseData() { + let responseText = {}; + if (this.requestJson.messages.length > 2) { + // subsequent responses + responseText = { + id: 'chatcmpl-8Y44iRCRrohYbAqm8rfBbJqTUADC7', + object: 'chat.completion', + created: 1703129508, + model: 'gpt-4-1106-vision-preview', + usage: { + prompt_tokens: 451, + completion_tokens: 16, + total_tokens: 467, + }, + choices: [ + { + message: { + role: 'assistant', + content: 'A fake response from the LLM. Nice.', + }, + finish_reason: 'length', + index: 0, + }, + ], + }; + } else { + // first response + responseText = { + id: 'chatcmpl-8Y44iRCRrohYbAqm8rfBbJqTUADC7', + object: 'chat.completion', + created: 1703129508, + model: 'gpt-4-1106-vision-preview', + usage: { + prompt_tokens: 451, + completion_tokens: 16, + total_tokens: 467, + }, + choices: [ + { + message: { + role: 'assistant', + content: + 'The chart you\'re referring to is a bar graph titled "The Number of Diamonds', + }, + finish_reason: 'length', + index: 0, + }, + ], + }; + } + + return responseText; + } + + /** + * Gets running prompt info, appends the latest request, and packages it into a JSON object for the LLM. + * @function + * @name OpenAIPrompt + * @memberof module:constants + * @returns {json} + */ + OpenAIPrompt(text, img = null) { + // request init + let url = 'https://api.openai.com/v1/chat/completions'; + let auth = constants.openAIAuthKey; + let requestJson = chatLLM.OpenAIJson(text, img); + console.log('LLM request: ', requestJson); + + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + auth, + }, + body: JSON.stringify(requestJson), + }) + .then((response) => response.json()) + .then((data) => { + chatLLM.ProcessLLMResponse(data, 'openai'); + }) + .catch((error) => { + chatLLM.WaitingSound(false); + console.error('Error:', error); + chatLLM.DisplayChatMessage(LLMName, 'Error processing request.', true); + // also todo: handle errors somehow + }); + } + OpenAIJson(text, img = null) { + let sysMessage = constants.LLMSystemMessage; + let backupMessage = + 'Describe ' + singleMaidr.type + ' charts to a blind person'; + // headers and sys message + if (!this.requestJson) { + this.requestJson = {}; + this.requestJson.model = 'gpt-4-vision-preview'; + this.requestJson.max_tokens = constants.LLMmaxResponseTokens; // note: if this is too short (tested with less than 200), the response gets cut off + + // sys message + this.requestJson.messages = []; + this.requestJson.messages[0] = {}; + this.requestJson.messages[0].role = 'system'; + this.requestJson.messages[0].content = sysMessage; + if (constants.LLMPreferences) { + this.requestJson.messages[1] = {}; + this.requestJson.messages[1].role = 'system'; + this.requestJson.messages[1].content = constants.LLMPreferences; + } + } + + // user message + // if we have an image (first time only), send the image and the text, otherwise just the text + let i = this.requestJson.messages.length; + this.requestJson.messages[i] = {}; + this.requestJson.messages[i].role = 'user'; + if (img) { + // first message, include the img + this.requestJson.messages[i].content = [ + { + type: 'text', + text: text, + }, + { + type: 'image_url', + image_url: { url: img }, + }, + ]; + } else { + // just the text + this.requestJson.messages[i].content = text; + } + + return this.requestJson; + } + + async GeminiPrompt(text, imgBase64 = null) { + try { + // Save the image for next time + if (imgBase64 == null) { + imgBase64 = constants.LLMImage; + } else { + constants.LLMImage = imgBase64; + } + constants.LLMImage = imgBase64; + + // Import the module + const { GoogleGenerativeAI } = await import( + 'https://esm.run/@google/generative-ai' + ); + const API_KEY = constants.geminiAuthKey; + const genAI = new GoogleGenerativeAI(API_KEY); + const model = genAI.getGenerativeModel({ model: 'gemini-pro-vision' }); + + // Create the prompt + let prompt = constants.LLMSystemMessage; + if (constants.LLMPreferences) { + prompt += constants.LLMPreferences; + } + prompt += '\n\n' + text; // Use the text parameter as the prompt + const image = { + inlineData: { + data: imgBase64, // Use the base64 image string + mimeType: 'image/png', // Or the appropriate mime type of your image + }, + }; + + // Generate the content + console.log('LLM request: ', prompt, image); + const result = await model.generateContent([prompt, image]); + console.log(result.response.text()); + + // Process the response + chatLLM.ProcessLLMResponse(result.response, 'gemini'); + } catch (error) { + console.error('Error in GeminiPrompt:', error); + throw error; // Rethrow the error for further handling if necessary + } + } + + /** + * Displays chat message from the user and LLM in a chat history window + * @function + * @name DisplayChatMessage + * @memberof module:constants + * @returns {void} + */ + DisplayChatMessage(user = 'User', text = '', isSystem = false) { + let hLevel = 'h3'; + if (!isSystem && constants.LLMModel == 'multi' && user != 'User') { + if (this.firstMulti) { + let multiAIName = resources.GetString('multi'); + let titleHtml = ` +
+

${multiAIName} Responses

+
+ `; + this.RenderChatMessage(titleHtml); + this.firstMulti = false; + } + hLevel = 'h4'; + } + let html = ` +
+ <${hLevel} class="chatLLM_message_user">${user} +

${text}

+
+ `; + + this.RenderChatMessage(html); + } + RenderChatMessage(html) { + document + .getElementById('chatLLM_chat_history') + .insertAdjacentHTML('beforeend', html); + document.getElementById('chatLLM_input').value = ''; + + // scroll to bottom + document.getElementById('chatLLM_chat_history').scrollTop = + document.getElementById('chatLLM_chat_history').scrollHeight; + } + + /** + * Resets the chat history window + */ + ResetChatHistory() { + // clear the main chat history + document.getElementById('chatLLM_chat_history').innerHTML = ''; + // unhide the more button + document + .getElementById('more_suggestions_container') + .classList.add('hidden'); + document.getElementById('more_suggestions').classList.remove('hidden'); + + // reset the data + this.requestJson = null; + this.firstTime = true; + } + + /** + * Destroys the chatLLM element and its backdrop. + * @function + * @name Destroy + * @memberof module:constants + * @returns {void} + */ + Destroy() { + // chatLLM element destruction + let chatLLM = document.getElementById('chatLLM'); + if (chatLLM) { + chatLLM.remove(); + } + let backdrop = document.getElementById('chatLLM_modal_backdrop'); + if (backdrop) { + backdrop.remove(); + } + } + + /** + * Toggles the modal on and off. + * @param {boolean} [onoff=false] - Whether to turn the chatLLM on or off. Defaults to false (close). + */ + Toggle(onoff) { + if (typeof onoff == 'undefined') { + if (document.getElementById('chatLLM').classList.contains('hidden')) { + onoff = true; + } else { + onoff = false; + } + } + if (onoff) { + // open + this.whereWasMyFocus = document.activeElement; + constants.tabMovement = 0; + document.getElementById('chatLLM').classList.remove('hidden'); + document + .getElementById('chatLLM_modal_backdrop') + .classList.remove('hidden'); + document.querySelector('#chatLLM .close').focus(); + + // first time, send default query + if (this.firstTime) { + // get name from resource + let LLMName = resources.GetString(constants.LLMModel); + this.firstTime = false; + this.DisplayChatMessage(LLMName, 'Processing Chart...', true); + let defaultPrompt = this.GetDefaultPrompt(); + this.Submit(defaultPrompt, true); + } + } else { + // close + document.getElementById('chatLLM').classList.add('hidden'); + document.getElementById('chatLLM_modal_backdrop').classList.add('hidden'); + this.whereWasMyFocus.focus(); + this.whereWasMyFocus = null; + } + } + + /** + * Converts the active chart to a jpg image. + * @id {string} - The html ID of the chart to convert. + */ + async ConvertSVGtoJPG(id, model) { + let svgElement = document.getElementById(id); + return new Promise((resolve, reject) => { + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext('2d'); + + var svgData = new XMLSerializer().serializeToString(svgElement); + if (!svgData.startsWith(' + + + + + `; + + document.querySelector('body').insertAdjacentHTML('beforeend', html); + + // close events + let allClose = document.querySelectorAll( + '#close_desc, #description .close' + ); + for (let i = 0; i < allClose.length; i++) { + constants.events.push([ + allClose[i], + 'click', + function (e) { + description.Toggle(false); + }, + ]); + } + constants.events.push([ + document.getElementById('description'), + 'keyup', + function (e) { + if (e.key == 'Esc') { + // esc + description.Toggle(false); + } + }, + ]); + + // open events + constants.events.push([ + document, + 'keyup', + function (e) { + if (e.key == 'd') { + description.Toggle(true); + } + }, + ]); + } + + /** + * Removes the description element and backdrop from the DOM. + */ + Destroy() { + // description element destruction + let description = document.getElementById('menu'); + if (description) { + description.remove(); + } + let backdrop = document.getElementById('desc_modal_backdrop'); + if (backdrop) { + backdrop.remove(); + } + } + + /** + * Toggles the visibility of the description element. + * @param {boolean} [onoff=false] - Whether to turn the description element on or off. + */ + Toggle(onoff = false) { + if (typeof onoff == 'undefined') { + if (document.getElementById('description').classList.contains('hidden')) { + onoff = true; + } else { + onoff = false; + } + } + if (onoff) { + // open + this.whereWasMyFocus = document.activeElement; + constants.tabMovement = 0; + this.PopulateData(); + document.getElementById('description').classList.remove('hidden'); + document.getElementById('desc_modal_backdrop').classList.remove('hidden'); + document.querySelector('#description .close').focus(); + } else { + // close + document.getElementById('description').classList.add('hidden'); + document.getElementById('desc_modal_backdrop').classList.add('hidden'); + this.whereWasMyFocus.focus(); + this.whereWasMyFocus = null; + } + } + + /** + * Populates the data for the chart and table based on the chart type and plot data. + */ + PopulateData() { + let descHtml = ''; + + // chart labels and descriptions + let descType = ''; + if (constants.chartType == 'bar') { + descType = 'Bar chart'; + } else if (constants.chartType == 'heat') { + descType = 'Heatmap'; + } else if (constants.chartType == 'box') { + descType = 'Box plot'; + } else if (constants.chartType == 'scatter') { + descType = 'Scatter plot'; + } else if (constants.chartType == 'line') { + descType = 'Line chart'; + } else if (constants.chartType == 'hist') { + descType = 'Histogram'; + } + + if (descType) { + descHtml += `

Type: ${descType}

`; + } + if (plot.title != null) { + descHtml += `

Title: ${plot.title}

`; + } + if (plot.subtitle != null) { + descHtml += `

Subtitle: ${plot.subtitle}

`; + } + if (plot.caption != null) { + descHtml += `

Caption: ${plot.caption}

`; + } + + // table of data, prep + let descTableHtml = ''; + let descLabelX = null; + let descLabelY = null; + let descTickX = null; + let descTickY = null; + let descData = null; + let descNumCols = 0; + let descNumColsWithLabels = 0; + let descNumRows = 0; + let descNumRowsWithLabels = 0; + if (constants.chartType == 'bar') { + if (plot.plotLegend.x != null) { + descLabelX = plot.plotLegend.x; + descNumColsWithLabels += 1; + } + if (plot.plotLegend.y != null) { + descLabelY = plot.plotLegend.y; + descNumRowsWithLabels += 1; + } + if (plot.columnLabels != null) { + descTickX = plot.columnLabels; + descNumRowsWithLabels += 1; + } + if (plot.plotData != null) { + descData = []; + descData[0] = plot.plotData; + descNumCols = plot.plotData.length; + descNumRows = 1; + descNumColsWithLabels += descNumCols; + descNumRowsWithLabels += descNumRows; + } + } + + // table of data, create + if (descData != null) { + descTableHtml += ''; + + // header rows + if (descLabelX != null || descTickX != null) { + descTableHtml += ''; + if (descLabelX != null) { + descTableHtml += ''; + if (descLabelY != null) { + descTableHtml += ''; + } + if (descTickY != null) { + descTableHtml += ''; + } + descTableHtml += ``; + descTableHtml += ''; + } + if (descTickX != null) { + descTableHtml += ''; + if (descLabelY != null) { + descTableHtml += ''; + } + if (descTickY != null) { + descTableHtml += ''; + } + for (let i = 0; i < descNumCols; i++) { + descTableHtml += ``; + } + descTableHtml += ''; + } + descTableHtml += ''; + } + + // body rows + if (descNumRows > 0) { + descTableHtml += ''; + for (let i = 0; i < descNumRows; i++) { + descTableHtml += ''; + if (descLabelY != null && i == 0) { + descTableHtml += ``; + } + if (descTickY != null) { + descTableHtml += ``; + } + for (let j = 0; j < descNumCols; j++) { + descTableHtml += ``; + } + descTableHtml += ''; + } + descTableHtml += ''; + } + + descTableHtml += '
${descLabelX}
${descTickX[i]}
${descLabelY}${descTickY[i]}${descData[i][j]}
'; + } + + // bar: don't need colspan or rowspan stuff, put legendX and Y as headers + + document.getElementById('desc_title').innerHTML = descType + ' description'; + document.getElementById('desc_content').innerHTML = descHtml; + document.getElementById('desc_table').innerHTML = descTableHtml; + } +} + +/** + * Represents a position in 3D space. + * @class + */ +class Position { + constructor(x = 0, y = 0, z = -1) { + this.x = x; + this.y = y; + this.z = z; // rarely used + } +} + +// HELPER FUNCTIONS +/** + * A helper class with static methods. + */ +class Helper { + /** + * Checks if an object is present in an array. + * @param {Object} obj - The object to search for. + * @param {Array} arr - The array to search in. + * @returns {boolean} - True if the object is present in the array, false otherwise. + */ + static containsObject(obj, arr) { + for (let i = 0; i < arr.length; i++) { + if (arr[i] === obj) return true; + } + return false; + } +} + +/** + * A class representing a Tracker. + * @class + */ +class Tracker { + constructor() { + this.DataSetup(); + } + + /** + * Sets up the tracker data by checking if previous data exists and creating new data if it doesn't. + */ + DataSetup() { + let prevData = this.GetTrackerData(); + if (prevData) { + // good to go already, do nothing + } else { + let data = {}; + data.userAgent = Object.assign(navigator.userAgent); + data.vendor = Object.assign(navigator.vendor); + data.language = Object.assign(navigator.language); + data.platform = Object.assign(navigator.platform); + data.events = []; + + this.SaveTrackerData(data); + } + } + + /** + * Downloads the tracker data as a JSON file. + */ + DownloadTrackerData() { + let link = document.createElement('a'); + let data = this.GetTrackerData(); + let fileStr = new Blob([JSON.stringify(data)], { type: 'text/plain' }); + link.href = URL.createObjectURL(fileStr); + link.download = 'tracking.json'; + link.click(); + } + + /** + * Saves the tracker data to local storage. + * @param {Object} data - The data to be saved. + */ + SaveTrackerData(data) { + localStorage.setItem(constants.project_id, JSON.stringify(data)); + } + + /** + * Retrieves tracker data from local storage. + * @returns {Object} The tracker data. + */ + GetTrackerData() { + let data = JSON.parse(localStorage.getItem(constants.project_id)); + return data; + } + + /** + * Removes the project_id from localStorage, clears the tracking data, and sets up new data. + */ + Delete() { + localStorage.removeItem(constants.project_id); + this.data = null; + + if (constants.debugLevel > 0) { + console.log('tracking data cleared'); + } + + this.DataSetup(); + } + + /** + * Logs an event with various properties to the tracker data. + * @param {Event} e - The event to log. + */ + LogEvent(e) { + let eventToLog = {}; + + // computer stuff + eventToLog.timestamp = Object.assign(e.timeStamp); + eventToLog.time = Date().toString(); + eventToLog.key = Object.assign(e.key); + eventToLog.altKey = Object.assign(e.altKey); + eventToLog.ctrlKey = Object.assign(e.ctrlKey); + eventToLog.shiftKey = Object.assign(e.shiftKey); + if (e.path) { + eventToLog.focus = Object.assign(e.path[0].tagName); + } + + // settings etc, which we have to reassign otherwise they'll all be the same val + if (!this.isUndefinedOrNull(constants.position)) { + eventToLog.position = Object.assign(constants.position); + } + if (!this.isUndefinedOrNull(constants.minX)) { + eventToLog.min_x = Object.assign(constants.minX); + } + if (!this.isUndefinedOrNull(constants.maxX)) { + eventToLog.max_x = Object.assign(constants.maxX); + } + if (!this.isUndefinedOrNull(constants.minY)) { + eventToLog.min_y = Object.assign(constants.minY); + } + if (!this.isUndefinedOrNull(constants.MAX_FREQUENCY)) { + eventToLog.max_frequency = Object.assign(constants.MAX_FREQUENCY); + } + if (!this.isUndefinedOrNull(constants.MIN_FREQUENCY)) { + eventToLog.min_frequency = Object.assign(constants.MIN_FREQUENCY); + } + if (!this.isUndefinedOrNull(constants.NULL_FREQUENCY)) { + eventToLog.null_frequency = Object.assign(constants.NULL_FREQUENCY); + } + if (!this.isUndefinedOrNull(constants.MAX_SPEED)) { + eventToLog.max_speed = Object.assign(constants.MAX_SPEED); + } + if (!this.isUndefinedOrNull(constants.MIN_SPEED)) { + eventToLog.min_speed = Object.assign(constants.MIN_SPEED); + } + if (!this.isUndefinedOrNull(constants.INTERVAL)) { + eventToLog.interval = Object.assign(constants.INTERVAL); + } + if (!this.isUndefinedOrNull(constants.vol)) { + eventToLog.volume = Object.assign(constants.vol); + } + if (!this.isUndefinedOrNull(constants.autoPlayRate)) { + eventToLog.autoplay_rate = Object.assign(constants.autoPlayRate); + } + if (!this.isUndefinedOrNull(constants.colorSelected)) { + eventToLog.color = Object.assign(constants.colorSelected); + } + if (!this.isUndefinedOrNull(constants.brailleDisplayLength)) { + eventToLog.braille_display_length = Object.assign( + constants.brailleDisplayLength + ); + } + if (!this.isUndefinedOrNull(constants.duration)) { + eventToLog.tone_duration = Object.assign(constants.duration); + } + if (!this.isUndefinedOrNull(constants.autoPlayOutlierRate)) { + eventToLog.autoplay_outlier_rate = Object.assign( + constants.autoPlayOutlierRate + ); + } + if (!this.isUndefinedOrNull(constants.autoPlayPointsRate)) { + eventToLog.autoplay_points_rate = Object.assign( + constants.autoPlayPointsRate + ); + } + if (!this.isUndefinedOrNull(constants.textMode)) { + eventToLog.text_mode = Object.assign(constants.textMode); + } + if (!this.isUndefinedOrNull(constants.sonifMode)) { + eventToLog.sonification_mode = Object.assign(constants.sonifMode); + } + if (!this.isUndefinedOrNull(constants.brailleMode)) { + eventToLog.braille_mode = Object.assign(constants.brailleMode); + } + if (!this.isUndefinedOrNull(constants.chartType)) { + eventToLog.chart_type = Object.assign(constants.chartType); + } + if (!this.isUndefinedOrNull(constants.infoDiv.innerHTML)) { + let textDisplay = Object.assign(constants.infoDiv.innerHTML); + textDisplay = textDisplay.replaceAll(/<[^>]*>?/gm, ''); + eventToLog.text_display = textDisplay; + } + if (!this.isUndefinedOrNull(location.href)) { + eventToLog.location = Object.assign(location.href); + } + + // chart specific values + let x_tickmark = ''; + let y_tickmark = ''; + let x_label = ''; + let y_label = ''; + let value = ''; + let fill_value = ''; + if (constants.chartType == 'bar') { + if (!this.isUndefinedOrNull(plot.columnLabels[position.x])) { + x_tickmark = plot.columnLabels[position.x]; + } + if (!this.isUndefinedOrNull(plot.plotLegend.x)) { + x_label = plot.plotLegend.x; + } + if (!this.isUndefinedOrNull(plot.plotLegend.y)) { + y_label = plot.plotLegend.y; + } + if (!this.isUndefinedOrNull(plot.plotData[position.x])) { + value = plot.plotData[position.x]; + } + } else if (constants.chartType == 'heat') { + if (!this.isUndefinedOrNull(plot.x_labels[position.x])) { + x_tickmark = plot.x_labels[position.x].trim(); + } + if (!this.isUndefinedOrNull(plot.y_labels[position.y])) { + y_tickmark = plot.y_labels[position.y].trim(); + } + if (!this.isUndefinedOrNull(plot.x_group_label)) { + x_label = plot.x_group_label; + } + if (!this.isUndefinedOrNull(plot.y_group_label)) { + y_label = plot.y_group_label; + } + if (!this.isUndefinedOrNull(plot.values)) { + if (!this.isUndefinedOrNull(plot.values[position.x][position.y])) { + value = plot.values[position.x][position.y]; + } + } + if (!this.isUndefinedOrNull(plot.group_labels[2])) { + fill_value = plot.group_labels[2]; + } + } else if (constants.chartType == 'box') { + let plotPos = + constants.plotOrientation == 'vert' ? position.x : position.y; + let sectionPos = + constants.plotOrientation == 'vert' ? position.y : position.x; + + if (!this.isUndefinedOrNull(plot.x_group_label)) { + x_label = plot.x_group_label; + } + if (!this.isUndefinedOrNull(plot.y_group_label)) { + y_label = plot.y_group_label; + } + if (constants.plotOrientation == 'vert') { + if (plotPos > -1 && sectionPos > -1) { + if ( + !this.isUndefinedOrNull(plot.plotData[plotPos][sectionPos].label) + ) { + y_tickmark = plot.plotData[plotPos][sectionPos].label; + } + if (!this.isUndefinedOrNull(plot.x_labels[position.x])) { + x_tickmark = plot.x_labels[position.x]; + } + if ( + !this.isUndefinedOrNull(plot.plotData[plotPos][sectionPos].values) + ) { + value = plot.plotData[plotPos][sectionPos].values; + } else if ( + !this.isUndefinedOrNull(plot.plotData[plotPos][sectionPos].y) + ) { + value = plot.plotData[plotPos][sectionPos].y; + } + } + } else { + if (plotPos > -1 && sectionPos > -1) { + if ( + !this.isUndefinedOrNull(plot.plotData[plotPos][sectionPos].label) + ) { + x_tickmark = plot.plotData[plotPos][sectionPos].label; + } + if (!this.isUndefinedOrNull(plot.y_labels[position.y])) { + y_tickmark = plot.y_labels[position.y]; + } + if ( + !this.isUndefinedOrNull(plot.plotData[plotPos][sectionPos].values) + ) { + value = plot.plotData[plotPos][sectionPos].values; + } else if ( + !this.isUndefinedOrNull(plot.plotData[plotPos][sectionPos].x) + ) { + value = plot.plotData[plotPos][sectionPos].x; + } + } + } + } else if (constants.chartType == 'point') { + if (!this.isUndefinedOrNull(plot.x_group_label)) { + x_label = plot.x_group_label; + } + if (!this.isUndefinedOrNull(plot.y_group_label)) { + y_label = plot.y_group_label; + } + + if (!this.isUndefinedOrNull(plot.x[position.x])) { + x_tickmark = plot.x[position.x]; + } + if (!this.isUndefinedOrNull(plot.y[position.x])) { + y_tickmark = plot.y[position.x]; + } + + value = [x_tickmark, y_tickmark]; + } + + eventToLog.x_tickmark = Object.assign(x_tickmark); + eventToLog.y_tickmark = Object.assign(y_tickmark); + eventToLog.x_label = Object.assign(x_label); + eventToLog.y_label = Object.assign(y_label); + eventToLog.value = Object.assign(value); + eventToLog.fill_value = Object.assign(fill_value); + + //console.log("x_tickmark: '", x_tickmark, "', y_tickmark: '", y_tickmark, "', x_label: '", x_label, "', y_label: '", y_label, "', value: '", value, "', fill_value: '", fill_value); + + let data = this.GetTrackerData(); + data.events.push(eventToLog); + this.SaveTrackerData(data); + } + + /** + * Checks if the given item is undefined or null. + * @param {*} item - The item to check. + * @returns {boolean} - Returns true if the item is undefined or null, else false. + */ + isUndefinedOrNull(item) { + try { + return item === undefined || item === null; + } catch { + return true; + } + } +} + +/** + * Represents a Review object. + * @class + */ +class Review { + constructor() {} + + /** + * Toggles the review mode on or off. + * @param {boolean} [onoff=true] - Whether to turn review mode on or off. Default is true. + */ + ToggleReviewMode(onoff = true) { + // true means on or show + if (onoff) { + constants.reviewSaveSpot = document.activeElement; + constants.review_container.classList.remove('hidden'); + constants.reviewSaveBrailleMode = constants.brailleMode; + constants.review.focus(); + + display.announceText('Review on'); + } else { + constants.review_container.classList.add('hidden'); + if (constants.reviewSaveBrailleMode == 'on') { + // we have to turn braille mode back on + display.toggleBrailleMode('on'); + } else { + constants.reviewSaveSpot.focus(); + } + display.announceText('Review off'); + } + } +} + +/** + * Represents a class for logging errors. + */ +class LogError { + constructor() {} + + /** + * Logs the absent element and turns off visual highlighting. + * @param {string} a - The absent element to log. + */ + LogAbsentElement(a) { + console.log(a, 'not found. Visual highlighting is turned off.'); + } + + /** + * Logs a critical element and indicates that MAIDR is unable to run. + * @param {string} a - The critical element to log. + */ + LogCriticalElement(a) { + consolelog(a, 'is critical. MAIDR unable to run'); + } + + /** + * Logs a message indicating that two values do not have the same length. + * @param {*} a - The first value to compare. + * @param {*} b - The second value to compare. + */ + LogDifferentLengths(a, b) { + console.log( + a, + 'and', + b, + 'do not have the same length. Visual highlighting is turned off.' + ); + } + + /** + * Logs a message indicating that too many elements were found and only the first n elements will be highlighted. + * @param {string} a - The type of element being highlighted. + * @param {number} b - The maximum number of elements to highlight. + */ + LogTooManyElements(a, b) { + console.log( + 'Too many', + a, + 'elements. Only the first', + b, + 'will be highlighted.' + ); + } + + /** + * Logs a message indicating that the provided parameter is not an array. + * @param {*} a - The parameter that is not an array. + */ + LogNotArray(a) { + console.log(a, 'is not an array. Visual highlighting is turned off.'); + } +} diff --git a/react-maidr/src/js/controls.js b/react-maidr/src/js/controls.js new file mode 100644 index 00000000..573094dc --- /dev/null +++ b/react-maidr/src/js/controls.js @@ -0,0 +1,3217 @@ +/** + * Represents a control object. + * @class + */ +class Control { + /** + * Creates a new instance of the Controls class. + * @constructor + */ + constructor() { + this.SetControls(); + } + + /** + * Sets up event listeners for the global controls and prefix events. + * @function + * @memberof Maidr + * @instance + * @name SetControls + * @returns {void} + */ + SetControls() { + // global controls + + // variable initialization + let controlElements = [ + constants.chart, + constants.brailleInput, + constants.review_container, + ]; + let pressedL = false; + let pressedTimeout = null; + + // main BTS controls + for (let i = 0; i < controlElements.length; i++) { + constants.events.push([ + controlElements[i], + 'keydown', + function (e) { + // init + let lastPlayed = ''; + + // if we're awaiting an L + X prefix, we don't want to do anything else + if (pressedL) { + return; + } + + // B: braille mode + if (e.key == 'b') { + constants.tabMovement = 0; + e.preventDefault(); + display.toggleBrailleMode(); + } + + // T: aria live text output mode + if (e.key == 't') { + display.toggleTextMode(); + } + + // S: sonification mode + if (e.key == 's') { + display.toggleSonificationMode(); + } + + // R: review mode + if (e.key == 'r' && !e.ctrlKey && !e.shiftKey) { + // r, but let Ctrl and Shift R go through cause I use that to refresh + constants.tabMovement = 0; + e.preventDefault(); + if (constants.review_container.classList.contains('hidden')) { + review.ToggleReviewMode(true); + } else { + review.ToggleReviewMode(false); + } + } + + if (e.key == ' ') { + // space 32, replay info but no other changes + + // exception: if we just initilized, position might not be in range + if (position.x < 0) position.x = 0; + if (position.y < 0) position.y = 0; + + if (constants.showDisplay) { + display.displayValues(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + } + + // switch layer controls + if ( + Array.isArray(singleMaidr.type) && + [].concat(singleMaidr.type).includes('point') && + [].concat(singleMaidr.type).includes('smooth') + ) { + // page down /(fn+down arrow): change chart type (layer) + if (e.key == 'PageDown' && constants.brailleMode == 'off') { + display.changeChartLayer('down'); + } + + // page up / (fn+up arrow): change chart type (layer) + if (e.key == 'PageUp' && constants.brailleMode == 'off') { + display.changeChartLayer('up'); + } + } + + // Debugging. + // Because we destroy on blur, it's hard to debug, so here's throwaway code to put a breakpoint on + // todo: on publish, remove this + if (e.key == '-') { + let nothing = null; + } + }, + ]); + } + + // We want to tab or shift tab past the chart, + // but we delay adding this eventlistener for a moment so the chart loads first + for (let i = 0; i < controlElements.length; i++) { + constants.events.push([ + controlElements[i], + 'keydown', + function (e) { + if (e.key == 'Tab') { + // save key to be used on blur event later + if (e.shiftKey) { + constants.tabDirection = -1; + } else { + constants.tabDirection = 1; + } + } + }, + ]); + } + + // prefix events + constants.events.push([ + document, + 'keydown', + function (e) { + // init + let lastPlayed = ''; + + // enable / disable prefix mode + if (e.key == 'l') { + pressedL = true; + if (pressedTimeout != null) { + clearTimeout(pressedTimeout); + pressedTimeout = null; + } + pressedTimeout = setTimeout(function () { + pressedL = false; + }, constants.keypressInterval); + } + + // ctrl/cmd: stop autoplay + if (constants.isMac ? e.metaKey : e.ctrlKey) { + // (ctrl/cmd)+(home/fn+left arrow): first element + if (e.key == 'Home') { + // chart types + if (constants.chartType == 'bar' || constants.chartType == 'hist') { + position.x = 0; + } else if (constants.chartType == 'box') { + position.x = 0; + position.y = plot.sections.length - 1; + } else if (constants.chartType == 'heat') { + position.x = 0; + position.y = 0; + } else if (constants.chartType == 'point') { + position.x = 0; + } else if (constants.chartType == 'smooth') { + positionL1.x = 0; + } + + UpdateAllBraille(); + } + + // (ctrl/cmd)+(end/fn+right arrow): last element + else if (e.key == 'End') { + // chart types + if (constants.chartType == 'bar' || constants.chartType == 'hist') { + position.x = plot.bars.length - 1; + } else if (constants.chartType == 'box') { + position.x = plot.sections.length - 1; + position.y = 0; + } else if (constants.chartType == 'heat') { + position.x = plot.num_cols - 1; + position.y = plot.num_rows - 1; + } else if (constants.chartType == 'point') { + position.x = plot.y.length - 1; + } else if (constants.chartType == 'smooth') { + positionL1.x = plot.curvePoints.length - 1; + } + + UpdateAllBraille(); + } + } + + // Prefix mode stuff: L is enabled, look for these keys + if (pressedL) { + if (e.key == 'x') { + // X: x label + let xlabel = ''; + if (constants.chartType == 'bar') { + xlabel = plot.plotLegend.x; + } else if ( + constants.chartType == 'heat' || + constants.chartType == 'box' || + singleMaidr.type == 'point' || + singleMaidr.type == 'line' || + singleMaidr.type.includes('point') + ) { + xlabel = plot.x_group_label; + } + display.displayInfo('x label', xlabel); + pressedL = false; + } else if (e.key == 'y') { + // Y: y label + let ylabel = ''; + if (constants.chartType == 'bar') { + ylabel = plot.plotLegend.y; + } else if ( + constants.chartType == 'heat' || + constants.chartType == 'box' || + singleMaidr.type == 'point' || + singleMaidr.type == 'line' || + singleMaidr.type.includes('point') + ) { + ylabel = plot.y_group_label; + } + display.displayInfo('y label', ylabel); + pressedL = false; + } else if (e.key == 't') { + // T: title + display.displayInfo('title', plot.title); + pressedL = false; + } else if (e.key == 's') { + // subtitle + display.displayInfo('subtitle', plot.subtitle); + pressedL = false; + } else if (e.key == 'c') { + // caption + display.displayInfo('caption', plot.caption); + pressedL = false; + } else if (e.key != 'l') { + pressedL = false; + } + } + + // // period: speed up + // if (e.key == '.') { + // constants.SpeedUp(); + // display.announceText('Speed up'); + // } + + // // comma: speed down + // if (e.key == ',') { + // constants.SpeedDown(); + // display.announceText('Speed down'); + // } + // // /: reset speed + // if (e.key == '/') { + // constants.SpeedReset(); + // display.announceText('Speed reset'); + // } + }, + ]); + + if ([].concat(singleMaidr.type).includes('bar')) { + window.position = new Position(-1, -1); + window.plot = new BarChart(); + + // global variables + constants.lastx = 0; + let lastPlayed = ''; + + // control eventlisteners + constants.events.push([ + constants.chart, + 'keydown', + function (e) { + let updateInfoThisRound = false; // we only update info and play tones on certain keys + let isAtEnd = false; + + if (e.key == 'ArrowRight') { + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + position.x -= 1; + Autoplay('right', position.x, plot.plotData.length); + } else { + position.x = plot.plotData.length - 1; // go all the way + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if ( + e.altKey && + e.shiftKey && + position.x != plot.bars.length - 1 + ) { + constants.lastx = position.x; + Autoplay('reverse-right', plot.bars.length, position.x); + } else { + position.x += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (e.key == 'ArrowLeft') { + // var prevLink = document.getElementById('prev'); // what is prev in the html? + // if (prevLink) { + // left arrow 37 + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + position.x += 1; + Autoplay('left', position.x, -1); + } else { + position.x = 0; // go all the way + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (e.altKey && e.shiftKey && position.x != 0) { + constants.lastx = position.x; + Autoplay('reverse-left', -1, position.x); + } else { + position.x += -1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + // } + } + + // update display / text / audio + if (updateInfoThisRound && !isAtEnd) { + UpdateAll(); + } + if (isAtEnd) { + audio.playEnd(); + } + }, + ]); + + constants.events.push([ + constants.brailleInput, + 'keydown', + function (e) { + let updateInfoThisRound = false; // we only update info and play tones on certain keys + let isAtEnd = false; + + if (e.key == 'ArrowRight') { + // right arrow + e.preventDefault(); + if (e.target.selectionStart > e.target.value.length - 2) { + } else if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + position.x -= 1; + Autoplay('right', position.x, plot.plotData.length); + } else { + position.x = plot.bars.length - 1; // go all the way + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if ( + e.altKey && + e.shiftKey && + position.x != plot.bars.length - 1 + ) { + constants.lastx = position.x; + Autoplay('reverse-right', plot.bars.length, position.x); + } else { + position.x += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (e.key == 'ArrowLeft') { + // left arrow + e.preventDefault(); + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + position.x += 1; + Autoplay('left', position.x, -1); + } else { + position.x = 0; // go all the way + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (e.altKey && e.shiftKey && position.x != 0) { + constants.lastx = position.x; + Autoplay('reverse-left', -1, position.x); + } else { + position.x += -1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (e.key == 'Tab') { + // do nothing, we handle this in global events + } else { + e.preventDefault(); + } + + // update display / text / audio + if (updateInfoThisRound && !isAtEnd) { + UpdateAllBraille(); + } + if (isAtEnd) { + audio.playEnd(); + } + }, + ]); + + let controlElements = [constants.chart, constants.brailleInput]; + let lastx = 0; + for (let i = 0; i < controlElements.length; i++) { + constants.events.push([ + controlElements[i], + 'keydown', + function (e) { + // period: speed up + if (e.key == '.') { + constants.SpeedUp(); + PlayDuringSpeedChange(); + display.announceText('Speed up'); + } + + // comma: speed down + if (e.key == ',') { + constants.SpeedDown(); + PlayDuringSpeedChange(); + display.announceText('Speed down'); + } + + // /: reset speed + if (e.key == '/') { + constants.SpeedReset(); + PlayDuringSpeedChange(); + display.announceText('Speed reset'); + } + }, + ]); + } + function PlayDuringSpeedChange() { + if (constants.autoplayId != null) { + constants.KillAutoplay(); + if (lastPlayed == 'reverse-left') { + Autoplay('right', position.x, lastx); + } else if (lastPlayed == 'reverse-right') { + Autoplay('left', position.x, lastx); + } else { + Autoplay(lastPlayed, position.x, lastx); + } + } + } + function lockPosition() { + // lock to min / max postions + let didLockHappen = false; + // if (!constants.hasRect) { + // return didLockHappen; + // } + + if (position.x < 0) { + position.x = 0; + didLockHappen = true; + } + if (position.x > plot.plotData.length - 1) { + position.x = plot.plotData.length - 1; + didLockHappen = true; + } + + return didLockHappen; + } + function UpdateAll() { + if (constants.showDisplay) { + display.displayValues(); + } + if (constants.showRect && constants.hasRect) { + plot.Select(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + } + function UpdateAllAutoplay() { + if (constants.showDisplayInAutoplay) { + display.displayValues(); + } + if (constants.showRect && constants.hasRect) { + plot.Select(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + + if (constants.brailleMode != 'off') { + display.UpdateBraillePos(); + } + } + function UpdateAllBraille() { + if (constants.showDisplayInBraille) { + display.displayValues(); + } + if (constants.showRect && constants.hasRect) { + plot.Select(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + display.UpdateBraillePos(); + } + function Autoplay(dir, start, end) { + lastPlayed = dir; + let step = 1; // default right and reverse-left + if (dir == 'left' || dir == 'reverse-right') { + step = -1; + } + + // clear old autoplay if exists + if (constants.autoplayId != null) { + constants.KillAutoplay(); + } + + if (dir == 'reverse-right' || dir == 'reverse-left') { + position.x = start; + } + + constants.autoplayId = setInterval(function () { + position.x += step; + if (position.x < 0 || plot.plotData.length - 1 < position.x) { + constants.KillAutoplay(); + lockPosition(); + } else if (position.x == end) { + constants.KillAutoplay(); + UpdateAllAutoplay(); + } else { + UpdateAllAutoplay(); + } + }, constants.autoPlayRate); + } + } else if ([].concat(singleMaidr.type).includes('box')) { + // variable initialization + constants.plotId = 'geom_boxplot.gTree.78.1'; + window.plot = new BoxPlot(); + if (constants.plotOrientation == 'vert') { + window.position = new Position(0, 6); // always 6 + } else { + window.position = new Position(-1, plot.plotData.length); + } + let rect; + if (constants.hasRect) { + rect = new BoxplotRect(); + } + let lastPlayed = ''; + + // control eventlisteners + constants.events.push([ + constants.chart, + 'keydown', + function (e) { + let updateInfoThisRound = false; // we only update info and play tones on certain keys + let isAtEnd = false; + + // right arrow + if (e.key == 'ArrowRight') { + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + if (constants.plotOrientation == 'vert') { + Autoplay('right', position.x, plot.plotData.length - 1); + } else { + Autoplay('right', position.x, plot.sections.length - 1); + } + } else { + isAtEnd = lockPosition(); + if (constants.plotOrientation == 'vert') { + position.x = plot.plotData.length - 1; + } else { + position.x = plot.sections.length - 1; + } + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (constants.plotOrientation == 'vert') { + if ( + e.altKey && + e.shiftKey && + plot.sections.length - 1 != position.x + ) { + lastY = position.y; + Autoplay('reverse-right', plot.plotData.length - 1, position.x); + } else { + if (position.x == -1 && position.y == plot.sections.length) { + position.y -= 1; + } + position.x += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else { + if ( + e.altKey && + e.shiftKey && + plot.sections.length - 1 != position.x + ) { + constants.lastx = position.x; + Autoplay('reverse-right', plot.sections.length - 1, position.x); + } else { + if (position.x == -1 && position.y == plot.plotData.length) { + position.y -= 1; + } + position.x += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } + constants.navigation = 1; + } + // left arrow + if (e.key == 'ArrowLeft') { + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + Autoplay('left', position.x, -1); + } else { + position.x = 0; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (e.altKey && e.shiftKey && position.x > 0) { + if (constants.plotOrientation == 'vert') { + lastY = position.y; + } else { + constants.lastx = position.x; + } + Autoplay('reverse-left', 0, position.x); + } else { + position.x += -1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + constants.navigation = 1; + } + // up arrow + if (e.key == 'ArrowUp') { + let oldY = position.y; + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + if (constants.plotOrientation == 'vert') { + Autoplay('up', position.y, plot.sections.length); + } else { + Autoplay('up', position.y, plot.plotData.length); + } + } else { + if (constants.plotOrientation == 'vert') { + position.y = plot.sections.length - 1; + } else { + position.y = plot.plotData.length - 1; + } + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (constants.plotOrientation == 'vert') { + if ( + e.altKey && + e.shiftKey && + position.y != plot.sections.length - 1 + ) { + lastY = position.y; + Autoplay('reverse-up', plot.sections.length - 1, position.y); + } else { + position.y += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else { + if ( + e.altKey && + e.shiftKey && + position.y != plot.sections.length - 1 + ) { + constants.lastx = position.x; + Autoplay('reverse-up', plot.plotData.length - 1, position.y); + } else { + position.y += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } + constants.navigation = 0; + } + // down arrow + if (e.key == 'ArrowDown') { + let oldY = position.y; + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + Autoplay('down', position.y, -1); + } else { + position.y = 0; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (e.altKey && e.shiftKey && position.y != 0) { + if (constants.plotOrientation == 'vert') { + lastY = position.y; + } else { + constants.lastx = position.x; + } + Autoplay('reverse-down', 0, position.y); + } else { + if (constants.plotOrientation == 'vert') { + if (position.x == -1 && position.y == plot.sections.length) { + position.x += 1; + } + } else { + if (position.x == -1 && position.y == plot.plotData.length) { + position.x += 1; + } + } + position.y += -1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + //position.x = GetRelativeBoxPosition(oldY, position.y); + constants.navigation = 0; + } + + // update display / text / audio + if (updateInfoThisRound && !isAtEnd) { + UpdateAll(); + } + if (isAtEnd) { + audio.playEnd(); + } + }, + ]); + + constants.events.push([ + constants.brailleInput, + 'keydown', + function (e) { + let updateInfoThisRound = false; // we only update info and play tones on certain keys + let setBrailleThisRound = false; + let isAtEnd = false; + + if (e.key == 'ArrowRight') { + // right arrow + e.preventDefault(); + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + if (constants.plotOrientation == 'vert') { + Autoplay('right', position.x, plot.plotData.length - 1); + } else { + Autoplay('right', position.x, plot.sections.length); + } + } else { + if (constants.plotOrientation == 'vert') { + position.x = plot.plotData.length - 1; + } else { + position.x = plot.sections.length - 1; + } + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (constants.plotOrientation == 'vert') { + if ( + e.altKey && + e.shiftKey && + plot.plotData.length - 1 != position.x + ) { + lastY = position.y; + Autoplay('reverse-right', plot.plotData.length - 1, position.x); + } else { + if ( + position.x == -1 && + position.y == plot.plotData[position.x].length + ) { + position.y -= 1; + } + position.x += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else { + if ( + e.altKey && + e.shiftKey && + plot.sections.length - 1 != position.x + ) { + constants.lastx = position.x; + Autoplay('reverse-right', plot.sections.length - 1, position.x); + } else { + if (position.x == -1 && position.y == plot.plotData.length) { + position.y -= 1; + } + position.x += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } + setBrailleThisRound = true; + constants.navigation = 1; + } else if (e.key == 'ArrowLeft') { + // left arrow + e.preventDefault(); + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + Autoplay('left', position.x, -1); + } else { + position.x = 0; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (e.altKey && e.shiftKey && position.x > 0) { + if (constants.plotOrientation == 'vert') { + lastY = position.y; + } else { + constants.lastx = position.x; + } + Autoplay('reverse-left', 0, position.x); + } else { + position.x += -1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + setBrailleThisRound = true; + constants.navigation = 1; + } else if (e.key == 'ArrowUp') { + // up arrow + let oldY = position.y; + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + if (constants.plotOrientation == 'vert') { + if (position.x < 0) position.x = 0; + Autoplay('up', position.y, plot.sections.length); + } else { + Autoplay('up', position.y, plot.plotData.length); + } + } else if (constants.plotOrientation == 'vert') { + position.y = plot.sections.length - 1; + updateInfoThisRound = true; + } else { + position.y = plot.plotData.length - 1; + updateInfoThisRound = true; + } + } else if (constants.plotOrientation == 'vert') { + if ( + e.altKey && + e.shiftKey && + position.y != plot.sections.length - 1 + ) { + lasY = position.y; + Autoplay('reverse-up', plot.sections.length - 1, position.y); + } else { + position.y += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else { + if ( + e.altKey && + e.shiftKey && + position.y != plot.plotData.length - 1 + ) { + constants.lastx = position.x; + Autoplay('reverse-up', plot.plotData.length - 1, position.y); + } else { + position.y += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } + if (constants.plotOrientation == 'vert') { + } else { + setBrailleThisRound = true; + } + constants.navigation = 0; + } else if (e.key == 'ArrowDown') { + // down arrow + let oldY = position.y; + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + Autoplay('down', position.y, -1); + } else { + position.y = 0; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (e.altKey && e.shiftKey && position.y != 0) { + if (constants.plotOrientation == 'vert') { + lastY = position.y; + } else { + constants.lastx = position.x; + } + Autoplay('reverse-down', 0, position.y); + } else { + if (constants.plotOrientation == 'vert') { + if (position.x == -1 && position.y == plot.sections.length) { + position.x += 1; + } + } else { + if (position.x == -1 && position.y == plot.plotData.length) { + position.x += 1; + } + } + position.y += -1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + constants.navigation = 0; + if (constants.plotOrientation == 'vert') { + } else { + setBrailleThisRound = true; + } + constants.navigation = 0; + } else if (e.key == 'Tab') { + // do nothing, we handle this in global events + } else { + e.preventDefault(); + // todo: allow some controls through like page refresh + } + + // update audio. todo: add a setting for this later + if (updateInfoThisRound && !isAtEnd) { + if (setBrailleThisRound) display.SetBraille(plot); + setTimeout(UpdateAllBraille, 50); // we delay this by just a moment as otherwise the cursor position doesn't get set + } + if (isAtEnd) { + audio.playEnd(); + } + }, + ]); + + let controlElements = [constants.chart, constants.brailleInput]; + let lastx = 0; + for (let i = 0; i < controlElements.length; i++) { + constants.events.push([ + controlElements[i], + 'keydown', + function (e) { + // period: speed up + if (e.key == '.') { + constants.SpeedUp(); + PlayDuringSpeedChange(); + display.announceText('Speed up'); + } + + // comma: speed down + if (e.key == ',') { + constants.SpeedDown(); + PlayDuringSpeedChange(); + display.announceText('Speed down'); + } + + // /: reset speed + if (e.key == '/') { + constants.SpeedReset(); + PlayDuringSpeedChange(); + display.announceText('Speed reset'); + } + }, + ]); + } + function PlayDuringSpeedChange() { + if (constants.autoplayId != null) { + constants.KillAutoplay(); + if (lastPlayed == 'reverse-left') { + if (constants.plotOrientation == 'vert') { + Autoplay('right', position.y, lastY); + } else { + Autoplay('right', position.x, lastx); + } + } else if (lastPlayed == 'reverse-right') { + if (constants.plotOrientation == 'vert') { + Autoplay('left', position.y, lastY); + } else { + Autoplay('left', position.x, lastx); + } + } else if (lastPlayed == 'reverse-up') { + if (constants.plotOrientation == 'vert') { + Autoplay('down', position.y, lastY); + } else { + Autoplay('down', position.x, lastx); + } + } else if (lastPlayed == 'reverse-down') { + if (constants.plotOrientation == 'vert') { + Autoplay('up', position.y, lastY); + } else { + Autoplay('up', position.x, lastx); + } + } else { + if (constants.plotOrientation == 'vert') { + Autoplay(lastPlayed, position.y, lastY); + } else { + Autoplay(lastPlayed, position.x, lastx); + } + } + } + } + + function UpdateAll() { + if (constants.showDisplay) { + display.displayValues(); + } + if (constants.showRect && constants.hasRect) { + rect.UpdateRect(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + } + function UpdateAllAutoplay() { + if (constants.showDisplayInAutoplay) { + display.displayValues(); + } + if (constants.showRect && constants.hasRect) { + rect.UpdateRect(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + if (constants.brailleMode != 'off') { + display.UpdateBraillePos(); + } + } + function UpdateAllBraille() { + if (constants.showDisplayInBraille) { + display.displayValues(); + } + if (constants.showRect && constants.hasRect) { + rect.UpdateRect(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + display.UpdateBraillePos(); + } + function lockPosition() { + // lock to min / max postions + let didLockHappen = false; + if (position.y < 0) { + position.y = 0; + didLockHappen = true; + } + if (position.x < 0) { + position.x = 0; + didLockHappen = true; + } + if (constants.plotOrientation == 'vert') { + if (position.x > plot.plotData.length - 1) { + position.x = plot.plotData.length - 1; + didLockHappen = true; + } + if (position.y > plot.sections.length - 1) { + position.y = plot.sections.length - 1; + didLockHappen = true; + } + } else { + if (position.y > plot.plotData.length - 1) { + position.y = plot.plotData.length - 1; + didLockHappen = true; + } + if (position.x > plot.sections.length - 1) { + position.x = plot.sections.length - 1; + didLockHappen = true; + } + } + + return didLockHappen; + } + + function Autoplay(dir, start, end) { + lastPlayed = dir; + let step = 1; // default right / up / reverse-left / reverse-down + if ( + dir == 'left' || + dir == 'down' || + dir == 'reverse-right' || + dir == 'reverse-up' + ) { + step = -1; + } + + // clear old autoplay if exists + if (constants.autoplayId != null) { + constants.KillAutoplay(); + } + + if (dir == 'reverse-left' || dir == 'reverse-right') { + position.x = start; + } else if (dir == 'reverse-up' || dir == 'reverse-down') { + position.y = start; + } + + if (constants.debugLevel > 0) { + console.log('starting autoplay', dir, start, end); + } + + UpdateAllAutoplay(); // play current tone before we move + constants.autoplayId = setInterval(function () { + let doneNext = false; + if (dir == 'left' || dir == 'right' || dir == 'up' || dir == 'down') { + if ( + (position.x < 1 && dir == 'left') || + (constants.plotOrientation == 'vert' && + dir == 'up' && + position.y > plot.sections.length - 2) || + (constants.plotOrientation == 'horz' && + dir == 'up' && + position.y > plot.plotData.length - 2) || + (constants.plotOrientation == 'horz' && + dir == 'right' && + position.x > plot.sections.length - 2) || + (constants.plotOrientation == 'vert' && + dir == 'right' && + position.x > plot.plotData.length - 2) || + (constants.plotOrientation == 'horz' && + dir == 'down' && + position.y < 1) || + (constants.plotOrientation == 'vert' && + dir == 'down' && + position.y < 1) + ) { + doneNext = true; + } + } else { + if ( + (dir == 'reverse-left' && position.x >= end) || + (dir == 'reverse-right' && position.x <= end) || + (dir == 'reverse-up' && position.y <= end) || + (dir == 'reverse-down' && position.y >= end) + ) { + doneNext = true; + } + } + + if (doneNext) { + constants.KillAutoplay(); + } else { + if ( + dir == 'left' || + dir == 'right' || + dir == 'reverse-left' || + dir == 'reverse-right' + ) { + position.x += step; + } else { + position.y += step; + } + UpdateAllAutoplay(); + } + if (constants.debugLevel > 5) { + console.log('autoplay pos', position); + } + }, constants.autoPlayRate); + } + } else if ([].concat(singleMaidr.type).includes('heat')) { + // variable initialization + constants.plotId = 'geom_rect.rect.2.1'; + window.position = new Position(-1, -1); + window.plot = new HeatMap(); + let rect = new HeatMapRect(); + let lastPlayed = ''; + constants.lastx = 0; + + // control eventlisteners + constants.events.push([ + constants.chart, + 'keydown', + function (e) { + let updateInfoThisRound = false; + let isAtEnd = false; + + // right arrow 39 + if (e.key == 'ArrowRight') { + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + position.x -= 1; + Autoplay('right', position.x, plot.num_cols); + } else { + position.x = plot.num_cols - 1; + updateInfoThisRound = true; + } + } else if ( + e.altKey && + e.shiftKey && + position.x != plot.num_cols - 1 + ) { + constants.lastx = position.x; + Autoplay('reverse-right', plot.num_cols, position.x); + } else { + if (position.x == -1 && position.y == -1) { + position.y += 1; + } + position.x += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + constants.navigation = 1; + } + + // left arrow 37 + if (e.key == 'ArrowLeft') { + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + position.x += 1; + Autoplay('left', position.x, -1); + } else { + position.x = 0; + updateInfoThisRound = true; + } + } else if (e.altKey && e.shiftKey && position.x != 0) { + constants.lastx = position.x; + Autoplay('reverse-left', -1, position.x); + } else { + position.x -= 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + constants.navigation = 1; + } + + // up arrow 38 + if (e.key == 'ArrowUp') { + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + position.y += 1; + Autoplay('up', position.y, -1); + } else { + position.y = 0; + updateInfoThisRound = true; + } + } else if (e.altKey && e.shiftKey && position.y != 0) { + constants.lastx = position.x; + Autoplay('reverse-up', -1, position.y); + } else { + position.y -= 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + constants.navigation = 0; + } + + // down arrow 40 + if (e.key == 'ArrowDown') { + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + position.y -= 1; + Autoplay('down', position.y, plot.num_rows); + } else { + position.y = plot.num_rows - 1; + updateInfoThisRound = true; + } + } else if ( + e.altKey && + e.shiftKey && + position.y != plot.num_rows - 1 + ) { + constants.lastx = position.x; + Autoplay('reverse-down', plot.num_rows, position.y); + } else { + if (position.x == -1 && position.y == -1) { + position.x += 1; + } + position.y += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + constants.navigation = 0; + } + + // update text, display, and audio + if (updateInfoThisRound && !isAtEnd) { + UpdateAll(); + } + if (isAtEnd) { + audio.playEnd(); + } + }, + ]); + + let controlElements = [constants.chart, constants.brailleInput]; + let lastx = 0; + for (let i = 0; i < controlElements.length; i++) { + constants.events.push([ + controlElements[i], + 'keydown', + function (e) { + // period: speed up + if (e.key == '.') { + constants.SpeedUp(); + PlayDuringSpeedChange(); + display.announceText('Speed up'); + } + + // comma: speed down + if (e.key == ',') { + constants.SpeedDown(); + PlayDuringSpeedChange(); + display.announceText('Speed down'); + } + + // /: reset speed + if (e.key == '/') { + constants.SpeedReset(); + PlayDuringSpeedChange(); + display.announceText('Speed reset'); + } + }, + ]); + } + function PlayDuringSpeedChange() { + if (constants.autoplayId != null) { + constants.KillAutoplay(); + if (lastPlayed == 'reverse-left') { + Autoplay('right', position.x, lastx); + } else if (lastPlayed == 'reverse-right') { + Autoplay('left', position.x, lastx); + } else if (lastPlayed == 'reverse-up') { + Autoplay('down', position.x, lastx); + } else if (lastPlayed == 'reverse-down') { + Autoplay('up', position.x, lastx); + } else { + Autoplay(lastPlayed, position.x, lastx); + } + } + } + + constants.events.push([ + constants.brailleInput, + 'keydown', + function (e) { + let updateInfoThisRound = false; + let isAtEnd = false; + + if (e.key == 'ArrowRight') { + // right arrow + if ( + e.target.selectionStart > e.target.value.length - 3 || + e.target.value.substring( + e.target.selectionStart + 1, + e.target.selectionStart + 2 + ) == 'â ³' + ) { + // already at the end, do nothing + e.preventDefault(); + } else { + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (position.x == -1 && position.y == -1) { + position.x += 1; + position.y += 1; + } + if (e.shiftKey) { + position.x -= 1; + Autoplay('right', position.x, plot.num_cols); + } else { + position.x = plot.num_cols - 1; + updateInfoThisRound = true; + } + } else if ( + e.altKey && + e.shiftKey && + position.x != plot.num_cols - 1 + ) { + constants.lastx = position.x; + Autoplay('reverse-right', plot.num_cols, position.x); + } else { + if (position.x == -1 && position.y == -1) { + position.y += 1; + } + position.x += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + + // we need pos to be y*(num_cols+1), (and num_cols+1 because there's a spacer character) + let pos = position.y * (plot.num_cols + 1) + position.x; + e.target.setSelectionRange(pos, pos); + e.preventDefault(); + + constants.navigation = 1; + } + } else if (e.key == 'ArrowLeft') { + // left + if ( + e.target.selectionStart == 0 || + e.target.value.substring( + e.target.selectionStart - 1, + e.target.selectionStart + ) == 'â ³' + ) { + e.preventDefault(); + } else { + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + position.x += 1; + Autoplay('left', position.x, -1); + } else { + position.x = 0; + updateInfoThisRound = true; + } + } else if (e.altKey && e.shiftKey && position.x != 0) { + constants.lastx = position.x; + Autoplay('reverse-left', -1, position.x); + } else { + position.x += -1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + + let pos = position.y * (plot.num_cols + 1) + position.x; + e.target.setSelectionRange(pos, pos); + e.preventDefault(); + + constants.navigation = 1; + } + } else if (e.key == 'ArrowDown') { + // down + if (position.y + 1 == plot.num_rows) { + e.preventDefault(); + } else { + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (position.x == -1 && position.y == -1) { + position.x += 1; + position.y += 1; + } + if (e.shiftKey) { + position.y -= 1; + Autoplay('down', position.y, plot.num_rows); + } else { + position.y = plot.num_rows - 1; + updateInfoThisRound = true; + } + } else if ( + e.altKey && + e.shiftKey && + position.y != plot.num_rows - 1 + ) { + constants.lastx = position.x; + Autoplay('reverse-down', plot.num_rows, position.y); + } else { + if (position.x == -1 && position.y == -1) { + position.x += 1; + } + position.y += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + + let pos = position.y * (plot.num_cols + 1) + position.x; + e.target.setSelectionRange(pos, pos); + e.preventDefault(); + + constants.navigation = 0; + } + } else if (e.key == 'ArrowUp') { + // up + if (e.target.selectionStart - plot.num_cols - 1 < 0) { + e.preventDefault(); + } else { + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + position.y += 1; + Autoplay('up', position.y, -1); + } else { + position.y = 0; + updateInfoThisRound = true; + } + } else if (e.altKey && e.shiftKey && position.y != 0) { + constants.lastx = position.x; + Autoplay('reverse-up', -1, position.y); + } else { + position.y += -1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + + let pos = position.y * (plot.num_cols + 1) + position.x; + e.target.setSelectionRange(pos, pos); + e.preventDefault(); + + constants.navigation = 0; + } + } else if (e.key == 'Tab') { + // do nothing, we handle this in global events + } else { + e.preventDefault(); + } + + if (updateInfoThisRound && !isAtEnd) { + UpdateAllBraille(); + } + if (isAtEnd) { + audio.playEnd(); + } + }, + ]); + + function sleep(time) { + return new Promise((resolve) => setTimeout(resolve, time)); + } + + // heat helper functions + function lockPosition() { + // lock to min / max postions + let didLockHappen = false; + + if (position.x < 0) { + position.x = 0; + didLockHappen = true; + } + if (position.x > plot.num_cols - 1) { + position.x = plot.num_cols - 1; + didLockHappen = true; + } + if (position.y < 0) { + position.y = 0; + didLockHappen = true; + } + if (position.y > plot.num_rows - 1) { + position.y = plot.num_rows - 1; + didLockHappen = true; + } + + return didLockHappen; + } + + function UpdateAll() { + if (constants.showDisplay) { + display.displayValues(); + } + if (constants.showRect && constants.hasRect) { + rect.UpdateRectDisplay(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + } + function UpdateAllAutoplay() { + if (constants.showDisplayInAutoplay) { + display.displayValues(); + } + if (constants.showRect && constants.hasRect) { + rect.UpdateRectDisplay(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + if (constants.brailleMode != 'off') { + display.UpdateBraillePos(); + } + } + function UpdateAllBraille() { + if (constants.showDisplayInBraille) { + display.displayValues(); + } + if (constants.showRect && constants.hasRect) { + rect.UpdateRectDisplay(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + display.UpdateBraillePos(); + } + + function Autoplay(dir, start, end) { + lastPlayed = dir; + let step = 1; // default right, down, reverse-left, and reverse-up + if ( + dir == 'left' || + dir == 'up' || + dir == 'reverse-right' || + dir == 'reverse-down' + ) { + step = -1; + } + + // clear old autoplay if exists + if (constants.autoplayId != null) { + constants.KillAutoplay(); + } + + if (dir == 'reverse-left' || dir == 'reverse-right') { + position.x = start; + } else if (dir == 'reverse-up' || dir == 'reverse-down') { + position.y = start; + } + + constants.autoplayId = setInterval(function () { + if ( + dir == 'left' || + dir == 'right' || + dir == 'reverse-left' || + dir == 'reverse-right' + ) { + position.x += step; + if (position.x < 0 || plot.num_cols - 1 < position.x) { + constants.KillAutoplay(); + lockPosition(); + } else if (position.x == end) { + constants.KillAutoplay(); + UpdateAllAutoplay(); + } else { + UpdateAllAutoplay(); + } + } else { + // up or down + position.y += step; + if (position.y < 0 || plot.num_rows - 1 < position.y) { + constants.KillAutoplay(); + lockPosition(); + } else if (position.y == end) { + constants.KillAutoplay(); + UpdateAllAutoplay(); + } else { + UpdateAllAutoplay(); + } + } + }, constants.autoPlayRate); + } + } else if ( + [].concat(singleMaidr.type).includes('point') || + [].concat(singleMaidr.type).includes('smooth') + ) { + // variable initialization + constants.plotId = 'geom_point.points.12.1'; + window.position = new Position(-1, -1); + window.plot = new ScatterPlot(); + let layer0Point = new Layer0Point(); + let layer1Point = new Layer1Point(); + + let lastPlayed = ''; // for autoplay use + constants.lastx = 0; // for scatter point layer autoplay use + let lastx1 = 0; // for smooth layer autoplay use + + window.positionL1 = new Position(lastx1, lastx1); + + // control eventlisteners + constants.events.push([ + [constants.chart, constants.brailleInput], + 'keydown', + function (e) { + let updateInfoThisRound = false; + let isAtEnd = false; + + // left and right arrows are enabled only at point layer + if (constants.chartType == 'point') { + // right arrow 39 + if (e.key == 'ArrowRight') { + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + position.x -= 1; + Autoplay('right', position.x, plot.x.length); + } else { + position.x = plot.x.length - 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if ( + e.altKey && + e.shiftKey && + position.x != plot.x.length - 1 + ) { + constants.lastx = position.x; + Autoplay('reverse-right', plot.x.length, position.x); + } else { + position.x += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } + + // left arrow 37 + if (e.key == 'ArrowLeft') { + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + position.x += 1; + Autoplay('left', position.x, -1); + } else { + position.x = 0; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (e.altKey && e.shiftKey && position.x != 0) { + constants.lastx = position.x; + Autoplay('reverse-left', -1, position.x); + } else { + position.x -= 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } + } else if (constants.chartType == 'smooth') { + if (!positionL1.x) { + positionL1.x = lastx1; + } + + if (e.key == 'ArrowRight' && e.shiftKey) { + if ( + (constants.isMac ? e.metaKey : e.ctrlKey) && + constants.sonifMode != 'off' + ) { + PlayLine('right'); + } else if (e.altKey && constants.sonifMode != 'off') { + PlayLine('reverse-right'); + } + } + + if (e.key == 'ArrowLeft' && e.shiftKey) { + if ( + (constants.isMac ? e.metaKey : e.ctrlKey) && + constants.sonifMode != 'off' + ) { + PlayLine('left'); + } else if (e.altKey && constants.sonifMode != 'off') { + PlayLine('reverse-left'); + } + } + } + + // update text, display, and audio + if ( + updateInfoThisRound && + constants.chartType == 'point' && + !isAtEnd + ) { + UpdateAll(); + } + if (isAtEnd) { + audio.playEnd(); + } + }, + ]); + + let controlElements = [constants.chart, constants.brailleInput]; + let lastx = 0; + for (let i = 0; i < controlElements.length; i++) { + constants.events.push([ + controlElements[i], + 'keydown', + function (e) { + // period: speed up + if (e.key == '.') { + constants.SpeedUp(); + PlayDuringSpeedChange(); + display.announceText('Speed up'); + } + + // comma: speed down + if (e.key == ',') { + constants.SpeedDown(); + PlayDuringSpeedChange(); + display.announceText('Speed down'); + } + + // /: reset speed + if (e.key == '/') { + constants.SpeedReset(); + PlayDuringSpeedChange(); + display.announceText('Speed reset'); + } + }, + ]); + } + function PlayDuringSpeedChange() { + if (constants.autoplayId != null) { + constants.KillAutoplay(); + audio.KillSmooth(); + if (lastPlayed == 'reverse-left') { + if (constants.chartType == 'point') { + Autoplay('right', position.x, lastx); + } else if (constants.chartType == 'smooth') { + Autoplay('right', positionL1.x, lastx1); + } + } else if (lastPlayed == 'reverse-right') { + if (constants.chartType == 'point') { + Autoplay('left', position.x, lastx); + } else if (constants.chartType == 'smooth') { + Autoplay('left', positionL1.x, lastx1); + } + } else { + if (constants.chartType == 'point') { + Autoplay(lastPlayed, position.x, lastx); + } else if (constants.chartType == 'smooth') { + Autoplay(lastPlayed, positionL1.x, lastx1); + } + } + } + } + + constants.events.push([ + constants.brailleInput, + 'keydown', + function (e) { + let updateInfoThisRound = false; + let isAtEnd = false; + + // @TODO + // only smooth layer can access to braille display + if (constants.chartType == 'smooth') { + lockPosition(); + if (e.key == 'ArrowRight') { + // right arrow + e.preventDefault(); + constants.brailleInput.setSelectionRange( + positionL1.x, + positionL1.x + ); + if (e.target.selectionStart > e.target.value.length - 2) { + e.preventDefault(); + } else if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + positionL1.x -= 1; + Autoplay('right', positionL1.x, plot.curvePoints.length); + } else { + positionL1.x = plot.curvePoints.length - 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if ( + e.altKey && + e.shiftKey && + positionL1.x != plot.curvePoints.length - 1 + ) { + lastx1 = positionL1.x; + Autoplay( + 'reverse-right', + plot.curvePoints.length, + positionL1.x + ); + } else { + positionL1.x += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (e.key == 'ArrowLeft') { + // left + e.preventDefault(); + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + positionL1.x += 1; + Autoplay('left', positionL1.x, -1); + } else { + positionL1.x = 0; // go all the way + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (e.altKey && e.shiftKey && positionL1.x != 0) { + Autoplay('reverse-left', -1, positionL1.x); + } else { + positionL1.x -= 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else { + e.preventDefault(); + } + } else if (e.key == 'Tab') { + // do nothing, we handle this in global events + } else { + e.preventDefault(); + } + + lastx1 = positionL1.x; + + if (updateInfoThisRound && !isAtEnd) { + UpdateAllBraille(); + } + if (isAtEnd) { + audio.playEnd(); + } + }, + ]); + + // helper functions + function lockPosition() { + // lock to min / max positions + let didLockHappen = false; + if (constants.chartType == 'point') { + if (position.x < 0) { + position.x = 0; + didLockHappen = true; + } + if (position.x > plot.x.length - 1) { + position.x = plot.x.length - 1; + didLockHappen = true; + } + } else if (constants.chartType == 'smooth') { + if (positionL1.x < 0) { + positionL1.x = 0; + didLockHappen = true; + } + if (positionL1.x > plot.curvePoints.length - 1) { + positionL1.x = plot.curvePoints.length - 1; + didLockHappen = true; + } + } + + return didLockHappen; + } + + function UpdateAll() { + if (constants.showDisplay) { + display.displayValues(); + } + if (layer0Point.hasRect) { + layer0Point.UpdatePointDisplay(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + } + + function UpdateAllAutoplay() { + if (constants.showDisplayInAutoplay) { + display.displayValues(); + } + if (constants.showRect) { + if (constants.chartType == 'point' && layer0Point.hasRect) { + layer0Point.UpdatePointDisplay(); + } else if (constants.chartType == 'smooth' && layer1Point.hasRect) { + layer1Point.UpdatePointDisplay(); + } + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + if (constants.brailleMode != 'off') { + display.UpdateBraillePos(); + } + } + function UpdateAllBraille() { + if (constants.showDisplayInBraille) { + display.displayValues(); + } + if (layer1Point.hasRect) { + layer1Point.UpdatePointDisplay(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + display.UpdateBraillePos(); + } + + function Autoplay(dir, start, end) { + lastPlayed = dir; + let step = 1; // default right and reverse left + if (dir == 'left' || dir == 'reverse-right') { + step = -1; + } + + // clear old autoplay if exists + if (constants.autoplayId) { + constants.KillAutoplay(); + } + if (constants.isSmoothAutoplay) { + audio.KillSmooth(); + } + + if (dir == 'reverse-left' || dir == 'reverse-right') { + position.x = start; + position.L1x = start; + } + + if (constants.chartType == 'point') { + constants.autoplayId = setInterval(function () { + position.x += step; + // autoplay for two layers: point layer & smooth layer in braille + // plot.numPoints is not available anymore + if (position.x < 0 || position.x > plot.y.length - 1) { + constants.KillAutoplay(); + lockPosition(); + } else if (position.x == end) { + constants.KillAutoplay(); + UpdateAllAutoplay(); + } else { + UpdateAllAutoplay(); + } + }, constants.autoPlayRate); + } else if (constants.chartType == 'smooth') { + constants.autoplayId = setInterval(function () { + positionL1.x += step; + // autoplay for two layers: point layer & smooth layer in braille + // plot.numPoints is not available anymore + if ( + positionL1.x < 0 || + positionL1.x > plot.curvePoints.length - 1 + ) { + constants.KillAutoplay(); + lockPosition(); + } else if (positionL1.x == end) { + constants.KillAutoplay(); + UpdateAllAutoplay(); + } else { + UpdateAllAutoplay(); + } + }, constants.autoPlayRate); + } + } + + function PlayLine(dir) { + lastPlayed = dir; + + let freqArr = []; + let panningArr = []; + let panPoint = audio.SlideBetween( + positionL1.x, + 0, + plot.curvePoints.length - 1, + -1, + 1 + ); + let x = positionL1.x < 0 ? 0 : positionL1.x; + let duration = 0; + if (dir == 'right') { + for (let i = x; i < plot.curvePoints.length; i++) { + freqArr.push( + audio.SlideBetween( + plot.curvePoints[i], + plot.curveMinY, + plot.curveMaxY, + constants.MIN_FREQUENCY, + constants.MAX_FREQUENCY + ) + ); + } + panningArr = [panPoint, 1]; + duration = + (Math.abs(plot.curvePoints.length - x) / plot.curvePoints.length) * + 3; + } else if (dir == 'left') { + for (let i = x; i >= 0; i--) { + freqArr.push( + audio.SlideBetween( + plot.curvePoints[i], + plot.curveMinY, + plot.curveMaxY, + constants.MIN_FREQUENCY, + constants.MAX_FREQUENCY + ) + ); + } + panningArr = [panPoint, -1]; + duration = (Math.abs(x) / plot.curvePoints.length) * 3; + } else if (dir == 'reverse-right') { + for (let i = plot.curvePoints.length - 1; i >= x; i--) { + freqArr.push( + audio.SlideBetween( + plot.curvePoints[i], + plot.curveMinY, + plot.curveMaxY, + constants.MIN_FREQUENCY, + constants.MAX_FREQUENCY + ) + ); + } + panningArr = [1, panPoint]; + duration = + (Math.abs(plot.curvePoints.length - x) / plot.curvePoints.length) * + 3; + } else if (dir == 'reverse-left') { + for (let i = 0; i <= x; i++) { + freqArr.push( + audio.SlideBetween( + plot.curvePoints[i], + plot.curveMinY, + plot.curveMaxY, + constants.MIN_FREQUENCY, + constants.MAX_FREQUENCY + ) + ); + } + panningArr = [-1, panPoint]; + duration = (Math.abs(x) / plot.curvePoints.length) * 3; + } + + if (constants.isSmoothAutoplay) { + audio.KillSmooth(); + } + + // audio.playSmooth(freqArr, 2, panningArr, constants.vol, 'sine'); + audio.playSmooth(freqArr, duration, panningArr, constants.vol, 'sine'); + } + } else if ([].concat(singleMaidr.type).includes('hist')) { + window.position = new Position(-1, -1); + window.plot = new Histogram(); + + // global variables + let lastPlayed = ''; + constants.lastx = 0; + + // control eventlisteners + constants.events.push([ + [constants.chart, constants.brailleInput], + 'keydown', + function (e) { + let updateInfoThisRound = false; // we only update info and play tones on certain keys + let isAtEnd = false; + + // Right + if ( + e.key == 'ArrowRight' && + !(constants.isMac ? e.metaKey : e.ctrlKey) && + !e.shiftKey + ) { + // just right arrow, move right + e.preventDefault(); + position.x += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } else if ( + e.key == 'ArrowRight' && + (constants.isMac ? e.metaKey : e.ctrlKey) && + e.shiftKey + ) { + // ctrl shift right arrow, autoplay right + e.preventDefault(); + position.x -= 1; + Autoplay('right', position.x, plot.plotData.length); + } else if ( + e.key == 'ArrowRight' && + !(constants.isMac ? e.metaKey : e.ctrlKey) && + e.altKey && + e.shiftKey + ) { + // alt shift right, autoplay from right + e.preventDefault(); + constants.lastx = position.x; + Autoplay('reverse-right', plot.bars.length, position.x); + } else if ( + e.key == 'ArrowRight' && + (constants.isMac ? e.metaKey : e.ctrlKey) && + !e.shiftKey + ) { + // ctrl right arrow, go to end + e.preventDefault(); + position.x = plot.plotData.length - 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + + // Left + if ( + e.key == 'ArrowLeft' && + !(constants.isMac ? e.metaKey : e.ctrlKey) && + !e.shiftKey + ) { + // just left arrow, move left + e.preventDefault(); + position.x += -1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } else if ( + e.key == 'ArrowLeft' && + (constants.isMac ? e.metaKey : e.ctrlKey) && + e.shiftKey + ) { + // ctrl shift left arrow, autoplay left + e.preventDefault(); + position.x += 1; + Autoplay('left', position.x, -1); + } else if ( + e.key == 'ArrowLeft' && + !(constants.isMac ? e.metaKey : e.ctrlKey) && + e.altKey && + e.shiftKey + ) { + // alt shift left, autoplay from left + e.preventDefault(); + constants.lastx = position.x; + Autoplay('reverse-left', -1, position.x); + } else if ( + e.key == 'ArrowLeft' && + (constants.isMac ? e.metaKey : e.ctrlKey) && + !e.shiftKey + ) { + // ctrl left arrow, go to beginning + e.preventDefault(); + position.x = 0; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + + // update display / text / audio + if (updateInfoThisRound && !isAtEnd) { + if (constants.brailleMode == 'off') { + UpdateAll(); + } else { + UpdateAllBraille(); + } + } + if (isAtEnd) { + audio.playEnd(); + } + }, + ]); + + let controlElements = [constants.chart, constants.brailleInput]; + let lastx = 0; + for (let i = 0; i < controlElements.length; i++) { + constants.events.push([ + controlElements[i], + 'keydown', + function (e) { + // period: speed up + if (e.key == '.') { + e.preventDefault(); + constants.SpeedUp(); + PlayDuringSpeedChange(); + display.announceText('Speed up'); + } + + // comma: speed down + if (e.key == ',') { + e.preventDefault(); + constants.SpeedDown(); + PlayDuringSpeedChange(); + display.announceText('Speed down'); + } + + // /: reset speed + if (e.key == '/') { + e.preventDefault(); + constants.SpeedReset(); + PlayDuringSpeedChange(); + display.announceText('Speed reset'); + } + }, + ]); + } + function PlayDuringSpeedChange() { + if (constants.autoplayId != null) { + constants.KillAutoplay(); + if (lastPlayed == 'reverse-left') { + Autoplay('right', position.x, lastx); + } else if (lastPlayed == 'reverse-right') { + Autoplay('left', position.x, lastx); + } else { + Autoplay(lastPlayed, position.x, lastx); + } + } + } + + // lock to min / max postions + function lockPosition() { + let didLockHappen = false; + + if (position.x < 0) { + position.x = 0; + didLockHappen = true; + } + if (position.x > plot.plotData.length - 1) { + position.x = plot.plotData.length - 1; + didLockHappen = true; + } + + return didLockHappen; + } + function UpdateAll() { + if (constants.showDisplay) { + display.displayValues(); + } + if (constants.showRect && constants.hasRect) { + plot.Select(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + } + function UpdateAllAutoplay() { + if (constants.showDisplayInAutoplay) { + display.displayValues(); + } + if (constants.showRect && constants.hasRect) { + plot.Select(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + + if (constants.brailleMode != 'off') { + display.UpdateBraillePos(); + } + } + function UpdateAllBraille() { + if (constants.showDisplayInBraille) { + display.displayValues(); + } + if (constants.showRect && constants.hasRect) { + plot.Select(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + display.UpdateBraillePos(); + } + function Autoplay(dir, start, end) { + lastPlayed = dir; + let step = 1; // default right and reverse-left + if (dir == 'left' || dir == 'reverse-right') { + step = -1; + } + + // clear old autoplay if exists + if (constants.autoplayId != null) { + constants.KillAutoplay(); + } + + if (dir == 'reverse-right' || dir == 'reverse-left') { + position.x = start; + } + + constants.autoplayId = setInterval(function () { + position.x += step; + if (position.x < 0 || plot.plotData.length - 1 < position.x) { + constants.KillAutoplay(); + lockPosition(); + } else if (position.x == end) { + constants.KillAutoplay(); + UpdateAllAutoplay(); + } else { + UpdateAllAutoplay(); + } + }, constants.autoPlayRate); + } + } else if ( + [].concat(singleMaidr.type).includes('stacked_bar') || + [].concat(singleMaidr.type).includes('stacked_normalized_bar') || + [].concat(singleMaidr.type).includes('dodged_bar') + ) { + window.position = new Position(-1, -1); + window.plot = new Segmented(); + + // global variables + let lastPlayed = ''; + constants.lastx = 0; + + // control eventlisteners + constants.events.push([ + [constants.chart, constants.brailleInput], + 'keydown', + function (e) { + let updateInfoThisRound = false; // we only update info and play tones on certain keys + let isAtEnd = false; + constants.navigation = 0; // 0 for up/down, 1 for left/right + + if (constants.brailleMode == 'on') { + if (e.key == 'Tab') { + // allow + } else { + e.preventDefault(); + } + } + + // Right + if ( + e.key == 'ArrowRight' && + !(constants.isMac ? e.metaKey : e.ctrlKey) && + !e.shiftKey + ) { + // just right arrow, move right + position.x += 1; + updateInfoThisRound = true; + constants.navigation = 1; + isAtEnd = lockPosition(); + } else if ( + e.key == 'ArrowRight' && + (constants.isMac ? e.metaKey : e.ctrlKey) && + e.shiftKey + ) { + // ctrl shift right arrow, autoplay right + position.x -= 1; + Autoplay('right', position.x, plot.plotData.length); + } else if ( + e.key == 'ArrowRight' && + !(constants.isMac ? e.metaKey : e.ctrlKey) && + e.altKey && + e.shiftKey + ) { + // alt shift right, autoplay from right + constants.lastx = position.x; + Autoplay('reverse-right', plot.plotData.length, position.x); + } else if ( + e.key == 'ArrowRight' && + (constants.isMac ? e.metaKey : e.ctrlKey) && + !e.shiftKey + ) { + // ctrl right arrow, go to end + position.x = plot.plotData.length - 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + + // Left + if ( + e.key == 'ArrowLeft' && + !(constants.isMac ? e.metaKey : e.ctrlKey) && + !e.shiftKey + ) { + // just left arrow, move left + position.x += -1; + updateInfoThisRound = true; + constants.navigation = 1; + isAtEnd = lockPosition(); + } else if ( + e.key == 'ArrowLeft' && + (constants.isMac ? e.metaKey : e.ctrlKey) && + e.shiftKey + ) { + // ctrl shift left arrow, autoplay left + position.x += 1; + Autoplay('left', position.x, -1); + } else if ( + e.key == 'ArrowLeft' && + !(constants.isMac ? e.metaKey : e.ctrlKey) && + e.altKey && + e.shiftKey + ) { + // alt shift left, autoplay from left + constants.lastx = position.x; + Autoplay('reverse-left', -1, position.x); + } else if ( + e.key == 'ArrowLeft' && + (constants.isMac ? e.metaKey : e.ctrlKey) && + !e.shiftKey + ) { + // ctrl left arrow, go to beginning + position.x = 0; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + + // Up + if ( + e.key == 'ArrowUp' && + !(constants.isMac ? e.metaKey : e.ctrlKey) && + !e.shiftKey + ) { + // just up arrow, move up + position.y += 1; + updateInfoThisRound = true; + constants.navigation = 0; + isAtEnd = lockPosition(); + } else if ( + e.key == 'ArrowUp' && + (constants.isMac ? e.metaKey : e.ctrlKey) && + e.shiftKey + ) { + // ctrl shift up arrow, autoplay up + Autoplay('up', position.y, plot.plotData[0].length); + } else if ( + e.key == 'ArrowUp' && + !(constants.isMac ? e.metaKey : e.ctrlKey) && + e.altKey && + e.shiftKey + ) { + // alt shift up, autoplay from up + constants.lastx = position.x; + Autoplay('reverse-up', -1, plot.plotData[0].length); + } else if ( + e.key == 'ArrowUp' && + (constants.isMac ? e.metaKey : e.ctrlKey) && + !e.shiftKey + ) { + // ctrl up arrow, go to top + position.y = plot.plotData[0].length - 1; + updateInfoThisRound = true; + } + + // Down + if ( + e.key == 'ArrowDown' && + !(constants.isMac ? e.metaKey : e.ctrlKey) && + !e.shiftKey + ) { + // just down arrow, move down + position.y += -1; + updateInfoThisRound = true; + constants.navigation = 0; + isAtEnd = lockPosition(); + } else if ( + e.key == 'ArrowDown' && + (constants.isMac ? e.metaKey : e.ctrlKey) && + e.shiftKey + ) { + // ctrl shift down arrow, autoplay down + Autoplay('down', position.y, -1); + } else if ( + e.key == 'ArrowDown' && + !(constants.isMac ? e.metaKey : e.ctrlKey) && + e.altKey && + e.shiftKey + ) { + // alt shift down, autoplay from down + constants.lastx = position.x; + Autoplay('reverse-down', -1, position.y); + } else if ( + e.key == 'ArrowDown' && + (constants.isMac ? e.metaKey : e.ctrlKey) && + !e.shiftKey + ) { + // ctrl down arrow, go to bottom + position.y = 0; + updateInfoThisRound = true; + } + + // update display / text / audio + if (updateInfoThisRound && !isAtEnd) { + if (constants.brailleMode == 'off') { + UpdateAll(); + } else { + UpdateAllBraille(); + } + } + if (isAtEnd) { + audio.playEnd(); + } + }, + ]); + + let controlElements = [constants.chart, constants.brailleInput]; + let lastx = 0; + for (let i = 0; i < controlElements.length; i++) { + constants.events.push([ + controlElements[i], + 'keydown', + function (e) { + // period: speed up + if (e.key == '.') { + constants.SpeedUp(); + PlayDuringSpeedChange(); + display.announceText('Speed up'); + } + + // comma: speed down + if (e.key == ',') { + constants.SpeedDown(); + PlayDuringSpeedChange(); + display.announceText('Speed down'); + } + + // /: reset speed + if (e.key == '/') { + constants.SpeedReset(); + PlayDuringSpeedChange(); + display.announceText('Speed reset'); + } + }, + ]); + } + function PlayDuringSpeedChange() { + if (constants.autoplayId != null) { + constants.KillAutoplay(); + if (lastPlayed == 'reverse-left') { + Autoplay('right', position.x, lastx); + } else if (lastPlayed == 'reverse-right') { + Autoplay('left', position.x, lastx); + } else if (lastPlayed == 'reverse-up') { + Autoplay('down', position.x, lastx); + } else if (lastPlayed == 'reverse-down') { + Autoplay('up', position.x, lastx); + } else { + Autoplay(lastPlayed, position.x, lastx); + } + } + } + + // lock to min / max postions + function lockPosition() { + let didLockHappen = false; + + if (position.x < 0) { + position.x = 0; + didLockHappen = true; + } + if (position.x > plot.level.length - 1) { + position.x = plot.plotData.length - 1; + didLockHappen = true; + } + if (position.y < 0) { + position.y = 0; + didLockHappen = true; + } + if (position.y > plot.fill.length - 1) { + position.y = plot.fill.length - 1; + didLockHappen = true; + } + + return didLockHappen; + } + function UpdateAll() { + if (constants.showDisplay) { + display.displayValues(); + } + if (constants.showRect && constants.hasRect) { + plot.Select(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + } + function UpdateAllAutoplay() { + if (constants.showDisplayInAutoplay) { + display.displayValues(); + } + if (constants.showRect && constants.hasRect) { + plot.Select(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + + if (constants.brailleMode != 'off') { + display.UpdateBraillePos(); + } + } + function UpdateAllBraille() { + if (constants.showDisplayInBraille) { + display.SetBraille(); + display.displayValues(); + } + if (constants.showRect && constants.hasRect) { + plot.Select(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + + display.UpdateBraillePos(); + } + function Autoplay(dir, start, end) { + lastPlayed = dir; + let step = 1; // default right, up, reverse-left, and reverse-down + if ( + dir == 'left' || + dir == 'down' || + dir == 'reverse-right' || + dir == 'reverse-up' + ) { + step = -1; + } + + // clear old autoplay if exists + if (constants.autoplayId != null) { + constants.KillAutoplay(); + } + + if (dir == 'reverse-left' || dir == 'reverse-right') { + position.x = start; + } else if (dir == 'reverse-up' || dir == 'reverse-down') { + position.y = start; + } + + constants.autoplayId = setInterval(function () { + if ( + dir == 'left' || + dir == 'right' || + dir == 'reverse-left' || + dir == 'reverse-right' + ) { + position.x += step; + if (position.x < 0 || plot.plotData.length - 1 < position.x) { + constants.KillAutoplay(); + lockPosition(); + } else if (position.x == end) { + constants.KillAutoplay(); + UpdateAllAutoplay(); + } else { + UpdateAllAutoplay(); + } + } else { + // up or down + position.y += step; + if (position.y < 0 || plot.plotData[0].length - 1 < position.y) { + constants.KillAutoplay(); + lockPosition(); + } else if (position.y == end) { + constants.KillAutoplay(); + UpdateAllAutoplay(); + } else { + UpdateAllAutoplay(); + } + } + }, constants.autoPlayRate); + } + } else if (singleMaidr.type == 'line') { + window.position = new Position(-1, -1); + window.plot = new LinePlot(); + let point = new Point(); + + // global variables + let lastPlayed = ''; + constants.lastx = 0; + + // control eventlisteners + constants.events.push([ + constants.chart, + 'keydown', + function (e) { + let updateInfoThisRound = false; // we only update info and play tones on certain keys + let isAtEnd = false; + + if (e.key == 'ArrowRight') { + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + position.x -= 1; + Autoplay('right', position.x, plot.pointValuesY.length); + } else { + position.x = plot.pointValuesY.length - 1; // go all the way + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if ( + e.altKey && + e.shiftKey && + position.x != plot.pointValuesY.length - 1 + ) { + constants.lastx = position.x; + Autoplay('reverse-right', plot.pointValuesY.length, position.x); + } else { + position.x += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (e.key == 'ArrowLeft') { + // var prevLink = document.getElementById('prev'); // what is prev in the html? + // if (prevLink) { + // left arrow 37 + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + position.x += 1; + Autoplay('left', position.x, -1); + } else { + position.x = 0; // go all the way + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (e.altKey && e.shiftKey && position.x != 0) { + constants.lastx = position.x; + Autoplay('reverse-left', -1, position.x); + } else { + position.x += -1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + // } + } + + // update display / text / audio + if (updateInfoThisRound && !isAtEnd) { + UpdateAll(); + } + if (isAtEnd) { + audio.playEnd(); + } + }, + ]); + + constants.events.push([ + constants.brailleInput, + 'keydown', + function (e) { + let updateInfoThisRound = false; // we only update info and play tones on certain keys + let isAtEnd = false; + + if (e.key == 'ArrowRight') { + // right arrow + e.preventDefault(); + if (e.target.selectionStart > e.target.value.length - 2) { + } else if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + position.x -= 1; + Autoplay('right', position.x, plot.pointValuesY.length); + } else { + position.x = plot.pointValuesY.length - 1; // go all the way + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if ( + e.altKey && + e.shiftKey && + position.x != plot.pointValues.length - 1 + ) { + constants.lastx = position.x; + Autoplay('reverse-right', plot.pointValuesY.length, position.x); + } else { + position.x += 1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (e.key == 'ArrowLeft') { + // left arrow + e.preventDefault(); + if (constants.isMac ? e.metaKey : e.ctrlKey) { + if (e.shiftKey) { + position.x += 1; + Autoplay('left', position.x, -1); + } else { + position.x = 0; // go all the way + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (e.altKey && e.shiftKey && position.x != 0) { + constants.lastx = position.x; + Autoplay('reverse-left', -1, position.x); + } else { + position.x += -1; + updateInfoThisRound = true; + isAtEnd = lockPosition(); + } + } else if (e.key == 'Tab') { + // do nothing, we handle this in global events + } else { + e.preventDefault(); + } + + // update display / text / audio + if (updateInfoThisRound && !isAtEnd) { + UpdateAllBraille(); + } + if (isAtEnd) { + audio.playEnd(); + } + }, + ]); + + // control eventlisteners + // constants.events.push([ + // [constants.chart, constants.brailleInput], + // 'keydown', + // function (e) { + // let updateInfoThisRound = false; // we only update info and play tones on certain keys + // let isAtEnd = false; + + // if (e.key == 'ArrowRight') { + // // right arrow + // e.preventDefault(); + // if (e.target.selectionStart > e.target.value.length - 2) { + // } else if (constants.isMac ? e.metaKey : e.ctrlKey) { + // if (e.shiftKey) { + // position.x -= 1; + // Autoplay('right', position.x, plot.pointValuesY.length); + // } else { + // position.x = plot.pointValuesY.length - 1; // go all the way + // updateInfoThisRound = true; + // isAtEnd = lockPosition(); + // } + // } else if ( + // e.altKey && + // e.shiftKey && + // position.x != plot.pointValuesY.length - 1 + // ) { + // constants.lastx = position.x; + // Autoplay('reverse-right', plot.pointValuesY.length, position.x); + // } else { + // position.x += 1; + // updateInfoThisRound = true; + // isAtEnd = lockPosition(); + // } + // } else if (e.key == 'ArrowLeft') { + // // left arrow + // e.preventDefault(); + // if (constants.isMac ? e.metaKey : e.ctrlKey) { + // if (e.shiftKey) { + // position.x += 1; + // Autoplay('left', position.x, -1); + // } else { + // position.x = 0; // go all the way + // updateInfoThisRound = true; + // isAtEnd = lockPosition(); + // } + // } else if (e.altKey && e.shiftKey && position.x != 0) { + // constants.lastx = position.x; + // Autoplay('reverse-left', -1, position.x); + // } else { + // position.x += -1; + // updateInfoThisRound = true; + // isAtEnd = lockPosition(); + // } + // } else if (e.key == 'Tab') { + // // do nothing, we handle this in global events + // } else { + // e.preventDefault(); + // // Right + // if ( + // e.key == 'ArrowRight' && + // !(constants.isMac ? e.metaKey : e.ctrlKey) && + // !e.shiftKey + // ) { + // // just right arrow, move right + // position.x += 1; + // updateInfoThisRound = true; + // isAtEnd = lockPosition(); + // } else if ( + // e.key == 'ArrowRight' && + // (constants.isMac ? e.metaKey : e.ctrlKey) && + // e.shiftKey + // ) { + // // ctrl shift right arrow, autoplay right + // position.x += -1; + // Autoplay('outward_right', position.x, plot.pointValuesY.length); + // } else if ( + // e.key == 'ArrowRight' && + // !(constants.isMac ? e.metaKey : e.ctrlKey) && + // e.altKey && + // e.shiftKey && + // position.x != plot.pointValuesY.length - 1 + // ) { + // // alt shift right, autoplay from right + // constants.lastx = position.x; + // Autoplay('inward_right', plot.pointValues.length, position.x); + // } else if ( + // e.key == 'ArrowRight' && + // (constants.isMac ? e.metaKey : e.ctrlKey) && + // !e.shiftKey + // ) { + // // ctrl right arrow, go to end + // position.x = plot.pointValuesY.length - 1; // go all the way + // updateInfoThisRound = true; + // isAtEnd = lockPosition(); + // } + + // // Left + // if ( + // e.key == 'ArrowLeft' && + // !(constants.isMac ? e.metaKey : e.ctrlKey) && + // !e.shiftKey + // ) { + // // just left arrow, move left + // position.x += -1; + // updateInfoThisRound = true; + // isAtEnd = lockPosition(); + // } else if ( + // e.key == 'ArrowLeft' && + // (constants.isMac ? e.metaKey : e.ctrlKey) && + // e.shiftKey + // ) { + // // ctrl shift left arrow, autoplay left + // position.x += 1; + // Autoplay('outward_left', position.x, -1); + // } else if ( + // e.key == 'ArrowLeft' && + // !(constants.isMac ? e.metaKey : e.ctrlKey) && + // e.altKey && + // e.shiftKey + // ) { + // // alt shift left, autoplay from left + // constants.lastx = position.x; + // Autoplay('inward_left', -1, position.x); + // } else if ( + // e.key == 'ArrowLeft' && + // (constants.isMac ? e.metaKey : e.ctrlKey) && + // !e.shiftKey + // ) { + // // ctrl left arrow, go to beginning + // position.x = 0; // go all the way + // updateInfoThisRound = true; + // isAtEnd = lockPosition(); + // } + + // // update display / text / audio + // if (updateInfoThisRound && !isAtEnd) { + // if (constants.brailleMode == 'off') { + // UpdateAll(); + // } else { + // UpdateAllBraille(); + // } + // } + // if (isAtEnd) { + // audio.playEnd(); + // } + // } + // }, + // ]); + + let controlElements = [constants.chart, constants.brailleInput]; + let lastx = 0; + for (let i = 0; i < controlElements.length; i++) { + constants.events.push([ + controlElements[i], + 'keydown', + function (e) { + // period: speed up + if (e.key == '.') { + constants.SpeedUp(); + PlayDuringSpeedChange(); + display.announceText('Speed up'); + } + + // comma: speed down + if (e.key == ',') { + constants.SpeedDown(); + PlayDuringSpeedChange(); + display.announceText('Speed down'); + } + + // /: reset speed + if (e.key == '/') { + constants.SpeedReset(); + PlayDuringSpeedChange(); + display.announceText('Speed reset'); + } + }, + ]); + } + function PlayDuringSpeedChange() { + if (constants.autoplayId != null) { + constants.KillAutoplay(); + if (lastPlayed == 'reverse-left') { + Autoplay('right', position.x, lastx); + } else if (lastPlayed == 'reverse-right') { + Autoplay('left', position.x, lastx); + } else { + Autoplay(lastPlayed, position.x, lastx); + } + } + } + + function lockPosition() { + // lock to min / max postions + let didLockHappen = false; + // if (!constants.hasRect) { + // return didLockHappen; + // } + + if (position.x < 0) { + position.x = 0; + didLockHappen = true; + } + if (position.x > plot.pointValuesY.length - 1) { + position.x = plot.pointValuesY.length - 1; + didLockHappen = true; + } + + return didLockHappen; + } + function UpdateAll() { + if (constants.showDisplay) { + display.displayValues(); + } + if (constants.showRect) { + point.UpdatePointDisplay(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + } + function UpdateAllAutoplay() { + if (constants.showDisplayInAutoplay) { + display.displayValues(); + } + if (constants.showRect) { + point.UpdatePointDisplay(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + + if (constants.brailleMode != 'off') { + display.UpdateBraillePos(); + } + } + function UpdateAllBraille() { + if (constants.showDisplayInBraille) { + display.displayValues(); + } + if (constants.showRect) { + point.UpdatePointDisplay(); + } + if (constants.sonifMode != 'off') { + plot.PlayTones(); + } + display.UpdateBraillePos(); + } + function Autoplay(dir, start, end) { + lastPlayed = dir; + let step = 1; // default right and reverse-left + if (dir == 'left' || dir == 'reverse-right') { + step = -1; + } + + // clear old autoplay if exists + if (constants.autoplayId != null) { + constants.KillAutoplay(); + } + + if (dir == 'reverse-right' || dir == 'reverse-left') { + position.x = start; + } + + constants.autoplayId = setInterval(function () { + position.x += step; + if (position.x < 0 || plot.pointValuesY.length - 1 < position.x) { + constants.KillAutoplay(); + lockPosition(); + } else if (position.x == end) { + constants.KillAutoplay(); + UpdateAllAutoplay(); + } else { + UpdateAllAutoplay(); + } + }, constants.autoPlayRate); + } + function PlayLine(dir) { + lastPlayed = dir; + + let freqArr = []; + let panningArr = []; + let panPoint = audio.SlideBetween( + positionL1.x, + 0, + plot.curvePoints.length - 1, + -1, + 1 + ); + let x = positionL1.x < 0 ? 0 : positionL1.x; + let duration = 0; + if (dir == 'right') { + for (let i = x; i < plot.curvePoints.length; i++) { + freqArr.push( + audio.SlideBetween( + plot.curvePoints[i], + plot.curveMinY, + plot.curveMaxY, + constants.MIN_FREQUENCY, + constants.MAX_FREQUENCY + ) + ); + } + panningArr = [panPoint, 1]; + duration = + (Math.abs(plot.curvePoints.length - x) / plot.curvePoints.length) * + 3; + } else if (dir == 'left') { + for (let i = x; i >= 0; i--) { + freqArr.push( + audio.SlideBetween( + plot.curvePoints[i], + plot.curveMinY, + plot.curveMaxY, + constants.MIN_FREQUENCY, + constants.MAX_FREQUENCY + ) + ); + } + panningArr = [panPoint, -1]; + duration = (Math.abs(x) / plot.curvePoints.length) * 3; + } else if (dir == 'reverse-right') { + for (let i = plot.curvePoints.length - 1; i >= x; i--) { + freqArr.push( + audio.SlideBetween( + plot.curvePoints[i], + plot.curveMinY, + plot.curveMaxY, + constants.MIN_FREQUENCY, + constants.MAX_FREQUENCY + ) + ); + } + panningArr = [1, panPoint]; + duration = + (Math.abs(plot.curvePoints.length - x) / plot.curvePoints.length) * + 3; + } else if (dir == 'reverse-left') { + for (let i = 0; i <= x; i++) { + freqArr.push( + audio.SlideBetween( + plot.curvePoints[i], + plot.curveMinY, + plot.curveMaxY, + constants.MIN_FREQUENCY, + constants.MAX_FREQUENCY + ) + ); + } + panningArr = [-1, panPoint]; + duration = (Math.abs(x) / plot.curvePoints.length) * 3; + } + + if (constants.isSmoothAutoplay) { + audio.KillSmooth(); + } + + // audio.playSmooth(freqArr, 2, panningArr, constants.vol, 'sine'); + audio.playSmooth(freqArr, duration, panningArr, constants.vol, 'sine'); + } + } + } + + /** + * Gets the next or previous focusable element based on the current focus. + * @param {string} nextprev - Determines whether to get the next or previous focusable element. Defaults to 'next'. + * @returns {HTMLElement|null} - The next or previous focusable element, or null if it does not exist. + */ + GetNextPrevFocusable(nextprev = 'next') { + // store all focusable elements for future tabbing away from chart + let focusableSelectors = + 'a[href], button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + constants.focusables = Array.from( + document.querySelectorAll(focusableSelectors) + ); + + // get index of chart in focusables + let chartIndex = constants.focusables.indexOf(constants.chart); + + // remove all the stuff we add manually from focusables + let maidrFocusables = + constants.main_container.querySelectorAll(focusableSelectors); + for (let i = 0; i < maidrFocusables.length; i++) { + let index = constants.focusables.indexOf(maidrFocusables[i]); + if (index > -1) { + constants.focusables.splice(index, 1); + } + // and adjust chartIndex + if (chartIndex > index) { + chartIndex--; + } + } + + // now we get next / prev based on chartIndex. If DNE, return null + } +} diff --git a/react-maidr/src/js/display.js b/react-maidr/src/js/display.js new file mode 100644 index 00000000..a8eb14df --- /dev/null +++ b/react-maidr/src/js/display.js @@ -0,0 +1,1205 @@ +/** + * A class representing the display of the chart. + * @class + */ +class Display { + /** + * Creates a new instance of the Display class. + * @constructor + */ + constructor() { + this.infoDiv = constants.infoDiv; + + this.x = {}; + this.x.id = 'x'; + this.x.textBase = 'x-value: '; + + this.y = {}; + this.y.id = 'y'; + this.y.textBase = 'y-value: '; + + this.boxplotGridPlaceholders = [ + resources.GetString('lower_outlier'), + resources.GetString('min'), + resources.GetString('25'), + resources.GetString('50'), + resources.GetString('75'), + resources.GetString('max'), + resources.GetString('upper_outlier'), + ]; + } + + /** + * Toggles the text mode between 'off', 'terse', and 'verbose'. + * Updates the constants.textMode property and announces the new mode. + */ + toggleTextMode() { + if (constants.textMode == 'off') { + constants.textMode = 'terse'; + } else if (constants.textMode == 'terse') { + constants.textMode = 'verbose'; + } else if (constants.textMode == 'verbose') { + constants.textMode = 'off'; + } + + this.announceText( + ' ' + constants.textMode + ); + } + + /** + * Toggles braille mode on or off. + * @param {string} [onoff] - Optional parameter to explicitly set braille mode on or off. If not supplied, defaults to toggling the current braille mode. + * @returns {void} + */ + toggleBrailleMode(onoff) { + // exception: if we just initilized, position might not be in range + if (position.x < 0) position.x = 0; + if (position.y < 0) position.y = 0; + + if (constants.chartType == 'point') { + this.announceText('Braille is not supported in point layer.'); + return; + } + if (typeof onoff === 'undefined') { + if (typeof constants.brailleMode === 'undefined') { + constants.brailleMode = 'off'; + onoff = constants.brailleMode == 'on'; + } else { + // switch on/off + if (constants.brailleMode == 'on') { + onoff = 'off'; + } else { + onoff = 'on'; + } + constants.brailleMode = onoff; + } + } + if (onoff == 'on') { + if (constants.chartType == 'box') { + // braille mode is on before any plot is selected + if ( + constants.plotOrientation != 'vert' && + position.x == -1 && + position.y == plot.plotData.length + ) { + position.x += 1; + position.y -= 1; + } else if ( + constants.plotOrientation == 'vert' && + position.x == 0 && + position.y == plot.plotData[0].length - 1 + ) { + // do nothing; don't think there's any problem + } + } + + constants.brailleMode = 'on'; + document + .getElementById(constants.braille_container_id) + .classList.remove('hidden'); + constants.brailleInput.focus(); + constants.brailleInput.setSelectionRange(position.x, position.x); + + this.SetBraille(); + + if (constants.chartType == 'heat') { + let pos = position.y * (plot.num_cols + 1) + position.x; + constants.brailleInput.setSelectionRange(pos, pos); + } + + // braille mode is on before navigation of chart + // very important to make sure braille works properly + if (position.x == -1 && position.y == -1) { + constants.brailleInput.setSelectionRange(0, 0); + } + } else { + constants.brailleMode = 'off'; + document + .getElementById(constants.braille_container_id) + .classList.add('hidden'); + + if (constants.review_container) { + if (!constants.review_container.classList.contains('hidden')) { + constants.review.focus(); + } else { + constants.chart.focus(); + } + } else { + constants.chart.focus(); + } + } + + this.announceText('Braille ' + constants.brailleMode); + } + + /** + * Toggles the sonification mode based on the current chart type and sonification mode. + * If the chart type is point, stacked_bar, stacked_normalized_bar, or dodged_bar, the sonification mode can be toggled between 'off', 'on', and 'same'. + * If the chart type is not one of the above, the sonification mode can only be toggled between 'off' and 'on'. + */ + toggleSonificationMode() { + if ( + constants.chartType == 'point' || + constants.chartType == 'stacked_bar' || + constants.chartType == 'stacked_normalized_bar' || + constants.chartType == 'dodged_bar' + ) { + if (constants.sonifMode == 'off') { + constants.sonifMode = 'on'; + this.announceText(resources.GetString('son_sep')); + } else if (constants.sonifMode == 'on') { + constants.sonifMode = 'same'; + this.announceText(resources.GetString('son_same')); + } else if (constants.sonifMode == 'same') { + constants.sonifMode = 'off'; + this.announceText(resources.GetString('son_off')); + } + } else { + if (constants.sonifMode == 'off') { + constants.sonifMode = 'on'; + this.announceText(resources.GetString('son_on')); + } else { + constants.sonifMode = 'off'; + this.announceText(resources.GetString('son_off')); + } + } + } + + /** + * Changes the chart layer up or down and updates the position relative to where we were on the previous layer. + * This only applies to charts that have multiple layers, such as point and smooth in a standard scatterplot. + * @param {string} [updown='down'] - The direction to change the chart layer. Can be 'up' or 'down'. Defaults to 'down'. + */ + changeChartLayer(updown = 'down') { + // get possible chart types, where we are, and move between them + let chartTypes = maidr.type; + if (Array.isArray(chartTypes)) { + let currentIndex = chartTypes.indexOf(constants.chartType); + if (updown == 'down') { + if (currentIndex == 0) { + //constants.chartType = chartTypes[chartTypes.length - 1]; + } else { + constants.chartType = chartTypes[currentIndex - 1]; + this.announceText('Switched to ' + constants.chartType); // todo: connect this to a resource file so it can be localized + } + } else { + if (currentIndex == chartTypes.length - 1) { + //constants.chartType = chartTypes[0]; + } else { + constants.chartType = chartTypes[currentIndex + 1]; + this.announceText('Switched to ' + constants.chartType); // todo: connect this to a resource file so it can be localized + } + } + } + + // update position relative to where we were on the previous layer + // newX = oldX * newLen / oldLen + if (constants.chartType == 'point') { + position.x = Math.round( + ((plot.x.length - 1) * positionL1.x) / (plot.curvePoints.length - 1) + ); + } else if (constants.chartType == 'smooth') { + // reverse math of the above + positionL1.x = Math.round( + ((plot.curvePoints.length - 1) * position.x) / (plot.x.length - 1) + ); + } + } + + /** + * Sets the text of the announce container element. + * @param {string} txt - The text to be displayed in the announce container. + */ + announceText(txt) { + this.displayInfo('announce', txt, constants.announceContainer); + } + + /** + * Updates the position of the cursor in the braille display based on the current chart type and position. + */ + UpdateBraillePos() { + if ( + constants.chartType == 'bar' || + constants.chartType == 'hist' || + constants.chartType == 'line' + ) { + constants.brailleInput.setSelectionRange(position.x, position.x); + } else if ( + constants.chartType == 'stacked_bar' || + constants.chartType == 'stacked_normalized_bar' || + constants.chartType == 'dodged_bar' + ) { + // if we're not on the top y position + let pos = null; + if (position.y < plot.plotData[0].length - 1) { + pos = position.x; + } else { + pos = position.x * (plot.fill.length + 1) + position.y; + } + constants.brailleInput.setSelectionRange(pos, pos); + } else if (constants.chartType == 'heat') { + let pos = position.y * (plot.num_cols + 1) + position.x; + constants.brailleInput.setSelectionRange(pos, pos); + } else if (constants.chartType == 'box') { + // on box we extend characters a lot and have blanks, so we go to our type + let sectionPos = + constants.plotOrientation == 'vert' ? position.y : position.x; + let targetLabel = this.boxplotGridPlaceholders[sectionPos]; + let haveTargetLabel = false; + let adjustedPos = 0; + if (constants.brailleData) { + for (let i = 0; i < constants.brailleData.length; i++) { + if (constants.brailleData[i].type != 'blank') { + if ( + resources.GetString(constants.brailleData[i].type) == targetLabel + ) { + haveTargetLabel = true; + break; + } + } + adjustedPos += constants.brailleData[i].numChars; + } + } else { + throw 'Braille data not set up, cannot move cursor in braille, sorry.'; + } + // but sometimes we don't have our targetLabel, go to the start + // future todo: look for nearby label and go to the nearby side of that + if (!haveTargetLabel) { + adjustedPos = 0; + } + + constants.brailleInput.setSelectionRange(adjustedPos, adjustedPos); + } else if ( + singleMaidr.type == 'smooth' || + singleMaidr.type.includes('smooth') + ) { + constants.brailleInput.setSelectionRange(positionL1.x, positionL1.x); + } + } + + /** + * Builds an html text string to output to both visual users and aria live based on what chart we're on, our position, and the mode. + * Typical output is something like "x is 5, y is 10". + * @function + * @memberof module:display + * @returns {void} + */ + displayValues() { + // we build an html text string to output to both visual users and aria live based on what chart we're on, our position, and the mode + // note: we do this all as one string rather than changing individual element IDs so that aria-live receives a single update + + let output = ''; + let verboseText = ''; + let reviewText = ''; + if (constants.chartType == 'bar') { + // {legend x} is {colname x}, {legend y} is {value y} + if (plot.columnLabels[position.x]) { + if (plot.plotLegend.x.length > 0) { + verboseText += plot.plotLegend.x + ' is '; + } + verboseText += plot.columnLabels[position.x] + ', '; + } + if (plot.plotData[position.x]) { + if (plot.plotLegend) { + verboseText += plot.plotLegend.y + ' is '; + } + verboseText += plot.plotData[position.x]; + } + if (constants.textMode == 'off') { + // do nothing :D + } else if (constants.textMode == 'terse') { + // {colname} {value} + output += + '

' + + plot.columnLabels[position.x] + + ' ' + + plot.plotData[position.x] + + '

\n'; + } else if (constants.textMode == 'verbose') { + output += '

' + verboseText + '

\n'; + } + } else if (constants.chartType == 'heat') { + // col name and value + if (constants.navigation == 1) { + verboseText += + plot.x_group_label + + ' ' + + plot.x_labels[position.x] + + ', ' + + plot.y_group_label + + ' ' + + plot.y_labels[position.y] + + ', ' + + plot.fill + + ' is '; + // if (constants.hasRect) { + verboseText += plot.data[position.y][position.x]; + // } + } else { + verboseText += + plot.y_group_label + + ' ' + + plot.y_labels[position.y] + + ', ' + + plot.x_group_label + + ' ' + + plot.x_labels[position.x] + + ', ' + + plot.fill + + ' is '; + // if (constants.hasRect) { + verboseText += plot.data[position.y][position.x]; + // } + } + // terse and verbose alternate between columns and rows + if (constants.textMode == 'off') { + // do nothing :D + } else if (constants.textMode == 'terse') { + // value only + if (constants.navigation == 1) { + // column navigation + output += + '

' + + plot.x_labels[position.x] + + ', ' + + plot.data[position.y][position.x] + + '

\n'; + } else { + // row navigation + output += + '

' + + plot.y_labels[position.y] + + ', ' + + plot.data[position.y][position.x] + + '

\n'; + } + } else if (constants.textMode == 'verbose') { + output += '

' + verboseText + '

\n'; + } + } else if (constants.chartType == 'box') { + // setup + let val = 0; + let numPoints = 1; + let isOutlier = false; + let plotPos = + constants.plotOrientation == 'vert' ? position.x : position.y; + let sectionKey = plot.GetSectionKey( + constants.plotOrientation == 'vert' ? position.y : position.x + ); + let textTerse = ''; + let textVerbose = ''; + + if (sectionKey == 'lower_outlier' || sectionKey == 'upper_outlier') { + isOutlier = true; + } + if (plot.plotData[plotPos][sectionKey] == null) { + val = ''; + if (isOutlier) numPoints = 0; + } else if (isOutlier) { + val = plot.plotData[plotPos][sectionKey].join(', '); + numPoints = plot.plotData[plotPos][sectionKey].length; + } else { + val = plot.plotData[plotPos][sectionKey]; + } + + // set output + + // group label for verbose + if (constants.navigation) { + if (plot.x_group_label) textVerbose += plot.x_group_label; + } else if (!constants.navigation) { + if (plot.y_group_label) textVerbose += plot.y_group_label; + } + // and axes label + if (constants.navigation) { + if (plot.x_labels[plotPos]) { + textVerbose += ' is '; + textTerse += plot.x_labels[plotPos] + ', '; + textVerbose += plot.x_labels[plotPos] + ', '; + } else { + textVerbose += ', '; + } + } else if (!constants.navigation) { + if (plot.y_labels[plotPos]) { + textVerbose += ' is '; + textTerse += plot.y_labels[plotPos] + ', '; + textVerbose += plot.y_labels[plotPos] + ', '; + } else { + textVerbose += ', '; + } + } + // outliers + if (isOutlier) { + textTerse += numPoints + ' '; + textVerbose += numPoints + ' '; + } + // label + textVerbose += resources.GetString(sectionKey); + if (numPoints == 1) textVerbose += ' is '; + else { + textVerbose += 's '; + if (numPoints > 1) textVerbose += ' are '; + } + if ( + isOutlier || + (constants.navigation && constants.plotOrientation == 'horz') || + (!constants.navigation && constants.plotOrientation == 'vert') + ) { + textTerse += resources.GetString(sectionKey); + + // grammar + if (numPoints != 1) { + textTerse += 's'; + } + textTerse += ' '; + } + // val + if (plot.plotData[plotPos][sectionKey] != null && !isOutlier) { + textTerse += 'empty'; + textVerbose += 'empty'; + } else { + textTerse += val; + textVerbose += val; + } + + verboseText = textVerbose; // yeah it's an extra var, who cares + if (constants.textMode == 'verbose') + output = '

' + textVerbose + '

\n'; + else if (constants.textMode == 'terse') + output = '

' + textTerse + '

\n'; + } else if ( + [].concat(singleMaidr.type).includes('point') || + [].concat(singleMaidr.type).includes('smooth') + ) { + if (constants.chartType == 'point') { + // point layer + verboseText += + plot.x_group_label + + ' ' + + plot.x[position.x] + + ', ' + + plot.y_group_label + + ' [' + + plot.y[position.x].join(', ') + + ']'; + + if (constants.textMode == 'off') { + // do nothing + } else if (constants.textMode == 'terse') { + output += + '

' + + plot.x[position.x] + + ', ' + + '[' + + plot.y[position.x].join(', ') + + ']' + + '

\n'; + } else if (constants.textMode == 'verbose') { + // set from verboseText + } + } else if (constants.chartType == 'smooth') { + // best fit smooth layer + verboseText += + plot.x_group_label + + ' ' + + plot.curveX[positionL1.x] + + ', ' + + plot.y_group_label + + ' ' + + plot.curvePoints[positionL1.x]; // verbose mode: x and y values + + if (constants.textMode == 'off') { + // do nothing + } else if (constants.textMode == 'terse') { + // terse mode: gradient trend + // output += '

' + plot.gradient[positionL1.x] + '

\n'; + + // display absolute gradient of the graph + output += '

' + plot.curvePoints[positionL1.x] + '

\n'; + } else if (constants.textMode == 'verbose') { + // set from verboseText + } + } + if (constants.textMode == 'verbose') + output = '

' + verboseText + '

\n'; + } else if (constants.chartType == 'hist') { + if (constants.textMode == 'terse') { + // terse: {x}, {y} + output = + '

' + + plot.plotData[position.x].x + + ', ' + + plot.plotData[position.x].y + + '

\n'; + } else if (constants.textMode == 'verbose') { + // verbose: {xlabel} is xmin through xmax, {ylabel} is y + output = '

'; + if (plot.legendX) { + output = plot.legendX + ' is '; + } + output += plot.plotData[position.x].xmin; + output += ' through ' + plot.plotData[position.x].xmax + ', '; + if (plot.legendY) { + output += plot.legendY + ' is '; + } + output += plot.plotData[position.x].y; + } + } else if (constants.chartType == 'line') { + // line layer + if (plot.plotLegend) { + verboseText += plot.plotLegend.x + ' is '; + } + verboseText += plot.pointValuesX[position.x] + ', '; + if (plot.plotLegend) { + plot.plotLegend.y + ' is '; + } + verboseText += plot.pointValuesY[position.x]; + + if (constants.textMode == 'off') { + // do nothing + } else if (constants.textMode == 'terse') { + output += + '

' + + plot.pointValuesX[position.x] + + ', ' + + plot.pointValuesY[position.x] + + '

\n'; + } else if (constants.textMode == 'verbose') { + // set from verboseText + output += '

' + verboseText + '

\n'; + } + } else if ( + constants.chartType == 'stacked_bar' || + constants.chartType == 'stacked_normalized_bar' || + constants.chartType == 'dodged_bar' + ) { + // {legend x} is {colname x}, {legend y} is {colname y}, value is {plotData[x][y]} + if (plot.plotLegend) { + verboseText += plot.plotLegend.x + ' is '; + } + verboseText += plot.level[position.x] + ', '; + if (plot.plotLegend) { + verboseText += plot.plotLegend.y + ' is '; + } + verboseText += plot.fill[position.y] + ', '; + verboseText += 'value is ' + plot.plotData[position.x][position.y]; + + if (constants.textMode == 'off') { + // do nothing + } else if (constants.textMode == 'terse') { + // navigation == 1 ? {colname x} : {colname y} is {plotData[x][y]} + if (constants.navigation == 1) { + output += + '

' + + plot.level[position.x] + + ' is ' + + plot.plotData[position.x][position.y] + + '

\n'; + } else { + output += + '

' + + plot.fill[position.y] + + ' is ' + + plot.plotData[position.x][position.y] + + '

\n'; + } + } else { + output += '

' + verboseText + '

\n'; + } + } + + if (constants.infoDiv) constants.infoDiv.innerHTML = output; + if (constants.review) { + if (output.length > 0) { + constants.review.value = output.replace(/<[^>]*>?/gm, ''); + } else { + constants.review.value = verboseText; + } + } + } + + /** + * Displays information on the webpage and an aria live region based on the textType and textValue provided. + * @param {string} textType - The type of text to be displayed. + * @param {string} textValue - The value of the text to be displayed. + */ + displayInfo(textType, textValue, elem = constants.infoDiv) { + let textToAdd = ''; + if (textType == 'announce') { + if (textValue) { + textToAdd = textValue; + } + } else if (textType) { + if (textValue) { + if (constants.textMode == 'terse') { + textToAdd = textValue; + } else if (constants.textMode == 'verbose') { + let capsTextType = + textType.charAt(0).toUpperCase() + textType.slice(1); + textToAdd = capsTextType + ' is ' + textValue; + } + } else { + let aOrAn = ['a', 'e', 'i', 'o', 'u'].includes(textType.charAt(0)) + ? 'an' + : 'a'; + + textToAdd = 'Plot does not have ' + aOrAn + ' ' + textType; + } + } + if (textToAdd.length > 0) { + elem.innerHTML = null; + let p = document.createElement('p'); + p.innerHTML = textToAdd; + elem.appendChild(p); + } + } + + /** + * Sets the braille representation of the chart based on the current chart type and plot data. + */ + SetBraille() { + let brailleArray = []; + + if (constants.chartType == 'heat') { + let range = (constants.maxY - constants.minY) / 3; + let low = constants.minY + range; + let medium = low + range; + let high = medium + range; + for (let i = 0; i < plot.data.length; i++) { + for (let j = 0; j < plot.data[i].length; j++) { + if (plot.data[i][j] == 0) { + brailleArray.push('⠀'); + } else if (plot.data[i][j] <= low) { + brailleArray.push('⠤'); + } else if (plot.data[i][j] <= medium) { + brailleArray.push('⠒'); + } else { + brailleArray.push('⠉'); + } + } + brailleArray.push('⠳'); + } + } else if ( + constants.chartType == 'stacked_bar' || + constants.chartType == 'stacked_normalized_bar' || + constants.chartType == 'dodged_bar' + ) { + // if we're not on the top y position, display just this level, using local min max + if (position.y < plot.plotData[0].length - 1) { + let localMin = null; + let localMax = null; + for (let i = 0; i < plot.plotData.length; i++) { + if (i == 0) { + localMin = plot.plotData[i][position.y]; + localMax = plot.plotData[i][position.y]; + } else { + if (plot.plotData[i][position.y] < localMin) { + localMin = plot.plotData[i][position.y]; + } + if (plot.plotData[i][position.y] > localMax) { + localMax = plot.plotData[i][position.y]; + } + } + } + let range = (localMax - localMin) / 4; + let low = localMin + range; + let medium = low + range; + let medium_high = medium + range; + for (let i = 0; i < plot.plotData.length; i++) { + if (plot.plotData[i][position.y] == 0) { + brailleArray.push('⠀'); + } else if (plot.plotData[i][position.y] <= low) { + brailleArray.push('⣀'); + } else if (plot.plotData[i][position.y] <= medium) { + brailleArray.push('⠤'); + } else if (plot.plotData[i][position.y] <= medium_high) { + brailleArray.push('⠒'); + } else { + brailleArray.push('⠉'); + } + } + } else { + // all mode, do braille similar to heatmap, with all data and seperator + for (let i = 0; i < plot.plotData.length; i++) { + let range = (constants.maxY - constants.minY) / 4; + let low = constants.minY + range; + let medium = low + range; + let medium_high = medium + range; + for (let j = 0; j < plot.plotData[i].length; j++) { + if (plot.plotData[i][j] == 0) { + brailleArray.push('⠀'); + } else if (plot.plotData[i][j] <= low) { + brailleArray.push('⣀'); + } else if (plot.plotData[i][j] <= medium) { + brailleArray.push('⠤'); + } else if (plot.plotData[i][j] <= medium_high) { + brailleArray.push('⠒'); + } else { + brailleArray.push('⠉'); + } + } + brailleArray.push('⠳'); + } + } + } else if (constants.chartType == 'bar') { + let range = (constants.maxY - constants.minY) / 4; + let low = constants.minY + range; + let medium = low + range; + let medium_high = medium + range; + for (let i = 0; i < plot.plotData.length; i++) { + if (plot.plotData[i] <= low) { + brailleArray.push('⣀'); + } else if (plot.plotData[i] <= medium) { + brailleArray.push('⠤'); + } else if (plot.plotData[i] <= medium_high) { + brailleArray.push('⠒'); + } else { + brailleArray.push('⠉'); + } + } + } else if (constants.chartType == 'smooth') { + let range = (plot.curveMaxY - plot.curveMinY) / 4; + let low = plot.curveMinY + range; + let medium = low + range; + let medium_high = medium + range; + let high = medium_high + range; + for (let i = 0; i < plot.curvePoints.length; i++) { + if (plot.curvePoints[i] <= low) { + brailleArray.push('⣀'); + } else if (plot.curvePoints[i] <= medium) { + brailleArray.push('⠤'); + } else if (plot.curvePoints[i] <= medium_high) { + brailleArray.push('⠒'); + } else if (plot.curvePoints[i] <= high) { + brailleArray.push('⠉'); + } + } + } else if (constants.chartType == 'hist') { + let range = (constants.maxY - constants.minY) / 4; + let low = constants.minY + range; + let medium = low + range; + let medium_high = medium + range; + for (let i = 0; i < plot.plotData.length; i++) { + if (plot.plotData[i].y <= low) { + brailleArray.push('⣀'); + } else if (plot.plotData[i].y <= medium) { + brailleArray.push('⠤'); + } else if (plot.plotData[i].y <= medium_high) { + brailleArray.push('⠒'); + } else { + brailleArray.push('⠉'); + } + } + } else if (constants.chartType == 'box' && position.y > -1) { + // Idea here is to use different braille characters to physically represent the box + // if sections are longer or shorter we'll add more characters + // example: outlier, small space, long min, med 25/50/75, short max: ⠂ ⠒⠒⠒⠒⠒⠒⠿⠸⠿⠒ + // + // So, we get weighted lengths of each section (or gaps between outliers, etc), + // and then create the appropriate number of characters + // Full explanation on readme + // + // This is messy and long (250 lines). If anyone wants to improve, be my guest + + // Some init stuff + let plotPos; + let globalMin; + let globalMax; + let numSections = plot.sections.length; + if (constants.plotOrientation == 'vert') { + plotPos = position.x; + globalMin = constants.minY; + globalMax = constants.maxY; + } else { + plotPos = position.y; + globalMin = constants.minX; + globalMax = constants.maxX; + } + + // We convert main plot data to array of values and types, including min and max, and seperating outliers and removing nulls + let valData = []; + valData.push({ type: 'global_min', value: globalMin }); + for (let i = 0; i < numSections; i++) { + let sectionKey = plot.sections[i]; + let point = plot.plotData[plotPos][sectionKey]; + let charData = {}; + + if (point != null) { + if (sectionKey == 'lower_outlier' || sectionKey == 'upper_outlier') { + for (let j = 0; j < point.length; j++) { + charData = { + type: sectionKey, + value: point[j], + }; + valData.push(charData); + } + } else { + charData = { + type: sectionKey, + value: point, + }; + valData.push(charData); + } + } + } + valData.push({ type: 'global_max', value: globalMax }); + + // Then we convert to lengths and types + // We assign lengths based on the difference between each point, and assign blanks if this comes before or after an outlier + let lenData = []; + let isBeforeMid = true; + for (let i = 0; i < valData.length; i++) { + let diff; + // we compare inwardly, and midpoint is len 0 + if (isBeforeMid) { + diff = Math.abs(valData[i + 1].value - valData[i].value); + } else { + diff = Math.abs(valData[i].value - valData[i - 1].value); + } + + if ( + valData[i].type == 'global_min' || + valData[i].type == 'global_max' + ) { + lenData.push({ type: 'blank', length: diff }); + } else if (valData[i].type == 'lower_outlier') { + // add diff as space, as well as a 0 len outlier point + // add blank last, as the earlier point is covered by global_min + lenData.push({ type: valData[i].type, length: 0 }); + lenData.push({ type: 'blank', length: diff }); + } else if (valData[i].type == 'upper_outlier') { + // add diff as space, as well as a 0 len outlier point, but reverse order from lower_outlier obvs + lenData.push({ type: 'blank', length: diff }); + lenData.push({ type: valData[i].type, length: 0 }); + } else if (valData[i].type == 'q2') { + // change calc method after midpoint, as we want spacing to go outward from center (and so center has no length) + isBeforeMid = false; + lenData.push({ type: valData[i].type, length: 0 }); + } else { + // normal points + lenData.push({ type: valData[i].type, length: diff }); + } + } + + // We create a set of braille characters based on the lengths + + // Method: + // We normalize the lengths of each characters needed length + // by the total number of characters we have availble + // (including offset from characters requiring 1 character). + // Then apply the appropriate number of characters to each + + // A few exceptions: + // exception: each must have min 1 character (not blanks or length 0) + // exception: for 25/75 and min/max, if they aren't exactly equal, assign different num characters + // exception: center is always 456 123 + + // Step 1, sorta init. + // We prepopulate each non null section with a single character, and log for character offset + let locMin = -1; + let locQ1 = -1; + let locQ3 = -1; + let locMax = -1; + let numAllocatedChars = 0; // counter for number of characters we've already assigned + for (let i = 0; i < lenData.length; i++) { + if ( + lenData[i].type != 'blank' && + (lenData[i].length > 0 || + lenData[i].type == 'lower_outlier' || + lenData[i].type == 'upper_outlier') + ) { + lenData[i].numChars = 1; + numAllocatedChars++; + } else { + lenData[i].numChars = 0; + } + + // store 25/75 min/max locations so we can check them later more easily + if (lenData[i].type == 'min' && lenData[i].length > 0) locMin = i; + if (lenData[i].type == 'max' && lenData[i].length > 0) locMax = i; + if (lenData[i].type == 'q1') locQ1 = i; + if (lenData[i].type == 'q3') locQ3 = i; + + // 50 gets 2 characters by default + if (lenData[i].type == 'q2') { + lenData[i].numChars = 2; + numAllocatedChars++; // we just ++ here as we already ++'d above + } + } + + // make sure rules are set for pairs (q1 / q3, min / max) + // if they're equal length, we don't need to do anything as they already each have 1 character + // if they're not equal length, we need to add 1 character to the longer one + if (locMin > -1 && locMax > -1) { + // we do it this way as we don't always have both min and max + + if (lenData[locMin].length != lenData[locMax].length) { + if (lenData[locMin].length > lenData[locMax].length) { + lenData[locMin].numChars++; + numAllocatedChars++; + } else { + lenData[locMax].numChars++; + numAllocatedChars++; + } + } + } + // same for q1/q3 + if (lenData[locQ1].length != lenData[locQ3].length) { + if (lenData[locQ1].length > lenData[locQ3].length) { + lenData[locQ1].numChars++; + numAllocatedChars++; + } else { + lenData[locQ3].numChars++; + numAllocatedChars++; + } + } + + // Step 2: normalize and allocate remaining characters and add to our main braille array + let charsAvailable = constants.brailleDisplayLength - numAllocatedChars; + let allocateCharacters = this.AllocateCharacters(lenData, charsAvailable); + // apply allocation + let brailleData = lenData; + for (let i = 0; i < allocateCharacters.length; i++) { + if (allocateCharacters[i]) { + brailleData[i].numChars += allocateCharacters[i]; + } + } + + constants.brailleData = brailleData; + if (constants.debugLevel > 5) { + console.log('plotData[i]', plot.plotData[plotPos]); + console.log('valData', valData); + console.log('lenData', lenData); + console.log('brailleData', brailleData); + } + + // convert to braille characters + for (let i = 0; i < brailleData.length; i++) { + for (let j = 0; j < brailleData[i].numChars; j++) { + let brailleChar = '⠀'; // blank + if (brailleData[i].type == 'min' || brailleData[i].type == 'max') { + brailleChar = '⠒'; + } else if ( + brailleData[i].type == 'q1' || + brailleData[i].type == 'q3' + ) { + brailleChar = '⠿'; + } else if (brailleData[i].type == 'q2') { + if (j == 0) { + brailleChar = '⠸'; + } else { + brailleChar = '⠇'; + } + } else if ( + brailleData[i].type == 'lower_outlier' || + brailleData[i].type == 'upper_outlier' + ) { + brailleChar = '⠂'; + } + brailleArray.push(brailleChar); + } + } + } else if (constants.chartType == 'line') { + // TODO + // ⠑ + let range = (constants.maxY - constants.minY) / 4; + let low = constants.minY + range; + let medium = low + range; + let medium_high = medium + range; + let high = medium_high + range; + + for (let i = 0; i < plot.pointValuesY.length; i++) { + if ( + plot.pointValuesY[i] <= low && + i - 1 >= 0 && + plot.pointValuesY[i - 1] > low + ) { + // move from higher ranges to low + if (plot.pointValuesY[i - 1] <= medium) { + // move away from medium range + brailleArray.push('⢄'); + } else if (plot.pointValuesY[i - 1] <= medium_high) { + // move away from medium high range + brailleArray.push('⢆'); + } else if (plot.pointValuesY[i - 1] > medium_high) { + // move away from high range + brailleArray.push('⢇'); + } + } else if (plot.pointValuesY[i] <= low) { + // in the low range + brailleArray.push('⣀'); + } else if (i - 1 >= 0 && plot.pointValuesY[i - 1] <= low) { + // move from low to higher ranges + if (plot.pointValuesY[i] <= medium) { + // move to medium range + brailleArray.push('⡠'); + } else if (plot.pointValuesY[i] <= medium_high) { + // move to medium high range + brailleArray.push('⡰'); + } else if (plot.pointValuesY[i] > medium_high) { + // move to high range + brailleArray.push('⡸'); + } + } else if ( + plot.pointValuesY[i] <= medium && + i - 1 >= 0 && + plot.pointValuesY[i - 1] > medium + ) { + if (plot.pointValuesY[i - 1] <= medium_high) { + // move away from medium high range to medium + brailleArray.push('⠢'); + } else if (plot.pointValuesY[i - 1] > medium_high) { + // move away from high range + brailleArray.push('⠣'); + } + } else if (plot.pointValuesY[i] <= medium) { + brailleArray.push('⠤'); + } else if (i - 1 >= 0 && plot.pointValuesY[i - 1] <= medium) { + // move from medium to higher ranges + if (plot.pointValuesY[i] <= medium_high) { + // move to medium high range + brailleArray.push('⠔'); + } else if (plot.pointValuesY[i] > medium_high) { + // move to high range + brailleArray.push('⠜'); + } + } else if ( + plot.pointValuesY[i] <= medium_high && + i - 1 >= 0 && + plot.pointValuesY[i - 1] > medium_high + ) { + // move away from high range to medium high + brailleArray.push('⠑'); + } else if (plot.pointValuesY[i] <= medium_high) { + brailleArray.push('⠒'); + } else if (i - 1 >= 0 && plot.pointValuesY[i - 1] <= medium_high) { + // move from medium high to high range + brailleArray.push('⠊'); + } else if (plot.pointValuesY[i] <= high) { + brailleArray.push('⠉'); + } + } + } + + constants.brailleInput.value = brailleArray.join(''); + + constants.brailleInput.value = brailleArray.join(''); + if (constants.debugLevel > 5) { + console.log('braille:', constants.brailleInput.value); + } + + this.UpdateBraillePos(); + } + + /** + * Calculates the impact of character length on the given character data. + * Used by boxplots. + * @param {Object} charData - The character data to calculate the impact for. + * @param {number} charData.length - The total length of all characters. + * @param {number} charData.numChars - The total number of characters. + * @returns {number} The impact of character length on the given character data. + */ + CharLenImpact(charData) { + return charData.length / charData.numChars; + } + + /** + * This function allocates a total number of characters among an array of lengths, + * proportionally to each length. + * + * @param {Array} arr - The array of objects containing lengths, type, and current numChars. Each length should be a positive number. + * @param {number} charsToAllocate - The total number of characters to be allocated. + * + * The function first calculates the sum of all lengths in the array. Then, it + * iterates over the array and calculates an initial allocation for each length, + * rounded to the nearest integer, based on its proportion of the total length. + * + * If the sum of these initial allocations is not equal to the total number of + * characters due to rounding errors, the function makes adjustments to the allocations. + * + * The adjustments are made in a loop that continues until the difference between + * the total number of characters and the sum of the allocations is zero, or until + * the loop has run a maximum number of iterations equal to the length of the array. + * + * In each iteration of the loop, the function calculates a rounding adjustment for + * each length, again based on its proportion of the total length, and adds this + * adjustment to the length's allocation. + * + * If there's still a difference after the maximum number of iterations, the function + * falls back to a simpler method of distributing the difference: it sorts the lengths + * by their allocations and adds or subtracts 1 from each length in this order until + * the difference is zero. + * + * The function returns an array of the final allocations. + * + * @returns {Array} The array of allocations. + */ + AllocateCharacters(arr, charsToAllocate) { + // init + let allocation = []; + let sumLen = 0; + for (let i = 0; i < arr.length; i++) { + sumLen += arr[i].length; + } + let notAllowed = ['lower_outlier', 'upper_outlier', '50']; // these types only have the 1 char they were assigned above + + // main allocation + for (let i = 0; i < arr.length; i++) { + if (!notAllowed.includes(arr[i].type)) { + allocation[i] = Math.round((arr[i].length / sumLen) * charsToAllocate); + } + } + + // main allocation is not perfect, so we need to adjust + let allocatedSum = allocation.reduce((a, b) => a + b, 0); + let difference = charsToAllocate - allocatedSum; + + // If there's a rounding error, add/subtract characters proportionally + let maxIterations = arr.length; // inf loop handler :D + while (difference !== 0 && maxIterations > 0) { + // (same method as above) + for (let i = 0; i < arr.length; i++) { + if (!notAllowed.includes(arr[i].type)) { + allocation[i] += Math.round((arr[i].length / sumLen) * difference); + } + } + allocatedSum = allocation.reduce((a, b) => a + b, 0); + difference = charsToAllocate - allocatedSum; + + maxIterations--; + } + + // if there's still a rounding error after max iterations, fuck it, just distribute it evenly + if (difference !== 0) { + // create an array of indices sorted low to high based on current allocations + let indices = []; + for (let i = 0; i < arr.length; i++) { + indices.push(i); + } + indices.sort((a, b) => allocation[a] - allocation[b]); + + // if we need to add or remove characters, do so from the beginning + let plusminus = -1; // add or remove? + if (difference > 0) { + plusminus = 1; + } + let i = 0; + let maxIterations = indices.length * 3; // run it for a while just in case + while (difference > 0 && maxIterations > 0) { + allocation[indices[i]] += plusminus; + difference += -plusminus; + + i += 1; + // loop back to start if we end + if (i >= indices.length) { + i = 0; + } + + maxIterations += -1; + } + } + + return allocation; + } +} diff --git a/react-maidr/src/js/heatmap.js b/react-maidr/src/js/heatmap.js new file mode 100644 index 00000000..ec00fc99 --- /dev/null +++ b/react-maidr/src/js/heatmap.js @@ -0,0 +1,401 @@ +/** + * A class representing a heatmap. + * @class + */ +class HeatMap { + /** + * Creates a new Heatmap object. + * @constructor + */ + constructor() { + // initialize variables xlevel, data, and elements + let xlevel = null; + let ylevel = null; + if ('axes' in singleMaidr) { + if (singleMaidr.axes.x) { + if (singleMaidr.axes.x.level) { + xlevel = singleMaidr.axes.x.level; + } + } + if (singleMaidr.axes.y) { + if (singleMaidr.axes.y.level) { + ylevel = singleMaidr.axes.y.level; + } + } + } + if ('data' in singleMaidr) { + this.data = singleMaidr.data; + this.num_rows = this.data.length; + this.num_cols = this.data[0].length; + } else { + // critical error, no data + console.error('No data found in singleMaidr object'); + } + if ('selector' in singleMaidr) { + this.elements = document.querySelectorAll(singleMaidr.selector); + constants.hasRect = 1; + } else if ('elements' in singleMaidr) { + this.elements = singleMaidr.elements; + constants.hasRect = 1; + } else { + this.elements = null; + constants.hasRect = 0; + } + + this.group_labels = this.getGroupLabels(); + this.x_labels = xlevel; + this.y_labels = ylevel; + this.title = this.getTitle(); + this.fill = this.getFill(); + + if (constants.hasRect) { + this.SetHeatmapRectData(); + } + + this.updateConstants(); + + this.x_group_label = this.group_labels[0].trim(); + this.y_group_label = this.group_labels[1].trim(); + } + + /** + * Returns an array of heatmap data containing unique x and y coordinates, norms, number of rows, and number of columns. + * If 'data' exists in singleMaidr, it returns the norms from the data. Otherwise, it calculates the norms from the unique x and y coordinates. + * @returns {Array} An array of heatmap data containing unique x and y coordinates, norms, number of rows, and number of columns. + */ + SetHeatmapRectData() { + // We get a set of x and y coordinates from the heatmap squares, + // which is different and only sometimes connected to the actual data + // note, only runs if constants.hasRect is true + + // get the x_coord and y_coord to check if a square exists at the coordinates + let x_coord_check = []; + let y_coord_check = []; + let unique_x_coord = []; + let unique_y_coord = []; + for (let i = 0; i < this.elements.length; i++) { + if (this.elements[i]) { + // heatmap SVG containing path element instead of rect + if (this.elements[i] instanceof SVGPathElement) { + // Assuming the path data is in the format "M x y L x y L x y L x y" + const path_d = this.elements[i].getAttribute('d'); + const regex = /[ML]\s*(-?\d+(\.\d+)?)\s+(-?\d+(\.\d+)?)/g; + const match = regex.exec(path_d); + + const coords = [Number(match[1]), Number(match[3])]; + const x = coords[0]; + const y = coords[1]; + + x_coord_check.push(parseFloat(x)); + y_coord_check.push(parseFloat(y)); + } else { + x_coord_check.push(parseFloat(this.elements[i].getAttribute('x'))); + y_coord_check.push(parseFloat(this.elements[i].getAttribute('y'))); + } + } + } + + // sort the squares to access from left to right, up to down + x_coord_check.sort(function (a, b) { + return a - b; + }); // ascending + y_coord_check.sort(function (a, b) { + return a - b; + }); + + let svgScaler = this.GetSVGScaler(); + // inverse scale if svg has a negative scale in the actual svg + if (svgScaler[0] == -1) { + x_coord_check = x_coord_check.reverse(); + } + if (svgScaler[1] == -1) { + y_coord_check = y_coord_check.reverse(); + } + + // get unique elements from x_coord and y_coord + unique_x_coord = [...new Set(x_coord_check)]; + unique_y_coord = [...new Set(y_coord_check)]; + + this.x_coord = unique_x_coord; + this.y_coord = unique_y_coord; + } + + /** + * Updates the constants used in the heatmap. + * minX: 0, always + * maxX: the x length of the data array + * minY: the minimum value of the data array + * maxY: the maximum value of the data array + * autoPlayRate: the rate at which the heatmap will autoplay, based on the number of columns + * + */ + updateConstants() { + constants.minX = 0; + constants.maxX = this.data[0].length - 1; + constants.minY = Math.min(...this.data.map((row) => Math.min(...row))); + constants.maxY = Math.max(...this.data.map((row) => Math.max(...row))); + constants.autoPlayRate = Math.min( + Math.ceil(constants.AUTOPLAY_DURATION / (constants.maxX + 1)), + constants.MAX_SPEED + ); + constants.DEFAULT_SPEED = constants.autoPlayRate; + if (constants.autoPlayRate < constants.MIN_SPEED) { + constants.MIN_SPEED = constants.autoPlayRate; + } + } + + /** + * Plays a tone using the audio object. + */ + PlayTones() { + audio.playTone(); + } + + /** + * Returns an array of the X and Y scales of the first SVG element found in the elements array. + * @returns {Array} An array containing the X and Y scales of the SVG element. + */ + GetSVGScaler() { + let scaleX = 1; + let scaleY = 1; + // start with some square (first), look all the way up the parents to the svg, and record any scales along the way + + // but first, are we even in an svg that can be scaled? + let isSvg = false; + let element = this.elements[0]; // a random start, may as well be the first + while (element) { + if (element.tagName.toLowerCase() == 'body') { + break; + } + if (element.tagName && element.tagName.toLowerCase() === 'svg') { + isSvg = true; + } + element = element.parentNode; + } + + if (isSvg) { + let element = this.elements[0]; // a random start, may as well be the first + while (element) { + if (element.tagName.toLowerCase() == 'body') { + break; + } + if (element.getAttribute('transform')) { + let transform = element.getAttribute('transform'); + let match = transform.match( + /scale\((-?\d+(\.\d+)?),\s*(-?\d+(\.\d+)?)\)/ + ); + if (match) { + if (!isNaN(match[1])) { + scaleX *= parseFloat(match[1]); + } + if (!isNaN(match[3])) { + scaleY *= parseFloat(match[3]); + } + } + } + element = element.parentNode; + } + } + + return [scaleX, scaleY]; + } + + /** + * Returns the sum of squared values of the RGB color of a plot element. + * @param {number} i - The index of the plot element. + * @returns {number} The sum of squared values of the RGB color. + */ + getRGBNorm(i) { + let rgb_string = this.elements[i].getAttribute('fill'); + let rgb_array = rgb_string.slice(4, -1).split(','); + // just get the sum of squared value of rgb, similar without sqrt, save computation + return rgb_array + .map(function (x) { + return Math.pow(x, 2); + }) + .reduce(function (a, b) { + return a + b; + }); + } + + /** + * Returns an array of group labels for the heatmap. + * @returns {Array} An array containing the X and Y labels for the heatmap. + */ + getGroupLabels() { + let labels_nodelist; + let legendX = ''; + let legendY = ''; + + if ('labels' in singleMaidr) { + if ('x' in singleMaidr.labels) { + legendX = singleMaidr.labels.x; + } + if ('y' in singleMaidr.labels) { + legendY = singleMaidr.labels.y; + } + } + if ('axes' in singleMaidr) { + if ('x' in singleMaidr.axes) { + if ('label' in singleMaidr.axes.x) { + if (legendX == '') { + legendX = singleMaidr.axes.x.label; + } + } + } + if ('y' in singleMaidr.axes) { + if ('label' in singleMaidr.axes.y) { + if (legendY == '') { + legendY = singleMaidr.axes.y.label; + } + } + } + } + + labels_nodelist = [legendX, legendY]; + + return labels_nodelist; + } + + /** + * Returns the x-axis labels from the singleMaidr object. + * @returns {Array} The x-axis labels. + */ + getXLabels() { + if ('axes' in singleMaidr) { + if ('x' in singleMaidr.axes) { + if ('level' in singleMaidr.axes.x) { + return singleMaidr.axes.x.level; + } + } + } + } + + /** + * Returns the y-axis labels from the singleMaidr object, if available. + * @returns {Array|undefined} The y-axis labels, or undefined if not available. + */ + getYLabels() { + if ('axes' in singleMaidr) { + if ('y' in singleMaidr.axes) { + if ('level' in singleMaidr.axes.y) { + return singleMaidr.axes.y.level; + } + } + } + } + + /** + * Returns the title of the singleMaidr object, if it exists. + * If not, returns the title of the labels object within singleMaidr, if it exists. + * @returns {string|undefined} The title of the singleMaidr or labels object, or undefined if neither exists. + */ + getTitle() { + if ('title' in singleMaidr) { + return singleMaidr.title; + } else if ('labels' in singleMaidr) { + if ('title' in singleMaidr.labels) { + return singleMaidr.labels.title; + } + } + } + + /** + * Returns the subtitle from the `singleMaidr` object if it exists. + * @returns {string|undefined} The subtitle string if it exists, otherwise undefined. + */ + getSubtitle() { + if ('labels' in singleMaidr) { + if ('subtitle' in singleMaidr.labels) { + return singleMaidr.labels.subtitle; + } + } + } + + /** + * Returns the caption from the `singleMaidr` object's `labels` property, if it exists. + * @returns {string|undefined} The caption string, or undefined if it doesn't exist. + */ + getCaption() { + if ('labels' in singleMaidr) { + if ('caption' in singleMaidr.labels) { + return singleMaidr.labels.caption; + } + } + } + + /** + * Returns the fill color for the heatmap based on the `fill` property in `singleMaidr.labels`. + * @returns {string|undefined} The fill color or undefined if `singleMaidr.labels.fill` is not defined. + */ + getFill() { + if ('labels' in singleMaidr) { + if ('fill' in singleMaidr.labels) { + return singleMaidr.labels.fill; + } + } + } +} + +/** + * Represents a rectangular heatmap. + * @class + */ +class HeatMapRect { + /** + * Creates a new instance of Heatmap. + * @constructor + */ + constructor() { + if (constants.hasRect) { + this.x = plot.x_coord[0]; + this.y = plot.y_coord[0]; + this.squareIndex = 0; + this.rectStrokeWidth = 4; // px + this.height = Math.abs(plot.y_coord[1] - plot.y_coord[0]); + this.width = Math.abs(plot.x_coord[1] - plot.x_coord[0]); + } + } + + /** + * Updates the position of the rectangle based on the current x and y coordinates. + */ + UpdateRect() { + this.x = plot.x_coord[position.x]; + this.y = plot.y_coord[position.y]; + // find which square we're on by searching for the x and y coordinates + for (let i = 0; i < plot.elements.length; i++) { + if ( + plot.elements[i].getAttribute('x') == this.x && + plot.elements[i].getAttribute('y') == this.y + ) { + this.squareIndex = i; + break; + } + } + } + + /** + * Updates the rectangle display. + * @function + * @memberof Heatmap + * @returns {void} + */ + UpdateRectDisplay() { + this.UpdateRect(); + if (document.getElementById('highlight_rect')) + document.getElementById('highlight_rect').remove(); // destroy and recreate + const svgns = 'http://www.w3.org/2000/svg'; + var rect = document.createElementNS(svgns, 'rect'); + rect.setAttribute('id', 'highlight_rect'); + rect.setAttribute('x', this.x); + rect.setAttribute('y', this.y); + rect.setAttribute('width', this.width); + rect.setAttribute('height', this.height); + rect.setAttribute('stroke', constants.colorSelected); + rect.setAttribute('stroke-width', this.rectStrokeWidth); + rect.setAttribute('fill', 'none'); + plot.elements[this.squareIndex].parentNode.appendChild(rect); + //constants.chart.appendChild(rect); + } +} diff --git a/react-maidr/src/js/histogram.js b/react-maidr/src/js/histogram.js new file mode 100644 index 00000000..089a294e --- /dev/null +++ b/react-maidr/src/js/histogram.js @@ -0,0 +1,196 @@ +/** + * A class representing a histogram. + * @class + */ +class Histogram { + /** + * Creates a new Histogram object. + * @constructor + */ + constructor() { + // initialize main data: data, elements + + // data (required) + if ('data' in singleMaidr) { + this.plotData = singleMaidr.data; + } else { + console.log('Error: no data found'); + return; + } + // elements (optional) + this.bars = null; + if ('selector' in singleMaidr) { + this.bars = document.querySelectorAll(singleMaidr.selector); + } else if ('elements' in singleMaidr) { + this.bars = singleMaidr.elements; + } + + // labels (optional) + this.legendX = null; + this.legendY = null; + if ('labels' in singleMaidr) { + if ('x' in singleMaidr.labels) { + this.legendX = singleMaidr.labels.x; + } + if ('y' in singleMaidr.labels) { + this.legendY = singleMaidr.labels.y; + } + } + if ('axes' in singleMaidr) { + if ('x' in singleMaidr.axes) { + if ('label' in singleMaidr.axes.x) { + if (!this.legendX) { + this.legendX = singleMaidr.axes.x.label; + } + } + } + if ('y' in singleMaidr.axes) { + if ('label' in singleMaidr.axes.y) { + if (!this.legendY) { + this.legendY = singleMaidr.axes.y.label; + } + } + } + } + + // tick labels: todo, not sure if they'll exist or not + + // title (optional) + this.title = ''; + if ('labels' in singleMaidr) { + if ('title' in singleMaidr.labels) { + this.title = singleMaidr.labels.title; + } + } + if (this.title == '') { + if ('title' in singleMaidr) { + this.title = singleMaidr.title; + } + } + + // title (optional) + if ('labels' in singleMaidr) { + if ('subtitle' in singleMaidr.labels) { + this.subtitle = singleMaidr.labels.subtitle; + } + } + // title (optional) + if ('labels' in singleMaidr) { + if ('caption' in singleMaidr.labels) { + this.caption = singleMaidr.labels.caption; + } + } + + this.SetMaxMin(); + + this.autoplay = null; + } + + /** + * Plays a tone using the audio object. + */ + PlayTones() { + audio.playTone(); + } + + /** + * Sets the maximum and minimum values for the plot data. + */ + SetMaxMin() { + for (let i = 0; i < this.plotData.length; i++) { + if (i == 0) { + constants.maxY = this.plotData[i].y; + constants.minY = this.plotData[i].y; + constants.maxX = this.plotData[i].xmax; + constants.minX = this.plotData[i].xmin; + } else { + if (this.plotData[i].y > constants.maxY) { + constants.maxY = this.plotData[i].y; + } + if (this.plotData[i].y < constants.minY) { + constants.minY = this.plotData[i].y; + } + if (this.plotData[i].xmax > constants.maxX) { + constants.maxX = this.plotData[i].xmax; + } + if (this.plotData[i].xmin < constants.minX) { + constants.minX = this.plotData[i].xmin; + } + } + } + constants.autoPlayRate = Math.min( + Math.ceil(constants.AUTOPLAY_DURATION / (constants.maxX + 1)), + constants.MAX_SPEED + ); + constants.DEFAULT_SPEED = constants.autoPlayRate; + if (constants.autoPlayRate < constants.MIN_SPEED) { + constants.MIN_SPEED = constants.autoPlayRate; + } + } + + /** + * Selects an element and changes its color. + */ + Select() { + this.UnSelectPrevious(); + if (this.bars) { + this.activeElement = this.bars[position.x]; + if (this.activeElement) { + // Case where fill is a direct attribute + if (this.activeElement.hasAttribute('fill')) { + this.activeElementColor = this.activeElement.getAttribute('fill'); + // Get new color to highlight and replace fill value + this.activeElement.setAttribute( + 'fill', + constants.GetBetterColor(this.activeElementColor) + ); + // Case where fill is within the style attribute + } else if ( + this.activeElement.hasAttribute('style') && + this.activeElement.getAttribute('style').indexOf('fill') !== -1 + ) { + let styleString = this.activeElement.getAttribute('style'); + // Extract all style attributes and values + let styleArray = constants.GetStyleArrayFromString(styleString); + this.activeElementColor = styleArray[styleArray.indexOf('fill') + 1]; + // Get new color to highlight and replace fill value in style array + styleArray[styleArray.indexOf('fill') + 1] = constants.GetBetterColor( + this.activeElementColor + ); + // Recreate style string and set style attribute + styleString = constants.GetStyleStringFromArray(styleArray); + this.activeElement.setAttribute('style', styleString); + } + } + } + } + + /** + * Unselects the previously selected element by setting its fill attribute to the original color. + * @function + * @name UnSelectPrevious + * @memberof module:histogram + * @instance + * @returns {void} + */ + UnSelectPrevious() { + if (this.activeElement) { + // set fill attribute to the original color + if (this.activeElement.hasAttribute('fill')) { + this.activeElement.setAttribute('fill', this.activeElementColor); + this.activeElement = null; + } else if ( + this.activeElement.hasAttribute('style') && + this.activeElement.getAttribute('style').indexOf('fill') !== -1 + ) { + let styleString = this.activeElement.getAttribute('style'); + let styleArray = constants.GetStyleArrayFromString(styleString); + styleArray[styleArray.indexOf('fill') + 1] = this.activeElementColor; + // Recreate style string and set style attribute + styleString = constants.GetStyleStringFromArray(styleArray); + this.activeElement.setAttribute('style', styleString); + this.activeElement = null; + } + } + } +} diff --git a/react-maidr/src/js/init.js b/react-maidr/src/js/init.js new file mode 100644 index 00000000..36d78897 --- /dev/null +++ b/react-maidr/src/js/init.js @@ -0,0 +1,480 @@ +// events and init functions +// we do some setup, but most of the work is done when user focuses on an element matching an id from maidr user data +document.addEventListener('DOMContentLoaded', function (e) { + // we wrap in DOMContentLoaded to make sure everything has loaded before we run anything + + // create global vars + window.constants = new Constants(); + window.resources = new Resources(); + window.tracker = new Tracker(); + window.logError = new LogError(); + + // set focus events for all charts matching maidr ids + let maidrObjects = []; + if (!Array.isArray(maidr)) { + maidrObjects.push(maidr); + } else { + maidrObjects = maidr; + } + // set focus events for all maidr ids + DestroyMaidr(); // just in case + window.maidrIds = []; + for (let i = 0; i < maidrObjects.length; i++) { + let maidrId = maidrObjects[i].id; + maidrIds.push(maidrId); + let maidrElemn = document.getElementById(maidrId); + if (maidrElemn) { + maidrElemn.setAttribute('tabindex', '0'); + maidrElemn.addEventListener('focus', function (e) { + ShouldWeInitMaidr(maidrObjects[i]); + }); + // blur done elsewhere + } + } + + // events etc for user study page + // run tracker stuff only on user study page + if (document.getElementById('download_data_trigger')) { + // download data button + document + .getElementById('download_data_trigger') + .addEventListener('click', function (e) { + tracker.DownloadTrackerData(); + }); + + // general events + document.addEventListener('keydown', function (e) { + // reset tracking with Ctrl + F5 / command + F5, and Ctrl + Shift + R / command + Shift + R + // future todo: this should probably be a button with a confirmation. This is dangerous + if ( + (e.key == 'F5' && (constants.isMac ? e.metaKey : e.ctrlKey)) || + (e.key == 'R' && (constants.isMac ? e.metaKey : e.ctrlKey)) + ) { + e.preventDefault(); + tracker.Delete(); + location.reload(true); + } + + // Tracker + if (constants.isTracking) { + if (e.key == 'F10') { + //tracker.DownloadTrackerData(); + } else { + if (plot) { + tracker.LogEvent(e); + } + } + } + + // Stuff to only run if we're on a chart (so check if the info div exists?) + if (document.getElementById('info')) { + } + }); + } +}); + +/** + * Initializes the Maidr app for a given chart, taken from the matching ID of the focused chart + * @param {Object} thisMaidr - The json schema for the chart to be initialized. + */ +function InitMaidr(thisMaidr) { + // there's a rare bug where constants isn't defined yet, so we check for that + if (typeof constants != 'undefined') { + // init vars and html + window.singleMaidr = thisMaidr; + constants.chartId = singleMaidr.id; + if (Array.isArray(singleMaidr.type)) { + constants.chartType = singleMaidr.type[0]; + } else { + constants.chartType = singleMaidr.type; + } + CreateChartComponents(singleMaidr); + window.control = new Control(); // this inits the actual chart object and Position + window.review = new Review(); + window.display = new Display(); + window.audio = new Audio(); + + // blur destruction events + let controlElements = [ + constants.chart, + constants.brailleInput, + constants.review, + ]; + for (let i = 0; i < controlElements.length; i++) { + constants.events.push([controlElements[i], 'blur', ShouldWeDestroyMaidr]); + } + + // kill autoplay event + constants.events.push([document, 'keydown', KillAutoplayEvent]); + + // actually do eventlisteners for all events + this.SetEvents(); + + // once everything is set up, announce the chart name (or title as a backup) to the user + if ('name' in singleMaidr) { + display.announceText(singleMaidr.name); + } else if ('title' in singleMaidr) { + display.announceText(singleMaidr.title); + } + } +} + +/** + * Determines whether to initialize Maidr based on conditions: + - maidr isn't enabled (check if singleMaidr is undefined or false) + - the chart we're moving to isn't the same as the one we're on + If successful, calls InitMaidr. If not, does nothing. + note: if we move from one to another, destroy the current first + * @param {Object} thisMaidr - The Maidr object to be initialized. + */ +function ShouldWeInitMaidr(thisMaidr) { + if (typeof singleMaidr == 'undefined') { + // not enabled + InitMaidr(thisMaidr); + } else if (!singleMaidr) { + // not enabled + InitMaidr(thisMaidr); + } else if (thisMaidr.id !== singleMaidr.id) { + // different chart, destroy first + DestroyMaidr(); + InitMaidr(thisMaidr); + } +} + +/** + * Determines whether Maidr should be destroyed based conditions: + - we've tabbed away from the chart or any component + - we're allowed to tab within the system (ie, braille input, review mode, etc) + * If tab movement is 0, do nothing. If tab movement is 1 or -1, move to before/after and then destroy. + * @param {Event} e - The blur event from the Tab key that triggers this function. + */ +function ShouldWeDestroyMaidr(e) { + // timeout to delay blur event. + // I forget why this is necessary, but it is. - smm + setTimeout(() => { + if (constants.tabMovement == 0) { + // do nothing, this is an allowed move + // but also reset so we can leave later + constants.tabMovement = null; + } else { + if (constants.tabMovement == 1 || constants.tabMovement == -1) { + // move to before / after, and then destroy + FocusBeforeOrAfter(); + } + DestroyMaidr(); + } + }, 0); +} + +/** + * Creates a temporary div element and sets focus on it before or after the main container based on the tab movement direction. + * @function + * @name FocusBeforeOrAfter + * @returns {void} + */ +function FocusBeforeOrAfter() { + // Tab / forward + if (constants.tabMovement == 1) { + let focusTemp = document.createElement('div'); + focusTemp.setAttribute('tabindex', '0'); + constants.main_container.after(focusTemp); + focusTemp.focus(); + focusTemp.remove(); + } + // Shift + Tab / backward + else if (constants.tabMovement == -1) { + // create an element to focus on, add it before currentFocus, focus it, then remove it + let focusTemp = document.createElement('div'); + focusTemp.setAttribute('tabindex', '0'); + constants.main_container.before(focusTemp); + focusTemp.focus(); + focusTemp.remove(); + } +} + +/** + * Removes all events, global variables, and chart components associated with Maidr, resetting it to its uninitialized state. + */ +function DestroyMaidr() { + // chart cleanup + if (constants.chartType == 'bar' || constants.chartType == 'hist') { + // deselect, if possible + if (typeof plot.DeselectAll === 'function') { + plot.DeselectAll(); + } + if (typeof plot.UnSelectPrevious === 'function') { + plot.UnSelectPrevious(); + } + } + + // remove events + for (let i = 0; i < constants.events.length; i++) { + if (Array.isArray(constants.events[i][0])) { + for (let j = 0; j < constants.events[i][0].length; j++) { + constants.events[i][0][j].removeEventListener( + constants.events[i][1], + constants.events[i][2] + ); + } + } else { + constants.events[i][0].removeEventListener( + constants.events[i][1], + constants.events[i][2] + ); + } + } + for (let i = 0; i < constants.postLoadEvents.length; i++) { + if (Array.isArray(constants.postLoadEvents[i][0])) { + for (let j = 0; j < constants.postLoadEvents[i][0].length; j++) { + constants.postLoadEvents[i][0][j].removeEventListener( + constants.postLoadEvents[i][1], + constants.postLoadEvents[i][2] + ); + } + } else { + constants.postLoadEvents[i][0].removeEventListener( + constants.postLoadEvents[i][1], + constants.postLoadEvents[i][2] + ); + } + } + constants.events = []; + constants.postLoadEvents = []; + + // remove global vars + constants.chartId = null; + constants.chartType = null; + constants.tabMovement = null; + DestroyChartComponents(); + + window.review = null; + window.display = null; + window.control = null; + window.plot = null; + window.audio = null; + window.singleMaidr = null; +} +/** + * Kills autoplay if the user presses the control key (Windows) or command key (Mac). + * @param {KeyboardEvent} e - The keyboard event object. + */ +function KillAutoplayEvent(e) { + // Kill autoplay + if ( + constants.isMac + ? e.key == 'Meta' || e.key == 'ContextMenu' + : e.key == 'Control' + ) { + // ctrl (either one) + constants.KillAutoplay(); + } +} + +/** + * Adds all events and post load events to the DOM elements. + * Assumes that all events are in constants.events and all post load events are in constants.postLoadEvents. + */ +function SetEvents() { + // add all events + for (let i = 0; i < constants.events.length; i++) { + if (Array.isArray(constants.events[i][0])) { + for (let j = 0; j < constants.events[i][0].length; j++) { + constants.events[i][0][j].addEventListener( + constants.events[i][1], + constants.events[i][2] + ); + } + } else { + constants.events[i][0].addEventListener( + constants.events[i][1], + constants.events[i][2] + ); + } + } + // add all post load events + // we delay adding post load events just a tick so the chart loads + setTimeout(function () { + for (let i = 0; i < constants.postLoadEvents.length; i++) { + if (Array.isArray(constants.postLoadEvents[i][0])) { + for (let j = 0; j < constants.postLoadEvents[i][0].length; j++) { + constants.postLoadEvents[i][0][j].addEventListener( + constants.postLoadEvents[i][1], + constants.postLoadEvents[i][2] + ); + } + } else { + constants.postLoadEvents[i][0].addEventListener( + constants.postLoadEvents[i][1], + constants.postLoadEvents[i][2] + ); + } + } + }, 100); +} + +/** + * Initializes the html chart components needed, such as: + * - Creates a structure with a main container and a chart container + * - Resets the parents from just chart to main container > chart container > chart + * - Creates a braille input + * - Creates an info aria live region + * - Creates announcements aria live region + * - Creates a review mode form field + * - Also sets the constants associated with these elements + * + */ +function CreateChartComponents() { + // init html stuff. aria live regions, braille input, etc + + // core chart + let chart = document.getElementById(singleMaidr.id); + + // we create a structure with a main container, and a chart container + let main_container = document.createElement('div'); + main_container.id = constants.main_container_id; + let chart_container = document.createElement('div'); + chart_container.id = constants.chart_container_id; + // update parents from just chart, to main container > chart container > chart + chart.parentNode.replaceChild(main_container, chart); + main_container.appendChild(chart); + chart.parentNode.replaceChild(chart_container, chart); + chart_container.appendChild(chart); + chart.focus(); // focus used to be on chart and just got lost as we rearranged, so redo focus + + constants.chart = chart; + constants.chart_container = chart_container; + constants.main_container = main_container; + + // braille input, pre sibling of chart container + constants.chart_container.insertAdjacentHTML( + 'beforebegin', + '\n' + ); + + // info aria live, next sibling of chart container + constants.chart_container.insertAdjacentHTML( + 'afterend', + '
\n
\n

\n

\n
\n' + ); + + // announcements, next sibling of info + document + .getElementById(constants.info_id) + .insertAdjacentHTML( + 'afterend', + '
\n' + ); + + // end chime audio element + // TODO: external media file is not working as a stereo audio so commenting this out until we find a solution + // document + // .getElementById(constants.info_id) + // .insertAdjacentHTML( + // 'afterend', + // '' + // ); + + // review mode form field + document + .getElementById(constants.info_id) + .insertAdjacentHTML( + 'beforebegin', + '' + ); + + // some tweaks + constants.chart_container.setAttribute('role', 'application'); + + // set page elements + constants.brailleContainer = document.getElementById( + constants.braille_container_id + ); + constants.brailleInput = document.getElementById(constants.braille_input_id); + constants.infoDiv = document.getElementById(constants.info_id); + constants.announceContainer = document.getElementById( + constants.announcement_container_id + ); + constants.nonMenuFocus = constants.chart; + constants.endChime = document.getElementById(constants.end_chime_id); + constants.review_container = document.querySelector( + '#' + constants.review_id_container + ); + constants.review = document.querySelector('#' + constants.review_id); + + // help menu + window.menu = new Menu(); + + // LLM question modal + window.chatLLM = new ChatLLM(); + + // Description modal + window.description = new Description(); // developement on hold +} + +/** + * Removes all chart components from the DOM and resets related variables to null. + * @function + * @name DestroyChartComponents + * @returns {void} + */ +function DestroyChartComponents() { + // remove html stuff + if (constants.chart_container != null) { + if (constants.chart != null) { + if (constants.chart_container.parentNode != null) { + constants.chart_container.parentNode.replaceChild( + constants.chart, + constants.chart_container + ); + } + } + constants.chart_container.remove(); + } + if (constants.brailleContainer != null) { + constants.brailleContainer.remove(); + } + if (constants.infoDiv != null) { + constants.infoDiv.remove(); + } + if (constants.announceContainer != null) { + constants.announceContainer.remove(); + } + if (constants.endChime != null) { + constants.endChime.remove(); + } + if (constants.review_container != null) { + constants.review_container.remove(); + } + + if (typeof menu != 'undefined') { + menu.Destroy(); + } + if (typeof description != 'undefined') { + description.Destroy(); + } + + constants.chart = null; + constants.chart_container = null; + constants.brailleContainer = null; + constants.brailleInput = null; + constants.infoDiv = null; + constants.announceContainer = null; + constants.endChime = null; + constants.review_container = null; + menu = null; + description = null; +} diff --git a/react-maidr/src/js/lineplot.js b/react-maidr/src/js/lineplot.js new file mode 100644 index 00000000..b3e9b0aa --- /dev/null +++ b/react-maidr/src/js/lineplot.js @@ -0,0 +1,254 @@ +/** + * Represents a line plot. + * @class + */ +class LinePlot { + /** + * Creates a new instance of LinePlot. + * @constructor + */ + constructor() { + this.SetLineLayer(); + this.SetAxes(); + this.UpdateConstants(); + } + + /** + * Sets the line layer for the chart. + */ + SetLineLayer() { + let elements; + if ('selector' in singleMaidr) { + elements = document.querySelectorAll(singleMaidr.selector); + } else if ('elements' in singleMaidr) { + elements = singleMaidr.elements; + } + + if (elements) { + this.plotLine = elements[elements.length - 1]; + + let pointCoords = this.GetPointCoords(); + let pointValues = this.GetPoints(); + + this.chartLineX = pointCoords[0]; // x coordinates of curve + this.chartLineY = pointCoords[1]; // y coordinates of curve + + this.pointValuesX = pointValues[0]; // actual values of x + this.pointValuesY = pointValues[1]; // actual values of y + + this.curveMinY = Math.min(...this.pointValuesY); + this.curveMaxY = Math.max(...this.pointValuesY); + } + } + + /** + * Updates the constants for the line plot. + * This includes the minimum and maximum x and y values, the autoplay rate, and the default speed. + */ + UpdateConstants() { + constants.minX = 0; + constants.maxX = singleMaidr.data.length - 1; + constants.minY = singleMaidr.data.reduce( + (min, item) => (item.y < min ? item.y : min), + singleMaidr.data[0].y + ); + constants.maxY = singleMaidr.data.reduce( + (max, item) => (item.y > max ? item.y : max), + singleMaidr.data[0].y + ); + + constants.autoPlayRate = Math.min( + Math.ceil(constants.AUTOPLAY_DURATION / (constants.maxX + 1)), + constants.MAX_SPEED + ); + constants.DEFAULT_SPEED = constants.autoPlayRate; + if (constants.autoPlayRate < constants.MIN_SPEED) { + constants.MIN_SPEED = constants.autoPlayRate; + } + } + + /** + * Returns an array of x and y coordinates of each point in the plot line. + * @returns {Array>} An array of x and y coordinates of each point in the plot line. + */ + GetPointCoords() { + let svgLineCoords = [[], []]; + // lineplot SVG containing path element instead of polyline + if (this.plotLine instanceof SVGPathElement) { + // Assuming the path data is in the format "M x y L x y L x y L x y" + const pathD = this.plotLine.getAttribute('d'); + const regex = /[ML]\s*(-?\d+(\.\d+)?)\s+(-?\d+(\.\d+)?)/g; + + let match; + while ((match = regex.exec(pathD)) !== null) { + svgLineCoords[0].push(match[1]); // x coordinate + svgLineCoords[1].push(match[3]); // y coordinate + } + } else { + let points = this.plotLine.getAttribute('points').split(' '); + for (let i = 0; i < points.length; i++) { + if (points[i] !== '') { + let point = points[i].split(','); + svgLineCoords[0].push(point[0]); + svgLineCoords[1].push(point[1]); + } + } + } + return svgLineCoords; + } + + /** + * Returns an array of x and y points from the data object in singleMaidr. + * @returns {Array>|undefined} An array containing two arrays of numbers representing x and y points respectively, or undefined if data is not defined. + */ + GetPoints() { + let x_points = []; + let y_points = []; + + let data; + if ('data' in singleMaidr) { + data = singleMaidr.data; + } + if (typeof data !== 'undefined') { + for (let i = 0; i < data.length; i++) { + x_points.push(data[i].x); + y_points.push(data[i].y); + } + return [x_points, y_points]; + } else { + return; + } + } + + /** + * Sets the x and y group labels and title for the line plot based on the axes and title properties of the singleMaidr object. + */ + SetAxes() { + let legendX = ''; + let legendY = ''; + if ('axes' in singleMaidr) { + // legend labels + if (singleMaidr.axes.x) { + if (singleMaidr.axes.x.label) { + if (legendX == '') { + legendX = singleMaidr.axes.x.label; + } + } + } + if (singleMaidr.axes.y) { + if (singleMaidr.axes.y.label) { + if (legendY == '') { + legendY = singleMaidr.axes.y.label; + } + } + } + } + + this.plotLegend = { + x: legendX, + y: legendY, + }; + + // title + this.title = ''; + if ('labels' in singleMaidr) { + if ('title' in singleMaidr.labels) { + this.title = singleMaidr.labels.title; + } + } + if (this.title == '') { + if ('title' in singleMaidr) { + this.title = singleMaidr.title; + } + } + + // subtitle + if ('labels' in singleMaidr) { + if ('subtitle' in singleMaidr.labels) { + this.subtitle = singleMaidr.labels.subtitle; + } + } + // caption + if ('labels' in singleMaidr) { + if ('caption' in singleMaidr.labels) { + this.caption = singleMaidr.labels.caption; + } + } + } + + /** + * Plays a tone using the audio object. + */ + PlayTones() { + audio.playTone(); + } +} + +/** + * Represents a point on a chart. + * @class + */ +class Point { + /** + * Creates a new instance of Point. + * @constructor + */ + constructor() { + this.x = plot.chartLineX[0]; + this.y = plot.chartLineY[0]; + } + + /** + * Clears the existing points and updates the x and y coordinates for the chart line. + * @async + * @returns {Promise} + */ + async UpdatePoints() { + await this.ClearPoints(); + this.x = plot.chartLineX[position.x]; + this.y = plot.chartLineY[position.x]; + } + + /** + * Clears existing points, updates the points, and prints a new point on the chart. + * @async + * @returns {Promise} + */ + async PrintPoints() { + await this.ClearPoints(); + await this.UpdatePoints(); + const svgns = 'http://www.w3.org/2000/svg'; + var point = document.createElementNS(svgns, 'circle'); + point.setAttribute('id', 'highlight_point'); + point.setAttribute('cx', this.x); + point.setAttribute('cy', this.y); + point.setAttribute('r', 1.75); + point.setAttribute( + 'style', + 'fill:' + constants.colorSelected + ';stroke:' + constants.colorSelected + ); + constants.chart.appendChild(point); + } + + /** + * Removes all highlighted points from the line plot. + * @async + */ + async ClearPoints() { + let points = document.getElementsByClassName('highlight_point'); + for (let i = 0; i < points.length; i++) { + document.getElementsByClassName('highlight_point')[i].remove(); + } + if (document.getElementById('highlight_point')) + document.getElementById('highlight_point').remove(); + } + + /** + * Clears the points, updates them, and prints them to the display. + */ + UpdatePointDisplay() { + this.ClearPoints(); + this.UpdatePoints(); + this.PrintPoints(); + } +} diff --git a/react-maidr/src/js/scatterplot.js b/react-maidr/src/js/scatterplot.js new file mode 100644 index 00000000..b2b01526 --- /dev/null +++ b/react-maidr/src/js/scatterplot.js @@ -0,0 +1,835 @@ +/** + * A class representing a scatter plot. + * @class + */ +class ScatterPlot { + /** + * Creates a new Scatterplot object. + * @constructor + */ + constructor() { + this.prefix = this.GetPrefix(); + this.SetScatterLayer(); + this.SetLineLayer(); + this.SetAxes(); + this.svgScaler = this.GetSVGScaler(); + } + + /** + * Sets the x and y group labels and title for the scatterplot based on the data in singleMaidr. + */ + SetAxes() { + this.x_group_label = ''; + this.y_group_label = ''; + this.title = ''; + if ('labels' in singleMaidr) { + if ('x' in singleMaidr.labels) { + this.x_group_label = singleMaidr.labels.x; + } + if ('y' in singleMaidr.labels) { + this.y_group_label = singleMaidr.labels.y; + } + if ('title' in singleMaidr.labels) { + this.title = singleMaidr.labels.title; + } + } + if ('axes' in singleMaidr) { + if ('x' in singleMaidr.axes) { + if (this.x_group_label == '') { + this.x_group_label = singleMaidr.axes.x.label; + } + } + if ('y' in singleMaidr.axes) { + if (this.y_group_label == '') { + this.y_group_label = singleMaidr.axes.y.label; + } + } + } + if ('title' in singleMaidr) { + if (this.title == '') { + this.title = singleMaidr.title; + } + } + } + + /** + * Sets the scatter layer for the chart. + * @function + * @memberof scatterplot + * @returns {void} + */ + SetScatterLayer() { + // initially set as smooth layer (layer 2), if possible + let elIndex = this.GetElementIndex('point'); // check if we have it + if (elIndex != -1) { + if ('selector' in singleMaidr) { + this.plotPoints = document.querySelectorAll( + singleMaidr.selector[elIndex] + ); + } else if ('elements' in singleMaidr) { + this.plotPoints = singleMaidr.elements[elIndex]; + } + } else if (singleMaidr.type == 'point') { + if ('selector' in singleMaidr) { + this.plotPoints = document.querySelectorAll(singleMaidr.selector); + } else if ('elements' in singleMaidr) { + this.plotPoints = singleMaidr.elements; + } + } + let svgPointCoords = this.GetSvgPointCoords(); + let pointValues = this.GetPointValues(); + + this.chartPointsX = svgPointCoords[0]; // x coordinates of points + this.chartPointsY = svgPointCoords[1]; // y coordinates of points + + this.x = pointValues[0]; // actual values of x + this.y = pointValues[1]; // actual values of y + + // for sound weight use + this.points_count = pointValues[2]; // number of each points + this.max_count = pointValues[3]; + } + + /** + * Sets the plot line layer for the scatterplot. + */ + SetLineLayer() { + // layer = 2, smooth layer (from singleMaidr types) + let elIndex = this.GetElementIndex('smooth'); // check if we have it + if (elIndex != -1) { + if ('selector' in singleMaidr) { + this.plotLine = document.querySelectorAll( + singleMaidr.selector[elIndex] + )[0]; + } else if ('elements' in singleMaidr) { + this.plotLine = singleMaidr.elements[elIndex][0]; + } + } else if (singleMaidr.type == 'smooth') { + if ('selector' in singleMaidr) { + this.plotLine = document.querySelectorAll(singleMaidr.selector)[0]; + } else if ('elements' in singleMaidr) { + this.plotLine = singleMaidr.elements; + } + } + let svgLineCoords = this.GetSvgLineCoords(); + let smoothCurvePoints = this.GetSmoothCurvePoints(); + + this.chartLineX = svgLineCoords[0]; // x coordinates of curve + this.chartLineY = svgLineCoords[1]; // y coordinates of curve + + this.curveX = smoothCurvePoints[0]; // actual values of x + this.curvePoints = smoothCurvePoints[1]; // actual values of y + + // if there is only point layer, then curvePoints will be empty + if (this.curvePoints && this.curvePoints.length > 0) { + this.curveMinY = Math.min(...this.curvePoints); + this.curveMaxY = Math.max(...this.curvePoints); + } else { + this.curveMinY = Number.MAX_VALUE; + this.curveMaxY = Number.MIN_VALUE; + } + this.gradient = this.GetGradient(); + } + + /** + * Returns an array of X and Y coordinates of the plot points. + * @returns {Array>} An array of X and Y coordinates. + */ + GetSvgPointCoords() { + let points = new Map(); + + if (this.plotPoints) { + for (let i = 0; i < this.plotPoints.length; i++) { + let x; + let y; + + // extract x, y coordinates based on the SVG element type + if (this.plotPoints[i] instanceof SVGPathElement) { + let pathD = this.plotPoints[i].getAttribute('d'); + let regex = /M\s*(-?\d+(\.\d+)?)\s+(-?\d+(\.\d+)?)/g; + + let match = regex.exec(pathD); + x = parseFloat(match[1]); + y = parseFloat(match[3]); + } else { + x = parseFloat(this.plotPoints[i].getAttribute(this.prefix + 'x')); // .toFixed(1); + y = parseFloat(this.plotPoints[i].getAttribute(this.prefix + 'y')); + } + + if (!points.has(x)) { + points.set(x, new Set([y])); + } else { + points.get(x).add(y); + } + } + } else if ([].concat(singleMaidr.type).includes('point')) { + // pull from data instead + let elIndex = this.GetElementIndex('point'); + let xyFormat = this.GetDataXYFormat(elIndex); + let data; + if (elIndex > -1) { + data = singleMaidr.data[elIndex]; + } else { + data = singleMaidr.data; + } + let x = []; + let y = []; + if (xyFormat == 'array') { + if ('x' in data) { + x = data['x']; + } + if ('y' in data) { + y = data['y']; + } + } else if (xyFormat == 'object') { + for (let i = 0; i < data.length; i++) { + let xValue = data[i]['x']; + let yValue = data[i]['y']; + x.push(xValue); + y.push(yValue); + } + } + for (let i = 0; i < x.length; i++) { + let xValue = x[i]; + let yValue = y[i]; + if (!points.has(xValue)) { + points.set(xValue, new Set([yValue])); + } else { + points.get(xValue).add(yValue); + } + } + } + + points = new Map( + [...points].sort(function (a, b) { + return a[0] - b[0]; + }) + ); + + points.forEach(function (value, key) { + points[key] = Array.from(value).sort(function (a, b) { + return a - b; + }); + }); + + let X = [...points.keys()]; + + let Y = []; + for (let i = 0; i < X.length; i++) { + Y.push(points[X[i]]); + } + + return [X, Y]; + } + + /** + * Returns the index of the specified element in the singleMaidr object. + * @param {string} elementName - The name of the element to search for. + * @returns {number} - The index of the element in the singleMaidr object, or -1 if not found. + */ + GetElementIndex(elementName = 'point') { + let elIndex = -1; + if ('type' in singleMaidr && Array.isArray(singleMaidr.type)) { + elIndex = singleMaidr.type.indexOf(elementName); + } + return elIndex; + } + + /** + * Determines the format of the data at the given index and returns it as either an object or an array. + * @param {number} dataIndex - The index of the data to check. + * @returns {string} - The format of the data as either 'object' or 'array'. + */ + GetDataXYFormat(dataIndex) { + // detect if data is in form [{x: 1, y: 2}, {x: 2, y: 3}] (object) or {x: [1, 2], y: [2, 3]]} (array) + let data; + if (dataIndex > -1) { + data = singleMaidr.data[dataIndex]; + } else { + data = singleMaidr.data; + } + + let xyFormat; + if (Array.isArray(data)) { + xyFormat = 'object'; + } else { + xyFormat = 'array'; + } + + return xyFormat; + } + + /** + * Returns an array of the X and Y scales of the first SVG element containing the plot points. + * @returns {Array} An array containing the X and Y scales of the first SVG element containing the plot points. + */ + GetSVGScaler() { + let scaleX = 1; + let scaleY = 1; + // start with some square (first), look all the way up the parents to the svg, and record any scales along the way + + // but first, are we even in an svg that can be scaled? + let isSvg = false; + if (this.plotPoints) { + let element = this.plotPoints[0]; // a random start, may as well be the first + while (element) { + if (element.tagName.toLowerCase() == 'body') { + break; + } + if (element.tagName && element.tagName.toLowerCase() === 'svg') { + isSvg = true; + } + element = element.parentNode; + } + + if (isSvg) { + let element = this.plotPoints[0]; // a random start, may as well be the first + while (element) { + if (element.tagName.toLowerCase() == 'body') { + break; + } + if (element.getAttribute('transform')) { + let transform = element.getAttribute('transform'); + let match = transform.match( + /scale\((-?\d+(\.\d+)?),\s*(-?\d+(\.\d+)?)\)/ + ); + if (match) { + if (!isNaN(match[1])) { + scaleX *= parseFloat(match[1]); + } + if (!isNaN(match[3])) { + scaleY *= parseFloat(match[3]); + } + } + } + element = element.parentNode; + } + } + } + + return [scaleX, scaleY]; + } + + /** + * Returns a prefix based on the element type. + * This helps manipulate svg stuff, as the attribute info is slightly different depending on svg source + * @returns {string} The prefix. + */ + GetPrefix() { + let pointIndex = this.GetElementIndex('point'); + + let element = null; + if (pointIndex != -1) { + if ('selector' in singleMaidr) { + element = document.querySelectorAll( + singleMaidr.selector[pointIndex] + )[0]; + } else if ('elements' in singleMaidr) { + element = singleMaidr.elements[pointIndex][0]; + } + } else if (singleMaidr.type == 'point') { + if ('selector' in singleMaidr) { + element = document.querySelectorAll(singleMaidr.selector)[0]; + } else if ('elements' in singleMaidr) { + element = singleMaidr.elements[0]; + } + } + let prefix = ''; + if (element && element.tagName.toLowerCase() === 'circle') { + prefix = 'c'; + } + return prefix; + } + + /** + * Retrieves x and y values from data and returns them in a specific format. + * @returns {Array} An array containing X, Y, points_count, and max_points. + */ + GetPointValues() { + let points = new Map(); // keep track of x and y values + + let X = []; + let Y = []; + let points_count = []; + let max_points; + + // prepare to fetch data from the correct index in the correct format + let elIndex = this.GetElementIndex('point'); + let xyFormat = this.GetDataXYFormat(elIndex); + + let data; + if (elIndex > -1) { + // data comes directly as an array, in a 'point' layer, so fetch directly as an array from that index + data = singleMaidr.data[elIndex]; + } else if (singleMaidr.type == 'point') { + // data comes directly as an array, no 'point' layer, so fetch directly as an array + data = singleMaidr.data; + } + if (typeof data !== 'undefined') { + // assuming we got something, loop through the data and extract the x and y values + let xValues = []; + let yValues = []; + if (xyFormat == 'array') { + if ('x' in data) { + xValues = data['x']; + } + if ('y' in data) { + yValues = data['y']; + } + } else if (xyFormat == 'object') { + for (let i = 0; i < data.length; i++) { + let x = data[i]['x']; + let y = data[i]['y']; + xValues.push(x); + yValues.push(y); + } + } + + for (let i = 0; i < xValues.length; i++) { + let x = xValues[i]; + let y = yValues[i]; + if (!points.has(x)) { + points.set(x, new Map([[y, 1]])); + } else { + if (points.get(x).has(y)) { + let mapy = points.get(x); + mapy.set(y, mapy.get(y) + 1); + } else { + points.get(x).set(y, 1); + } + } + } + + constants.minX = 0; + constants.maxX = [...new Set(xValues)].length; + + constants.minY = Math.min(...yValues); + constants.maxY = Math.max(...yValues); + + constants.autoPlayRate = Math.min( + Math.ceil(constants.AUTOPLAY_DURATION / (constants.maxX + 1)), + constants.MAX_SPEED + ); + constants.DEFAULT_SPEED = constants.autoPlayRate; + if (constants.autoPlayRate < constants.MIN_SPEED) { + constants.MIN_SPEED = constants.autoPlayRate; + } + + points = new Map( + [...points].sort(function (a, b) { + return a[0] - b[0]; + }) + ); + + points.forEach(function (value, key) { + points[key] = Array.from(value).sort(function (a, b) { + return a[0] - b[0]; + }); + }); + + for (const [x_val, y_val] of points) { + X.push(x_val); + let y_arr = []; + let y_count = []; + for (const [y, count] of y_val) { + y_arr.push(y); + y_count.push(count); + } + Y.push(y_arr.sort()); + points_count.push(y_count); + } + max_points = Math.max(...points_count.map((a) => Math.max(...a))); + } + + return [X, Y, points_count, max_points]; + } + + /** + * Plays a run of tones for the point layer or a single tone for the best fit smooth layer. + * @function + * @memberof ClassName + * @returns {void} + */ + PlayTones() { + // kill the previous separate-points play before starting the next play + if (constants.sepPlayId) { + constants.KillSepPlay(); + } + if (constants.chartType == 'point') { + // point layer + // we play a run of tones + position.z = 0; + constants.sepPlayId = setInterval( + function () { + // play this tone + audio.playTone(); + + // and then set up for the next one + position.z += 1; + + // and kill if we're done + if (position.z + 1 > plot.y[position.x].length) { + constants.KillSepPlay(); + position.z = -1; + } + }, + constants.sonifMode == 'on' ? constants.autoPlayPointsRate : 0 + ); // play all tones at the same time + } else if (constants.chartType == 'smooth') { + // best fit smooth layer + audio.playTone(); + } + } + + /** + * Extracts the x and y coordinates from the point attribute of a polyline SVG element. + * @returns {Array>} An array containing two arrays: the x-coordinates and y-coordinates. + */ + GetSvgLineCoords() { + let x_points = []; + let y_points = []; + + if (this.plotLine) { + // scatterplot SVG containing path element instead of polyline + if (this.plotLine instanceof SVGPathElement) { + // Assuming the path data is in the format "M x y L x y L x y L x y" + const pathD = this.plotLine.getAttribute('d'); + const regex = /[ML]\s*(-?\d+(\.\d+)?)\s+(-?\d+(\.\d+)?)/g; + + let match; + while ((match = regex.exec(pathD)) !== null) { + x_points.push(match[1]); // x coordinate + y_points.push(match[3]); // y coordinate + } + } else if (this.plotLine instanceof SVGPolylineElement) { + // extract all the y coordinates from the point attribute of polyline + let str = this.plotLine.getAttribute('points'); + let coords = str.split(' '); + + for (let i = 0; i < coords.length; i++) { + let coord = coords[i].split(','); + x_points.push(parseFloat(coord[0])); + y_points.push(parseFloat(coord[1])); + } + } + } else if ([].concat(singleMaidr.type).includes('smooth')) { + // fetch from data instead + let elIndex = this.GetElementIndex('smooth'); + let xyFormat = this.GetDataXYFormat(elIndex); + let data; + if (elIndex > -1) { + data = singleMaidr.data[elIndex]; + } else { + data = singleMaidr.data + } + if (xyFormat == 'object') { + for (let i = 0; i < data.length; i++) { + x_points.push(data[i]['x']); + y_points.push(data[i]['y']); + } + } else if (xyFormat == 'array') { + if ('x' in data) { + x_points = data['x']; + } + if ('y' in data) { + y_points = data['y']; + } + } + } + + return [x_points, y_points]; + } + + /** + * Returns an array of x and y points for a smooth curve. + * @returns {Array>|undefined} An array of x and y points or undefined if data is not defined. + */ + GetSmoothCurvePoints() { + let x_points = []; + let y_points = []; + + let elIndex = this.GetElementIndex('smooth'); + let xyFormat = this.GetDataXYFormat(elIndex); + + let data; + if (elIndex > -1) { + // data comes directly as an array, in a 'smooth' layer, so fetch directly as an array from that index + data = singleMaidr.data[elIndex]; + } else if (singleMaidr.type == 'smooth') { + // data comes directly as an array, no 'smooth' layer, so fetch directly as an array + data = singleMaidr.data; + } + if (typeof data !== 'undefined') { + if (xyFormat == 'object') { + for (let i = 0; i < data.length; i++) { + x_points.push(data[i]['x']); + y_points.push(data[i]['y']); + } + } else if (xyFormat == 'array') { + if ('x' in data) { + x_points = data['x']; + } + if ('y' in data) { + y_points = data['y']; + } + } + } + + return [x_points, y_points]; + } + + /** + * Calculates the absolute gradient between each pair of consecutive points on the curve. + * @returns {Array} An array of absolute gradients between each pair of consecutive points on the curve, followed by the string 'end'. + */ + GetGradient() { + let gradients = []; + + for (let i = 0; i < this.curvePoints.length - 1; i++) { + let abs_grad = Math.abs( + (this.curvePoints[i + 1] - this.curvePoints[i]) / + (this.curveX[i + 1] - this.curveX[i]) + ).toFixed(3); + gradients.push(abs_grad); + } + + gradients.push('end'); + + return gradients; + } + + /** + * Returns whether or not we have elements / selectors for the given type. + * @param {string} type - The type of element to check for. eg, 'point' or 'smooth'. + * @returns {boolean} - True if we have elements / selectors for the given type, false otherwise. + * @function + * @memberof scatterplot + */ + GetRectStatus(type) { + let elIndex = this.GetElementIndex(type); + if (elIndex > -1) { + if ('selector' in singleMaidr) { + return !!singleMaidr.selector[elIndex]; + } else if ('elements' in singleMaidr) { + return !!singleMaidr.elements[elIndex]; + } + } else { + if ('selector' in singleMaidr) { + return !!singleMaidr.selector; + } else if ('elements' in singleMaidr) { + return !!singleMaidr.elements; + } + } + } +} + +/** + * Represents a point in Layer 0 of a scatterplot chart. + * @class + */ +class Layer0Point { + // circles + + /** + * Creates a new Layer0Point object. + * @constructor + */ + constructor() { + if ([].concat(singleMaidr.type).includes('point')) { + this.x = plot.chartPointsX[0]; + this.y = plot.chartPointsY[0]; + this.strokeWidth = 1.35; + this.hasRect = plot.GetRectStatus('point'); + this.circleIndex = []; + } + } + + /** + * Clears the points and updates the chart with new data. + * @returns {Promise} + */ + async UpdatePoints() { + await this.ClearPoints(); + this.x = plot.chartPointsX[position.x]; + this.y = plot.chartPointsY[position.x]; + // find which circles we're on by searching for the x value + this.circleIndex = []; + for (let j = 0; j < this.y.length; j++) { + for (let i = 0; i < plot.plotPoints.length; i++) { + let x; + let y; + + if (plot.plotPoints[i] instanceof SVGPathElement) { + const pathD = plot.plotPoints[i].getAttribute('d'); + const regex = /M\s*(-?\d+(\.\d+)?)\s+(-?\d+(\.\d+)?)/g; + + let match = regex.exec(pathD); + x = parseFloat(match[1]); + y = parseFloat(match[3]); + } else if ( + plot.plotPoints[i] instanceof SVGUseElement || + plot.plotPoints[i] instanceof SVGCircleElement + ) { + x = plot.plotPoints[i].getAttribute(plot.prefix + 'x'); + y = plot.plotPoints[i].getAttribute(plot.prefix + 'y'); + } + + if (x == this.x && y == this.y[j]) { + this.circleIndex.push(i); + break; + } + } + } + } + + /** + * Clears the points, updates them, and prints them on the scatterplot. + * @async + * @function + * @returns {Promise} + */ + async PrintPoints() { + await this.ClearPoints(); + await this.UpdatePoints(); + for (let i = 0; i < this.circleIndex.length; i++) { + const svgns = 'http://www.w3.org/2000/svg'; + var point = document.createElementNS(svgns, 'circle'); + point.setAttribute('class', 'highlight_point'); + point.setAttribute('cx', this.x); + if (plot.svgScaler[1] == -1) { + point.setAttribute( + 'cy', + constants.chart.getBoundingClientRect().height - this.y[i] + ); + } else { + let y; + + if (plot.plotPoints[this.circleIndex[i]] instanceof SVGPathElement) { + const pathD = plot.plotPoints[this.circleIndex[i]].getAttribute('d'); + const regex = /M\s*(-?\d+(\.\d+)?)\s+(-?\d+(\.\d+)?)/g; + + let match = regex.exec(pathD); + y = parseFloat(match[3]); + } else if ( + plot.plotPoints[this.circleIndex[i]] instanceof SVGUseElement || + plot.plotPoints[this.circleIndex[i]] instanceof SVGCircleElement + ) { + y = plot.plotPoints[this.circleIndex[i]].getAttribute(plot.prefix + 'y'); + } + + point.setAttribute('cy', y); + } + point.setAttribute('r', 3.95); + point.setAttribute('stroke', constants.colorSelected); + point.setAttribute('stroke-width', this.strokeWidth); + point.setAttribute('fill', constants.colorSelected); + constants.chart.appendChild(point); + } + } + + /** + * Clears all highlighted points from the scatterplot. + * @async + */ + async ClearPoints() { + if (document.getElementById('highlight_point')) + document.getElementById('highlight_point').remove(); + let points = document.getElementsByClassName('highlight_point'); + for (let i = 0; i < points.length; i++) { + document.getElementsByClassName('highlight_point')[i].remove(); + } + } + + /** + * Clears the points, updates them, and prints them to the screen. + */ + UpdatePointDisplay() { + this.ClearPoints(); + this.UpdatePoints(); + this.PrintPoints(); + } +} + +/** + * Represents a point in Layer 1 of a scatterplot chart. + */ +class Layer1Point { + // smooth segments + + /** + * Creates a new Layer1Point object. + * @constructor + */ + constructor() { + if ([].concat(singleMaidr.type).includes('smooth')) { + this.x = plot.chartLineX[0]; + this.y = plot.chartLineY[0]; + this.strokeWidth = 1.35; + this.hasRect = plot.GetRectStatus('point'); + } + } + + /** + * Clears the existing points and updates the x and y coordinates of the chart line. + * @async + * @returns {Promise} + */ + async UpdatePoints() { + await this.ClearPoints(); + this.x = plot.chartLineX[positionL1.x]; + this.y = plot.chartLineY[positionL1.x]; + } + + /** + * Clears the points, updates them, and prints them on the scatterplot. + * @async + * @returns {Promise} + */ + async PrintPoints() { + await this.ClearPoints(); + await this.UpdatePoints(); + const svgns = 'http://www.w3.org/2000/svg'; + var point = document.createElementNS(svgns, 'circle'); + point.setAttribute('id', 'highlight_point'); + point.setAttribute('cx', this.x); + if (plot.svgScaler[1] == -1) { + point.setAttribute( + 'cy', + constants.chart.getBoundingClientRect().height - this.y + ); + } else { + point.setAttribute('cy', this.y); + } + point.setAttribute('r', 3.95); + point.setAttribute('stroke', constants.colorSelected); + point.setAttribute('stroke-width', this.strokeWidth); + point.setAttribute('fill', constants.colorSelected); + if (plot.svgScaler[1] == -1) { + constants.chart.appendChild(point); + } else { + plot.plotLine.parentNode.appendChild(point); + } + } + + /** + * Removes all highlighted points from the scatterplot. + * @async + */ + async ClearPoints() { + let points = document.getElementsByClassName('highlight_point'); + for (let i = 0; i < points.length; i++) { + document.getElementsByClassName('highlight_point')[i].remove(); + } + if (document.getElementById('highlight_point')) + document.getElementById('highlight_point').remove(); + } + + /** + * Clears the points, updates them, and prints them to the screen. + */ + UpdatePointDisplay() { + this.ClearPoints(); + this.UpdatePoints(); + this.PrintPoints(); + } +} diff --git a/react-maidr/src/js/segmented.js b/react-maidr/src/js/segmented.js new file mode 100644 index 00000000..a2a11e4e --- /dev/null +++ b/react-maidr/src/js/segmented.js @@ -0,0 +1,326 @@ +/** + * Represents a segmented chart. + * @class + */ +class Segmented { + /** + * Creates a new Segmented object. + * @constructor + */ + constructor() { + // initialize variables level, data, and elements + let fill = null; + let data = null; + let elements = null; + if ('axes' in singleMaidr) { + //axes.x.level + if ('x' in singleMaidr.axes) { + if ('level' in singleMaidr.axes.x) { + this.level = singleMaidr.axes.x.level; + } + } else if ('y' in singleMaidr.axes) { + if ('level' in singleMaidr.axes.y) { + this.level = singleMaidr.axes.y.level; + } + } + // axes.fill + if ('fill' in singleMaidr.axes) { + if ('level' in singleMaidr.axes.fill) { + this.fill = singleMaidr.axes.fill.level; + } + } + } + if ('data' in singleMaidr) { + data = singleMaidr.data; + } + if ('selector' in singleMaidr) { + elements = document.querySelectorAll(singleMaidr.selector); + } else if ('elements' in singleMaidr) { + elements = singleMaidr.elements; + } + + // gracefull failure: must have level + fill + data, elements optional + if (elements == null) { + logError.LogAbsentElement('elements'); + constants.hasRect = 0; + } + if (data) { + if (this.fill) { + this.fill = this.fill.reverse(); // typically fill is in reverse order + } + let dataAndELements = this.ParseData(data, elements); + this.plotData = dataAndELements[0]; + this.elements = dataAndELements[1]; + } else { + console.log( + 'Segmented chart missing level, fill, or data. Unable to create chart.' + ); + return; + } + + // column labels, both legend and tick + let legendX = ''; + let legendY = ''; + if ('axes' in singleMaidr) { + // legend labels + if (singleMaidr.axes.x) { + if (singleMaidr.axes.x.label) { + legendX = singleMaidr.axes.x.label; + } + } + if (singleMaidr.axes.y) { + if (singleMaidr.axes.y.label) { + legendY = singleMaidr.axes.y.label; + } + } + } + // labels override axes + if ('labels' in singleMaidr) { + if ('x' in singleMaidr.labels) { + legendX = singleMaidr.labels.x; + } + if ('y' in singleMaidr.labels) { + legendY = singleMaidr.labels.y; + } + } + + this.plotLegend = { + x: legendX, + y: legendY, + }; + + // title + this.title = ''; + if ('labels' in singleMaidr) { + if ('title' in singleMaidr.labels) { + this.title = singleMaidr.labels.title; + } + } + if (this.title == '') { + if ('title' in singleMaidr) { + this.title = singleMaidr.title; + } + } + + // subtitle + if ('labels' in singleMaidr) { + if ('subtitle' in singleMaidr.labels) { + this.subtitle = singleMaidr.labels.subtitle; + } + } + // caption + if ('labels' in singleMaidr) { + if ('caption' in singleMaidr.labels) { + this.caption = singleMaidr.labels.caption; + } + } + + // set the max and min values for the plot + this.SetMaxMin(); + + // create summary and all levels + this.CreateSummaryLevel(); + this.CreateAllLevel(); + + this.autoplay = null; + } + + /** + * Parses data and elements to create a full 2D array of data using level and fill. + * @param {Array} data - The data to parse. + * @param {Array} [elements=null] - The elements to parse. + * @returns {Array} An array containing the parsed plot data and plot elements. + */ + ParseData(data, elements = null) { + let plotData = []; + let plotElements = []; + + // override and kill elements if not same length as data + if (elements) { + if (elements.length != data.length) { + plotElements = null; + } + } else { + plotElements = null; + } + + // create a full 2d array of data using level and fill + for (let i = 0; i < this.level.length; i++) { + for (let j = 0; j < this.fill.length; j++) { + // loop through data, find matching level and fill, assign value + // if no match, assign null + for (let k = 0; k < data.length; k++) { + // init + if (!plotData[i]) { + plotData[i] = []; + if (plotElements != null) { + if (!plotElements[i]) { + plotElements[i] = []; + } + } + } + if (!plotData[i][j]) { + plotData[i][j] = 0; + if (plotElements != null) { + if (!plotElements[i][j]) { + plotElements[i][j] = null; + } + } + } + // set actual values + if (data[k].x == this.level[i] && data[k].fill == this.fill[j]) { + plotData[i][j] = data[k].y; + if (elements) { + plotElements[i][j] = elements[k]; + } + break; + } + } + } + } + + return [plotData, plotElements]; + } + + /** + * Creates another y level that is the sum of all the other levels. + */ + CreateSummaryLevel() { + for (let i = 0; i < this.plotData.length; i++) { + let sum = 0; + for (let j = 0; j < this.plotData[i].length; j++) { + sum += this.plotData[i][j]; + } + this.plotData[i].push(sum); + } + + this.fill.push('Sum'); + } + + /** + * Creates another y level that plays all the other levels separately. + */ + CreateAllLevel() { + for (let i = 0; i < this.plotData.length; i++) { + let all = []; + for (let j = 0; j < this.fill.length; j++) { + if (this.fill[j] != 'Sum') { + all.push(this.plotData[i][j]); + } + } + this.plotData[i].push(all); + } + + this.fill.push('All'); + } + + /** + * Plays tones based on the plot data at the current position. + * If sonifMode is 'on', it plays a run of tones. If sonifMode is 'same', it plays all tones at once. + */ + PlayTones() { + if (Array.isArray(this.plotData[position.x][position.y])) { + if (constants.sonifMode == 'on') { + // we play a run of tones + position.z = 0; + constants.KillSepPlay(); + constants.sepPlayId = setInterval( + function () { + // play this tone + audio.playTone(); + + // and then set up for the next one + position.z += 1; + + // and kill if we're done + if (!Array.isArray(plot.plotData[position.x][position.y])) { + constants.KillSepPlay(); + position.z = -1; + } else if ( + position.z + 1 > + plot.plotData[position.x][position.y].length + ) { + constants.KillSepPlay(); + position.z = -1; + } + }, + constants.sonifMode == 'on' ? constants.autoPlayPointsRate : 0 + ); + } else { + // sonifMode == 'same', so we play all at once + + // adjust these volumes by amplitude, min 50% max 125% + let volMin = Math.min(...this.plotData[position.x][position.y]); + let volMax = Math.max(...this.plotData[position.x][position.y]); + for (let i = 0; i < this.plotData[position.x][position.y].length; i++) { + position.z = i; + let vol = audio.SlideBetween( + this.plotData[position.x][position.y][i], + volMin, + volMax, + constants.combinedVolMin, + constants.combinedVolMax + ); + audio.playTone({ volScale: vol }); + } + } + } else { + audio.playTone(); + } + } + + /** + * Sets the maximum and minimum values for the y-axis based on the data in `singleMaidr.data`. + * Also sets the maximum x value, auto play rate, default speed, and minimum speed. + */ + SetMaxMin() { + for (let i = 0; i < singleMaidr.data.length; i++) { + if (i == 0) { + constants.maxY = singleMaidr.data[i].y; + constants.minY = singleMaidr.data[i].y; + } else { + if (singleMaidr.data[i].y > constants.maxY) { + constants.maxY = singleMaidr.data[i].y; + } + if (singleMaidr.data[i].y < constants.minY) { + constants.minY = singleMaidr.data[i].y; + } + } + } + constants.maxX = this.level.length; + constants.autoPlayRate = Math.min( + Math.ceil(constants.AUTOPLAY_DURATION / (constants.maxX + 1)), + constants.MAX_SPEED + ); + constants.DEFAULT_SPEED = constants.autoPlayRate; + if (constants.autoPlayRate < constants.MIN_SPEED) { + constants.MIN_SPEED = constants.autoPlayRate; + } + } + + /** + * Selects an element and changes its color to a better one. + */ + Select() { + this.UnSelectPrevious(); + if (this.elements) { + this.activeElement = this.elements[position.x][position.y]; + if (this.activeElement) { + this.activeElementColor = this.activeElement.style.fill; + let newColor = constants.GetBetterColor(this.activeElementColor); + this.activeElement.style.fill = newColor; + } + } + } + + /** + * Unselects the previously selected element by resetting its fill color to the active element color. + * Also sets the active element to null. + */ + UnSelectPrevious() { + if (this.activeElement) { + this.activeElement.style.fill = this.activeElementColor; + this.activeElement = null; + } + } +} diff --git a/react-maidr/src/main.jsx b/react-maidr/src/main.jsx new file mode 100644 index 00000000..5cc59919 --- /dev/null +++ b/react-maidr/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) diff --git a/react-maidr/vite.config.js b/react-maidr/vite.config.js new file mode 100644 index 00000000..5a33944a --- /dev/null +++ b/react-maidr/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/yarn.lock b/yarn.lock index 2c1f17a8..4ee87533 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4869,7 +4869,7 @@ jiti@^1.19.1: resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.0.tgz#7c97f8fe045724e136a397f7340475244156105d" integrity sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q== -js-tokens@^4.0.0: +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== @@ -5463,6 +5463,13 @@ lodash@^4.17.15, lodash@^4.17.21, lodash@^4.17.4: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +loose-envify@^1.1.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + lru-cache@^10.0.1, "lru-cache@^9.1.1 || ^10.0.0": version "10.1.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.1.0.tgz#2098d41c2dc56500e6c88584aa656c84de7d0484" @@ -6803,6 +6810,13 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react@^18.2.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" + integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== + dependencies: + loose-envify "^1.1.0" + read-cmd-shim@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/read-cmd-shim/-/read-cmd-shim-4.0.0.tgz#640a08b473a49043e394ae0c7a34dd822c73b9bb" @@ -7566,7 +7580,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -7584,6 +7598,15 @@ string-width@^1.0.1, string-width@^1.0.2: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -7612,7 +7635,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -7626,6 +7649,13 @@ strip-ansi@^3.0.0, strip-ansi@^3.0.1: dependencies: ansi-regex "^2.0.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -8366,7 +8396,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -8383,6 +8413,15 @@ wrap-ansi@^2.0.0: string-width "^1.0.1" strip-ansi "^3.0.1" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"