Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 233 additions & 0 deletions electron/src/components/graph/GraphWithMarkerControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import {
AutoSyncedBigGraph,
useGraphSync,
type GraphConfig,
} from "@/components/graph";
import { TimeSeries, TimeSeriesValue } from "@/lib/timeseries";
import { Unit } from "@/control/units";

type TimeSeriesData = {
newData: TimeSeries | null;
title?: string;
color?: string;
lines?: any[];
};

type GraphWithMarkerControlsProps = {
syncHook: ReturnType<typeof useGraphSync>;
newData: TimeSeriesData | TimeSeriesData[];
config: GraphConfig;
unit?: Unit;
renderValue?: (value: number) => string;
graphId: string;
currentTimeSeries: TimeSeries | null;
};

function createMarkerElement(
timestamp: number,
value: number,
name: string,
startTime: number,
endTime: number,
graphWidth: number,
graphHeight: number,
) {
// Calculate the position of the timestamp
const ratio = (timestamp - startTime) / (endTime - startTime);
const xPos = Math.min(Math.max(ratio, 0), 1) * graphWidth;
const yPos = graphHeight - value;

const line = document.createElement("div");
line.style.position = "absolute";
line.style.left = `${xPos}px`;
line.style.top = `${yPos}px`;
line.style.height = `${value}px`;
line.style.width = "2px";
line.style.background = "rgba(0, 0, 0, 0.5)";
line.className = "vertical-marker";

const label = document.createElement("div");
label.textContent = name;
label.style.position = "absolute";
label.style.left = `${xPos}px`;
label.style.top = `${yPos - 20}px`;
label.style.transform = "translateX(-50%)";
label.style.color = "black";
label.style.padding = "2px 4px";
label.style.fontSize = "12px";
label.style.whiteSpace = "nowrap";
label.className = "marker-label";

return { line, label };
}

