Skip to content

added export feature #123

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 19, 2025
Merged
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
33 changes: 33 additions & 0 deletions frontend/src/components/spectrogram/SpectrogramVizContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,38 @@ const SpectrogramVizContainer = ({
}
};

const handleSaveSpectrogram = async () => {
if (!spectrogramUrl) return;

try {
// Fetch the image blob
const response = await fetch(spectrogramUrl);
const blob = await response.blob();

// Create a download link
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;

// Generate filename based on timestamp and visualization UUID
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `spectrogram-${visualizationRecord.uuid}-${timestamp}.png`;

link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(downloadUrl);
} catch (error) {
console.error('Error saving spectrogram:', error);
setJobInfo((prevStatus) => ({
...prevStatus,
status: 'error',
message: 'Failed to save spectrogram',
}));
}
};

/**
* Once a job is created, periodically poll the server to check its status
*/
Expand Down Expand Up @@ -184,6 +216,7 @@ const SpectrogramVizContainer = ({
<SpectrogramVisualization
imageUrl={spectrogramUrl}
hasError={jobInfo.status === 'failed' || jobInfo.status === 'error'}
onSave={handleSaveSpectrogram}
/>
</Col>
</Row>
Expand Down
37 changes: 27 additions & 10 deletions frontend/src/components/spectrogram/index.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
interface SpectrogramVisualizationProps {
imageUrl: string | null;
hasError: boolean;
onSave?: () => void;
}

/**
* Renders a spectrogram visualization or placeholder based on provided image URL
* @param imageUrl - URL of the spectrogram image to display
* @param hasError - Whether there was an error generating the spectrogram
* @param onSave - Optional callback function to handle saving the spectrogram
*/
const SpectrogramVisualization = ({
imageUrl,
hasError,
onSave,
}: SpectrogramVisualizationProps) => {
return (
<div
Expand All @@ -19,21 +22,35 @@ const SpectrogramVisualization = ({
height: 500,
backgroundColor: imageUrl && !hasError ? 'transparent' : '#f8f9fa',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}}
>
{imageUrl ? (
<img
src={imageUrl}
alt="Spectrogram visualization"
style={{
maxWidth: '100%',
maxHeight: '100%',
aspectRatio: window.innerWidth / window.innerHeight,
objectFit: 'contain',
}}
/>
<>
<img
src={imageUrl}
alt="Spectrogram visualization"
style={{
maxWidth: '90%',
maxHeight: '100%',
aspectRatio: window.innerWidth / window.innerHeight,
objectFit: 'contain',
}}
/>
{onSave && (
<button
className="btn btn-primary position-absolute top-0 end-0 m-0"
onClick={onSave}
aria-label="Export Spectrogram"
>
<i className="bi bi-download me-2" />
Export
</button>
)}
</>
) : (
<p className="text-muted">
{hasError
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/waterfall/WaterfallPlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,10 @@ export function WaterfallPlot({
/>
)}
</div>
<div style={{ position: 'relative', height: `${WATERFALL_HEIGHT}px` }}>
<div
className="waterfall-plot"
style={{ position: 'relative', height: `${WATERFALL_HEIGHT}px` }}
>
<canvas ref={plotCanvasRef} style={{ display: 'block' }} />
<canvas
ref={overlayCanvasRef}
Expand Down
91 changes: 91 additions & 0 deletions frontend/src/components/waterfall/WaterfallVizContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,96 @@ export const WaterfallVizContainer = ({
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
);

const handleSaveWaterfall = async () => {
try {
// Get all canvas elements
const periodogramCanvas = document.querySelector('#chartCanvas canvas');
const waterfallPlotCanvas = document.querySelector(
'.waterfall-plot canvas:first-child',
);
const waterfallOverlayCanvas = document.querySelector(
'.waterfall-plot canvas:last-child',
);

if (
!periodogramCanvas ||
!waterfallPlotCanvas ||
!waterfallOverlayCanvas
) {
throw new Error('Could not find all visualization canvases');
}

const timestamp = new Date().toISOString().replace(/[:.]/g, '-');

// Create a new canvas to combine all elements
const combinedCanvas = document.createElement('canvas');
const ctx = combinedCanvas.getContext('2d');
if (!ctx) throw new Error('Could not create canvas context');

// Get dimensions
const periodogramHeight = (periodogramCanvas as HTMLCanvasElement).height;
const waterfallHeight = (waterfallPlotCanvas as HTMLCanvasElement).height;
const waterfallWidth = (waterfallPlotCanvas as HTMLCanvasElement).width;

// Define padding
const padding = 20; // pixels of padding on all sides

// Set combined canvas size with padding
combinedCanvas.width = waterfallWidth + padding * 2;
combinedCanvas.height = periodogramHeight + waterfallHeight + padding * 2;

// Fill background with white
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, combinedCanvas.width, combinedCanvas.height);

// Draw periodogram with padding
ctx.drawImage(periodogramCanvas as HTMLCanvasElement, padding, padding);

// Draw waterfall plot with padding
ctx.drawImage(
waterfallPlotCanvas as HTMLCanvasElement,
padding,
periodogramHeight + padding,
);

// Draw overlay on top of waterfall with padding
ctx.drawImage(
waterfallOverlayCanvas as HTMLCanvasElement,
padding,
periodogramHeight + padding,
);

// Convert combined canvas to blob
const combinedBlob = await new Promise<Blob>((resolve, reject) => {
combinedCanvas.toBlob((blob: Blob | null) => {
if (blob) resolve(blob);
else reject(new Error('Failed to create blob from combined canvas'));
}, 'image/png');
});

// Helper function to trigger download
const downloadFile = (blob: Blob, filename: string) => {
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(downloadUrl);
};

// Download combined image
downloadFile(
combinedBlob,
`waterfall-visualization-${visualizationRecord.uuid}-${timestamp}.png`,
);
} catch (error) {
console.error('Error saving visualization:', error);
// You might want to show an error message to the user here
}
};

return (
<div>
<Row>
Expand All @@ -69,6 +159,7 @@ export const WaterfallVizContainer = ({
waterfallFiles={waterfallFiles}
settings={settings}
setSettings={setSettings}
onSave={handleSaveWaterfall}
/>
</Row>
<Row>
Expand Down
81 changes: 41 additions & 40 deletions frontend/src/components/waterfall/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,14 @@ interface WaterfallVisualizationProps {
waterfallFiles: WaterfallFile[];
settings: WaterfallSettings;
setSettings: React.Dispatch<React.SetStateAction<WaterfallSettings>>;
onSave?: () => void;
}

const WaterfallVisualization: React.FC<WaterfallVisualizationProps> = ({
waterfallFiles,
settings,
setSettings,
onSave,
}: WaterfallVisualizationProps) => {
const [displayedFileIndex, setDisplayedFileIndex] = useState(
settings.fileIndex,
Expand Down Expand Up @@ -667,46 +669,45 @@ const WaterfallVisualization: React.FC<WaterfallVisualizationProps> = ({

return (
<div>
<h5>
Scan {displayedFileIndex + 1} (
{waterfallFiles[displayedFileIndex].timestamp})
</h5>
<Periodogram
chartOptions={chart}
chartContainerStyle={{
height: 200,
paddingRight: PLOTS_RIGHT_MARGIN - CANVASJS_RIGHT_MARGIN,
paddingTop: 0,
paddingBottom: 0,
}}
yAxisTitle="dBm per bin"
/>
{/* Div to test left plot margins */}
{/* <div
style={{ width: PLOTS_LEFT_MARGIN, height: 30, backgroundColor: 'red' }}
/> */}
{/* Div to test right plot margins */}
{/* <div
style={{
width: PLOTS_RIGHT_MARGIN,
height: 30,
backgroundColor: 'blue',
float: 'right',
}}
/> */}
<WaterfallPlot
scan={scan}
display={display}
setWaterfall={setScanWaterfall}
setScaleChanged={setScaleChanged}
setResetScale={setResetScale}
currentFileIndex={settings.fileIndex}
onRowSelect={handleRowSelect}
fileRange={waterfallRange}
totalFiles={waterfallFiles.length}
colorLegendWidth={PLOTS_LEFT_MARGIN}
indexLegendWidth={PLOTS_RIGHT_MARGIN}
/>
{onSave && (
<div className="d-flex justify-content-end mb-3">
<button
className="btn btn-primary"
onClick={onSave}
aria-label="Export Waterfall"
>
<i className="bi bi-download me-2" />
Export
</button>
</div>
)}
<div style={{ position: 'relative' }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Periodogram
chartOptions={chart}
chartContainerStyle={{
height: 200,
paddingRight: PLOTS_RIGHT_MARGIN - CANVASJS_RIGHT_MARGIN,
paddingTop: 0,
paddingBottom: 0,
}}
yAxisTitle="dBm per bin"
/>
<WaterfallPlot
scan={scan}
display={display}
setWaterfall={setWaterfall}
setScaleChanged={setScaleChanged}
setResetScale={setResetScale}
currentFileIndex={settings.fileIndex}
onRowSelect={handleRowSelect}
fileRange={waterfallRange}
totalFiles={rhFiles.length}
colorLegendWidth={PLOTS_LEFT_MARGIN}
indexLegendWidth={PLOTS_RIGHT_MARGIN}
/>
</div>
</div>
</div>
);
};
Expand Down