Refactor transport calculation into shared domain
This commit is contained in:
+73
-118
@@ -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>';
|
||||
|
||||
Reference in New Issue
Block a user