export function GraphWithMarkerControls({
syncHook,
newData,
config,
unit,
renderValue,
graphId,
currentTimeSeries,
}: GraphWithMarkerControlsProps) {
const graphWrapperRef = useRef<HTMLDivElement | null>(null);
const [markerName, setMarkerName] = useState("");
const [markers, setMarkers] = useState<
{ timestamp: number; name: string; value: number }[]
>([]);
const [statusMessage, setStatusMessage] = useState<string | null>(null);

Check warning on line 79 in electron/src/components/graph/GraphWithMarkerControls.tsx

View workflow job for this annotation

GitHub Actions / build-electron

'setStatusMessage' is assigned a value but never used

Check warning on line 79 in electron/src/components/graph/GraphWithMarkerControls.tsx

View workflow job for this annotation

GitHub Actions / build-electron

'setStatusMessage' is assigned a value but never used

const dynamicMarkerLines = markers.map((marker, index) => ({

Check warning on line 81 in electron/src/components/graph/GraphWithMarkerControls.tsx

View workflow job for this annotation

GitHub Actions / build-electron

'index' is defined but never used

Check warning on line 81 in electron/src/components/graph/GraphWithMarkerControls.tsx

View workflow job for this annotation

GitHub Actions / build-electron

'index' is defined but never used
type: "user_marker" as const,
value: marker.value,
label: marker.name,
color: "#ff0000",
width: 2,
show: true,
markerTimestamp: marker.timestamp,
}));

// Time Tick for forcing marker redraw
const [timeTick, setTimeTick] = useState(0);

// Set interval to force redraw the marker effect frequently (e.g., every 50ms)
useEffect(() => {
if (!currentTimeSeries?.current) return;
const intervalId = setInterval(() => {
setTimeTick((prev) => prev + 1);
}, 50);
return () => clearInterval(intervalId);
}, [currentTimeSeries?.current]);

const handleAddMarker = useCallback(() => {
if (currentTimeSeries?.current && markerName.trim()) {
const ts = currentTimeSeries.current.timestamp;
const val = currentTimeSeries.current.value;
const name = markerName.trim();

setMarkers((prev) => [...prev, { timestamp: ts, name, value: val }]);
}
}, [currentTimeSeries, markerName]);

// Marker Drawing Effect
useEffect(() => {
if (!graphWrapperRef.current || !currentTimeSeries?.current) return;

const graphEl = graphWrapperRef.current;
// The BigGraph component is the first child (the one with the actual chart)
// TODO: Find a better way to do this
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is a better way to do it, please implement. Otherwise delete the comment.

const chartContainer = graphEl.querySelector(
".h-\\[50vh\\] > div > div.flex-1 > div",
);
if (!chartContainer) return;

const graphWidth = chartContainer.clientWidth;
const graphHeight = chartContainer.clientHeight;

const overlayContainer = chartContainer.parentElement;
if (!overlayContainer) return;

// Remove previous markers and labels from the overlay container
overlayContainer
.querySelectorAll(".vertical-marker, .marker-label")
.forEach((el) => el.remove());

// Get the visible time window
const currentTimeWindow = syncHook.controlProps.timeWindow;
const defaultDuration = config.defaultTimeWindow as number;
const validTimeWindowMs =
(typeof currentTimeWindow === "number" && currentTimeWindow) ||
defaultDuration || // Fallback to config default
30 * 60 * 1000; // Final fallback (30 minutes)

const endTime = currentTimeSeries.current.timestamp;
const startTime = endTime - validTimeWindowMs;

// Assuming the graph's fixed Y-scale is from -1 to 1 based on the sine wave example
const graphMin = -1;
const graphMax = 1;
// TODO: For real-world graphs (like Winder), you might need to read the actual min/max scale
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't assume that the min/max of the y axis will always be -1 and 1.
See roundness value of the laser (min: 0, max: 100)

// from the uPlot instance or define a safe range if the data is unconstrained.

markers.forEach(({ timestamp, name }) => {
if (timestamp >= startTime && timestamp <= endTime) {
// Find the data point closest to the marker timestamp to get the correct Y-value
const closest = currentTimeSeries.long.values
.filter((v): v is TimeSeriesValue => v !== null)
.reduce((prev, curr) =>
Math.abs(curr.timestamp - timestamp) <
Math.abs(prev.timestamp - timestamp)
? curr
: prev,
);
if (!closest) return;

// Calculate the Y-position in pixels from the bottom of the chart area
const normalizedValue =
(closest.value - graphMin) / (graphMax - graphMin);
const valueY = normalizedValue * graphHeight;

const { line, label } = createMarkerElement(
timestamp,
valueY,
name,
startTime,
endTime,
graphWidth,
graphHeight,
);

overlayContainer.appendChild(line);
overlayContainer.appendChild(label);
}
});
}, [
markers,
currentTimeSeries,
timeTick,
config.defaultTimeWindow,
syncHook.controlProps.timeWindow,
]);

const finalConfig = {
...config,
lines: [...(config.lines || []), ...dynamicMarkerLines],
};

return (
<div className="flex flex-col gap-2">
<div ref={graphWrapperRef} className="relative">
{/* Render the core chart component */}
<AutoSyncedBigGraph
syncHook={syncHook}
newData={newData}
config={finalConfig}
unit={unit}
renderValue={renderValue}
graphId={graphId}
/>
</div>

{/* Marker Input and Button */}
<div className="flex items-center gap-2">
<span className="font-medium">Add Marker:</span>
<input
type="text"
placeholder={`Marker for ${config.title}`}
value={markerName}
onChange={(e) => setMarkerName(e.target.value)}
className="rounded border px-2 py-1"
/>
<button
onClick={handleAddMarker}
className="rounded bg-gray-200 px-3 py-1 hover:bg-gray-300"
disabled={!currentTimeSeries?.current}
>
Add
</button>
<p className="ml-4 text-sm text-gray-600">{statusMessage ?? ""}</p>
</div>
</div>
);
}
134 changes: 134 additions & 0 deletions electron/src/components/graph/excelExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,27 @@ export function exportGraphsToExcel(
XLSX.utils.book_append_sheet(workbook, dataWorksheet, dataSheetName);
}

