From 13af5f71c3c0956d040df7f44efd17242076718b Mon Sep 17 00:00:00 2001 From: Shuhao Wu Date: Mon, 5 Jan 2026 23:35:40 -0500 Subject: [PATCH 1/2] Chart component (tests unreviewed) --- .github/agents/build-and-test.agent.md.bak | 33 ++ AGENTS.md | 34 +- CURRENT_TASK.md | 18 +- docs/development/frontend-architecture.md | 13 +- frontend/package-lock.json | 573 +++++++++++++++++++++ frontend/package.json | 5 +- frontend/src/v2/chart.test.ts | 481 ++++++++++++++++- frontend/src/v2/chart.ts | 404 +++++++++++++++ frontend/src/v2/streamer.ts | 12 +- frontend/vitest.config.ts | 2 + 10 files changed, 1530 insertions(+), 45 deletions(-) create mode 100644 .github/agents/build-and-test.agent.md.bak create mode 100644 frontend/src/v2/chart.ts diff --git a/.github/agents/build-and-test.agent.md.bak b/.github/agents/build-and-test.agent.md.bak new file mode 100644 index 0000000..08ae457 --- /dev/null +++ b/.github/agents/build-and-test.agent.md.bak @@ -0,0 +1,33 @@ +--- +description: Builds, test, lint the project. +name: Build and Test +tools: ['execute/getTerminalOutput', 'execute/runInTerminal', 'read/readFile'] +model: GPT-5 mini (copilot) +--- + +You're an agent that builds, test, and lints the project. Your job is to run commands to build, test, lint the project and return the results to the user. Do NOT edit any code, only run the commands according to the following instructions and return the outputs requested by the user. + +Use these commands EXACTLY, character by character, as they will be auto approved and saves valuable time! For non-conventional tasks, you can use your own commands but explain why and get user approval so use it sparingly. + +For requests that requires multiple commands, run commands until all tasks requested are done. + +You should ALWAYS run a lint FIRST before the build and test to ensure code style is correct. Return the results of this if it fails and explain it is a lint failure. + +## Commands + +If the user requests for the backend to be built, tested, and/or linted, pick from one of these commands depending on the tasks given: + +- Build the backend binary with the frontend: `make prod` +- Lint the backend code: `make backend-lint` +- Run all backend tests: `make backend-test` +- Run all the backend tests with coverage: `make backend-test COVERAGE=1` +- If the user wants to run a subset of the tests: `go test -run ./...` (not auto approved but may be useful). + +If the user requests for the frontend to be built, tested, and/or linted, pick from one of these commands depending on the tasks given. Make sure to first `cd frontend` before running these commands. It is possible that the terminal is already in the `frontend` directory, so check with `pwd` first: + +- Build the frontend code: `npm run build` +- Type Check and lint the frontend code and apply any fixes: `npm run lint:write` +- Run all frontend tests: `npm run test` +- Run all frontend tests with coverage: `npm run test:coverage` +- Run the frontend benchmarks: `npm run benchmark` +- Run a specific frontend test: `npm run test -- -t ` (not auto approved but may be useful). diff --git a/AGENTS.md b/AGENTS.md index 22fbc85..163fefc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,24 +14,25 @@ Read these if not sure about data flow or architecture. ## Build and test instructions -Try to ALWAYS use these exact commands as they are auto approved. +VERY VERY IMPORTANT TO FOLLOW THE FOLLOWING: Use these EXACT commands CHARACTER BY CHARACTER as they will be auto approved and do not use other commands as they will need approval and will be slower. If you do not use one of the commands below, ALWAYS explain why you need the deviation before running it. -### Backend +Backend instructions -- Building the binary: `make prod` (see Makefile if necessary) which creates `build/wesplot`. -- Lint the code with `make lint` -- Run all tests: `make test` -- Run all tests and check for code coverage: `make test COVERAGE=1` -- Run these commands separately (not with && or ;) so they can be auto approved as auto approval relies on exact matches. +- Build the backend binary with the frontend: `make prod` +- Lint the backend code: `make backend-lint` +- Run all backend tests: `make backend-test` +- Run all the backend tests with coverage: `make backend-test COVERAGE=1` +- If the user wants to run a subset of the tests: `go test -run ./...` (not auto approved but may be useful). -### Frontend +Frontend instructions (need to `cd frontend` first, which might already been done in the open terminal). -First run `cd frontend` to get into the frontend directory (the terminal might already be in there, check with `pwd`). Run this as it is auto approved. - -- Run test: `npm run test` -- Run benchmark: `npm run benchmark` -- Run test with coverage: `npm run test:coverage` -- Run lint: `npm run lint:write` +- Build the frontend code: `npm run build` +- Type Check and lint the frontend code and apply any fixes: `npm run lint:write` +- Run all frontend tests: `npm run test` + - DO NOT PASS `--silent` to this command as its output has already been minimized. +- Run all frontend tests with coverage: `npm run test:coverage` +- Run the frontend benchmarks: `npm run benchmark` +- Run a specific frontend test: `npm run test -- -t ` (not auto approved but may be useful). ## Coding rules @@ -44,9 +45,10 @@ All rules below applies unless told otherwise by user prompts. ### Test policy - Add sufficient test coverage for code changes. Think of all the possible edge cases and comment inline in the tests on why these cases matter. -- Code coverage should be 100% (but some error paths might be near-impossible to test, so they can be skipped). Check with `make test COVERAGE=1`. -- Unit tests should be in `_test.go` as per normal Go convention. +- Code coverage should be 100% (but some error paths might be near-impossible to test, so they can be skipped). +- Unit tests should be in `_test.go` as per normal Go convention, `.test.ts` for frontend, and `.bench.ts` for frontend benchmarks. - Test failures should be accompanied with good error messages for debugging. +- Follow test commands exactly as stated above where possible. ### Completion policy diff --git a/CURRENT_TASK.md b/CURRENT_TASK.md index 784fb6b..84eab8f 100644 --- a/CURRENT_TASK.md +++ b/CURRENT_TASK.md @@ -87,15 +87,15 @@ This is a **major frontend rewrite** to support multi-series with independent X - [x] Update documentation to match implementation (buffers created on-demand) - [x] Implement a test application in `src/v2/main.ts` and `v2.html` that shows the streamer working and streams the data into a table. - - [ ] **Step 5:** Implement Chart component - - [ ] Consider approaches for testing visual elements for AI agents - - [ ] Create `src/v2/chart.ts` - - [ ] Define Chart API (constructor options: series IDs, display config) - - [ ] Integrate with Chart.js for rendering - - [ ] Handle data updates from Streamer callbacks - - [ ] Support multiple series with independent X values - - [ ] Implement efficient data appending (no full re-renders) - - [ ] Add basic configuration (colors, labels, etc.) + - [x] **Step 5:** Implement Chart component + - [x] Create `src/v2/chart.ts` + - [x] Define Chart API (constructor options: series IDs, display config) + - [x] Integrate with Chart.js for rendering + - [x] Handle data updates from Streamer callbacks + - [x] Support multiple series with independent X values + - [x] Implement efficient data appending (no full re-renders) + - [x] Add basic configuration (colors, labels, etc.) + - [ ] Add ways to remove a series from the ChartJS plot if it is removed. - [ ] **Step 6:** Create v2 main application - [ ] Create `src/v2/main.ts` diff --git a/docs/development/frontend-architecture.md b/docs/development/frontend-architecture.md index 2efde90..f8cf939 100644 --- a/docs/development/frontend-architecture.md +++ b/docs/development/frontend-architecture.md @@ -141,10 +141,11 @@ interface ChartConfig { container: HTMLElement; // DOM element to render chart into seriesIds: number[]; // Which series to display (series identifiers) metadata: Metadata; // Stream metadata (from onMetadata) - windowSize?: number; // Max points to display (rolling window) colors?: string[]; // Series colors } +**Note:** The rolling window is enforced by the Streamer via per-series CircularBuffers; charts should use the supplied segments as-is. + class Chart { constructor(container: HTMLElement, config: ChartConfig); @@ -161,9 +162,6 @@ class Chart { // incoming message. render(): void; - // Handle stream end - handleStreamEnd(error: boolean, message: string): void; - // Update chart options (title, labels, etc.) updateOptions(options: Partial): void; @@ -197,7 +195,6 @@ class Chart { 5. **Lifecycle:** - `update()`: Called by Streamer (via callback) to attach the latest buffer segments to the chart and increment a generation counter (cheap, allocation-free). - `render()`: Called on `requestAnimationFrame`; checks the generation counter and only performs Chart.js data conversion and drawing when the generation changed since the last render. - - `handleStreamEnd()`: Called when stream ends (clean termination or error) - `destroy()`: Clean up Chart.js instance and buffers **Data Flow:** @@ -249,15 +246,13 @@ function main() { }, onStreamEnd: (error, message) => { - // 5. Handle stream termination - if (chart) { - chart.handleStreamEnd(error, message); - } + // 5. Handle stream termination at the application level if (error) { console.error('Stream error:', message); } }, + onError: (error) => { console.error('Streamer error:', error); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6352fbd..2ccb891 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,12 +20,55 @@ "@types/lodash-es": "^4.17.12", "@types/node": "^25.0.3", "@vitest/coverage-v8": "^4.0.16", + "jsdom": "^27.4.0", "rollup-plugin-visualizer": "^6.0.5", "typescript": "^5.9.3", "vite": "^7.3.0", "vitest": "^4.0.16" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.30", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz", + "integrity": "sha512-9CnlMCI0LmCIq0olalQqdWrJHPzm0/tw3gzOA9zJSgvFX7Xau3D24mAGa4BtwxwY69nsuJW6kQqqCzf/mEcQgg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -249,6 +292,143 @@ "node": ">=14.21.3" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.22.tgz", + "integrity": "sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -691,6 +871,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.8.0.tgz", + "integrity": "sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@exodus/crypto": "^1.0.0-rc.4" + }, + "peerDependenciesMeta": { + "@exodus/crypto": { + "optional": true + } + } + }, "node_modules/@fortawesome/fontawesome-free": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.1.0.tgz", @@ -1251,6 +1449,16 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1299,6 +1507,16 @@ "js-tokens": "^9.0.1" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1380,6 +1598,50 @@ "dev": true, "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.6.tgz", + "integrity": "sha512-legscpSpgSAeGEe0TNcai97DKt9Vd9AsAdOL7Uoetb52Ar/8eJm3LIa39qpv8wWzLFlNG4vVvppQM+teaMPj3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -1409,6 +1671,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", @@ -1426,6 +1695,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1567,6 +1849,19 @@ "node": ">=8" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -1574,6 +1869,34 @@ "dev": true, "license": "MIT" }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -1600,6 +1923,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -1674,12 +2004,63 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lodash-es": { "version": "4.17.22", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1718,6 +2099,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1773,6 +2161,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1829,6 +2230,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -1839,6 +2250,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.54.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", @@ -1913,6 +2334,19 @@ } } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -2008,6 +2442,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2052,6 +2493,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2228,6 +2715,53 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2263,6 +2797,45 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7252bef..50c17bf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,14 +11,15 @@ "test:coverage": "vitest run --coverage", "test:watch": "vitest watch", "benchmark": "vitest --run bench", - "lint": "biome check", - "lint:write": "biome check --write" + "lint": "tsc && biome check", + "lint:write": "tsc && biome check --write" }, "devDependencies": { "@biomejs/biome": "2.3.10", "@types/lodash-es": "^4.17.12", "@types/node": "^25.0.3", "@vitest/coverage-v8": "^4.0.16", + "jsdom": "^27.4.0", "rollup-plugin-visualizer": "^6.0.5", "typescript": "^5.9.3", "vite": "^7.3.0", diff --git a/frontend/src/v2/chart.test.ts b/frontend/src/v2/chart.test.ts index b0c345b..181219a 100644 --- a/frontend/src/v2/chart.test.ts +++ b/frontend/src/v2/chart.test.ts @@ -1,7 +1,482 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChartOptions } from "./chart.js"; +import { Chart, InteractionMode } from "./chart.js"; + +// Mock Chart.js +vi.mock("chart.js", () => { + class MockChart { + data = { + datasets: [], + }; + options = { + plugins: { + title: { text: "" }, + }, + scales: { + x: {}, + y: {}, + }, + }; + update = vi.fn(); + destroy = vi.fn(); + static register = vi.fn(); + } + + return { + Chart: MockChart, + CategoryScale: {}, + LinearScale: {}, + TimeScale: {}, + PointElement: {}, + LineElement: {}, + Title: {}, + Tooltip: {}, + Legend: {}, + }; +}); + +vi.mock("chartjs-plugin-zoom", () => ({ + default: {}, +})); describe("Chart", () => { - it("should be a placeholder test", () => { - expect(true).toBe(true); + let container: HTMLElement; + let options: ChartOptions; + let chart: Chart; + + // Mock requestAnimationFrame + let rafCallbacks: ((timestamp: number) => void)[] = []; + let rafId = 0; + + beforeEach(() => { + // Reset RAF mock + rafCallbacks = []; + rafId = 0; + + // Mock requestAnimationFrame + global.requestAnimationFrame = vi.fn((callback) => { + const id = ++rafId; + rafCallbacks.push(callback); + return id; + }); + + // Mock cancelAnimationFrame + global.cancelAnimationFrame = vi.fn((_id) => { + // Simple implementation: just mark as cancelled + }); + + // Create container + container = document.createElement("div"); + document.body.appendChild(container); + + // Create options (translated from app-level metadata) + options = { + title: "Test Chart", + columns: ["Series 0", "Series 1", "Series 2"], + xLabel: "X Axis", + yLabel: "Y Axis", + xIsTimestamp: false, + xMin: undefined, + xMax: undefined, + yMin: undefined, + yMax: undefined, + }; + }); + + afterEach(() => { + if (chart) { + chart.destroy(); + } + document.body.removeChild(container); + vi.clearAllMocks(); + }); + + describe("constructor", () => { + it("should create chart with configured series", () => { + chart = new Chart({ + container, + seriesIds: [0, 1], + options, + }); + + expect(container.querySelector("canvas")).not.toBeNull(); + }); + + it("should not schedule RAF on construction", () => { + chart = new Chart({ + container, + seriesIds: [0, 1], + options, + }); + + expect(requestAnimationFrame).not.toHaveBeenCalled(); + }); + }); + + describe("update", () => { + beforeEach(() => { + chart = new Chart({ + container, + seriesIds: [0], + options, + }); + }); + + it("should schedule RAF when no RAF is pending", () => { + const xSegments = [new Float64Array([1, 2, 3])]; + const ySegments = [new Float64Array([10, 20, 30])]; + + chart.update(0, xSegments, ySegments); + + expect(requestAnimationFrame).toHaveBeenCalledTimes(1); + }); + + it("should not schedule multiple RAFs for multiple updates", () => { + const xSegments = [new Float64Array([1, 2, 3])]; + const ySegments = [new Float64Array([10, 20, 30])]; + + chart.update(0, xSegments, ySegments); + chart.update(0, xSegments, ySegments); + chart.update(0, xSegments, ySegments); + + expect(requestAnimationFrame).toHaveBeenCalledTimes(1); + }); + + it("should log error for mismatched segment lengths", () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const xSegments = [new Float64Array([1, 2])]; + const ySegments = [ + new Float64Array([10, 20, 30]), + new Float64Array([40, 50]), + ]; + + chart.update(0, xSegments, ySegments); + + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("xSegments and ySegments length mismatch"), + ); + consoleError.mockRestore(); + }); + + it("should handle new series dynamically", () => { + const xSegments = [new Float64Array([1, 2])]; + const ySegments = [new Float64Array([10, 20])]; + + // Update series that wasn't in initial config + chart.update(5, xSegments, ySegments); + + expect(requestAnimationFrame).toHaveBeenCalledTimes(1); + }); + }); + + describe("render", () => { + beforeEach(() => { + chart = new Chart({ + container, + seriesIds: [0], + options, + }); + }); + + it("should clear RAF state after rendering", () => { + const xSegments = [new Float64Array([1, 2, 3])]; + const ySegments = [new Float64Array([10, 20, 30])]; + + chart.update(0, xSegments, ySegments); + + // Execute RAF callback + expect(rafCallbacks.length).toBe(1); + rafCallbacks[0](performance.now()); + + // RAF should be cleared + expect(cancelAnimationFrame).not.toHaveBeenCalled(); + }); + + it("should schedule new RAF on subsequent update", () => { + const xSegments = [new Float64Array([1, 2, 3])]; + const ySegments = [new Float64Array([10, 20, 30])]; + + // First update and render + chart.update(0, xSegments, ySegments); + rafCallbacks[0](performance.now()); + + // Clear RAF callbacks + rafCallbacks = []; + vi.clearAllMocks(); + + // Second update should schedule new RAF + chart.update(0, xSegments, ySegments); + expect(requestAnimationFrame).toHaveBeenCalledTimes(1); + }); + + it("should convert single segment to Chart.js data", () => { + const xSegments = [new Float64Array([1, 2, 3])]; + const ySegments = [new Float64Array([10, 20, 30])]; + + chart.update(0, xSegments, ySegments); + rafCallbacks[0](performance.now()); + + // Check that data was converted (access via chart internals) + // We can't directly access Chart.js instance in tests, but we verify RAF was called + expect(rafCallbacks.length).toBe(1); + }); + + it("should convert wrapped segments (two segments)", () => { + const xSegments = [new Float64Array([1, 2]), new Float64Array([3, 4])]; + const ySegments = [ + new Float64Array([10, 20]), + new Float64Array([30, 40]), + ]; + + chart.update(0, xSegments, ySegments); + rafCallbacks[0](performance.now()); + + expect(rafCallbacks.length).toBe(1); + }); + + it("should handle NaN discontinuities", () => { + const xSegments = [new Float64Array([1, Number.NaN, 3])]; + const ySegments = [new Float64Array([10, Number.NaN, 30])]; + + chart.update(0, xSegments, ySegments); + rafCallbacks[0](performance.now()); + + expect(rafCallbacks.length).toBe(1); + }); + + it("should handle empty segments", () => { + const xSegments: Float64Array[] = []; + const ySegments: Float64Array[] = []; + + chart.update(0, xSegments, ySegments); + rafCallbacks[0](performance.now()); + + expect(rafCallbacks.length).toBe(1); + }); + + it("should not render if generation unchanged", () => { + const xSegments = [new Float64Array([1, 2, 3])]; + const ySegments = [new Float64Array([10, 20, 30])]; + + // First update and render + chart.update(0, xSegments, ySegments); + const mockChart = (chart as unknown as { _chart: { update: () => void } }) + ._chart; + rafCallbacks[0](performance.now()); + + // Clear mocks + vi.clearAllMocks(); + + // Manually trigger render again without update + rafCallbacks[0](performance.now()); + + // Chart.update should not be called again (no changes) + expect(mockChart.update).not.toHaveBeenCalled(); + }); + + it("should render multiple series", () => { + chart = new Chart({ + container, + seriesIds: [0, 1], + options, + }); + + const xSegments0 = [new Float64Array([1, 2])]; + const ySegments0 = [new Float64Array([10, 20])]; + const xSegments1 = [new Float64Array([3, 4])]; + const ySegments1 = [new Float64Array([30, 40])]; + + chart.update(0, xSegments0, ySegments0); + chart.update(1, xSegments1, ySegments1); + + rafCallbacks[0](performance.now()); + + expect(rafCallbacks.length).toBe(1); + }); + }); + + describe("destroy", () => { + it("should cancel pending RAF", () => { + chart = new Chart({ + container, + seriesIds: [0], + options, + }); + + const xSegments = [new Float64Array([1, 2, 3])]; + const ySegments = [new Float64Array([10, 20, 30])]; + + chart.update(0, xSegments, ySegments); + + // Destroy before RAF executes + chart.destroy(); + + expect(cancelAnimationFrame).toHaveBeenCalledWith(1); + }); + + it("should clean up Chart.js instance", () => { + chart = new Chart({ + container, + seriesIds: [0], + options, + }); + + const mockChart = ( + chart as unknown as { _chart: { destroy: () => void } } + )._chart; + + chart.destroy(); + + expect(mockChart.destroy).toHaveBeenCalled(); + }); + + it("should handle destroy without pending RAF", () => { + chart = new Chart({ + container, + seriesIds: [0], + options, + }); + + // No update, no RAF scheduled + expect(() => chart.destroy()).not.toThrow(); + }); + }); + + describe("updateOptions", () => { + beforeEach(() => { + chart = new Chart({ + container, + seriesIds: [0], + options, + }); + }); + + it("should update chart options", () => { + const mockChart = ( + chart as unknown as { + _chart: { + options: { + plugins: { title: { text: string } }; + }; + update: (mode: string) => void; + }; + } + )._chart; + + chart.updateOptions({ + title: "New Title", + xLabel: "New X", + yLabel: "New Y", + }); + + expect(mockChart.options.plugins.title.text).toBe("New Title"); + expect(mockChart.update).toHaveBeenCalledWith("none"); + }); + }); + + describe("zoom/pan API", () => { + beforeEach(() => { + chart = new Chart({ + container, + seriesIds: [0], + options, + }); + }); + + it("should be disabled by default", () => { + expect(chart.zoomPanMode).toBe(InteractionMode.None); + }); + + it("should enable zoom and disable pan", () => { + const mockChart = ( + chart as unknown as { _chart: { update: (m: string) => void } } + )._chart; + expect(mockChart.update).not.toHaveBeenCalled(); + + chart.zoomPanMode = InteractionMode.Zoom; + + expect(chart.zoomPanMode).toBe(InteractionMode.Zoom); + expect(mockChart.update).toHaveBeenCalledWith("none"); + }); + + it("should enable pan and disable zoom", () => { + const mockChart = ( + chart as unknown as { _chart: { update: (m: string) => void } } + )._chart; + + // Turn zoom on, then enable pan + chart.zoomPanMode = InteractionMode.Zoom; + expect(chart.zoomPanMode).toBe(InteractionMode.Zoom); + + chart.zoomPanMode = InteractionMode.Pan; + expect(chart.zoomPanMode).toBe(InteractionMode.Pan); + expect(mockChart.update).toHaveBeenCalledWith("none"); + }); + + it("should disable modes when asked", () => { + chart.zoomPanMode = InteractionMode.Zoom; + expect(chart.zoomPanMode).toBe(InteractionMode.Zoom); + + chart.zoomPanMode = InteractionMode.None; + expect(chart.zoomPanMode).toBe(InteractionMode.None); + }); + }); + + describe("edge cases", () => { + it("should handle large segments efficiently", () => { + chart = new Chart({ + container, + seriesIds: [0], + options, + }); + + const size = 10000; + const xSegments = [new Float64Array(size).fill(1)]; + const ySegments = [new Float64Array(size).fill(2)]; + + const start = performance.now(); + chart.update(0, xSegments, ySegments); + rafCallbacks[0](performance.now()); + const elapsed = performance.now() - start; + + // Should be reasonably fast (< 100ms for 10k points) + expect(elapsed).toBeLessThan(100); + }); + + it("should handle mixed NaN and valid values", () => { + chart = new Chart({ + container, + seriesIds: [0], + options, + }); + + const xSegments = [new Float64Array([1, Number.NaN, 3, 4, Number.NaN])]; + const ySegments = [ + new Float64Array([10, Number.NaN, 30, 40, Number.NaN]), + ]; + + chart.update(0, xSegments, ySegments); + rafCallbacks[0](performance.now()); + + expect(rafCallbacks.length).toBe(1); + }); + + it("should handle all NaN segments", () => { + chart = new Chart({ + container, + seriesIds: [0], + options, + }); + + const xSegments = [new Float64Array([Number.NaN, Number.NaN])]; + const ySegments = [new Float64Array([Number.NaN, Number.NaN])]; + + chart.update(0, xSegments, ySegments); + rafCallbacks[0](performance.now()); + + expect(rafCallbacks.length).toBe(1); + }); }); }); diff --git a/frontend/src/v2/chart.ts b/frontend/src/v2/chart.ts new file mode 100644 index 0000000..261b854 --- /dev/null +++ b/frontend/src/v2/chart.ts @@ -0,0 +1,404 @@ +import { + CategoryScale, + type ChartConfiguration, + Chart as ChartJS, + Legend, + LinearScale, + LineElement, + type Point, + PointElement, + TimeScale, + Title, + Tooltip, +} from "chart.js"; +import "chartjs-adapter-date-fns"; +import zoomPlugin from "chartjs-plugin-zoom"; +import type { ZoomPluginOptions } from "chartjs-plugin-zoom/types/options"; + +// Register Chart.js components +ChartJS.register( + CategoryScale, + LinearScale, + TimeScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + zoomPlugin, +); + +// Monotonic counter used to give each Chart instance a unique id for +// performance marks/measures. +let _nextChartId = 0; + +export interface ChartOptions { + title?: string; + xLabel?: string; + yLabel?: string; + xMin?: number; + xMax?: number; + yMin?: number; + yMax?: number; + columns?: string[]; + xIsTimestamp?: boolean; +} + +export interface ChartConfig { + container: HTMLElement; // DOM element to render chart into + seriesIds: number[]; // Which series to display (series identifiers) + options: ChartOptions; // Chart options + colors?: string[]; // Series colors +} + +interface SeriesState { + xSegments: Float64Array[]; + ySegments: Float64Array[]; + generation: number; // Incremented on each update + lastRenderedGeneration: number; // Last generation rendered + datasetIndex: number; // Index in Chart.js datasets array +} + +// Interaction mode: mutually exclusive states for user interaction +export enum InteractionMode { + None = "none", + Zoom = "zoom", + Pan = "pan", +} + +export class Chart { + private _chart: ChartJS; + private _chartId: number; + private _series: Map = new Map(); + private _options: ChartOptions; + private _renderScheduled = false; + private _rafId: number | null = null; + + // Stable zoom plugin options so callers can programmatically toggle zoom/pan. + private _zoomPluginOptions: ZoomPluginOptions = { + pan: { + enabled: false, + mode: "xy", + }, + zoom: { + wheel: { enabled: false }, + pinch: { enabled: false }, + drag: { enabled: false }, + mode: "x", + }, + }; + + // Track active mode (mutually exclusive). + private _activeMode: InteractionMode = InteractionMode.None; + + constructor(config: ChartConfig) { + this._options = config.options; + // Assign a unique id for this chart instance for performance spans + this._chartId = ++_nextChartId; + + // Create Chart.js configuration + const chartConfig: ChartConfiguration = { + type: "line", + data: { + datasets: [], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + scales: { + x: { + type: this._options.xIsTimestamp ? "time" : "linear", + title: { + display: true, + text: this._options.xLabel || "X", + }, + min: this._options.xMin, + max: this._options.xMax, + }, + y: { + title: { + display: true, + text: this._options.yLabel || "Y", + }, + min: this._options.yMin, + max: this._options.yMax, + }, + }, + plugins: { + title: { + display: true, + text: this._options.title || "Wesplot", + }, + legend: { + display: true, + position: "bottom", + }, + zoom: this._zoomPluginOptions, + }, + }, + }; + + // Create Chart.js instance + const canvas = document.createElement("canvas"); + config.container.appendChild(canvas); + this._chart = new ChartJS(canvas, chartConfig); + + // Initialize datasets for configured series + for (const seriesId of config.seriesIds) { + this._getOrCreateSeries(seriesId, config.colors); + } + } + + /** + * Update chart with new data (allocation-free, fast). + * Stores segment references and schedules a render if needed. + */ + update( + seriesId: number, + xSegments: Float64Array[], + ySegments: Float64Array[], + ): void { + // Validate input + if (xSegments.length !== ySegments.length) { + console.error( + `Chart.update: xSegments and ySegments length mismatch for series ${seriesId}`, + ); + return; + } + + const series = this._getOrCreateSeries(seriesId); + + // Store references (no copy) and bump generation + series.xSegments = xSegments; + series.ySegments = ySegments; + series.generation++; + + // Schedule render if not already scheduled + if (!this._renderScheduled) { + this._renderScheduled = true; + this._rafId = requestAnimationFrame(this._renderFrame); + } + } + + /** + * Render frame callback (called by requestAnimationFrame). + * Converts segments to Chart.js data and updates the chart. + */ + private _renderFrame = (_timestamp: number): void => { + const start = performance.now(); + + // Clear scheduling state + this._renderScheduled = false; + this._rafId = null; + + let changed = false; + + // Process each series + for (const series of this._series.values()) { + // Skip if already rendered + if (series.generation === series.lastRenderedGeneration) { + continue; + } + + // Convert segments to Chart.js data + const data = this._convertSegmentsToData( + series.xSegments, + series.ySegments, + ); + + // Update dataset + this._chart.data.datasets[series.datasetIndex].data = data; + series.lastRenderedGeneration = series.generation; + changed = true; + } + + // Update Chart.js if any dataset changed + if (changed) { + this._chart.update("none"); // 'none' mode = no animation + const end = performance.now(); + performance.measure(`render-${this._chartId}`, { start, end }); + } + }; + + // Convert Float64Array segments to Chart.js data format. + // Handles NaN sentinel values (discontinuities) by converting to null. + private _convertSegmentsToData( + xSegments: Float64Array[], + ySegments: Float64Array[], + ): (Point | null)[] { + const data: (Point | null)[] = []; + + // Process each segment pair + for (let segIdx = 0; segIdx < xSegments.length; segIdx++) { + const xSeg = xSegments[segIdx]; + const ySeg = ySegments[segIdx]; + + for (let i = 0; i < xSeg.length; i++) { + const x = xSeg[i]; + const y = ySeg[i]; + + // Convert NaN (discontinuity sentinel) to null for Chart.js + if (Number.isNaN(x) || Number.isNaN(y)) { + data.push(null); + } else { + data.push({ x, y }); + } + } + } + + return data; + } + + /** + * Get or create series state. + * Creates a new Chart.js dataset if series doesn't exist. + */ + private _getOrCreateSeries(seriesId: number, colors?: string[]): SeriesState { + let series = this._series.get(seriesId); + if (!series) { + // Create new dataset for this series + const datasetIndex = this._chart.data.datasets.length; + const label = this._options.columns?.[seriesId] || `Series ${seriesId}`; + const color = colors?.[seriesId] || this._getDefaultColor(seriesId); + + this._chart.data.datasets.push({ + label, + data: [], + borderColor: color, + backgroundColor: color, + pointRadius: 0, // No points for performance + borderWidth: 1, + spanGaps: false, // Don't connect across null values (discontinuities) + }); + + series = { + xSegments: [], + ySegments: [], + generation: 0, + lastRenderedGeneration: -1, + datasetIndex, + }; + this._series.set(seriesId, series); + } + return series; + } + + /** + * Get default color for a series. + */ + private _getDefaultColor(seriesId: number): string { + // Extended palette: 20+ visually distinct, colorblind-friendly colors + const colors = [ + "#3366CC", // blue + "#DC3912", // red + "#FF9900", // orange + "#109618", // green + "#990099", // purple + "#3B3EAC", // indigo + "#0099C6", // cyan + "#DD4477", // pink + "#66AA00", // lime + "#B82E2E", // dark red + "#316395", // steel blue + "#994499", // violet + "#22AA99", // teal + "#AAAA11", // olive + "#6633CC", // deep purple + "#E67300", // dark orange + "#8B0707", // maroon + "#329262", // dark green + "#5574A6", // muted blue + "#3B3EAC", // indigo (repeat for wraparound) + "#B77322", // brown + "#16D620", // bright green + "#B91383", // magenta + "#F4359E", // hot pink + "#9C5935", // sienna + "#A9C413", // yellow-green + "#2A778D", // blue-green + "#668D1C", // olive green + "#BEA413", // gold + "#0C5922", // forest green + ]; + return colors[seriesId % colors.length]; + } + + /** + * Update chart options. + */ + updateOptions(options: Partial): void { + if (options.title !== undefined && this._chart.options.plugins?.title) { + this._chart.options.plugins.title.text = options.title; + } + if (options.xLabel !== undefined && this._chart.options.scales?.x) { + // @ts-expect-error - Chart.js types are complex; title exists at runtime + this._chart.options.scales.x.title = { + display: true, + text: options.xLabel, + }; + } + if (options.yLabel !== undefined && this._chart.options.scales?.y) { + // @ts-expect-error - Chart.js types are complex; title exists at runtime + this._chart.options.scales.y.title = { + display: true, + text: options.yLabel, + }; + } + this._chart.update("none"); + } + + /** + * Get or set the active interaction mode. Use `InteractionMode.None` to clear. + */ + get zoomPanMode(): InteractionMode { + return this._activeMode; + } + + set zoomPanMode(mode: InteractionMode) { + // Set booleans based on the requested mode + this._activeMode = mode; + + const isZoom = this._activeMode === InteractionMode.Zoom; + const isPan = this._activeMode === InteractionMode.Pan; + + const zoom = this._zoomPluginOptions.zoom; + if (zoom) { + if (zoom.drag) { + zoom.drag.enabled = isZoom; + } + if (zoom.pinch) { + zoom.pinch.enabled = isZoom; + } + if (zoom.wheel) { + zoom.wheel.enabled = isZoom; + } + } + + const pan = this._zoomPluginOptions.pan; + if (pan) { + pan.enabled = isPan; + } + + // Apply change to Chart.js + this._chart.update("none"); + } + + /** + * Destroy chart and clean up resources. + */ + destroy(): void { + // Cancel pending RAF + if (this._rafId !== null) { + cancelAnimationFrame(this._rafId); + this._rafId = null; + this._renderScheduled = false; + } + + // Destroy Chart.js instance + this._chart.destroy(); + + // Clear series state + this._series.clear(); + } +} diff --git a/frontend/src/v2/streamer.ts b/frontend/src/v2/streamer.ts index c0b7976..cde1bb8 100644 --- a/frontend/src/v2/streamer.ts +++ b/frontend/src/v2/streamer.ts @@ -43,10 +43,10 @@ export class Streamer { private _streamEndReceived = false; constructor( - private wsUrl: string, - private windowSize: number, + private _wsUrl: string, + private _windowSize: number, ) { - if (!Number.isInteger(windowSize) || windowSize <= 0) { + if (!Number.isInteger(_windowSize) || _windowSize <= 0) { throw new Error("windowSize must be a positive integer"); } } @@ -81,7 +81,7 @@ export class Streamer { this._streamEndReceived = false; let connectionEstablished = false; - this._ws = new WebSocket(this.wsUrl); + this._ws = new WebSocket(this._wsUrl); this._ws.binaryType = "arraybuffer"; this._ws.onopen = () => { @@ -157,8 +157,8 @@ export class Streamer { // Create circular buffers for each series based on metadata const numSeries = metadata.WesplotOptions.Columns.length; for (let seriesId = 0; seriesId < numSeries; seriesId++) { - this._xBuffers.set(seriesId, new CircularBuffer(this.windowSize)); - this._yBuffers.set(seriesId, new CircularBuffer(this.windowSize)); + this._xBuffers.set(seriesId, new CircularBuffer(this._windowSize)); + this._yBuffers.set(seriesId, new CircularBuffer(this._windowSize)); } this._invokeMetadataCallbacks(metadata); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 1a0387a..7e6487d 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -2,10 +2,12 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + environment: "jsdom", coverage: { provider: "v8", include: ["src/v2/**/*.ts"], exclude: ["**/*.bench.ts"], }, + silent: "passed-only", }, }); From 64ee84bdd99c08073658b01b7263c179af1ecad0 Mon Sep 17 00:00:00 2001 From: Shuhao Wu Date: Tue, 6 Jan 2026 00:16:16 -0500 Subject: [PATCH 2/2] Added an UI to test. Chart test still unreviewed. --- CURRENT_TASK.md | 23 +- frontend/src/v2/chart.test.ts | 7 + frontend/src/v2/chart.ts | 63 ++++- frontend/src/v2/main.ts | 402 ++++++++++++++++++++++--------- frontend/src/v2/streamer.test.ts | 45 ++++ frontend/src/v2/streamer.ts | 9 + frontend/v2.html | 44 ++-- 7 files changed, 437 insertions(+), 156 deletions(-) diff --git a/CURRENT_TASK.md b/CURRENT_TASK.md index 84eab8f..3a9c3d9 100644 --- a/CURRENT_TASK.md +++ b/CURRENT_TASK.md @@ -95,27 +95,32 @@ This is a **major frontend rewrite** to support multi-series with independent X - [x] Support multiple series with independent X values - [x] Implement efficient data appending (no full re-renders) - [x] Add basic configuration (colors, labels, etc.) - - [ ] Add ways to remove a series from the ChartJS plot if it is removed. - [ ] **Step 6:** Create v2 main application - - [ ] Create `src/v2/main.ts` - - [ ] Initialize Streamer and connect to `/ws2` - - [ ] Create one or more Chart instances - - [ ] Register chart update callbacks with Streamer - - [ ] Handle connection lifecycle (connect, stream end, errors) + - [x] Create `src/v2/main.ts` + - [x] Initialize Streamer and connect to `/ws2` + - [x] Create one Chart instances + - [x] Register chart update callbacks with Streamer + - [ ] Handle connection lifecycle (connect, stream end, errors) on the application side with a tool bar at the bottom + +- [ ] **Step 7**: Allow v2 application to be interactive + - [ ] Implement ability to pause the stream + - [ ] Implement ability to configure the chart options + - [ ] Implement ability to add a second chart via a split + - [ ] Add ways to remove a series from the ChartJS plot if it is removed. -- [ ] **Step 7:** Add comprehensive tests for v2 components +- [ ] **Step 8:** Add comprehensive tests for v2 components - [ ] Integration tests for v2 app (end-to-end streaming) - [ ] Performance tests (memory usage, frame rates) - [ ] Ensure 100% coverage where possible -- [ ] **Step 8:** Update build and deployment +- [ ] **Step 9:** Update build and deployment - [ ] Update Makefile to build v2 frontend - [ ] Ensure v2.html is served by backend - [ ] Test v2 with live data streaming - [ ] Verify no regressions in original frontend -- [ ] **Step 9:** Final validation and documentation +- [ ] **Step 10:** Final validation and documentation - [ ] Run all tests (backend and frontend) - [ ] Update user documentation for v2 features - [ ] Mark Phase 2 complete diff --git a/frontend/src/v2/chart.test.ts b/frontend/src/v2/chart.test.ts index 181219a..4406fde 100644 --- a/frontend/src/v2/chart.test.ts +++ b/frontend/src/v2/chart.test.ts @@ -20,12 +20,19 @@ vi.mock("chart.js", () => { update = vi.fn(); destroy = vi.fn(); static register = vi.fn(); + static defaults = { + font: { size: 16 }, + elements: { + point: { borderWidth: 0, radius: 1 }, + }, + }; } return { Chart: MockChart, CategoryScale: {}, LinearScale: {}, + LineController: {}, TimeScale: {}, PointElement: {}, LineElement: {}, diff --git a/frontend/src/v2/chart.ts b/frontend/src/v2/chart.ts index 261b854..dc5daf9 100644 --- a/frontend/src/v2/chart.ts +++ b/frontend/src/v2/chart.ts @@ -4,6 +4,7 @@ import { Chart as ChartJS, Legend, LinearScale, + LineController, LineElement, type Point, PointElement, @@ -19,6 +20,7 @@ import type { ZoomPluginOptions } from "chartjs-plugin-zoom/types/options"; ChartJS.register( CategoryScale, LinearScale, + LineController, TimeScale, PointElement, LineElement, @@ -28,12 +30,17 @@ ChartJS.register( zoomPlugin, ); +ChartJS.defaults.font.size = 16; +ChartJS.defaults.elements.point.borderWidth = 0; +ChartJS.defaults.elements.point.radius = 1; + // Monotonic counter used to give each Chart instance a unique id for // performance marks/measures. let _nextChartId = 0; export interface ChartOptions { title?: string; + showTitle?: boolean; xLabel?: string; yLabel?: string; xMin?: number; @@ -42,6 +49,7 @@ export interface ChartOptions { yMax?: number; columns?: string[]; xIsTimestamp?: boolean; + yUnit?: string; } export interface ChartConfig { @@ -110,16 +118,19 @@ export class Chart { x: { type: this._options.xIsTimestamp ? "time" : "linear", title: { - display: true, - text: this._options.xLabel || "X", + display: !!this._options.xLabel, + text: this._options.xLabel, }, min: this._options.xMin, max: this._options.xMax, }, y: { title: { - display: true, - text: this._options.yLabel || "Y", + display: !!this._options.yLabel, + text: this._options.yLabel, + }, + ticks: { + callback: this._addUnits, }, min: this._options.yMin, max: this._options.yMax, @@ -127,7 +138,7 @@ export class Chart { }, plugins: { title: { - display: true, + display: this._options.showTitle ?? false, text: this._options.title || "Wesplot", }, legend: { @@ -331,20 +342,31 @@ export class Chart { if (options.title !== undefined && this._chart.options.plugins?.title) { this._chart.options.plugins.title.text = options.title; } + + if (options.showTitle !== undefined && this._chart.options.plugins?.title) { + this._chart.options.plugins.title.display = options.showTitle; + } + if (options.xLabel !== undefined && this._chart.options.scales?.x) { // @ts-expect-error - Chart.js types are complex; title exists at runtime this._chart.options.scales.x.title = { - display: true, + display: !!options.xLabel, text: options.xLabel, }; } + if (options.yLabel !== undefined && this._chart.options.scales?.y) { // @ts-expect-error - Chart.js types are complex; title exists at runtime this._chart.options.scales.y.title = { - display: true, + display: !!options.yLabel, text: options.yLabel, }; } + + if (options.yUnit !== undefined) { + this._options.yUnit = options.yUnit; + } + this._chart.update("none"); } @@ -401,4 +423,31 @@ export class Chart { // Clear series state this._series.clear(); } + + /** + * Add units to y-axis tick values. + */ + private _addUnits = ( + value: number | string, + _index: unknown, + _ticks: unknown, + ): string => { + let displayValue: string; + let displayUnit: string = ""; + + if (typeof value === "number") { + displayValue = value.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 3, + }); + } else { + displayValue = value; + } + + if (this._options.yUnit && this._options.yUnit.length > 0) { + displayUnit = ` ${this._options.yUnit}`; + } + + return `${displayValue}${displayUnit}`; + }; } diff --git a/frontend/src/v2/main.ts b/frontend/src/v2/main.ts index 0a2594a..cbc73cd 100644 --- a/frontend/src/v2/main.ts +++ b/frontend/src/v2/main.ts @@ -1,13 +1,17 @@ /** - * Wesplot v2 Test Application + * Wesplot v2 Main Application * - * This test app uses the Streamer to connect to /ws2 and displays - * streaming data in tables, one per series. + * Connects Streamer to Chart and manages UI state. */ +import { Chart, type ChartOptions } from "./chart.js"; import { Streamer } from "./streamer.js"; import type { Metadata } from "./types.js"; +// Import styles (CSS imports don't use .js extension in Vite) +// biome-ignore lint/correctness/useImportExtensions: CSS imports are handled by Vite bundler +import "../styles/app.css"; + let baseHost = location.host; if (import.meta.env.DEV) { // This does mean in development, we can only run one of these at a time, @@ -15,141 +19,299 @@ if (import.meta.env.DEV) { baseHost = `${location.hostname}:5274`; } -const statusDiv = document.getElementById("status") as HTMLDivElement; -const tablesContainer = document.getElementById( - "tables-container", -) as HTMLDivElement; +// DOM elements - retrieve on demand to ensure DOM is loaded +function getElement(id: string): T { + const element = document.getElementById(id); + if (!element) throw new Error(`${id} element not found`); + return element as T; +} + +/** + * Handles the status bar at the bottom of the page. + */ +class StatusBar { + private statusText: HTMLElement; + private liveIndicator: HTMLElement; + private notLiveIndicator: HTMLElement; + private errorIndicator: HTMLElement; + private pauseButton: HTMLButtonElement; + private chartWrapper: ChartWrapper; + + constructor(chartWrapper: ChartWrapper) { + this.chartWrapper = chartWrapper; + this.statusText = getElement("status-text"); + this.liveIndicator = getElement("live-indicator"); + this.notLiveIndicator = getElement("not-live-indicator"); + this.errorIndicator = getElement("error-indicator"); + this.pauseButton = getElement("btn-pause"); + this.setupPauseButton(); + } + + updateStatus( + message: string, + state: "connecting" | "live" | "paused" | "error" | "disconnected", + ): void { + this.statusText.textContent = message; -function updateStatus(message: string, color: string = "black") { - if (statusDiv) { - statusDiv.textContent = message; - statusDiv.style.color = color; + // Update indicators + this.liveIndicator.style.display = + state === "live" ? "inline-block" : "none"; + this.notLiveIndicator.style.display = + state === "connecting" || state === "paused" || state === "disconnected" + ? "inline-block" + : "none"; + this.errorIndicator.style.display = + state === "error" ? "inline-block" : "none"; + } + + private setupPauseButton(): void { + this.pauseButton.addEventListener("click", () => { + const isPaused = !this.chartWrapper.isPaused; + this.chartWrapper.setPaused(isPaused); + + const icon = this.pauseButton.querySelector("i"); + if (!icon) return; + + if (isPaused) { + icon.className = "fa-solid fa-play"; + icon.title = "Resume"; + this.updateStatus("Paused", "paused"); + } else { + icon.className = "fa-solid fa-pause"; + icon.title = "Pause"; + this.updateStatus("Streaming", "live"); + } + }); } } -const streamer = new Streamer(`ws://${baseHost}/ws2`, 1000); +/** + * Handles the chart and its title bar with action buttons. + */ +class ChartWrapper { + private panelContainer: HTMLElement; + private titleBar: HTMLElement | null = null; + private titleText: HTMLElement | null = null; + private chartArea: HTMLElement | null = null; + private chartContainer: HTMLElement | null = null; + private chart: Chart | null = null; + private _isPaused = false; + private screenshotBtn: HTMLButtonElement | null = null; + private resetZoomBtn: HTMLButtonElement | null = null; + private zoomBtn: HTMLButtonElement | null = null; + private panBtn: HTMLButtonElement | null = null; + private settingsBtn: HTMLButtonElement | null = null; + + constructor() { + this.panelContainer = getElement("panel"); + } + + setPaused(paused: boolean): void { + this._isPaused = paused; + } + + get isPaused(): boolean { + return this._isPaused; + } + + createChart(metadata: Metadata): void { + console.log("Metadata received:", metadata); -const tables: Map< - number, - { table: HTMLTableElement; tbody: HTMLTableSectionElement } -> = new Map(); + // Create title bar + this.createTitleBar(metadata.WesplotOptions.Title || "Wesplot v2"); -streamer.registerCallbacks({ - onMetadata: (metadata: Metadata) => { - updateStatus("Connected, received metadata", "green"); - console.log("Metadata:", metadata); + // Create chart area + this.chartArea = document.createElement("div"); + this.chartArea.className = "chart-area"; + this.panelContainer.appendChild(this.chartArea); - // Clear existing tables - tablesContainer.innerHTML = ""; - tables.clear(); + // Create chart container + this.chartContainer = document.createElement("div"); + this.chartContainer.className = "chartjs-container"; + this.chartArea.appendChild(this.chartContainer); - // Set container to flex layout for side-by-side tables - tablesContainer.style.display = "flex"; - tablesContainer.style.flexDirection = "row"; - tablesContainer.style.gap = "10px"; - tablesContainer.style.flexWrap = "wrap"; + // Create chart options from metadata + const chartOptions: ChartOptions = { + title: metadata.WesplotOptions.Title, + showTitle: false, + xLabel: metadata.WesplotOptions.XLabel, + yLabel: metadata.WesplotOptions.YLabel, + xMin: metadata.WesplotOptions.XMin, + xMax: metadata.WesplotOptions.XMax, + yMin: metadata.WesplotOptions.YMin, + yMax: metadata.WesplotOptions.YMax, + yUnit: metadata.WesplotOptions.YUnit, + columns: metadata.WesplotOptions.Columns, + xIsTimestamp: metadata.XIsTimestamp, + }; - // Create a table for each series + // Determine which series to display (all of them for now) const numSeries = metadata.WesplotOptions.Columns.length; - for (let seriesId = 0; seriesId < numSeries; seriesId++) { - const seriesName = - metadata.WesplotOptions.Columns[seriesId] || `Series ${seriesId}`; - - const table = document.createElement("table"); - const thead = document.createElement("thead"); - const headerRow = document.createElement("tr"); - const thSeries = document.createElement("th"); - thSeries.textContent = seriesName; - thSeries.colSpan = 2; - headerRow.appendChild(thSeries); - thead.appendChild(headerRow); - - const subHeaderRow = document.createElement("tr"); - const thX = document.createElement("th"); - thX.textContent = "X"; - const thY = document.createElement("th"); - thY.textContent = "Y"; - subHeaderRow.appendChild(thX); - subHeaderRow.appendChild(thY); - thead.appendChild(subHeaderRow); - - const tbody = document.createElement("tbody"); - - table.appendChild(thead); - table.appendChild(tbody); - - // Style the table for flex layout - table.style.flex = "1"; - table.style.minWidth = "200px"; - - tablesContainer.appendChild(table); - - tables.set(seriesId, { table, tbody }); - } - }, + const seriesIds = Array.from({ length: numSeries }, (_, i) => i); + + // Create chart + this.chart = new Chart({ + container: this.chartContainer, + seriesIds, + options: chartOptions, + }); + } + + private createTitleBar(title: string): void { + // Create title bar + this.titleBar = document.createElement("div"); + this.titleBar.className = "title-bar"; + + // Create title text + this.titleText = document.createElement("div"); + this.titleText.className = "title-text"; + this.titleText.textContent = title; + this.titleBar.appendChild(this.titleText); + + // Create button bar + const buttonBar = document.createElement("div"); + buttonBar.className = "button-bar"; + + // Create buttons + this.screenshotBtn = this.createButton("fa-camera", "Save image"); + this.resetZoomBtn = this.createButton("fa-expand", "Reset zoom"); + this.zoomBtn = this.createButton("fa-magnifying-glass", "Zoom"); + this.panBtn = this.createButton("fa-arrows-up-down-left-right", "Pan"); + this.settingsBtn = this.createButton("fa-gear", "Settings"); - onData: ( + buttonBar.appendChild(this.screenshotBtn); + buttonBar.appendChild(this.resetZoomBtn); + buttonBar.appendChild(this.zoomBtn); + buttonBar.appendChild(this.panBtn); + buttonBar.appendChild(this.settingsBtn); + + this.titleBar.appendChild(buttonBar); + this.panelContainer.appendChild(this.titleBar); + + // Setup button event listeners + this.setupButtons(); + } + + private createButton(iconClass: string, title: string): HTMLButtonElement { + const button = document.createElement("button"); + const icon = document.createElement("i"); + icon.className = `fa-solid ${iconClass}`; + icon.title = title; + button.appendChild(icon); + return button; + } + + updateData( seriesId: number, xSegments: Float64Array[], ySegments: Float64Array[], - ) => { - const tableInfo = tables.get(seriesId); - if (!tableInfo) return; - - // Concatenate segments - const xData = concatenateSegments(xSegments); - const yData = concatenateSegments(ySegments); - - // Clear tbody - tableInfo.tbody.innerHTML = ""; - - // Add rows (last 50, latest at top) - const maxRows = 50; - const start = Math.max(0, xData.length - maxRows); - for (let i = xData.length - 1; i >= start; i--) { - const row = document.createElement("tr"); - const cellX = document.createElement("td"); - cellX.textContent = xData[i].toString(); - const cellY = document.createElement("td"); - cellY.textContent = yData[i].toString(); - row.appendChild(cellX); - row.appendChild(cellY); - tableInfo.tbody.appendChild(row); + ): void { + if (!this.chart || this._isPaused) { + return; } - }, - onStreamEnd: (error: boolean, message: string) => { - const color = error ? "red" : "blue"; - updateStatus(`Stream ended: ${message}`, color); - }, + // Update chart with new data + this.chart.update(seriesId, xSegments, ySegments); + } - onError: (error: Error) => { - updateStatus(`Error: ${error.message}`, "red"); - console.error(error); - }, -}); + private setupButtons(): void { + // Placeholder for button event listeners + // Implement functionality as needed + if ( + !this.screenshotBtn || + !this.resetZoomBtn || + !this.zoomBtn || + !this.panBtn || + !this.settingsBtn + ) { + console.error("Buttons not initialized"); + return; + } -// Connect to the streamer -streamer - .connect() - .then(() => { - updateStatus("Connected to WebSocket", "green"); - }) - .catch((error) => { - updateStatus(`Failed to connect: ${error.message}`, "red"); - console.error(error); - }); - -function concatenateSegments(segments: Float64Array[]): Float64Array { - if (segments.length === 1) { - return segments[0]; + this.screenshotBtn.addEventListener("click", () => { + console.log("Screenshot button clicked"); + }); + this.resetZoomBtn.addEventListener("click", () => { + console.log("Reset zoom button clicked"); + }); + this.zoomBtn.addEventListener("click", () => { + console.log("Zoom button clicked"); + }); + this.panBtn.addEventListener("click", () => { + console.log("Pan button clicked"); + }); + this.settingsBtn.addEventListener("click", () => { + console.log("Settings button clicked"); + }); } - const totalLength = segments.reduce((sum, seg) => sum + seg.length, 0); - const result = new Float64Array(totalLength); - let offset = 0; - for (const seg of segments) { - result.set(seg, offset); - offset += seg.length; +} + +// State +let streamer: Streamer | null = null; +let statusBar: StatusBar | null = null; +let chartWrapper: ChartWrapper | null = null; + +// Initialize application +async function main() { + try { + chartWrapper = new ChartWrapper(); + statusBar = new StatusBar(chartWrapper); + + statusBar.updateStatus("Connecting...", "connecting"); + + // Create streamer with 1000 point window size + streamer = new Streamer(`ws://${baseHost}/ws2`, 1000); + + // Register callbacks + streamer.registerCallbacks({ + onMetadata: handleMetadata, + onData: handleData, + onStreamEnd: handleStreamEnd, + onError: handleError, + }); + + // Connect to WebSocket + await streamer.connect(); + statusBar.updateStatus("Connected", "live"); + } catch (error) { + handleError(error instanceof Error ? error : new Error(String(error))); } - return result; } + +function handleMetadata(metadata: Metadata): void { + if (!statusBar || !chartWrapper) return; + + // Create chart with title bar + chartWrapper.createChart(metadata); + + statusBar.updateStatus("Streaming", "live"); +} + +function handleData( + seriesId: number, + xSegments: Float64Array[], + ySegments: Float64Array[], +): void { + if (!chartWrapper) return; + chartWrapper.updateData(seriesId, xSegments, ySegments); +} + +function handleStreamEnd(error: boolean, message: string): void { + if (!statusBar) return; + const statusMessage = error + ? `Error: ${message}` + : `Stream ended: ${message}`; + statusBar.updateStatus(statusMessage, error ? "error" : "disconnected"); + console.log(`Stream ended: ${message}`); +} + +function handleError(error: Error): void { + if (!statusBar) return; + statusBar.updateStatus(`Error: ${error.message}`, "error"); + console.error("Streamer error:", error); +} + +// Start application when DOM is ready +window.addEventListener("load", () => { + main(); +}); diff --git a/frontend/src/v2/streamer.test.ts b/frontend/src/v2/streamer.test.ts index fe2283f..2a0b2b2 100644 --- a/frontend/src/v2/streamer.test.ts +++ b/frontend/src/v2/streamer.test.ts @@ -517,4 +517,49 @@ describe("Streamer", () => { expect(onData).not.toHaveBeenCalled(); streamer.disconnect(); }); + + it("should record the timestamp of the last data message received", async () => { + const streamer = new Streamer("ws://localhost/ws2", 1000); + const onData = vi.fn(); + + streamer.registerCallbacks({ onData }); + + const connectPromise = streamer.connect(); + const mockWs = getWebSocket(streamer); + mockWs.simulateOpen(); + await connectPromise; + + // Initially null + expect(streamer.latestDataReceivedTime).toBeNull(); + + // Send metadata first to create buffers + const metadataMsg = createTestMetadataMessage(); + mockWs.simulateMessage(encodeWSMessage(metadataMsg).buffer as ArrayBuffer); + + // Send data message + const dataMsg: WSMessageData = { + Header: { + Version: ProtocolVersion, + Reserved: [0, 0], + Type: MessageTypeData, + Length: 56, + }, + Payload: { + SeriesID: 0, + Length: 3, + X: new Float64Array([1.0, 2.0, 3.0]), + Y: new Float64Array([10.0, 20.0, 30.0]), + }, + }; + + const beforeTime = performance.now(); + mockWs.simulateMessage(encodeWSMessage(dataMsg).buffer as ArrayBuffer); + const afterTime = performance.now(); + + expect(streamer.latestDataReceivedTime).not.toBeNull(); + expect(streamer.latestDataReceivedTime).toBeGreaterThanOrEqual(beforeTime); + expect(streamer.latestDataReceivedTime).toBeLessThanOrEqual(afterTime); + + streamer.disconnect(); + }); }); diff --git a/frontend/src/v2/streamer.ts b/frontend/src/v2/streamer.ts index cde1bb8..f6d4f56 100644 --- a/frontend/src/v2/streamer.ts +++ b/frontend/src/v2/streamer.ts @@ -41,6 +41,7 @@ export class Streamer { private _onStreamEndCallbacks: Set = new Set(); private _onErrorCallbacks: Set = new Set(); private _streamEndReceived = false; + private _latestDataReceivedTime: number | null = null; constructor( private _wsUrl: string, @@ -126,6 +127,11 @@ export class Streamer { } } + // Get the timestamp of the last data message received + get latestDataReceivedTime(): number | null { + return this._latestDataReceivedTime; + } + private _handleMessage(data: ArrayBuffer): void { const buf = new Uint8Array(data); const msg = decodeWSMessage(buf); @@ -195,6 +201,9 @@ export class Streamer { const ySegments = yBuffer.segments(); this._invokeDataCallbacks(SeriesID, xSegments, ySegments); + + // Record the timestamp of the last data message received + this._latestDataReceivedTime = performance.now(); } private _handleStreamEnd(msg: WSMessageStreamEnd): void { diff --git a/frontend/v2.html b/frontend/v2.html index f4724aa..dc426df 100644 --- a/frontend/v2.html +++ b/frontend/v2.html @@ -4,29 +4,33 @@ Wesplot v2 - -
-

Wesplot v2 Test Application

-
Initializing...
-
+
+
+
+
+
+ +
+
+ + + + Connecting... +
+ +
+ + + + + +
+