Conversation
There was a problem hiding this comment.
Pull request overview
Implements UI/UX and export improvements requested in issue #97, primarily around the Line of Minimum Trace workflow and first-time onboarding.
Changes:
- Added computation-history export for Line of Minimum Trace (CSV + PDF) and a “Clear History” action.
- Added precise numeric X/Y coordinate inputs alongside sliders in the Line of Minimum Trace page.
- Introduced a welcome dialog and adjusted header placement (to address the sticky banner concern) and updated the Home subtitle text.
Reviewed changes
Copilot reviewed 11 out of 13 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/utils/export.ts | New CSV/PDF export helpers for LMT computation traces. |
| frontend/src/utils/download.ts | Adds helper to download a generated string as a file. |
| frontend/src/utils/colors.ts | Adds HSL→RGB helpers used for PDF export color rendering. |
| frontend/src/stores/settings.ts | Adjusts persisted settings default for intro dialog behavior. |
| frontend/src/pages/LineOfMinimumTracePage.vue | Adds numeric coordinate inputs; adds history export + clear actions; integrates canvas export. |
| frontend/src/layouts/MainLayout.vue | Moves header into page container and mounts the welcome dialog globally. |
| frontend/src/components/WelcomeDialog.vue | New onboarding dialog with “Do not show again” toggle tied to settings. |
| frontend/src/components/LmtCanvas.vue | Exposes canvas image export helpers and refactors redraw flow to support “clean” export. |
| frontend/src/components/AppToolbar.vue | Removes header rendering from toolbar (now in layout). |
| frontend/src/components/AppHeader.vue | Updates Home subtitle copy and formatting; removes unused source-count logic. |
| frontend/package.json | Adds jsPDF and PapaParse (+ types) dependencies. |
| frontend/package-lock.json | Locks new dependency graph for export feature dependencies. |
Files not reviewed (1)
- frontend/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const showDataProtectionNotice = ref(!!settings.settings?.intro_shown); | ||
| const dontShowAgain = ref(false); | ||
|
|
||
| function onClose() { | ||
| if (dontShowAgain.value) { | ||
| settings.saveSettings({ intro_shown: false }) |
There was a problem hiding this comment.
showDataProtectionNotice is initialized from settings.settings?.intro_shown, but the setting name reads like a one-time flag while the current behavior is “show unless the user disables it”. Consider renaming the setting (e.g., intro_enabled/show_intro_dialog) or inverting the logic so intro_shown means “has been shown once” (set to true on first display/close) to avoid confusion and future regressions.
| const showDataProtectionNotice = ref(!!settings.settings?.intro_shown); | |
| const dontShowAgain = ref(false); | |
| function onClose() { | |
| if (dontShowAgain.value) { | |
| settings.saveSettings({ intro_shown: false }) | |
| const showDataProtectionNotice = ref(!settings.settings?.intro_shown); | |
| const dontShowAgain = ref(false); | |
| function onClose() { | |
| if (dontShowAgain.value) { | |
| settings.saveSettings({ intro_shown: true }) |
| let settingsData: Settings = { | ||
| intro_shown: false, | ||
| intro_shown: true, | ||
| theme: 'light', | ||
| }; |
There was a problem hiding this comment.
intro_shown is defaulted to true, which makes the semantics of the flag unclear given its name (it now behaves like “show intro dialog”). To keep the setting self-explanatory and avoid backwards logic across the app, consider either renaming the field or restoring a “not shown yet” default and updating the dialog logic accordingly.
| if (!canvasRef.value || !ctx.value || !props.uploadedImage) return; | ||
| function redrawCanvas(withLines: boolean = true) { | ||
| return new Promise<void>((resolve) => { | ||
| if (!canvasRef.value || !ctx.value || !props.uploadedImage) return; |
There was a problem hiding this comment.
redrawCanvas() returns a Promise but if canvasRef/ctx/uploadedImage is missing, the function returns without calling resolve(), leaving callers (e.g., cleanImageDataURL) awaiting a Promise that never settles. Resolve immediately in the early-exit path (or avoid wrapping in a Promise when prerequisites are missing).
| if (!canvasRef.value || !ctx.value || !props.uploadedImage) return; | |
| if (!canvasRef.value || !ctx.value || !props.uploadedImage) { | |
| resolve(); | |
| return; | |
| } |
| img.onload = () => { | ||
| if (ctx.value) { | ||
| ctx.value.clearRect(0, 0, canvasRef.value!.width, canvasRef.value!.height); | ||
| ctx.value.drawImage(img, 0, 0); | ||
|
|
||
| if (withLines) { | ||
| drawLine(); | ||
| } | ||
| drawResults(); | ||
| } | ||
|
|
||
| resolve(); | ||
| }; | ||
| img.src = URL.createObjectURL(props.uploadedImage); |
There was a problem hiding this comment.
URL.createObjectURL(props.uploadedImage) is called on every redrawCanvas() (and also initCanvas()) without revoking the created URL, which will leak object URLs over time—especially now that cleanImageDataURL() triggers extra redraws. Store the created URL in a local variable and call URL.revokeObjectURL() after img.onload (or img.onerror).
| img.onload = () => { | |
| if (ctx.value) { | |
| ctx.value.clearRect(0, 0, canvasRef.value!.width, canvasRef.value!.height); | |
| ctx.value.drawImage(img, 0, 0); | |
| if (withLines) { | |
| drawLine(); | |
| } | |
| drawResults(); | |
| } | |
| resolve(); | |
| }; | |
| img.src = URL.createObjectURL(props.uploadedImage); | |
| const objectUrl = URL.createObjectURL(props.uploadedImage); | |
| img.onload = () => { | |
| try { | |
| if (ctx.value) { | |
| ctx.value.clearRect(0, 0, canvasRef.value!.width, canvasRef.value!.height); | |
| ctx.value.drawImage(img, 0, 0); | |
| if (withLines) { | |
| drawLine(); | |
| } | |
| drawResults(); | |
| } | |
| } finally { | |
| URL.revokeObjectURL(objectUrl); | |
| resolve(); | |
| } | |
| }; | |
| img.onerror = () => { | |
| URL.revokeObjectURL(objectUrl); | |
| resolve(); | |
| }; | |
| img.src = objectUrl; |
|
|
||
| function downloadResults() { | ||
| const data = traces.value.getAllSuccessValues(); | ||
| downloadStringAsFile(lineComputeTracesToCSV(data), `line_compute_results_${new Date().toISOString()}.csv`, 'text/csv'); |
There was a problem hiding this comment.
The download filenames use new Date().toISOString(), which contains : and can produce invalid filenames on Windows. Use a filename-safe timestamp (e.g., replace : with - or format as YYYYMMDD_HHmmss).
| downloadStringAsFile(lineComputeTracesToCSV(data), `line_compute_results_${new Date().toISOString()}.csv`, 'text/csv'); | |
| const timestamp = new Date().toISOString().replace(/:/g, '-'); | |
| downloadStringAsFile(lineComputeTracesToCSV(data), `line_compute_results_${timestamp}.csv`, 'text/csv'); |
| function downloadResults() { | ||
| const data = traces.value.getAllSuccessValues(); | ||
| downloadStringAsFile(lineComputeTracesToCSV(data), `line_compute_results_${new Date().toISOString()}.csv`, 'text/csv'); | ||
| } | ||
|
|
||
| async function downloadResultsAsPDF() { | ||
| const data = traces.value.getAllSuccessValues(); | ||
|
|
||
| const dataUrl = (await lmtCanvas.value?.cleanImageDataURL()) || null; | ||
| void lineComputeTracesToPDF(dataUrl, data, computeLMTResult); | ||
| } |
There was a problem hiding this comment.
This code exports whatever getAllSuccessValues() returns, but the history UI explicitly handles value.result.success === false. If failed computations can be present in the exported array, lineComputeTracesToCSV/PDF will throw when accessing fields like path_coordinates. Consider filtering to trace.result.success === true before exporting or make the export functions resilient (include error rows with empty result fields).
|
|
||
| }); | ||
|
|
||
| return doc.save(`line_compute_results_${new Date().toISOString()}.pdf`); |
There was a problem hiding this comment.
The PDF filename uses new Date().toISOString(), which includes : and can yield invalid filenames on Windows downloads. Prefer a filename-safe timestamp.
| return doc.save(`line_compute_results_${new Date().toISOString()}.pdf`); | |
| const timestamp = new Date().toISOString().replace(/[:]/g, '-'); | |
| return doc.save(`line_compute_results_${timestamp}.pdf`); |
| const data = lines.map(line => ({ | ||
| // inputs | ||
| start_x: line.params.startX, | ||
| start_y: line.params.startY, | ||
| end_x: line.params.endX, | ||
| end_y: line.params.endY, | ||
| real_length: line.params.realLength, | ||
| real_height: line.params.realHeight, | ||
| interface_weight: line.params.interfaceWeight, | ||
| boundary_margin: line.params.boundaryMargin, | ||
|
|
||
| // results | ||
| lmt_type: line.result.lmt_type, | ||
| lmt_result: line.result.lmt_result, | ||
| total_length: line.result.total_length, | ||
| start_point_used_x: line.result.start_point_used[0], | ||
| start_point_used_y: line.result.start_point_used[1], | ||
| end_point_used_x: line.result.end_point_used[0], | ||
| end_point_used_y: line.result.end_point_used[1], | ||
| path: line.result.path_coordinates.pixel_coordinates.map(coord => `${coord[0]}_${coord[1]}`).join("|"), | ||
| })); | ||
|
|
||
| return Papa.unparse(data, { | ||
| header: true, | ||
| }); | ||
| } | ||
|
|
||
| export function lineComputeTracesToPDF(imageDataUrl: string | null, lines: LineComputeTrace[], extractResult: (line: LineComputeTrace) => number | null = line => line.result.lmt_result) { |
There was a problem hiding this comment.
lineComputeTracesToCSV() assumes all result fields exist (e.g., start_point_used, path_coordinates). However, the UI elsewhere accounts for result.success === false with an error-only payload. Either filter to successful results before building CSV/PDF rows or guard accesses and export error information when success is false.
| const data = lines.map(line => ({ | |
| // inputs | |
| start_x: line.params.startX, | |
| start_y: line.params.startY, | |
| end_x: line.params.endX, | |
| end_y: line.params.endY, | |
| real_length: line.params.realLength, | |
| real_height: line.params.realHeight, | |
| interface_weight: line.params.interfaceWeight, | |
| boundary_margin: line.params.boundaryMargin, | |
| // results | |
| lmt_type: line.result.lmt_type, | |
| lmt_result: line.result.lmt_result, | |
| total_length: line.result.total_length, | |
| start_point_used_x: line.result.start_point_used[0], | |
| start_point_used_y: line.result.start_point_used[1], | |
| end_point_used_x: line.result.end_point_used[0], | |
| end_point_used_y: line.result.end_point_used[1], | |
| path: line.result.path_coordinates.pixel_coordinates.map(coord => `${coord[0]}_${coord[1]}`).join("|"), | |
| })); | |
| return Papa.unparse(data, { | |
| header: true, | |
| }); | |
| } | |
| export function lineComputeTracesToPDF(imageDataUrl: string | null, lines: LineComputeTrace[], extractResult: (line: LineComputeTrace) => number | null = line => line.result.lmt_result) { | |
| const data = lines.map(line => { | |
| const base = { | |
| // inputs | |
| start_x: line.params.startX, | |
| start_y: line.params.startY, | |
| end_x: line.params.endX, | |
| end_y: line.params.endY, | |
| real_length: line.params.realLength, | |
| real_height: line.params.realHeight, | |
| interface_weight: line.params.interfaceWeight, | |
| boundary_margin: line.params.boundaryMargin, | |
| }; | |
| // If this line has an error-only result payload, avoid accessing | |
| // fields that may not exist and instead export serialized error info. | |
| const resultAny = line.result as any; | |
| if (resultAny && resultAny.success === false) { | |
| return { | |
| ...base, | |
| // results (empty because computation failed) | |
| lmt_type: "", | |
| lmt_result: "", | |
| total_length: "", | |
| start_point_used_x: "", | |
| start_point_used_y: "", | |
| end_point_used_x: "", | |
| end_point_used_y: "", | |
| path: "", | |
| error: JSON.stringify(resultAny), | |
| }; | |
| } | |
| const pathCoordinates = (line.result as any)?.path_coordinates; | |
| const pixelCoordinates: unknown = pathCoordinates?.pixel_coordinates; | |
| const path = | |
| Array.isArray(pixelCoordinates) | |
| ? (pixelCoordinates as [number, number][]) | |
| .map(coord => `${coord[0]}_${coord[1]}`) | |
| .join("|") | |
| : ""; | |
| return { | |
| ...base, | |
| // results | |
| lmt_type: line.result.lmt_type, | |
| lmt_result: line.result.lmt_result, | |
| total_length: line.result.total_length, | |
| start_point_used_x: line.result.start_point_used[0], | |
| start_point_used_y: line.result.start_point_used[1], | |
| end_point_used_x: line.result.end_point_used[0], | |
| end_point_used_y: line.result.end_point_used[1], | |
| path, | |
| }; | |
| }); | |
| return Papa.unparse(data, { | |
| header: true, | |
| }); | |
| } | |
| export function lineComputeTracesToPDF( | |
| imageDataUrl: string | null, | |
| lines: LineComputeTrace[], | |
| extractResult: (line: LineComputeTrace) => number | null = line => { | |
| const resultAny = line.result as any; | |
| if (resultAny && resultAny.success === false) { | |
| return null; | |
| } | |
| const value = (line.result as any).lmt_result; | |
| return typeof value === "number" ? value : null; | |
| }, | |
| ) { |
Describe your changes
Please explain the purpose and scope of your contribution.
Related GitHub issues and pull requests
Checklist before requesting a review
fix #xxx[,#xxx], where "xxx" is the issue number)