// Excel worksheet for timestamps and timestamp markers
if (graphLineData.targetLines.length > 0) {
const markerReportData =
createGraphLineMarkerReportSheet(graphLineData);
const markerReportWorksheet = XLSX.utils.aoa_to_sheet(markerReportData);
// Set column widths here (e.g., Column A = 15, Column B = 25)
markerReportWorksheet["!cols"] = [
{ wch: 20 }, // Column A (Labels: 'Timestamp', 'Value', etc.)
{ wch: 30 }, // Column B (Values, where the Date object resides)
];
const markerReportSheetName = generateUniqueSheetName(
`${seriesTitle} Marker Report`,
usedSheetNames,
);
XLSX.utils.book_append_sheet(
workbook,
markerReportWorksheet,
markerReportSheetName,
);
}

processedCount++;
});

Expand Down Expand Up @@ -290,6 +311,119 @@ function createGraphLineDataSheet(graphLine: {
});
}

function createGraphLineMarkerReportSheet(graphLine: {
graphTitle: string;
lineTitle: string;
series: TimeSeries;
color?: string;
unit?: Unit;
renderValue?: (value: number) => string;
config: GraphConfig;
targetLines: GraphLine[];
}): any[][] {
const [timestamps, values] = seriesToUPlotData(graphLine.series.long);
const unitSymbol = renderUnitSymbol(graphLine.unit) || "";
// Initialize Report Data and Header
const reportData: any[][] = [
[`Marker Report: ${graphLine.lineTitle}`],
["Graph", graphLine.graphTitle],
["Line Name", graphLine.lineTitle],
["", ""],
["--- Data Point Marker Status ---", ""],
["", ""],
];

if (timestamps.length === 0) {
reportData.push(["No data points to report"]);
return reportData;
}

// Filter User Markers
const allTargetLines = graphLine.targetLines.filter(
(line) => line.show !== false,
);
const userMarkers = allTargetLines.filter(
(line) => line.type === "user_marker" && line.label,
);

// Map Markers to Closest Data Point Index
const markerIndexMap = new Map<
number,
{ label: string; originalTimestamp: number }
>();

userMarkers.forEach((line) => {
const markerTime = line.markerTimestamp || line.value; // Use the correct high-precision timestamp
let closestDataPointIndex = -1;
let minTimeDifference = Infinity;

// Find the data point with the closest timestamp
timestamps.forEach((ts, index) => {
const difference = Math.abs(ts - markerTime);
if (difference < minTimeDifference) {
minTimeDifference = difference;
closestDataPointIndex = index;
}
});

// Store the marker data at the index of the closest data point
if (closestDataPointIndex !== -1) {
markerIndexMap.set(closestDataPointIndex, {
label: line.label || "User Marker",
originalTimestamp: markerTime,
});
}
});

// Add the final header before the timestamp report starts
reportData.push(["--- BEGIN DETAILED REPORT ---", ""], ["", ""]);

// Handle case where no user markers were created
if (userMarkers.length === 0) {
reportData.push(["No user-created markers found.", ""]);
}

timestamps.forEach((dataPointTimestamp, index) => {
const value = values[index];
const markerData = markerIndexMap.get(index);

let finalMarkerLabel = "";
let timeToDisplay = dataPointTimestamp; // Default to data sample time

if (markerData) {
finalMarkerLabel = `${markerData.label}`;
timeToDisplay = markerData.originalTimestamp;
}

// Format the time (using timeToDisplay)
const formattedTime = new Date(timeToDisplay)
.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
.replace(/ /g, "");

// Row 1: Timestamp
reportData.push(["Timestamp", formattedTime]);

// Row 2: Value
const formattedValue = graphLine.renderValue
? graphLine.renderValue(value)
: value?.toFixed(3) || "";
reportData.push([`Value (${unitSymbol})`, formattedValue]);

// Row 3: Marker Name
reportData.push(["Marker", finalMarkerLabel]);

// Separator
reportData.push(["", ""]);
});

return reportData;
}

// Ensure sheet names are unique and valid for Excel
function generateUniqueSheetName(
name: string,
Expand Down
3 changes: 2 additions & 1 deletion electron/src/components/graph/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@ export type PropGraphSync = {

// Configuration types for additional lines
export type GraphLine = {
type: "threshold" | "target";
type: "threshold" | "target" | "user_marker"; // TODO: redundant or not?
value: number;
color: string;
label?: string;
width?: number;
dash?: number[];
show?: boolean;
markerTimestamp?: number;
};

export type GraphConfig = {
Expand Down
Loading
Loading