datasheets

This commit is contained in:
2026-01-15 11:03:46 +01:00
parent f29ceacb51
commit e26c73e3b6
57 changed files with 163 additions and 63 deletions

View File

@@ -16,13 +16,6 @@ type Assets = {
qrDataUrl: string | null;
};
function chunk<T>(arr: T[], size: number): T[][] {
if (size <= 0) return [arr];
const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
export function DatasheetDocument(props: { model: DatasheetModel; assets: Assets }): React.ReactElement {
const { model, assets } = props;
const headerTitle = model.labels.datasheet;
@@ -30,13 +23,6 @@ export function DatasheetDocument(props: { model: DatasheetModel; assets: Assets
// Dense tables require compact headers (no wrapping). Use standard abbreviations.
const firstColLabel = model.locale === 'de' ? 'Adern & QS' : 'Cores & CS';
const tablePages: Array<{ table: DatasheetVoltageTable; rows: DatasheetVoltageTable['rows'] }> =
model.voltageTables.flatMap(t => {
const perPage = 30;
const chunks = chunk(t.rows, perPage);
return chunks.map(rows => ({ table: t, rows }));
});
return (
<Document>
<Page size="A4" style={styles.page}>
@@ -67,18 +53,28 @@ export function DatasheetDocument(props: { model: DatasheetModel; assets: Assets
) : null}
</Page>
{tablePages.map((p, index) => (
<Page key={`${p.table.voltageLabel}-${index}`} size="A4" style={styles.page}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
<Footer locale={model.locale} siteUrl={CONFIG.siteUrl} />
{/*
Render all voltage sections in a single flow so React-PDF can paginate naturally.
This avoids hard page breaks that waste remaining whitespace at the bottom of a page.
*/}
<Page size="A4" style={styles.page}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
<Footer locale={model.locale} siteUrl={CONFIG.siteUrl} />
<Section title={`${model.labels.crossSection}${p.table.voltageLabel}`}>
{p.table.metaItems.length ? <KeyValueGrid items={p.table.metaItems} /> : null}
</Section>
{model.voltageTables.map((t: DatasheetVoltageTable) => (
<View key={t.voltageLabel}>
<Section
title={`${model.labels.crossSection}${t.voltageLabel}`}
// Prevent orphaned voltage headings at page bottom; let the rest flow.
minPresenceAhead={140}
>
{t.metaItems.length ? <KeyValueGrid items={t.metaItems} /> : null}
</Section>
<DenseTable table={{ columns: p.table.columns, rows: p.rows }} firstColLabel={firstColLabel} />
</Page>
))}
</Document>
);
<DenseTable table={{ columns: t.columns, rows: t.rows }} firstColLabel={firstColLabel} />
</View>
))}
</Page>
</Document>
);
}

View File

@@ -8,6 +8,69 @@ 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;
@@ -15,50 +78,68 @@ export function DenseTable(props: {
const cols = props.table.columns;
const rows = props.table.rows;
const noWrapHeader = (label: string): string => {
const raw = String(label || '').trim();
if (!raw) return '';
// Ensure the header never wraps into a second line.
// - Remove whitespace break opportunities (NBSP)
// NOTE: Avoid inserting zero-width joiners between letters.
// Some PDF viewers render them with spacing/odd glyph behavior.
// This is intentionally aggressive because broken headers destroy scanability.
return raw.replace(/\s+/g, '\u00A0');
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*
// - guarantee enough width for data headers when there is available space
const cfgMin = 0.18;
const cfgMax = 0.30;
// - 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;
let cfgPct = cols.length >= 14 ? 0.22 : cols.length >= 12 ? 0.24 : cols.length >= 10 ? 0.26 : 0.30;
// 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 minDataPct = cols.length >= 14 ? 0.045 : cols.length >= 12 ? 0.05 : cols.length >= 10 ? 0.055 : 0.06;
// If the initial cfgPct leaves too little width per data column, reduce cfgPct.
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 dataTotal = 1 - cfgPct;
const each = cols.length ? dataTotal / cols.length : dataTotal;
const dataWs = cols.map((_, idx) => {
const dataWs = dataPcts.map((p, idx) => {
// Keep the last column as the remainder so percentages sum to exactly 100%.
if (idx === cols.length - 1) {
const used = each * Math.max(0, cols.length - 1);
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 `${(each * 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}>
<View style={styles.tableHeader} wrap={false}>
@@ -67,11 +148,12 @@ export function DenseTable(props: {
style={[
styles.tableHeaderCell,
styles.tableHeaderCellCfg,
{ fontSize: headerFontSize, paddingHorizontal: 3 },
cols.length ? styles.tableHeaderCellDivider : null,
]}
wrap={false}
>
{noWrapHeader(props.firstColLabel)}
{headerText(props.firstColLabel)}
</Text>
</View>
{cols.map((c, idx) => {
@@ -79,10 +161,14 @@ export function DenseTable(props: {
return (
<View key={c.key} style={{ width: dataWs[idx] }}>
<Text
style={[styles.tableHeaderCell, !isLast ? styles.tableHeaderCellDivider : null]}
style={[
styles.tableHeaderCell,
{ fontSize: headerFontSize, paddingHorizontal: 3 },
!isLast ? styles.tableHeaderCellDivider : null,
]}
wrap={false}
>
{noWrapHeader(c.label)}
{headerText(c.label)}
</Text>
</View>
);
@@ -94,15 +180,31 @@ export function DenseTable(props: {
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 }}>
<Text style={[styles.tableCell, styles.tableCellCfg, cols.length ? styles.tableCellDivider : null]}>{r.configuration}</Text>
<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] }}>
<Text style={[styles.tableCell, !isLast ? styles.tableCellDivider : null]}>{cell}</Text>
<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 File

@@ -24,7 +24,7 @@ export function KeyValueGrid(props: { items: KeyValueItem[] }): React.ReactEleme
return (
<View
key={`${left.label}-${rowIndex}`}
style={[styles.kvRow, isLast ? styles.kvRowLast : null]}
style={[styles.kvRow, rowIndex % 2 === 0 ? styles.kvRowAlt : null, isLast ? styles.kvRowLast : null]}
wrap={false}
>
<View style={[styles.kvCell, { width: '23%' }]}>

View File

@@ -7,10 +7,11 @@ export function Section(props: {
title: string;
children: React.ReactNode;
boxed?: boolean;
minPresenceAhead?: number;
}): React.ReactElement {
const boxed = props.boxed ?? true;
return (
<View style={boxed ? styles.section : styles.sectionPlain}>
<View style={boxed ? styles.section : styles.sectionPlain} minPresenceAhead={props.minPresenceAhead}>
<Text style={styles.sectionTitle}>{props.title}</Text>
{props.children}
</View>

View File

@@ -103,6 +103,7 @@ export const styles = StyleSheet.create({
borderBottomWidth: 1,
borderBottomColor: COLORS.lightGray,
},
kvRowAlt: { backgroundColor: COLORS.almostWhite },
kvRowLast: { borderBottomWidth: 0 },
kvCell: { paddingVertical: 6, paddingHorizontal: 8 },
// Visual separator between (label,value) pairs in the 4-col KV grid.
@@ -114,7 +115,7 @@ export const styles = StyleSheet.create({
kvLabelText: { fontSize: 8.5, fontWeight: 700, color: COLORS.mediumGray },
kvValueText: { fontSize: 9.5, color: COLORS.darkGray },
tableWrap: { width: '100%', borderWidth: 1, borderColor: COLORS.lightGray },
tableWrap: { width: '100%', borderWidth: 1, borderColor: COLORS.lightGray, marginBottom: 14 },
tableHeader: {
width: '100%',
flexDirection: 'row',