Skip to content

2026 04 07 Docx Comparison Rendering Fixes Design

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.

The comparison DOCX export (exportComparisonReportbuildComparisonReport) uses landscape orientation and captures DOM elements via html-to-image. Three sections render poorly:

  1. Tornado/Sensitivity chart — all scenario cards captured as a single image from a 3-column CSS grid, making each chart ~270px wide and illegible.
  2. Model Totals table — 22 columns (Scenario + 20 years + Cumulative) cause extreme text wrapping even in landscape. The Scenario column wraps to 10+ lines.
  3. Model Totals chart — the capture container has overflow-auto, so toPng captures 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”

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.

Capture each tornado card as a separate image and insert them sequentially in the DOCX.

ComparisonTornadoCard.tsx

  • Add a cardRef (useRef<HTMLDivElement>) to the root element.
  • Expose getCaptureElement(): HTMLElement | null via useImperativeHandle with 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 | null to getCaptureElements(): HTMLElement[] — filters out nulls and returns all card root elements.

reporting/types.ts

  • ComparisonChartGetters.tornadoGridElementtornadoCardElements: () => HTMLElement[]
  • ComparisonChartImages.tornadotornado: ComparisonChartImage[] (array, not single)
  • ComparisonReportInput.chartImages.tornado type updates accordingly.

ComparisonPage.tsx

  • Update getChartGetters() to return tornadoCardElements: () => 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)
  • When not selected: []

buildComparisonReport.ts

  • In the Sensitivity Analysis section, loop through chartImages.tornado array.
  • Insert each image at LANDSCAPE_CHART_WIDTH × LANDSCAPE_CHART_HEIGHT — one per row, fully legible.

Each tornado card renders as a full-width landscape image in the DOCX, stacked vertically.


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.

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.

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.

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”

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.

Pass width: element.scrollWidth and height: element.scrollHeight to toPng so it captures the full content including the horizontally-scrolled area.

exportComparisonReport.ts — replace the model totals capture:

// Before
const modelTotalsDataUrl = selectedSections.has("comp-model-chart")
? await captureElementDataUrl(getters.modelTotalsElement())
: null;
// After
const 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.

The full table content is captured regardless of scroll position.


ActionFile
Modifysrc/features/reports/forecasting/sections/Comparison/ComparisonTornadoCard.tsx
Modifysrc/features/reports/forecasting/sections/Comparison/ComparisonTornadoGrid.tsx
Modifysrc/features/reports/forecasting/sections/Comparison/reporting/types.ts
Modifysrc/features/reports/forecasting/sections/Comparison/ComparisonPage.tsx
Modifysrc/features/reports/forecasting/sections/Comparison/reporting/exportComparisonReport.ts
Modifysrc/features/reports/forecasting/sections/Comparison/reporting/buildComparisonReport.ts
Modifysrc/features/reports/forecasting/sections/Comparison/export-utils.ts
Modifysrc/lib/export-csv.ts
Terminal window
pnpm tsc --noEmit # 0 errors
pnpm biome check src/ # 0 issues
pnpm 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