216 lines
7.8 KiB
TypeScript
216 lines
7.8 KiB
TypeScript
import * as React from 'react';
|
|
import { Text, View } from '@react-pdf/renderer';
|
|
|
|
import type { DatasheetVoltageTable } from '../../model/types';
|
|
import { styles } from '../styles';
|
|
|
|
function clamp(n: number, min: number, max: number): number {
|
|
return Math.max(min, Math.min(max, n));
|
|
}
|
|
|
|
function normTextForMeasure(v: unknown): string {
|
|
return String(v ?? '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function textLen(v: unknown): number {
|
|
return normTextForMeasure(v).length;
|
|
}
|
|
|
|
function distributeWithMinMax(weights: number[], total: number, minEach: number, maxEach: number): number[] {
|
|
const n = weights.length;
|
|
if (!n) return [];
|
|
|
|
const mins = Array.from({ length: n }, () => minEach);
|
|
const maxs = Array.from({ length: n }, () => maxEach);
|
|
|
|
// If mins don't fit, scale them down proportionally.
|
|
const minSum = mins.reduce((a, b) => a + b, 0);
|
|
if (minSum > total) {
|
|
const k = total / minSum;
|
|
return mins.map(m => m * k);
|
|
}
|
|
|
|
const result = mins.slice();
|
|
let remaining = total - minSum;
|
|
let remainingIdx = Array.from({ length: n }, (_, i) => i);
|
|
|
|
// Distribute remaining proportionally, respecting max constraints.
|
|
// Loop is guaranteed to terminate because each iteration either:
|
|
// - removes at least one index due to hitting max, or
|
|
// - exhausts `remaining`.
|
|
while (remaining > 1e-9 && remainingIdx.length) {
|
|
const wSum = remainingIdx.reduce((acc, i) => acc + Math.max(0, weights[i] || 0), 0);
|
|
if (wSum <= 1e-9) {
|
|
// No meaningful weights: distribute evenly.
|
|
const even = remaining / remainingIdx.length;
|
|
for (const i of remainingIdx) result[i] += even;
|
|
remaining = 0;
|
|
break;
|
|
}
|
|
|
|
const nextIdx: number[] = [];
|
|
for (const i of remainingIdx) {
|
|
const w = Math.max(0, weights[i] || 0);
|
|
const add = (w / wSum) * remaining;
|
|
const capped = Math.min(result[i] + add, maxs[i]);
|
|
const used = capped - result[i];
|
|
result[i] = capped;
|
|
remaining -= used;
|
|
if (result[i] + 1e-9 < maxs[i]) nextIdx.push(i);
|
|
}
|
|
remainingIdx = nextIdx;
|
|
}
|
|
|
|
// Numerical guard: force exact sum by adjusting the last column.
|
|
const sum = result.reduce((a, b) => a + b, 0);
|
|
const drift = total - sum;
|
|
if (Math.abs(drift) > 1e-9) result[result.length - 1] += drift;
|
|
|
|
return result;
|
|
}
|
|
|
|
export function DenseTable(props: {
|
|
table: Pick<DatasheetVoltageTable, 'columns' | 'rows'>;
|
|
firstColLabel: string;
|
|
}): React.ReactElement {
|
|
const cols = props.table.columns;
|
|
const rows = props.table.rows;
|
|
|
|
const headerText = (label: string): string => {
|
|
// Table headers must NEVER wrap into a second line.
|
|
// react-pdf can wrap on spaces, so we replace whitespace with NBSP.
|
|
return String(label || '').replace(/\s+/g, '\u00A0').trim();
|
|
};
|
|
|
|
// Column widths: use explicit percentages (no rounding gaps) so the table always
|
|
// consumes the full content width.
|
|
// Goal:
|
|
// - keep the designation column *not too wide*
|
|
// - distribute data columns by estimated content width (header + cells)
|
|
// so columns better fit their data
|
|
// Make first column denser so numeric columns get more room.
|
|
// (Long designations can still wrap in body if needed, but table scanability
|
|
// benefits more from wider data columns.)
|
|
const cfgMin = 0.14;
|
|
const cfgMax = 0.23;
|
|
|
|
// A content-based heuristic.
|
|
// React-PDF doesn't expose a reliable text-measurement API at render time,
|
|
// so we approximate width by string length (compressed via sqrt to reduce outliers).
|
|
const cfgContentLen = Math.max(textLen(props.firstColLabel), ...rows.map(r => textLen(r.configuration)), 8);
|
|
const dataContentLens = cols.map((c, ci) => {
|
|
const headerL = textLen(c.label);
|
|
let cellMax = 0;
|
|
for (const r of rows) cellMax = Math.max(cellMax, textLen(r.cells[ci]));
|
|
// Slightly prioritize the header (scanability) over a single long cell.
|
|
return Math.max(headerL * 1.15, cellMax, 3);
|
|
});
|
|
|
|
// Use mostly-linear weights so long headers get noticeably more space.
|
|
const cfgWeight = cfgContentLen * 1.05;
|
|
const dataWeights = dataContentLens.map(l => l);
|
|
const dataWeightSum = dataWeights.reduce((a, b) => a + b, 0);
|
|
const rawCfgPct = dataWeightSum > 0 ? cfgWeight / (cfgWeight + dataWeightSum) : 0.28;
|
|
let cfgPct = clamp(rawCfgPct, cfgMin, cfgMax);
|
|
|
|
// Ensure a minimum per-data-column width; if needed, shrink cfgPct.
|
|
// These floors are intentionally generous. Too-narrow columns are worse than a
|
|
// slightly narrower first column for scanability.
|
|
const minDataPct = cols.length >= 14 ? 0.045 : cols.length >= 12 ? 0.05 : cols.length >= 10 ? 0.055 : 0.06;
|
|
const cfgPctMaxForMinData = 1 - cols.length * minDataPct;
|
|
if (Number.isFinite(cfgPctMaxForMinData)) cfgPct = Math.min(cfgPct, cfgPctMaxForMinData);
|
|
cfgPct = clamp(cfgPct, cfgMin, cfgMax);
|
|
|
|
const dataTotal = Math.max(0, 1 - cfgPct);
|
|
const maxDataPct = Math.min(0.24, Math.max(minDataPct * 2.8, dataTotal * 0.55));
|
|
const dataPcts = distributeWithMinMax(dataWeights, dataTotal, minDataPct, maxDataPct);
|
|
|
|
const cfgW = `${(cfgPct * 100).toFixed(4)}%`;
|
|
const dataWs = dataPcts.map((p, idx) => {
|
|
// Keep the last column as the remainder so percentages sum to exactly 100%.
|
|
if (idx === dataPcts.length - 1) {
|
|
const used = dataPcts.slice(0, -1).reduce((a, b) => a + b, 0);
|
|
const remainder = Math.max(0, dataTotal - used);
|
|
return `${(remainder * 100).toFixed(4)}%`;
|
|
}
|
|
return `${(p * 100).toFixed(4)}%`;
|
|
});
|
|
|
|
const headerFontSize = cols.length >= 14 ? 5.7 : cols.length >= 12 ? 5.9 : cols.length >= 10 ? 6.2 : 6.6;
|
|
|
|
return (
|
|
<View style={styles.tableWrap} break={false} minPresenceAhead={24}>
|
|
<View style={styles.tableHeader} wrap={false}>
|
|
<View style={{ width: cfgW }}>
|
|
<Text
|
|
style={[
|
|
styles.tableHeaderCell,
|
|
styles.tableHeaderCellCfg,
|
|
{ fontSize: headerFontSize, paddingHorizontal: 3 },
|
|
cols.length ? styles.tableHeaderCellDivider : null,
|
|
]}
|
|
wrap={false}
|
|
>
|
|
{headerText(props.firstColLabel)}
|
|
</Text>
|
|
</View>
|
|
{cols.map((c, idx) => {
|
|
const isLast = idx === cols.length - 1;
|
|
return (
|
|
<View key={c.key} style={{ width: dataWs[idx] }}>
|
|
<Text
|
|
style={[
|
|
styles.tableHeaderCell,
|
|
{ fontSize: headerFontSize, paddingHorizontal: 3 },
|
|
!isLast ? styles.tableHeaderCellDivider : null,
|
|
]}
|
|
wrap={false}
|
|
>
|
|
{headerText(c.label)}
|
|
</Text>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{rows.map((r, ri) => (
|
|
<View
|
|
key={`${r.configuration}-${ri}`}
|
|
style={[styles.tableRow, ri % 2 === 0 ? styles.tableRowAlt : null]}
|
|
wrap={false}
|
|
// If the row doesn't fit, move the whole row to the next page.
|
|
// This prevents page breaks mid-row.
|
|
minPresenceAhead={16}
|
|
>
|
|
<View style={{ width: cfgW }} wrap={false}>
|
|
<Text
|
|
style={[
|
|
styles.tableCell,
|
|
styles.tableCellCfg,
|
|
// Denser first column: slightly smaller type + tighter padding.
|
|
{ fontSize: 6.2, paddingHorizontal: 3 },
|
|
cols.length ? styles.tableCellDivider : null,
|
|
]}
|
|
wrap={false}
|
|
>
|
|
{r.configuration}
|
|
</Text>
|
|
</View>
|
|
{r.cells.map((cell, ci) => {
|
|
const isLast = ci === r.cells.length - 1;
|
|
return (
|
|
<View key={`${cols[ci]?.key || ci}`} style={{ width: dataWs[ci] }} wrap={false}>
|
|
<Text style={[styles.tableCell, !isLast ? styles.tableCellDivider : null]} wrap={false}>
|
|
{cell}
|
|
</Text>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|