2026 04 07 Docx Comparison Rendering Fixes Design
DOCX Comparison Report Rendering Fixes
Section titled “DOCX Comparison Report Rendering Fixes”Fix three rendering issues in the comparison DOCX export: tornado charts squeezed 3-in-a-row, Model Totals table with 22+ columns wrapping, and Model Totals chart image clipped by
overflow-auto.
Context
Section titled “Context”The comparison DOCX export (exportComparisonReport → buildComparisonReport) uses landscape orientation and captures DOM elements via html-to-image. Three sections render poorly:
- Tornado/Sensitivity chart — all scenario cards captured as a single image from a 3-column CSS grid, making each chart ~270px wide and illegible.
- Model Totals table — 22 columns (
Scenario+ 20 years +Cumulative) cause extreme text wrapping even in landscape. TheScenariocolumn wraps to 10+ lines. - Model Totals chart — the capture container has
overflow-auto, sotoPngcaptures the visible viewport only, clipping content scrolled off the right edge.
Fix 1: Tornado Charts — Individual Capture
Section titled “Fix 1: Tornado Charts — Individual Capture”Problem
Section titled “Problem”ComparisonTornadoGrid.getCaptureElement() returns one <div> wrapping the entire grid (grid-cols-1 md:grid-cols-2 lg:grid-cols-3). This single element is captured as one PNG and inserted at 820×400 in the DOCX. With 3 scenarios, each tornado chart is ~270px wide — unreadable.
Solution
Section titled “Solution”Capture each tornado card as a separate image and insert them sequentially in the DOCX.
Architecture Changes
Section titled “Architecture Changes”ComparisonTornadoCard.tsx
- Add a
cardRef(useRef<HTMLDivElement>) to the root element. - Expose
getCaptureElement(): HTMLElement | nullviauseImperativeHandlewith React 19 ref-as-prop pattern ({ ref }: { ref?: Ref<ComparisonTornadoCardHandle> }).
ComparisonTornadoGrid.tsx
- Create refs for each card using
useRef<(ComparisonTornadoCardHandle | null)[]>(mutable ref array pattern). - Change the imperative handle from
getCaptureElement(): HTMLElement | nulltogetCaptureElements(): HTMLElement[]— filters out nulls and returns all card root elements.
reporting/types.ts
ComparisonChartGetters.tornadoGridElement→tornadoCardElements: () => HTMLElement[]ComparisonChartImages.tornado→tornado: ComparisonChartImage[](array, not single)ComparisonReportInput.chartImages.tornadotype updates accordingly.
ComparisonPage.tsx
- Update
getChartGetters()to returntornadoCardElements: () => tornadoRef.current?.getCaptureElements?.() ?? [].
exportComparisonReport.ts
- When
selectedSections.has("comp-tornado-chart"):- Call
getters.tornadoCardElements()→HTMLElement[] - Loop:
captureElementDataUrl(el)for each element - Produce
ChartImage[](one per card)
- Call
- When not selected:
[]
buildComparisonReport.ts
- In the Sensitivity Analysis section, loop through
chartImages.tornadoarray. - Insert each image at
LANDSCAPE_CHART_WIDTH × LANDSCAPE_CHART_HEIGHT— one per row, fully legible.
Result
Section titled “Result”Each tornado card renders as a full-width landscape image in the DOCX, stacked vertically.
Fix 2: Model Totals Table — Transpose
Section titled “Fix 2: Model Totals Table — Transpose”Problem
Section titled “Problem”buildComparisonModelTotalsRows produces rows keyed by scenario with one column per year: Scenario | 2026 | 2027 | ... | 2045 | Cumulative = 22 columns. Even in landscape, each column gets ~4% of page width, causing extreme text wrapping.
Solution
Section titled “Solution”Transpose the table: years as rows, scenarios as columns. With 3 scenarios: Year | Scenario A | Scenario B | Scenario C = 4 columns, 21 rows (20 years + cumulative). This mirrors how buildComparisonSalesRows already works.
Changes
Section titled “Changes”export-utils.ts — rewrite buildComparisonModelTotalsRows:
export function buildComparisonModelTotalsRows(data: ComparisonScenarioData[]): CSVRow[] { const labels = computeYearLabelUnion(data.map((d) => d.yearLabels)); const cumulatives = data.map(() => 0);
const rows: CSVRow[] = labels.map((year) => { const row: CSVRow = { Year: year }; for (let i = 0; i < data.length; i++) { const scenario = data[i]; const labelToIndex = new Map(scenario.yearLabels.map((l, idx) => [l, idx])); const idx = labelToIndex.get(year); const val = idx != null ? scenario.totalSalesByYear[idx] : 0; row[scenario.meta.label] = Number(val.toFixed(2)); cumulatives[i] += val; } return row; });
// Add cumulative row const cumulativeRow: CSVRow = { Year: "Cumulative" }; for (let i = 0; i < data.length; i++) { cumulativeRow[data[i].meta.label] = Number(cumulatives[i].toFixed(2)); } rows.push(cumulativeRow);
return rows;}No other files change — csvRowsToTable(buildComparisonModelTotalsRows(data)) in buildComparisonReport.ts consumes it identically.
Result
Section titled “Result”Table has 1 + N columns (typically 4) and ~21 rows. Fits comfortably in landscape.
Fix 3: Model Totals Chart — Fix overflow-auto Clipping
Section titled “Fix 3: Model Totals Chart — Fix overflow-auto Clipping”Problem
Section titled “Problem”ComparisonModelTotals.tsx wraps its table in <div ref={containerRef} className="border rounded-lg overflow-auto">. When toPng(element, { pixelRatio: 2 }) captures this element, it captures the CSS-rendered viewport, not the full scrolled content. Content beyond the visible scroll position is clipped.
Solution
Section titled “Solution”Pass width: element.scrollWidth and height: element.scrollHeight to toPng so it captures the full content including the horizontally-scrolled area.
Changes
Section titled “Changes”exportComparisonReport.ts — replace the model totals capture:
// Beforeconst modelTotalsDataUrl = selectedSections.has("comp-model-chart") ? await captureElementDataUrl(getters.modelTotalsElement()) : null;
// Afterconst modelTotalsElement = selectedSections.has("comp-model-chart") ? getters.modelTotalsElement() : null;const modelTotalsDataUrl = modelTotalsElement ? await captureElementFullScroll(modelTotalsElement) : null;src/lib/export-csv.ts — add a new captureElementFullScroll function:
export async function captureElementFullScroll(element: HTMLElement): Promise<string | null> { try { const { toPng } = await import("html-to-image"); return await toPng(element, { pixelRatio: 2, width: element.scrollWidth, height: element.scrollHeight, }); } catch { return null; }}The DOCX insertion via chartImageOrPlaceholder already constrains the image to LANDSCAPE_CHART_WIDTH × LANDSCAPE_CHART_HEIGHT, so Word will scale the wider capture proportionally.
Result
Section titled “Result”The full table content is captured regardless of scroll position.
Files Modified
Section titled “Files Modified”| Action | File |
|---|---|
| Modify | src/features/reports/forecasting/sections/Comparison/ComparisonTornadoCard.tsx |
| Modify | src/features/reports/forecasting/sections/Comparison/ComparisonTornadoGrid.tsx |
| Modify | src/features/reports/forecasting/sections/Comparison/reporting/types.ts |
| Modify | src/features/reports/forecasting/sections/Comparison/ComparisonPage.tsx |
| Modify | src/features/reports/forecasting/sections/Comparison/reporting/exportComparisonReport.ts |
| Modify | src/features/reports/forecasting/sections/Comparison/reporting/buildComparisonReport.ts |
| Modify | src/features/reports/forecasting/sections/Comparison/export-utils.ts |
| Modify | src/lib/export-csv.ts |
Verification
Section titled “Verification”pnpm tsc --noEmit # 0 errorspnpm biome check src/ # 0 issuespnpm build # exit 0 (with clean working tree)Manual: export comparison DOCX, verify:
- Tornado charts render 1-per-row, full width, legible
- Model Totals table has Year rows × Scenario columns, no wrapping
- Model Totals chart image shows full table without right-side clipping