Compare commits
23 Commits
0cc9883ed4
...
main
| Author | SHA256 | Date | |
|---|---|---|---|
| 91bcb528c1 | |||
| d06b32c1b7 | |||
| 212dea6a7d | |||
| af89ca96a1 | |||
| 981a736821 | |||
| 3dd3f80b04 | |||
| 976fd9f647 | |||
| be844bc4d0 | |||
| a90824efcd | |||
| af68e8923c | |||
| 15f74335c7 | |||
| b0024aec80 | |||
| ae874cd665 | |||
| 7f402fcade | |||
| 6d0d27511f | |||
| 9784ff4f26 | |||
| 96589039a4 | |||
| a02eae32a1 | |||
| c7c5df68d3 | |||
| 8dd57d4b16 | |||
| ef35a36512 | |||
| a711acbea8 | |||
| 9f99495eab |
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
/.cache
|
||||||
|
/.parcel-cache
|
||||||
|
/.vscode
|
||||||
13
.terserrc
Normal file
13
.terserrc
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"mangle": {
|
||||||
|
"reserved": [
|
||||||
|
"setPlan",
|
||||||
|
"logEvent",
|
||||||
|
"exportCSV",
|
||||||
|
"resetSystem",
|
||||||
|
"navigateTemp",
|
||||||
|
"logTemp",
|
||||||
|
"showGraph"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
44
.vscode/tasks.json
vendored
Normal file
44
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Build TypeScript and Bundle Static Site",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd ${workspaceFolder}; source nodetopath.sh; npm run build",
|
||||||
|
"problemMatcher": [],
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clean site",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd ${workspaceFolder}; rm -rf dist",
|
||||||
|
"problemMatcher": [],
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Run Site",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "cd ${workspaceFolder}; source nodetopath.sh; npm run start",
|
||||||
|
"problemMatcher": [],
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": true
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
25
LICENSE
Normal file
25
LICENSE
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
BSD 2-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2025, Terrence Ezrol (ezterry)
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
66
README.MD
Normal file
66
README.MD
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Manual Coffee Roasting Plan
|
||||||
|
|
||||||
|
**Project Name:** Manual Coffee Roasting Plan
|
||||||
|
**Hosting:** [View on Gitea](https://git-hojo.devnull.name/ezterry/Manual-Coffee-Roasting-Plan)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
A tool to track and visualize the manual coffee roasting process, including timer functionality and potential chart-based roasting curve tracking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
1. Clone the repository.
|
||||||
|
2. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
# or
|
||||||
|
yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Development Environment (VS Code)
|
||||||
|
1. Open the project in VS Code.
|
||||||
|
2. Ensure the following extensions are installed:
|
||||||
|
- **TypeScript** (for `.ts` file support)
|
||||||
|
- **Parcel** (for bundling)
|
||||||
|
3. Start the dev server:
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
# or
|
||||||
|
yarn start
|
||||||
|
```
|
||||||
|
This will serve the app at `http://localhost:1234` (default Parcel port).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
Build the production-ready files:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
# or
|
||||||
|
yarn build
|
||||||
|
```
|
||||||
|
The output will be in the `dist/` folder. Deploy this folder to any static hosting service (e.g., GitHub Pages, Netlify, or Gitea's built-in static hosting).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Technologies Used
|
||||||
|
- **TypeScript** (`src/roast.ts`) for type-safe logic.
|
||||||
|
- **Chart.js** and **chartjs-plugin-annotation** for potential roasting curve visualizations.
|
||||||
|
- **Parcel** for bundling and optimization.
|
||||||
|
- **PostCSS** and **Autoprefixer** for CSS compatibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- The core logic is in toast.ts
|
||||||
|
- The graph visual is in tempgraph.ts
|
||||||
|
- roast.html is the main web app, this is all static server content but the typescript may save some local information at your request on your system
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Contributing
|
||||||
|
Pull requests are welcome! For major changes, please open an issue first.
|
||||||
40
TODO.md
Normal file
40
TODO.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# TODO List Template for Project
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This template outlines the current state of the project files and future tasks to improve organization and functionality.
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
|
||||||
|
#### `roast.ts`
|
||||||
|
- Manages timer functions (`startTimer`, `stopTimer`).
|
||||||
|
- Handles event logging (`logEvent`) and system reset.
|
||||||
|
- Provides plan setting and navigation through temperature buttons.
|
||||||
|
- Implements CSV export functionality.
|
||||||
|
- Supports graph display and manipulation.
|
||||||
|
- Manages saved roast plans (save, load, clear).
|
||||||
|
|
||||||
|
#### `roast.html`
|
||||||
|
- Defines the user interface for coffee roast logging.
|
||||||
|
- Includes input fields for setting roast plans and saving them.
|
||||||
|
- Contains control elements to manage events and export data.
|
||||||
|
|
||||||
|
#### `roast.css`
|
||||||
|
- Styles the timer display with phase-specific backgrounds.
|
||||||
|
- Formats buttons and containers for a cohesive visual layout.
|
||||||
|
- Ensures responsiveness and accessibility across devices.
|
||||||
|
|
||||||
|
### Future Tasks
|
||||||
|
|
||||||
|
1. **Enhance User Interface:**
|
||||||
|
- Update visual styling for better user experience in `roast.css`.
|
||||||
|
- Implement a popup library window for managing roast plans, allowing users to save, restore, edit, and delete plans.
|
||||||
|
- Integrate access to hard-coded library plans that can be added to the user's saved list if the online library changes.
|
||||||
|
|
||||||
|
2. **Expand Functionality:**
|
||||||
|
- Add information about the rate of rise during roasting.
|
||||||
|
- Capture details about the roast (start/end weight).
|
||||||
|
- Implement a favorite button for quick access to frequently used plans.
|
||||||
|
|
||||||
|
3. **Documentation and Testing:**
|
||||||
|
- Create comprehensive documentation for all features.
|
||||||
|
- Develop test cases to ensure robustness of functionality.
|
||||||
5
init.sh
5
init.sh
@@ -3,5 +3,10 @@
|
|||||||
#source node binaries
|
#source node binaries
|
||||||
. nodetopath.sh
|
. nodetopath.sh
|
||||||
npm install -g typescript
|
npm install -g typescript
|
||||||
|
npm install -g ts-node
|
||||||
|
npm install --save-dev @types/node
|
||||||
|
#npm install --save-dev typescript parcel-bundler
|
||||||
|
npm i --save-dev parcel
|
||||||
|
npm install --save-dev autoprefixer postcss
|
||||||
npm install chart.js@4.4.1
|
npm install chart.js@4.4.1
|
||||||
npm install chartjs-plugin-annotation@3.1.0
|
npm install chartjs-plugin-annotation@3.1.0
|
||||||
3249
package-lock.json
generated
Normal file
3249
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"chart.js": "^4.4.1",
|
||||||
|
"chartjs-plugin-annotation": "^3.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.16.3",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"parcel": "^2.15.4",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "parcel src/roast.html",
|
||||||
|
"build": "parcel build src/roast.html --public-url=./ --dist-dir dist"
|
||||||
|
},
|
||||||
|
"compilerOptions": {
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"sourceMap": true,
|
||||||
|
"module": "ESNext"
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/planStorage.ts
Normal file
74
src/planStorage.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// planStorage.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a roast plan to local storage.
|
||||||
|
*
|
||||||
|
* @param name The name of the plan.
|
||||||
|
* @param temperatures A comma-separated string of temperature targets.
|
||||||
|
*/
|
||||||
|
export function savePlan(name: string, temperatures: string): void {
|
||||||
|
if (!name) {
|
||||||
|
throw new Error("Please provide a valid name for the roast plan.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedTemperatures = temperatures.trim();
|
||||||
|
if (!trimmedTemperatures) {
|
||||||
|
throw new Error("Temperature list is empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate temperature input
|
||||||
|
const parsedTemps = trimmedTemperatures.split(',').map(x => x.trim()).map(Number);
|
||||||
|
if (parsedTemps.some(val => isNaN(val))) {
|
||||||
|
throw new Error("Invalid temperature list. Please enter comma-separated numbers.");
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(`roastplan_${name}`, trimmedTemperatures);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a saved roast plan from local storage.
|
||||||
|
*
|
||||||
|
* @param name The name of the plan to load.
|
||||||
|
* @returns A string containing the temperature targets, or null if not found.
|
||||||
|
*/
|
||||||
|
export function retrievePlan(name: string): string | null {
|
||||||
|
if (!name) return null;
|
||||||
|
|
||||||
|
const stored = localStorage.getItem(`roastplan_${name}`);
|
||||||
|
return stored || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all saved roast plans from local storage.
|
||||||
|
*/
|
||||||
|
export function clearAllPlans(): void {
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith("roastplan_")) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keysToRemove.forEach(key => localStorage.removeItem(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all saved roast plan names from local storage.
|
||||||
|
*
|
||||||
|
* @returns An array of strings representing the names of saved plans.
|
||||||
|
*/
|
||||||
|
export function listSavedPlans(): string[] {
|
||||||
|
const savedPlanNames: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith("roastplan_")) {
|
||||||
|
const planName = key.replace("roastplan_", "");
|
||||||
|
savedPlanNames.push(planName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return savedPlanNames;
|
||||||
|
}
|
||||||
222
src/roast.css
Normal file
222
src/roast.css
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
body {
|
||||||
|
background-color: #e6ccff;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#timer {
|
||||||
|
font-size: 48px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 2px solid #666;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Roast phase backgrounds */
|
||||||
|
#timer.initial-phase {
|
||||||
|
background-color: #ffffff; /* white before Charge */
|
||||||
|
}
|
||||||
|
|
||||||
|
#timer.charge-phase {
|
||||||
|
background-color: #e6ffe6; /* pale green after Charge */
|
||||||
|
}
|
||||||
|
|
||||||
|
#timer.yellow-phase {
|
||||||
|
background-color: #fffdd0; /* light yellow after Yellow */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dynamic muddy yellow-brown transition (0–40% dev) */
|
||||||
|
#timer.dev-phase {
|
||||||
|
transition: background-color 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#create-plan {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#create-plan input {
|
||||||
|
width: 80%;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#create-plan button {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group button:not(.nav-btn) {
|
||||||
|
font-size: 20px;
|
||||||
|
border: 2px solid #666;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group .nav-btn {
|
||||||
|
padding: 15px 25px;
|
||||||
|
font-size: 20px;
|
||||||
|
border: 2px solid #666;
|
||||||
|
background-color: #E8E;
|
||||||
|
color: #333;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group button:active {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
#event-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#event-buttons button.activated {
|
||||||
|
background-color: #ccc; /* gray to indicate completed step */
|
||||||
|
color: #333;
|
||||||
|
border: 2px solid #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#event-buttons button {
|
||||||
|
padding: 15px 25px;
|
||||||
|
font-size: 20px;
|
||||||
|
border: 2px solid #666;
|
||||||
|
background-color: #afa;
|
||||||
|
color: #333;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#event-buttons button:active {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.used-temp {
|
||||||
|
background-color: #ccc !important;
|
||||||
|
color: #333;
|
||||||
|
border: 2px solid #888;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#log-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log-table table {
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 90%;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log-table th,
|
||||||
|
#log-table td {
|
||||||
|
border: 1px solid #666;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#log-table th {
|
||||||
|
background-color: #f0e6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#export-csv {
|
||||||
|
margin: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#export-csv button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 18px;
|
||||||
|
border: 2px solid #666;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#export-csv button:active {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#temp-buttons {
|
||||||
|
margin: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#temp-buttons button {
|
||||||
|
text-align: center;
|
||||||
|
width: 3.5em;
|
||||||
|
}
|
||||||
|
#graph-container {
|
||||||
|
position: fixed; /* Centered and float over the screen */
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%); /* Aligns perfectly center */
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
height: 400px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1000; /* Ensures it's above other elements */
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-display-flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#close-btn:hover {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
}
|
||||||
68
src/roast.html
Normal file
68
src/roast.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Stovetop Coffee Roast Logger</title>
|
||||||
|
<link rel="stylesheet" href="./roast.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="timer">
|
||||||
|
<span id="roastTime">0:00</span>
|
||||||
|
<span id="devPercent" style="display: none;"> (0%)</span>
|
||||||
|
</div>
|
||||||
|
<div id="create-plan">
|
||||||
|
<input type="text" id="plan-input" placeholder="Enter temperatures (comma separated)">
|
||||||
|
<button onclick="setPlan()">Set Plan</button>
|
||||||
|
|
||||||
|
<!-- Save and Load Plan UI -->
|
||||||
|
<div id="plan-storage" style="margin-top: 16px;">
|
||||||
|
<input type="text" id="save-name" placeholder="Save plan as..." style="width: 60%; padding: 8px;">
|
||||||
|
<button onclick="saveCurrentPlan()" style="padding: 8px 16px;">Save Plan</button>
|
||||||
|
|
||||||
|
<br><br>
|
||||||
|
<button onclick="clearSavedPlans()" style="margin-top: 10px; padding: 6px 16px;">Clear All Plans</button>
|
||||||
|
|
||||||
|
<label for="saved-plans">Load a saved plan:</label>
|
||||||
|
<select id="saved-plans" onchange="loadSavedPlan(this.value)" style="margin-left: 8px; padding: 6px;">
|
||||||
|
<option value="">-- Select saved plan --</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="controls" class="hidden">
|
||||||
|
<div id="temp-buttons" class="button-group"></div>
|
||||||
|
<div id="event-buttons" class="button-group">
|
||||||
|
<button onclick="logEvent('Charge')">Charge</button>
|
||||||
|
<button onclick="logEvent('Yellow')">Yellow</button>
|
||||||
|
<button onclick="logEvent('First Crack')">First Crack</button>
|
||||||
|
<button onclick="logEvent('Second Crack')">Second Crack</button>
|
||||||
|
<button onclick="logEvent('Drop')">Drop</button>
|
||||||
|
</div>
|
||||||
|
<div id="export-csv">
|
||||||
|
<button onclick="exportCSV()">Export CSV</button>
|
||||||
|
<button onclick="showGraph()">Show Graph</button>
|
||||||
|
<button onclick="resetSystem()">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="log-table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Event</th>
|
||||||
|
<th>Temperature</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="log-body">
|
||||||
|
<!-- Log entries will be inserted here -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="graph-container" class="hidden">
|
||||||
|
<button onclick="hideGraph()" id="close-btn">X</button>
|
||||||
|
<canvas id="myChart"></canvas>
|
||||||
|
</div>
|
||||||
|
<!--script type="module" src="./tempgraph.ts"></script-->
|
||||||
|
<script type="module" src="./roast.ts"></script></body>
|
||||||
|
</html>
|
||||||
260
src/roast.ts
260
src/roast.ts
@@ -1,15 +1,41 @@
|
|||||||
|
import { loadGraph } from './tempgraph.ts';
|
||||||
|
import { savePlan, retrievePlan, clearAllPlans, listSavedPlans } from './planStorage.ts';
|
||||||
|
|
||||||
let timer = 0;
|
let timer = 0;
|
||||||
let isRunning = false;
|
let isRunning = false;
|
||||||
let log: Array<{ time: string, event: string, temp?: number }> = [];
|
let log: Array<{ time: string, event: string, temp?: number }> = [];
|
||||||
let targets: number[] = [];
|
let targets: number[] = [];
|
||||||
let currentTargetIndex = 0;
|
let currentTargetIndex = 0;
|
||||||
let interval: NodeJS.Timeout | null = null;
|
let interval: NodeJS.Timeout | null = null;
|
||||||
|
let firstCrackTime: number | null = null;
|
||||||
|
let loggedTemps = new Set<number>();
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
startTimer: () => void;
|
||||||
|
stopTimer: () => void;
|
||||||
|
logEvent: (eventName: string) => void;
|
||||||
|
resetSystem: () => void;
|
||||||
|
setPlan: () => void;
|
||||||
|
navigateTemp: (direction: number) => void;
|
||||||
|
logTemp: (temp: number) => void;
|
||||||
|
exportCSV: () => void;
|
||||||
|
showGraph: () => void;
|
||||||
|
hideGraph: () => void;
|
||||||
|
saveCurrentPlan: () => void;
|
||||||
|
loadSavedPlan: (name: string) => void;
|
||||||
|
clearSavedPlans: () => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function startTimer(): void {
|
function startTimer(): void {
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
alert("Timer is already running. Click again to reset.");
|
alert("Timer is already running. Click again to reset.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
timer = 0;
|
||||||
|
updateTimerDisplay();
|
||||||
isRunning = true;
|
isRunning = true;
|
||||||
interval = setInterval(() => {
|
interval = setInterval(() => {
|
||||||
timer++;
|
timer++;
|
||||||
@@ -18,48 +44,117 @@ function startTimer(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stopTimer(): void {
|
function stopTimer(): void {
|
||||||
clearInterval(interval!);
|
if (interval !== null) {
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
isRunning = false;
|
isRunning = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTimerDisplay(): void {
|
function updateTimerDisplay(): void {
|
||||||
|
const roastTimeEl = document.getElementById("roastTime")!;
|
||||||
|
const devPercentEl = document.getElementById("devPercent")!;
|
||||||
|
|
||||||
const minutes = Math.floor(timer / 60);
|
const minutes = Math.floor(timer / 60);
|
||||||
const seconds = timer % 60;
|
const seconds = timer % 60;
|
||||||
document.getElementById("timer")!.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
roastTimeEl.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
if (firstCrackTime !== null && timer > firstCrackTime) {
|
||||||
|
const devTime = timer - firstCrackTime;
|
||||||
|
const devRatio = Math.floor((devTime / timer) * 100);
|
||||||
|
devPercentEl.textContent = ` (${devRatio}%)`;
|
||||||
|
devPercentEl.style.display = 'inline';
|
||||||
|
} else {
|
||||||
|
devPercentEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const timerEl = document.getElementById("timer")!;
|
||||||
|
timerEl.className = "";
|
||||||
|
|
||||||
|
if (!log.some(e => e.event === "Charge")) {
|
||||||
|
timerEl.classList.add("initial-phase");
|
||||||
|
} else if (!log.some(e => e.event === "Yellow")) {
|
||||||
|
timerEl.classList.add("charge-phase");
|
||||||
|
} else if (firstCrackTime === null) {
|
||||||
|
timerEl.classList.add("yellow-phase");
|
||||||
|
} else {
|
||||||
|
timerEl.classList.add("dev-phase");
|
||||||
|
const devTime = timer - firstCrackTime;
|
||||||
|
const devRatio = Math.min(devTime / timer, 0.4);
|
||||||
|
const r = Math.round(255 - devRatio * 100);
|
||||||
|
const g = Math.round(255 - devRatio * 150);
|
||||||
|
const b = Math.round(208 - devRatio * 125);
|
||||||
|
timerEl.style.backgroundColor = `rgb(${r},${g},${b})`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logEvent(eventName: string): void {
|
function logEvent(eventName: string): void {
|
||||||
if (eventName === "Charge") {
|
if (eventName === "Charge") {
|
||||||
if (log.length > 0) {
|
if (log.length > 0 || isRunning) {
|
||||||
|
if (isRunning) {
|
||||||
|
stopTimer();
|
||||||
|
}
|
||||||
log = [];
|
log = [];
|
||||||
timer = 0;
|
timer = 0;
|
||||||
isRunning = false;
|
|
||||||
}
|
}
|
||||||
startTimer();
|
startTimer();
|
||||||
log.push({ time: formatTime(timer), event: eventName, temp: null });
|
log.push({ time: formatTime(timer), event: eventName });
|
||||||
updateLog();
|
updateLog();
|
||||||
|
updateEventButtons(eventName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (eventName === "Drop") {
|
if (eventName === "Drop") {
|
||||||
stopTimer();
|
stopTimer();
|
||||||
log.push({ time: formatTime(timer), event: eventName, temp: null });
|
log.push({ time: formatTime(timer), event: eventName });
|
||||||
updateLog();
|
updateLog();
|
||||||
document.getElementById("create-plan")!.classList.add("hidden");
|
document.getElementById("create-plan")!.classList.remove("hidden");
|
||||||
const tempButtons = document.getElementById("temp-buttons")!;
|
document.getElementById("temp-buttons")!.innerHTML = "";
|
||||||
tempButtons.innerHTML = "";
|
updateEventButtons(eventName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log.push({ time: formatTime(timer), event: eventName, temp: null });
|
if (eventName === "First Crack") {
|
||||||
|
firstCrackTime = timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.push({ time: formatTime(timer), event: eventName });
|
||||||
updateLog();
|
updateLog();
|
||||||
|
updateEventButtons(eventName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEventButtons(eventName: string): void {
|
||||||
|
const eventSequence = ["Charge", "Yellow", "First Crack", "Second Crack", "Drop"];
|
||||||
|
if (eventSequence.includes(eventName)) {
|
||||||
|
const pressedIndex = eventSequence.indexOf(eventName);
|
||||||
|
for (let i = 0; i <= pressedIndex; i++) {
|
||||||
|
const btn = document.querySelector(`#event-buttons button:nth-child(${i + 1})`) as HTMLButtonElement;
|
||||||
|
if (btn) btn.classList.add("activated");
|
||||||
|
}
|
||||||
|
} else if (eventName === "Reset") {
|
||||||
|
document.querySelectorAll("#event-buttons button").forEach(btn =>
|
||||||
|
btn.classList.remove("activated")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetSystem(): void {
|
function resetSystem(): void {
|
||||||
stopTimer();
|
stopTimer();
|
||||||
log = [];
|
log = [];
|
||||||
|
timer = 0;
|
||||||
|
firstCrackTime = null;
|
||||||
|
loggedTemps.clear();
|
||||||
updateLog();
|
updateLog();
|
||||||
document.getElementById("create-plan")!.classList.add("hidden");
|
updateTimerDisplay();
|
||||||
const tempButtons = document.getElementById("temp-buttons")!;
|
|
||||||
tempButtons.innerHTML = "";
|
const devPercentEl = document.getElementById("devPercent")!;
|
||||||
|
devPercentEl.style.display = "none";
|
||||||
|
|
||||||
|
const timerEl = document.getElementById("timer")!;
|
||||||
|
timerEl.className = "initial-phase";
|
||||||
|
timerEl.style.backgroundColor = "";
|
||||||
|
|
||||||
|
document.getElementById("create-plan")!.classList.remove("hidden");
|
||||||
|
document.getElementById("temp-buttons")!.innerHTML = "";
|
||||||
|
|
||||||
|
updateEventButtons("Reset");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
@@ -80,17 +175,24 @@ function setPlan(): void {
|
|||||||
function generateTempButtons(): void {
|
function generateTempButtons(): void {
|
||||||
const container = document.getElementById("temp-buttons")!;
|
const container = document.getElementById("temp-buttons")!;
|
||||||
container.innerHTML = "";
|
container.innerHTML = "";
|
||||||
|
|
||||||
if (targets.length === 0) {
|
if (targets.length === 0) {
|
||||||
container.innerHTML = "<p>No temperature plan set.</p>";
|
container.innerHTML = "<p>No temperature plan set.</p>";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTarget = targets[currentTargetIndex];
|
const currentTarget = targets[currentTargetIndex];
|
||||||
const buttons: string[] = [];
|
const buttons: string[] = [];
|
||||||
|
|
||||||
buttons.push('<button class="nav-btn" onclick="navigateTemp(-1);"><<</button>');
|
buttons.push('<button class="nav-btn" onclick="navigateTemp(-1);"><<</button>');
|
||||||
|
|
||||||
for (let i = -2; i <= 2; i++) {
|
for (let i = -2; i <= 2; i++) {
|
||||||
const temp = currentTarget + i;
|
const temp = currentTarget + i;
|
||||||
buttons.push(`<button onclick="logTemp(${temp})">${temp}</button>`);
|
const used = loggedTemps.has(temp);
|
||||||
|
const classStr = used ? 'used-temp' : '';
|
||||||
|
buttons.push(`<button onclick="logTemp(${temp})" class="${classStr}">${temp}</button>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons.push('<button class="nav-btn" onclick="navigateTemp(1);">>></button>');
|
buttons.push('<button class="nav-btn" onclick="navigateTemp(1);">>></button>');
|
||||||
container.innerHTML = buttons.join(" ");
|
container.innerHTML = buttons.join(" ");
|
||||||
}
|
}
|
||||||
@@ -107,6 +209,7 @@ function navigateTemp(direction: number): void {
|
|||||||
|
|
||||||
function logTemp(temp: number): void {
|
function logTemp(temp: number): void {
|
||||||
log.push({ time: formatTime(timer), event: "Temp", temp });
|
log.push({ time: formatTime(timer), event: "Temp", temp });
|
||||||
|
loggedTemps.add(temp);
|
||||||
updateLog();
|
updateLog();
|
||||||
currentTargetIndex++;
|
currentTargetIndex++;
|
||||||
if (currentTargetIndex >= targets.length) {
|
if (currentTargetIndex >= targets.length) {
|
||||||
@@ -126,8 +229,20 @@ function updateLog(): void {
|
|||||||
`).join("");
|
`).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createCSVData(): string[] {
|
||||||
|
const header = "Time,Event,Temperature";
|
||||||
|
const logEntries = log.map(entry =>
|
||||||
|
`${entry.time},${entry.event}${entry.temp !== null ? `,${entry.temp}` : ''}`
|
||||||
|
);
|
||||||
|
return [header, ...logEntries];
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCSVContent(): string {
|
||||||
|
return createCSVData().join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
function exportCSV(): void {
|
function exportCSV(): void {
|
||||||
const csvContent = "Time,Event,Temperature\n" + log.map(entry => `${entry.time},${entry.event},${entry.temp !== null ? entry.temp : ''}`).join("\n");
|
const csvContent = generateCSVContent();
|
||||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.setAttribute("href", URL.createObjectURL(blob));
|
link.setAttribute("href", URL.createObjectURL(blob));
|
||||||
@@ -136,4 +251,117 @@ function exportCSV(): void {
|
|||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showGraph(): void {
|
||||||
|
const graphContainer = document.getElementById("graph-container");
|
||||||
|
if (graphContainer) {
|
||||||
|
loadGraph(createCSVData());
|
||||||
|
graphContainer.classList.remove("hidden");
|
||||||
|
graphContainer.classList.add("graph-display-flex");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideGraph(): void {
|
||||||
|
const graphContainer = document.getElementById("graph-container");
|
||||||
|
if (graphContainer) {
|
||||||
|
graphContainer.classList.add("hidden");
|
||||||
|
graphContainer.classList.remove("graph-display-flex");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCurrentPlan(): void {
|
||||||
|
const nameInput = document.getElementById("save-name") as HTMLInputElement;
|
||||||
|
const planName = nameInput.value.trim();
|
||||||
|
const rawInput = (document.getElementById("plan-input") as HTMLInputElement).value.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
savePlan(planName, rawInput);
|
||||||
|
alert(`Plan "${planName}" saved successfully.`);
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
nameInput.value = "";
|
||||||
|
updateSavedPlansDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSavedPlan(name: string): void {
|
||||||
|
const stored = retrievePlan(name);
|
||||||
|
if (stored) {
|
||||||
|
targets = stored.split(",").map(Number);
|
||||||
|
currentTargetIndex = 0;
|
||||||
|
document.getElementById("controls")!.classList.remove("hidden");
|
||||||
|
document.getElementById("create-plan")!.classList.add("hidden");
|
||||||
|
|
||||||
|
// Set the plan in the text box
|
||||||
|
console.log("Set plan: " +stored);
|
||||||
|
const planInput = document.getElementById("plan-input") as HTMLInputElement;
|
||||||
|
const savedName = document.getElementById("save-name") as HTMLInputElement;
|
||||||
|
planInput.value = stored;
|
||||||
|
savedName.value = name;
|
||||||
|
|
||||||
|
generateTempButtons();
|
||||||
|
|
||||||
|
// Reset the input dropdown to the first (choose prompt) entry
|
||||||
|
updateSavedPlansDropdown();
|
||||||
|
} else {
|
||||||
|
alert(`No plan found with the name "${name}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSavedPlansDropdown(): void {
|
||||||
|
const dropdown = document.getElementById("saved-plans") as HTMLSelectElement;
|
||||||
|
|
||||||
|
// Retrieve saved plans from planStorage.ts
|
||||||
|
const savedPlans = listSavedPlans();
|
||||||
|
|
||||||
|
// Reset to default prompt
|
||||||
|
dropdown.innerHTML = '<option value="">-- Select Saved Plan --</option>';
|
||||||
|
|
||||||
|
// Populate the dropdown with saved plans
|
||||||
|
savedPlans.forEach(planName => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = planName;
|
||||||
|
option.textContent = planName;
|
||||||
|
dropdown.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set the first entry as selected if available
|
||||||
|
if (savedPlans.length > 0) {
|
||||||
|
dropdown.selectedIndex = 0; // Index 1 corresponds to the first actual plan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSavedPlans(): void {
|
||||||
|
const confirmClear = confirm("Are you sure you want to delete all saved roast plans?");
|
||||||
|
if (!confirmClear) return;
|
||||||
|
|
||||||
|
clearAllPlans();
|
||||||
|
updateSavedPlansDropdown();
|
||||||
|
alert("All saved roast plans have been deleted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
|
updateSavedPlansDropdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Include in window export
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.startTimer = startTimer;
|
||||||
|
window.stopTimer = stopTimer;
|
||||||
|
window.logEvent = logEvent;
|
||||||
|
window.resetSystem = resetSystem;
|
||||||
|
window.setPlan = setPlan;
|
||||||
|
window.navigateTemp = navigateTemp;
|
||||||
|
window.logTemp = logTemp;
|
||||||
|
window.exportCSV = exportCSV;
|
||||||
|
window.showGraph = showGraph;
|
||||||
|
window.hideGraph = hideGraph;
|
||||||
|
window.saveCurrentPlan = saveCurrentPlan;
|
||||||
|
window.loadSavedPlan = loadSavedPlan;
|
||||||
|
window.clearSavedPlans = clearSavedPlans;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|||||||
137
src/tempgraph.ts
Normal file
137
src/tempgraph.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { Chart, registerables } from 'chart.js';
|
||||||
|
import annotationPlugin from 'chartjs-plugin-annotation';
|
||||||
|
import { AnnotationOptions } from 'chartjs-plugin-annotation';
|
||||||
|
|
||||||
|
// Register Chart.js components and plugins
|
||||||
|
Chart.register(...registerables);
|
||||||
|
Chart.register(annotationPlugin);
|
||||||
|
|
||||||
|
// External chart instance tracker
|
||||||
|
let chartInstance: Chart | null = null;
|
||||||
|
|
||||||
|
/* Test data if nothing is provided */
|
||||||
|
function getTestData(): string[] {
|
||||||
|
const sampleData = `Time,Event,Temperature
|
||||||
|
0:00,Charge,
|
||||||
|
0:00,Temp,139
|
||||||
|
0:03,Temp,90
|
||||||
|
0:10,Temp,129
|
||||||
|
0:13,Temp,161
|
||||||
|
0:19,Temp,191
|
||||||
|
0:22,Yellow,
|
||||||
|
0:24,Temp,207
|
||||||
|
0:24,First Crack,
|
||||||
|
0:30,Temp,208
|
||||||
|
0:31,Drop,`;
|
||||||
|
return sampleData.split('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTime(time: string): number {
|
||||||
|
const [mins, secs] = time.split(':').map(Number);
|
||||||
|
return mins * 60 + secs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadGraph(dataArray?: string[]): void {
|
||||||
|
const lines = dataArray || getTestData();
|
||||||
|
|
||||||
|
const data = lines.slice(1).map(line => {
|
||||||
|
const [time, event, temp] = line.split(',');
|
||||||
|
return { time, event, temp };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compute dynamic max time based on all entries
|
||||||
|
const maxEventTime = data.reduce((max, entry) => {
|
||||||
|
return entry.time ? Math.max(max, parseTime(entry.time)) : max;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const tempData = data
|
||||||
|
.filter(entry => entry.event === 'Temp')
|
||||||
|
.map(entry => {
|
||||||
|
const timeInSeconds = parseTime(entry.time);
|
||||||
|
const temp = parseFloat(entry.temp);
|
||||||
|
return { x: timeInSeconds, y: temp };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extend tempData to ensure final Drop timestamp is visible
|
||||||
|
const lastTemp = tempData[tempData.length - 1];
|
||||||
|
const extendedTempData = lastTemp ? tempData.concat({ x: maxEventTime, y: lastTemp.y }) : tempData;
|
||||||
|
|
||||||
|
const ctx = document.getElementById('myChart') as HTMLCanvasElement;
|
||||||
|
ctx.width = 800;
|
||||||
|
ctx.height = 400;
|
||||||
|
|
||||||
|
const annotationsArray: AnnotationOptions<'line'>[] = data
|
||||||
|
.filter(entry => entry.event !== 'Temp')
|
||||||
|
.map(entry => {
|
||||||
|
const timeInSeconds = parseTime(entry.time);
|
||||||
|
return {
|
||||||
|
type: 'line',
|
||||||
|
mode: 'vertical',
|
||||||
|
scaleID: 'x',
|
||||||
|
value: timeInSeconds,
|
||||||
|
borderColor: 'red',
|
||||||
|
borderWidth: 2,
|
||||||
|
label: {
|
||||||
|
content: entry.event,
|
||||||
|
display: true,
|
||||||
|
rotation: -90,
|
||||||
|
position: 'end',
|
||||||
|
yAdjust: -10,
|
||||||
|
font: {
|
||||||
|
size: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up previous chart instance if exists
|
||||||
|
if (chartInstance) {
|
||||||
|
chartInstance.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInstance = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
datasets: [{
|
||||||
|
label: 'Temperature (°C)',
|
||||||
|
data: extendedTempData,
|
||||||
|
borderColor: 'blue',
|
||||||
|
fill: false,
|
||||||
|
pointRadius: 5,
|
||||||
|
pointBackgroundColor: 'blue'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
animation: {
|
||||||
|
duration: 0
|
||||||
|
},
|
||||||
|
responsive: true,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'linear',
|
||||||
|
position: 'bottom',
|
||||||
|
min: 0,
|
||||||
|
max: maxEventTime,
|
||||||
|
ticks: {
|
||||||
|
callback: function (value) {
|
||||||
|
const mins = Math.floor(Number(value) / 60);
|
||||||
|
const secs = Number(value) % 60;
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Temperature (°C)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
annotation: {
|
||||||
|
annotations: annotationsArray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user