Refactor transport calculation into shared domain

This commit is contained in:
2026-09-24 13:11:18 +02:00
parent fa3000b325
commit defbf20202
10 changed files with 582 additions and 132 deletions
+73 -118
View File
@@ -769,11 +769,9 @@
<div class="lo-presets-section">
<h4>🚚 Preset Transport Type:</h4>
<div class="lo-preset-buttons">
<button class="lo-preset-btn" data-preset="container_20ft">20ft Container</button>
<button class="lo-preset-btn" data-preset="container_40ft">40ft Container</button>
<button class="lo-preset-btn" data-preset="container_40ft_hc">40ft High Cube</button>
<button class="lo-preset-btn" data-preset="lkw_sattelzug">LKW Sattelzug</button>
<button class="lo-preset-btn" data-preset="lkw_tandem">LKW Tandem</button>
{% for preset in transport_presets %}
<button class="lo-preset-btn" data-preset="{{ preset.key }}" data-length="{{ preset.length_m }}" data-width="{{ preset.width_m }}" data-height="{{ preset.height_m }}" data-max-weight="{{ preset.max_weight_kg }}">{{ preset.name }}</button>
{% endfor %}
<button class="lo-preset-btn" data-preset="custom">Custom</button>
</div>
</div>
@@ -1360,83 +1358,11 @@ document.getElementById('d-pdf').addEventListener('click', async () => {
}
});
// =====================================================
// LOAD OPTIMIZER CORE
// =====================================================
class LoadOptimizer {
constructor() {
this.state = {
rollDiameter: 0, coreDiameter: 150, rollWidth: 0,
rollWeight: 0, m2PerRoll: 0, productLength: 0
};
this.presets = {
container_20ft: { name:"20ft Container", length:5.90, width:2.35, height:2.39, maxWeight:21600 },
container_40ft: { name:"40ft Container", length:12.19, width:2.35, height:2.39, maxWeight:30480 },
container_40ft_hc:{ name:"40ft High Cube", length:12.19, width:2.35, height:2.69, maxWeight:30480 },
lkw_sattelzug: { name:"LKW Sattelzug", length:13.50, width:2.50, height:2.70, maxWeight:24000 },
lkw_tandem: { name:"LKW Tandem", length:13.50, width:2.50, height:2.70, maxWeight:25000 }
};
}
setRollData(data) {
this.state = {
rollDiameter: data.rollDiameter || 0,
coreDiameter: data.coreDiameter || 150,
rollWidth: data.rollWidth || 0,
rollWeight: data.rollWeight || 0,
m2PerRoll: data.m2PerRoll || 0,
productLength: data.productLength || 0
};
}
calculateLoadUtilization(dim, maxLoadWeight, marginSide, marginCeiling) {
marginSide = marginSide || 0;
marginCeiling = marginCeiling || 0;
const { rollDiameter, coreDiameter, rollWidth, rollWeight, m2PerRoll } = this.state;
const D = rollDiameter / 1000, d = coreDiameter / 1000;
const effWidth = dim.width - marginSide;
const effHeight = dim.height - marginCeiling;
const rollsAlongLength = Math.floor(dim.length / rollWidth);
const rollsAlongWidth = Math.floor(effWidth / D);
const rollsPerLayer = rollsAlongLength * rollsAlongWidth;
const maxStackHeight = Math.floor(effHeight / D);
const maxByGeometry = rollsPerLayer * maxStackHeight;
const maxByWeight = rollWeight > 0 ? Math.floor(maxLoadWeight / rollWeight) : Infinity;
const volPerRoll = (Math.PI / 4) * (D*D - d*d) * rollWidth;
const containerVol = dim.length * dim.width * dim.height;
const effVol = dim.length * effWidth * effHeight;
const maxByVolume = volPerRoll > 0 ? Math.floor(effVol / volPerRoll) : Infinity;
const scenarios = [
{ name:'Weight', rolls: maxByWeight, weight: maxByWeight * rollWeight, type:'weight' },
{ name:'Volume', rolls: maxByVolume, weight: maxByVolume * rollWeight, type:'volume' },
{ name:'Geometry', rolls: maxByGeometry, weight: maxByGeometry * rollWeight, type:'geometry' }
];
const limiting = scenarios.reduce((mn, c) => c.rolls < mn.rolls ? c : mn);
const finalRolls = limiting.rolls === Infinity ? 0 : limiting.rolls;
const finalWeight = finalRolls * rollWeight;
const finalM2 = finalRolls * m2PerRoll;
return {
scenarios, limiting, finalRolls, finalWeight, finalM2,
utilizationWeight: maxLoadWeight > 0 ? (finalWeight / maxLoadWeight) * 100 : 0,
utilizationVolume: containerVol > 0 ? (finalRolls * volPerRoll / containerVol) * 100 : 0,
rollsPerLayer, rollsAlongLength, rollsAlongWidth, maxStackHeight,
containerVol, volPerRoll, effWidth, effHeight,
marginSide, marginCeiling
};
}
fmt(n, dec=1) { return isFinite(n) ? n.toFixed(dec) : '–'; }
}
// =====================================================
// LOAD OPTIMIZER UI
// =====================================================
class LoadOptimizerUI {
constructor() {
this.optimizer = new LoadOptimizer();
this.currentPreset = null;
this.init();
}
@@ -1487,12 +1413,12 @@ class LoadOptimizerUI {
}
loadPreset(id) {
if (id === 'custom') { this.clearPresets(); return; }
const p = this.optimizer.presets[id];
if (!p) return;
document.getElementById('customLength').value = p.length;
document.getElementById('customWidth').value = p.width;
document.getElementById('customHeight').value = p.height;
document.getElementById('customMaxWeight').value = p.maxWeight;
const button = document.querySelector('[data-preset="'+id+'"]');
if (!button) return;
document.getElementById('customLength').value = button.dataset.length;
document.getElementById('customWidth').value = button.dataset.width;
document.getElementById('customHeight').value = button.dataset.height;
document.getElementById('customMaxWeight').value = button.dataset.maxWeight;
this.updatePresetBtns(id);
this.currentPreset = id;
this.updateEffectiveHints();
@@ -1509,7 +1435,7 @@ class LoadOptimizerUI {
return { sideM, ceilM };
}
calculate() {
async calculate() {
const L = parseFloat(document.getElementById('customLength').value);
const W = parseFloat(document.getElementById('customWidth').value);
const H = parseFloat(document.getElementById('customHeight').value);
@@ -1524,10 +1450,37 @@ class LoadOptimizerUI {
return;
}
const { sideM, ceilM } = this.getMargins();
this.optimizer.setRollData(rollData);
const res = this.optimizer.calculateLoadUtilization({ length:L, width:W, height:H }, MW, sideM, ceilM);
this.showResults(res, MW);
this.drawSideView(res, { length:L, width:W, height:H });
let response;
try {
response = await fetch('/api/calculations/transport', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transport_preset: this.currentPreset,
length_m: L,
width_m: W,
height_m: H,
max_weight_kg: MW,
margin_side_m: sideM,
margin_ceiling_m: ceilM,
roll_diameter_mm: rollData.rollDiameter,
core_diameter_mm: rollData.coreDiameter,
roll_width_m: rollData.rollWidth,
roll_weight_kg: rollData.rollWeight,
product_length_m: rollData.productLength
})
});
} catch (_error) {
alert('Transport calculation service failed');
return;
}
const payload = await response.json().catch(() => null);
if (!response.ok || !payload || payload.status !== 'success') {
alert(payload?.invalid?.[0]?.message || 'Transport calculation failed');
return;
}
this.showResults(payload.analysis);
this.drawSideView(payload.analysis);
}
getRollData() {
@@ -1537,30 +1490,29 @@ class LoadOptimizerUI {
const L = parseFloat(document.getElementById('d-le')?.value || 0);
const d = parseFloat(document.getElementById('d-co')?.value || 150);
if (!D || !W || !GW) return null;
return { rollDiameter:D, coreDiameter:d, rollWidth:W, rollWeight:GW, m2PerRoll:L*W, productLength:L };
return { rollDiameter:D, coreDiameter:d, rollWidth:W, rollWeight:GW, productLength:L };
}
showResults(res, maxW) {
const m2PerRoll = this.optimizer.state.m2PerRoll || 0;
document.getElementById('limitingFactorValue').textContent = res.limiting.name;
document.getElementById('totalRollsValue').textContent = res.finalRolls;
showResults(analysis) {
document.getElementById('limitingFactorValue').textContent = analysis.limiting.name;
document.getElementById('totalRollsValue').textContent = analysis.final_rolls;
document.getElementById('rollsDetailValue').textContent =
'(' + res.rollsAlongLength + ' along length × ' + res.rollsAlongWidth + ' across, ' + res.maxStackHeight + ' layers)';
document.getElementById('weightUtilValue').textContent = this.optimizer.fmt(res.utilizationWeight);
document.getElementById('volumeUtilValue').textContent = this.optimizer.fmt(res.utilizationVolume);
document.getElementById('productM2Value').textContent = this.optimizer.fmt(res.finalM2, 0);
'(' + analysis.layout.rolls_along_length + ' along length × ' + analysis.layout.rolls_along_width + ' across, ' + analysis.layout.max_stack_height + ' layers)';
document.getElementById('weightUtilValue').textContent = this.fmt(analysis.utilization_weight);
document.getElementById('volumeUtilValue').textContent = this.fmt(analysis.utilization_volume);
document.getElementById('productM2Value').textContent = this.fmt(analysis.final_m2, 0);
const tbody = document.querySelector('#comparisonTable tbody');
tbody.innerHTML = '';
res.scenarios.forEach(sc => {
analysis.scenarios.forEach(sc => {
const tr = document.createElement('tr');
const lim = (sc.type === res.limiting.type);
const lim = (sc.type === analysis.limiting.type);
if (lim) tr.classList.add('lo-recommended');
const util = maxW > 0 ? (sc.weight / maxW * 100).toFixed(1) : '–';
const scM2 = isFinite(sc.rolls) && m2PerRoll > 0 ? Math.round(sc.rolls * m2PerRoll) : '–';
const util = this.fmt(sc.utilization_weight);
const scM2 = sc.m2 === null ? '–' : Math.round(sc.m2);
tr.innerHTML =
'<td>'+ sc.name +(lim?' ★':'')+'</td>'+
'<td>'+ (sc.rolls === Infinity ? '–' : sc.rolls) +'</td>'+
'<td>'+ (isFinite(sc.weight) ? Math.round(sc.weight) : '–') +'</td>'+
'<td>'+ (sc.rolls === null ? '–' : sc.rolls) +'</td>'+
'<td>'+ (sc.weight === null ? '–' : Math.round(sc.weight)) +'</td>'+
'<td>'+ scM2 +'</td>'+
'<td>'+ util +'%</td>';
tbody.appendChild(tr);
@@ -1568,26 +1520,29 @@ class LoadOptimizerUI {
document.getElementById('resultsContainer').classList.add('show');
}
drawSideView(res, dim) {
fmt(n, dec=1) { return Number.isFinite(n) ? n.toFixed(dec) : '–'; }
drawSideView(analysis) {
const svg = document.getElementById('sideViewSVG');
const section = document.getElementById('sideViewSection');
if (!svg || !res || res.finalRolls === 0) return;
if (!svg || !analysis || analysis.final_rolls === 0) return;
const D_m = this.optimizer.state.rollDiameter / 1000;
const aW = res.rollsAlongWidth;
const aL = res.rollsAlongLength;
const layers = res.maxStackHeight;
const total = res.finalRolls;
const marginSide = res.marginSide || 0;
const marginCeil = res.marginCeiling || 0;
const D_m = analysis.roll.diameter_mm / 1000;
const aW = analysis.layout.rolls_along_width;
const aL = analysis.layout.rolls_along_length;
const layers = analysis.layout.max_stack_height;
const total = analysis.final_rolls;
const marginSide = analysis.layout.margin_side_m || 0;
const marginCeil = analysis.layout.margin_ceiling_m || 0;
const dim = analysis.transport;
const PAD = 52, W_SVG = 800, H_SVG = 300;
const scaleX = (W_SVG - 2*PAD) / dim.width;
const scaleY = (H_SVG - 2*PAD) / dim.height;
const scaleX = (W_SVG - 2*PAD) / dim.width_m;
const scaleY = (H_SVG - 2*PAD) / dim.height_m;
const scale = Math.min(scaleX, scaleY);
const cW = dim.width * scale;
const cH = dim.height * scale;
const cW = dim.width_m * scale;
const cH = dim.height_m * scale;
const cX = PAD + ((W_SVG - 2*PAD) - cW) / 2;
const cY = PAD + ((H_SVG - 2*PAD) - cH) / 2;
const rD = D_m * scale;
@@ -1638,12 +1593,12 @@ class LoadOptimizerUI {
const dimY = cY + cH + 20;
s += '<line x1="'+cX.toFixed(1)+'" y1="'+dimY.toFixed(1)+'" x2="'+(cX+cW).toFixed(1)+'" y2="'+dimY.toFixed(1)+'" stroke="#666" stroke-width="1" marker-start="url(#arr2)" marker-end="url(#arr)"/>';
s += '<text x="'+(cX+cW/2).toFixed(1)+'" y="'+(dimY+13).toFixed(1)+'" text-anchor="middle" font-size="11" fill="#555">'+dim.width+' m</text>';
s += '<text x="'+(cX+cW/2).toFixed(1)+'" y="'+(dimY+13).toFixed(1)+'" text-anchor="middle" font-size="11" fill="#555">'+dim.width_m+' m</text>';
const dimX = cX - 22;
const midY = (cY + cH/2).toFixed(1);
s += '<line x1="'+dimX.toFixed(1)+'" y1="'+cY.toFixed(1)+'" x2="'+dimX.toFixed(1)+'" y2="'+(cY+cH).toFixed(1)+'" stroke="#666" stroke-width="1" marker-start="url(#arr2)" marker-end="url(#arr)"/>';
s += '<text x="'+(dimX-5).toFixed(1)+'" y="'+midY+'" text-anchor="middle" font-size="11" fill="#555" transform="rotate(-90,'+(dimX-5).toFixed(1)+','+midY+')">'+dim.height+' m</text>';
s += '<text x="'+(dimX-5).toFixed(1)+'" y="'+midY+'" text-anchor="middle" font-size="11" fill="#555" transform="rotate(-90,'+(dimX-5).toFixed(1)+','+midY+')">'+dim.height_m+' m</text>';
s += '<text x="'+(cX+cW/2).toFixed(1)+'" y="'+(cY-8).toFixed(1)+'" text-anchor="middle" font-size="11" fill="#666" font-style="italic">'+aL+' rows deep (into page)</text>';
s += '<text x="'+(cX+cW-4).toFixed(1)+'" y="'+(cY+15).toFixed(1)+'" text-anchor="end" font-size="10" fill="#555" font-style="italic">'+aW+' wide × '+layers+' high × '+aL+' deep · '+total+' rolls</text>';